settings-3 task 5: the box report field and the console's enforcement gate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+160
−0
internal/console/updatesgate.go+58 −0internal/console/updatesgate_test.go+92 −0internal/status/snapshot.go+10 −0
diff --git a/internal/console/updatesgate.go b/internal/console/updatesgate.go| new file mode 100644 |
| index 000000000..a91a0bc50 |
| --- /dev/null |
| +++ b/internal/console/updatesgate.go |
| @@ -0,0 +1,58 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "sort" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// updatesEnforced is §4.4's roll-order gate: pin, manual and major may be |
| +// turned on only while every box in the deployment reports a binary that |
| +// applies them and protects the builds they name from retention. The housekeeper |
| +// duty moves between boxes, so one old box anywhere could prune a pinned |
| +// build. Expected boxes are the edges records and active fleet members, as for |
| +// the GeoIP rollout verdict (geoipInventory); a box that is expected and has |
| +// no fresh heartbeat blocks, and so does an incomplete read. Unlike the GeoIP |
| +// verdict, an incomplete heartbeat read blocks here too, and so does an |
| +// inventory that found no box at all: a gate that has seen nothing cannot |
| +// vouch for every box. |
| +// |
| +// The blocking lines name platform box labels and fleet names. They are |
| +// operator detail, logged by the doors and never shown to app members. |
| +func (s *Server) updatesEnforced(ctx context.Context) (ok bool, blocking []string, err error) { |
| + inv, err := s.geoipInventory(ctx) |
| + if err != nil { |
| + return false, nil, err |
| + } |
| + now := time.Now() |
| + keys := map[geoipBoxKey]bool{} |
| + for k := range inv.expected { |
| + keys[k] = true |
| + } |
| + for k, b := range inv.boxes { |
| + if !status.BoxStale(b, now, status.SnapshotPeriod) { |
| + keys[k] = true |
| + } |
| + } |
| + for k := range keys { |
| + b, seen := inv.boxes[k] |
| + switch { |
| + case !seen: |
| + blocking = append(blocking, k.String()+": no heartbeat") |
| + case status.BoxStale(b, now, status.SnapshotPeriod): |
| + blocking = append(blocking, k.String()+": no heartbeat since "+b.GeneratedAt.UTC().Format(time.RFC3339)) |
| + case b.Metrics == nil || b.Metrics.UpdateSettings < status.UpdateSettingsProtocol: |
| + blocking = append(blocking, k.String()+": runs a binary that does not apply update settings") |
| + } |
| + } |
| + if len(keys) == 0 { |
| + blocking = append(blocking, "no box was found to report a binary") |
| + } |
| + if !inv.expectedComplete || !inv.heartbeatsComplete { |
| + blocking = append(blocking, "the list of boxes could not be read in full") |
| + } |
| + sort.Strings(blocking) |
| + return len(blocking) == 0, blocking, nil |
| +} |
diff --git a/internal/console/updatesgate_test.go b/internal/console/updatesgate_test.go| new file mode 100644 |
| index 000000000..0fd2939e4 |
| --- /dev/null |
| +++ b/internal/console/updatesgate_test.go |
| @@ -0,0 +1,92 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// seedSettingsBox writes label's heartbeat at at carrying m as its private |
| +// metrics block (nil writes a heartbeat with no metrics at all). |
| +func seedSettingsBox(t *testing.T, fx *consoleFixture, label string, at time.Time, m *status.BoxMetrics) { |
| + t.Helper() |
| + b := status.CollectBox(nil, status.Probes{Release: "v1"}, at) |
| + b.Label = label |
| + b.Metrics = m |
| + if err := status.WriteBox(context.Background(), fx.rel, b); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestUpdatesEnforcedNeedsEveryBox(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedEdgesRecord(t, fx, "ie-1") |
| + seedEdgesRecord(t, fx, "uk-1") |
| + now := time.Now() |
| + seedSettingsBox(t, fx, "ie-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) |
| + seedSettingsBox(t, fx, "uk-1", now, &status.BoxMetrics{}) |
| + ok, blocking, err := fx.srv.updatesEnforced(context.Background()) |
| + if err != nil || ok || len(blocking) != 1 || !strings.Contains(blocking[0], "uk-1") { |
| + t.Fatalf("one old binary: %v %q %v", ok, blocking, err) |
| + } |
| + seedSettingsBox(t, fx, "uk-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) |
| + if ok, _, err := fx.srv.updatesEnforced(context.Background()); !ok || err != nil { |
| + t.Fatalf("every box enforces: %v %v", ok, err) |
| + } |
| +} |
| + |
| +// Review Focus 4. |
| +func TestUpdatesEnforcedNamesAStaleBox(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedEdgesRecord(t, fx, "ie-1") |
| + seedSettingsBox(t, fx, "ie-1", time.Now().Add(-3*status.SnapshotPeriod), &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) |
| + ok, blocking, _ := fx.srv.updatesEnforced(context.Background()) |
| + if ok || len(blocking) != 1 || !strings.Contains(blocking[0], "ie-1") { |
| + t.Fatalf("a stale box: %v %q", ok, blocking) |
| + } |
| +} |
| + |
| +func TestUpdatesEnforcedNeedsAHeartbeat(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedEdgesRecord(t, fx, "ie-1") |
| + if ok, blocking, _ := fx.srv.updatesEnforced(context.Background()); ok || len(blocking) != 1 { |
| + t.Fatalf("an expected box with no heartbeat: %v %q", ok, blocking) |
| + } |
| +} |
| + |
| +// An empty inventory has seen no box at all, so it cannot vouch that every |
| +// box enforces: it blocks rather than reading as "nothing to wait on". |
| +func TestUpdatesEnforcedNeedsSomeBox(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if ok, blocking, err := fx.srv.updatesEnforced(context.Background()); ok || err != nil || len(blocking) != 1 { |
| + t.Fatalf("no box known: %v %q %v", ok, blocking, err) |
| + } |
| +} |
| + |
| +// A heartbeat with no metrics block is a binary that does not say it |
| +// enforces; an unexpected box that has gone stale is retired and does not |
| +// block; an unexpected fresh box with an old binary does. |
| +func TestUpdatesEnforcedBoxShapes(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + seedEdgesRecord(t, fx, "ie-1") |
| + seedSettingsBox(t, fx, "ie-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) |
| + seedSettingsBox(t, fx, "old-1", now.Add(-time.Hour), &status.BoxMetrics{}) |
| + if ok, blocking, err := fx.srv.updatesEnforced(context.Background()); !ok || err != nil { |
| + t.Fatalf("a retired box blocked: %v %q %v", ok, blocking, err) |
| + } |
| + seedSettingsBox(t, fx, "new-1", now, &status.BoxMetrics{}) |
| + ok, blocking, _ := fx.srv.updatesEnforced(context.Background()) |
| + if ok || len(blocking) != 1 || !strings.Contains(blocking[0], "new-1") { |
| + t.Fatalf("an unlisted fresh old binary: %v %q", ok, blocking) |
| + } |
| + seedSettingsBox(t, fx, "new-1", now, &status.BoxMetrics{UpdateSettings: status.UpdateSettingsProtocol}) |
| + seedSettingsBox(t, fx, "ie-1", now, nil) |
| + ok, blocking, _ = fx.srv.updatesEnforced(context.Background()) |
| + if ok || len(blocking) != 1 || !strings.Contains(blocking[0], "ie-1") { |
| + t.Fatalf("a heartbeat with no metrics: %v %q", ok, blocking) |
| + } |
| +} |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index 45788606c..424863e9b 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -443,6 +443,12 @@ type BoxMetrics struct { |
| // spec §5.5); its absence is how the rollout verdict tells an older |
| // binary, which does not strip forged geo headers. |
| GeoIP *GeoIPState `json:"geoip,omitempty"` |
| + // UpdateSettings is the instance-update-settings protocol this binary |
| + // implements: it applies pin, manual and major, and its retention |
| + // protects the builds they name (settings spec §4.4). Absent (0) is a |
| + // binary that ignores them; the console refuses those settings until |
| + // every box reports UpdateSettingsProtocol. |
| + UpdateSettings int `json:"update_settings,omitempty"` |
| // RouteVersions is host -> registry.Route.Version for every non-platform |
| // route this box carries with a Version the adopt pass has stamped |
| // (issue #78's staged_version chip) — a hostname-keyed map, which is |
| @@ -525,6 +531,10 @@ type BoxMetrics struct { |
| ConfigGuard string `json:"config_guard,omitempty"` |
| } |
| +// UpdateSettingsProtocol is the BoxMetrics.UpdateSettings a binary reports |
| +// once it applies update settings and protects the builds they name. |
| +const UpdateSettingsProtocol = 1 |
| + |
| // AliasReading is one alias hostname's status as CollectBox saw it this |
| // tick: Route is the route it points at (registry.RouteAlias.Host), DNSStatus |
| // and CertExpiresAt are the box-stamped health columns (Task 2/5), carried |