carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log"
8 "math"
9 "strings"
10 "sync/atomic"
11 "time"
12
13 "github.com/carlosframework/platform/internal/alerting"
14 "github.com/carlosframework/platform/internal/metrics"
15 "github.com/carlosframework/platform/internal/release"
16 "github.com/carlosframework/platform/internal/status"
17)
18
19// Alert sinks: where a firing or clearing transition from metrics.Evaluate
20// goes once alertTick has published the new state to the bucket.
21//
22// Two sinks, either, both, or neither:
23//
24// CARLOS_ALERT_WEBHOOK the event as JSON, POSTed (httpPostWebhook)
25// CARLOS_ALERT_SNS_TOPIC_ARN a plain-text subject and message, published
26// to that SNS topic with the box's instance role
27//
28// SNS is here because a webhook cannot reach it: Publish needs a SigV4
29// signature, which only the AWS SDK on a box holding the role can make. The
30// flagship's topics (carlos-flagship-<region>-alerts) already carry confirmed
31// email subscriptions, so this is the path from a disk at 91% to an inbox
32// (discussion #192).
33//
34// Delivery is best effort and never fatal. A sink that fails is logged and
35// counted, the other sink still runs, and the tick carries on. Failed sends
36// are not rescheduled by a later tick: Evaluate emits an event only on a
37// transition, so the next tick has no event to resend. (The SDK itself tries a
38// retryable failure up to three times inside one publish, and a failed bucket
39// write in alertTick repeats the whole transition next tick, delivery
40// included.) The count is what makes a lost send visible, through
41// status.AlertDelivery in the box snapshot.
42//
43// The delivery itself (the webhook post, the SNS publish and its timeout)
44// is internal/alerting's, shared with the console; this file formats the
45// box's alerts and counts their failures. The same sinks carry the
46// operator pages of the moves code (PageOperator, moves spec §13).
47
48// snsPublisher is the one SNS call the sinks make.
49type snsPublisher = alerting.Publisher
50
51// alertCounters counts failed sends since the process started, and holds the
52// one fact the status loop cannot derive from the env: whether the SNS client
53// was actually built. They are separate goroutines, so this is their seam.
54type alertCounters struct {
55 webhook atomic.Int64
56 sns atomic.Int64
57 // snsInit is snsInitUnknown until buildAlertSinks has run for a valid
58 // topic ARN, then snsInitOK or snsInitFailed.
59 snsInit atomic.Int32
60}
61
62const (
63 snsInitUnknown int32 = iota
64 snsInitOK
65 snsInitFailed
66)
67
68// processAlertCounters is the process-wide count the metrics loop writes
69// and the status loop reads. They are separate goroutines with no other
70// shared state, so this is the seam between them.
71var processAlertCounters alertCounters
72
73// alertSinks is the configured delivery for one agent: alerting's sinks,
74// counting their failures in counts.
75type alertSinks struct {
76 sinks alerting.Sinks
77 counts *alertCounters
78}
79
80// newAlertSinks builds a sink set. An empty webhookURL or topicARN switches
81// that sink off; a nil publisher does the same for SNS.
82func newAlertSinks(webhookURL string, post func(url string, body []byte) error,
83 topicARN string, pub snsPublisher, counts *alertCounters) *alertSinks {
84 if counts == nil {
85 counts = &processAlertCounters
86 }
87 return &alertSinks{counts: counts, sinks: alerting.Sinks{WebhookURL: webhookURL, Post: post, TopicARN: topicARN, Pub: pub,
88 WebhookFailed: func() { counts.webhook.Add(1) }, SNSFailed: func() { counts.sns.Add(1) }}}
89}
90
91// deliver sends one transition to every configured sink. Safe on a nil
92// receiver, which means "no sinks".
93func (a *alertSinks) deliver(ctx context.Context, rules metrics.Rules, at time.Time, ev metrics.Event) {
94 if a == nil {
95 return
96 }
97 if a.sinks.HasWebhook() {
98 evBody, err := json.Marshal(ev)
99 if err != nil {
100 log.Printf("host: alert event %s: marshal: %v", ev.Rule, err)
101 } else if err := a.sinks.PostWebhook(evBody); err != nil {
102 log.Printf("host: alert webhook post (rule %s): %v", ev.Rule, err)
103 }
104 }
105 if a.sinks.HasSNS() {
106 subject, message := formatAlert(rules, at, ev)
107 if err := a.sinks.PublishSNS(ctx, subject, message); err != nil {
108 log.Printf("host: alert sns publish (rule %s): %v", ev.Rule, err)
109 }
110 }
111}
112
113// PageOperator pages an operator through this agent's sinks: the same
114// webhook and SNS topic deliver uses, its failures counted with theirs
115// (alerting.Sinks.Page). Safe on a nil receiver, which means "no sinks":
116// nil, and one line in the journal.
117func (a *alertSinks) PageOperator(ctx context.Context, level, subject, body string) error {
118 if a == nil {
119 return (*alerting.Sinks)(nil).Page(ctx, level, subject, body)
120 }
121 return a.sinks.Page(ctx, level, subject, body)
122}
123
124// PageOperator pages an operator on the sinks the metrics loop armed
125// (armPaging): the moves code's paging primitive (moves spec §13). Before
126// they are armed, or with none configured, it returns nil and says so once.
127func PageOperator(ctx context.Context, level, subject, body string) error {
128 return alerting.Page(ctx, level, subject, body)
129}
130
131// armAgentAlerts builds the agent's alert sinks from the env and arms
132// paging on them (armPaging). runAgent calls it once at startup, before
133// anything that can run a moves pass, and hands the same instance to the
134// metrics loop, so arming never waits on the metrics store.
135func armAgentAlerts(ctx context.Context, getenv func(string) string, counts *alertCounters, newClient snsClientFactory) *alertSinks {
136 sinks := buildAlertSinks(ctx, getenv, counts, newClient)
137 armPaging(sinks)
138 return sinks
139}
140
141// armPaging makes a the sinks PageOperator pages on. nil means none.
142func armPaging(a *alertSinks) {
143 if a == nil {
144 alerting.Arm(nil)
145 return
146 }
147 alerting.Arm(&a.sinks)
148}
149
150// deliverRelease sends a D1 refusal to every configured sink. The webhook
151// body is the record with kind "release_refused"; SNS gets a sentence.
152func (a *alertSinks) deliverRelease(ctx context.Context, r release.Refusal) {
153 if a == nil {
154 return
155 }
156 if a.sinks.HasWebhook() {
157 body, err := json.Marshal(struct {
158 Kind string `json:"kind"`
159 // The record's own Kind is shadowed by the envelope's "kind"
160 // above, so it travels as refusal_kind.
161 RefusalKind string `json:"refusal_kind,omitempty"`
162 release.Refusal
163 }{"release_refused", r.Kind, r})
164 if err == nil {
165 if err := a.sinks.PostWebhook(body); err != nil {
166 log.Printf("host: alert webhook post (release refusal %s): %v", r.Host, err)
167 }
168 }
169 }
170 if a.sinks.HasSNS() {
171 subject, msg := formatReleaseAlert(r)
172 if err := a.sinks.PublishSNS(ctx, subject, msg); err != nil {
173 log.Printf("host: alert sns publish (release refusal %s): %v", r.Host, err)
174 }
175 }
176}
177
178// formatReleaseAlert is a refusal's SNS subject and message. A recovery that
179// failed reads as one in the subject (spec §2.6): "rolled back" over a box
180// whose restored build never came up would send the operator back to bed.
181func formatReleaseAlert(r release.Refusal) (subject, msg string) {
182 if r.Kind == release.RefusalWrongProgram {
183 // Nothing started and nothing was rolled back: the box never left its
184 // old build, and "--retry" would be refused the same way. A version's
185 // manifest is immutable, so the way out is a NEW version.
186 subject = fmt.Sprintf("%s: %s refused, wrong program (still on %s)", r.Host, r.Version, r.RestoredVersion)
187 if len(subject) > 100 { // SNS subject limit
188 subject = subject[:100]
189 }
190 msg = fmt.Sprintf("%s refused %s at %s: %s\n\nShip a new version with carlos ship --program-change and promote it, if the change is intended, or promote a different build.",
191 r.Host, r.Version, r.RefusedAt.Format(time.RFC3339), r.Reason)
192 return subject, msg
193 }
194 if r.Kind == release.RefusalNotProven {
195 // D3's binding check: the same shape as the wrong program. Nothing
196 // started and nothing was rolled back, and "--retry" would re-sign
197 // the pointer over the same staged bytes.
198 still := r.RestoredVersion
199 if still == "" {
200 still = "nothing"
201 }
202 subject = fmt.Sprintf("%s: %s refused, staged bytes do not match dev's proof (still on %s)", r.Host, r.Version, still)
203 if len(subject) > 100 { // SNS subject limit
204 subject = subject[:100]
205 }
206 msg = fmt.Sprintf("%s refused %s at %s: %s\n\nThe staged bytes do not match dev's proof, so %s was never pointed live. The box is still on %s. Roll the exact bytes dev proved.",
207 r.Host, r.Version, r.RefusedAt.Format(time.RFC3339), strings.TrimPrefix(r.Reason, "not proven: "), r.Version, still)
208 return subject, msg
209 }
210 if r.Recovered {
211 subject = fmt.Sprintf("%s: %s did not start, rolled back to %s", r.Host, r.Version, r.RestoredVersion)
212 } else {
213 subject = fmt.Sprintf("%s: %s did not start and the rollback did not come up either", r.Host, r.Version)
214 }
215 if len(subject) > 100 { // SNS subject limit
216 subject = subject[:100]
217 }
218 msg = fmt.Sprintf("%s refused %s at %s: %s", r.Host, r.Version, r.RefusedAt.Format(time.RFC3339), r.Reason)
219 if !r.Recovered {
220 msg += "\n\nThe rollback did not come up either: " + recoveryError(r)
221 }
222 msg += fmt.Sprintf("\n\nRetry with: carlos promote --retry %s <channel>", r.Version)
223 return subject, msg
224}
225
226// recoveryError is why a refusal's recovery failed, never empty for one that
227// did: a record written before the outcome was recorded has none to give.
228func recoveryError(r release.Refusal) string {
229 if r.RecoveryError != "" {
230 return r.RecoveryError
231 }
232 return "outcome not recorded"
233}
234
235// parseAlertTopicARN checks CARLOS_ALERT_SNS_TOPIC_ARN and returns the
236// region the topic lives in (alerting.ParseTopicARN).
237func parseAlertTopicARN(s string) (region string, err error) { return alerting.ParseTopicARN(s) }
238
239// alertDeliveryProbe is the box's answer to "do my alert rules have anywhere
240// to go". The webhook and the ARN's shape come from the same env
241// buildAlertSinks reads; whether the SNS client was actually built comes from
242// counts, which buildAlertSinks fills in. So the snapshot reports the sink the
243// agent really holds: a topic ARN that does not parse is not a sink, and one
244// whose client failed to build reads SNS=false, SNSUnavailable=true.
245func alertDeliveryProbe(getenv func(string) string, counts *alertCounters) *status.AlertDelivery {
246 if counts == nil {
247 counts = &processAlertCounters
248 }
249 out := &status.AlertDelivery{
250 Webhook: strings.TrimSpace(getenv("CARLOS_ALERT_WEBHOOK")) != "",
251 WebhookFailures: counts.webhook.Load(),
252 SNSFailures: counts.sns.Load(),
253 }
254 if _, err := parseAlertTopicARN(getenv("CARLOS_ALERT_SNS_TOPIC_ARN")); err == nil {
255 // Only an initialised client is a sink. snsInitUnknown (not built
256 // yet, or the metrics loop never got there) is neither available
257 // nor unavailable: the box has no working SNS sink to report.
258 switch counts.snsInit.Load() {
259 case snsInitOK:
260 out.SNS = true
261 case snsInitFailed:
262 out.SNSUnavailable = true
263 }
264 }
265 return out
266}
267
268// snsClientFactory builds the publisher for a region. It is a parameter so
269// tests can make client construction fail without an AWS config.
270type snsClientFactory func(ctx context.Context, region string) (snsPublisher, error)
271
272// newSNSClient is the production factory: the default credential chain (the
273// box's instance role), aimed at the topic's own region.
274func newSNSClient(ctx context.Context, region string) (snsPublisher, error) {
275 return alerting.NewSNSClient(ctx, region)
276}
277
278// buildAlertSinks reads the sink env and builds the sinks. A bad topic ARN
279// is logged once and leaves SNS off; a client that cannot be built does the
280// same, and is also recorded in counts (state and one counted failure) so the
281// status snapshot reports the sink the agent really has. Neither stops the
282// metrics loop. With nothing configured it says so in the journal, because
283// that is the state that used to be silent.
284func buildAlertSinks(ctx context.Context, getenv func(string) string, counts *alertCounters, newClient snsClientFactory) *alertSinks {
285 webhook := strings.TrimSpace(getenv("CARLOS_ALERT_WEBHOOK"))
286 topic := strings.TrimSpace(getenv("CARLOS_ALERT_SNS_TOPIC_ARN"))
287
288 var pub snsPublisher
289 if topic != "" {
290 region, err := parseAlertTopicARN(topic)
291 if err != nil {
292 log.Printf("agent: alerts: CARLOS_ALERT_SNS_TOPIC_ARN is not usable, SNS sink off: %v", err)
293 topic = ""
294 } else if p, err := newClient(ctx, region); err != nil {
295 log.Printf("agent: alerts: build SNS client, SNS sink off: %v", err)
296 counts.sns.Add(1)
297 counts.snsInit.Store(snsInitFailed)
298 topic = ""
299 } else {
300 counts.snsInit.Store(snsInitOK)
301 pub = p
302 }
303 }
304
305 switch {
306 case webhook == "" && topic == "":
307 log.Printf("agent: alerts: NO SINK CONFIGURED (set CARLOS_ALERT_SNS_TOPIC_ARN or CARLOS_ALERT_WEBHOOK); disk, memory and cpu alerts will be published to the bucket and delivered nowhere")
308 case webhook != "" && topic != "":
309 log.Printf("agent: alerts: delivering to webhook and SNS topic %s", topic)
310 case topic != "":
311 log.Printf("agent: alerts: delivering to SNS topic %s", topic)
312 default:
313 log.Printf("agent: alerts: delivering to webhook")
314 }
315 return newAlertSinks(webhook, httpPostWebhook, topic, pub, counts)
316}
317
318// formatAlert renders one transition as an email: a one-line subject and a
319// short plain-text body. The inputs are the event, the rule levels and a
320// time, and nothing else, so no URL, ARN or credential can reach the
321// message. The subject is ASCII with no newline and under 100 characters,
322// which is what SNS accepts.
323func formatAlert(rules metrics.Rules, at time.Time, ev metrics.Event) (subject, body string) {
324 subjectLine := alertSubject(ev)
325 var b strings.Builder
326 if ev.Firing {
327 fmt.Fprintf(&b, "%s.\n\n", alertSentence(ev))
328 } else {
329 fmt.Fprintf(&b, "%s.\n\n", alertClearSentence(ev))
330 }
331 if level, ok := alertLevel(rules, ev.Rule); ok {
332 fmt.Fprintf(&b, "the alert level is %s.\n", level)
333 }
334 if ev.Firing {
335 if d := alertSustain(rules, ev.Rule); d > 0 {
336 fmt.Fprintf(&b, "it stayed above that level for %d minutes before this alert was raised.\n", int(d.Minutes()))
337 }
338 }
339 fmt.Fprintf(&b, "this was reported at %s.\n", at.UTC().Format("2006-01-02 15:04 MST"))
340 if ev.Firing {
341 b.WriteString("you will get another email when it clears.\n")
342 }
343 fmt.Fprintf(&b, "\nrule: %s\nbox: %s\n", ev.Rule, ev.Box)
344 return subjectLine, b.String()
345}
346
347func alertSubject(ev metrics.Event) string {
348 if ev.Firing {
349 return "carlos alert: " + alertSentence(ev)
350 }
351 return "carlos alert cleared: " + alertClearSentence(ev)
352}
353
354// alertSentence is the lower-case "what is wrong" clause, shared by the
355// subject and the body.
356func alertSentence(ev metrics.Event) string {
357 switch ev.Rule {
358 case "disk":
359 return fmt.Sprintf("disk is %s full on %s", pct(ev.Value), ev.Box)
360 case "mem":
361 return fmt.Sprintf("memory use is %s on %s", pct(ev.Value), ev.Box)
362 case "cpu":
363 return fmt.Sprintf("cpu use is %s on %s", pct(ev.Value), ev.Box)
364 case "lag":
365 return fmt.Sprintf("mail relay lag is %d seconds on %s", int64(ev.Value), ev.Box)
366 case "geoip":
367 return geoipAlertSentence(ev)
368 default:
369 return fmt.Sprintf("%s is firing on %s", ev.Rule, ev.Box)
370 }
371}
372
373func alertClearSentence(ev metrics.Event) string {
374 switch ev.Rule {
375 case "disk":
376 return fmt.Sprintf("disk is back to %s full on %s", pct(ev.Value), ev.Box)
377 case "mem":
378 return fmt.Sprintf("memory use is back to %s on %s", pct(ev.Value), ev.Box)
379 case "cpu":
380 return fmt.Sprintf("cpu use is back to %s on %s", pct(ev.Value), ev.Box)
381 case "lag":
382 return fmt.Sprintf("mail relay lag is back to %d seconds on %s", int64(ev.Value), ev.Box)
383 case "geoip":
384 return fmt.Sprintf("geoip on %s is back to normal", ev.Box)
385 default:
386 return fmt.Sprintf("%s has cleared on %s", ev.Rule, ev.Box)
387 }
388}
389
390func alertLevel(r metrics.Rules, rule string) (string, bool) {
391 switch rule {
392 case "disk":
393 return pct(r.DiskFrac), true
394 case "mem":
395 return pct(r.MemFrac), true
396 case "cpu":
397 return pct(r.CPUFrac), true
398 case "lag":
399 return fmt.Sprintf("%d seconds", r.LagSecs), true
400 }
401 return "", false
402}
403
404func alertSustain(r metrics.Rules, rule string) time.Duration {
405 switch rule {
406 case "mem":
407 return r.MemSustain
408 case "cpu":
409 return r.CPUSustain
410 }
411 return 0
412}
413
414// geoipAlertSentence reads the geoip rule's reason code (geoip spec §10.4).
415func geoipAlertSentence(ev metrics.Event) string {
416 switch int(ev.Value) {
417 case 1:
418 return fmt.Sprintf("the geoip database on %s is more than 45 days old", ev.Box)
419 case 2:
420 return fmt.Sprintf("the GeoLite2 database on %s expires within 9 days", ev.Box)
421 case 3:
422 return fmt.Sprintf("the geoip database on %s has expired and was removed", ev.Box)
423 case 4:
424 return fmt.Sprintf("%s has not loaded the published geoip database for over an hour", ev.Box)
425 case 5:
426 return fmt.Sprintf("the geoip feed has failed 7 times in a row (seen from %s)", ev.Box)
427 }
428 return fmt.Sprintf("geoip needs attention on %s", ev.Box)
429}
430
431// pct renders a 0..1 fraction as a whole percent.
432func pct(f float64) string { return fmt.Sprintf("%d%%", int(math.Round(f*100))) }
433