settings-4 task 10: boxes report that they enforce update settings, and why each instance runs what it does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+428
−6
internal/host/agent.go+7 −2internal/host/updatesreport.go+109 −0internal/host/updatesreport_test.go+179 −0internal/instanceupdates/control.go+4 −2internal/status/contract_test.go+12 −1internal/status/dryrun.go+34 −0internal/status/snapshot.go+38 −1internal/status/status_test.go+45 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index a5b6d1b1f..f2f58a1ae 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -185,7 +185,6 @@ import ( |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/instancefs" |
| - "github.com/carlosframework/platform/internal/instanceupdates" |
| "github.com/carlosframework/platform/internal/layout" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/moves" |
| @@ -300,6 +299,9 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| // (buildStateHook, below): a failure here is one operator line, the |
| // agent keeps running, and every spawn is refused until it is fixed. |
| bs, bsErr := newBuildStates(cfg, reg, release.OptDir()) |
| + // The status report says this box enforces update settings only while |
| + // the build-state store is up (settings spec §4.2; Astra I3 #1). |
| + noteBuildStatesInit(bsErr) |
| if bsErr != nil { |
| log.Printf("agent: build state store unavailable: %v. every instance and worker spawn is refused until it is fixed and the agent restarted", bsErr) |
| } |
| @@ -376,7 +378,7 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| Timers: reg, |
| // The control socket's read-only GET /updates answers from the |
| // same registry copy (instance update settings spec §5.1). |
| - UpdatesReader: instanceupdates.RegistryReader(reg), |
| + UpdatesReader: updatesReader(reg, release.OptDir()), |
| // Asked inside Acquire, before the lease (amadan #213). |
| ConfigGate: configGate(), |
| } |
| @@ -3451,6 +3453,9 @@ func statusLoop(reg *registry.Registry, svc *activator.Service, buildStates func |
| } else { |
| p.BuildStates = pins |
| } |
| + // The update-settings protocol this box enforces, and root's last |
| + // decision per instance (settings spec §4.3). Private Metrics only. |
| + fillUpdateProbes(&p, reg, release.OptDir()) |
| // A fleet box (CARLOS_FLEET set) never gets here as a contender: it |
| // self-reports only, never attempting the election at all — no |
diff --git a/internal/host/updatesreport.go b/internal/host/updatesreport.go| new file mode 100644 |
| index 000000000..1dfec7499 |
| --- /dev/null |
| +++ b/internal/host/updatesreport.go |
| @@ -0,0 +1,109 @@ |
| +package host |
| + |
| +import ( |
| + "sync/atomic" |
| + |
| + "github.com/carlosframework/platform/internal/instanceupdates" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// buildStatesFailed is set by runAgent (noteBuildStatesInit) when the |
| +// build-state store did not start. The refusing BeforeExec hook is then |
| +// installed, so the box runs nothing it cannot record. |
| +var buildStatesFailed atomic.Bool |
| + |
| +// noteBuildStatesInit records how newBuildStates ended, for the report. A box |
| +// with no replica bucket is (nil, nil): it runs no hibernating instance, so |
| +// it has no build state to keep, and it enforces everything it has to; it |
| +// reports the protocol. |
| +func noteBuildStatesInit(err error) { buildStatesFailed.Store(err != nil) } |
| + |
| +// boxSettingsMetrics is this binary's settings protocol: it applies pin, |
| +// manual and major, and its retention protects the builds they name |
| +// (settings spec §4.4). It lifts the console's refusal. A box whose |
| +// build-state service failed to start reports 0: it refuses every exec and |
| +// enforces nothing (Astra I3 #1). |
| +func boxSettingsMetrics() int { |
| + if buildStatesFailed.Load() { |
| + return 0 |
| + } |
| + return status.UpdateSettingsProtocol |
| +} |
| + |
| +// fillUpdateProbes adds the update-settings report to one status tick's |
| +// probes: the protocol this box enforces and root's last decision per host. |
| +func fillUpdateProbes(p *status.Probes, reg *registry.Registry, optDir string) { |
| + p.UpdateSettings = boxSettingsMetrics() |
| + p.Updates = updatesReport(reg, optDir) |
| +} |
| + |
| +// updatesReport is root's last decision per host with settings or a decision, |
| +// for the console's list and instance page. The column is read as root reads |
| +// it (ParseUpdateSettingsLenient): a Control name a newer console added must |
| +// not blank the report. A column that does not parse even then still reports |
| +// root's decision (its reason is unknown). |
| +func updatesReport(reg *registry.Registry, optDir string) map[string]status.HostUpdates { |
| + routes, err := reg.List() |
| + if err != nil { |
| + return nil |
| + } |
| + out := map[string]status.HostUpdates{} |
| + for _, rt := range routes { |
| + u, perr := release.ParseUpdateSettingsLenient(rt.UpdateSettings) |
| + c, _ := release.ReadConsumed(optDir, rt.Host) |
| + if perr == nil && u.Default() && c == nil { |
| + continue |
| + } |
| + if perr != nil && c == nil { |
| + continue |
| + } |
| + bs, _ := release.ParseBuildStateCopy(rt.BuildState) |
| + h := status.HostUpdates{} |
| + if perr == nil { |
| + h.Major = u.Major |
| + } |
| + if c != nil { |
| + h.Desired, h.Reason, h.Detail, h.Baking = c.Desired, c.Reason, c.Detail, c.Baking |
| + // Root writes a raw parse error as the detail of reason unknown. |
| + // Members see only a build version. |
| + if h.Reason == string(release.ReasonUnknown) && !release.ValidVersion(h.Detail) { |
| + h.Detail = "" |
| + } |
| + h.Completed, h.CompletedFailed = c.Completed, c.CompletedFailed |
| + } |
| + // The build state joins the watermark only once the copy has been |
| + // read; unread, root does no request bookkeeping either. |
| + if bs.Known && bs.State != nil { |
| + if w := release.Watermark(c, bs.State); w != h.Completed { |
| + h.Completed, h.CompletedFailed = w, bs.State.CompletedFailed |
| + } |
| + } |
| + if perr == nil && u.InstallRequest > h.Completed { |
| + h.Pending = u.InstallRequest |
| + } |
| + out[rt.Host] = h |
| + } |
| + if len(out) == 0 { |
| + return nil |
| + } |
| + return out |
| +} |
| + |
| +// updatesReader is the control socket's reader (plan 3's RegistryReader, |
| +// which keeps the channel fallback, plus root's decision and the serving |
| +// build). |
| +func updatesReader(reg *registry.Registry, optDir string) func(string) (instanceupdates.Report, error) { |
| + base := instanceupdates.RegistryReader(reg) |
| + return func(host string) (instanceupdates.Report, error) { |
| + rep, err := base(host) |
| + if err != nil { |
| + return rep, err |
| + } |
| + if c, _ := release.ReadConsumed(optDir, host); c != nil { |
| + rep.Desired, rep.Reason = c.Desired, c.Reason |
| + } |
| + return rep, nil |
| + } |
| +} |
diff --git a/internal/host/updatesreport_test.go b/internal/host/updatesreport_test.go| new file mode 100644 |
| index 000000000..81b3795c3 |
| --- /dev/null |
| +++ b/internal/host/updatesreport_test.go |
| @@ -0,0 +1,179 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +func TestTheReportCarriesRootsDecision(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Desired: "v1", Reason: "major", Detail: "v1", Completed: 2, Baking: "v4"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + two := 2 |
| + if err := reg.SetUpdateSettings("a.example.com", release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Major: &two, InstallRequest: 3}.Column()); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := updatesReport(reg, b.optDir)["a.example.com"] |
| + if got.Desired != "v1" || got.Reason != "major" || got.Detail != "v1" || got.Pending != 3 || got.Completed != 2 || |
| + got.Baking != "v4" || got.Major == nil || *got.Major != 2 { |
| + t.Fatalf("report = %+v; want root's decision, the baking head beside it, and the effective major", got) |
| + } |
| + rep, err := updatesReader(reg, b.optDir)("a.example.com") |
| + if err != nil || rep.Desired != "v1" || rep.Reason != "major" || rep.Settings.Major == nil { |
| + t.Fatalf("socket report = %+v, %v", rep, err) |
| + } |
| +} |
| + |
| +func TestEveryBoxOfThisBinaryReportsTheProtocol(t *testing.T) { |
| + if m := boxSettingsMetrics(); m != status.UpdateSettingsProtocol { |
| + t.Fatalf("this binary reports %d", m) |
| + } |
| + // Astra I3 #1: a box whose build-state service failed does not claim to |
| + // enforce settings, so the console keeps refusing them. |
| + buildStatesFailed.Store(true) |
| + t.Cleanup(func() { buildStatesFailed.Store(false) }) |
| + if m := boxSettingsMetrics(); m != 0 { |
| + t.Fatalf("a box with no build-state service reports %d", m) |
| + } |
| +} |
| + |
| +// The agent's note of a failed build-state service is what the report reads: |
| +// a box with no replica bucket (nil, nil) has nothing to enforce and reports |
| +// the protocol; one whose store failed to start reports 0. |
| +func TestTheProtocolFollowsTheBuildStateService(t *testing.T) { |
| + t.Cleanup(func() { buildStatesFailed.Store(false) }) |
| + noteBuildStatesInit(nil) |
| + if m := boxSettingsMetrics(); m != status.UpdateSettingsProtocol { |
| + t.Fatalf("a box with no replica bucket reports %d, want the protocol", m) |
| + } |
| + noteBuildStatesInit(errors.New("no regions")) |
| + if m := boxSettingsMetrics(); m != 0 { |
| + t.Fatalf("a box whose build-state store failed reports %d, want 0", m) |
| + } |
| + noteBuildStatesInit(nil) |
| + if m := boxSettingsMetrics(); m != status.UpdateSettingsProtocol { |
| + t.Fatalf("a recovered start reports %d, want the protocol", m) |
| + } |
| +} |
| + |
| +func TestTheStatusProbesCarryTheProtocolAndTheDecisions(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Desired: "v1", Reason: "manual"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + var p status.Probes |
| + fillUpdateProbes(&p, reg, b.optDir) |
| + if p.UpdateSettings != status.UpdateSettingsProtocol || p.Updates["a.example.com"].Reason != "manual" { |
| + t.Fatalf("probes = %d, %+v", p.UpdateSettings, p.Updates) |
| + } |
| +} |
| + |
| +// A newer console may add a Control name this binary does not know; the |
| +// report must still be made, with the hold it carries (R-SETTINGS-UNREADABLE). |
| +func TestTheReportSurvivesAnUnknownControlName(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Desired: "v1", Reason: "manual"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + col := `{"updates":"manual","channel":"stable","control":["updates","from-the-future"]}` |
| + if err := reg.SetUpdateSettings("a.example.com", col); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := updatesReport(reg, b.optDir)["a.example.com"]; got.Reason != "manual" { |
| + t.Fatalf("report = %+v; want the decision despite the unknown control name", got) |
| + } |
| + rep, err := updatesReader(reg, b.optDir)("a.example.com") |
| + if err != nil || rep.Reason != "manual" || rep.Settings.Updates != release.UpdatesManual || rep.Settings.Channel != "stable" { |
| + t.Fatalf("socket report = %+v, %v", rep, err) |
| + } |
| + if len(rep.MayChange) != 1 || rep.MayChange[0] != release.ControlUpdates { |
| + t.Fatalf("may change = %v; want only the known name", rep.MayChange) |
| + } |
| +} |
| + |
| +// A route with the default settings and no decision has nothing to say; the |
| +// serving build is added to the socket report; the channel fallback stays. |
| +func TestTheReaderKeepsTheChannelFallbackAndAddsTheServingBuild(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + if got := updatesReport(reg, b.optDir); got != nil { |
| + t.Fatalf("report = %+v; want none for a default instance root has not decided", got) |
| + } |
| + rt, _ := reg.Get("a.example.com") |
| + rt.Channel = "beta" |
| + if err := reg.Upsert(*rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetServingVersion("a.example.com", "v7"); err != nil { |
| + t.Fatal(err) |
| + } |
| + rep, err := updatesReader(reg, b.optDir)("a.example.com") |
| + if err != nil || rep.Settings.Channel != "beta" || rep.Serving != "v7" { |
| + t.Fatalf("socket report = %+v, %v; want the route's channel and its serving build", rep, err) |
| + } |
| +} |
| + |
| +// Consumed.Detail for reason unknown can be a raw parse error; it must not |
| +// reach a member. A build version is the one detail that may. |
| +func TestAnUnknownReasonReportsNoRawDetail(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + raw := `update settings: invalid character 'x' looking for beginning of value` |
| + for _, c := range []struct{ detail, want string }{{raw, ""}, {"v3", "v3"}} { |
| + if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Reason: "unknown", Detail: c.detail}); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := updatesReport(reg, b.optDir)["a.example.com"] |
| + if got.Reason != "unknown" || got.Detail != c.want { |
| + t.Fatalf("detail %q: report = %+v, want detail %q", c.detail, got, c.want) |
| + } |
| + } |
| + if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Reason: "major", Detail: "not a version!"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := updatesReport(reg, b.optDir)["a.example.com"]; got.Detail != "not a version!" { |
| + t.Fatalf("a reason other than unknown keeps root's detail, got %+v", got) |
| + } |
| +} |
| + |
| +// The watermark joins the consumed file and the build state only when the |
| +// build-state copy has been read. Unread, the report is the consumed file's |
| +// alone, and a request is pending only above that. |
| +func TestAnUnreadBuildStateReportsTheConsumedFileAlone(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + if err := release.WriteConsumed(b.optDir, release.Consumed{Schema: 1, Host: "a.example.com", Reason: "request", Completed: 2}); err != nil { |
| + t.Fatal(err) |
| + } |
| + set := func(req uint64) { |
| + t.Helper() |
| + if err := reg.SetUpdateSettings("a.example.com", release.UpdateSettings{Updates: release.UpdatesAuto, InstallRequest: req}.Column()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + setCopy := func(c release.BuildStateCopy) { |
| + t.Helper() |
| + if err := reg.SetBuildState("a.example.com", c.Column()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // Unread copy that nevertheless holds a higher completed id: not used. |
| + setCopy(release.BuildStateCopy{Known: false, State: &release.BuildState{Schema: 1, Host: "a.example.com", Completed: 9, CompletedFailed: true}}) |
| + set(5) |
| + got := updatesReport(reg, b.optDir)["a.example.com"] |
| + if got.Completed != 2 || got.Pending != 5 || got.CompletedFailed { |
| + t.Fatalf("unread: report = %+v; want completed 2 from the consumed file, pending 5", got) |
| + } |
| + set(2) |
| + if got := updatesReport(reg, b.optDir)["a.example.com"]; got.Pending != 0 || got.Completed != 2 { |
| + t.Fatalf("unread, request at the watermark: report = %+v; want nothing pending", got) |
| + } |
| + // Read: the build state's higher completed id counts. |
| + setCopy(release.BuildStateCopy{Known: true, State: &release.BuildState{Schema: 1, Host: "a.example.com", Completed: 9, CompletedFailed: true}}) |
| + set(5) |
| + got = updatesReport(reg, b.optDir)["a.example.com"] |
| + if got.Completed != 9 || got.Pending != 0 || !got.CompletedFailed { |
| + t.Fatalf("read: report = %+v; want completed 9, failed, nothing pending", got) |
| + } |
| +} |
diff --git a/internal/instanceupdates/control.go b/internal/instanceupdates/control.go| index a115b9e9b..ef30fc4ba 100644 |
| --- a/internal/instanceupdates/control.go |
| +++ b/internal/instanceupdates/control.go |
| @@ -66,7 +66,7 @@ var errNoRoute = errors.New("instanceupdates: no route for host") |
| // RegistryReader answers from the box's registry copy: the settings column |
| // (with the route's channel when the column names none), the installed |
| // build and the serving build. Root's desired build and reason |
| -// are added by plan 4's reader, which wraps this one. |
| +// are added by the host's reader, which wraps this one. |
| func RegistryReader(reg interface { |
| Get(host string) (*registry.Route, error) |
| }) func(string) (Report, error) { |
| @@ -78,7 +78,9 @@ func RegistryReader(reg interface { |
| if rt == nil { |
| return Report{}, errNoRoute |
| } |
| - u, err := release.ParseUpdateSettings(rt.UpdateSettings) |
| + // Lenient, as the box's passes read the column: a Control name a newer |
| + // console added must not make the whole report unavailable. |
| + u, err := release.ParseUpdateSettingsLenient(rt.UpdateSettings) |
| if err != nil { |
| return Report{}, err |
| } |
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go| index ca8855275..a46b992ac 100644 |
| --- a/internal/status/contract_test.go |
| +++ b/internal/status/contract_test.go |
| @@ -82,7 +82,7 @@ var allowedBoxKeys = map[string][]string{ |
| "": {"generated_at", "label", "region", "routes", "load", "capacity", "replication", "adoption_running", "config_path_running", "restart_path_running", "expired_canaries", "metrics"}, |
| "routes": {"static", "resident", "hibernating", "awake", "asleep"}, |
| "replication": {"healthy", "lag_seconds"}, |
| - "metrics": {"mem_total_bytes", "mem_avail_bytes", "load1", "disk_free_bytes", "release", "tunnel", "route_versions", "upstreams", "layout", "config_refusals", "config_refusals_total", "release_refusals", "proof_waits", "config_guard", "build_states", "build_states_err"}, |
| + "metrics": {"mem_total_bytes", "mem_avail_bytes", "load1", "disk_free_bytes", "release", "tunnel", "route_versions", "upstreams", "layout", "config_refusals", "config_refusals_total", "release_refusals", "proof_waits", "config_guard", "build_states", "build_states_err", "update_settings", "updates"}, |
| "metrics.route_versions": {wildcardAnyKey}, |
| "metrics.upstreams": {wildcardAnyKey}, |
| "metrics.upstreams.*[]": {"addr", "up", "since", "fails", "last_error"}, |
| @@ -105,6 +105,10 @@ var allowedBoxKeys = map[string][]string{ |
| // the field level. |
| "metrics.build_states": {wildcardAnyKey}, |
| "metrics.build_states.*": {"account", "app", "builds", "complete"}, |
| + // updates (settings spec §4.3) is host -> root's last decision for that |
| + // host, in the private block only; the values are pinned to the field level. |
| + "metrics.updates": {wildcardAnyKey}, |
| + "metrics.updates.*": {"desired", "reason", "detail", "baking", "major", "pending", "completed", "completed_failed"}, |
| } |
| // allowedFleetKeys are THE schema for Fleet's JSON output. Same friction as |
| @@ -287,6 +291,7 @@ func fixtureProbes() status.Probes { |
| diskFrac := 0.80 // Between 0.70 and 0.90 -> "getting-full" |
| lagSecs := int64(300) // 5 minutes, healthy (< 15 min) |
| adopting := true // a real reading, so the field is scanned with a value in it |
| + contractMajor := 2 |
| memTotal := int64(8123456789) // distinct from dryrun.go's 4242424242 |
| memAvail := int64(3123456789) // distinct from dryrun.go's 4242424241 |
| @@ -355,6 +360,12 @@ func fixtureProbes() status.Probes { |
| BuildStates: map[string]status.BuildStatePins{ |
| "contract-held.internal": {Account: "bab", App: "km", Builds: []string{"v1", "v2"}, Complete: true}, |
| }, |
| + // One host's update decision, so metrics.updates is exercised by the |
| + // allowlist walk too. |
| + UpdateSettings: status.UpdateSettingsProtocol, |
| + Updates: map[string]status.HostUpdates{ |
| + "contract-decided.internal": {Desired: "v2", Reason: "major", Detail: "v2", Baking: "v3", Major: &contractMajor, Pending: 4, Completed: 3, CompletedFailed: true}, |
| + }, |
| ConfigGuard: "bypassed (CARLOS_NO_SEALED_CONFIG)", |
| MemFrac: &memFrac, |
| DiskFrac: &diskFrac, |
diff --git a/internal/status/dryrun.go b/internal/status/dryrun.go| index 5e198c134..791056ecc 100644 |
| --- a/internal/status/dryrun.go |
| +++ b/internal/status/dryrun.go |
| @@ -152,6 +152,17 @@ const ( |
| var dryRunBuildStateNeedles = []string{dryRunBuildStateHost, dryRunBuildStateBuild} |
| +// dryRunUpdatesHost and dryRunUpdatesBuild are the update-decision needles |
| +// (settings spec §4.3): Box.Metrics.Updates names the hosts this box runs and |
| +// the builds root chose for them, so the same scope split applies. REQUIRED in |
| +// status/boxes/*.json and BANNED in every other published object. |
| +const ( |
| + dryRunUpdatesHost = "updates-host.internal" |
| + dryRunUpdatesBuild = "v-dryrun-updates" |
| +) |
| + |
| +var dryRunUpdatesNeedles = []string{dryRunUpdatesHost, dryRunUpdatesBuild} |
| + |
| // 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 |
| @@ -262,6 +273,11 @@ func dryRunFixtures() ([]registry.Route, Probes) { |
| BuildStates: map[string]BuildStatePins{ |
| dryRunBuildStateHost: {Account: "dryrun", App: "app", Builds: []string{dryRunBuildStateBuild}, Complete: true}, |
| }, |
| + // One host's update decision, for the updates scope check. |
| + UpdateSettings: UpdateSettingsProtocol, |
| + Updates: map[string]HostUpdates{ |
| + dryRunUpdatesHost: {Desired: dryRunUpdatesBuild, Reason: "manual", Completed: 1}, |
| + }, |
| // 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. |
| @@ -429,6 +445,9 @@ func DryRun(dir string, out io.Writer) error { |
| // buildStateKeySeen is the same positive half for the build-state report |
| // (dryRunBuildStateNeedles). |
| buildStateKeySeen := false |
| + // updatesKeySeen is the same positive half for the update decisions |
| + // (dryRunUpdatesNeedles). |
| + updatesKeySeen := false |
| for _, key := range keys { |
| body, _, err := s.Get(ctx, key) |
| @@ -543,6 +562,17 @@ func DryRun(dir string, out io.Writer) error { |
| return fmt.Errorf("dry run: %s leaked a build_states fixture %v. BuildStates must never reach the public feed", key, leaks) |
| } |
| + // dryRunUpdatesNeedles: the ninth scope split, on the same pattern. |
| + // The update decisions name hosts and builds. |
| + if strings.HasPrefix(key, "status/boxes/") { |
| + if leaks := ScanForLeaks(body, dryRunUpdatesNeedles); len(leaks) == 0 { |
| + return fmt.Errorf("dry run: %s: expected updates fixtures %v, found none. Updates not attached?", key, dryRunUpdatesNeedles) |
| + } |
| + updatesKeySeen = true |
| + } else if leaks := ScanForLeaks(body, dryRunUpdatesNeedles); len(leaks) > 0 { |
| + return fmt.Errorf("dry run: %s leaked an updates fixture %v. Updates 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 |
| @@ -583,6 +613,10 @@ func DryRun(dir string, out io.Writer) error { |
| return fmt.Errorf("dry run: no status/boxes/*.json object was scanned. The build_states scope check never ran") |
| } |
| + if !updatesKeySeen { |
| + return fmt.Errorf("dry run: no status/boxes/*.json object was scanned. The updates 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 487e36553..90356705c 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -183,6 +183,34 @@ type Probes struct { |
| // collect them. Private Metrics only: they name hosts. |
| BuildStates map[string]BuildStatePins |
| BuildStatesErr string |
| + // UpdateSettings is the update-settings protocol this box enforces |
| + // (BoxMetrics.UpdateSettings): UpdateSettingsProtocol, or 0 when the box |
| + // cannot honour pin, manual and major. Updates is root's last decision per |
| + // host. Private Metrics only: Updates names hosts and builds. |
| + UpdateSettings int |
| + Updates map[string]HostUpdates |
| +} |
| + |
| +// HostUpdates is root's last update decision for one instance, for the |
| +// console's list and instance page (settings spec §4.3). Everything is |
| +// omitempty: a field root has nothing to say about is absent. |
| +type HostUpdates struct { |
| + // Desired is the build root last chose for the instance, and Reason why |
| + // it differs from the channel head (a release.TargetReason word). Detail |
| + // is a build version when root names one (the major hold's head). |
| + Desired string `json:"desired,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| + Detail string `json:"detail,omitempty"` |
| + // Baking is the head a pending request waits on, reported beside Reason. |
| + Baking string `json:"baking,omitempty"` |
| + // Major is the instance's effective major hold, so a list can name one |
| + // the instance inherits. |
| + Major *int `json:"major,omitempty"` |
| + // Pending is the install request not yet completed; Completed is the |
| + // highest completed request; CompletedFailed says that one failed. |
| + Pending uint64 `json:"pending,omitempty"` |
| + Completed uint64 `json:"completed,omitempty"` |
| + CompletedFailed bool `json:"completed_failed,omitempty"` |
| } |
| // RowLayout is one instance-kind route's layout, as the box's layout pass |
| @@ -465,6 +493,12 @@ type BoxMetrics struct { |
| // fresh report says so (Astra I2 #5): an absent map is never "nothing to |
| // protect". |
| BuildStatesErr string `json:"build_states_err,omitempty"` |
| + // Updates is, per instance this box hosts that has non-default settings |
| + // or a decision of root's, root's last decision (settings spec §4.3). |
| + // Host-keyed and private like RouteVersions: it names hosts and builds, |
| + // so it lives in this block and never reaches the public feed. Omitted |
| + // when empty. |
| + Updates map[string]HostUpdates `json:"updates,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 |
| @@ -953,7 +987,8 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| 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.Rollout != nil || p.ConfigGuard != "" || |
| - p.Alerts != nil || p.GeoIP != nil || len(p.BuildStates) > 0 || p.BuildStatesErr != "" { |
| + p.Alerts != nil || p.GeoIP != nil || len(p.BuildStates) > 0 || p.BuildStatesErr != "" || |
| + p.UpdateSettings != 0 || len(p.Updates) > 0 { |
| box.Metrics = &BoxMetrics{ |
| ConsoleProcesses: p.ConsoleProcesses, |
| ActivationID: p.ActivationID, ActivationComplete: p.ActivationComplete, |
| @@ -993,6 +1028,8 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| // The caller builds a fresh map every tick. |
| BuildStates: p.BuildStates, |
| BuildStatesErr: p.BuildStatesErr, |
| + UpdateSettings: p.UpdateSettings, |
| + Updates: p.Updates, |
| } |
| } |
diff --git a/internal/status/status_test.go b/internal/status/status_test.go| index 2801921fa..ad9400103 100644 |
| --- a/internal/status/status_test.go |
| +++ b/internal/status/status_test.go |
| @@ -1675,3 +1675,48 @@ func TestCollectBoxCarriesABuildStatesFailure(t *testing.T) { |
| t.Fatalf("the failure is not published: %s", raw) |
| } |
| } |
| + |
| +// TestCollectBoxCarriesTheUpdateSettingsProtocolAndDecisions (settings spec |
| +// §4.3, §4.4): a box that enforces update settings says so, and names root's |
| +// last decision per instance in the private Metrics block. The protocol alone |
| +// attaches the block (the console reads it to lift its refusal); the hosts |
| +// and builds never reach the public fleet.json. |
| +func TestCollectBoxCarriesTheUpdateSettingsProtocolAndDecisions(t *testing.T) { |
| + routes := []registry.Route{{Host: "decided-secret.example.com", Kind: "instance", Hibernate: true}} |
| + only := status.CollectBox(routes, status.Probes{MachineID: "m", UpdateSettings: status.UpdateSettingsProtocol}, time.Now()) |
| + if only.Metrics == nil || only.Metrics.UpdateSettings != status.UpdateSettingsProtocol { |
| + t.Fatalf("metrics = %+v, want the protocol alone to attach the block", only.Metrics) |
| + } |
| + raw, _ := json.Marshal(only) |
| + if strings.Contains(string(raw), `"updates"`) { |
| + t.Errorf("a box with no decisions publishes updates: %s", raw) |
| + } |
| + |
| + two := 2 |
| + ups := map[string]status.HostUpdates{ |
| + "decided-secret.example.com": {Desired: "v-secret-desired", Reason: "major", Detail: "v1", Baking: "v-secret-baking", Major: &two, Pending: 3, Completed: 2, CompletedFailed: true}, |
| + } |
| + b := status.CollectBox(routes, status.Probes{MachineID: "m", Updates: ups}, time.Now()) |
| + if b.Metrics == nil || !reflect.DeepEqual(b.Metrics.Updates, ups) { |
| + t.Fatalf("updates = %+v, want %+v", b.Metrics, ups) |
| + } |
| + s := teststore(t) |
| + ctx := context.Background() |
| + if err := status.WriteBox(ctx, s, b); err != nil { |
| + t.Fatal(err) |
| + } |
| + boxes, complete, err := status.LoadBoxesComplete(ctx, s) |
| + if err != nil || !complete || len(boxes) != 1 || boxes[0].Metrics == nil || !reflect.DeepEqual(boxes[0].Metrics.Updates, ups) { |
| + t.Fatalf("read back %+v, %v, %v; want the decisions", boxes, complete, err) |
| + } |
| + f, err := status.BuildFleet(ctx, s, routes, time.Now(), 5*time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fj, _ := json.Marshal(f) |
| + for _, needle := range []string{"decided-secret.example.com", "v-secret-desired", "v-secret-baking", `"updates"`, "update_settings"} { |
| + if strings.Contains(string(fj), needle) { |
| + t.Errorf("fleet.json leaked %q: %s", needle, fj) |
| + } |
| + } |
| +} |