| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "sort" |
| 6 | "time" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/status" |
| 9 | ) |
| 10 | |
| 11 | // updatesEnforced is §4.4's roll-order gate: pin, manual and major may be |
| 12 | // turned on only while every box in the deployment reports a binary that |
| 13 | // applies them and protects the builds they name from retention. The housekeeper |
| 14 | // duty moves between boxes, so one old box anywhere could prune a pinned |
| 15 | // build. Expected boxes are the edges records and active fleet members, as for |
| 16 | // the GeoIP rollout verdict (geoipInventory); a box that is expected and has |
| 17 | // no fresh heartbeat blocks, and so does an incomplete read. Unlike the GeoIP |
| 18 | // verdict, an incomplete heartbeat read blocks here too, and so do an |
| 19 | // inventory that found no box at all and a fleet registry that was never |
| 20 | // read (unset, absent, or failing): a gate that has not seen every box |
| 21 | // cannot vouch for every box. |
| 22 | // |
| 23 | // The blocking lines name platform box labels and fleet names. They are |
| 24 | // operator detail, logged by the doors and never shown to app members. |
| 25 | func (s *Server) updatesEnforced(ctx context.Context) (ok bool, blocking []string, err error) { |
| 26 | inv, err := s.geoipInventory(ctx) |
| 27 | if err != nil { |
| 28 | return false, nil, err |
| 29 | } |
| 30 | now := time.Now() |
| 31 | keys := map[geoipBoxKey]bool{} |
| 32 | for k := range inv.expected { |
| 33 | keys[k] = true |
| 34 | } |
| 35 | for k, b := range inv.boxes { |
| 36 | if !status.BoxStale(b, now, status.SnapshotPeriod) { |
| 37 | keys[k] = true |
| 38 | } |
| 39 | } |
| 40 | for k := range keys { |
| 41 | b, seen := inv.boxes[k] |
| 42 | switch { |
| 43 | case !seen: |
| 44 | blocking = append(blocking, k.String()+": no heartbeat") |
| 45 | case status.BoxStale(b, now, status.SnapshotPeriod): |
| 46 | blocking = append(blocking, k.String()+": no heartbeat since "+b.GeneratedAt.UTC().Format(time.RFC3339)) |
| 47 | case b.Metrics == nil || b.Metrics.UpdateSettings < status.UpdateSettingsProtocol: |
| 48 | blocking = append(blocking, k.String()+": runs a binary that does not apply update settings") |
| 49 | } |
| 50 | } |
| 51 | if len(keys) == 0 { |
| 52 | blocking = append(blocking, "no box was found to report a binary") |
| 53 | } |
| 54 | // An unset or absent registry is a fleet list nobody read, not an empty |
| 55 | // one (Astra r1 #1): the GeoIP verdict may read it as platform-only, but |
| 56 | // this gate cannot vouch for fleet boxes it never listed. A registry that |
| 57 | // failed to open or list also leaves expectedComplete false; this one |
| 58 | // line, with the reason, covers both. |
| 59 | if !inv.fleetsListed { |
| 60 | blocking = append(blocking, "the fleet registry could not be read ("+inv.fleetsUnread+"), so no fleet box was checked") |
| 61 | } |
| 62 | if (inv.fleetsListed && !inv.expectedComplete) || !inv.heartbeatsComplete { |
| 63 | blocking = append(blocking, "the list of boxes could not be read in full") |
| 64 | } |
| 65 | sort.Strings(blocking) |
| 66 | return len(blocking) == 0, blocking, nil |
| 67 | } |
| 68 | |