status: leak guards for ReleaseRefusals
The dry-run needle, the contract allowlist and a CollectBox/BuildFleet test now cover Metrics.ReleaseRefusals the way they cover ConfigRefusals: required in status/boxes/*.json, banned everywhere else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+97
−13
internal/status/contract_test.go+21 −13internal/status/dryrun.go+29 −0internal/status/status_test.go+47 −0
CI passed — run details
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go| index 445720e6f..4b7f0fe04 100644 |
| --- a/internal/status/contract_test.go |
| +++ b/internal/status/contract_test.go |
| @@ -75,7 +75,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", "config_refusals", "config_refusals_total", "config_guard"}, |
| + "metrics": {"mem_total_bytes", "mem_avail_bytes", "load1", "disk_free_bytes", "release", "tunnel", "route_versions", "upstreams", "config_refusals", "config_refusals_total", "release_refusals", "config_guard"}, |
| "metrics.route_versions": {wildcardAnyKey}, |
| "metrics.upstreams": {wildcardAnyKey}, |
| "metrics.upstreams.*[]": {"addr", "up", "since", "fails", "last_error"}, |
| @@ -83,6 +83,9 @@ var allowedBoxKeys = map[string][]string{ |
| // the private block only. Pinned to the field level so a future field on |
| // the refusal is a publication decision too. |
| "metrics.config_refusals[]": {"host", "reason", "since", "last", "count"}, |
| + // release_refusals (D1) names the host and build the box rolled back, in |
| + // the private block only, pinned to the field level for the same reason. |
| + "metrics.release_refusals[]": {"host", "version", "reason", "refused_at"}, |
| } |
| // allowedFleetKeys are THE schema for Fleet's JSON output. Same friction as |
| @@ -302,18 +305,23 @@ func fixtureProbes() status.Probes { |
| {Host: "contract-refused.internal", Reason: "cannot_open", Since: time.Now().Add(-time.Hour), Last: time.Now(), Count: 2}, |
| }, |
| ConfigRefusalsTotal: 1, |
| - ConfigGuard: "bypassed (CARLOS_NO_SEALED_CONFIG)", |
| - MemFrac: &memFrac, |
| - DiskFrac: &diskFrac, |
| - Listening: listening, |
| - LagSecs: &lagSecs, |
| - AdoptRunning: &adopting, |
| - MemTotalBytes: &memTotal, |
| - MemAvailBytes: &memAvail, |
| - Load1: &load1, |
| - DiskFreeBytes: &diskFree, |
| - Release: "contract-fixture-sha", // distinct from dryrun.go's "dryrun4242sha" |
| - Tunnel: "connected", // distinct from dryrun.go's "reconnecting" |
| + // One rolled-back build, so metrics.release_refusals is exercised |
| + // by the allowlist walk too. |
| + ReleaseRefusals: []status.ReleaseRefusal{ |
| + {Host: "contract-rolled-back.internal", Version: "v2", Reason: "exited: status=2", RefusedAt: time.Now()}, |
| + }, |
| + ConfigGuard: "bypassed (CARLOS_NO_SEALED_CONFIG)", |
| + MemFrac: &memFrac, |
| + DiskFrac: &diskFrac, |
| + Listening: listening, |
| + LagSecs: &lagSecs, |
| + AdoptRunning: &adopting, |
| + MemTotalBytes: &memTotal, |
| + MemAvailBytes: &memAvail, |
| + Load1: &load1, |
| + DiskFreeBytes: &diskFree, |
| + Release: "contract-fixture-sha", // distinct from dryrun.go's "dryrun4242sha" |
| + Tunnel: "connected", // distinct from dryrun.go's "reconnecting" |
| } |
| } |
diff --git a/internal/status/dryrun.go b/internal/status/dryrun.go| index 25e02899f..fd9026ae5 100644 |
| --- a/internal/status/dryrun.go |
| +++ b/internal/status/dryrun.go |
| @@ -116,6 +116,14 @@ const dryRunConfigRefusalHost = "refused-secret.internal" |
| var dryRunConfigRefusalNeedles = []string{dryRunConfigRefusalHost} |
| +// dryRunReleaseRefusalHost is the release refusal needle (D1): |
| +// Box.Metrics.ReleaseRefusals names the hosts whose build this box rolled |
| +// back, so the same scope split applies. REQUIRED in status/boxes/*.json and |
| +// BANNED in every other published object. |
| +const dryRunReleaseRefusalHost = "refused-build.internal" |
| + |
| +var dryRunReleaseRefusalNeedles = []string{dryRunReleaseRefusalHost} |
| + |
| // 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 |
| @@ -203,6 +211,10 @@ func dryRunFixtures() ([]registry.Route, Probes) { |
| {Host: dryRunConfigRefusalHost, Reason: "no_key", Since: time.Now().Add(-time.Hour), Last: time.Now(), Count: 2}, |
| }, |
| ConfigRefusalsTotal: 1, |
| + // One rolled-back build (D1), for the same scope check. |
| + ReleaseRefusals: []ReleaseRefusal{ |
| + {Host: dryRunReleaseRefusalHost, Version: "v-dryrun", Reason: "exited: status=2", RefusedAt: 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. |
| @@ -358,6 +370,9 @@ func DryRun(dir string, out io.Writer) error { |
| // configRefusalKeySeen is the same positive half for the config guard's |
| // refusal list (dryRunConfigRefusalNeedles), amadan #213. |
| configRefusalKeySeen := false |
| + // releaseRefusalKeySeen is the same positive half for D1's release |
| + // refusal list (dryRunReleaseRefusalNeedles). |
| + releaseRefusalKeySeen := false |
| for _, key := range keys { |
| body, _, err := s.Get(ctx, key) |
| @@ -428,6 +443,17 @@ func DryRun(dir string, out io.Writer) error { |
| return fmt.Errorf("dry run: %s leaked a config_refusals fixture %v — ConfigRefusals must never reach the public feed", key, leaks) |
| } |
| + // dryRunReleaseRefusalNeedles: the fifth scope split, on the same |
| + // pattern. The rolled-back builds name real hosts. |
| + if strings.HasPrefix(key, "status/boxes/") { |
| + if leaks := ScanForLeaks(body, dryRunReleaseRefusalNeedles); len(leaks) == 0 { |
| + return fmt.Errorf("dry run: %s: expected release_refusals fixtures %v, found none — ReleaseRefusals not attached?", key, dryRunReleaseRefusalNeedles) |
| + } |
| + releaseRefusalKeySeen = true |
| + } else if leaks := ScanForLeaks(body, dryRunReleaseRefusalNeedles); len(leaks) > 0 { |
| + return fmt.Errorf("dry run: %s leaked a release_refusals fixture %v — ReleaseRefusals 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 |
| @@ -455,6 +481,9 @@ func DryRun(dir string, out io.Writer) error { |
| if !configRefusalKeySeen { |
| return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — config_refusals scope check never ran") |
| } |
| + if !releaseRefusalKeySeen { |
| + return fmt.Errorf("dry run: no status/boxes/*.json object was scanned — release_refusals scope check never ran") |
| + } |
| fmt.Fprintln(out, "dry-run: no leaks") |
| return nil |
diff --git a/internal/status/status_test.go b/internal/status/status_test.go| index c9f6c9035..45db1778d 100644 |
| --- a/internal/status/status_test.go |
| +++ b/internal/status/status_test.go |
| @@ -1497,6 +1497,53 @@ func TestCollectBoxCarriesConfigRefusals(t *testing.T) { |
| } |
| } |
| +// TestCollectBoxCarriesReleaseRefusals (D1): a box that rolled a build back |
| +// says so in its private Metrics block, naming the host, version and reason. |
| +// The hostname never reaches the public fleet.json, and a box with no |
| +// refusal carries no key at all. |
| +func TestCollectBoxCarriesReleaseRefusals(t *testing.T) { |
| + at := time.Date(2026, 10, 6, 9, 30, 40, 0, time.UTC) |
| + refusals := []status.ReleaseRefusal{ |
| + {Host: "rolled-back.example.com", Version: "v2-secret-build", Reason: "exited: status=2", RefusedAt: at}, |
| + } |
| + routes := []registry.Route{{Host: "rolled-back.example.com", Kind: "instance", Hibernate: true}} |
| + b := status.CollectBox(routes, status.Probes{MachineID: "m", ReleaseRefusals: refusals}, time.Now()) |
| + if b.Metrics == nil { |
| + t.Fatal("metrics block missing: a release refusal alone should attach it") |
| + } |
| + if got := b.Metrics.ReleaseRefusals; len(got) != 1 || got[0] != refusals[0] { |
| + t.Fatalf("release_refusals = %+v, want %+v", got, refusals) |
| + } |
| + |
| + s := teststore(t) |
| + ctx := context.Background() |
| + if err := status.WriteBox(ctx, s, b); err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err := status.BuildFleet(ctx, s, routes, time.Now(), 5*time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fj, err := json.Marshal(f) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, needle := range []string{"rolled-back.example.com", "v2-secret-build", "exited: status=2", "release_refusals"} { |
| + if strings.Contains(string(fj), needle) { |
| + t.Errorf("fleet.json leaked %q: %s", needle, fj) |
| + } |
| + } |
| + |
| + quiet := status.CollectBox(routes, status.Probes{MachineID: "m"}, time.Now()) |
| + if quiet.Metrics != nil { |
| + t.Errorf("metrics = %+v with no refusal, want absent", quiet.Metrics) |
| + } |
| + raw, _ := json.Marshal(status.CollectBox(routes, status.Probes{MachineID: "m", MemTotalBytes: new(int64)}, time.Now())) |
| + if strings.Contains(string(raw), "release_refusals") { |
| + t.Errorf("a box with no refusal publishes release_refusals: %s", raw) |
| + } |
| +} |
| + |
| // The config guard's bypass is visible in the private block, and only there. |
| func TestCollectBoxCarriesTheConfigGuardBypass(t *testing.T) { |
| b := status.CollectBox(nil, status.Probes{MachineID: "m", ConfigGuard: "bypassed (CARLOS_NO_SEALED_CONFIG)"}, time.Now()) |