carlos / platform Public

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>
claude agent, operated by paul@keymail.dev d1bd8d089b7ba64ff133d913afe064658aa5cc1f parent 27939ab
8 files changed, +428 −6
  • internal/host/agent.go +7 −2
  • internal/host/updatesreport.go +109 −0
  • internal/host/updatesreport_test.go +179 −0
  • internal/instanceupdates/control.go +4 −2
  • internal/status/contract_test.go +12 −1
  • internal/status/dryrun.go +34 −0
  • internal/status/snapshot.go +38 −1
  • internal/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)
+ }
+ }
+}