// Package alerting is the operator alert delivery the agent and the console // can share: a webhook, an SNS topic, either, both or neither (Sinks), and // Page, the paging primitive the moves code calls (moves spec §13). // // Delivery is best effort. A sink that fails is counted through its failure // hook and its error returned; the other sink still runs. Nothing here // retries beyond what the AWS SDK does inside one publish. package alerting import ( "bytes" "context" "encoding/json" "errors" "fmt" "log" "net/http" "regexp" "strings" "sync" "sync/atomic" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws/arn" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/sns" snstypes "github.com/aws/aws-sdk-go-v2/service/sns/types" "github.com/carlosframework/platform/internal/rootaws" ) // Page levels (moves spec §13): an incident wants someone now, a warning // wants someone today. const ( LevelIncident = "incident" LevelWarning = "warning" ) // Publisher is the one SNS call this package makes. type Publisher interface { Publish(context.Context, *sns.PublishInput, ...func(*sns.Options)) (*sns.PublishOutput, error) } // PublishTimeout bounds one SNS publish, so a black-holed endpoint cannot // hold the caller for the SDK's own default retries. const PublishTimeout = 10 * time.Second // subjectLimit is SNS's: a subject is under 100 characters. const subjectLimit = 100 // Sinks is one process's delivery. An empty WebhookURL or a nil Post // switches the webhook off; an empty TopicARN or a nil Pub switches SNS off. // The failure hooks count a failed send; nil counts nothing. type Sinks struct { WebhookURL string Post func(url string, body []byte) error TopicARN string Pub Publisher WebhookFailed func() SNSFailed func() } // HasWebhook and HasSNS say whether that sink is on. Safe on nil. func (s *Sinks) HasWebhook() bool { return s != nil && s.WebhookURL != "" && s.Post != nil } func (s *Sinks) HasSNS() bool { return s != nil && s.TopicARN != "" && s.Pub != nil } // PostWebhook posts body to the webhook. A failure is counted and returned. // The caller checks HasWebhook first. func (s *Sinks) PostWebhook(body []byte) error { if err := s.Post(s.WebhookURL, body); err != nil { if s.WebhookFailed != nil { s.WebhookFailed() } return err } return nil } // PublishSNS publishes a subject and message to the topic, bounded by // PublishTimeout. A failure is counted and returned. The caller checks // HasSNS first. func (s *Sinks) PublishSNS(ctx context.Context, subject, message string) error { return s.publish(ctx, &sns.PublishInput{TopicArn: &s.TopicARN, Subject: &subject, Message: &message}) } func (s *Sinks) publish(ctx context.Context, in *sns.PublishInput) error { pctx, cancel := context.WithTimeout(ctx, PublishTimeout) defer cancel() if _, err := s.Pub.Publish(pctx, in); err != nil { if s.SNSFailed != nil { s.SNSFailed() } return err } return nil } // pagePayload is a page on the webhook. type pagePayload struct { Kind string `json:"kind"` Level string `json:"level"` Subject string `json:"subject"` Body string `json:"body"` } // Page sends one page to every configured sink: the webhook gets // {"kind":"page","level","subject","body"}, SNS the subject (made to fit // SNS's rules) and the body, with the level as a message attribute so a // subscription can filter on it. The caller's subject and body are the // whole message: no URL, ARN or credential is added here, and the caller // must not put a secret or a tenant path in either. With no sink it logs // once (until the next Arm) and returns nil. A failed sink is counted, and // every failure is returned joined. func (s *Sinks) Page(ctx context.Context, level, subject, body string) error { if !s.HasWebhook() && !s.HasSNS() { if !noSinkLogged.Swap(true) { log.Printf("alerting: page not delivered, no sink configured (set CARLOS_ALERT_SNS_TOPIC_ARN or CARLOS_ALERT_WEBHOOK): %s", fitSubject(subject)) } return nil } var errs []error if s.HasWebhook() { b, err := json.Marshal(pagePayload{Kind: "page", Level: level, Subject: subject, Body: body}) if err == nil { err = s.PostWebhook(b) } if err != nil { errs = append(errs, fmt.Errorf("webhook: %w", err)) } } if s.HasSNS() { subj := fitSubject(subject) in := &sns.PublishInput{TopicArn: &s.TopicARN, Subject: &subj, Message: &body, MessageAttributes: map[string]snstypes.MessageAttributeValue{"level": {DataType: aws.String("String"), StringValue: aws.String(level)}}} if err := s.publish(ctx, in); err != nil { errs = append(errs, fmt.Errorf("sns: %w", err)) } } return errors.Join(errs...) } // fitSubject makes a subject SNS accepts: printable ASCII, no line break, // under 100 characters. A line break becomes a space and anything else // outside printable ASCII a question mark. func fitSubject(s string) string { out := make([]byte, 0, len(s)) for _, r := range s { switch { case r == '\n' || r == '\r' || r == '\t': if n := len(out); n == 0 || out[n-1] != ' ' { out = append(out, ' ') } case r < 0x20 || r > 0x7e: out = append(out, '?') default: out = append(out, byte(r)) } if len(out) >= subjectLimit-1 { break } } return string(out) } // The armed sinks: the process's own, set once its sinks are built (Arm). var ( armedMu sync.RWMutex armed *Sinks noSinkLogged atomic.Bool ) // Arm makes s the sinks Page delivers on. nil means none. Arming starts a // fresh "no sink" notice. func Arm(s *Sinks) { armedMu.Lock() armed = s armedMu.Unlock() noSinkLogged.Store(false) } // Page pages an operator on the armed sinks (Sinks.Page). func Page(ctx context.Context, level, subject, body string) error { armedMu.RLock() s := armed armedMu.RUnlock() return s.Page(ctx, level, subject, body) } // FromEnv builds a process's sinks from the agent's two variables, // CARLOS_ALERT_WEBHOOK (posted through PostJSON) and // CARLOS_ALERT_SNS_TOPIC_ARN (published from the topic's own region through // newClient, NewSNSClient when nil). The console arms these for its move // pass (moves Plan 5 Task 7). A topic that does not parse, or whose client // cannot be built, leaves SNS off and says so; the webhook is unaffected. // It never returns nil: with neither set, Page logs that no sink is // configured. func FromEnv(ctx context.Context, getenv func(string) string, newClient func(context.Context, string) (Publisher, error)) *Sinks { if newClient == nil { newClient = NewSNSClient } s := &Sinks{} if url := strings.TrimSpace(getenv("CARLOS_ALERT_WEBHOOK")); url != "" { s.WebhookURL, s.Post = url, PostJSON } if topic := strings.TrimSpace(getenv("CARLOS_ALERT_SNS_TOPIC_ARN")); topic != "" { region, err := ParseTopicARN(topic) if err != nil { log.Printf("alerting: CARLOS_ALERT_SNS_TOPIC_ARN is not usable, SNS sink off: %v", err) return s } pub, err := newClient(ctx, region) if err != nil { log.Printf("alerting: build the SNS client, SNS sink off: %v", err) return s } s.TopicARN, s.Pub = topic, pub } return s } // PostJSON is the production webhook post: a 5 second POST with a JSON // content type. Any non-2xx status is an error. func PostJSON(url string, body []byte) error { client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Post(url, "application/json", bytes.NewReader(body)) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("host: webhook: non-2xx status %s", resp.Status) } return nil } // topicARNPattern is the whole shape of a standard SNS topic ARN: partition, // region, a 12 digit account, and a topic name that is only letters, digits, // hyphen and underscore. The name has no colon, so a subscription ARN // (topic ARN plus ":") does not match, and no dot, so FIFO // topics (which need a message group id this sink does not send) do not // either. var topicARNPattern = regexp.MustCompile(`^arn:aws(-cn|-us-gov)?:sns:([a-z0-9-]+):[0-9]{12}:[A-Za-z0-9_-]{1,256}$`) // ParseTopicARN checks a CARLOS_ALERT_SNS_TOPIC_ARN value and returns the // region the topic lives in. SNS is regional and a topic is reached only // through its own region's endpoint, so the region is read from the ARN and // not from AWS_REGION (host.env pins that to the deployment bucket's region // on every flagship box, which would aim three of the four at the wrong // place). func ParseTopicARN(s string) (region string, err error) { s = strings.TrimSpace(s) if s == "" { return "", errors.New("empty") } a, err := arn.Parse(s) if err != nil { return "", err } if a.Service != "sns" { return "", fmt.Errorf("service %q, want sns", a.Service) } if !topicARNPattern.MatchString(s) { return "", errors.New("not a topic arn (want arn:aws:sns::<12 digit account>:)") } return a.Region, nil } // NewSNSClient is the production publisher: the default credential chain // (a box's instance role, the console's task role), aimed at the topic's // own region. func NewSNSClient(ctx context.Context, region string) (Publisher, error) { cfg, err := rootaws.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) if err != nil { return nil, err } return publishFunc(sns.NewFromConfig(cfg).Publish), nil } // publishFunc is the Publisher over one bound method value. It is not // `return sns.NewFromConfig(cfg), nil` on purpose: an *sns.Client stored in // an interface makes the linker keep every exported SNS method once // html/template is linked in (discussion #275, ~0.75 MB here and ~15 MB // across the SDK clients). A method value keeps Publish alone. type publishFunc func(context.Context, *sns.PublishInput, ...func(*sns.Options)) (*sns.PublishOutput, error) func (f publishFunc) Publish(ctx context.Context, in *sns.PublishInput, opts ...func(*sns.Options)) (*sns.PublishOutput, error) { return f(ctx, in, opts...) }