carlos / platform Public

settings-3 task 5: the box report field and the console's enforcement gate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9f5b66047eb0b20942fafa8bcff99afa913a4fab parent 4c415b1
3 files changed, +160 −0
  • internal/console/updatesgate.go +58 −0
  • internal/console/updatesgate_test.go +92 −0
  • internal/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