Unverified Commit c146ce84 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

fix(setup): 安装摘要和 setup 结尾卡片显示告警发往哪里,没有邮件中继时大声提示

parent 82ffa55a
Loading
Loading
Loading
Loading
+11 −1
Changes for cmd/felis/breakglass.go: 11 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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
+202 −0
Changes for cmd/felis/tui_alerts_test.go: 202 added lines, 0 removed lines.
Original line number Diff line number Diff line
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)
	}
}
+15 −0
Changes for cmd/felis/tui_root.go: 15 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
+5 −2
Changes for cmd/felis/tui_smtp.go: 5 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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{}
	}
+77 −0
Changes for cmd/felis/tui_summary.go: 77 added lines, 0 removed lines.
Original line number Diff line number Diff line
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"
}
Loading