feat(watchdog): 主机侧巡检定时器按异常邮件通知平台所有者,operator 增加 phase 与 build_info 指标、卡死存活探针与告警规则

This commit is contained in:
Lemon-miaow committed 2026-09-24 18:06:19 +08:00
1 parent d50492b86f
commit d17524cd67
26 files changed
+2684 -32

No files matched your search

+3
View File
@@ -19,6 +19,7 @@ import (
"felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/imagepin" "felis.lolicon.best/internal/imagepin"
"felis.lolicon.best/internal/mail" "felis.lolicon.best/internal/mail"
"felis.lolicon.best/internal/metrics"
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/panel" "felis.lolicon.best/internal/panel"
"felis.lolicon.best/internal/passkey" "felis.lolicon.best/internal/passkey"
@@ -110,6 +111,8 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
return 1 return 1
} }
metrics.SetBuildInfo("api", resolvedVersion())
token := os.Getenv("FELIS_SERVICE_TOKEN") token := os.Getenv("FELIS_SERVICE_TOKEN")
if token == "" { if token == "" {
fmt.Fprintln(stderr, "felis api: warning: FELIS_SERVICE_TOKEN unset — internal face will reject all callers") fmt.Fprintln(stderr, "felis api: warning: FELIS_SERVICE_TOKEN unset — internal face will reject all callers")
+31 -7
View File
@@ -1,11 +1,15 @@
package main package main
import ( import (
"context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/http"
"os" "os"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
felismetrics "felis.lolicon.best/internal/metrics" felismetrics "felis.lolicon.best/internal/metrics"
@@ -75,16 +79,19 @@ func cmdOperator(args []string, _, stderr io.Writer) int {
} }
fmt.Fprintf(stderr, "felis operator: watching namespace %q\n", *namespace) fmt.Fprintf(stderr, "felis operator: watching namespace %q\n", *namespace)
// Register the two probe endpoints. controller-runtime only mounts /healthz and // /healthz fails while a reconcile pass has been stuck past its limit, so the
// /readyz once at least one check is registered, so a bare listener would 404. // liveness probe restarts an operator whose workers are wedged (a Pod whose
// The checks are the canonical always-pass ping: the probes' contract is "the // process answers but no server starts or stops). /readyz waits for the
// manager process is up and serving", and a dependency hiccup (e.g. an API blip) // informer caches: until they sync the operator acts on nothing, and one that
// must not restart the operator. // never syncs (lost RBAC, an unreachable API) never reports Available.
if err := mgr.AddHealthzCheck("ping", healthz.Ping); err != nil { // A dependency hiccup fails neither: the caches ride through API blips, and
// each pass is bounded well inside the stuck limit.
watch := &operator.ReconcileWatch{}
if err := mgr.AddHealthzCheck("reconcile", watch.Check); err != nil {
fmt.Fprintf(stderr, "felis operator: register healthz check: %v\n", err) fmt.Fprintf(stderr, "felis operator: register healthz check: %v\n", err)
return 1 return 1
} }
if err := mgr.AddReadyzCheck("ping", healthz.Ping); err != nil { if err := mgr.AddReadyzCheck("informers", cacheSynced(mgr.GetCache())); err != nil {
fmt.Fprintf(stderr, "felis operator: register readyz check: %v\n", err) fmt.Fprintf(stderr, "felis operator: register readyz check: %v\n", err)
return 1 return 1
} }
@@ -98,6 +105,7 @@ func cmdOperator(args []string, _, stderr io.Writer) int {
fmt.Fprintf(stderr, "felis operator: register metrics: %v\n", err) fmt.Fprintf(stderr, "felis operator: register metrics: %v\n", err)
return 1 return 1
} }
felismetrics.SetBuildInfo("operator", resolvedVersion())
r := &operator.Reconciler{ r := &operator.Reconciler{
Client: mgr.GetClient(), Client: mgr.GetClient(),
@@ -110,6 +118,7 @@ func cmdOperator(args []string, _, stderr io.Writer) int {
// Uncached: the maintenance-lock check lists Jobs only when a server is // Uncached: the maintenance-lock check lists Jobs only when a server is
// about to start, which does not justify a namespace-wide Job informer. // about to start, which does not justify a namespace-wide Job informer.
Jobs: mgr.GetAPIReader(), Jobs: mgr.GetAPIReader(),
Watch: watch,
} }
if err := r.SetupWithManager(mgr); err != nil { if err := r.SetupWithManager(mgr); err != nil {
fmt.Fprintf(stderr, "felis operator: setup controller: %v\n", err) fmt.Fprintf(stderr, "felis operator: setup controller: %v\n", err)
@@ -131,3 +140,18 @@ func cmdOperator(args []string, _, stderr io.Writer) int {
} }
return 0 return 0
} }
// cacheSynced is a readyz check that passes once every informer the manager
// started has synced. It waits at most a second, well inside the probe timeout.
func cacheSynced(c interface {
WaitForCacheSync(ctx context.Context) bool
}) healthz.Checker {
return func(req *http.Request) error {
ctx, cancel := context.WithTimeout(req.Context(), time.Second)
defer cancel()
if !c.WaitForCacheSync(ctx) {
return errors.New("informer caches not synced")
}
return nil
}
}
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"context"
"net/http/httptest"
"testing"
)
type fakeCache bool
func (f fakeCache) WaitForCacheSync(ctx context.Context) bool {
if !f {
<-ctx.Done()
}
return bool(f)
}
// TestCacheSynced: the operator reports ready only once its informers synced,
// and a check against caches that never sync returns within its own deadline.
func TestCacheSynced(t *testing.T) {
req := httptest.NewRequest("GET", "/readyz", nil)
if err := cacheSynced(fakeCache(true))(req); err != nil {
t.Errorf("synced: %v", err)
}
if err := cacheSynced(fakeCache(false))(req); err == nil {
t.Error("unsynced caches reported ready")
}
}
+2
View File
@@ -27,6 +27,7 @@ Commands:
apply Create a MinecraftServer CRD (direct K8s write; use -f server.json) apply Create a MinecraftServer CRD (direct K8s write; use -f server.json)
setup Run host bootstrap + first-run setup console (TUI; requires root/sudo) setup Run host bootstrap + first-run setup console (TUI; requires root/sudo)
converge Fill in fields a newer desired spec added to already-installed system servers converge Fill in fields a newer desired spec added to already-installed system servers
watchdog Check the platform once and mail the owners what has gone wrong (run by felis-watchdog.timer)
version Print the build stamp of this binary version Print the build stamp of this binary
update Report which platform components have updates available update Report which platform components have updates available
breakGlass Open the local break-glass emergency console (TUI; requires root/sudo) breakGlass Open the local break-glass emergency console (TUI; requires root/sudo)
@@ -67,6 +68,7 @@ var commands = map[string]func(args []string, stdout, stderr io.Writer) int{
"pin-images": cmdPinImages, "pin-images": cmdPinImages,
"version": cmdVersion, "version": cmdVersion,
"update": cmdUpdate, "update": cmdUpdate,
"watchdog": cmdWatchdog,
} }
// run dispatches a subcommand. It is separate from main so the router is // run dispatches a subcommand. It is separate from main so the router is
+249
View File
@@ -0,0 +1,249 @@
package main
import (
"context"
"database/sql"
"errors"
"flag"
"fmt"
"io"
"net"
"os"
"strings"
"time"
"felis.lolicon.best/internal/config"
"felis.lolicon.best/internal/mail"
"felis.lolicon.best/internal/platform"
"felis.lolicon.best/internal/store"
"felis.lolicon.best/internal/watchdog"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// proxyFor is how long the game proxy may refuse connections before it is
// mailed: a restart takes seconds.
const proxyFor = 3 * time.Minute
// cmdWatchdog runs one pass of the platform watchdog (internal/watchdog): it
// checks the cluster, PostgreSQL, the game proxy, the database backups and the
// host, prints every finding, and mails the platform owners what came due.
// deploy/bootstrap.sh runs it every two minutes from felis-watchdog.timer.
func cmdWatchdog(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("watchdog", flag.ContinueOnError)
fs.SetOutput(stderr)
cfgPath := fs.String("config", "/etc/felis/felis.toml", "path to felis.toml (the host copy, which reaches PostgreSQL on 127.0.0.1)")
statePath := fs.String("state", "/var/lib/felis/watchdog/state.json", "state kept between runs (root only: it caches the relay password)")
quietPath := fs.String("quiet-file", "/run/felis/watchdog-quiet-until", "Unix time before which nothing is mailed; the installer writes it while it restarts things on purpose")
backupDir := fs.String("backup-dir", "/var/lib/felis/db-backups", `control-plane database backups to check for freshness ("" skips the check)`)
diskPaths := fs.String("disk-paths", "/,/var/lib/rancher/k3s,/var/lib/postgresql,/var/lib/felis", "comma-separated paths whose filesystems must keep free space")
proxyAddr := fs.String("proxy-addr", "", `game proxy address to dial, e.g. 127.0.0.1:25565 ("" skips the check)`)
controlNS := fs.String("control-namespace", platform.DefaultControlNamespace, "namespace of the control plane")
dryRun := fs.Bool("dry-run", false, "print every finding and the mail that is due; send nothing and keep the state as it was")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
cfg, err := config.Load(*cfgPath)
if err != nil {
fmt.Fprintf(stderr, "felis watchdog: %v\n", err)
return 1
}
state, err := watchdog.LoadState(*statePath)
if err != nil {
fmt.Fprintf(stderr, "felis watchdog: %v\n", err)
return 1
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
now := time.Now()
var report watchdog.Report
add := func(f *watchdog.Finding) {
if f != nil {
report.Findings = append(report.Findings, *f)
}
}
// The cluster: one unreachable API server stands in for every check behind it.
minecraftNS := cfg.K8s.Namespace
if minecraftNS == "" {
minecraftNS = platform.DefaultMinecraftNamespace
}
cl, err := buildSystemServerClient()
var found []watchdog.Finding
if err == nil {
found, err = watchdog.Cluster{Client: cl, ControlNamespace: *controlNS, MinecraftNamespace: minecraftNS}.Check(ctx, now)
}
if err != nil {
f := watchdog.KubeAPIDown(err)
add(&f)
report.Unknown = append(report.Unknown, watchdog.ClusterPrefixes...)
} else {
report.Findings = append(report.Findings, found...)
if cfg.SMTP.Host != "" {
refreshSMTPPassword(ctx, cl, *controlNS, state, stderr)
}
}
if recipients, err := ownerEmails(ctx, cfg.Database.URL); err != nil {
f := watchdog.PostgresDown(err)
add(&f)
} else {
state.Recipients = recipients
}
if *proxyAddr != "" {
add(proxyFinding(ctx, *proxyAddr))
}
if *backupDir != "" {
add(watchdog.BackupFinding(*backupDir, now))
}
report.Findings = append(report.Findings, watchdog.DiskFindings(splitList(*diskPaths))...)
add(watchdog.MemoryFinding("/proc/meminfo"))
if len(report.Findings) == 0 {
fmt.Fprintln(stdout, "felis watchdog: every check passed")
}
for _, f := range report.Findings {
fmt.Fprintf(stdout, "felis watchdog: [%s] %s: %s\n", f.Severity, f.Key, f.SummaryEN)
}
plan := state.Observe(report, now)
host, _ := os.Hostname()
subject, body := plan.Message(host, now)
if *dryRun {
if plan.Empty() {
fmt.Fprintln(stdout, "felis watchdog: nothing is due to be mailed")
} else {
fmt.Fprintf(stdout, "felis watchdog: due to be mailed to %s:\nSubject: %s\n\n%s", strings.Join(state.Recipients, ", "), subject, strings.ReplaceAll(body, "\r\n", "\n"))
}
return 0
}
save := func() int {
if err := watchdog.SaveState(*statePath, state); err != nil {
fmt.Fprintf(stderr, "felis watchdog: save state: %v\n", err)
return 1
}
return 0
}
if plan.Empty() {
return save()
}
if until := watchdog.QuietUntil(*quietPath); now.Before(until) {
fmt.Fprintf(stdout, "felis watchdog: quiet until %s (installer running); holding this mail: %s\n", until.UTC().Format(time.RFC3339), subject)
return save()
}
switch {
case cfg.SMTP.Host == "":
fmt.Fprintf(stdout, "felis watchdog: no [smtp] relay configured, so this is logged only: %s\n", subject)
case len(state.Recipients) == 0:
fmt.Fprintf(stdout, "felis watchdog: no owner account has a verified email, so this is logged only: %s\n", subject)
default:
if err := sendAlert(ctx, cfg, state, subject, body); err != nil {
// Not committed: the same alerts come due again next run.
fmt.Fprintf(stderr, "felis watchdog: mail %q: %v\n", subject, err)
save()
return 1
}
fmt.Fprintf(stdout, "felis watchdog: mailed %s: %s\n", strings.Join(state.Recipients, ", "), subject)
}
state.Commit(plan, now)
return save()
}
// refreshSMTPPassword caches the relay password from the felis-smtp Secret, or
// forgets it when the Secret is gone (a relay without AUTH). An env var named by
// [smtp] password_ref, when set, wins at send time instead.
func refreshSMTPPassword(ctx context.Context, cl client.Client, ns string, state *watchdog.State, stderr io.Writer) {
var sec corev1.Secret
err := cl.Get(ctx, client.ObjectKey{Namespace: ns, Name: platform.SMTPSecretName}, &sec)
switch {
case apierrors.IsNotFound(err):
state.SMTPPassword = ""
case err != nil:
fmt.Fprintf(stderr, "felis watchdog: read %s/%s (keeping the cached relay password): %v\n", ns, platform.SMTPSecretName, err)
default:
state.SMTPPassword = string(sec.Data[platform.SMTPSecretPasswordKey])
}
}
// ownerEmails pings PostgreSQL and returns the verified addresses of the
// enabled owner accounts, the people who can act on an alert.
func ownerEmails(ctx context.Context, url string) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
drv, err := store.Open(ctx, url)
if err != nil {
return nil, err
}
defer drv.Close()
rows, err := drv.DB().QueryContext(ctx,
`SELECT email FROM users
WHERE role = 'owner' AND email_verified AND COALESCE(email, '') <> ''
AND NOT disabled AND deleted_at IS NULL
ORDER BY email`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var email sql.NullString
if err := rows.Scan(&email); err != nil {
return nil, err
}
out = append(out, email.String)
}
return out, rows.Err()
}
// proxyFinding dials the game proxy; players reach every server through it.
func proxyFinding(ctx context.Context, addr string) *watchdog.Finding {
d := net.Dialer{Timeout: 5 * time.Second}
conn, err := d.DialContext(ctx, "tcp", addr)
if err == nil {
conn.Close()
return nil
}
return &watchdog.Finding{
Key: "proxy", Severity: watchdog.Critical, For: proxyFor,
Summary: fmt.Sprintf("游戏代理 %s 无法连接:玩家进不了任何服务器", addr),
SummaryEN: fmt.Sprintf("the game proxy at %s refuses connections: players cannot reach any server", addr),
Hint: fmt.Sprintf("systemctl status felis-velocity; journalctl -u felis-velocity -n 200 (%v)", err),
}
}
// sendAlert mails subject/body to every recipient; it fails only when no
// recipient got it.
func sendAlert(ctx context.Context, cfg *config.Config, state *watchdog.State, subject, body string) error {
password := state.SMTPPassword
if ref := cfg.SMTP.PasswordRef; ref != "" && os.Getenv(ref) != "" {
password = os.Getenv(ref)
}
relay := &mail.SMTP{Host: cfg.SMTP.Host, Port: cfg.SMTP.Port, From: cfg.SMTP.From, Username: cfg.SMTP.Username, Password: password}
var errs []error
for _, to := range state.Recipients {
if err := relay.SendNotice(ctx, to, subject, body); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", to, err))
}
}
if len(errs) == len(state.Recipients) {
return errors.Join(errs...)
}
return nil
}
func splitList(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"context"
"net"
"strings"
"testing"
)
// TestProxyFinding: a listening proxy is healthy; a closed port is the critical
// "players cannot reach any server" finding.
func TestProxyFinding(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := ln.Addr().String()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
c.Close()
}
}()
if f := proxyFinding(context.Background(), addr); f != nil {
t.Fatalf("listening proxy reported: %+v", f)
}
ln.Close()
f := proxyFinding(context.Background(), addr)
if f == nil || f.Key != "proxy" || !strings.Contains(f.SummaryEN, addr) {
t.Fatalf("closed proxy = %+v, want the proxy finding", f)
}
}
func TestSplitList(t *testing.T) {
got := splitList(" /, /var/lib/felis ,,")
if strings.Join(got, "|") != "/|/var/lib/felis" {
t.Fatalf("splitList = %q", got)
}
}
+121 -5
View File
@@ -1,14 +1,20 @@
# Felis alert rules — plain Prometheus format (also the promtool-tested source # Felis alert rules — plain Prometheus format (also the promtool-tested source
# for felis-prometheusrule.yaml). See docs/troubleshooting.md §14 for scraping # for felis-prometheusrule.yaml). See docs/troubleshooting.md §14 for scraping
# and loading instructions. # and loading instructions. These are for a deployment that brings its own
# Prometheus; every install already runs `felis watchdog` on the host, which
# checks the same conditions without one and mails the owners (§14).
# #
# felis_* series come from two processes: # felis_* series come from these processes:
# - felis-operator pod :8080/metrics → felis_servers_total, felis_start_duration_seconds # - felis-operator-metrics Service :8080 → felis_servers_total, felis_server_phase,
# - felis-api internal :8081/metrics → felis_image_build_failures_total, # felis_start_duration_seconds,
# felis_build_info{component="operator"},
# controller_runtime_*, workqueue_*
# - felis-api-internal Service :8081 → felis_image_build_failures_total,
# felis_mail_total, felis_rate_limited_total, # felis_mail_total, felis_rate_limited_total,
# felis_auth_otp_lockouts_total, # felis_auth_otp_lockouts_total,
# felis_auth_failures_total, # felis_auth_failures_total,
# felis_audit_write_failures_total # felis_audit_write_failures_total,
# felis_build_info{component="api"}
# - node-exporter textfile collector → felis_db_backup_* (felis-db-backup.timer) # - node-exporter textfile collector → felis_db_backup_* (felis-db-backup.timer)
# node_* / kube_* series come from node-exporter / kube-state-metrics. # node_* / kube_* series come from node-exporter / kube-state-metrics.
groups: groups:
@@ -37,6 +43,116 @@ groups:
observed when readiness is first reached). A start that never completes observed when readiness is first reached). A start that never completes
records nothing — cross-check desiredState=Running servers with no ready records nothing — cross-check desiredState=Running servers with no ready
phase (troubleshooting §1). phase (troubleshooting §1).
- name: felis.platform.rules
rules:
- alert: FelisOperatorDown
expr: absent(felis_build_info{component="operator"})
for: 10m
labels:
severity: critical
annotations:
summary: "felis-operator is down or not scraped"
description: >-
No felis_build_info{component="operator"} series for 10 minutes. Without
the operator no server starts, stops or recovers. Check
`kubectl -n felis get deploy felis-operator` and its log; if the pod is
healthy, the felis-operator-metrics Service is not being scraped
(troubleshooting §14).
- alert: FelisAPIDown
expr: absent(felis_build_info{component="api"})
for: 10m
labels:
severity: critical
annotations:
summary: "felis-api is down or not scraped"
description: >-
No felis_build_info{component="api"} series for 10 minutes. The panel,
sign-in and the proxy's player lookups all go through felis-api. Check
`kubectl -n felis get deploy felis-api` and its log; if the pod is
healthy, the felis-api-internal Service is not being scraped
(troubleshooting §14).
- alert: FelisLoginGateDown
expr: felis_server_phase{role="login",desired="Running",phase!="Running"} == 1
for: 10m
labels:
severity: critical
annotations:
summary: "the login gate {{ $labels.server }} is {{ $labels.phase }}"
description: >-
Every player connection passes through the login server first, so no one
can join. The MinecraftServer's conditions carry the reason:
`kubectl -n minecraft describe minecraftserver {{ $labels.server }}`
(troubleshooting §1, §2).
- alert: FelisSystemServerDown
expr: felis_server_phase{role!="",role!="login",desired="Running",phase!="Running"} == 1
for: 10m
labels:
severity: warning
annotations:
summary: "system server {{ $labels.server }} ({{ $labels.role }}) is {{ $labels.phase }}"
description: >-
Players who sign in are sent to the lobby; while it is down they stay at
the gate. `kubectl -n minecraft describe minecraftserver {{ $labels.server }}`
shows the reason (troubleshooting §1, §2).
- alert: FelisServerFailed
expr: felis_server_phase{role="",phase="Failed"} == 1
for: 5m
labels:
severity: warning
annotations:
summary: "server {{ $labels.server }} is Failed"
description: >-
The operator gave up on this server (a crash loop, an image that will not
pull, a world volume that will not mount). Its conditions carry the
reason: `kubectl -n minecraft describe minecraftserver {{ $labels.server }}`
(troubleshooting §2).
- alert: FelisReconcileErrors
expr: sum(increase(controller_runtime_reconcile_errors_total{controller="minecraftserver"}[15m])) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "the operator failed over 10 reconciles in 15 minutes"
description: >-
Server changes are being retried instead of applied. The felis-operator
log names each failing server and its error.
- alert: FelisReconcileStuck
expr: max(workqueue_longest_running_processor_seconds{name="minecraftserver"}) > 300
for: 5m
labels:
severity: critical
annotations:
summary: "an operator reconcile has been running for over 5 minutes"
description: >-
Each reconcile is bounded at 3 minutes, so this one is ignoring its
deadline and holding a worker. The liveness probe restarts the operator
once a pass passes 10 minutes; the log from before the restart shows
where it hung.
- name: felis.jobs.rules
rules:
- alert: FelisWorldJobFailed
expr: kube_job_failed{namespace="minecraft",condition="true"} == 1
labels:
severity: warning
annotations:
summary: "Job {{ $labels.job_name }} failed"
description: >-
A world backup, restore or reaper run failed; after a failed backup that
world's newest archive is older than planned.
`kubectl -n minecraft logs job/{{ $labels.job_name }}` has the error
(troubleshooting §10).
- alert: FelisReaperStale
expr: time() - kube_cronjob_status_last_successful_time{namespace="minecraft",cronjob="felis-reaper"} > 26 * 3600
for: 10m
labels:
severity: warning
annotations:
summary: "the world reaper has not succeeded in over 26h"
description: >-
felis-reaper runs daily; idle worlds are neither backed up nor reclaimed
while it fails. `kubectl -n minecraft get jobs --sort-by=.metadata.creationTimestamp`
lists its runs, and the newest one's log
shows why (troubleshooting §10).
- name: felis.node.rules - name: felis.node.rules
rules: rules:
- alert: FelisNodeDiskSpaceLow - alert: FelisNodeDiskSpaceLow
+216
View File
@@ -291,3 +291,219 @@ tests:
The actions went through but their audit rows were lost. The felis-api log The actions went through but their audit rows were lost. The felis-api log
names each lost row (`audit: lost ...`); the usual cause is PostgreSQL names each lost row (`audit: lost ...`); the usual cause is PostgreSQL
being unreachable or out of disk. being unreachable or out of disk.
- name: operator and api presence
interval: 1m
input_series:
- series: 'felis_build_info{component="operator",version="v1",job="felis-operator",instance="op-0"}'
values: '1x30'
# felis-api stops being scraped after 5m; the series goes stale 5m later.
- series: 'felis_build_info{component="api",version="v1",job="felis-api",instance="api-0"}'
values: '1x5'
alert_rule_test:
- eval_time: 25m
alertname: FelisOperatorDown
exp_alerts: []
- eval_time: 15m
alertname: FelisAPIDown
exp_alerts: []
- eval_time: 25m
alertname: FelisAPIDown
exp_alerts:
- exp_labels:
severity: critical
component: api
exp_annotations:
summary: "felis-api is down or not scraped"
description: >-
No felis_build_info{component="api"} series for 10 minutes. The panel,
sign-in and the proxy's player lookups all go through felis-api. Check
`kubectl -n felis get deploy felis-api` and its log; if the pod is
healthy, the felis-api-internal Service is not being scraped
(troubleshooting §14).
- name: operator never scraped
interval: 1m
input_series:
- series: 'felis_build_info{component="api",version="v1",job="felis-api",instance="api-0"}'
values: '1x30'
alert_rule_test:
- eval_time: 5m
alertname: FelisOperatorDown
exp_alerts: []
- eval_time: 15m
alertname: FelisOperatorDown
exp_alerts:
- exp_labels:
severity: critical
component: operator
exp_annotations:
summary: "felis-operator is down or not scraped"
description: >-
No felis_build_info{component="operator"} series for 10 minutes. Without
the operator no server starts, stops or recovers. Check
`kubectl -n felis get deploy felis-operator` and its log; if the pod is
healthy, the felis-operator-metrics Service is not being scraped
(troubleshooting §14).
- name: system and user servers down
interval: 1m
input_series:
- series: 'felis_server_phase{server="login",role="login",phase="Starting",desired="Running"}'
values: '1x20'
- series: 'felis_server_phase{server="lobby",role="lobby",phase="Failed",desired="Running"}'
values: '1x20'
# Stopped on purpose: not an outage.
- series: 'felis_server_phase{server="lobby2",role="lobby",phase="Stopped",desired="Stopped"}'
values: '1x20'
# A user server carries no role label (the operator publishes role="").
- series: 'felis_server_phase{server="survival",phase="Failed",desired="Running"}'
values: '1x20'
- series: 'felis_server_phase{server="creative",phase="Running",desired="Running"}'
values: '1x20'
alert_rule_test:
- eval_time: 9m
alertname: FelisLoginGateDown
exp_alerts: []
- eval_time: 11m
alertname: FelisLoginGateDown
exp_alerts:
- exp_labels:
severity: critical
server: login
role: login
phase: Starting
desired: Running
exp_annotations:
summary: "the login gate login is Starting"
description: >-
Every player connection passes through the login server first, so no one
can join. The MinecraftServer's conditions carry the reason:
`kubectl -n minecraft describe minecraftserver login`
(troubleshooting §1, §2).
- eval_time: 11m
alertname: FelisSystemServerDown
exp_alerts:
- exp_labels:
severity: warning
server: lobby
role: lobby
phase: Failed
desired: Running
exp_annotations:
summary: "system server lobby (lobby) is Failed"
description: >-
Players who sign in are sent to the lobby; while it is down they stay at
the gate. `kubectl -n minecraft describe minecraftserver lobby`
shows the reason (troubleshooting §1, §2).
- eval_time: 3m
alertname: FelisServerFailed
exp_alerts: []
- eval_time: 6m
alertname: FelisServerFailed
exp_alerts:
- exp_labels:
severity: warning
server: survival
phase: Failed
desired: Running
exp_annotations:
summary: "server survival is Failed"
description: >-
The operator gave up on this server (a crash loop, an image that will not
pull, a world volume that will not mount). Its conditions carry the
reason: `kubectl -n minecraft describe minecraftserver survival`
(troubleshooting §2).
- name: operator reconcile errors and a stuck pass
interval: 1m
input_series:
# Two failed reconciles a minute from 6m on.
- series: 'controller_runtime_reconcile_errors_total{controller="minecraftserver",job="felis-operator"}'
values: '0x5 0+2x20'
# One pass that started at 5m and never returns.
- series: 'workqueue_longest_running_processor_seconds{name="minecraftserver",controller="minecraftserver",job="felis-operator"}'
values: '0x5 60+60x20'
alert_rule_test:
- eval_time: 5m
alertname: FelisReconcileErrors
exp_alerts: []
- eval_time: 25m
alertname: FelisReconcileErrors
exp_alerts:
- exp_labels:
severity: warning
exp_annotations:
summary: "the operator failed over 10 reconciles in 15 minutes"
description: >-
Server changes are being retried instead of applied. The felis-operator
log names each failing server and its error.
- eval_time: 12m
alertname: FelisReconcileStuck
exp_alerts: []
- eval_time: 20m
alertname: FelisReconcileStuck
exp_alerts:
- exp_labels:
severity: critical
exp_annotations:
summary: "an operator reconcile has been running for over 5 minutes"
description: >-
Each reconcile is bounded at 3 minutes, so this one is ignoring its
deadline and holding a worker. The liveness probe restarts the operator
once a pass passes 10 minutes; the log from before the restart shows
where it hung.
- name: world job failures and a late reaper
interval: 1m
input_series:
- series: 'kube_job_failed{namespace="minecraft",job_name="backup-survival-abc",condition="true"}'
values: '0x2 1x10'
- series: 'kube_job_failed{namespace="minecraft",job_name="backup-survival-abc",condition="false"}'
values: '1x2 0x10'
# A build Job in another namespace is FelisImageBuildFailures' business.
- series: 'kube_job_failed{namespace="felis-build",job_name="build-x",condition="true"}'
values: '1x12'
# Evaluation starts at the epoch, so "over a day ago" is a negative timestamp.
- series: 'kube_cronjob_status_last_successful_time{namespace="minecraft",cronjob="felis-reaper"}'
values: '-100000x30'
alert_rule_test:
- eval_time: 1m
alertname: FelisWorldJobFailed
exp_alerts: []
- eval_time: 5m
alertname: FelisWorldJobFailed
exp_alerts:
- exp_labels:
severity: warning
namespace: minecraft
job_name: backup-survival-abc
condition: "true"
exp_annotations:
summary: "Job backup-survival-abc failed"
description: >-
A world backup, restore or reaper run failed; after a failed backup that
world's newest archive is older than planned.
`kubectl -n minecraft logs job/backup-survival-abc` has the error
(troubleshooting §10).
- eval_time: 5m
alertname: FelisReaperStale
exp_alerts: []
- eval_time: 15m
alertname: FelisReaperStale
exp_alerts:
- exp_labels:
severity: warning
namespace: minecraft
cronjob: felis-reaper
exp_annotations:
summary: "the world reaper has not succeeded in over 26h"
description: >-
felis-reaper runs daily; idle worlds are neither backed up nor reclaimed
while it fails. `kubectl -n minecraft get jobs --sort-by=.metadata.creationTimestamp`
lists its runs, and the newest one's log
shows why (troubleshooting §10).
- name: a reaper that ran yesterday stays quiet
interval: 1m
input_series:
- series: 'kube_cronjob_status_last_successful_time{namespace="minecraft",cronjob="felis-reaper"}'
values: '-50000x30'
alert_rule_test:
- eval_time: 25m
alertname: FelisReaperStale
exp_alerts: []
+110
View File
@@ -37,6 +37,116 @@ spec:
observed when readiness is first reached). A start that never completes observed when readiness is first reached). A start that never completes
records nothing — cross-check desiredState=Running servers with no ready records nothing — cross-check desiredState=Running servers with no ready
phase (troubleshooting §1). phase (troubleshooting §1).
- name: felis.platform.rules
rules:
- alert: FelisOperatorDown
expr: absent(felis_build_info{component="operator"})
for: 10m
labels:
severity: critical
annotations:
summary: "felis-operator is down or not scraped"
description: >-
No felis_build_info{component="operator"} series for 10 minutes. Without
the operator no server starts, stops or recovers. Check
`kubectl -n felis get deploy felis-operator` and its log; if the pod is
healthy, the felis-operator-metrics Service is not being scraped
(troubleshooting §14).
- alert: FelisAPIDown
expr: absent(felis_build_info{component="api"})
for: 10m
labels:
severity: critical
annotations:
summary: "felis-api is down or not scraped"
description: >-
No felis_build_info{component="api"} series for 10 minutes. The panel,
sign-in and the proxy's player lookups all go through felis-api. Check
`kubectl -n felis get deploy felis-api` and its log; if the pod is
healthy, the felis-api-internal Service is not being scraped
(troubleshooting §14).
- alert: FelisLoginGateDown
expr: felis_server_phase{role="login",desired="Running",phase!="Running"} == 1
for: 10m
labels:
severity: critical
annotations:
summary: "the login gate {{ $labels.server }} is {{ $labels.phase }}"
description: >-
Every player connection passes through the login server first, so no one
can join. The MinecraftServer's conditions carry the reason:
`kubectl -n minecraft describe minecraftserver {{ $labels.server }}`
(troubleshooting §1, §2).
- alert: FelisSystemServerDown
expr: felis_server_phase{role!="",role!="login",desired="Running",phase!="Running"} == 1
for: 10m
labels:
severity: warning
annotations:
summary: "system server {{ $labels.server }} ({{ $labels.role }}) is {{ $labels.phase }}"
description: >-
Players who sign in are sent to the lobby; while it is down they stay at
the gate. `kubectl -n minecraft describe minecraftserver {{ $labels.server }}`
shows the reason (troubleshooting §1, §2).
- alert: FelisServerFailed
expr: felis_server_phase{role="",phase="Failed"} == 1
for: 5m
labels:
severity: warning
annotations:
summary: "server {{ $labels.server }} is Failed"
description: >-
The operator gave up on this server (a crash loop, an image that will not
pull, a world volume that will not mount). Its conditions carry the
reason: `kubectl -n minecraft describe minecraftserver {{ $labels.server }}`
(troubleshooting §2).
- alert: FelisReconcileErrors
expr: sum(increase(controller_runtime_reconcile_errors_total{controller="minecraftserver"}[15m])) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "the operator failed over 10 reconciles in 15 minutes"
description: >-
Server changes are being retried instead of applied. The felis-operator
log names each failing server and its error.
- alert: FelisReconcileStuck
expr: max(workqueue_longest_running_processor_seconds{name="minecraftserver"}) > 300
for: 5m
labels:
severity: critical
annotations:
summary: "an operator reconcile has been running for over 5 minutes"
description: >-
Each reconcile is bounded at 3 minutes, so this one is ignoring its
deadline and holding a worker. The liveness probe restarts the operator
once a pass passes 10 minutes; the log from before the restart shows
where it hung.
- name: felis.jobs.rules
rules:
- alert: FelisWorldJobFailed
expr: kube_job_failed{namespace="minecraft",condition="true"} == 1
labels:
severity: warning
annotations:
summary: "Job {{ $labels.job_name }} failed"
description: >-
A world backup, restore or reaper run failed; after a failed backup that
world's newest archive is older than planned.
`kubectl -n minecraft logs job/{{ $labels.job_name }}` has the error
(troubleshooting §10).
- alert: FelisReaperStale
expr: time() - kube_cronjob_status_last_successful_time{namespace="minecraft",cronjob="felis-reaper"} > 26 * 3600
for: 10m
labels:
severity: warning
annotations:
summary: "the world reaper has not succeeded in over 26h"
description: >-
felis-reaper runs daily; idle worlds are neither backed up nor reclaimed
while it fails. `kubectl -n minecraft get jobs --sort-by=.metadata.creationTimestamp`
lists its runs, and the newest one's log
shows why (troubleshooting §10).
- name: felis.node.rules - name: felis.node.rules
rules: rules:
- alert: FelisNodeDiskSpaceLow - alert: FelisNodeDiskSpaceLow
+67
View File
@@ -252,6 +252,13 @@ GOROOT_DIR="/opt/felis/go"
NANO_SERVICE="/etc/systemd/system/felis-nano.service" NANO_SERVICE="/etc/systemd/system/felis-nano.service"
DB_BACKUP_SERVICE="/etc/systemd/system/felis-db-backup.service" DB_BACKUP_SERVICE="/etc/systemd/system/felis-db-backup.service"
DB_BACKUP_TIMER="/etc/systemd/system/felis-db-backup.timer" DB_BACKUP_TIMER="/etc/systemd/system/felis-db-backup.timer"
WATCHDOG_SERVICE="/etc/systemd/system/felis-watchdog.service"
WATCHDOG_TIMER="/etc/systemd/system/felis-watchdog.timer"
WATCHDOG_STATE="/var/lib/felis/watchdog/state.json"
# While this marker holds a future Unix time, felis watchdog mails nothing: an install
# restarts the control plane and the system servers on purpose. cleanup removes it; the
# time in it is the backstop for an installer killed before its EXIT trap runs.
WATCHDOG_QUIET_FILE="/run/felis/watchdog-quiet-until"
VELOCITY_DIR="/opt/felis/velocity" VELOCITY_DIR="/opt/felis/velocity"
VELOCITY_USER="felis-velocity" VELOCITY_USER="felis-velocity"
VELOCITY_SERVICE="/etc/systemd/system/felis-velocity.service" VELOCITY_SERVICE="/etc/systemd/system/felis-velocity.service"
@@ -336,6 +343,9 @@ cleanup() {
for path in "${TEMP_PATHS[@]-}"; do for path in "${TEMP_PATHS[@]-}"; do
[ -n "$path" ] && rm -rf -- "$path" || true [ -n "$path" ] && rm -rf -- "$path" || true
done done
# A failed install leaves something broken the owners should hear about, so the
# watchdog speaks again the moment the installer exits, however it exits.
rm -f -- "$WATCHDOG_QUIET_FILE" 2>/dev/null || true
} }
remember_temp() { TEMP_PATHS+=("$1"); } remember_temp() { TEMP_PATHS+=("$1"); }
@@ -2415,6 +2425,60 @@ run_migrations() {
ok "migrations applied" ok "migrations applied"
} }
# Silence felis watchdog's mail for the rest of this install (see WATCHDOG_QUIET_FILE).
# Two hours covers a slow source build; cleanup lifts it as soon as the installer exits.
quiet_watchdog() {
install -d -m 0755 "$(dirname "$WATCHDOG_QUIET_FILE")"
printf '%s\n' "$(( $(date +%s) + 7200 ))" > "$WATCHDOG_QUIET_FILE"
}
# The platform watchdog: every two minutes it checks the control plane, the login gate,
# the fleet, PostgreSQL, the game proxy, the database backups and the host's disks and
# memory, and mails the owners (their verified addresses, over the [smtp] relay) what
# has stayed wrong long enough to matter. It runs on the host so a k3s that is down is
# still reported. The first run happens now, so a broken unit shows up in this install.
install_watchdog_timer() {
local disks="/,/var/lib/rancher/k3s,/var/lib/postgresql,/var/lib/felis" path
for path in "$FELIS_WORLDS_HOST_PATH" "$FELIS_ARCHIVE_LOCAL_PATH" "$FELIS_DB_BACKUP_DIR"; do
if [ -n "$path" ]; then disks="${disks},${path}"; fi
done
install -d -m 0700 "$(dirname "$WATCHDOG_STATE")"
cat > "$WATCHDOG_SERVICE" <<EOF
[Unit]
Description=Felis platform watchdog (health checks, owner alert mail)
After=network-online.target k3s.service postgresql.service
[Service]
Type=oneshot
ExecStart=${HOST_BIN} watchdog -config ${STATE_DIR}/felis.host.toml -state ${WATCHDOG_STATE} -quiet-file ${WATCHDOG_QUIET_FILE} -backup-dir ${FELIS_DB_BACKUP_DIR} -proxy-addr 127.0.0.1:${FELIS_GAME_PORT} -disk-paths ${disks}
TimeoutStartSec=3min
Nice=5
PrivateTmp=yes
NoNewPrivileges=yes
ProtectSystem=full
EOF
cat > "$WATCHDOG_TIMER" <<EOF
[Unit]
Description=Felis platform watchdog, every two minutes
[Timer]
OnBootSec=3min
OnUnitActiveSec=2min
AccuracySec=15s
[Install]
WantedBy=timers.target
EOF
systemctl daemon-reload
systemctl enable --now felis-watchdog.timer
if systemctl start felis-watchdog.service; then
ok "watchdog: checks every 2 minutes and mails the owners' verified addresses (journalctl -u felis-watchdog)"
else
journalctl -u felis-watchdog.service -n 20 --no-pager >&2 || true
warn "the first watchdog run failed (log above); nothing will be mailed until it runs: sudo systemctl start felis-watchdog.service"
fi
}
# The daily database backup. The first run happens now, so a broken pipeline (pg_dump # The daily database backup. The first run happens now, so a broken pipeline (pg_dump
# missing, directory unwritable) shows up in this install rather than in the first # missing, directory unwritable) shows up in this install rather than in the first
# restore someone needs. # restore someone needs.
@@ -3019,6 +3083,7 @@ main() {
main_nano main_nano
return return
fi fi
quiet_watchdog
pause_package_background_timers pause_package_background_timers
detect_node_ip detect_node_ip
ensure_swap ensure_swap
@@ -3073,6 +3138,8 @@ main() {
install_velocity install_velocity
# After deploy_bundle: the bundle's MinecraftServer export reads the cluster. # After deploy_bundle: the bundle's MinecraftServer export reads the cluster.
install_db_backup_timer install_db_backup_timer
# Last: its first run should see the platform as this install leaves it.
install_watchdog_timer
mark_bootstrap_done mark_bootstrap_done
summary summary
} }
+69
View File
@@ -1152,6 +1152,75 @@ expect "a failed first backup shows its log" "JOURNAL: pg_dump: connection refus
expect "a failed first backup is a loud warning" "WARN: the first database backup failed" "$out" expect "a failed first backup is a loud warning" "WARN: the first database backup failed" "$out"
rm -rf "$tdir" rm -rf "$tdir"
wblock="$(awk '/^install_watchdog_timer\(\) \{/,/^}/' "$BS")"
[ -n "$wblock" ] || { echo "FAIL: no install_watchdog_timer found in $BS"; exit 1; }
[ "$(printf '%s\n' "$wblock" | wc -l)" -lt 60 ] \
|| { echo "FAIL: the extracted block is not install_watchdog_timer -- did its closing brace move?"; exit 1; }
qblock="$(awk '/^quiet_watchdog\(\) \{/,/^}/' "$BS")"
[ -n "$qblock" ] || { echo "FAIL: no quiet_watchdog found in $BS"; exit 1; }
tdir="$(mktemp -d)"
run_watchdog_timer() { # $1: exit status of the first run, $2: FELIS_WORLDS_HOST_PATH
FIRST="$1" FELIS_WORLDS_HOST_PATH="$2" WATCHDOG_SERVICE="$tdir/felis-watchdog.service" WATCHDOG_TIMER="$tdir/felis-watchdog.timer" \
WATCHDOG_STATE="$tdir/watchdog/state.json" WATCHDOG_QUIET_FILE=/run/felis/watchdog-quiet-until \
FELIS_DB_BACKUP_DIR=/var/lib/felis/db-backups FELIS_ARCHIVE_LOCAL_PATH=/var/lib/felis/archives FELIS_GAME_PORT=25577 \
HOST_BIN=/usr/local/bin/felis STATE_DIR=/etc/felis bash -c '
set -Eeuo pipefail
ok() { printf "OK: %s\n" "$*"; }; warn() { printf "WARN: %s\n" "$*"; }
systemctl() { printf "SYSTEMCTL: %s\n" "$*"; [ "$1" != start ] || return "$FIRST"; }
journalctl() { printf "JOURNAL: parse /etc/felis/felis.host.toml\n"; }
'"$wblock"'
install_watchdog_timer' 2>&1
}
out="$(run_watchdog_timer 0 "")"
unit="$(cat "$tdir/felis-watchdog.service")"
timer="$(cat "$tdir/felis-watchdog.timer")"
expect "the watchdog runs the host binary against the host config, dialing the proxy's port" \
"ExecStart=/usr/local/bin/felis watchdog -config /etc/felis/felis.host.toml -state $tdir/watchdog/state.json -quiet-file /run/felis/watchdog-quiet-until -backup-dir /var/lib/felis/db-backups -proxy-addr 127.0.0.1:25577 -disk-paths /,/var/lib/rancher/k3s,/var/lib/postgresql,/var/lib/felis,/var/lib/felis/archives,/var/lib/felis/db-backups" "$unit"
expect "a wedged run is killed before the next one is due twice over" "TimeoutStartSec=3min" "$unit"
expect "the watchdog runs every two minutes" "OnUnitActiveSec=2min" "$timer"
expect "the watchdog starts soon after boot" "OnBootSec=3min" "$timer"
expect "the watchdog timer is enabled" "SYSTEMCTL: enable --now felis-watchdog.timer" "$out"
expect "the first watchdog run happens during the install" "SYSTEMCTL: start felis-watchdog.service" "$out"
expect "a working first run is reported" "OK: watchdog: checks every 2 minutes" "$out"
if [ "$(stat -c %a "$tdir/watchdog" 2>/dev/null || stat -f %Lp "$tdir/watchdog")" = 700 ]; then
echo "PASS the watchdog state directory is private (it caches the relay password)"
else
echo "FAIL the watchdog state directory must be 0700"; fails=$((fails + 1))
fi
out="$(run_watchdog_timer 0 /srv/worlds)"
expect "a custom worlds root is watched for free space" "-disk-paths /,/var/lib/rancher/k3s,/var/lib/postgresql,/var/lib/felis,/srv/worlds," "$(cat "$tdir/felis-watchdog.service")"
out="$(run_watchdog_timer 1 "")"
expect "a failed first watchdog run shows its log" "JOURNAL: parse /etc/felis/felis.host.toml" "$out"
expect "a failed first watchdog run is a loud warning" "WARN: the first watchdog run failed" "$out"
out="$(WATCHDOG_QUIET_FILE="$tdir/run/quiet" bash -c '
set -Eeuo pipefail
'"$qblock"'
quiet_watchdog; cat "$WATCHDOG_QUIET_FILE"; date +%s' 2>&1)"
until_ts="$(printf '%s\n' "$out" | sed -n 1p)"; now_ts="$(printf '%s\n' "$out" | sed -n 2p)"
if [ -n "$until_ts" ] && [ "$((until_ts - now_ts))" -ge 3600 ] && [ "$((until_ts - now_ts))" -le 7200 ]; then
echo "PASS the install quiets the watchdog for a bounded while"
else
echo "FAIL quiet_watchdog wrote '$until_ts' at $now_ts, want now+1h..2h"; fails=$((fails + 1))
fi
rm -rf "$tdir"
cblock="$(awk '/^cleanup\(\) \{/,/^}/' "$BS")"
case "$cblock" in
*'rm -f -- "$WATCHDOG_QUIET_FILE"'*) echo "PASS the installer lifts the watchdog's quiet period on exit" ;;
*) echo "FAIL cleanup must remove WATCHDOG_QUIET_FILE, or a failed install stays silent"; fails=$((fails + 1)) ;;
esac
main_block="$(awk '/^main\(\) \{/,/^}/' "$BS")"
case "$main_block" in
*quiet_watchdog*pause_package_background_timers*install_db_backup_timer*install_watchdog_timer*mark_bootstrap_done*)
echo "PASS main quiets the watchdog first and installs it last" ;;
*) echo "FAIL main must call quiet_watchdog before any restart and install_watchdog_timer after the backup timer"; fails=$((fails + 1)) ;;
esac
# --------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------
if [ "$fails" -eq 0 ]; then if [ "$fails" -eq 0 ]; then
echo "ALL PASS" echo "ALL PASS"
+96 -10
View File
@@ -964,11 +964,75 @@ space.
--- ---
## 14. Metrics for diagnosis (spec §23) ## 14. Health alerts, and metrics for diagnosis (spec §23)
Every full install runs `felis watchdog` from `felis-watchdog.timer`, which
mails the owners when something breaks, with no monitoring stack needed. The
metrics and Prometheus rules below are for a deployment that also runs its own
Prometheus.
### The watchdog: what mails the owners
Every two minutes the host checks:
| Check | Mailed after | Severity |
|---|---|---|
| `felis-api`, `felis-operator` or `registry` Deployment has no available pod (or is missing) | 5 min | critical |
| Kubernetes API unreachable (k3s down): the cluster checks below are then unknown and keep their state | 5 min | critical |
| Login gate not `Running` while it should be | 10 min | critical |
| Other system servers (lobby) not `Running` while they should be | 10 min | warning |
| A user server in `Failed` (§2) | 5 min | warning |
| A backup, restore or reaper Job failed in the last 24h | at once, once | warning |
| The reaper CronJob last succeeded over 26h ago (§10) | at once | warning |
| Node `NotReady`, or kubelet reports Disk/Memory/PID pressure (§13b) | 2–5 min | critical |
| PostgreSQL unreachable | 3 min | critical |
| The game proxy (`felis-velocity`) refuses connections on the game port | 3 min | critical |
| Newest control-plane database backup over 26h old, or none (§16) | 10 min | critical |
| A watched filesystem below 15% free (below 5%: critical) | 15 min (5 min) | warning |
| Host memory available below 10% | 15 min | warning |
How it mails:
- **Recipients** are the verified email addresses of enabled owner accounts.
The relay is the `[smtp]` one sign-in codes use.
- **One mail per run**, holding everything that came due: new problems, a
daily reminder for each problem still open, and a resolved notice once a
problem has stayed gone for 10 minutes. A condition that heals before its
delay is never mailed; one that turns critical is mailed again at once.
- **During an install** nothing is mailed. `bootstrap.sh` writes
`/run/felis/watchdog-quiet-until` and removes it when it exits.
- **Caching:** the relay password and the recipient list are cached in
`/var/lib/felis/watchdog/state.json` (root-only). An outage of PostgreSQL or
of the API server can therefore still be mailed.
- **No relay or no verified owner address:** each alert is written to the
journal only.
Commands:
```bash
journalctl -u felis-watchdog -n 40 # every check of the latest runs
sudo felis watchdog -dry-run # run the checks now; mail nothing, change nothing
systemctl list-timers felis-watchdog.timer # when it last and next runs
```
A healthy run logs `every check passed`. Otherwise it logs one line per
finding, and the mail's subject once one is sent.
A `-dry-run` from a shell uses the command's defaults, and those do not include
the game-proxy check. The unit carries `-proxy-addr 127.0.0.1:<game port>` and
the disk list the install chose. `systemctl cat felis-watchdog` shows both.
### Metrics
All four mandated metrics have real producers; scrape them when triaging: All four mandated metrics have real producers; scrape them when triaging:
- `felis_servers_total` — managed server count. - `felis_servers_total` — managed server count.
- `felis_server_phase{server,role,phase,desired}` — 1 for each server's
current phase. `role` is `login`/`lobby` for system servers and empty for
user servers. `desired` tells a server that is down on purpose from one that
failed to come up.
- `felis_build_info{component,version}` — 1 on the process serving it
(`operator` or `api`). Its absence is how an alert tells which process is down.
- `felis_start_duration_seconds` — histogram, observed once per start when - `felis_start_duration_seconds` — histogram, observed once per start when
readiness is first reached (`ReadySignalAt − StartRequestedAt`). A start that readiness is first reached (`ReadySignalAt − StartRequestedAt`). A start that
never completes (§1) contributes **nothing** here — absence of observations is never completes (§1) contributes **nothing** here — absence of observations is
@@ -980,12 +1044,16 @@ All four mandated metrics have real producers; scrape them when triaging:
### Scraping ### Scraping
The series come from two processes: The series come from two processes. Both Services carry the
`prometheus.io/scrape|port|path` annotations, so a Prometheus that discovers
annotated Service endpoints picks them up as is.
- `felis-operator` pod `:8080/metrics` — `felis_servers_total`, - `felis-operator` `:8080/metrics` (Service `felis-operator-metrics`) —
`felis_start_duration_seconds` (no Service; scrape pod-scoped, e.g. a `felis_servers_total`, `felis_server_phase`, `felis_start_duration_seconds`,
PodMonitor targeting port `metrics`). `felis_build_info{component="operator"}`, and controller-runtime's
`controller_runtime_reconcile_*` / `workqueue_*` series.
- `felis-api` internal face `:8081/metrics` (Service `felis-api-internal`) — - `felis-api` internal face `:8081/metrics` (Service `felis-api-internal`) —
`felis_build_info{component="api"}`,
`felis_image_build_failures_total`, and the sign-in series of §17 `felis_image_build_failures_total`, and the sign-in series of §17
(`felis_mail_total`, `felis_rate_limited_total`, (`felis_mail_total`, `felis_rate_limited_total`,
`felis_auth_otp_lockouts_total`, `felis_auth_failures_total`, `felis_auth_otp_lockouts_total`, `felis_auth_failures_total`,
@@ -999,11 +1067,27 @@ The series come from two processes:
### Alert rules ### Alert rules
`deploy/alerts/` ships ready-made rules: build failures, slow starts, node `deploy/alerts/` ships ready-made rules for these groups:
disk/memory thresholds, the kubelet `DiskPressure` condition, control-plane
database backup freshness (§16; needs node-exporter's textfile collector), and - **Control plane:** `felis-operator` or `felis-api` down or unscraped
sign-in abuse: the mail budget, relay failures, throttled floods, account (`FelisOperatorDown`, `FelisAPIDown`).
code locks and the refused sign-in rate, plus lost audit rows (§17). - **Servers:** the login gate or another system server not running
(`FelisLoginGateDown`, `FelisSystemServerDown`), and a user server in
`Failed` (`FelisServerFailed`).
- **Operator reconcile:** errors piling up (`FelisReconcileErrors`), and a
pass stuck past its 3-minute bound (`FelisReconcileStuck`). The operator's
liveness probe restarts a pod whose pass passes 10 minutes; its readiness
waits for the informer caches to sync.
- **World Jobs (kube-state-metrics):** a failed backup/restore/reaper Job
(`FelisWorldJobFailed`) and a reaper that has not succeeded in 26h
(`FelisReaperStale`).
- **Builds and starts:** build failures and slow starts.
- **Node:** disk and memory thresholds, and the kubelet `DiskPressure`
condition.
- **Database backups:** control-plane backup freshness (§16; needs
node-exporter's textfile collector).
- **Sign-in abuse (§17):** the mail budget, relay failures, throttled floods,
account code locks and the refused sign-in rate, plus lost audit rows.
- Plain Prometheus: add `felis-alerts.yaml` to `rule_files`. Check and unit-test - Plain Prometheus: add `felis-alerts.yaml` to `rule_files`. Check and unit-test
it standalone with `promtool check rules felis-alerts.yaml` and it standalone with `promtool check rules felis-alerts.yaml` and
@@ -1380,6 +1464,8 @@ for 10 seconds (the Free plan's limits).
| PVC left behind after delete | §13 | | PVC left behind after delete | §13 |
| Node out of disk; pods evicted / ImagePullBackOff | §13b | | Node out of disk; pods evicted / ImagePullBackOff | §13b |
| Which metric to scrape | §14 | | Which metric to scrape | §14 |
| An alert mail from the watchdog; nothing is mailed when something breaks | §14 |
| `FelisOperatorDown` / `FelisAPIDown` / `FelisLoginGateDown` / `FelisReconcileStuck` | §14, §1, §2 |
| Upgrade / roll back a bad control-plane image | §15 | | Upgrade / roll back a bad control-plane image | §15 |
| `image_change_unconfirmed` / `image_not_in_registry` / `registry_unavailable`; move a world to a newer Minecraft | §15b | | `image_change_unconfirmed` / `image_not_in_registry` / `registry_unavailable`; move a world to a newer Minecraft | §15b |
| Database backup overdue / `FelisDBBackupStale` / panel shows 从未备份 | §16 | | Database backup overdue / `FelisDBBackupStale` / panel shows 从未备份 | §16 |
+98
View File
@@ -0,0 +1,98 @@
package metrics
import (
"os"
"reflect"
"regexp"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
"sigs.k8s.io/yaml"
)
// The shipped alert rules live in deploy/alerts. promtool tests what they do
// (felis-alerts_test.yml); these tests pin what promtool cannot see from there.
type ruleGroups struct {
Groups []struct {
Name string `json:"name"`
Rules []struct {
Alert string `json:"alert"`
Expr string `json:"expr"`
} `json:"rules"`
} `json:"groups"`
}
func readYAML(t *testing.T, path string, into any) {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if err := yaml.Unmarshal(raw, into); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
}
// TestPrometheusRuleMatchesPlainRules: the PrometheusRule twin carries exactly
// the groups of the promtool-tested plain file.
func TestPrometheusRuleMatchesPlainRules(t *testing.T) {
var plain, twin map[string]any
readYAML(t, "../../deploy/alerts/felis-alerts.yaml", &plain)
readYAML(t, "../../deploy/alerts/felis-prometheusrule.yaml", &twin)
spec, _ := twin["spec"].(map[string]any)
if !reflect.DeepEqual(plain["groups"], spec["groups"]) {
t.Fatal("deploy/alerts/felis-prometheusrule.yaml spec.groups differs from felis-alerts.yaml groups; copy the plain file's groups over")
}
}
// TestAlertRulesUseRealMetrics: every felis_* series a rule reads is one this
// package exports (or the backup timer's textfile series), so a renamed metric
// cannot leave an alert silently watching nothing.
func TestAlertRulesUseRealMetrics(t *testing.T) {
reg := prometheus.NewRegistry()
if err := Register(reg); err != nil {
t.Fatal(err)
}
families, err := reg.Gather()
if err != nil {
t.Fatal(err)
}
known := map[string]bool{}
for _, mf := range families {
known[mf.GetName()] = true
}
// Collectors with no children yet gather nothing; name them from their
// descriptors instead.
for _, c := range Collectors() {
ch := make(chan *prometheus.Desc, 4)
c.Describe(ch)
close(ch)
for d := range ch {
if m := regexp.MustCompile(`fqName: "([^"]+)"`).FindStringSubmatch(d.String()); m != nil {
known[m[1]] = true
}
}
}
var rules ruleGroups
readYAML(t, "../../deploy/alerts/felis-alerts.yaml", &rules)
series := regexp.MustCompile(`felis_[a-z_]+`)
for _, g := range rules.Groups {
for _, r := range g.Rules {
for _, name := range series.FindAllString(r.Expr, -1) {
if strings.HasPrefix(name, "felis_db_backup_") {
continue // written by felis-db-backup.timer for node-exporter
}
base := name
for _, suffix := range []string{"_bucket", "_count", "_sum"} {
base = strings.TrimSuffix(base, suffix)
}
if !known[name] && !known[base] {
t.Errorf("alert %s reads %s, which no felis collector exports", r.Alert, name)
}
}
}
}
}
+45
View File
@@ -32,6 +32,18 @@ var (
Help: "Current number of Minecraft servers known to the operator, by desired state.", Help: "Current number of Minecraft servers known to the operator, by desired state.",
}, []string{"state"}) }, []string{"state"})
// ServerPhase is 1 for each server's current phase and absent for every other
// phase. role is the server's system role (login, lobby), empty for a user
// server, so an alert can single out the login gate; desired is its
// desiredState, so a server that is down on purpose can be told from one that
// failed to come up. SyncServerPhases republishes it from a full List, so a
// deleted server's series goes away instead of freezing at its last phase.
ServerPhase = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "server_phase",
Help: "1 for each Minecraft server's current phase, by server, system role, phase and desired state.",
}, []string{"server", "role", "phase", "desired"})
// StartDurationSeconds observes the wall-clock time from desiredState=Running // StartDurationSeconds observes the wall-clock time from desiredState=Running
// to a server reporting ready. Buckets are tuned for Minecraft cold starts // to a server reporting ready. Buckets are tuned for Minecraft cold starts
// (seconds to a few minutes), not the default sub-second web-latency buckets. // (seconds to a few minutes), not the default sub-second web-latency buckets.
@@ -107,8 +119,25 @@ var (
Name: "audit_write_failures_total", Name: "audit_write_failures_total",
Help: "Audit rows the API failed to write.", Help: "Audit rows the API failed to write.",
}) })
// BuildInfo is 1 for the process serving it, labelled by component
// ("operator", "api") and version. Both processes register every collector,
// so this is the one series that says which of them a scrape reached: an
// alert on absent(felis_build_info{component="api"}) fires when felis-api is
// down or no longer scraped, where every other felis_* series would still be
// present from the operator.
BuildInfo = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "build_info",
Help: "1 for the Felis component serving these metrics, by component and version.",
}, []string{"component", "version"})
) )
// SetBuildInfo marks this process as component at version on felis_build_info.
func SetBuildInfo(component, version string) {
BuildInfo.WithLabelValues(component, version).Set(1)
}
// OTPPurposes are the email-code doors OTPLockoutsTotal is labelled by. // OTPPurposes are the email-code doors OTPLockoutsTotal is labelled by.
var OTPPurposes = []string{"onboard_email", "login_email", "op_login", "migrate_confirm"} var OTPPurposes = []string{"onboard_email", "login_email", "op_login", "migrate_confirm"}
@@ -149,12 +178,27 @@ func SyncServerGauge(states []string) {
} }
} }
// ServerPhaseSample is one server's felis_server_phase series.
type ServerPhaseSample struct {
Server, Role, Phase, Desired string
}
// SyncServerPhases republishes felis_server_phase from a full snapshot of the
// fleet, Resetting first for the same reason SyncServerGauge does.
func SyncServerPhases(samples []ServerPhaseSample) {
ServerPhase.Reset()
for _, s := range samples {
ServerPhase.WithLabelValues(s.Server, s.Role, s.Phase, s.Desired).Set(1)
}
}
// Collectors returns every felis_* collector in a stable order. Production and // Collectors returns every felis_* collector in a stable order. Production and
// tests register the same slice, so the test asserting the full set is exposed // tests register the same slice, so the test asserting the full set is exposed
// also pins the production surface. // also pins the production surface.
func Collectors() []prometheus.Collector { func Collectors() []prometheus.Collector {
return []prometheus.Collector{ return []prometheus.Collector{
ServersTotal, ServersTotal,
ServerPhase,
StartDurationSeconds, StartDurationSeconds,
ImageBuildFailuresTotal, ImageBuildFailuresTotal,
ReaperWorldsDeletedTotal, ReaperWorldsDeletedTotal,
@@ -164,6 +208,7 @@ func Collectors() []prometheus.Collector {
AuthFailuresTotal, AuthFailuresTotal,
SessionsRevokedTotal, SessionsRevokedTotal,
AuditWriteFailuresTotal, AuditWriteFailuresTotal,
BuildInfo,
} }
} }
+24
View File
@@ -13,9 +13,11 @@ import (
// silent dashboard/alert break. // silent dashboard/alert break.
var wantNames = []string{ var wantNames = []string{
"felis_servers_total", "felis_servers_total",
"felis_server_phase",
"felis_start_duration_seconds", "felis_start_duration_seconds",
"felis_image_build_failures_total", "felis_image_build_failures_total",
"felis_reaper_worlds_deleted_total", "felis_reaper_worlds_deleted_total",
"felis_build_info",
} }
func TestRegisterExposesNamedFelisMetrics(t *testing.T) { func TestRegisterExposesNamedFelisMetrics(t *testing.T) {
@@ -28,6 +30,8 @@ func TestRegisterExposesNamedFelisMetrics(t *testing.T) {
// family — this proves the exported vars are the ones actually registered, // family — this proves the exported vars are the ones actually registered,
// not shadow copies. // not shadow copies.
ServersTotal.WithLabelValues("Running").Set(3) ServersTotal.WithLabelValues("Running").Set(3)
ServerPhase.WithLabelValues("survival", "", "Running", "Running").Set(1)
SetBuildInfo("operator", "v1.2.3")
StartDurationSeconds.Observe(12.5) StartDurationSeconds.Observe(12.5)
ImageBuildFailuresTotal.Inc() ImageBuildFailuresTotal.Inc()
ReaperWorldsDeletedTotal.Add(2) ReaperWorldsDeletedTotal.Add(2)
@@ -54,6 +58,26 @@ func TestRegisterExposesNamedFelisMetrics(t *testing.T) {
} }
} }
// TestSyncServerPhasesDropsDeletedServers: each sync publishes exactly the
// servers in the snapshot, so a server that left the fleet or changed phase keeps
// no stale series behind.
func TestSyncServerPhasesDropsDeletedServers(t *testing.T) {
SyncServerPhases([]ServerPhaseSample{
{Server: "login", Role: "login", Phase: "Starting", Desired: "Running"},
{Server: "survival", Phase: "Running", Desired: "Running"},
})
SyncServerPhases([]ServerPhaseSample{
{Server: "login", Role: "login", Phase: "Running", Desired: "Running"},
})
if n := testutil.CollectAndCount(ServerPhase); n != 1 {
t.Fatalf("series after the second sync = %d, want 1", n)
}
if got := testutil.ToFloat64(ServerPhase.WithLabelValues("login", "login", "Running", "Running")); got != 1 {
t.Errorf("login Running = %v, want 1", got)
}
ServerPhase.Reset()
}
func TestSyncServerGaugeResetsStaleStates(t *testing.T) { func TestSyncServerGaugeResetsStaleStates(t *testing.T) {
// Self-contained (Reset->sync->assert within this one test) so it never // Self-contained (Reset->sync->assert within this one test) so it never
// clobbers another test's ServersTotal children. The correctness point is the // clobbers another test's ServersTotal children. The correctness point is the
+30 -3
View File
@@ -13,8 +13,8 @@ import (
// defaultGaugeInterval is the republish cadence used when none is configured. // defaultGaugeInterval is the republish cadence used when none is configured.
const defaultGaugeInterval = 30 * time.Second const defaultGaugeInterval = 30 * time.Second
// GaugeSyncer periodically republishes felis_servers_total (spec §23) from a // GaugeSyncer periodically republishes felis_servers_total (spec §23) and
// full List of MinecraftServers. // felis_server_phase from a full List of MinecraftServers.
// //
// felis_servers_total is a fleet-wide gauge partitioned by desiredState, which a // felis_servers_total is a fleet-wide gauge partitioned by desiredState, which a
// per-object Reconcile fundamentally cannot maintain: one reconcile observes a // per-object Reconcile fundamentally cannot maintain: one reconcile observes a
@@ -62,7 +62,8 @@ func (g *GaugeSyncer) Start(ctx context.Context) error {
} }
} }
// SyncOnce Lists the fleet once and republishes felis_servers_total from it. // SyncOnce Lists the fleet once and republishes felis_servers_total and
// felis_server_phase from it.
// Separated from Start so the List->translate->gauge path is unit-testable // Separated from Start so the List->translate->gauge path is unit-testable
// against a fake client, leaving only the ticker loop untested. // against a fake client, leaving only the ticker loop untested.
func (g *GaugeSyncer) SyncOnce(ctx context.Context) error { func (g *GaugeSyncer) SyncOnce(ctx context.Context) error {
@@ -71,9 +72,35 @@ func (g *GaugeSyncer) SyncOnce(ctx context.Context) error {
return err return err
} }
metrics.SyncServerGauge(serverStates(list.Items)) metrics.SyncServerGauge(serverStates(list.Items))
metrics.SyncServerPhases(serverPhases(list.Items))
return nil return nil
} }
// serverPhases maps each server to its felis_server_phase labels. A server the
// operator has not reported on yet is Unknown; desiredState defaults as in
// serverStates.
func serverPhases(items []v1alpha1.MinecraftServer) []metrics.ServerPhaseSample {
out := make([]metrics.ServerPhaseSample, 0, len(items))
for i := range items {
ms := &items[i]
phase := string(ms.Status.Phase)
if phase == "" {
phase = string(v1alpha1.PhaseUnknown)
}
desired := string(ms.Spec.DesiredState)
if desired == "" {
desired = string(v1alpha1.DesiredStopped)
}
out = append(out, metrics.ServerPhaseSample{
Server: ms.Name,
Role: ms.Labels[v1alpha1.LabelSystemRole],
Phase: phase,
Desired: desired,
})
}
return out
}
// serverStates maps each server to its desiredState, defaulting an unset state // serverStates maps each server to its desiredState, defaulting an unset state
// to Stopped (the CRD default, spec §4). Pure, so the labelling rule is covered // to Stopped (the CRD default, spec §4). Pure, so the labelling rule is covered
// by SyncOnce's fake-client test rather than only at runtime. // by SyncOnce's fake-client test rather than only at runtime.
+27
View File
@@ -48,3 +48,30 @@ func TestGaugeSyncerPublishesFleetStateFromCluster(t *testing.T) {
t.Errorf("servers_total{state=Stopped} = %v, want 2", got) t.Errorf("servers_total{state=Stopped} = %v, want 2", got)
} }
} }
// TestGaugeSyncerPublishesServerPhases: every server gets one felis_server_phase
// series carrying its system role, observed phase (Unknown before the first
// report) and desired state.
func TestGaugeSyncerPublishesServerPhases(t *testing.T) {
login := gaugeServer("login", v1alpha1.DesiredRunning)
login.Labels = map[string]string{v1alpha1.LabelSystemRole: "login"}
login.Status.Phase = v1alpha1.PhaseFailed
fresh := gaugeServer("fresh", "")
c := fake.NewClientBuilder().WithScheme(newScheme(t)).WithObjects(login, fresh).Build()
if err := (&operator.GaugeSyncer{Client: c}).SyncOnce(context.Background()); err != nil {
t.Fatalf("SyncOnce: %v", err)
}
defer metrics.ServerPhase.Reset()
if n := testutil.CollectAndCount(metrics.ServerPhase); n != 2 {
t.Fatalf("felis_server_phase series = %d, want 2", n)
}
for _, labels := range [][]string{
{"login", "login", "Failed", "Running"},
{"fresh", "", "Unknown", "Stopped"},
} {
if got := testutil.ToFloat64(metrics.ServerPhase.WithLabelValues(labels...)); got != 1 {
t.Errorf("felis_server_phase%v = %v, want 1", labels, got)
}
}
}
+13 -1
View File
@@ -78,6 +78,8 @@ type Reconciler struct {
// (internal/maintenance). It is the manager's uncached API reader, so the // (internal/maintenance). It is the manager's uncached API reader, so the
// operator needs jobs:list and no Job informer. Nil skips the check. // operator needs jobs:list and no Job informer. Nil skips the check.
Jobs client.Reader Jobs client.Reader
// Watch records the passes in flight for the liveness probe. Nil skips it.
Watch *ReconcileWatch
} }
func (r *Reconciler) now() metav1.Time { func (r *Reconciler) now() metav1.Time {
@@ -109,8 +111,18 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error {
// The same server is never reconciled twice at once regardless. // The same server is never reconciled twice at once regardless.
const maxConcurrentReconciles = 4 const maxConcurrentReconciles = 4
// Reconcile drives a single MinecraftServer toward spec.desiredState. // Reconcile drives a single MinecraftServer toward spec.desiredState. One pass
// is bounded by reconcileTimeout and reported to r.Watch while it runs.
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
ctx, cancel := context.WithTimeout(ctx, reconcileTimeout)
defer cancel()
if r.Watch != nil {
defer r.Watch.begin(req.Name)()
}
return r.reconcile(ctx, req)
}
func (r *Reconciler) reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var server v1alpha1.MinecraftServer var server v1alpha1.MinecraftServer
if err := r.Get(ctx, req.NamespacedName, &server); err != nil { if err := r.Get(ctx, req.NamespacedName, &server); err != nil {
// Deletion is handled by owner references on the children. // Deletion is handled by owner references on the children.
+91
View File
@@ -0,0 +1,91 @@
package operator
import (
"fmt"
"net/http"
"sync"
"time"
)
// reconcileTimeout bounds one reconcile pass. The slowest legitimate pass waits
// on RCON: 5s for a probe, defaultSaveTimeout for the world save ahead of a stop.
// A pass still running past this is waiting on something that will not answer;
// cancelling its context makes the calls that honour it return, and the server
// is retried with backoff.
const reconcileTimeout = 3 * time.Minute
// stuckReconcileAfter is how long one pass may run before the operator reports
// itself unhealthy. It is well past reconcileTimeout, so only a pass blocked in a
// call that ignores its context gets here. Each such pass holds one of the
// maxConcurrentReconciles workers for good; a few of them stall every server's
// start and stop while the Pod still looks healthy. Failing the liveness probe
// gets the operator restarted, the one fix for a goroutine that never returns.
const stuckReconcileAfter = 10 * time.Minute
// ReconcileWatch tracks the reconcile passes in flight so the liveness probe can
// tell a busy operator from a wedged one. An idle operator has nothing in flight
// and is healthy: a quiet fleet reconciles rarely, so the time since the last
// pass says nothing about whether the next one would run.
type ReconcileWatch struct {
// StuckAfter defaults to stuckReconcileAfter.
StuckAfter time.Duration
// Now defaults to time.Now.
Now func() time.Time
mu sync.Mutex
next uint64
inflight map[uint64]inflightPass
}
type inflightPass struct {
server string
started time.Time
}
func (w *ReconcileWatch) now() time.Time {
if w.Now != nil {
return w.Now()
}
return time.Now()
}
// begin records a pass for server and returns the func that ends it.
func (w *ReconcileWatch) begin(server string) func() {
w.mu.Lock()
defer w.mu.Unlock()
if w.inflight == nil {
w.inflight = map[uint64]inflightPass{}
}
w.next++
id := w.next
w.inflight[id] = inflightPass{server: server, started: w.now()}
return func() {
w.mu.Lock()
delete(w.inflight, id)
w.mu.Unlock()
}
}
// Check is a healthz.Checker: it fails while any pass has been running longer
// than StuckAfter, naming the server and how long.
func (w *ReconcileWatch) Check(_ *http.Request) error {
limit := w.StuckAfter
if limit <= 0 {
limit = stuckReconcileAfter
}
now := w.now()
w.mu.Lock()
defer w.mu.Unlock()
var oldest *inflightPass
for id := range w.inflight {
p := w.inflight[id]
if oldest == nil || p.started.Before(oldest.started) {
oldest = &p
}
}
if oldest != nil && now.Sub(oldest.started) > limit {
return fmt.Errorf("reconcile of %s has been running for %s (limit %s)",
oldest.server, now.Sub(oldest.started).Truncate(time.Second), limit)
}
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package operator
import (
"context"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
)
// TestReconcileWatchCheck: an idle or busy operator is healthy; one pass running
// past the limit fails the check and names the server; the check recovers once
// that pass returns.
func TestReconcileWatchCheck(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
w := &ReconcileWatch{StuckAfter: 10 * time.Minute, Now: func() time.Time { return now }}
if err := w.Check(nil); err != nil {
t.Fatalf("idle: %v", err)
}
endOld := w.begin("survival")
now = now.Add(9 * time.Minute)
endNew := w.begin("creative")
if err := w.Check(nil); err != nil {
t.Fatalf("9m in: %v", err)
}
now = now.Add(2 * time.Minute)
err := w.Check(nil)
if err == nil || !strings.Contains(err.Error(), "survival") || !strings.Contains(err.Error(), "11m0s") {
t.Fatalf("11m in: err = %v, want survival stuck for 11m0s", err)
}
endOld()
if err := w.Check(nil); err != nil {
t.Fatalf("after the stuck pass returned: %v", err)
}
endNew()
}
// TestReconcileBoundedAndWatched: Reconcile hands the pass a deadline and holds
// an in-flight entry exactly while it runs.
func TestReconcileBoundedAndWatched(t *testing.T) {
scheme := runtime.NewScheme()
if err := v1alpha1.AddToScheme(scheme); err != nil {
t.Fatal(err)
}
watch := &ReconcileWatch{}
var deadline time.Duration
var inflight int
cl := fake.NewClientBuilder().WithScheme(scheme).WithInterceptorFuncs(interceptor.Funcs{
Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if d, ok := ctx.Deadline(); ok && deadline == 0 {
deadline = time.Until(d)
}
watch.mu.Lock()
inflight = len(watch.inflight)
watch.mu.Unlock()
return c.Get(ctx, key, obj, opts...)
},
}).Build()
r := &Reconciler{Client: cl, Scheme: scheme, Watch: watch}
if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Namespace: "minecraft", Name: "missing"}}); err != nil {
t.Fatalf("Reconcile: %v", err)
}
if deadline <= 0 || deadline > reconcileTimeout {
t.Errorf("pass deadline = %s, want within %s", deadline, reconcileTimeout)
}
if inflight != 1 {
t.Errorf("in-flight passes during the pass = %d, want 1", inflight)
}
if n := len(watch.inflight); n != 0 {
t.Errorf("in-flight passes after the pass = %d, want 0", n)
}
}
+44 -3
View File
@@ -173,6 +173,21 @@ const (
// DNS; the on-node break-glass console resolves its ClusterIP and dials it directly. // DNS; the on-node break-glass console resolves its ClusterIP and dials it directly.
const APIInternalServiceName = SAAPI + "-internal" const APIInternalServiceName = SAAPI + "-internal"
// OperatorMetricsServiceName is the ClusterIP Service in front of the operator's
// /metrics, the scrape target for felis_servers_total, felis_server_phase,
// felis_start_duration_seconds and controller-runtime's reconcile series.
const OperatorMetricsServiceName = SAOperator + "-metrics"
// scrapeAnnotations mark a Service for a Prometheus that discovers targets with
// the common prometheus.io/* convention (kubernetes_sd role: endpoints).
func scrapeAnnotations(port int32) map[string]string {
return map[string]string{
"prometheus.io/scrape": "true",
"prometheus.io/port": fmt.Sprint(port),
"prometheus.io/path": "/metrics",
}
}
// APIInternalPort is the felis-api internal-face port, exported for the on-node // APIInternalPort is the felis-api internal-face port, exported for the on-node
// console which builds http://<clusterIP>:APIInternalPort after a Service lookup. // console which builds http://<clusterIP>:APIInternalPort after a Service lookup.
const APIInternalPort = apiInternalPort const APIInternalPort = apiInternalPort
@@ -206,6 +221,7 @@ func Workloads(p Params) []Object {
apiService(p), apiService(p),
apiInternalService(p), apiInternalService(p),
OperatorDeployment(p), OperatorDeployment(p),
operatorMetricsService(p),
registryDeployment(p), registryDeployment(p),
registryService(p), registryService(p),
registryPVC(p), registryPVC(p),
@@ -431,7 +447,9 @@ func apiInternalService(p Params) *corev1.Service {
labels := controlPlanePodLabels(ComponentAPI) labels := controlPlanePodLabels(ComponentAPI)
return &corev1.Service{ return &corev1.Service{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"}, TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
ObjectMeta: metav1.ObjectMeta{Name: APIInternalServiceName, Namespace: p.ControlNamespace, Labels: labels}, // The internal face also serves /metrics (felis-api's felis_* series).
ObjectMeta: metav1.ObjectMeta{Name: APIInternalServiceName, Namespace: p.ControlNamespace, Labels: labels,
Annotations: scrapeAnnotations(apiInternalPort)},
Spec: corev1.ServiceSpec{ Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP, Type: corev1.ServiceTypeClusterIP,
Selector: labels, Selector: labels,
@@ -445,6 +463,28 @@ func apiInternalService(p Params) *corev1.Service {
} }
} }
// operatorMetricsService gives the operator's metrics listener a stable scrape
// target. ClusterIP only: /metrics is unauthenticated, like felis-api's.
func operatorMetricsService(p Params) *corev1.Service {
p = p.withDefaults()
labels := controlPlanePodLabels(ComponentOperator)
return &corev1.Service{
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
ObjectMeta: metav1.ObjectMeta{Name: OperatorMetricsServiceName, Namespace: p.ControlNamespace, Labels: labels,
Annotations: scrapeAnnotations(operatorMetricsPort)},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
Selector: labels,
Ports: []corev1.ServicePort{{
Name: "metrics",
Port: operatorMetricsPort,
TargetPort: intstr.FromString("metrics"),
Protocol: corev1.ProtocolTCP,
}},
},
}
}
// OperatorDeployment renders the felis-operator Deployment (spec §5). It runs as // OperatorDeployment renders the felis-operator Deployment (spec §5). It runs as
// the felis-operator SA and carries controlPlanePodLabels(operator), the second // the felis-operator SA and carries controlPlanePodLabels(operator), the second
// pod the allow-rcon peer admits (the readiness prober dials RCON). It takes NO // pod the allow-rcon peer admits (the readiness prober dials RCON). It takes NO
@@ -478,8 +518,9 @@ func OperatorDeployment(p Params) *appsv1.Deployment {
{Name: tmpVolume, MountPath: "/tmp"}, {Name: tmpVolume, MountPath: "/tmp"},
}, },
// controller-runtime serves /healthz and /readyz on the health listener // controller-runtime serves /healthz and /readyz on the health listener
// (both registered as always-pass pings in cmd/felis/operator.go): the // (cmd/felis/operator.go): /healthz fails while a reconcile pass is stuck,
// probe's contract is "the manager process is up", not a dependency check. // so liveness restarts a wedged operator; /readyz waits for the informer
// caches. Neither checks a dependency, so an API blip restarts nothing.
ReadinessProbe: &corev1.Probe{ ReadinessProbe: &corev1.Probe{
ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{ ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{
Path: "/readyz", Port: intstr.FromInt32(operatorHealthPort), Path: "/readyz", Port: intstr.FromInt32(operatorHealthPort),
+36 -3
View File
@@ -608,13 +608,14 @@ func TestWorkloads_DeploymentsCarryProbes(t *testing.T) {
} }
// TestWorkloads_BundleContents sanity-checks the slice Workloads returns: the two // TestWorkloads_BundleContents sanity-checks the slice Workloads returns: the two
// control-plane Deployments, the api external+internal Services, and the registry // control-plane Deployments, the api external+internal Services, the operator
// metrics Service, and the registry
// Deployment/Service/PVC, every one with TypeMeta (so its YAML header renders). The // Deployment/Service/PVC, every one with TypeMeta (so its YAML header renders). The
// internal Service must be present or the login pod's felis-api:8081 path is dead. // internal Service must be present or the login pod's felis-api:8081 path is dead.
func TestWorkloads_BundleContents(t *testing.T) { func TestWorkloads_BundleContents(t *testing.T) {
objs := Workloads(testParams()) objs := Workloads(testParams())
if len(objs) != 8 { if len(objs) != 9 {
t.Fatalf("Workloads returned %d objects, want 8", len(objs)) t.Fatalf("Workloads returned %d objects, want 9", len(objs))
} }
var haveInternalSvc bool var haveInternalSvc bool
for _, o := range objs { for _, o := range objs {
@@ -1014,3 +1015,35 @@ func TestWorldsRootStaticPV(t *testing.T) {
} }
} }
} }
// TestOperatorMetricsService: the operator's /metrics has a ClusterIP scrape
// target selecting the operator pods on their "metrics" port, marked for
// prometheus.io discovery like the api's internal face.
func TestOperatorMetricsService(t *testing.T) {
p := testParams()
svc := operatorMetricsService(p)
dep := OperatorDeployment(p)
if svc.Name != OperatorMetricsServiceName || svc.Namespace != p.ControlNamespace || svc.Spec.Type != corev1.ServiceTypeClusterIP {
t.Fatalf("Service = %s/%s %s, want %s/%s ClusterIP", svc.Namespace, svc.Name, svc.Spec.Type, p.ControlNamespace, OperatorMetricsServiceName)
}
if !mapSelectorMatches(svc.Spec.Selector, dep.Spec.Template.Labels) {
t.Errorf("selector %v does not select operator pod labels %v", svc.Spec.Selector, dep.Spec.Template.Labels)
}
if len(svc.Spec.Ports) != 1 || svc.Spec.Ports[0].TargetPort.StrVal != "metrics" || svc.Spec.Ports[0].Port != operatorMetricsPort {
t.Errorf("ports = %+v, want %d -> metrics", svc.Spec.Ports, operatorMetricsPort)
}
for _, s := range []*corev1.Service{svc, apiInternalService(p)} {
if s.Annotations["prometheus.io/scrape"] != "true" || s.Annotations["prometheus.io/port"] != fmt.Sprint(s.Spec.Ports[0].Port) {
t.Errorf("%s annotations = %v, want prometheus.io scrape on its port", s.Name, s.Annotations)
}
}
found := false
for _, o := range Workloads(p) {
if s, ok := o.(*corev1.Service); ok && s.Name == OperatorMetricsServiceName {
found = true
}
}
if !found {
t.Error("Workloads does not render the operator metrics Service")
}
}
+425
View File
@@ -0,0 +1,425 @@
package watchdog
import (
"bufio"
"context"
"fmt"
"os"
"strconv"
"strings"
"syscall"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/dbbackup"
"felis.lolicon.best/internal/naming"
"felis.lolicon.best/internal/platform"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
)
// Thresholds. The durations are how long a condition must hold before it is
// mailed (Finding.For): long enough for a rollout, a restart or an installer
// run to heal it, short enough that players are not the first to notice.
const (
controlPlaneFor = 5 * time.Minute
systemServerFor = 10 * time.Minute
failedServerFor = 5 * time.Minute
nodeNotReadyFor = 5 * time.Minute
nodePressureFor = 2 * time.Minute
postgresFor = 3 * time.Minute
kubeAPIFor = 5 * time.Minute
backupFor = 10 * time.Minute
diskLowFor = 15 * time.Minute
diskCriticalFor = 5 * time.Minute
memoryLowFor = 15 * time.Minute
// jobFailureWindow is how far back a failed Job is still news.
jobFailureWindow = 24 * time.Hour
// maxBackupAge is the daily database backup's deadline: a day plus the
// timer's randomized delay and a margin.
maxBackupAge = 26 * time.Hour
// maxReaperAge is the same for the daily reaper CronJob.
maxReaperAge = 26 * time.Hour
diskLowRatio = 0.15
diskCriticalRatio = 0.05
memoryLowRatio = 0.10
)
// controlDeployments are the control-plane Deployments, with what their outage
// takes down.
var controlDeployments = []struct {
name, impact, impactEN string
}{
{platform.SAAPI, "面板、登录验证和内部接口都不可用", "the panel, sign-in and the internal API are down"},
{platform.SAOperator, "服务器无法启动、停止或更新状态", "no server can start, stop or report status"},
{"registry", "游戏镜像拉取和构建都会失败", "game image pulls and builds fail"},
}
// ClusterPrefixes are the keys Cluster.Check produces. When the API server
// cannot be reached, alerts under them keep their state (Report.Unknown).
var ClusterPrefixes = []string{"deployment/", "system-server/", "server-failed/", "job-failed/", "reaper-stale", "node/"}
// Cluster checks the Kubernetes side: the control plane, the system servers,
// the fleet, recent Job failures, the reaper's schedule and the nodes.
type Cluster struct {
Client client.Client
ControlNamespace string
MinecraftNamespace string
}
// Check returns the cluster's findings. An error means the API server could
// not answer; the caller reports that and treats every cluster check as unknown.
func (c Cluster) Check(ctx context.Context, now time.Time) ([]Finding, error) {
var out []Finding
for _, d := range controlDeployments {
f, err := c.deployment(ctx, d.name, d.impact, d.impactEN)
if err != nil {
return nil, err
}
if f != nil {
out = append(out, *f)
}
}
var servers v1alpha1.MinecraftServerList
if err := c.Client.List(ctx, &servers, client.InNamespace(c.MinecraftNamespace)); err != nil {
return nil, fmt.Errorf("list servers: %w", err)
}
for i := range servers.Items {
if f := serverFinding(&servers.Items[i], c.MinecraftNamespace); f != nil {
out = append(out, *f)
}
}
var jobs batchv1.JobList
if err := c.Client.List(ctx, &jobs, client.InNamespace(c.MinecraftNamespace)); err != nil {
return nil, fmt.Errorf("list jobs: %w", err)
}
for i := range jobs.Items {
if f := jobFinding(&jobs.Items[i], now); f != nil {
out = append(out, *f)
}
}
var cj batchv1.CronJob
switch err := c.Client.Get(ctx, client.ObjectKey{Namespace: c.MinecraftNamespace, Name: platform.SAReaper}, &cj); {
case apierrors.IsNotFound(err):
// Retention is off; there is no reaper to be late.
case err != nil:
return nil, fmt.Errorf("get reaper cronjob: %w", err)
default:
if f := reaperFinding(&cj, now); f != nil {
out = append(out, *f)
}
}
var nodes corev1.NodeList
if err := c.Client.List(ctx, &nodes); err != nil {
return nil, fmt.Errorf("list nodes: %w", err)
}
for i := range nodes.Items {
out = append(out, nodeFindings(&nodes.Items[i])...)
}
return out, nil
}
func (c Cluster) deployment(ctx context.Context, name, impact, impactEN string) (*Finding, error) {
var d appsv1.Deployment
err := c.Client.Get(ctx, client.ObjectKey{Namespace: c.ControlNamespace, Name: name}, &d)
if apierrors.IsNotFound(err) {
return &Finding{
Key: "deployment/" + name, Severity: Critical, For: controlPlaneFor,
Summary: fmt.Sprintf("控制面组件 %s 不存在:%s", name, impact),
SummaryEN: fmt.Sprintf("control-plane Deployment %s is missing: %s", name, impactEN),
Hint: "re-run the installer, or `sudo felis apply`, to recreate it",
}, nil
}
if err != nil {
return nil, fmt.Errorf("get deployment %s: %w", name, err)
}
if d.Status.AvailableReplicas > 0 {
return nil, nil
}
want := int32(1)
if d.Spec.Replicas != nil {
want = *d.Spec.Replicas
}
return &Finding{
Key: "deployment/" + name, Severity: Critical, For: controlPlaneFor,
Summary: fmt.Sprintf("控制面组件 %s 没有可用副本(%d/%d 就绪):%s", name, d.Status.ReadyReplicas, want, impact),
SummaryEN: fmt.Sprintf("control-plane Deployment %s has no available replica (%d/%d ready): %s", name, d.Status.ReadyReplicas, want, impactEN),
Hint: fmt.Sprintf("kubectl -n %s get pods; kubectl -n %s logs deploy/%s --all-containers", c.ControlNamespace, c.ControlNamespace, name),
}, nil
}
// serverFinding reports a system server that should run and does not, and a
// user server that failed. A user server that is merely stopped or starting is
// its owner's business.
func serverFinding(ms *v1alpha1.MinecraftServer, ns string) *Finding {
phase := ms.Status.Phase
if phase == "" {
phase = v1alpha1.PhaseUnknown
}
why := failureMessage(ms)
if role := ms.Labels[v1alpha1.LabelSystemRole]; role != "" {
if ms.Spec.DesiredState != v1alpha1.DesiredRunning || phase == v1alpha1.PhaseRunning {
return nil
}
f := &Finding{
Key: "system-server/" + ms.Name, Severity: Warning, For: systemServerFor,
Summary: fmt.Sprintf("系统服务器 %s 处于 %s(应为 Running)%s", ms.Name, phase, why),
SummaryEN: fmt.Sprintf("system server %s is %s, not Running%s", ms.Name, phase, why),
Hint: fmt.Sprintf("kubectl -n %s describe minecraftserver %s; docs/troubleshooting.md §1-§2", ns, ms.Name),
}
if role == naming.SystemLoginServer {
f.Severity = Critical
f.Summary = fmt.Sprintf("登录门 %s 处于 %s(应为 Running),玩家无法进服%s", ms.Name, phase, why)
f.SummaryEN = fmt.Sprintf("login gate %s is %s, not Running: players cannot join%s", ms.Name, phase, why)
}
return f
}
if phase != v1alpha1.PhaseFailed {
return nil
}
return &Finding{
Key: "server-failed/" + ms.Name, Severity: Warning, For: failedServerFor,
Summary: fmt.Sprintf("服务器 %s 启动失败(Failed)%s", ms.Name, why),
SummaryEN: fmt.Sprintf("server %s is Failed%s", ms.Name, why),
Hint: fmt.Sprintf("kubectl -n %s describe minecraftserver %s; docs/troubleshooting.md §2", ns, ms.Name),
}
}
// failureMessage is the first false condition's message, as ": <message>".
func failureMessage(ms *v1alpha1.MinecraftServer) string {
for _, c := range ms.Status.Conditions {
if c.Status == "False" && c.Message != "" {
return ": " + c.Message
}
}
return ""
}
// jobFinding reports a Job in the minecraft namespace (a world backup, a
// restore, a reaper run) that failed within jobFailureWindow, once.
func jobFinding(j *batchv1.Job, now time.Time) *Finding {
for _, c := range j.Status.Conditions {
if c.Type != batchv1.JobFailed || c.Status != corev1.ConditionTrue {
continue
}
if now.Sub(c.LastTransitionTime.Time) > jobFailureWindow {
return nil
}
kind, kindEN := "任务", "Job"
switch {
case strings.HasPrefix(j.Name, "backup-"):
kind, kindEN = "世界备份任务", "world backup Job"
case strings.HasPrefix(j.Name, "restore-"):
kind, kindEN = "世界恢复任务", "world restore Job"
case strings.HasPrefix(j.Name, platform.SAReaper+"-"):
kind, kindEN = "世界回收任务", "world reaper Job"
}
reason := ""
if c.Reason != "" {
reason = " (" + c.Reason + ")"
}
return &Finding{
Key: "job-failed/" + j.Name, Severity: Warning, Event: true,
Summary: fmt.Sprintf("%s %s 失败%s", kind, j.Name, reason),
SummaryEN: fmt.Sprintf("%s %s failed%s", kindEN, j.Name, reason),
Hint: fmt.Sprintf("kubectl -n %s logs job/%s", j.Namespace, j.Name),
}
}
return nil
}
// reaperFinding reports a reaper CronJob that has not succeeded for over a day:
// idle worlds are neither backed up nor released, and expired backups pile up.
func reaperFinding(cj *batchv1.CronJob, now time.Time) *Finding {
if cj.Spec.Suspend != nil && *cj.Spec.Suspend {
return nil
}
last := cj.CreationTimestamp.Time
if cj.Status.LastSuccessfulTime != nil {
last = cj.Status.LastSuccessfulTime.Time
}
if now.Sub(last) <= maxReaperAge {
return nil
}
return &Finding{
Key: "reaper-stale", Severity: Warning,
Summary: fmt.Sprintf("世界回收任务已超过 %s 没有成功运行", roundHours(now.Sub(last))),
SummaryEN: fmt.Sprintf("the world reaper has not succeeded for %s", roundHours(now.Sub(last))),
Hint: fmt.Sprintf("kubectl -n %s get jobs; docs/troubleshooting.md §10", cj.Namespace),
}
}
// nodeFindings reports a node that is not Ready or is under pressure: the
// kubelet is about to evict pods, or already is.
func nodeFindings(n *corev1.Node) []Finding {
var out []Finding
for _, c := range n.Status.Conditions {
switch {
case c.Type == corev1.NodeReady && c.Status != corev1.ConditionTrue:
out = append(out, Finding{
Key: "node/" + n.Name + "/NotReady", Severity: Critical, For: nodeNotReadyFor,
Summary: fmt.Sprintf("节点 %s 未就绪:%s", n.Name, c.Message),
SummaryEN: fmt.Sprintf("node %s is not Ready: %s", n.Name, c.Message),
Hint: "systemctl status k3s; journalctl -u k3s -n 200",
})
case c.Type != corev1.NodeReady && c.Status == corev1.ConditionTrue &&
(c.Type == corev1.NodeDiskPressure || c.Type == corev1.NodeMemoryPressure || c.Type == corev1.NodePIDPressure):
out = append(out, Finding{
Key: "node/" + n.Name + "/" + string(c.Type), Severity: Critical, For: nodePressureFor,
Summary: fmt.Sprintf("节点 %s 报告 %s,kubelet 正在驱逐 Pod", n.Name, c.Type),
SummaryEN: fmt.Sprintf("node %s reports %s: the kubelet is evicting pods", n.Name, c.Type),
Hint: "docs/troubleshooting.md §13b",
})
}
}
return out
}
// KubeAPIDown is the finding for an API server that did not answer.
func KubeAPIDown(err error) Finding {
return Finding{
Key: "kube-api", Severity: Critical, For: kubeAPIFor,
Summary: "Kubernetes API 无法访问:面板、登录和所有服务器的状态都无法确认",
SummaryEN: "the Kubernetes API is unreachable: the panel, sign-in and every server are in doubt",
Hint: fmt.Sprintf("systemctl status k3s; journalctl -u k3s -n 200 (%v)", err),
}
}
// PostgresDown is the finding for a database that did not answer.
func PostgresDown(err error) Finding {
return Finding{
Key: "postgres", Severity: Critical, For: postgresFor,
Summary: "PostgreSQL 无法连接:登录、面板和服务器管理都会失败",
SummaryEN: "PostgreSQL is unreachable: sign-in, the panel and server management fail",
Hint: fmt.Sprintf("systemctl status postgresql; journalctl -u postgresql -n 100 (%v)", err),
}
}
// BackupFinding reports a control-plane database backup older than a day, or
// none at all, in dir.
func BackupFinding(dir string, now time.Time) *Finding {
bundles, err := dbbackup.List(dir)
if err != nil {
return &Finding{
Key: "db-backup", Severity: Critical, For: backupFor,
Summary: fmt.Sprintf("无法读取数据库备份目录 %s", dir),
SummaryEN: fmt.Sprintf("cannot read the database backup directory %s: %v", dir, err),
Hint: "docs/troubleshooting.md §16",
}
}
if len(bundles) > 0 && now.Sub(bundles[0].Created) <= maxBackupAge {
return nil
}
f := &Finding{
Key: "db-backup", Severity: Critical, For: backupFor,
Summary: fmt.Sprintf("%s 里没有任何控制面数据库备份", dir),
SummaryEN: fmt.Sprintf("no control-plane database backup in %s", dir),
Hint: "journalctl -u felis-db-backup -n 50; take one now with `sudo felis db backup` (docs/troubleshooting.md §16)",
}
if len(bundles) > 0 {
age := roundHours(now.Sub(bundles[0].Created))
f.Summary = fmt.Sprintf("最新的控制面数据库备份已是 %s 前(%s)", age, bundles[0].Name)
f.SummaryEN = fmt.Sprintf("the newest control-plane database backup is %s old (%s)", age, bundles[0].Name)
}
return f
}
// DiskFindings reports each filesystem under paths that is running out of
// space. Paths on one filesystem are reported once, under the first of them; a
// path that does not exist is skipped (a feature that is not in use).
func DiskFindings(paths []string) []Finding {
var out []Finding
seen := map[uint64]bool{}
for _, p := range paths {
var st syscall.Stat_t
if err := syscall.Stat(p, &st); err != nil {
continue
}
dev := uint64(st.Dev) // int32 on darwin
if seen[dev] {
continue
}
seen[dev] = true
var fs syscall.Statfs_t
if err := syscall.Statfs(p, &fs); err != nil || fs.Blocks == 0 {
continue
}
free := float64(fs.Bavail) / float64(fs.Blocks)
bsize := uint64(fs.Bsize) // uint32 on darwin
avail := humanBytes(uint64(fs.Bavail) * bsize)
switch {
case free < diskCriticalRatio:
out = append(out, Finding{
Key: "disk/" + p, Severity: Critical, For: diskCriticalFor,
Summary: fmt.Sprintf("%s 所在磁盘只剩 %.1f%%(%s):kubelet 即将驱逐游戏服务器", p, free*100, avail),
SummaryEN: fmt.Sprintf("the filesystem holding %s is %.1f%% free (%s): the kubelet is about to evict game servers", p, free*100, avail),
Hint: "docs/troubleshooting.md §13b",
})
case free < diskLowRatio:
out = append(out, Finding{
Key: "disk/" + p, Severity: Warning, For: diskLowFor,
Summary: fmt.Sprintf("%s 所在磁盘只剩 %.1f%%(%s)", p, free*100, avail),
SummaryEN: fmt.Sprintf("the filesystem holding %s is %.1f%% free (%s)", p, free*100, avail),
Hint: "docs/troubleshooting.md §13b",
})
}
}
return out
}
// MemoryFinding reports sustained low available memory from a /proc/meminfo
// style file. A file that cannot be read (not Linux) reports nothing.
func MemoryFinding(meminfo string) *Finding {
f, err := os.Open(meminfo)
if err != nil {
return nil
}
defer f.Close()
vals := map[string]uint64{}
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 2 {
continue
}
if v, err := strconv.ParseUint(fields[1], 10, 64); err == nil {
vals[strings.TrimSuffix(fields[0], ":")] = v * 1024
}
}
total, avail := vals["MemTotal"], vals["MemAvailable"]
if total == 0 || float64(avail)/float64(total) >= memoryLowRatio {
return nil
}
return &Finding{
Key: "memory", Severity: Warning, For: memoryLowFor,
Summary: fmt.Sprintf("主机可用内存只剩 %s / %s:有 OOM 风险", humanBytes(avail), humanBytes(total)),
SummaryEN: fmt.Sprintf("host memory available is %s of %s: processes risk being OOM-killed", humanBytes(avail), humanBytes(total)),
Hint: "PostgreSQL, the control plane, the registry and game servers share this node; stop idle servers or lower their memory",
}
}
func roundHours(d time.Duration) string {
return d.Round(time.Hour).String()
}
func humanBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
+187
View File
@@ -0,0 +1,187 @@
package watchdog
import (
"context"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/dbbackup"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
func scheme(t *testing.T) *runtime.Scheme {
t.Helper()
s := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(s); err != nil {
t.Fatal(err)
}
if err := v1alpha1.AddToScheme(s); err != nil {
t.Fatal(err)
}
return s
}
func deploy(name string, available int32) *appsv1.Deployment {
d := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "felis"}}
d.Status.AvailableReplicas = available
return d
}
func server(name, role string, desired v1alpha1.DesiredState, phase v1alpha1.Phase) *v1alpha1.MinecraftServer {
ms := &v1alpha1.MinecraftServer{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "minecraft"}}
if role != "" {
ms.Labels = map[string]string{v1alpha1.LabelSystemRole: role}
}
ms.Spec.DesiredState = desired
ms.Status.Phase = phase
return ms
}
func failedJob(name string, at time.Time) *batchv1.Job {
j := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "minecraft"}}
j.Status.Conditions = []batchv1.JobCondition{{
Type: batchv1.JobFailed, Status: corev1.ConditionTrue, Reason: "BackoffLimitExceeded",
LastTransitionTime: metav1.NewTime(at),
}}
return j
}
func findingKeys(fs []Finding) []string {
var out []string
for _, f := range fs {
out = append(out, f.Key)
}
sort.Strings(out)
return out
}
// TestClusterCheck: exactly the broken things are reported — a control-plane
// Deployment down or missing, the login gate not running, a failed user server,
// a recent failed backup Job, a late reaper and a node under disk pressure —
// and the healthy or deliberate states are not.
func TestClusterCheck(t *testing.T) {
now := t0
login := server("login", "login", v1alpha1.DesiredRunning, v1alpha1.PhaseStarting)
login.Status.Conditions = []metav1.Condition{{Type: "Ready", Status: "False", Message: "rcon unreachable"}}
reaper := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Name: "felis-reaper", Namespace: "minecraft",
CreationTimestamp: metav1.NewTime(now.Add(-72 * time.Hour))}}
reaper.Status.LastSuccessfulTime = &metav1.Time{Time: now.Add(-50 * time.Hour)}
node := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "n1"}}
node.Status.Conditions = []corev1.NodeCondition{
{Type: corev1.NodeReady, Status: corev1.ConditionTrue},
{Type: corev1.NodeDiskPressure, Status: corev1.ConditionTrue},
{Type: corev1.NodeMemoryPressure, Status: corev1.ConditionFalse},
}
cl := fake.NewClientBuilder().WithScheme(scheme(t)).WithObjects(
deploy("felis-api", 1), deploy("felis-operator", 0),
login,
server("lobby", "lobby", v1alpha1.DesiredRunning, v1alpha1.PhaseRunning),
server("halted", "lobby", v1alpha1.DesiredStopped, v1alpha1.PhaseStopped),
server("broken", "", v1alpha1.DesiredRunning, v1alpha1.PhaseFailed),
server("asleep", "", v1alpha1.DesiredStopped, v1alpha1.PhaseStopped),
failedJob("backup-survival-abc", now.Add(-time.Hour)),
failedJob("backup-old-abc", now.Add(-48*time.Hour)),
reaper, node,
).Build()
got, err := Cluster{Client: cl, ControlNamespace: "felis", MinecraftNamespace: "minecraft"}.Check(context.Background(), now)
if err != nil {
t.Fatalf("Check: %v", err)
}
want := []string{
"deployment/felis-operator", "deployment/registry",
"job-failed/backup-survival-abc", "node/n1/DiskPressure", "reaper-stale",
"server-failed/broken", "system-server/login",
}
if strings.Join(findingKeys(got), ",") != strings.Join(want, ",") {
t.Fatalf("findings = %v, want %v", findingKeys(got), want)
}
for _, f := range got {
switch f.Key {
case "system-server/login":
if f.Severity != Critical || !strings.Contains(f.Summary, "rcon unreachable") || !strings.Contains(f.SummaryEN, "players cannot join") {
t.Errorf("login finding = %+v", f)
}
case "deployment/registry":
if !strings.Contains(f.SummaryEN, "missing") {
t.Errorf("registry finding = %+v, want missing", f)
}
case "job-failed/backup-survival-abc":
if !f.Event || !strings.Contains(f.SummaryEN, "world backup Job") {
t.Errorf("job finding = %+v", f)
}
}
}
}
// TestClusterCheckAPIDown: a List the API server refuses is an error, not an
// empty (healthy-looking) cluster.
func TestClusterCheckAPIDown(t *testing.T) {
cl := fake.NewClientBuilder().WithScheme(runtime.NewScheme()).Build()
if _, err := (Cluster{Client: cl, ControlNamespace: "felis", MinecraftNamespace: "minecraft"}).Check(context.Background(), t0); err == nil {
t.Fatal("Check succeeded against a client that knows no kinds")
}
}
func TestBackupFinding(t *testing.T) {
dir := t.TempDir()
if f := BackupFinding(dir, t0); f == nil || !strings.Contains(f.SummaryEN, "no control-plane database backup") {
t.Fatalf("empty dir: %+v", f)
}
touch := func(at time.Time) {
if err := os.WriteFile(filepath.Join(dir, dbbackup.BundleName(at, "daily")), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
}
touch(t0.Add(-30 * time.Hour))
if f := BackupFinding(dir, t0); f == nil || !strings.Contains(f.SummaryEN, "30h0m0s old") {
t.Fatalf("stale: %+v", f)
}
touch(t0.Add(-2 * time.Hour))
if f := BackupFinding(dir, t0); f != nil {
t.Fatalf("fresh backup reported: %+v", f)
}
}
func TestMemoryFinding(t *testing.T) {
path := filepath.Join(t.TempDir(), "meminfo")
write := func(avail int) {
body := "MemTotal: 24000000 kB\nMemFree: 100000 kB\nMemAvailable: " + strconv.Itoa(avail) + " kB\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
write(2000000)
if f := MemoryFinding(path); f == nil || f.Key != "memory" {
t.Fatalf("8%% available: %+v", f)
}
write(6000000)
if f := MemoryFinding(path); f != nil {
t.Fatalf("25%% available reported: %+v", f)
}
if f := MemoryFinding(filepath.Join(t.TempDir(), "none")); f != nil {
t.Fatalf("missing meminfo reported: %+v", f)
}
}
// TestDiskFindingsDedupAndSkip: two paths on one filesystem yield at most one
// finding, and a path that does not exist is skipped.
func TestDiskFindingsDedupAndSkip(t *testing.T) {
dir := t.TempDir()
got := DiskFindings([]string{dir, filepath.Join(dir, "."), filepath.Join(dir, "missing")})
if len(got) > 1 {
t.Fatalf("findings = %v, want at most one for one filesystem", findingKeys(got))
}
}
+340
View File
@@ -0,0 +1,340 @@
// Package watchdog is the consumer the platform's health signals otherwise lack:
// `felis watchdog` runs on the host from a systemd timer, checks the control
// plane, the login gate, the fleet, PostgreSQL, the database backups and the
// node, and mails the platform owners when something has stayed wrong long
// enough to matter, again every day while it stays wrong, and once more when it
// clears.
//
// It runs on the host rather than in the cluster so the failures that take the
// cluster down (k3s stopped, the API server wedged, the node out of disk) are
// still reported: the one thing it needs to send mail, the relay, it reaches
// directly, with the credentials it cached on its last good run.
//
// This file is the pure part: turning one run's findings into state changes and
// a message. The probes that produce findings live in probes.go.
package watchdog
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Severity orders how urgent a finding is.
type Severity string
const (
Critical Severity = "critical"
Warning Severity = "warning"
)
// Finding is one condition a probe saw on this run.
type Finding struct {
// Key identifies the condition across runs, e.g. "deployment/felis-api". A
// key is also how a condition is found again once it clears.
Key string `json:"key"`
Severity Severity `json:"severity"`
// Summary and SummaryEN name what is wrong, in Chinese and English.
Summary string `json:"summary"`
SummaryEN string `json:"summary_en"`
// Hint says where to look next (a command, a troubleshooting section).
Hint string `json:"hint,omitempty"`
// For is how long the condition must hold before it is mailed, so a rollout
// or a restart that heals itself never pages anyone.
For time.Duration `json:"for"`
// Event marks a one-off (a failed Job): mailed once, with no reminder and no
// resolved notice when it goes away.
Event bool `json:"event,omitempty"`
}
// Report is one run's observations.
type Report struct {
Findings []Finding
// Unknown lists key prefixes no probe could evaluate on this run (the API
// server being down hides every cluster check). Alerts under them keep their
// state rather than reading as cleared.
Unknown []string
}
// Alert is a finding the state file remembers across runs.
type Alert struct {
Finding
FirstSeen time.Time `json:"first_seen"`
// Notified is when the alert was last mailed, at NotifiedSeverity; zero
// while it is pending.
Notified time.Time `json:"notified,omitempty"`
NotifiedSeverity Severity `json:"notified_severity,omitempty"`
// ClearedAt is when a mailed alert was first seen gone. It is mailed as
// resolved only after staying gone for resolveAfter, so a value hovering at
// its threshold does not mail on every crossing.
ClearedAt time.Time `json:"cleared_at,omitempty"`
}
// State is what the watchdog keeps between runs.
type State struct {
Alerts map[string]*Alert `json:"alerts"`
// Recipients and SMTPPassword are cached from the last run that could read
// them (the database and the felis-smtp Secret), so an outage of either can
// still be mailed.
Recipients []string `json:"recipients,omitempty"`
SMTPPassword string `json:"smtp_password,omitempty"`
}
const (
// remindEvery is how often a firing alert is mailed again.
remindEvery = 24 * time.Hour
// resolveAfter is how long a mailed alert must stay gone before it is
// mailed as resolved.
resolveAfter = 10 * time.Minute
)
// Plan is what one run has to tell the owners.
type Plan struct {
Firing []Alert // newly past their For, or worse than when last mailed
Reminders []Alert // still firing a day after the last mail
Resolved []Alert // gone for resolveAfter
// Active is every mailed condition still firing (one-off events aside), for
// the message's summary.
Active []Alert
}
// Empty reports whether the plan has nothing to mail.
func (p Plan) Empty() bool {
return len(p.Firing) == 0 && len(p.Reminders) == 0 && len(p.Resolved) == 0
}
// Observe folds a report into s and returns what is due to be mailed. It only
// tracks when conditions appeared and cleared; Commit records the plan as
// delivered. A run whose mail failed, or that falls in a quiet period, saves
// the state without committing, and the same alerts come due again next run.
func (s *State) Observe(r Report, now time.Time) Plan {
if s.Alerts == nil {
s.Alerts = map[string]*Alert{}
}
seen := map[string]bool{}
var plan Plan
for _, f := range r.Findings {
seen[f.Key] = true
a, ok := s.Alerts[f.Key]
if !ok {
a = &Alert{FirstSeen: now}
s.Alerts[f.Key] = a
}
a.Finding = f
a.ClearedAt = time.Time{}
notified := !a.Notified.IsZero()
switch {
case !notified && now.Sub(a.FirstSeen) >= f.For:
plan.Firing = append(plan.Firing, *a)
case notified && f.Severity == Critical && a.NotifiedSeverity != Critical:
// Worse than when it was mailed (a disk from low to nearly full):
// new news, mailed at once.
plan.Firing = append(plan.Firing, *a)
case notified && !f.Event && now.Sub(a.Notified) >= remindEvery:
plan.Reminders = append(plan.Reminders, *a)
}
}
for key, a := range s.Alerts {
if seen[key] || underAny(key, r.Unknown) {
continue
}
switch {
case a.Notified.IsZero() || a.Event:
// Never mailed, or a one-off: nothing to take back.
delete(s.Alerts, key)
case a.ClearedAt.IsZero():
a.ClearedAt = now
case now.Sub(a.ClearedAt) >= resolveAfter:
plan.Resolved = append(plan.Resolved, *a)
}
}
for _, a := range s.Alerts {
if !a.Notified.IsZero() && a.ClearedAt.IsZero() && !a.Event {
plan.Active = append(plan.Active, *a)
}
}
for _, l := range [][]Alert{plan.Firing, plan.Reminders, plan.Resolved, plan.Active} {
sortAlerts(l)
}
return plan
}
// Commit records plan as mailed at now.
func (s *State) Commit(plan Plan, now time.Time) {
for _, l := range [][]Alert{plan.Firing, plan.Reminders} {
for _, a := range l {
if cur := s.Alerts[a.Key]; cur != nil {
cur.Notified, cur.NotifiedSeverity = now, a.Severity
}
}
}
for _, a := range plan.Resolved {
delete(s.Alerts, a.Key)
}
}
func underAny(key string, prefixes []string) bool {
for _, p := range prefixes {
if strings.HasPrefix(key, p) {
return true
}
}
return false
}
// sortAlerts puts critical alerts first, then orders by key.
func sortAlerts(l []Alert) {
sort.Slice(l, func(i, j int) bool {
if (l[i].Severity == Critical) != (l[j].Severity == Critical) {
return l[i].Severity == Critical
}
return l[i].Key < l[j].Key
})
}
// Message renders the plan as one mail: a subject naming the counts and host,
// and a body listing what fired, what is still wrong and what cleared.
func (p Plan) Message(host string, now time.Time) (subject, body string) {
var parts, partsEN []string
if n := len(p.Firing) + len(p.Reminders); n > 0 {
parts = append(parts, fmt.Sprintf("%d 项异常", n))
partsEN = append(partsEN, fmt.Sprintf("%d firing", n))
}
if n := len(p.Resolved); n > 0 {
parts = append(parts, fmt.Sprintf("%d 项恢复", n))
partsEN = append(partsEN, fmt.Sprintf("%d resolved", n))
}
tag := "告警"
if len(p.Firing)+len(p.Reminders) == 0 {
tag = "恢复"
} else if hasCritical(p.Firing) || hasCritical(p.Reminders) {
tag = "严重告警"
}
subject = fmt.Sprintf("Felis %s(%s):%s · %s", tag, host, strings.Join(parts, ","), strings.Join(partsEN, ", "))
var b strings.Builder
fmt.Fprintf(&b, "Felis 平台巡检 / platform watchdog — %s, %s\r\n", host, now.UTC().Format("2006-01-02 15:04 UTC"))
section := func(title string, l []Alert, since bool) {
if len(l) == 0 {
return
}
fmt.Fprintf(&b, "\r\n%s\r\n", title)
for _, a := range l {
fmt.Fprintf(&b, "\r\n[%s] %s\r\n %s\r\n", a.Severity, a.Summary, a.SummaryEN)
if since {
fmt.Fprintf(&b, " 自 / since %s\r\n", a.FirstSeen.UTC().Format("2006-01-02 15:04 UTC"))
}
if a.Hint != "" {
fmt.Fprintf(&b, " → %s\r\n", a.Hint)
}
}
}
section("== 新出现的异常 / new ==", p.Firing, true)
section("== 仍未恢复(每日提醒)/ still firing (daily reminder) ==", p.Reminders, true)
section("== 已恢复 / resolved ==", p.Resolved, false)
if others := without(p.Active, p.Firing, p.Reminders); len(others) > 0 {
fmt.Fprintf(&b, "\r\n== 其他仍在进行的告警 / also still firing ==\r\n")
for _, a := range others {
fmt.Fprintf(&b, " [%s] %s / %s\r\n", a.Severity, a.Summary, a.SummaryEN)
}
}
// The journal rather than a -dry-run: the unit carries the flags (proxy port,
// disk paths) a bare command line would not.
fmt.Fprintf(&b, "\r\n在主机上运行 `journalctl -u felis-watchdog -n 40` 查看最近一次巡检的全部检查结果。\r\n"+
"Run `journalctl -u felis-watchdog -n 40` on the host for every check of the latest run.\r\n")
return subject, b.String()
}
func hasCritical(l []Alert) bool {
for _, a := range l {
if a.Severity == Critical {
return true
}
}
return false
}
// without returns the alerts of all not keyed in any of skip.
func without(all []Alert, skip ...[]Alert) []Alert {
keys := map[string]bool{}
for _, l := range skip {
for _, a := range l {
keys[a.Key] = true
}
}
var out []Alert
for _, a := range all {
if !keys[a.Key] {
out = append(out, a)
}
}
return out
}
// LoadState reads the state file; a missing file is a fresh state.
func LoadState(path string) (*State, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return &State{Alerts: map[string]*Alert{}}, nil
}
if err != nil {
return nil, err
}
var s State
if err := json.Unmarshal(raw, &s); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if s.Alerts == nil {
s.Alerts = map[string]*Alert{}
}
return &s, nil
}
// SaveState writes s atomically, readable by root only: it caches the relay
// password.
func SaveState(path string, s *State) error {
raw, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".state-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(raw); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
// QuietUntil reads the maintenance marker the installer writes while it
// restarts things on purpose: a Unix timestamp, before which nothing is mailed.
// A missing or unreadable marker means no quiet period.
func QuietUntil(path string) time.Time {
raw, err := os.ReadFile(path)
if err != nil {
return time.Time{}
}
var sec int64
if _, err := fmt.Sscan(strings.TrimSpace(string(raw)), &sec); err != nil {
return time.Time{}
}
return time.Unix(sec, 0)
}
+207
View File
@@ -0,0 +1,207 @@
package watchdog
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
var t0 = time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
func finding(key string, sev Severity, forDur time.Duration) Finding {
return Finding{Key: key, Severity: sev, For: forDur, Summary: key + " 中文", SummaryEN: key + " en"}
}
func keys(l []Alert) []string {
var out []string
for _, a := range l {
out = append(out, a.Key)
}
return out
}
// run observes r at now and commits whatever came due, like a run whose mail
// went out.
func run(s *State, r Report, now time.Time) Plan {
p := s.Observe(r, now)
s.Commit(p, now)
return p
}
// TestObserveLifecycle walks one condition through pending, firing, the daily
// reminder, clearing, and the resolved notice after it stayed clear.
func TestObserveLifecycle(t *testing.T) {
s := &State{}
f := finding("deployment/felis-api", Critical, 5*time.Minute)
down := Report{Findings: []Finding{f}}
if p := run(s, down, t0); !p.Empty() {
t.Fatalf("first sight mailed %+v, want it pending for 5m", p)
}
if p := run(s, down, t0.Add(4*time.Minute)); !p.Empty() {
t.Fatalf("4m in mailed %v", keys(p.Firing))
}
p := run(s, down, t0.Add(6*time.Minute))
if len(p.Firing) != 1 || p.Firing[0].Key != f.Key || !p.Firing[0].FirstSeen.Equal(t0) {
t.Fatalf("6m in: firing = %+v, want %s since t0", p.Firing, f.Key)
}
if p := run(s, down, t0.Add(8*time.Minute)); !p.Empty() {
t.Fatalf("already mailed, mailed again: %+v", p)
}
p = run(s, down, t0.Add(6*time.Minute+remindEvery))
if len(p.Reminders) != 1 || len(p.Firing) != 0 {
t.Fatalf("a day later: %+v, want one reminder", p)
}
up := Report{}
later := t0.Add(7*time.Minute + remindEvery)
if p := run(s, up, later); !p.Empty() {
t.Fatalf("just cleared, mailed %+v; want resolveAfter to pass first", p)
}
p = run(s, up, later.Add(resolveAfter))
if len(p.Resolved) != 1 || p.Resolved[0].Key != f.Key {
t.Fatalf("resolved = %v, want %s", keys(p.Resolved), f.Key)
}
if len(s.Alerts) != 0 {
t.Errorf("state after resolve = %v, want empty", s.Alerts)
}
}
// TestObserveFlapWithinResolveWindow: a condition that returns before
// resolveAfter is neither resolved nor mailed as new.
func TestObserveFlapWithinResolveWindow(t *testing.T) {
s := &State{}
f := finding("disk//", Warning, 0)
run(s, Report{Findings: []Finding{f}}, t0)
run(s, Report{}, t0.Add(time.Minute))
if p := run(s, Report{Findings: []Finding{f}}, t0.Add(3*time.Minute)); !p.Empty() {
t.Fatalf("flap mailed %+v", p)
}
if a := s.Alerts[f.Key]; a == nil || !a.ClearedAt.IsZero() {
t.Fatalf("alert after flap = %+v, want firing again", a)
}
}
// TestObservePendingNeverMailed: a condition that heals inside its For is
// dropped without a word.
func TestObservePendingNeverMailed(t *testing.T) {
s := &State{}
run(s, Report{Findings: []Finding{finding("deployment/registry", Critical, 5*time.Minute)}}, t0)
if p := run(s, Report{}, t0.Add(2*time.Minute)); !p.Empty() || len(s.Alerts) != 0 {
t.Fatalf("healed pending alert: plan %+v state %v", p, s.Alerts)
}
}
// TestObserveEscalation: a warning that turns critical is mailed again at once.
func TestObserveEscalation(t *testing.T) {
s := &State{}
run(s, Report{Findings: []Finding{finding("disk//", Warning, 0)}}, t0)
p := run(s, Report{Findings: []Finding{finding("disk//", Critical, 5*time.Minute)}}, t0.Add(time.Minute))
if len(p.Firing) != 1 || p.Firing[0].Severity != Critical {
t.Fatalf("escalation = %+v, want the critical finding mailed", p.Firing)
}
if p := run(s, Report{Findings: []Finding{finding("disk//", Critical, 5*time.Minute)}}, t0.Add(2*time.Minute)); !p.Empty() {
t.Fatalf("critical mailed twice: %+v", p)
}
}
// TestObserveEventOnce: a failed Job is mailed once, never reminded, and leaves
// without a resolved notice.
func TestObserveEventOnce(t *testing.T) {
s := &State{}
ev := finding("job-failed/backup-survival-x", Warning, 0)
ev.Event = true
if p := run(s, Report{Findings: []Finding{ev}}, t0); len(p.Firing) != 1 {
t.Fatalf("event not mailed: %+v", p)
}
if p := run(s, Report{Findings: []Finding{ev}}, t0.Add(remindEvery+time.Hour)); !p.Empty() {
t.Fatalf("event reminded: %+v", p)
}
if p := run(s, Report{}, t0.Add(remindEvery+2*time.Hour)); !p.Empty() || len(s.Alerts) != 0 {
t.Fatalf("event gone: plan %+v state %v, want silent removal", p, s.Alerts)
}
}
// TestObserveUnknownCarriesOver: while the API server is down, cluster alerts
// neither resolve nor restart their clocks.
func TestObserveUnknownCarriesOver(t *testing.T) {
s := &State{}
f := finding("system-server/login", Critical, 0)
run(s, Report{Findings: []Finding{f}}, t0)
api := finding("kube-api", Critical, 5*time.Minute)
for i := 1; i <= 3; i++ {
p := run(s, Report{Findings: []Finding{api}, Unknown: ClusterPrefixes}, t0.Add(time.Duration(i)*resolveAfter))
if len(p.Resolved) != 0 {
t.Fatalf("run %d resolved %v while the API was down", i, keys(p.Resolved))
}
}
if a := s.Alerts[f.Key]; a == nil || !a.ClearedAt.IsZero() {
t.Fatalf("login alert = %+v, want still firing", a)
}
}
// TestObserveUncommittedRetries: a plan whose mail failed comes due again.
func TestObserveUncommittedRetries(t *testing.T) {
s := &State{}
f := finding("postgres", Critical, 0)
if p := s.Observe(Report{Findings: []Finding{f}}, t0); len(p.Firing) != 1 {
t.Fatalf("not due: %+v", p)
}
if p := s.Observe(Report{Findings: []Finding{f}}, t0.Add(2*time.Minute)); len(p.Firing) != 1 || !p.Firing[0].FirstSeen.Equal(t0) {
t.Fatalf("after a failed send: %+v, want the same alert due again since t0", p.Firing)
}
}
func TestMessage(t *testing.T) {
s := &State{}
run(s, Report{Findings: []Finding{finding("memory", Warning, 0)}}, t0)
p := run(s, Report{Findings: []Finding{finding("memory", Warning, 0), finding("postgres", Critical, 0)}}, t0.Add(time.Minute))
subject, body := p.Message("node-1", t0.Add(time.Minute))
for _, want := range []string{"严重告警", "node-1", "1 项异常", "1 firing"} {
if !strings.Contains(subject, want) {
t.Errorf("subject %q lacks %q", subject, want)
}
}
for _, want := range []string{"postgres 中文", "postgres en", "== 其他仍在进行的告警 / also still firing ==", "memory 中文 / memory en", "journalctl -u felis-watchdog"} {
if !strings.Contains(body, want) {
t.Errorf("body lacks %q:\n%s", want, body)
}
}
}
func TestStateRoundTripPrivate(t *testing.T) {
path := filepath.Join(t.TempDir(), "watchdog", "state.json")
s := &State{SMTPPassword: "secret", Recipients: []string{"[email protected]"}}
run(s, Report{Findings: []Finding{finding("memory", Warning, time.Hour)}}, t0)
if err := SaveState(path, s); err != nil {
t.Fatalf("SaveState: %v", err)
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Fatalf("state file mode = %v (%v), want 0600", info.Mode(), err)
}
got, err := LoadState(path)
if err != nil || got.SMTPPassword != "secret" || !got.Alerts["memory"].FirstSeen.Equal(t0) {
t.Fatalf("LoadState = %+v, %v", got, err)
}
fresh, err := LoadState(filepath.Join(t.TempDir(), "missing.json"))
if err != nil || fresh.Alerts == nil {
t.Fatalf("missing state = %+v, %v", fresh, err)
}
}
func TestQuietUntil(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "quiet")
if !QuietUntil(marker).IsZero() {
t.Error("missing marker should mean no quiet period")
}
if err := os.WriteFile(marker, []byte("1790000000\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := QuietUntil(marker); got.Unix() != 1790000000 {
t.Errorf("QuietUntil = %v", got)
}
}