console: alert watcher — keymail on fire/clear via operators' own grants
The box judges itself and publishes alerts/boxes/<box>.json, but it has no keymail identity: the only send path that exists is the console's per-user OAuth grant. So the console polls that prefix every minute, diffs each rule's firing state against what it last told operators (the new alert_seen table), and mails the difference — each operator with that operator's OWN grant token. Only transitions are news. A rule the console has never seen that is firing is a transition; one it has never seen that is quiet is recorded silently, because "the disk is fine" is not a page. A send failure for any operator leaves alert_seen unrecorded, so the next tick retries the whole transition: a duplicate page is cheap, a missed one is what this exists to prevent. Operators with no grant are skipped and named in the log, once per pass and only when a pass has something to say. An unreadable or unparsable alert object is logged and stepped over — a watcher that dies on one bad file is a watcher that stops paging. StartAlertWatcher is called from serve(), never NewServer: constructing a Server, which every console test does dozens of times, must not start a goroutine or send mail. Delivery goes through a sendApp field so alertPass can be asserted on with no keymail server in play. Nothing here logs a grant token; keymail's own errors carry method and path only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+584
−0
cmd/console/main.go+5 −0internal/console/alertwatch.go+215 −0internal/console/alertwatch_test.go+231 −0internal/console/server.go+9 −0internal/console/store.go+63 −0internal/console/store_test.go+61 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 3f771b83c..659433cec 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -218,6 +218,11 @@ func serve() { |
| if err != nil { |
| log.Fatal(err) |
| } |
| + // The alert watcher polls alerts/boxes/*.json every minute and keymails |
| + // operators on each fire/clear (internal/console/alertwatch.go). Started |
| + // here rather than in NewServer so constructing a Server — which every |
| + // console test does — never starts a goroutine or sends mail. |
| + go srv.StartAlertWatcher(context.Background()) |
| addr := envOr("CONSOLE_ADDR", "127.0.0.1:8090") |
| log.Printf("console: %s listening on %s", origin, addr) |
| log.Fatal(http.ListenAndServe(addr, srv)) |
diff --git a/internal/console/alertwatch.go b/internal/console/alertwatch.go| new file mode 100644 |
| index 000000000..70df565d8 |
| --- /dev/null |
| +++ b/internal/console/alertwatch.go |
| @@ -0,0 +1,215 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "log" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/metrics" |
| +) |
| + |
| +// The keymail half of alert delivery lives here rather than on the box, |
| +// because the only keymail send path that exists is the console's per-user |
| +// OAuth grant (Keymail.SendApp) and the agent has no keymail identity. The |
| +// box judges itself and publishes alerts/boxes/<box>.json; this watcher |
| +// polls that prefix, diffs each rule's firing state against what it last |
| +// told operators (the alert_seen table), and mails the difference — each |
| +// operator with that operator's OWN grant token, so nobody is sent mail on |
| +// a credential they did not personally issue. |
| +// |
| +// The failure posture throughout is "never go silent": an unreadable or |
| +// unparsable alert object is logged and skipped, a send failure leaves the |
| +// transition unrecorded so the next pass retries the whole thing, and no |
| +// path here can panic a console that is otherwise serving fine. |
| + |
| +// alertWatchInterval is the poll cadence from the spec: a minute is fast |
| +// enough for a disk filling up and slow enough to be free. |
| +const alertWatchInterval = time.Minute |
| + |
| +// alertPrefix is the bucket prefix the agent publishes AlertState under |
| +// (metrics.AlertKey). |
| +const alertPrefix = "alerts/boxes/" |
| + |
| +// StartAlertWatcher runs alertPass immediately and then every minute until |
| +// ctx is done. Started from cmd/console's serve(), deliberately NOT from |
| +// NewServer: tests construct servers by the dozen and none of them should |
| +// grow a background goroutine or a keymail send. |
| +func (s *Server) StartAlertWatcher(ctx context.Context) { |
| + t := time.NewTicker(alertWatchInterval) |
| + defer t.Stop() |
| + for { |
| + if err := s.alertPass(ctx); err != nil { |
| + log.Printf("console: alert watch: %v", err) |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return |
| + case <-t.C: |
| + } |
| + } |
| +} |
| + |
| +// operatorGrant is one recipient and the token the console sends AS them |
| +// with. The token is never logged. |
| +type operatorGrant struct { |
| + address string |
| + token string |
| +} |
| + |
| +// alertRun is one pass's working state: who to mail, and the operators with |
| +// no grant to mail through — reported at most once per pass, and only if |
| +// the pass had something to say, so a fleet at rest logs nothing. |
| +type alertRun struct { |
| + to []operatorGrant |
| + missing []string |
| + noted bool |
| +} |
| + |
| +// recipients returns the mailable operators, logging the token-less ones the |
| +// first time a pass actually needs to send. |
| +func (r *alertRun) recipients() []operatorGrant { |
| + if !r.noted && len(r.missing) > 0 { |
| + r.noted = true |
| + log.Printf("console: alert watch: no keymail grant for %s — not notified (they must sign in to the console once)", |
| + strings.Join(r.missing, ", ")) |
| + } |
| + return r.to |
| +} |
| + |
| +// alertPass reads every alerts/boxes/*.json, diffs each rule's firing state |
| +// against alert_seen, and for each transition mails every operator holding a |
| +// grant and records the new state. It returns an error only for a failure |
| +// that makes the whole pass meaningless (listing the bucket, or reading the |
| +// operator list); per-box and per-rule trouble is logged and stepped over. |
| +func (s *Server) alertPass(ctx context.Context) error { |
| + if s.cfg.Releases == nil || s.sendApp == nil { |
| + return nil |
| + } |
| + keys, err := s.cfg.Releases.List(ctx, alertPrefix) |
| + if err != nil { |
| + return fmt.Errorf("list %s: %w", alertPrefix, err) |
| + } |
| + if len(keys) == 0 { |
| + return nil // no box has ever published an alert: nothing to do |
| + } |
| + run, err := s.alertRecipients() |
| + if err != nil { |
| + return err |
| + } |
| + for _, key := range keys { |
| + if !strings.HasSuffix(key, ".json") { |
| + continue |
| + } |
| + body, _, err := s.cfg.Releases.Get(ctx, key) |
| + if err != nil { |
| + log.Printf("console: alert watch: read %s: %v", key, err) |
| + continue |
| + } |
| + var st metrics.AlertState |
| + if err := json.Unmarshal(body, &st); err != nil { |
| + log.Printf("console: alert watch: unparsable %s: %v", key, err) |
| + continue |
| + } |
| + box := st.Box |
| + if box == "" { |
| + // Trust the key over an empty body field: the key is what the |
| + // publisher's own AlertKey produced. |
| + box = strings.TrimSuffix(strings.TrimPrefix(key, alertPrefix), ".json") |
| + } |
| + names := make([]string, 0, len(st.Rules)) |
| + for name := range st.Rules { |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + for _, name := range names { |
| + s.alertRule(ctx, run, box, name, st.Rules[name], st.UpdatedAt) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// alertRecipients resolves the operator list into recipients, splitting off |
| +// the operators the console cannot write as. |
| +func (s *Server) alertRecipients() (*alertRun, error) { |
| + addrs, err := s.store.Operators() |
| + if err != nil { |
| + return nil, fmt.Errorf("operators: %w", err) |
| + } |
| + run := &alertRun{} |
| + for _, addr := range addrs { |
| + tok, err := s.store.GrantToken(addr) |
| + if err != nil { |
| + return nil, fmt.Errorf("grant token for %s: %w", addr, err) |
| + } |
| + if tok == "" { |
| + run.missing = append(run.missing, addr) |
| + continue |
| + } |
| + run.to = append(run.to, operatorGrant{address: addr, token: tok}) |
| + } |
| + return run, nil |
| +} |
| + |
| +// alertRule handles one rule of one box: nothing to do unless its firing |
| +// state differs from what operators were last told. |
| +func (s *Server) alertRule(ctx context.Context, run *alertRun, box, rule string, rs metrics.RuleState, updated time.Time) { |
| + seen, known, err := s.store.AlertSeen(box, rule) |
| + if err != nil { |
| + log.Printf("console: alert watch: alert_seen %s/%s: %v", box, rule, err) |
| + return |
| + } |
| + if known && seen == rs.Firing { |
| + return |
| + } |
| + if !known && !rs.Firing { |
| + // First sighting of a quiet rule. Record it so a later fire is a |
| + // transition; say nothing — "everything is fine" is not a page. |
| + if err := s.store.SetAlertSeen(box, rule, false); err != nil { |
| + log.Printf("console: alert watch: record %s/%s: %v", box, rule, err) |
| + } |
| + return |
| + } |
| + subject, body := alertMessage(box, rule, rs, updated) |
| + delivered := true |
| + for _, op := range run.recipients() { |
| + if _, err := s.sendApp(ctx, op.token, op.address, subject, body); err != nil { |
| + // The address is safe to log; the token never is. |
| + log.Printf("console: alert watch: keymail %s/%s to %s: %v", box, rule, op.address, err) |
| + delivered = false |
| + } |
| + } |
| + if !delivered { |
| + // Leave the transition unrecorded: the next pass retries the whole |
| + // thing, which may re-mail an operator who did get it. A duplicate |
| + // page is cheap; a missed one is what this system exists to prevent. |
| + return |
| + } |
| + if err := s.store.SetAlertSeen(box, rule, rs.Firing); err != nil { |
| + log.Printf("console: alert watch: record %s/%s: %v", box, rule, err) |
| + } |
| +} |
| + |
| +// alertMessage is the operator-facing copy. Plain sentences, and no needle |
| +// beyond the value that triggered the transition — this mail lands in an |
| +// inbox, not on the operator dashboard. |
| +func alertMessage(box, rule string, rs metrics.RuleState, updated time.Time) (subject, body string) { |
| + at := updated |
| + if rs.Firing && !rs.FiredAt.IsZero() { |
| + at = rs.FiredAt |
| + } |
| + stamp := at.UTC().Format(time.RFC3339) |
| + if rs.Firing { |
| + subject = fmt.Sprintf("[carlos] %s: %s alert", box, rule) |
| + body = fmt.Sprintf("The %s alert is firing on %s.\n\nValue at the transition: %g\nTime (UTC): %s\n\n"+ |
| + "You are getting this because you hold the CARLOS fleet-operator role.\n", rule, box, rs.Value, stamp) |
| + return subject, body |
| + } |
| + subject = fmt.Sprintf("[carlos] %s: %s cleared", box, rule) |
| + body = fmt.Sprintf("The %s alert has cleared on %s.\n\nValue at the transition: %g\nTime (UTC): %s\n\n"+ |
| + "You are getting this because you hold the CARLOS fleet-operator role.\n", rule, box, rs.Value, stamp) |
| + return subject, body |
| +} |
diff --git a/internal/console/alertwatch_test.go b/internal/console/alertwatch_test.go| new file mode 100644 |
| index 000000000..02ad99d71 |
| --- /dev/null |
| +++ b/internal/console/alertwatch_test.go |
| @@ -0,0 +1,231 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/metrics" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// sentMail is one captured sendApp call. The token is captured so a test can |
| +// assert each operator is mailed with THEIR OWN grant, never a shared one. |
| +type sentMail struct { |
| + token, to, subject, body string |
| +} |
| + |
| +// alertFixture is a console fixture with two operators — one holding a |
| +// keymail grant, one not — and a captured sendApp. |
| +type alertFixture struct { |
| + *consoleFixture |
| + sent []sentMail |
| + err error // what the captured sendApp returns |
| +} |
| + |
| +func newAlertFixture(t *testing.T) *alertFixture { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + af := &alertFixture{consoleFixture: fx} |
| + for _, addr := range []string{"ops-a@keymail.dev", "ops-b@keymail.dev"} { |
| + if err := fx.st.SetOperator(addr, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // Only ops-a ever granted the console send access. |
| + if err := fx.st.SetGrantToken("ops-a@keymail.dev", "grant-a", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.sendApp = func(_ context.Context, token, to, subject, body string) (string, error) { |
| + af.sent = append(af.sent, sentMail{token, to, subject, body}) |
| + if af.err != nil { |
| + return "", af.err |
| + } |
| + return "msg-1", nil |
| + } |
| + return af |
| +} |
| + |
| +func (af *alertFixture) writeAlert(t *testing.T, st metrics.AlertState) { |
| + t.Helper() |
| + b, err := json.Marshal(st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Publishing is a CAS write (create-only on ""), so re-publishing a box's |
| + // state — which is what a fire→clear transition is — needs the etag. |
| + key := metrics.AlertKey(st.Box) |
| + etag := "" |
| + if _, cur, err := af.rel.Get(context.Background(), key); err == nil { |
| + etag = cur |
| + } |
| + if _, err := af.rel.Put(context.Background(), key, b, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func (af *alertFixture) pass(t *testing.T) { |
| + t.Helper() |
| + af.sent = nil |
| + if err := af.srv.alertPass(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func firingDisk(firing bool) metrics.AlertState { |
| + rs := metrics.RuleState{Firing: firing, Value: 0.95} |
| + if firing { |
| + rs.FiredAt = time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) |
| + rs.Since = rs.FiredAt |
| + } else { |
| + rs.Value = 0.80 |
| + } |
| + return metrics.AlertState{ |
| + V: 1, |
| + Box: "box-a", |
| + UpdatedAt: time.Date(2026, 8, 2, 10, 5, 0, 0, time.UTC), |
| + Rules: map[string]metrics.RuleState{"disk": rs}, |
| + } |
| +} |
| + |
| +// TestAlertPassFiresOnceThenGoesQuiet is the spec's core promise: a keymail |
| +// on the TRANSITION, to the operators who hold a grant, and silence while |
| +// the alert stays in the state the console already reported. |
| +func TestAlertPassFiresOnceThenGoesQuiet(t *testing.T) { |
| + af := newAlertFixture(t) |
| + af.writeAlert(t, firingDisk(true)) |
| + |
| + af.pass(t) |
| + if len(af.sent) != 1 { |
| + t.Fatalf("first pass sent %d keymails, want 1: %+v", len(af.sent), af.sent) |
| + } |
| + got := af.sent[0] |
| + if got.to != "ops-a@keymail.dev" || got.token != "grant-a" { |
| + t.Fatalf("sent to %q with token %q, want ops-a@keymail.dev/grant-a", got.to, got.token) |
| + } |
| + if got.subject != "[carlos] box-a: disk alert" { |
| + t.Fatalf("subject = %q", got.subject) |
| + } |
| + for _, want := range []string{"disk", "box-a", "0.95", "2026-08-02T10:00:00Z"} { |
| + if !strings.Contains(got.body, want) { |
| + t.Fatalf("body is missing %q:\n%s", want, got.body) |
| + } |
| + } |
| + if strings.Contains(got.body, "grant-a") { |
| + t.Fatal("body leaked the grant token") |
| + } |
| + |
| + // Recorded: the same state on the next pass says nothing. |
| + firing, known, err := af.st.AlertSeen("box-a", "disk") |
| + if err != nil || !known || !firing { |
| + t.Fatalf("AlertSeen = %v,%v,%v — want true,true,nil", firing, known, err) |
| + } |
| + af.pass(t) |
| + if len(af.sent) != 0 { |
| + t.Fatalf("second pass sent %d keymails, want 0: %+v", len(af.sent), af.sent) |
| + } |
| +} |
| + |
| +// TestAlertPassClears covers the other half of the transition: the alert |
| +// going away is news too. |
| +func TestAlertPassClears(t *testing.T) { |
| + af := newAlertFixture(t) |
| + af.writeAlert(t, firingDisk(true)) |
| + af.pass(t) |
| + |
| + af.writeAlert(t, firingDisk(false)) |
| + af.pass(t) |
| + if len(af.sent) != 1 { |
| + t.Fatalf("clear pass sent %d keymails, want 1: %+v", len(af.sent), af.sent) |
| + } |
| + if af.sent[0].subject != "[carlos] box-a: disk cleared" { |
| + t.Fatalf("subject = %q", af.sent[0].subject) |
| + } |
| + af.pass(t) |
| + if len(af.sent) != 0 { |
| + t.Fatalf("pass after clear sent %d keymails, want 0", len(af.sent)) |
| + } |
| +} |
| + |
| +// TestAlertPassRetriesAfterSendFailure: a failed send leaves the transition |
| +// unrecorded, so the next tick delivers it. A missed page is worse than a |
| +// duplicate one. |
| +func TestAlertPassRetriesAfterSendFailure(t *testing.T) { |
| + af := newAlertFixture(t) |
| + af.writeAlert(t, firingDisk(true)) |
| + af.err = context.DeadlineExceeded |
| + |
| + af.pass(t) |
| + if len(af.sent) != 1 { |
| + t.Fatalf("failing pass attempted %d sends, want 1", len(af.sent)) |
| + } |
| + if _, known, err := af.st.AlertSeen("box-a", "disk"); err != nil || known { |
| + t.Fatalf("AlertSeen recorded despite a send failure (known=%v, err=%v)", known, err) |
| + } |
| + |
| + af.err = nil |
| + af.pass(t) |
| + if len(af.sent) != 1 { |
| + t.Fatalf("retry pass sent %d keymails, want 1", len(af.sent)) |
| + } |
| + if firing, known, err := af.st.AlertSeen("box-a", "disk"); err != nil || !known || !firing { |
| + t.Fatalf("AlertSeen = %v,%v,%v after a successful retry", firing, known, err) |
| + } |
| +} |
| + |
| +// TestAlertPassQuietFirstSighting: a rule the console has never seen that is |
| +// NOT firing is recorded silently. Nobody wants a keymail saying the disk is |
| +// fine. |
| +func TestAlertPassQuietFirstSighting(t *testing.T) { |
| + af := newAlertFixture(t) |
| + af.writeAlert(t, firingDisk(false)) |
| + |
| + af.pass(t) |
| + if len(af.sent) != 0 { |
| + t.Fatalf("quiet first sighting sent %d keymails, want 0: %+v", len(af.sent), af.sent) |
| + } |
| + if firing, known, err := af.st.AlertSeen("box-a", "disk"); err != nil || !known || firing { |
| + t.Fatalf("AlertSeen = %v,%v,%v — want false,true,nil", firing, known, err) |
| + } |
| +} |
| + |
| +// TestAlertPassSurvivesJunk: an unparsable object under alerts/ is skipped, |
| +// and the boxes either side of it are still processed. A watcher that dies |
| +// on one bad file is a watcher that stops paging. |
| +func TestAlertPassSurvivesJunk(t *testing.T) { |
| + af := newAlertFixture(t) |
| + if _, err := af.rel.Put(context.Background(), "alerts/boxes/box-junk.json", []byte("{not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + af.writeAlert(t, firingDisk(true)) |
| + |
| + af.pass(t) |
| + if len(af.sent) != 1 { |
| + t.Fatalf("pass sent %d keymails, want 1: %+v", len(af.sent), af.sent) |
| + } |
| +} |
| + |
| +// TestAlertPassEmptyBucket: no alerts/ prefix at all is a no-op, not an |
| +// error — a fleet that has never published an alert is the normal case. |
| +func TestAlertPassEmptyBucket(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.sendApp = func(context.Context, string, string, string, string) (string, error) { |
| + t.Fatal("sent a keymail with no alerts published") |
| + return "", nil |
| + } |
| + if err := fx.srv.alertPass(context.Background()); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// TestNewServerDefaultsSendApp: production wiring goes through the console's |
| +// own keymail client; nothing else in NewServer starts a goroutine. |
| +func TestNewServerDefaultsSendApp(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if fx.srv.sendApp == nil { |
| + t.Fatal("NewServer left sendApp nil — the watcher would never deliver") |
| + } |
| + var _ release.Store = fx.rel |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 61807d3f7..8f7391309 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -120,6 +120,10 @@ type Server struct { |
| // notification attempt. Tests only — the send is off the request |
| // goroutine, and a test that asserted on it without this would be racing. |
| cliNotified chan struct{} |
| + // sendApp is how the alert watcher (alertwatch.go) delivers keymail: |
| + // km.SendApp in production, a capture in tests. A field rather than a |
| + // direct call so an alertPass assertion needs no keymail server. |
| + sendApp func(ctx context.Context, token, to, subject, body string) (string, error) |
| } |
| // NewServer wires a server over an opened Store, loading (or minting) the |
| @@ -141,6 +145,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| secure: strings.HasPrefix(cfg.Origin, "https://"), |
| cli: newCLILimits(), |
| } |
| + if s.km != nil { |
| + s.sendApp = s.km.SendApp |
| + } |
| + // No goroutine is started here: StartAlertWatcher is serve()'s job, so a |
| + // test that constructs a Server gets a Server and nothing else. |
| s.mux.HandleFunc("GET /{$}", s.handleHome) |
| s.mux.HandleFunc("POST /auth/continue", s.handleContinue) |
| s.mux.HandleFunc("GET /auth/signin", s.handleSignin) |
diff --git a/internal/console/store.go b/internal/console/store.go| index 1271001eb..07e471c65 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -160,6 +160,17 @@ func (s *Store) migrate() error { |
| created_at INTEGER NOT NULL, |
| expires_at INTEGER NOT NULL, |
| accepted_at INTEGER NOT NULL DEFAULT 0 |
| +);`, |
| + // The alert watcher's memory (alertwatch.go): the firing state the |
| + // console last told operators about, per (box, rule). A row's absence |
| + // means "never seen", which is why AlertSeen reports known separately |
| + // — a first sighting of a firing rule is news, a first sighting of a |
| + // quiet one is not. |
| + `CREATE TABLE IF NOT EXISTS alert_seen ( |
| + box TEXT NOT NULL, |
| + rule TEXT NOT NULL, |
| + firing INTEGER NOT NULL, |
| + PRIMARY KEY (box, rule) |
| );`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil { |
| @@ -1064,6 +1075,58 @@ func (s *Store) IsOperator(address string) (bool, error) { |
| return v != 0, nil |
| } |
| +// Operators lists every address holding the fleet-operator bit, sorted — |
| +// the alert watcher's recipient list. Sorted so a pass mails people in a |
| +// stable order and a test can assert on it. |
| +func (s *Store) Operators() ([]string, error) { |
| + rows, err := s.db.Query(`SELECT address FROM users WHERE operator != 0 ORDER BY address`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []string |
| + for rows.Next() { |
| + var a string |
| + if err := rows.Scan(&a); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, a) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// ---- alert watcher state (alertwatch.go) ------------------------------------ |
| + |
| +// AlertSeen reports the firing state the console last NOTIFIED operators |
| +// about for (box, rule), and whether it has ever seen that rule at all. |
| +// known=false is not the same as firing=false: a rule the console has never |
| +// seen fires as news, while one it has seen quiet stays quiet. |
| +func (s *Store) AlertSeen(box, rule string) (firing bool, known bool, err error) { |
| + var v int |
| + err = s.db.QueryRow(`SELECT firing FROM alert_seen WHERE box = ? AND rule = ?`, box, rule).Scan(&v) |
| + if err == sql.ErrNoRows { |
| + return false, false, nil |
| + } |
| + if err != nil { |
| + return false, false, err |
| + } |
| + return v != 0, true, nil |
| +} |
| + |
| +// SetAlertSeen records that operators have been told (box, rule) is in this |
| +// state. Written only AFTER every send for a transition succeeded, so a |
| +// delivery failure leaves the transition pending and the next pass retries |
| +// it — a duplicate page beats a missed one. |
| +func (s *Store) SetAlertSeen(box, rule string, firing bool) error { |
| + v := 0 |
| + if firing { |
| + v = 1 |
| + } |
| + _, err := s.db.Exec(`INSERT INTO alert_seen (box, rule, firing) VALUES (?, ?, ?) |
| +ON CONFLICT(box, rule) DO UPDATE SET firing = excluded.firing`, box, rule, v) |
| + return err |
| +} |
| + |
| // ---- grants (the console's Keymail access tokens) --------------------------- |
| // SetGrantToken stores the Keymail access token the user's OAuth approval |
diff --git a/internal/console/store_test.go b/internal/console/store_test.go| index 54b788bd4..c59908ebe 100644 |
| --- a/internal/console/store_test.go |
| +++ b/internal/console/store_test.go |
| @@ -330,6 +330,67 @@ func TestOperatorMigrationIdempotent(t *testing.T) { |
| } |
| } |
| +// TestOperatorsList: the alert watcher's recipient list is exactly the |
| +// addresses holding the operator bit, sorted, and revoking removes you. |
| +func TestOperatorsList(t *testing.T) { |
| + s := openTestStore(t) |
| + if ops, err := s.Operators(); err != nil || len(ops) != 0 { |
| + t.Fatalf("Operators on an empty store = %v %v, want none", ops, err) |
| + } |
| + for _, a := range []string{"zoe@keymail.dev", "amy@keymail.dev"} { |
| + if err := s.SetOperator(a, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // A plain user (never an operator) is not on the list. |
| + if _, err := s.EnsureUser("bob@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + ops, err := s.Operators() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(ops, []string{"amy@keymail.dev", "zoe@keymail.dev"}) { |
| + t.Fatalf("Operators = %v", ops) |
| + } |
| + if err := s.SetOperator("zoe@keymail.dev", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ops, _ := s.Operators(); !reflect.DeepEqual(ops, []string{"amy@keymail.dev"}) { |
| + t.Fatalf("Operators after revoke = %v", ops) |
| + } |
| +} |
| + |
| +// TestAlertSeenRoundTrip: the watcher's memory. "Never seen" and "seen, not |
| +// firing" are different answers — the first is news on a fire, the second is |
| +// what makes the second pass quiet. |
| +func TestAlertSeenRoundTrip(t *testing.T) { |
| + s := openTestStore(t) |
| + if firing, known, err := s.AlertSeen("box-a", "disk"); err != nil || known || firing { |
| + t.Fatalf("AlertSeen before anything = %v %v %v, want false false nil", firing, known, err) |
| + } |
| + if err := s.SetAlertSeen("box-a", "disk", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if firing, known, err := s.AlertSeen("box-a", "disk"); err != nil || !known || !firing { |
| + t.Fatalf("AlertSeen after fire = %v %v %v, want true true nil", firing, known, err) |
| + } |
| + // Same (box, rule) upserts rather than duplicating. |
| + if err := s.SetAlertSeen("box-a", "disk", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if firing, known, err := s.AlertSeen("box-a", "disk"); err != nil || !known || firing { |
| + t.Fatalf("AlertSeen after clear = %v %v %v, want false true nil", firing, known, err) |
| + } |
| + // Rules and boxes are independent keys. |
| + if _, known, _ := s.AlertSeen("box-a", "mem"); known { |
| + t.Fatal("AlertSeen(box-a, mem) is known — the rule key is not being used") |
| + } |
| + if _, known, _ := s.AlertSeen("box-b", "disk"); known { |
| + t.Fatal("AlertSeen(box-b, disk) is known — the box key is not being used") |
| + } |
| +} |
| + |
| func TestCreateAppClaimOnce(t *testing.T) { |
| s := openTestStore(t) |
| now := time.Now() |