carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log"
8 "sort"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/carlosframework/platform/internal/metrics"
14)
15
16// The keymail half of alert delivery lives here rather than on the box,
17// because the only keymail send path that exists is the console's per-user
18// OAuth grant (Keymail.SendApp) and the agent has no keymail identity. The
19// box judges itself and publishes alerts/boxes/<box>.json; this watcher
20// polls that prefix, diffs each rule's firing state against what it last
21// told each operator (the alert_seen table), and mails the difference —
22// each operator with that operator's OWN grant token, so nobody is sent
23// mail on a credential they did not personally issue.
24//
25// The diff is per RECIPIENT, not per rule, because delivery fails per
26// recipient: a success records that operator's row and a failure leaves
27// only theirs unrecorded, so the next pass retries exactly the operator
28// who missed out. One operator's revoked grant can no longer re-page every
29// healthy operator on the fleet, tick after tick.
30//
31// The failure posture throughout is "never go silent": an unreadable or
32// unparsable alert object is logged and skipped, an undelivered transition
33// stays pending for that operator until it lands, and no path here can
34// panic a console that is otherwise serving fine.
35
36// alertWatchInterval is the poll cadence from the spec: a minute is fast
37// enough for a disk filling up and slow enough to be free.
38const alertWatchInterval = time.Minute
39
40// alertPrefix is the bucket prefix the agent publishes AlertState under
41// (metrics.AlertKey).
42const alertPrefix = "alerts/boxes/"
43
44// StartAlertWatcher runs alertPass immediately and then every minute until
45// ctx is done. Started from cmd/console's serve(), deliberately NOT from
46// NewServer: tests construct servers by the dozen and none of them should
47// grow a background goroutine or a keymail send.
48func (s *Server) StartAlertWatcher(ctx context.Context) {
49 t := time.NewTicker(alertWatchInterval)
50 defer t.Stop()
51 for {
52 if err := s.alertPass(ctx); err != nil {
53 log.Printf("console: alert watch: %v", err)
54 }
55 select {
56 case <-ctx.Done():
57 return
58 case <-t.C:
59 }
60 }
61}
62
63// operatorGrant is one recipient and the token the console sends AS them
64// with. The token is never logged.
65type operatorGrant struct {
66 address string
67 token string
68}
69
70// alertRun is one pass's working state: who to mail, and the operators with
71// no grant to mail through — reported at most once per pass, and only if
72// the pass had something to say, so a fleet at rest logs nothing.
73type alertRun struct {
74 to []operatorGrant
75 missing []string
76 noted bool
77}
78
79// noteMissing names the operators the console cannot write as, at most once
80// per pass, called only when the pass actually has something to deliver.
81func (r *alertRun) noteMissing() {
82 if r.noted || len(r.missing) == 0 {
83 return
84 }
85 r.noted = true
86 log.Printf("console: alert watch: no keymail grant for %s — not notified (they must sign in to the console once)",
87 strings.Join(r.missing, ", "))
88}
89
90// noMailboxAddress is the synthetic alert_seen recipient standing for "no
91// operator was mailable when this transition happened". Recording it stops
92// the transition replaying — and being re-logged — every minute forever,
93// at the honest cost that the alert is simply lost: an operator who gains a
94// grant later is not caught up on it (see alertSeenFor).
95const noMailboxAddress = ""
96
97// alertPass reads every alerts/boxes/*.json and, for each rule, diffs its
98// firing state against what alert_seen says each operator was last told,
99// mailing and recording per recipient. It returns an error only for a failure
100// that makes the whole pass meaningless (listing the bucket, or reading the
101// operator list); per-box and per-rule trouble is logged and stepped over.
102func (s *Server) alertPass(ctx context.Context) error {
103 if s.cfg.Releases == nil || s.sendApp == nil {
104 return nil
105 }
106 keys, err := s.cfg.Releases.List(ctx, alertPrefix)
107 if err != nil {
108 return fmt.Errorf("list %s: %w", alertPrefix, err)
109 }
110 if len(keys) == 0 {
111 return nil // no box has ever published an alert: nothing to do
112 }
113 run, err := s.alertRecipients()
114 if err != nil {
115 return err
116 }
117 for _, key := range keys {
118 if !strings.HasSuffix(key, ".json") {
119 continue
120 }
121 body, _, err := s.cfg.Releases.Get(ctx, key)
122 if err != nil {
123 log.Printf("console: alert watch: read %s: %v", key, err)
124 continue
125 }
126 var st metrics.AlertState
127 if err := json.Unmarshal(body, &st); err != nil {
128 log.Printf("console: alert watch: unparsable %s: %v", key, err)
129 continue
130 }
131 box := st.Box
132 if box == "" {
133 // Trust the key over an empty body field: the key is what the
134 // publisher's own AlertKey produced.
135 box = strings.TrimSuffix(strings.TrimPrefix(key, alertPrefix), ".json")
136 }
137 names := make([]string, 0, len(st.Rules))
138 for name := range st.Rules {
139 names = append(names, name)
140 }
141 sort.Strings(names)
142 for _, name := range names {
143 s.alertRule(ctx, run, box, name, st.Rules[name], st.UpdatedAt)
144 }
145 }
146 return nil
147}
148
149// alertRecipients resolves the operator list into recipients, splitting off
150// the operators the console cannot write as.
151func (s *Server) alertRecipients() (*alertRun, error) {
152 addrs, err := s.store.Operators()
153 if err != nil {
154 return nil, fmt.Errorf("operators: %w", err)
155 }
156 return s.grantRecipients(addrs)
157}
158
159// grantRecipients resolves canonical addresses to their own Keymail grants.
160// Alert delivery and one-shot console notifications share this lookup so both
161// keep the same missing-grant and per-recipient-token behaviour.
162func (s *Server) grantRecipients(addrs []string) (*alertRun, error) {
163 run := &alertRun{}
164 seen := make(map[string]bool)
165 for _, addr := range addrs {
166 addr = strings.ToLower(strings.TrimSpace(addr))
167 if addr == "" || seen[addr] {
168 continue
169 }
170 seen[addr] = true
171 tok, err := s.store.GrantToken(addr)
172 if err != nil {
173 return nil, fmt.Errorf("grant token for %s: %w", addr, err)
174 }
175 if tok == "" {
176 run.missing = append(run.missing, addr)
177 continue
178 }
179 run.to = append(run.to, operatorGrant{address: addr, token: tok})
180 }
181 return run, nil
182}
183
184// alertRule handles one rule of one box: every mailable operator whose
185// recorded state differs from the published one gets a keymail, and each
186// send is recorded on its own.
187func (s *Server) alertRule(ctx context.Context, run *alertRun, box, rule string, rs metrics.RuleState, updated time.Time) {
188 if len(run.to) == 0 {
189 // Nobody the console can write as. Record the transition against the
190 // synthetic recipient so it stops replaying, and say so out loud —
191 // this is the one path where an alert is genuinely dropped.
192 if s.alertDueFor(box, rule, noMailboxAddress, rs) {
193 run.noteMissing()
194 log.Printf("console: alert watch: %s/%s transition dropped — no operator holds a keymail grant", box, rule)
195 s.recordAlertSeenFor(box, rule, noMailboxAddress, rs)
196 }
197 return
198 }
199 var due []operatorGrant
200 for _, op := range run.to {
201 if s.alertDueFor(box, rule, op.address, rs) {
202 due = append(due, op)
203 }
204 }
205 if len(due) == 0 {
206 return
207 }
208 run.noteMissing()
209 subject, body := alertMessage(box, rule, rs, updated)
210 for _, op := range due {
211 if _, err := s.sendApp(ctx, op.token, op.address, subject, body); err != nil {
212 // The address is safe to log; the token never is. Leaving this
213 // operator's row unrecorded is the retry: the next pass mails
214 // them and nobody else.
215 log.Printf("console: alert watch: keymail %s/%s to %s: %v", box, rule, op.address, err)
216 continue
217 }
218 s.recordAlertSeenFor(box, rule, op.address, rs)
219 }
220}
221
222// geoipReasonRule is the dedup key for one geoip reason (geoip spec
223// §10.4). The geoip rule's VALUE is a reason code, and a reason that
224// changes while firing (expiring becomes expired) is news the operator
225// must get, though firing never went false (code review R4-3). The plain
226// "geoip" row still carries firing/cleared; one extra row per reason marks
227// which reasons this address has been told about.
228func geoipReasonRule(code float64) string { return fmt.Sprintf("geoip@%d", int(code)) }
229
230// alertDueFor is alertDue plus the geoip reason rule.
231func (s *Server) alertDueFor(box, rule, address string, rs metrics.RuleState) bool {
232 due := s.alertDue(box, rule, address, rs.Firing)
233 if rule == "geoip" && rs.Firing && !due {
234 due = s.alertDue(box, geoipReasonRule(rs.Value), address, true)
235 }
236 return due
237}
238
239// recordAlertSeenFor records the transition and, for geoip, which reason
240// this address was LAST told about: exactly one reason row is true while
241// firing and none after a clear, so a reason seen before (2, 3, back to 2,
242// or a later episode) is news again (code review R5 on R4-3).
243func (s *Server) recordAlertSeenFor(box, rule, address string, rs metrics.RuleState) {
244 s.recordAlertSeen(box, rule, address, rs.Firing)
245 if rule != "geoip" {
246 return
247 }
248 for code := 1; code <= 5; code++ {
249 s.recordAlertSeen(box, geoipReasonRule(float64(code)), address, rs.Firing && int(rs.Value) == code)
250 }
251}
252
253// alertDue reports whether address must be told that (box, rule) is now in
254// this firing state, recording a quiet first sighting as it goes — nobody
255// wants mail saying the disk is fine.
256//
257// An address the console has never written to about this rule falls back to
258// the synthetic no-mailbox row: if the transition was already dropped for
259// want of any grant at all, an operator who gains a grant afterwards is not
260// retroactively paged about it.
261func (s *Server) alertDue(box, rule, address string, firing bool) bool {
262 seen, known, err := s.store.AlertSeen(box, rule, address)
263 if err != nil {
264 log.Printf("console: alert watch: alert_seen %s/%s for %s: %v", box, rule, address, err)
265 return false
266 }
267 if !known && address != noMailboxAddress {
268 seen, known, err = s.store.AlertSeen(box, rule, noMailboxAddress)
269 if err != nil {
270 log.Printf("console: alert watch: alert_seen %s/%s: %v", box, rule, err)
271 return false
272 }
273 }
274 if known && seen == firing {
275 return false
276 }
277 if !known && !firing {
278 // First sighting of a quiet rule. Record it so a later fire is a
279 // transition; say nothing.
280 s.recordAlertSeen(box, rule, address, false)
281 return false
282 }
283 return true
284}
285
286func (s *Server) recordAlertSeen(box, rule, address string, firing bool) {
287 if err := s.store.SetAlertSeen(box, rule, address, firing); err != nil {
288 log.Printf("console: alert watch: record %s/%s for %s: %v", box, rule, address, err)
289 }
290}
291
292// alertValueText renders one rule's transition value in that rule's own
293// unit. Every rule internal/metrics evaluates carries a fraction EXCEPT lag,
294// which is keymail relay seconds (see metrics.Rules.LagSecs) — so a single
295// percent formatter for all of them turns the first lag alert to fire into
296// "92000%". Used by both operator-facing surfaces: the dashboard's alerts
297// table and the alert mail below.
298//
299// An unknown rule name (a future rule this console has not been taught) is
300// rendered as a bare number rather than being given someone else's unit: a
301// unitless figure is honest, a wrong unit is not.
302func alertValueText(rule string, value float64) string {
303 switch rule {
304 case "disk", "mem", "cpu":
305 return fmt.Sprintf("%.0f%%", value*100)
306 case "lag":
307 return fmt.Sprintf("%.0fs", value)
308 case "geoip":
309 // A reason code, not a measurement (geoip spec §10.4).
310 return geoipReasonText(int(value))
311 default:
312 return strconv.FormatFloat(value, 'g', -1, 64)
313 }
314}
315
316// alertMessage is the operator-facing copy. Plain sentences, and no needle
317// beyond the value that triggered the transition — this mail lands in an
318// inbox, not on the operator dashboard.
319func alertMessage(box, rule string, rs metrics.RuleState, updated time.Time) (subject, body string) {
320 at := updated
321 if rs.Firing && !rs.FiredAt.IsZero() {
322 at = rs.FiredAt
323 }
324 stamp := at.UTC().Format(time.RFC3339)
325 if rs.Firing {
326 subject = fmt.Sprintf("[carlos] %s: %s alert", box, rule)
327 body = fmt.Sprintf("The %s alert is firing on %s.\n\nValue at the transition: %s\nTime (UTC): %s\n\n"+
328 "You are getting this because you hold the CARLOS fleet-operator role.\n", rule, box, alertValueText(rule, rs.Value), stamp)
329 return subject, body
330 }
331 subject = fmt.Sprintf("[carlos] %s: %s cleared", box, rule)
332 body = fmt.Sprintf("The %s alert has cleared on %s.\n\nValue at the transition: %s\nTime (UTC): %s\n\n"+
333 "You are getting this because you hold the CARLOS fleet-operator role.\n", rule, box, alertValueText(rule, rs.Value), stamp)
334 return subject, body
335}
336
337// geoipReasonText names a geoip rule reason code (geoip spec §10.4).
338func geoipReasonText(code int) string {
339 switch code {
340 case 1:
341 return "database over 45 days old"
342 case 2:
343 return "GeoLite2 database expires within 9 days"
344 case 3:
345 return "database expired and removed"
346 case 4:
347 return "published database not loaded for over an hour"
348 case 5:
349 return "feed failing"
350 }
351 return "geoip reason " + strconv.Itoa(code)
352}
353