From c146ce84e2a9127485341d6e6d28170ee4f21e97 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Sun, 27 Sep 2026 08:53:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(setup):=20=E5=AE=89=E8=A3=85=E6=91=98?= =?UTF-8?q?=E8=A6=81=E5=92=8C=20setup=20=E7=BB=93=E5=B0=BE=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=98=BE=E7=A4=BA=E5=91=8A=E8=AD=A6=E5=8F=91=E5=BE=80?= =?UTF-8?q?=E5=93=AA=E9=87=8C=EF=BC=8C=E6=B2=A1=E6=9C=89=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E4=B8=AD=E7=BB=A7=E6=97=B6=E5=A4=A7=E5=A3=B0=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/felis/breakglass.go | 12 ++- cmd/felis/tui_alerts_test.go | 202 +++++++++++++++++++++++++++++++++++ cmd/felis/tui_root.go | 15 +++ cmd/felis/tui_smtp.go | 7 +- cmd/felis/tui_summary.go | 77 +++++++++++++ deploy/bootstrap.sh | 14 +++ deploy/bootstrap_test.sh | 48 ++++++++- docs/troubleshooting.md | 9 +- 8 files changed, 378 insertions(+), 6 deletions(-) create mode 100644 cmd/felis/tui_alerts_test.go diff --git a/cmd/felis/breakglass.go b/cmd/felis/breakglass.go index 328421e..c162ac7 100644 --- a/cmd/felis/breakglass.go +++ b/cmd/felis/breakglass.go @@ -637,9 +637,19 @@ func runSetupTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, ro return runConsoleTUI(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, consoleModeSetup, recoveryConfig{}) } -func runConsoleTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) (breakGlassResult, error) { +// newConsoleRoot is the console's root model as the host runs it: the summary +// reads this host's alert route. +func newConsoleRoot(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) *rootModel { rm := newRootModel(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode) rm.recovery = recovery + rm.alertRoute = func(ctx context.Context) alertRoute { + return hostAlertRoute(ctx, hostSetupConfigPath, db.URL, defaultHeartbeatFile) + } + return rm +} + +func runConsoleTUI(ctx context.Context, s ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode, recovery recoveryConfig) (breakGlassResult, error) { + rm := newConsoleRoot(ctx, s, db, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser, adminExists, mode, recovery) final, err := tea.NewProgram(rm, tea.WithAltScreen()).Run() if err != nil { return breakGlassResult{}, err diff --git a/cmd/felis/tui_alerts_test.go b/cmd/felis/tui_alerts_test.go new file mode 100644 index 0000000..4e341ab --- /dev/null +++ b/cmd/felis/tui_alerts_test.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "felis.lolicon.best/internal/config" +) + +// TestAlertRouteLines: the summary's alert rows say where the watchdog's +// alerts go, and flag every route that reaches no one. +func TestAlertRouteLines(t *testing.T) { + cases := []struct { + name string + r alertRoute + alerts string + alertsOK bool + beat string + beatOK bool + }{ + { + name: "fresh install", + alerts: "only logged: no email relay (press e)", + beat: "none: no outside check (troubleshooting.md §14)", + }, + { + name: "relay, no verified owner", + r: alertRoute{relay: "smtp.example.com:587", heartbeat: "https://hc-ping.com/..."}, + alerts: "only logged: no verified Owner email (panel → Account)", + beat: "https://hc-ping.com/... every 2 minutes", beatOK: true, + }, + { + name: "relay, owners unreadable", + r: alertRoute{relay: "smtp.example.com:587", lookupErr: errors.New("connection refused")}, + alerts: "via smtp.example.com:587; could not read the Owner addresses", + beat: "none: no outside check (troubleshooting.md §14)", + }, + { + name: "owners but no relay", + r: alertRoute{recipients: []string{"owner@example.com"}}, + alerts: "only logged: no email relay (press e)", + beat: "none: no outside check (troubleshooting.md §14)", + }, + { + name: "mailed", + r: alertRoute{relay: "smtp.example.com:587", recipients: []string{"a@example.com", "b@example.com"}, heartbeatErr: errors.New("/etc/felis/watchdog-heartbeat-url: the heartbeat URL is not an http:// or https:// URL")}, + alerts: "mailed to a@example.com, b@example.com via smtp.example.com:587", alertsOK: true, + beat: "unreadable: /etc/felis/watchdog-heartbeat-url: the heartbeat URL is not an http:// or https:// URL", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if line, ok := c.r.alertsLine(); line != c.alerts || ok != c.alertsOK { + t.Errorf("alerts = %q, %v; want %q, %v", line, ok, c.alerts, c.alertsOK) + } + if line, ok := c.r.heartbeatLine(); line != c.beat || ok != c.beatOK { + t.Errorf("heartbeat = %q, %v; want %q, %v", line, ok, c.beat, c.beatOK) + } + }) + } +} + +// TestSummaryAlertRows: the rows show on the card, a route that needs action +// marked so it reads without colour, and stay off a summary with no route. +func TestSummaryAlertRows(t *testing.T) { + m := &summaryModel{ownerUsername: "owner", alerts: &alertRoute{relay: "smtp.example.com:587", recipients: []string{"owner@example.com"}}} + v := m.View() + for _, want := range []string{"alerts mailed to owner@example.com via smtp.example.com:587", "heartbeat ⚠ none: no outside check"} { + if !strings.Contains(v, want) { + t.Errorf("summary lacks %q:\n%s", want, v) + } + } + if strings.Contains(v, "⚠ mailed") { + t.Errorf("a route that reaches the owners is marked:\n%s", v) + } + m.alerts = &alertRoute{heartbeat: "https://hc-ping.com/..."} + v = m.View() + for _, want := range []string{"alerts ⚠ only logged: no email relay (press e)", "heartbeat https://hc-ping.com/... every 2 minutes"} { + if !strings.Contains(v, want) { + t.Errorf("summary lacks %q:\n%s", want, v) + } + } + m.alerts = nil + if v = m.View(); strings.Contains(v, "alerts") || strings.Contains(v, "heartbeat") { + t.Errorf("a summary with no route shows alert rows:\n%s", v) + } +} + +// TestRootSummaryReadsAlertRoute: the summary and the re-run status screen +// read the route each time they open, so a relay configured with e shows at +// once. +func TestRootSummaryReadsAlertRoute(t *testing.T) { + m := newTestRoot(false, consoleModeSetup, "") + reads := 0 + route := alertRoute{} + m.alertRoute = func(context.Context) alertRoute { + reads++ + return route + } + m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk"}) + sum, ok := m.screen.(*summaryModel) + if !ok || sum.alerts == nil || sum.alerts.relay != "" { + t.Fatalf("summary after storage: %T %+v", m.screen, sum) + } + route = alertRoute{relay: "smtp.example.com:587", recipients: []string{"owner@example.com"}} + m = drive(t, m, smtpResultMsg{configured: true}) + sum, ok = m.screen.(*summaryModel) + if !ok || sum.alerts == nil || sum.alerts.relay != "smtp.example.com:587" { + t.Fatalf("summary after configuring email: %T %+v", m.screen, sum) + } + if reads != 2 { + t.Errorf("route read %d times, want 2", reads) + } + + status := newTestRoot(true, consoleModeSetup, "") + status.alertRoute = m.alertRoute + status.showStatus() + if sum, ok := status.screen.(*summaryModel); !ok || sum.alerts == nil || sum.alerts.relay != "smtp.example.com:587" { + t.Fatalf("status screen: %T %+v", status.screen, status.screen) + } +} + +// TestHostAlertRoute reads the relay from the host config and the heartbeat +// file, shows the heartbeat by its host only, and reports a database it cannot +// reach. +func TestHostAlertRoute(t *testing.T) { + dir := t.TempDir() + cfg := filepath.Join(dir, "felis.host.toml") + writeTestFile(t, cfg, testWatchdogConfig+testWatchdogSMTP, 0o600) + beat := filepath.Join(dir, "watchdog-heartbeat-url") + writeTestFile(t, beat, "https://hc-ping.com/check-key\n", 0o600) + dbURL := "postgres://felis:pw@127.0.0.1:1/felis?sslmode=disable&connect_timeout=2" + + r := hostAlertRoute(context.Background(), cfg, dbURL, beat) + if r.relay != "smtp.config.example:2525" { + t.Errorf("relay = %q", r.relay) + } + if r.heartbeat != "https://hc-ping.com/..." || r.heartbeatErr != nil { + t.Errorf("heartbeat = %q, %v", r.heartbeat, r.heartbeatErr) + } + if r.lookupErr == nil { + t.Errorf("an unreachable database reads as %v", r.recipients) + } + + noRelay := filepath.Join(dir, "no-relay.toml") + writeTestFile(t, noRelay, testWatchdogConfig, 0o600) + writeTestFile(t, beat, "hc-ping.com/check-key\n", 0o600) + r = hostAlertRoute(context.Background(), noRelay, dbURL, beat) + if r.relay != "" { + t.Errorf("no [smtp]: relay = %q", r.relay) + } + if r.heartbeatErr == nil || strings.Contains(r.heartbeatErr.Error(), "check-key") { + t.Errorf("a bad heartbeat file: %v", r.heartbeatErr) + } + r = hostAlertRoute(context.Background(), noRelay, dbURL, filepath.Join(dir, "none")) + if r.heartbeat != "" || r.heartbeatErr != nil { + t.Errorf("no heartbeat file: %q, %v", r.heartbeat, r.heartbeatErr) + } +} + +// TestSummaryWithAlertsFitsTerminal: the two rows keep the summary inside the +// terminal, with the longest route lines. +func TestSummaryWithAlertsFitsTerminal(t *testing.T) { + for _, w := range []int{60, 80, 90} { + for _, h := range []int{24, 30, 45} { + m := newTestRoot(false, consoleModeSetup, "") + m.alertRoute = func(context.Context) alertRoute { + return alertRoute{relay: "smtp.example.com:587", lookupErr: errors.New("dial tcp 127.0.0.1:5432: connect: connection refused")} + } + m = drive(t, m, tea.WindowSizeMsg{Width: w, Height: h}) + m = drive(t, m, preflightDoneMsg{}) + m = drive(t, m, ownerResultMsg{username: "owner", setupTokenURL: "https://op.console.example.com/setup?token=t0ken"}) + m = drive(t, m, connectResultMsg{method: connectLocal, panelHostname: "panel.example.com"}) + m = drive(t, m, storageResultMsg{method: storageLocal, detail: "local disk · /var/lib/felis/uploads"}) + if _, ok := m.screen.(*summaryModel); !ok { + t.Fatalf("screen = %T, want the summary", m.screen) + } + if got := lipgloss.Height(m.View()); got > h { + t.Errorf("terminal %dx%d: summary with alert rows = %d rows (exceeds height)", w, h, got) + } + } + } +} + +// TestConsoleRootReadsHostAlertRoute: the console the host runs gives its +// summary this host's route, read against its database. +func TestConsoleRootReadsHostAlertRoute(t *testing.T) { + db := config.DatabaseConfig{URL: "postgres://felis:pw@127.0.0.1:1/felis?sslmode=disable&connect_timeout=2"} + rm := newConsoleRoot(context.Background(), &fakeOwnerStore{}, db, "felis.example.com", "admin.felis.example.com", "panel.felis.example.com", "", "minecraft", "root", false, consoleModeSetup, recoveryConfig{}) + if rm.alertRoute == nil { + t.Fatal("the console's summary reads no alert route") + } + if r := rm.alertRoute(context.Background()); r.lookupErr == nil { + t.Errorf("the route did not query the console's database: %+v", r) + } +} diff --git a/cmd/felis/tui_root.go b/cmd/felis/tui_root.go index eda8c20..1c01f5c 100644 --- a/cmd/felis/tui_root.go +++ b/cmd/felis/tui_root.go @@ -144,6 +144,9 @@ type rootModel struct { namespace string // minecraft workload namespace (cfg.K8s.Namespace); target of the halt op adminExists bool recovery recoveryConfig // how the account operations mail a recovery code + // alertRoute reads where the watchdog's alerts go for the summary; nil + // leaves those rows out. + alertRoute func(context.Context) alertRoute } func newRootModel(ctx context.Context, store ownerStore, db config.DatabaseConfig, rootDomain, adminHostname, panelHostname, accessAud, namespace, osUser string, adminExists bool, mode consoleMode) *rootModel { @@ -556,6 +559,7 @@ func (m *rootModel) showSummary() (tea.Model, tea.Cmd) { routedHosts: routed, localHint: m.result.connectMethod == connectLocal, alreadySetUp: m.result.alreadySetUp, + alerts: m.readAlertRoute(), }) } @@ -576,9 +580,20 @@ func (m *rootModel) showStatus() (tea.Model, tea.Cmd) { accessLabel: accessLabel, alreadySetUp: true, localHint: m.accessAud == "" && rootDomainEmbeddedIP(m.rootDomain) != "", + alerts: m.readAlertRoute(), }) } +// readAlertRoute reads the alert route afresh, so the summary shows a relay the +// Owner just configured with e. +func (m *rootModel) readAlertRoute() *alertRoute { + if m.alertRoute == nil { + return nil + } + r := m.alertRoute(m.ctx) + return &r +} + func panelURLFor(method connectMethod, panelHostname, rootDomain, adminHostname string) string { if method != connectLocal && panelHostname != "" { return "https://" + panelHostname diff --git a/cmd/felis/tui_smtp.go b/cmd/felis/tui_smtp.go index 0bfac85..3ecf2ce 100644 --- a/cmd/felis/tui_smtp.go +++ b/cmd/felis/tui_smtp.go @@ -279,8 +279,11 @@ func validateSMTPFrom(s string) error { // pre-fills the non-secret fields. The password (the felis-smtp Secret and its // host copy) is deliberately never read back — it must be re-entered to change. // Any read error falls back to a blank form rather than blocking reconfig. -func currentSMTPInputs() smtpInputs { - cfg, err := config.Load(hostSetupConfigPath) +func currentSMTPInputs() smtpInputs { return smtpInputsFrom(hostSetupConfigPath) } + +// smtpInputsFrom is currentSMTPInputs for the config at path. +func smtpInputsFrom(path string) smtpInputs { + cfg, err := config.Load(path) if err != nil || cfg.SMTP.Host == "" { return smtpInputs{} } diff --git a/cmd/felis/tui_summary.go b/cmd/felis/tui_summary.go index 51025bc..774a7a0 100644 --- a/cmd/felis/tui_summary.go +++ b/cmd/felis/tui_summary.go @@ -1,7 +1,9 @@ package main import ( + "context" "strings" + "time" tea "github.com/charmbracelet/bubbletea" ) @@ -20,6 +22,8 @@ type summaryModel struct { routedHosts []string alreadySetUp bool // re-run: Owner pre-existed localHint bool // show the self-signed-cert note + // alerts is where the watchdog's alerts go; nil leaves the rows out. + alerts *alertRoute } func (m *summaryModel) Init() tea.Cmd { return nil } @@ -73,6 +77,12 @@ func (m *summaryModel) View() string { if m.panelURL != "" { card.WriteString(tuiLabel.Render("panel ") + m.panelURL + "\n") } + if m.alerts != nil { + line, ok := m.alerts.alertsLine() + card.WriteString(routeRow("alerts ", line, ok)) + line, ok = m.alerts.heartbeatLine() + card.WriteString(routeRow("heartbeat ", line, ok)) + } b.WriteString(tuiCardStyle.Render(strings.TrimRight(card.String(), "\n")) + "\n\n") b.WriteString(tuiHint.Render("ℹ Everything else — servers, users, plugins — is configured in the panel. You won't need this console again.") + "\n") @@ -83,3 +93,70 @@ func (m *summaryModel) View() string { b.WriteString("\n" + tuiAction("c", "change connection", "s", "change storage", "e", "configure email", "enter/esc", "exit")) return b.String() } + +// alertRoute is where this host's watchdog alerts go, as the summary shows it: +// by mail through the [smtp] relay to the Owners' verified addresses, and the +// heartbeat that notices the host itself going down (docs/troubleshooting.md +// §14). Setup runs mail-less by design, so a fresh install has neither; the +// summary says so where the Owner can press e. +type alertRoute struct { + relay string // "host:port", "" with no [smtp] relay + recipients []string // enabled Owners' verified addresses + lookupErr error // the recipients could not be read + heartbeat string // the heartbeat URL's scheme and host, "" with none + heartbeatErr error // the heartbeat file does not read +} + +// hostAlertRoute reads the route from the host config at cfgPath, the database +// at dbURL and the heartbeat file at heartbeatPath: what the next watchdog run +// uses. +func hostAlertRoute(ctx context.Context, cfgPath, dbURL, heartbeatPath string) alertRoute { + var r alertRoute + if in := smtpInputsFrom(cfgPath); in.host != "" { + r.relay = in.host + ":" + in.port + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + r.recipients, r.lookupErr = ownerEmails(ctx, dbURL) + u, err := readHeartbeatURL(heartbeatPath) + switch { + case err != nil: + r.heartbeatErr = err + case u != "": + r.heartbeat = redactURL(u) + } + return r +} + +// alertsLine is the summary's alerts row; ok is false when the alerts reach no one. +func (r alertRoute) alertsLine() (line string, ok bool) { + switch { + case r.relay == "": + return "only logged: no email relay (press e)", false + case r.lookupErr != nil: + return "via " + r.relay + "; could not read the Owner addresses", false + case len(r.recipients) == 0: + return "only logged: no verified Owner email (panel → Account)", false + } + return "mailed to " + strings.Join(r.recipients, ", ") + " via " + r.relay, true +} + +// heartbeatLine is the summary's heartbeat row; ok is false with no heartbeat. +func (r alertRoute) heartbeatLine() (line string, ok bool) { + switch { + case r.heartbeatErr != nil: + return "unreadable: " + r.heartbeatErr.Error(), false + case r.heartbeat == "": + return "none: no outside check (troubleshooting.md §14)", false + } + return r.heartbeat + " every 2 minutes", true +} + +// routeRow renders one alert row, marked and in the warning style when it +// needs action: the mark reads without colour too. +func routeRow(label, line string, ok bool) string { + if !ok { + line = tuiWarn.Render("⚠ " + line) + } + return tuiLabel.Render(label) + line + "\n" +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index ae96901..c457ec3 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -4849,6 +4849,19 @@ heartbeat_host() { printf '%s://%s/...' "${url%%://*}" "$host" } +# summary_alerts says where the watchdog's alerts go. They go by mail only: through the +# [smtp] relay `felis setup` configures (e, configure email), to every enabled Owner's +# verified address. With no relay each alert, like each sign-in code, is only written to +# the journal. Without this line the operator learns that from an outage no one heard of. +summary_alerts() { + if persisted_smtp_block | grep -Eq '^[[:space:]]*host[[:space:]]*=[[:space:]]*"[^"]'; then + log "Alerts: the watchdog mails the Owner's verified email address through the [smtp] relay." + return 0 + fi + warn "NO ALERT MAIL: no email relay is configured, so the watchdog's alerts and the sign-in codes are only written to the journal." + warn "Configure one in 'sudo felis setup' (e: configure email) and verify the Owner's email in the panel; alerts go to that address." +} + # summary_heartbeat closes the install on the heartbeat: without one, nothing off this # machine notices it going down. summary_heartbeat() { @@ -5463,6 +5476,7 @@ summary() { fi echo summary_offsite + summary_alerts summary_heartbeat echo } diff --git a/deploy/bootstrap_test.sh b/deploy/bootstrap_test.sh index c826906..de4efb3 100644 --- a/deploy/bootstrap_test.sh +++ b/deploy/bootstrap_test.sh @@ -2012,10 +2012,54 @@ case "$(awk '/^validate_settings\(\) \{/,/^}/' "$BS")" in *) echo "FAIL validate_settings must call validate_heartbeat_url"; fails=$((fails + 1)) ;; esac case "$(awk '/^summary\(\) \{/,/^}/' "$BS")" in - *summary_offsite*summary_heartbeat*) echo "PASS the install's summary ends on the heartbeat" ;; - *) echo "FAIL summary must call summary_heartbeat"; fails=$((fails + 1)) ;; + *summary_offsite*summary_alerts*summary_heartbeat*) echo "PASS the install's summary ends on the alerts, then the heartbeat" ;; + *) echo "FAIL summary must call summary_alerts, then summary_heartbeat"; fails=$((fails + 1)) ;; esac +# The watchdog alerts by mail only, through the [smtp] relay. An install without one must +# say that its alerts are only logged; one with a relay must not cry wolf. +sablock="$(awk '/^summary_alerts\(\) \{/,/^}/' "$BS")" +[ -n "$sablock" ] || { echo "FAIL: no summary_alerts found in $BS"; exit 1; } +[ "$(printf '%s\n' "$sablock" | wc -l)" -lt 20 ] \ + || { echo "FAIL: the extracted block is not summary_alerts -- did its closing brace move?"; exit 1; } +run_summary_alerts() { # state-dir + STATE_DIR="$1" SBLOCK_FILE="$sfn" bash -c ' + log() { printf "LOG: %s\n" "$*"; }; warn() { printf "WARN: %s\n" "$*"; } + . "$SBLOCK_FILE" + '"$sablock"' + summary_alerts' 2>&1 +} +out="$(run_summary_alerts "$smtp_dir")" +expect "with a relay the summary says the alerts are mailed" "LOG: Alerts: the watchdog mails the Owner's verified email address through the [smtp] relay." "$out" +case "$out" in + *WARN*) echo "FAIL a configured relay must not be warned about: $out"; fails=$((fails + 1)) ;; + *) echo "PASS a configured relay is not warned about" ;; +esac +alerts_dir="$(mktemp -d)" +printf '[smtp]\n host = "relay.lan"\n port = 25\n from = "felis@example.net"\n' > "$alerts_dir/felis.host.toml" +out="$(run_summary_alerts "$alerts_dir")" +expect "a relay that signs in with no username still mails the alerts" "LOG: Alerts: the watchdog mails" "$out" +alerts_case=0 +for toml in '' '[server] +listen = "0.0.0.0:8080"' '[smtp] + host = "" + port = 587' '[smtp] + port = 587 + +[relay] + host = "mail.example"'; do + alerts_case=$((alerts_case + 1)) + rm -f "$alerts_dir/felis.host.toml" + [ -z "$toml" ] || printf '%s\n' "$toml" > "$alerts_dir/felis.host.toml" + out="$(run_summary_alerts "$alerts_dir")" + expect "no relay (case $alerts_case): the summary says alerts are only logged" "WARN: NO ALERT MAIL: no email relay is configured, so the watchdog's alerts and the sign-in codes are only written to the journal." "$out" + expect "no relay (case $alerts_case): the summary says how to add one" "WARN: Configure one in 'sudo felis setup' (e: configure email) and verify the Owner's email in the panel" "$out" + case "$out" in + *"LOG: Alerts"*) echo "FAIL no relay must not claim the alerts are mailed: $out"; fails=$((fails + 1)) ;; + esac +done +rm -rf "$alerts_dir" + 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/felis,/srv/worlds," "$(cat "$tdir/felis-watchdog.service")" case "$unit" in diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8e8b864..d196833 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1565,7 +1565,14 @@ How it mails: run with the API server and PostgreSQL both down caches the host copy.] - **No relay or no verified owner address:** each alert is written to the journal only, and a run with a heartbeat pings its failure endpoint while an - alert is open (below). + alert is open (below). Setup runs without mail, so a fresh install is in + this state. The install ends with `NO ALERT MAIL`, and the card at the end + of `sudo felis setup` has an `alerts` row that says where alerts go: `mailed + to
via `, or what is missing, marked `⚠`. Press `e` there + to configure email, then verify the Owner's address in the panel (Account → + Email Verification). The `heartbeat` row below it shows the check that is + pinged. [SH-TESTED; GO-TESTED: `TestAlertRouteLines`, + `TestHostAlertRoute`] Commands: