zdd-5 task 13: the rollout report in box status
status.RolloutReport and its words ride the private Metrics block only (dryrun guard fixture added). Manager.Status builds it without calling the activator; Deps.OnChange nudges after every transition with no lock held; Manager.observing tracks first-wake observations. reasonWord now returns the status.Reason* constants (one vocabulary).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+443
−30
internal/rollout/events.go+9 −18internal/rollout/manager.go+34 −4internal/rollout/manager_test.go+8 −7internal/rollout/scheduler.go+1 −0internal/rollout/status.go+86 −0internal/rollout/status_test.go+163 −0internal/rollout/wakeplan.go+13 −0internal/status/dryrun.go+36 −0internal/status/snapshot.go+82 −1internal/status/snapshot_test.go+11 −0
diff --git a/internal/rollout/events.go b/internal/rollout/events.go| index 82a4bca82..110873927 100644 |
| --- a/internal/rollout/events.go |
| +++ b/internal/rollout/events.go |
| @@ -5,6 +5,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/status" |
| ) |
| // EventCode is a rollout event's stable internal name (spec §9.4). The codes |
| @@ -24,20 +25,10 @@ const ( |
| EvStagingRefused EventCode = "staging.refused" |
| ) |
| -// The reason words: the closed vocabulary a member-facing event or status |
| -// line may give for a failure. Task 13 moves them into the status package |
| -// (status.Reason*) and points these at them. |
| -const ( |
| - reasonHealth = "health" |
| - reasonSettle = "settle" |
| - reasonExit = "exit" |
| - reasonStaging = "staging" |
| - reasonReused = "reused" |
| -) |
| - |
| // reasonWord turns an attempt's diagnostic text (SwapResult.Reason, an error) |
| -// into one of the reason words, so no free text, which can name paths and |
| -// internals, reaches a member-facing surface. The raw text goes to the |
| +// into one of status's reason words (status.Reason*, the one vocabulary the |
| +// events and the status report share), so no free text, which can name paths |
| +// and internals, reaches a member-facing surface. The raw text goes to the |
| // operator log instead. Anything unrecognised is a failed health check: the |
| // swap's checks are the health check, the settle and the exit watch, and the |
| // health check is the one a spawn or start error also fails. |
| @@ -45,15 +36,15 @@ func reasonWord(text string) string { |
| t := strings.ToLower(text) |
| switch { |
| case strings.Contains(t, "reused"): |
| - return reasonReused |
| + return status.ReasonReused |
| case strings.Contains(t, "staging"), strings.Contains(t, "staged"), strings.Contains(t, "digest"): |
| - return reasonStaging |
| + return status.ReasonStaging |
| case strings.Contains(t, "settle"): |
| - return reasonSettle |
| + return status.ReasonSettle |
| case strings.Contains(t, "exited"): |
| - return reasonExit |
| + return status.ReasonExit |
| } |
| - return reasonHealth |
| + return status.ReasonHealth |
| } |
| func textSwapping(build string, cause activator.Cause) string { |
diff --git a/internal/rollout/manager.go b/internal/rollout/manager.go| index c4b3997be..04776166e 100644 |
| --- a/internal/rollout/manager.go |
| +++ b/internal/rollout/manager.go |
| @@ -65,8 +65,14 @@ type Deps struct { |
| // backend's Materialize hook) so a wake is planned against it (Task 10, |
| // spec §6.4, §8.4). Nil: a cold managed host's wake is refused. |
| Materialize func(ctx context.Context, host string) error |
| - Now func() time.Time |
| - Logf func(format string, args ...any) |
| + // OnChange, when set, is told after every rollout transition (an |
| + // attempt's outcome, a wake's event, a newly installed build, a retry), |
| + // so the box republishes its status within seconds instead of at the |
| + // next tick (plan 6's deploy watch). It is a nudge: it is called with no |
| + // Manager or scheduler lock held, and it must not block. |
| + OnChange func() |
| + Now func() time.Time |
| + Logf func(format string, args ...any) |
| } |
| // pointerReadEvery bounds how often an app with a standing failure record |
| @@ -112,6 +118,10 @@ type Manager struct { |
| // nudging holds the hosts a Nudge is working on; true asks it to look |
| // once more for a nudge that arrived meanwhile. |
| nudging map[string]bool |
| + // observing holds the hosts whose first wake is inside its 10 s |
| + // observation: opened by a healthy first wake, closed by whatever ends |
| + // it (OnGen). Status reports them as settling. |
| + observing map[string]bool |
| // goodMu serialises every serving- and good-build write with its |
| // acknowledgement check (served, publishServing, writeGood), so an older |
| @@ -129,11 +139,19 @@ func NewManager(ctx context.Context, d Deps) *Manager { |
| } |
| m := &Manager{d: d, ctx: ctx, |
| apps: map[appKey]*scheduler{}, hostApp: map[string]appKey{}, cfg: map[string]*configTrack{}, |
| - retried: map[string]string{}, pointerRead: map[appKey]time.Time{}, nudging: map[string]bool{}} |
| + retried: map[string]string{}, pointerRead: map[appKey]time.Time{}, nudging: map[string]bool{}, observing: map[string]bool{}} |
| m.policies = &policyCache{load: d.Policy, ttl: 30 * time.Second, now: d.Now, m: map[appKey]policyEntry{}} |
| return m |
| } |
| +// changed tells OnChange a transition happened. Every caller has released |
| +// Manager.mu and any scheduler.mu first, so the callback may read Status. |
| +func (m *Manager) changed() { |
| + if m.d.OnChange != nil { |
| + m.d.OnChange() |
| + } |
| +} |
| + |
| func (m *Manager) scheduler(k appKey) *scheduler { |
| m.mu.Lock() |
| defer m.mu.Unlock() |
| @@ -248,6 +266,7 @@ func (m *Manager) observe(routes []registry.Route) []appKey { |
| } |
| now := m.d.Now() |
| keys := make([]appKey, 0, len(byApp)) |
| + moved := false |
| for k, list := range byApp { |
| keys = append(keys, k) |
| policy := m.policies.get(m.ctx, k.acct, k.app) |
| @@ -278,11 +297,18 @@ func (m *Manager) observe(routes []registry.Route) []appKey { |
| if retried[x.rt.Host] == x.rt.Version { |
| cause = activator.CauseRetry |
| } |
| - s.q.wantBuild(x.rt.Host, cause, x.rt.Version, now) |
| + if s.q.wantBuild(x.rt.Host, cause, x.rt.Version, now) { |
| + moved = true |
| + } |
| } |
| s.mu.Unlock() |
| s.poke() |
| } |
| + if moved { |
| + // A sleeper has no attempt to finish, so the pass that first sees |
| + // its new build is the news for the deploy watch. |
| + m.changed() |
| + } |
| return keys |
| } |
| @@ -383,6 +409,7 @@ func (m *Manager) retryBuild(k appKey, build, host string) { |
| m.mu.Unlock() |
| m.d.Logf("rollout: %s/%s build %s retried (%v)", k.acct, k.app, build, hosts) |
| s.poke() |
| + m.changed() |
| } |
| // RetryHost is the box half of `carlos instances retry <host>` (spec §8.4): |
| @@ -498,6 +525,7 @@ func (m *Manager) liftClearedHalts() { |
| if len(lifted) > 0 { |
| m.d.Logf("rollout: %s/%s: halt lifted for %v, their failure records are gone", s.key.acct, s.key.app, lifted) |
| s.poke() |
| + m.changed() |
| } |
| } |
| } |
| @@ -533,6 +561,7 @@ func (m *Manager) Restart(host, id string) bool { |
| s.mu.Unlock() |
| if changed { |
| s.poke() |
| + m.changed() |
| } |
| return changed |
| } |
| @@ -576,6 +605,7 @@ func (m *Manager) ConfigDelivered(host, rev string) { |
| s.mu.Unlock() |
| if changed { |
| s.poke() |
| + m.changed() // the revision is delivered and not yet active, which Status shows |
| } |
| } |
diff --git a/internal/rollout/manager_test.go b/internal/rollout/manager_test.go| index df1b8617f..183231c24 100644 |
| --- a/internal/rollout/manager_test.go |
| +++ b/internal/rollout/manager_test.go |
| @@ -11,6 +11,7 @@ import ( |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| ) |
| func TestAReleaseRollsOutCanaryFirstThenPaced(t *testing.T) { |
| @@ -933,13 +934,13 @@ func TestMemberEventsCarryOnlyTheReasonWord(t *testing.T) { |
| func TestReasonWords(t *testing.T) { |
| for text, want := range map[string]string{ |
| - "health check failed": reasonHealth, |
| - "swap: successor exited during its settle": reasonSettle, |
| - "exited within 10s of becoming healthy": reasonExit, |
| - "staging: digest mismatch": reasonStaging, |
| - "version name reused with different content, ship under a new name": reasonReused, |
| - "GET / answered 500": reasonHealth, |
| - "": reasonHealth, |
| + "health check failed": status.ReasonHealth, |
| + "swap: successor exited during its settle": status.ReasonSettle, |
| + "exited within 10s of becoming healthy": status.ReasonExit, |
| + "staging: digest mismatch": status.ReasonStaging, |
| + "version name reused with different content, ship under a new name": status.ReasonReused, |
| + "GET / answered 500": status.ReasonHealth, |
| + "": status.ReasonHealth, |
| } { |
| if got := reasonWord(text); got != want { |
| t.Errorf("reasonWord(%q) = %q, want %q", text, got, want) |
diff --git a/internal/rollout/scheduler.go b/internal/rollout/scheduler.go| index 402be5e23..8914a46f0 100644 |
| --- a/internal/rollout/scheduler.go |
| +++ b/internal/rollout/scheduler.go |
| @@ -221,6 +221,7 @@ func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.Sw |
| } |
| s.mu.Unlock() |
| defer s.poke() |
| + defer m.changed() // runs first: the slot is free and the record is written |
| if recErr != nil { |
| m.d.Logf("rollout: write failure record %s/%s %s: %v (held in memory)", rec.Account, rec.App, rec.Build, recErr) |
| } |
diff --git a/internal/rollout/status.go b/internal/rollout/status.go| new file mode 100644 |
| index 000000000..5e938b5f9 |
| --- /dev/null |
| +++ b/internal/rollout/status.go |
| @@ -0,0 +1,86 @@ |
| +package rollout |
| + |
| +import ( |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// Status is the box's rollout report (spec §8.4, §9.1), for the private |
| +// Metrics block. It reads the queue, the failure book and the config tracks |
| +// and never calls the activator, so it holds no lock the activator could |
| +// wait on: Manager.mu is released before any scheduler.mu is taken (the |
| +// lock order on Manager), and the book is read under a scheduler.mu as the |
| +// order allows. The hosts' serving state is what the last agent pass and |
| +// the last attempts recorded, not a fresh ask of the activator. |
| +func (m *Manager) Status() status.RolloutReport { |
| + r := status.RolloutReport{Hosts: map[string]status.HostRollout{}, Apps: map[string]status.AppRollout{}} |
| + for _, f := range m.d.Book.List() { |
| + r.Failures = append(r.Failures, status.RolloutFailure{Account: f.Account, App: f.App, Build: f.Build, Host: f.Host, |
| + Mode: f.Mode, Reason: reasonWord(f.Reason), PromotedAt: f.PromotedAt, At: f.At, HeldDown: f.HeldDown}) |
| + } |
| + m.mu.Lock() |
| + scheds := make([]*scheduler, 0, len(m.apps)) |
| + for _, s := range m.apps { |
| + scheds = append(scheds, s) |
| + } |
| + observing := make(map[string]bool, len(m.observing)) |
| + for h := range m.observing { |
| + observing[h] = true |
| + } |
| + type cfgView struct { |
| + pending bool |
| + reason string |
| + } |
| + cfg := make(map[string]cfgView, len(m.cfg)) |
| + for h, t := range m.cfg { |
| + if t.delivered != "" && t.delivered != t.active { |
| + v := cfgView{pending: true} |
| + if t.lastErr != "" { |
| + // A reason word, never the raw text (which can name paths). |
| + v.reason = reasonWord(t.lastErr) |
| + } |
| + cfg[h] = v |
| + } |
| + } |
| + m.mu.Unlock() |
| + for _, s := range scheds { |
| + app := status.AppRollout{} |
| + s.mu.Lock() |
| + for host, h := range s.q.hosts { |
| + hr := status.HostRollout{Account: s.key.acct, App: s.key.app, Installed: h.wantBuild, Serving: h.serving, |
| + Good: h.good, Awake: h.serving != ""} |
| + if v, ok := cfg[host]; ok { |
| + hr.ConfigPending, hr.ConfigError = true, v.reason |
| + } |
| + switch { |
| + case h.running != nil && (h.running.Canary || observing[host]): |
| + hr.Attempt, hr.AttemptBuild = status.AttemptSettling, h.running.ID.Build |
| + app.Settling++ |
| + case h.running != nil: |
| + hr.Attempt, hr.AttemptBuild = status.AttemptRunning, h.running.ID.Build |
| + app.Running++ |
| + case observing[host]: |
| + hr.Attempt, hr.AttemptBuild = status.AttemptSettling, h.wantBuild |
| + app.Settling++ |
| + default: |
| + if d, ok := s.q.pending(h); ok { |
| + hr.Attempt, hr.AttemptBuild, app.Queued = status.AttemptQueued, d.ID.Build, app.Queued+1 |
| + } |
| + } |
| + // A build held for this instance, with nothing else going on: the |
| + // failed words. A held-down instance is down. |
| + if hr.Attempt == "" && h.wantBuild != "" && h.serving != h.wantBuild { |
| + if rec, ok := m.d.Book.Lookup(s.key.acct, s.key.app, h.wantBuild); ok && |
| + m.d.Book.Blocks(s.key.acct, s.key.app, h.wantBuild, host, h.wantBuild == h.good) { |
| + hr.Attempt, hr.AttemptBuild, hr.HeldBy, hr.Reason = status.AttemptFailed, rec.Build, rec.Build, reasonWord(rec.Reason) |
| + if m.d.Book.HeldDown(s.key.acct, s.key.app, h.wantBuild, host) { |
| + hr.Attempt = status.AttemptDown |
| + } |
| + } |
| + } |
| + r.Hosts[host] = hr |
| + } |
| + s.mu.Unlock() |
| + r.Apps[s.key.acct+"/"+s.key.app] = app |
| + } |
| + return r |
| +} |
diff --git a/internal/rollout/status_test.go b/internal/rollout/status_test.go| new file mode 100644 |
| index 000000000..57ecb6797 |
| --- /dev/null |
| +++ b/internal/rollout/status_test.go |
| @@ -0,0 +1,163 @@ |
| +package rollout |
| + |
| +import ( |
| + "sync/atomic" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +func TestStatusReportsHeldAndRollingInstances(t *testing.T) { |
| + h := newHarness(t, "a", "b") |
| + h.stage("v2") |
| + gate := h.act.hold("a") |
| + h.install("v2", "a", "b") |
| + h.pass() |
| + waitFor(t, "a's canary", func() bool { _, s, _ := h.act.snapshot(); return len(s) == 1 }) |
| + // Recorded after the pass: a pass prunes the records of builds no route |
| + // installs, and v3 is installed nowhere. |
| + if _, err := h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "z", Reason: "health check failed", At: t0}); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := h.m.Status() |
| + if got := r.Hosts["a"]; got.Attempt != status.AttemptSettling || got.AttemptBuild != "v2" || got.Installed != "v2" || got.Serving != "v1" || !got.Awake { |
| + t.Fatalf("a = %+v, want the canary settling onto v2", got) |
| + } |
| + if got := r.Hosts["b"]; got.Attempt != status.AttemptQueued || got.AttemptBuild != "v2" { |
| + t.Fatalf("b = %+v, want queued behind the canary", got) |
| + } |
| + if app := r.Apps["bab/blog"]; app.Settling != 1 || app.Queued != 1 || app.Running != 0 { |
| + t.Fatalf("app = %+v", app) |
| + } |
| + if len(r.Failures) != 1 || r.Failures[0].Build != "v3" || r.Failures[0].Reason != status.ReasonHealth { |
| + t.Fatalf("failures = %+v", r.Failures) |
| + } |
| + close(gate) |
| +} |
| + |
| +func TestAFailedFirstWakeShowsAsHeld(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.act.mu.Lock() |
| + delete(h.act.gens, "a") // asleep: v2 is a first wake's build |
| + h.act.mu.Unlock() |
| + h.stage("v2") |
| + h.install("v2", "a") |
| + h.pass() |
| + h.act.serve("a", "v1") // the first wake failed and the good build serves in its place |
| + cur, _ := h.act.Current("a") |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenFellBack, Route: h.rows.get("a"), FirstWake: true, |
| + Mode: activator.ModeOverlap, Gen: cur, Failed: release.StagedIdentity{Version: "v2"}, |
| + Reason: "exited within 10s of becoming healthy"}) |
| + h.pass() |
| + got := h.m.Status().Hosts["a"] |
| + if got.Attempt != status.AttemptFailed || got.AttemptBuild != "v2" || got.HeldBy != "v2" || got.Reason != status.ReasonExit || got.Serving != "v1" { |
| + t.Fatalf("a = %+v", got) |
| + } |
| +} |
| + |
| +// A hold build that failed on the instance is held down: its wakes are |
| +// refused, which the report calls down. |
| +func TestAHeldDownInstanceShowsAsDown(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + if _, err := h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "a", Mode: "hold", |
| + Reason: "swap: successor exited during its settle", At: t0, HeldDown: []string{"a"}}); err != nil { |
| + t.Fatal(err) |
| + } |
| + h.install("v2", "a") |
| + h.pass() |
| + got := h.m.Status().Hosts["a"] |
| + if got.Attempt != status.AttemptDown || got.HeldBy != "v2" || got.Reason != status.ReasonSettle { |
| + t.Fatalf("a = %+v", got) |
| + } |
| +} |
| + |
| +// Spec §6.4, §9.1: an intentional stop (hibernation, maintenance) during a |
| +// first wake's 10 s ends the observation with no verdict. GenCancelled closes |
| +// what GenHealthy opened, so the host stops showing as settling and the |
| +// deploy watch can complete; nothing is recorded against the build. With no |
| +// closing event, observing[host] stayed set for ever. |
| +func TestACancelledObservationStopsSettlingAndRecordsNothing(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.act.mu.Lock() |
| + delete(h.act.gens, "a") // asleep: its first wake is the only thing that runs v2 |
| + h.act.mu.Unlock() |
| + h.stage("v2") |
| + h.install("v2", "a") |
| + h.pass() |
| + g := activator.Gen{N: 9, Build: "v2", Digest: "sha-v2"} |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: h.rows.get("a"), Gen: g, FirstWake: true}) |
| + if got := h.m.Status().Hosts["a"]; got.Attempt != status.AttemptSettling { |
| + t.Fatalf("under observation: a = %+v, want settling", got) |
| + } |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("a"), Gen: g, FirstWake: true}) |
| + if got := h.m.Status().Hosts["a"]; got.Attempt == status.AttemptSettling { |
| + t.Fatalf("after the cancelled observation: a = %+v, still settling", got) |
| + } |
| + if _, ok := h.book.Lookup("bab", "blog", "v2"); ok { |
| + t.Fatal("a cancelled observation was recorded as a failure of the build") |
| + } |
| + if r := h.rows.get("a"); r.GoodVersion != "v1" { |
| + t.Fatalf("good build = %q: a cancelled observation is no settle", r.GoodVersion) |
| + } |
| +} |
| + |
| +// Plan 6's deploy watch counts a sleeper as "installed, awaiting first |
| +// wake" once its box reports B installed. No attempt runs for a sleeper, so |
| +// the pass that first sees B must republish. |
| +func TestANewlyInstalledBuildIsPublishedAtOnce(t *testing.T) { |
| + h := newHarness(t, "a") |
| + var changes atomic.Int32 |
| + h.m.d.OnChange = func() { changes.Add(1) } |
| + h.act.mu.Lock() |
| + delete(h.act.gens, "a") // asleep |
| + h.act.mu.Unlock() |
| + h.stage("v2") |
| + h.install("v2", "a") |
| + h.pass() |
| + if changes.Load() == 0 { |
| + t.Fatal("a newly installed build waited for the five-minute status tick") |
| + } |
| + if got := h.m.Status().Hosts["a"]; got.Installed != "v2" || got.Awake { |
| + t.Fatalf("a = %+v, want v2 installed and asleep", got) |
| + } |
| +} |
| + |
| +// OnChange is a nudge called after every transition, with no manager or |
| +// scheduler lock held: this one reads Status, which takes both, so a call |
| +// under either lock deadlocks the test. |
| +func TestOnChangeFollowsEveryTransitionWithNoLockHeld(t *testing.T) { |
| + h := newHarness(t, "a") |
| + var changes atomic.Int32 |
| + h.m.d.OnChange = func() { |
| + h.m.Status() |
| + changes.Add(1) |
| + } |
| + h.stage("v2") |
| + h.install("v2", "a") |
| + h.pass() |
| + waitFor(t, "a on v2", func() bool { return h.servingAll("v2", "a") }) |
| + waitFor(t, "the attempt's change", func() bool { return changes.Load() >= 2 }) // the pass's, then done()'s |
| + before := changes.Load() |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: h.rows.get("a"), Gen: activator.Gen{N: 1, Build: "v2"}}) |
| + if changes.Load() <= before { |
| + t.Fatal("OnGen did not report its transition") |
| + } |
| +} |
| + |
| +func TestStatusReportsAConfigRevisionDeliveredButNotActive(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.pass() |
| + gate := h.act.hold("a") |
| + h.m.ConfigDelivered("a", "rev1") |
| + waitFor(t, "the config attempt", func() bool { _, s, _ := h.act.snapshot(); return len(s) == 1 }) |
| + got := h.m.Status().Hosts["a"] |
| + // The config attempt is its cohort's canary, so it reads as settling. |
| + if !got.ConfigPending || got.Attempt != status.AttemptSettling || got.AttemptBuild != "v1" || got.ConfigError != "" { |
| + t.Fatalf("a = %+v, want the config attempt in flight with its revision pending", got) |
| + } |
| + close(gate) |
| + waitFor(t, "the config to activate", func() bool { return !h.m.Status().Hosts["a"].ConfigPending }) |
| +} |
diff --git a/internal/rollout/wakeplan.go b/internal/rollout/wakeplan.go| index c7f66911c..099651ae2 100644 |
| --- a/internal/rollout/wakeplan.go |
| +++ b/internal/rollout/wakeplan.go |
| @@ -196,6 +196,19 @@ func (m *Manager) attemptRunning(host string) bool { |
| // they take goodMu and call back into Current. |
| func (m *Manager) OnGen(ev activator.GenEvent) { |
| rt := ev.Route |
| + m.mu.Lock() |
| + switch { |
| + case ev.Kind == activator.GenHealthy && ev.FirstWake: |
| + m.observing[rt.Host] = true |
| + case ev.Kind != activator.GenHealthy: |
| + // GenSettled, GenFailed, GenFellBack and GenCancelled (an |
| + // intentional stop ended the observation with no verdict) all close |
| + // it. The switch below has no recording arm for GenCancelled, so a |
| + // cancelled observation records nothing (spec §6.4). |
| + delete(m.observing, rt.Host) |
| + } |
| + m.mu.Unlock() |
| + defer m.changed() // after the event's own work, with no lock held |
| switch ev.Kind { |
| case activator.GenHealthy: |
| // A wake that is not a first wake includes the good build booted |
diff --git a/internal/status/dryrun.go b/internal/status/dryrun.go| index 377af3410..83dc32d8e 100644 |
| --- a/internal/status/dryrun.go |
| +++ b/internal/status/dryrun.go |
| @@ -132,6 +132,14 @@ const dryRunProofWaitHost = "proof-wait.internal" |
| var dryRunProofWaitNeedles = []string{dryRunProofWaitHost} |
| +// dryRunRolloutHost is the rollout report needle: Box.Metrics.Rollout names |
| +// the instances the rollout scheduler manages (hosts, failure records), so |
| +// the same scope split applies. REQUIRED in status/boxes/*.json and BANNED in |
| +// every other published object. |
| +const dryRunRolloutHost = "rollout-host.internal" |
| + |
| +var dryRunRolloutNeedles = []string{dryRunRolloutHost} |
| + |
| // ScanForLeaks reports which of banned's strings appear anywhere in j. It is |
| // the single implementation both DryRun's self-check and |
| // contract_test.go's scanForLeaks test helper run — the test package can't |
| @@ -227,6 +235,17 @@ func dryRunFixtures() ([]registry.Route, Probes) { |
| ProofWaits: []ProofWait{ |
| {Host: dryRunProofWaitHost, Version: "v-dryrun", SHA256: "dryrunproofsha", Prover: "dev", Reason: "no proof from dev", Since: time.Now()}, |
| }, |
| + // One held instance and its failure record, for the rollout scope check. |
| + Rollout: &RolloutReport{ |
| + Hosts: map[string]HostRollout{ |
| + dryRunRolloutHost: {Account: "dryrun", App: "app", Installed: "v-dryrun", Serving: "v-old", Good: "v-old", Awake: true, |
| + Attempt: AttemptFailed, AttemptBuild: "v-dryrun", HeldBy: "v-dryrun", Reason: ReasonHealth}, |
| + }, |
| + Apps: map[string]AppRollout{"dryrun/app": {Queued: 1}}, |
| + Failures: []RolloutFailure{ |
| + {Account: "dryrun", App: "app", Build: "v-dryrun", Host: dryRunRolloutHost, Mode: "overlap", Reason: ReasonHealth, PromotedAt: time.Now(), At: time.Now()}, |
| + }, |
| + }, |
| // The probe table, keyed by a route host and carrying a backend |
| // address — see dryRunUpstreamNeedles. One up and one down upstream, |
| // so the down one's reason string is exercised too. |
| @@ -388,6 +407,9 @@ func DryRun(dir string, out io.Writer) error { |
| // proofWaitKeySeen is the same positive half for D3's proof wait list |
| // (dryRunProofWaitNeedles). |
| proofWaitKeySeen := false |
| + // rolloutKeySeen is the same positive half for the rollout report |
| + // (dryRunRolloutNeedles). |
| + rolloutKeySeen := false |
| for _, key := range keys { |
| body, _, err := s.Get(ctx, key) |
| @@ -480,6 +502,17 @@ func DryRun(dir string, out io.Writer) error { |
| return fmt.Errorf("dry run: %s leaked a proof_waits fixture %v — ProofWaits must never reach the public feed", key, leaks) |
| } |
| + // dryRunRolloutNeedles: the seventh scope split, on the same |
| + // pattern. The rollout report names the managed instances. |
| + if strings.HasPrefix(key, "status/boxes/") { |
| + if leaks := ScanForLeaks(body, dryRunRolloutNeedles); len(leaks) == 0 { |
| + return fmt.Errorf("dry run: %s: expected rollout fixtures %v, found none — Rollout not attached?", key, dryRunRolloutNeedles) |
| + } |
| + rolloutKeySeen = true |
| + } else if leaks := ScanForLeaks(body, dryRunRolloutNeedles); len(leaks) > 0 { |
| + return fmt.Errorf("dry run: %s leaked a rollout fixture %v — Rollout must never reach the public feed", key, leaks) |
| + } |
| + |
| // Page assets (index.html, status.css) aren't JSON and simply fail |
| // to decode here — silently skipped, since the band-digit check |
| // only means anything for objects that actually carry Load/Capacity |
| @@ -513,6 +546,9 @@ func DryRun(dir string, out io.Writer) error { |
| if !proofWaitKeySeen { |
| return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — proof_waits scope check never ran") |
| } |
| + if !rolloutKeySeen { |
| + return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — rollout scope check never ran") |
| + } |
| fmt.Fprintln(out, "dry-run: no leaks") |
| return nil |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index e022884e9..c124c33e1 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -172,6 +172,9 @@ type Probes struct { |
| // ProofWaits is the hosts held on their current build waiting for a dev |
| // proof (D3). Private Metrics only: they name hosts. |
| ProofWaits []ProofWait |
| + // Rollout is the rollout scheduler's report (zero-downtime spec §8.4, |
| + // §9.1); nil with CARLOS_SWAP off. Private Metrics only: it names hosts. |
| + Rollout *RolloutReport |
| // ConfigGuard is "" in the normal mode and a words-only note when the |
| // config guard's explicit bypass is in effect on this box (amadan #213). |
| ConfigGuard string |
| @@ -246,6 +249,79 @@ type ReleaseRefusal struct { |
| RecoveryError string `json:"recovery_error,omitempty"` |
| } |
| +// RolloutReport is this box's rollout scheduler state (zero-downtime spec |
| +// §8.4, §9.1), published only with CARLOS_SWAP=overlap. It names hosts, so |
| +// it lives in the private Metrics block alone. |
| +type RolloutReport struct { |
| + Hosts map[string]HostRollout `json:"hosts,omitempty"` |
| + Apps map[string]AppRollout `json:"apps,omitempty"` // keyed "<acct>/<app>" |
| + Failures []RolloutFailure `json:"failures,omitempty"` |
| + // Unavailable, when set, is a reason code (not member-facing text) for |
| + // why CARLOS_SWAP=overlap is on but the scheduler could not arm; every |
| + // managed start is refused meanwhile (Task 14, refuseManaged). The rest |
| + // of the report is then empty. Plan 6 words it. |
| + Unavailable string `json:"unavailable,omitempty"` |
| +} |
| + |
| +// HostRollout is one managed instance. Attempt and Reason are the closed |
| +// words below; plan 6 renders them. |
| +type HostRollout struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Installed string `json:"installed"` |
| + Serving string `json:"serving,omitempty"` |
| + Good string `json:"good,omitempty"` |
| + Awake bool `json:"awake"` |
| + Attempt string `json:"attempt,omitempty"` |
| + AttemptBuild string `json:"attempt_build,omitempty"` |
| + HeldBy string `json:"held_by,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| + ConfigPending bool `json:"config_pending,omitempty"` |
| + // ConfigError is a reason word (Reason*), never raw error text. |
| + ConfigError string `json:"config_error,omitempty"` |
| +} |
| + |
| +// AppRollout counts one app's attempts. Settling is canary settles and |
| +// first-wake observations under way. |
| +type AppRollout struct { |
| + Queued int `json:"queued"` |
| + Running int `json:"running"` |
| + Settling int `json:"settling"` |
| +} |
| + |
| +// RolloutFailure is one failure record (spec §8.4). Reason is a reason word. |
| +type RolloutFailure struct { |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Build string `json:"build"` |
| + Host string `json:"host"` |
| + Mode string `json:"mode,omitempty"` |
| + Reason string `json:"reason"` |
| + PromotedAt time.Time `json:"promoted_at"` |
| + At time.Time `json:"at"` |
| + HeldDown []string `json:"held_down,omitempty"` |
| +} |
| + |
| +// The attempt words. |
| +const ( |
| + AttemptQueued = "queued" |
| + AttemptRunning = "running" |
| + AttemptSettling = "settling" |
| + AttemptFailed = "failed" |
| + AttemptDown = "down" |
| +) |
| + |
| +// The reason words: the closed vocabulary a member-facing event or status |
| +// line may give for a failure. internal/rollout maps every diagnostic text |
| +// onto one of them (reasonWord). |
| +const ( |
| + ReasonHealth = "health" |
| + ReasonSettle = "settle" |
| + ReasonExit = "exit" |
| + ReasonStaging = "staging" |
| + ReasonReused = "reused" |
| +) |
| + |
| // ProofWait is a host this box is holding on its current build because its |
| // channel head has no dev proof yet (D3, release.ProofGate), or because the |
| // box's proof policy file does not load. PRIVATE like the rest of |
| @@ -425,6 +501,10 @@ type BoxMetrics struct { |
| // until a dev proof for the new head arrives (D3). Hostname-bearing, so |
| // it lives in this private block only. Omitted when empty. |
| ProofWaits []ProofWait `json:"proof_waits,omitempty"` |
| + // Rollout is the rollout scheduler's report (zero-downtime spec §8.4, |
| + // §9.1). Hostname-bearing, so it lives in this private block only. |
| + // Omitted when the scheduler is off. |
| + Rollout *RolloutReport `json:"rollout,omitempty"` |
| // ConfigGuard says the box's admission guard is bypassed, e.g. |
| // "bypassed (CARLOS_NO_SEALED_CONFIG)": it admits placed exec instances |
| // without checking their sealed config. Omitted in the normal mode, so an |
| @@ -823,7 +903,7 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| p.DiskFreeBytes != nil || p.Release != "" || p.Tunnel != "" || |
| p.InstanceID != "" || p.Platform != "" || p.SystemChannel != "" || p.System != nil || |
| len(routeVersions) > 0 || len(aliasReadings) > 0 || len(p.Upstreams) > 0 || |
| - len(routeDBBytes) > 0 || p.Layout != nil || len(p.ConfigRefusals) > 0 || len(p.ReleaseRefusals) > 0 || len(p.ProofWaits) > 0 || p.ConfigGuard != "" || |
| + len(routeDBBytes) > 0 || p.Layout != nil || len(p.ConfigRefusals) > 0 || len(p.ReleaseRefusals) > 0 || len(p.ProofWaits) > 0 || p.Rollout != nil || p.ConfigGuard != "" || |
| p.Alerts != nil || p.GeoIP != nil { |
| box.Metrics = &BoxMetrics{ |
| ConsoleProcesses: p.ConsoleProcesses, |
| @@ -859,6 +939,7 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| ConfigRefusalsTotal: p.ConfigRefusalsTotal, |
| ReleaseRefusals: append([]ReleaseRefusal(nil), p.ReleaseRefusals...), |
| ProofWaits: append([]ProofWait(nil), p.ProofWaits...), |
| + Rollout: p.Rollout, |
| ConfigGuard: p.ConfigGuard, |
| } |
| } |
diff --git a/internal/status/snapshot_test.go b/internal/status/snapshot_test.go| index 85fdcb47c..1afd4c3b7 100644 |
| --- a/internal/status/snapshot_test.go |
| +++ b/internal/status/snapshot_test.go |
| @@ -157,3 +157,14 @@ func TestCollectBoxCarriesGeoIP(t *testing.T) { |
| t.Fatal("no GeoIP and nothing else known must still mean no metrics block") |
| } |
| } |
| + |
| +func TestTheRolloutReportRidesThePrivateMetrics(t *testing.T) { |
| + r := &RolloutReport{Hosts: map[string]HostRollout{"a.example.com": {App: "blog", Installed: "v2", Serving: "v1", Attempt: AttemptFailed, HeldBy: "v2", Reason: ReasonHealth}}} |
| + box := CollectBox(nil, Probes{Rollout: r}, time.Now()) |
| + if box.Metrics == nil || box.Metrics.Rollout == nil || box.Metrics.Rollout.Hosts["a.example.com"].HeldBy != "v2" { |
| + t.Fatalf("metrics = %+v", box.Metrics) |
| + } |
| + if b := CollectBox(nil, Probes{}, time.Now()); b.Metrics != nil { |
| + t.Fatal("the flag off publishes no rollout block and no Metrics because of it") |
| + } |
| +} |