zdd-readiness: claims-on boxes decline CARLOS_SWAP=overlap (host-claims-on)
swapArming now checks eb.Cfg.HostWrites.Enabled first and declines with the code host-claims-on; rolloutwords words it as unavailableDeclined and counts it in UnavailableDeclines, so the deploy watch URL-watches a claims box like any declined box.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+70
−8
deploywatch_test.go+2 −0internal/host/rollout.go+15 −2internal/host/rollout_test.go+39 −0internal/rolloutwords/words.go+2 −1internal/rolloutwords/words_test.go+9 −3internal/status/snapshot.go+3 −2
diff --git a/deploywatch_test.go b/deploywatch_test.go| index 38a4ca0bb..1bd7805ba 100644 |
| --- a/deploywatch_test.go |
| +++ b/deploywatch_test.go |
| @@ -557,6 +557,8 @@ func TestWatchRolloutWithNoReportsTimesOutAsTheOldWatch(t *testing.T) { |
| "flag off": {rec("x.test", nil), rec("y.test", nil)}, |
| // R6: every box declined overlap. |
| "declined": {rec("x.test", &apiRollout{Box: "x1", Installed: B, Unavailable: "no-cgroup-delegation"}), rec("y.test", nil)}, |
| + // A claims-on box declines overlap too (#272): URL-watched like any declined box. |
| + "claims-on": {rec("x.test", &apiRollout{Box: "x1", Installed: B, Unavailable: "host-claims-on"}), rec("y.test", nil)}, |
| // A report on an instance the deploy does not target is not one. |
| "untargeted report": {rec("x.test", nil), rec("y.test", nil), rec("z.test", &apiRollout{Box: "x1", Installed: B, Serving: B, Awake: true})}, |
| } { |
diff --git a/internal/host/rollout.go b/internal/host/rollout.go| index 4df546508..7fd4ccef9 100644 |
| --- a/internal/host/rollout.go |
| +++ b/internal/host/rollout.go |
| @@ -28,6 +28,11 @@ const ( |
| rolloutNoDelegation = "no-cgroup-delegation" |
| rolloutNoGenControl = "no-generation-control" |
| rolloutGenerationsOff = "generations-off" |
| + // The flag is on but host claims are on: claims-backed launches cannot |
| + // yet carry a swap's recovery choice, its execution evidence, the |
| + // generations file or the good build (#272, "claims-on swaps"), so the |
| + // box declines and stays on today's paths. |
| + rolloutHostClaims = "host-claims-on" |
| // The flag is on and generations are on, but the scheduler's required |
| // state is unavailable, so every managed start is refused |
| // (refuseManaged). |
| @@ -134,8 +139,9 @@ func swapDelegation(eb *activator.ExecBackend) string { |
| // swapArming decides, before the activator exists, whether CARLOS_SWAP=overlap |
| // turns generations on (spec §15; plan 3's review: missing delegation must |
| // fail closed, CODEX-FINDINGS-ZDD-IMPL-3.md:138). With the flag off it is |
| -// false and says nothing. With the flag on but no usable containment it is |
| -// false too: generations, generation containment and the scheduler all stay |
| +// false and says nothing. With the flag on but host claims on, or no usable |
| +// containment, it is false too (claims-on boxes get no swaps, the owner's |
| +// ruling, #272): generations, generation containment and the scheduler all stay |
| // off, every leg keeps today's paths but for the failure records armRollout |
| // honours (honourDeclinedFailures), and the refusal is logged loudly and |
| // published as a reason code in the box's status (rolloutStatusProbe). |
| @@ -143,6 +149,13 @@ func swapArming(getenv func(string) string, eb *activator.ExecBackend) bool { |
| if !rollout.Enabled(getenv) { |
| return false |
| } |
| + if eb != nil && eb.Cfg.HostWrites.Enabled { |
| + log.Printf("agent: REFUSING %s=overlap (%s): host claims are on, and claims-backed launches cannot yet keep a swap's "+ |
| + "recovery choice, execution evidence, generations file or good build, so this box stays on today's stop-then-wake paths", |
| + rollout.SwapEnv, rolloutHostClaims) |
| + declineRollout(rolloutHostClaims) |
| + return false |
| + } |
| if why := swapDelegation(eb); why != "" { |
| log.Printf("agent: REFUSING %s=overlap (%s): generation containment needs the agent's delegated cgroup subtree "+ |
| "and the control channel's generation units, so this box stays on today's stop-then-wake paths", rollout.SwapEnv, why) |
diff --git a/internal/host/rollout_test.go b/internal/host/rollout_test.go| index f8d8acb61..ac39aae6d 100644 |
| --- a/internal/host/rollout_test.go |
| +++ b/internal/host/rollout_test.go |
| @@ -1,12 +1,15 @@ |
| package host |
| import ( |
| + "bytes" |
| "context" |
| "errors" |
| + "log" |
| "os" |
| "path/filepath" |
| "reflect" |
| "slices" |
| + "strings" |
| "sync" |
| "testing" |
| "time" |
| @@ -14,6 +17,7 @@ import ( |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -362,6 +366,41 @@ func TestDelegationArmsTheFlag(t *testing.T) { |
| } |
| } |
| +// A box with host claims on declines the swap flag (#272): claims-backed |
| +// launches cannot yet carry a swap's recovery choice, execution evidence, |
| +// generations file or good build. The claims check comes before delegation's, |
| +// so a box that has neither says claims. |
| +func TestSwapArmingDeclinesWhenHostClaimsAreOn(t *testing.T) { |
| + resetRollout(t) |
| + var buf bytes.Buffer |
| + log.SetOutput(&buf) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + // No cgroups either: the claims check must come first, so the code |
| + // says claims, not delegation. |
| + eb := &activator.ExecBackend{Cfg: activator.Config{HostWrites: hostwrite.Config{Enabled: true}}} |
| + if swapArming(overlapEnv(""), eb) { |
| + t.Fatal("armed a box with host claims on") |
| + } |
| + if got := rolloutUnarmed(); got != rolloutHostClaims { |
| + t.Fatalf("decline code %q, want %q", got, rolloutHostClaims) |
| + } |
| + if !strings.Contains(buf.String(), "REFUSING CARLOS_SWAP=overlap (host-claims-on)") { |
| + t.Fatalf("no operator line naming the claims refusal: %q", buf.String()) |
| + } |
| + r := rolloutStatusProbe() |
| + if r == nil || r.Unavailable != rolloutHostClaims { |
| + t.Fatalf("status %+v: want Unavailable %q", r, rolloutHostClaims) |
| + } |
| +} |
| + |
| +func TestSwapArmingIgnoresClaimsWithTheFlagOff(t *testing.T) { |
| + resetRollout(t) |
| + eb := &activator.ExecBackend{Cfg: activator.Config{HostWrites: hostwrite.Config{Enabled: true}}} |
| + if swapArming(noEnv, eb) || rolloutUnarmed() != "" { |
| + t.Fatal("flag off: a claims box must say nothing") |
| + } |
| +} |
| + |
| func TestRestartsFeedTheSchedulerWithTheirIdentity(t *testing.T) { |
| routes := []registry.Route{ |
| {Host: "a.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready", Hibernate: true}, |
diff --git a/internal/rolloutwords/words.go b/internal/rolloutwords/words.go| index 75c594990..6652d0901 100644 |
| --- a/internal/rolloutwords/words.go |
| +++ b/internal/rolloutwords/words.go |
| @@ -84,6 +84,7 @@ var unavailablePhrases = map[string]string{ |
| "no-cgroup-delegation": unavailableDeclined, |
| "no-generation-control": unavailableDeclined, |
| "generations-off": unavailableDeclined, |
| + "host-claims-on": unavailableDeclined, |
| "no-deploy-store": "its box cannot start instances: the deploy store is unavailable", |
| "no-release-key": "its box cannot start instances: the release key is unavailable", |
| "failure-book-unreadable": "its box cannot start instances: its record of failed builds cannot be read", |
| @@ -107,7 +108,7 @@ func Unavailable(code string) string { |
| // are safe to wake. |
| func UnavailableDeclines(code string) bool { |
| switch code { |
| - case "no-cgroup-delegation", "no-generation-control", "generations-off": |
| + case "no-cgroup-delegation", "no-generation-control", "generations-off", "host-claims-on": |
| return true |
| default: |
| return false |
diff --git a/internal/rolloutwords/words_test.go b/internal/rolloutwords/words_test.go| index 0c5a22f29..06e5aaf74 100644 |
| --- a/internal/rolloutwords/words_test.go |
| +++ b/internal/rolloutwords/words_test.go |
| @@ -83,7 +83,7 @@ func TestEveryCauseAndReasonHasAPhrase(t *testing.T) { |
| func TestEveryUnavailableCodeHasAPhrase(t *testing.T) { |
| // Declined codes |
| - for _, code := range []string{"no-cgroup-delegation", "no-generation-control", "generations-off"} { |
| + for _, code := range []string{"no-cgroup-delegation", "no-generation-control", "generations-off", "host-claims-on"} { |
| phrase := Unavailable(code) |
| if phrase == "" { |
| t.Errorf("Unavailable(%q) = empty string", code) |
| @@ -117,12 +117,12 @@ func TestEveryUnavailableCodeHasAPhrase(t *testing.T) { |
| } |
| } |
| -// UnavailableDeclines is true for exactly the three codes under which a box |
| +// UnavailableDeclines is true for exactly the four codes under which a box |
| // runs today's stop-then-start paths. A refusing code is not one, and nor is |
| // a code this binary does not know: a reader cannot assume a newer box's |
| // problem leaves its instances safe to wake. |
| func TestUnavailableDeclinesOnlyTheKnownDeclinedCodes(t *testing.T) { |
| - for _, code := range []string{"no-cgroup-delegation", "no-generation-control", "generations-off"} { |
| + for _, code := range []string{"no-cgroup-delegation", "no-generation-control", "generations-off", "host-claims-on"} { |
| if !UnavailableDeclines(code) { |
| t.Errorf("UnavailableDeclines(%q) = false, want true", code) |
| } |
| @@ -133,3 +133,9 @@ func TestUnavailableDeclinesOnlyTheKnownDeclinedCodes(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestHostClaimsDeclineReadsAsADecline(t *testing.T) { |
| + if Unavailable("host-claims-on") != unavailableDeclined || !UnavailableDeclines("host-claims-on") || UnavailableRefuses("host-claims-on") { |
| + t.Fatal("host-claims-on must read and act as a decline") |
| + } |
| +} |
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go| index 89255afb0..77aa2a6e0 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -259,13 +259,14 @@ type RolloutReport struct { |
| // Unavailable, when set, is a reason code (not member-facing text) for |
| // why CARLOS_SWAP=overlap is on but the scheduler could not arm. Either |
| // the box cannot contain generations ("no-cgroup-delegation", |
| - // "no-generation-control", "generations-off"), and stays on today's |
| + // "no-generation-control", "generations-off") or has host claims on |
| + // ("host-claims-on"), and stays on today's |
| // paths, or its required state is unavailable ("no-deploy-store", |
| // "no-release-key", "failure-book-unreadable"), and every managed start |
| // is refused meanwhile (Task 14, internal/host's swapArming and |
| // refuseManaged). The rest of the report is then empty. Plan 6 words it. |
| Unavailable string `json:"unavailable,omitempty"` |
| - // FailureBook is set only beside one of the first three codes: what a |
| + // FailureBook is set only beside a decline code (the first four): what a |
| // box that cannot contain generations does with the failure records an |
| // earlier armed run left (final review F6). "honoured": a managed wake, |
| // or a bounce, onto a build they hold for that instance is refused. |