| 1 | package main |
| 2 | |
| 3 | // π€ Release-sighting alerts β the human-detection half of the supply-chain |
| 4 | // bake window (docs/server-releases.md Β§ The bake window). The bake buys time |
| 5 | // between a release appearing and it installing; this is what makes that time |
| 6 | // useful: when the instance's own 6-hourly feed check sees its channel head |
| 7 | // CHANGE, it pushes the space's admins. The alert is out-of-band from the |
| 8 | // signing key β it fires on observation of the feed, not on the publisher's |
| 9 | // cooperation β so an attacker who can sign releases cannot publish quietly. |
| 10 | // |
| 11 | // Payload safety: channel names, versions, semvers, and hold lengths are |
| 12 | // public feed data (branding-class, exactly the changelog banner's argument |
| 13 | // in announce.go) β never user content, never a key. |
| 14 | |
| 15 | import ( |
| 16 | "encoding/json" |
| 17 | "fmt" |
| 18 | "log" |
| 19 | ) |
| 20 | |
| 21 | // releaseSeenKey is the per-channel last-sighted-head watermark, config-kept |
| 22 | // like changelog_announced: first sighting on a channel baselines silently (a |
| 23 | // fresh space isn't greeted with old news), later sightings announce the diff. |
| 24 | const releaseSeenKey = "release_seen_heads" |
| 25 | |
| 26 | // releaseAlertText builds the admin notification for a newly-sighted channel |
| 27 | // head. ok=false when the sighting shouldn't alert: edge (no hold β its |
| 28 | // riders are the operators doing the publishing, and the post-install |
| 29 | // changelog banner covers arrival). |
| 30 | func releaseAlertText(group, channel string, e releaseEntry, autoUpdate bool, major string) (title, body string, ok bool) { |
| 31 | label := e.Version |
| 32 | if e.Semver != "" { |
| 33 | label = "v" + e.Semver + " (" + e.Version + ")" |
| 34 | } |
| 35 | switch { |
| 36 | case majorHold(major, e) != "": |
| 37 | body = fmt.Sprintf("%s appeared on the %s channel, but it crosses the major version this space stays on β nothing installs until you raise that in the admin console.", label, channel) |
| 38 | case bakeHours(channel) == 0: |
| 39 | return "", "", false |
| 40 | case !autoUpdate: |
| 41 | body = fmt.Sprintf("%s appeared on the %s channel. Automatic updates are off for this space, so it's yours to apply from the admin console.", label, channel) |
| 42 | default: |
| 43 | body = fmt.Sprintf("%s appeared on the %s channel. It installs automatically after the safety hold (about %d hours) β check the admin console if you weren't expecting a release.", label, channel, bakeHours(channel)) |
| 44 | } |
| 45 | return "Server update on the way β " + group, body, true |
| 46 | } |
| 47 | |
| 48 | // noteReleaseSightings runs after every verified feed fetch (checkForUpdate). |
| 49 | // Returns (alerted, sent) for tests β whether this sighting warranted an |
| 50 | // alert, and how many push sends succeeded. |
| 51 | func (s *Server) noteReleaseSightings(m *releaseManifest) (alerted bool, sent int) { |
| 52 | ch := s.db.updateChannel() |
| 53 | e, ok := m.Channels[ch] |
| 54 | if !ok { |
| 55 | return false, 0 |
| 56 | } |
| 57 | raw, had, _ := s.db.getConfig(releaseSeenKey) |
| 58 | seen := map[string]string{} |
| 59 | if had { |
| 60 | if err := json.Unmarshal([]byte(raw), &seen); err != nil { |
| 61 | // A corrupt watermark re-baselines rather than announcing a backlog. |
| 62 | log.Printf("release-alert: bad %s config (%v) β re-baselining", releaseSeenKey, err) |
| 63 | seen = map[string]string{} |
| 64 | } |
| 65 | } |
| 66 | last := seen[ch] |
| 67 | if last == e.Version { |
| 68 | return false, 0 |
| 69 | } |
| 70 | // Advance the watermark before pushing (at-most-once, like the changelog): |
| 71 | // a crash mid-announce must not replay the alert on every later check. |
| 72 | seen[ch] = e.Version |
| 73 | if b, err := json.Marshal(seen); err == nil { |
| 74 | if err := s.db.setConfig(releaseSeenKey, string(b)); err != nil { |
| 75 | log.Printf("release-alert: save watermark: %v", err) |
| 76 | } |
| 77 | } |
| 78 | if last == "" { |
| 79 | return false, 0 // first sighting on this channel β baseline silently |
| 80 | } |
| 81 | if e.Version == buildVersion { |
| 82 | // The head is what we're already running (we just updated to it, or a |
| 83 | // pull-back landed on us) β nothing is "on the way". |
| 84 | return false, 0 |
| 85 | } |
| 86 | title, body, ok := releaseAlertText(s.db.getGroupName(), ch, e, s.db.updateAuto(), s.db.updateMajor()) |
| 87 | if !ok { |
| 88 | return false, 0 |
| 89 | } |
| 90 | admins, err := s.db.adminUserIDs() |
| 91 | if err != nil || len(admins) == 0 { |
| 92 | log.Printf("release-alert: %s head moved to %s, no admins to notify (err=%v)", ch, e.Version, err) |
| 93 | return true, 0 |
| 94 | } |
| 95 | recipients := make(map[string]bool, len(admins)) |
| 96 | for _, id := range admins { |
| 97 | recipients[id] = true |
| 98 | } |
| 99 | payload, _ := json.Marshal(map[string]any{ |
| 100 | "release": true, |
| 101 | "title": title, |
| 102 | "body": body, |
| 103 | }) |
| 104 | // skipActive=false: like the changelog banner, this is the one signal an |
| 105 | // admin gets with the app closed β and rare enough to always show. |
| 106 | sent = s.sendToRecipients(payload, "release-alert", "", false, recipients) |
| 107 | sent += s.apnsAnnounce(title, body, recipients) |
| 108 | log.Printf("release-alert: %s head %s announced to %d admin subscription(s)", ch, e.Version, sent) |
| 109 | return true, sent |
| 110 | } |
| 111 | |