fix(setup): 安装摘要和 setup 结尾卡片显示告警发往哪里,没有邮件中继时大声提示
This commit is contained in:
8 files changed
+378
-6
No files matched your search
+11
-1
@@ -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
|
||||
|
||||
@@ -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{"[email protected]"}},
|
||||
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{"[email protected]", "[email protected]"}, heartbeatErr: errors.New("/etc/felis/watchdog-heartbeat-url: the heartbeat URL is not an http:// or https:// URL")},
|
||||
alerts: "mailed to [email protected], [email protected] 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{"[email protected]"}}}
|
||||
v := m.View()
|
||||
for _, want := range []string{"alerts mailed to [email protected] 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{"[email protected]"}}
|
||||
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:[email protected]: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:[email protected]: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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = "[email protected]"\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
|
||||
|
||||
@@ -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 <address> via <relay>`, 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:
|
||||
|
||||
|
||||
Reference in new issue
Block a user