carlos / platform Public

host: arming verifies a real shim placement; decline cgroup-placement-failed (#272)

swapArming, once claims and delegation pass, now asks placementCheck:
cgroup.Manager.VerifyPlacement places a probe through the shim into a
scratch child (carlos-placement-check) with the default tier, checks the
probe saw itself there and memory.max reads back, and removes the child
once the probe is reaped. A failure declines the flag with the new code
cgroup-placement-failed, which reads as the approved declined phrase and
counts as a decline in rolloutwords. honourDeclinedFailures keys on the
activator built without generations, so it covers the new code as is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7fbafe7eb36da4a2bcb208a64c6aaeb89a1782d3 parent 961aed7
8 files changed, +469 −20
  • internal/cgroup/verify_linux.go +108 −0
  • internal/cgroup/verify_linux_test.go +166 −0
  • internal/cgroup/verify_other.go +17 −0
  • internal/host/rollout.go +27 −2
  • internal/host/rollout_test.go +134 −11
  • internal/rolloutwords/words.go +2 −1
  • internal/rolloutwords/words_test.go +11 −3
  • internal/status/snapshot.go +4 −3
diff --git a/internal/cgroup/verify_linux.go b/internal/cgroup/verify_linux.go
new file mode 100644
index 000000000..0b1de37b7
--- /dev/null
+++ b/internal/cgroup/verify_linux.go
@@ -0,0 +1,108 @@
+//go:build linux
+
+package cgroup
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "golang.org/x/sys/unix"
+)
+
+// placementCheckName is the scratch child of the delegated root that
+// VerifyPlacement places its probe in. It is no carlos-exec@ name, so no
+// host's cleanup or the stale sweep ever takes it for a host's cgroup.
+const placementCheckName = "carlos-placement-check"
+
+// placementProbeArgs is the probe tenant's argv after shimExe. Only tests
+// change it, to stand in a probe that misreports or never exits.
+var placementProbeArgs = []string{ProbeVerb}
+
+// VerifyPlacement does one real placement, through the shim, into a scratch
+// child cgroup with the default tier's limits, and answers nil only if the
+// placed process saw itself in that cgroup and the limit reads back. It is
+// the arm-time question for CARLOS_SWAP=overlap (#272): the second dev-box
+// check found a box that armed and then failed every generation start,
+// because no placement was possible under carlos-edge.service's hardening.
+//
+// It calls startViaShim itself, never the placeStart seam, so a test's
+// FakePlacementForTest cannot make it pass. The scratch cgroup is removed
+// once the probe is reaped; a removal failure is logged, not returned.
+func (m *Manager) VerifyPlacement(ctx context.Context) error {
+ if m == nil {
+ return ErrNoDelegation
+ }
+ if !shimAvailable() {
+ return errors.New("cgroup: placement check: the placement shim is not available in this process")
+ }
+ dir := filepath.Join(m.root, placementCheckName)
+ // A leftover from an earlier run is empty: the unit's restart killed
+ // its members. One that will not go fails the Mkdir below.
+ _ = os.Remove(dir)
+ // Nothing is created under a root that is no cgroup: the limit files
+ // would be plain files there, and the directory could not be removed.
+ var st unix.Statfs_t
+ if err := unix.Statfs(m.root, &st); err != nil {
+ return fmt.Errorf("cgroup: placement check: statfs %s: %w", m.root, err)
+ }
+ if st.Type != unix.CGROUP2_SUPER_MAGIC {
+ return fmt.Errorf("cgroup: placement check: %s is not a cgroup2 directory", m.root)
+ }
+ if err := os.Mkdir(dir, 0o755); err != nil {
+ return fmt.Errorf("cgroup: placement check: %w", err)
+ }
+ // Every path below reaps any probe it started before it returns, so
+ // this runs on an empty cgroup.
+ defer func() {
+ if err := os.Remove(dir); err != nil {
+ m.logf("cgroup: placement check: %s not removed: %v", dir, err)
+ }
+ }()
+ if err := m.applyLimit("placement check", dir, TierDefault, true); err != nil {
+ return fmt.Errorf("cgroup: placement check: %w", err)
+ }
+
+ var out bytes.Buffer
+ cmd := exec.Command(shimExe, placementProbeArgs...)
+ cmd.Env = []string{}
+ cmd.Stdout = &out
+ if _, err := m.startViaShim(dir, cmd, nil); err != nil {
+ // A placement error or not, the check failed; startViaShim has
+ // reaped anything it started.
+ return fmt.Errorf("cgroup: placement check: %w", err)
+ }
+ waited := make(chan error, 1)
+ go func() { waited <- cmd.Wait() }()
+ var werr error
+ select {
+ case werr = <-waited:
+ case <-ctx.Done():
+ _ = cmd.Process.Kill()
+ <-waited // bounded: the probe is dead and holds no other process
+ return fmt.Errorf("cgroup: placement check: the probe did not exit: %w", ctx.Err())
+ }
+ if werr != nil {
+ return fmt.Errorf("cgroup: placement check: the probe failed: %w", werr)
+ }
+ // What the probe saw in /proc/self/cgroup: dir as startViaShim names it
+ // to the shim, below /sys/fs/cgroup.
+ expected := strings.TrimPrefix(dir, "/sys/fs/cgroup")
+ if got := out.String(); got != expected+"\n" {
+ return fmt.Errorf("cgroup: placement check: the probe reported cgroup %q, want %q", got, expected+"\n")
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, "memory.max"))
+ if err != nil {
+ return fmt.Errorf("cgroup: placement check: %w", err)
+ }
+ if got, want := strings.TrimSpace(string(raw)), strconv.FormatInt(TierDefault.MemoryMax, 10); got != want {
+ return fmt.Errorf("cgroup: placement check: memory.max reads %q, want %q", got, want)
+ }
+ return nil
+}
diff --git a/internal/cgroup/verify_linux_test.go b/internal/cgroup/verify_linux_test.go
new file mode 100644
index 000000000..dfdbedd98
--- /dev/null
+++ b/internal/cgroup/verify_linux_test.go
@@ -0,0 +1,166 @@
+//go:build linux
+
+package cgroup
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// The arm-time placement check (zdd readiness 3, Task 3). The ungated tests
+// run anywhere; the kernel ones need CARLOS_SHIM_KERNEL=1 inside a unit with
+// Delegate=yes, as shim_kernel_linux_test.go's do:
+//
+// systemd-run --user --wait --pipe --collect -p Delegate=yes \
+// -E TMPDIR=/tmp/k/zddr3 -E CARLOS_SHIM_KERNEL=1 -E CARLOS_GEN_KERNEL=1 \
+// cgroup.test -test.run 'VerifyPlacement' -test.v -test.count=1
+
+func assertNoPlacementCheckDir(t *testing.T, root string) {
+ t.Helper()
+ if _, err := os.Lstat(filepath.Join(root, placementCheckName)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("%s left behind under %s (%v)", placementCheckName, root, err)
+ }
+}
+
+func TestVerifyPlacementNilManager(t *testing.T) {
+ var m *Manager
+ if err := m.VerifyPlacement(context.Background()); !errors.Is(err, ErrNoDelegation) {
+ t.Fatalf("nil manager: %v, want ErrNoDelegation", err)
+ }
+}
+
+func TestVerifyPlacementWithoutTheShim(t *testing.T) {
+ prev := shimHooked.Load()
+ shimHooked.Store(false)
+ t.Cleanup(func() { shimHooked.Store(prev) })
+ root := t.TempDir()
+ m := &Manager{root: root, logf: t.Logf}
+ err := m.VerifyPlacement(context.Background())
+ if err == nil || !strings.Contains(err.Error(), "shim") {
+ t.Fatalf("no shim: %v, want an error naming the shim", err)
+ }
+ assertNoPlacementCheckDir(t, root)
+}
+
+// A manager over a directory that is no cgroup answers an error and leaves
+// nothing behind, a leftover from an earlier run included.
+func TestVerifyPlacementOverATempRootFails(t *testing.T) {
+ root := t.TempDir()
+ if err := os.Mkdir(filepath.Join(root, placementCheckName), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ m := &Manager{root: root, logf: t.Logf}
+ err := m.VerifyPlacement(context.Background())
+ if err == nil {
+ t.Fatal("a temp directory passed the placement check")
+ }
+ t.Logf("temp root: %v", err)
+ assertNoPlacementCheckDir(t, root)
+ if m.placementsUnder(root) != 0 {
+ t.Fatal("a placement is still registered")
+ }
+}
+
+// TestVerifyPlacementKernelChild is the probe that never exits: it sleeps
+// when it finds itself in the placement check's cgroup, and otherwise (a
+// normal test run) returns at once.
+func TestVerifyPlacementKernelChild(t *testing.T) {
+ raw, err := os.ReadFile("/proc/self/cgroup")
+ if err != nil {
+ return
+ }
+ rel, ok := ParseSelfCgroup(string(raw))
+ if !ok || filepath.Base(rel) != placementCheckName {
+ return
+ }
+ time.Sleep(time.Minute)
+}
+
+func TestVerifyPlacementKernel(t *testing.T) {
+ m := shimKernelManager(t)
+ if err := m.VerifyPlacement(context.Background()); err != nil {
+ t.Fatalf("VerifyPlacement: %v", err)
+ }
+ assertNoPlacementCheckDir(t, m.root)
+ if m.placementsUnder(m.root) != 0 {
+ t.Fatal("a placement is still registered")
+ }
+}
+
+// A leftover from an earlier run (empty: the unit's restart killed its
+// members) is replaced, and the check still passes.
+func TestVerifyPlacementKernelReplacesALeftover(t *testing.T) {
+ m := shimKernelManager(t)
+ if err := os.Mkdir(filepath.Join(m.root, placementCheckName), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := m.VerifyPlacement(context.Background()); err != nil {
+ t.Fatalf("VerifyPlacement over a leftover: %v", err)
+ }
+ assertNoPlacementCheckDir(t, m.root)
+}
+
+// A shim that cannot place (here, one that is not the carlos binary at all)
+// fails the check, and the scratch cgroup is removed.
+func TestVerifyPlacementKernelBrokenShim(t *testing.T) {
+ m := shimKernelManager(t)
+ prev := shimExe
+ shimExe = "/bin/true"
+ t.Cleanup(func() { shimExe = prev })
+ err := m.VerifyPlacement(context.Background())
+ if err == nil {
+ t.Fatal("a shim that cannot place passed the check")
+ }
+ t.Logf("broken shim: %v", err)
+ assertNoPlacementCheckDir(t, m.root)
+}
+
+// A probe whose output is not its cgroup, or that exits non-zero, fails
+// the check: placement is proven only by what the placed process saw.
+func TestVerifyPlacementKernelChecksTheProbe(t *testing.T) {
+ m := shimKernelManager(t)
+ for name, args := range map[string][]string{
+ // The test binary with no tests: exits 0, prints "PASS" and a warning.
+ "wrong output": {"-test.run=^$"},
+ // An unknown flag: exits 2.
+ "non-zero exit": {"-test.run=^$", "-test.no-such-flag"},
+ } {
+ t.Run(name, func(t *testing.T) {
+ prev := placementProbeArgs
+ placementProbeArgs = args
+ t.Cleanup(func() { placementProbeArgs = prev })
+ err := m.VerifyPlacement(context.Background())
+ if err == nil {
+ t.Fatal("passed with a probe that did not report its cgroup")
+ }
+ t.Logf("%s: %v", name, err)
+ assertNoPlacementCheckDir(t, m.root)
+ })
+ }
+}
+
+// A probe that does not exit is killed when ctx ends, and reaped before the
+// scratch cgroup is removed: the removal succeeds.
+func TestVerifyPlacementKernelKillsAProbeThatHangs(t *testing.T) {
+ m := shimKernelManager(t)
+ prev := placementProbeArgs
+ placementProbeArgs = []string{"-test.run=^TestVerifyPlacementKernelChild$", "-test.count=1"}
+ t.Cleanup(func() { placementProbeArgs = prev })
+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
+ defer cancel()
+ start := time.Now()
+ err := m.VerifyPlacement(ctx)
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("hung probe: %v, want the context's deadline", err)
+ }
+ if d := time.Since(start); d > 10*time.Second {
+ t.Fatalf("took %v to give up on a hung probe", d)
+ }
+ t.Logf("hung probe: %v", err)
+ assertNoPlacementCheckDir(t, m.root)
+}
diff --git a/internal/cgroup/verify_other.go b/internal/cgroup/verify_other.go
new file mode 100644
index 000000000..d0c6f398f
--- /dev/null
+++ b/internal/cgroup/verify_other.go
@@ -0,0 +1,17 @@
+//go:build !linux
+
+package cgroup
+
+import (
+ "context"
+ "errors"
+)
+
+// VerifyPlacement needs cgroup v2 and the placement shim, so it is linux
+// only: elsewhere every check fails.
+func (m *Manager) VerifyPlacement(ctx context.Context) error {
+ if m == nil {
+ return ErrNoDelegation
+ }
+ return errors.New("cgroup: placement check: placement before exec needs linux")
+}
diff --git a/internal/host/rollout.go b/internal/host/rollout.go
index d57f6024b..d09feb71a 100644
--- a/internal/host/rollout.go
+++ b/internal/host/rollout.go
@@ -13,6 +13,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/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/rollout"
@@ -33,6 +34,11 @@ const (
// 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 the box has delegation, but a test placement into
+ // a scratch cgroup through the shim failed (placementCheck): generation
+ // starts could not be placed inside their limits (#272, the second
+ // dev-box check), so the box declines and stays on today's paths.
+ rolloutPlacementFailed = "cgroup-placement-failed"
// The flag is on and generations are on, but the scheduler's required
// state is unavailable, so every managed start is refused
// (refuseManaged).
@@ -87,7 +93,8 @@ func nudgeStatus() {
var rolloutDown atomic.Pointer[string]
// rolloutDeclined is why CARLOS_SWAP=overlap is set but the box stayed on
-// today's paths: it cannot contain generations (swapArming). Unlike
+// today's paths: it cannot contain generations, has host claims on, or
+// failed its arm-time test placement (swapArming). Unlike
// rolloutDown nothing is refused and no leg changes, except where failure
// records an earlier armed run left hold a build for an instance
// (honourDeclinedFailures, final review F6). The reason is published in the
@@ -138,6 +145,16 @@ func swapDelegation(eb *activator.ExecBackend) string {
return ""
}
+// placementCheck is swapArming's arm-time placement check: one real shim
+// placement into a scratch child of the delegated subtree
+// (cgroup.Manager.VerifyPlacement), bounded at 15 s. A seam host tests
+// replace, since a temp-dir root can never pass it.
+var placementCheck = func(m *cgroup.Manager) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ return m.VerifyPlacement(ctx)
+}
+
// 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
@@ -146,7 +163,10 @@ func swapDelegation(eb *activator.ExecBackend) string {
// 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).
+// published as a reason code in the box's status (rolloutStatusProbe). With
+// containment present it asks for one real placement last (placementCheck),
+// and declines the same way when it fails: an armed box whose generation
+// starts can never be placed fails every one of them (#272).
func swapArming(getenv func(string) string, eb *activator.ExecBackend) bool {
if !rollout.Enabled(getenv) {
return false
@@ -164,6 +184,11 @@ func swapArming(getenv func(string) string, eb *activator.ExecBackend) bool {
declineRollout(why)
return false
}
+ if err := placementCheck(eb.Cgroups); err != nil {
+ log.Printf("agent: REFUSING %s=overlap (%s): a test placement into a scratch cgroup failed (%v), so generations could not be started inside their limits; this box stays on today's stop-then-wake paths", rollout.SwapEnv, rolloutPlacementFailed, err)
+ declineRollout(rolloutPlacementFailed)
+ return false
+ }
return true
}
diff --git a/internal/host/rollout_test.go b/internal/host/rollout_test.go
index c80d80c08..56698fb39 100644
--- a/internal/host/rollout_test.go
+++ b/internal/host/rollout_test.go
@@ -74,6 +74,49 @@ func overlapEnv(pub string) func(string) string {
func noEnv(string) string { return "" }
+// stubPlacementCheck replaces swapArming's arm-time placement check with
+// one that answers err, and counts its calls. A temp-dir cgroup root can
+// never pass the real check (cgroup.Manager.VerifyPlacement), so every test
+// that expects swapArming to arm uses this with a nil err.
+func stubPlacementCheck(t *testing.T, err error) *int {
+ t.Helper()
+ orig := placementCheck
+ calls := new(int)
+ placementCheck = func(*cgroup.Manager) error { *calls++; return err }
+ t.Cleanup(func() { placementCheck = orig })
+ return calls
+}
+
+// forbidPlacementCheck fails the test if swapArming runs the placement
+// check: with the flag off, or after an earlier check declined, it must not.
+func forbidPlacementCheck(t *testing.T) {
+ t.Helper()
+ orig := placementCheck
+ placementCheck = func(*cgroup.Manager) error {
+ t.Error("swapArming ran the placement check")
+ return nil
+ }
+ t.Cleanup(func() { placementCheck = orig })
+}
+
+// armableBackend is an exec backend that passes every check swapArming makes
+// before the placement check: claims off, a delegated subtree (over a temp
+// root), and a control channel that speaks the generation verbs.
+func armableBackend(t *testing.T) *activator.ExecBackend {
+ t.Helper()
+ root := t.TempDir()
+ for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} {
+ if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ cgm, err := cgroup.SetupAt(root, t.Logf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &activator.ExecBackend{Cgroups: cgm, Control: instancectl.NewClient(filepath.Join(t.TempDir(), "ctl.sock"))}
+}
+
func unusedStore(context.Context) (release.Store, error) { return nil, errors.New("unused") }
func funcPtr(f any) uintptr { return reflect.ValueOf(f).Pointer() }
@@ -121,6 +164,7 @@ func TestTheFlagOffArmsNothing(t *testing.T) {
if adoptBounceFn(svc) == nil {
t.Fatal("the adopt bounce must stay BounceAdopted")
}
+ forbidPlacementCheck(t)
if swapArming(noEnv, nil) || activator.GenerationsEnabled() {
t.Fatal("CARLOS_SWAP unset turned generations on")
}
@@ -472,6 +516,7 @@ func TestNoDelegationDeclinesTheFlag(t *testing.T) {
t.Fatal(err)
}
resetRollout(t)
+ forbidPlacementCheck(t)
eb := &activator.ExecBackend{Control: instancectl.NewClient(filepath.Join(t.TempDir(), "ctl.sock"))}
if swapArming(overlapEnv(pub), eb) {
t.Fatal("armed generations with no delegated cgroup subtree")
@@ -512,27 +557,103 @@ func TestDelegationArmsTheFlag(t *testing.T) {
t.Fatal(err)
}
resetRollout(t)
- root := t.TempDir()
- for _, f := range []string{"cgroup.procs", "cgroup.subtree_control"} {
- if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil {
- t.Fatal(err)
- }
- }
- cgm, err := cgroup.SetupAt(root, t.Logf)
- if err != nil {
- t.Fatal(err)
- }
- eb := &activator.ExecBackend{Cgroups: cgm, Control: instancectl.NewClient(filepath.Join(t.TempDir(), "ctl.sock"))}
+ calls := stubPlacementCheck(t, nil)
+ eb := armableBackend(t)
if !swapArming(overlapEnv(pub), eb) || rolloutUnarmed() != "" {
t.Fatalf("delegation present: declined with %q", rolloutUnarmed())
}
+ if *calls != 1 {
+ t.Fatalf("the armed flag ran the placement check %d times, want once", *calls)
+ }
if swapArming(noEnv, eb) {
t.Fatal("the flag off armed generations")
}
+ if *calls != 1 {
+ t.Fatal("the flag off ran the placement check")
+ }
eb.Control = nil
if swapArming(overlapEnv(pub), eb) || rolloutUnarmed() != rolloutNoGenControl {
t.Fatalf("no generation control: armed, or declined with %q", rolloutUnarmed())
}
+ if *calls != 1 {
+ t.Fatal("a box that declined on its generation control still ran the placement check")
+ }
+}
+
+// The second dev-box check (#272): a box armed CARLOS_SWAP=overlap and then
+// failed every generation start, because no process could be placed in its
+// cgroup under carlos-edge.service's hardening. Arming now does one real
+// placement into a scratch cgroup and declines when it fails, with a code
+// of its own, a loud operator line, and the code in the box's status.
+func TestSwapArmingDeclinesWhenPlacementFails(t *testing.T) {
+ _, pub, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ resetRollout(t)
+ var buf bytes.Buffer
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+ eb := armableBackend(t)
+ var got *cgroup.Manager
+ orig := placementCheck
+ placementCheck = func(m *cgroup.Manager) error { got = m; return errors.New("probe exited before exec") }
+ t.Cleanup(func() { placementCheck = orig })
+ if swapArming(overlapEnv(pub), eb) {
+ t.Fatal("armed with a failed placement check")
+ }
+ if got != eb.Cgroups {
+ t.Fatal("the placement check was not run against the backend's cgroup manager")
+ }
+ if rolloutUnarmed() != rolloutPlacementFailed {
+ t.Fatalf("declined as %q", rolloutUnarmed())
+ }
+ if rolloutUnavailable() != "" {
+ t.Fatal("a placement decline refuses managed starts")
+ }
+ if s := buf.String(); !strings.Contains(s, "REFUSING CARLOS_SWAP=overlap (cgroup-placement-failed)") ||
+ !strings.Contains(s, "probe exited before exec") {
+ t.Fatalf("no operator line naming the placement refusal and its error: %q", s)
+ }
+ if r := rolloutStatusProbe(); r == nil || r.Unavailable != rolloutPlacementFailed {
+ t.Fatalf("status %+v: want Unavailable %q", r, rolloutPlacementFailed)
+ }
+}
+
+// A box that declined for a failed placement honours the failure records an
+// earlier armed run left, exactly as the other declined codes do
+// (honourDeclinedFailures keys on the activator built without generations,
+// not on the code), and publishes its own code beside the honoured book.
+func TestPlacementDeclineHonoursFailureRecords(t *testing.T) {
+ t.Setenv("CARLOS_OPT_DIR", t.TempDir())
+ svc, reg, dd := rolloutFixture(t, false)
+ acct := release.RouteAccount(managedRoute("x", ""))
+ writeFailureBook(t, dd, []rollout.FailureRecord{{
+ Account: acct, App: "blog", Build: "v2", Host: "sib.example.com", Cause: "adopt", Mode: "hold", Reason: "health",
+ }}, nil)
+ _, pub, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ stubPlacementCheck(t, errors.New("the shim exited before placement"))
+ if swapArming(overlapEnv(pub), armableBackend(t)) {
+ t.Fatal("armed with a failed placement check")
+ }
+ m := armRollout(context.Background(), overlapEnv(pub), svc, reg, dd, nil, unusedStore)
+ if m != nil || armedRollout() != nil {
+ t.Fatal("a declined box armed the scheduler")
+ }
+ if heldHolds() == nil || svc.Planner == nil {
+ t.Fatal("a placement decline did not honour the failure records")
+ }
+ blocked := managedRoute("a.example.com", "v2")
+ blocked.GoodVersion, blocked.GoodDigest = "v1", "sha256:v1"
+ if p := svc.Planner(context.Background(), blocked); p.Refuse != refuseNoSwaps {
+ t.Fatalf("a wake onto a held build planned %+v, want refused", p)
+ }
+ if r := rolloutStatusProbe(); r == nil || r.Unavailable != rolloutPlacementFailed || r.FailureBook != rolloutBookHonoured {
+ t.Fatalf("status = %+v, want the decline code %q and the book %q", r, rolloutPlacementFailed, rolloutBookHonoured)
+ }
}
// A box with host claims on declines the swap flag (#272): claims-backed
@@ -541,6 +662,7 @@ func TestDelegationArmsTheFlag(t *testing.T) {
// so a box that has neither says claims.
func TestSwapArmingDeclinesWhenHostClaimsAreOn(t *testing.T) {
resetRollout(t)
+ forbidPlacementCheck(t)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
@@ -564,6 +686,7 @@ func TestSwapArmingDeclinesWhenHostClaimsAreOn(t *testing.T) {
func TestSwapArmingIgnoresClaimsWithTheFlagOff(t *testing.T) {
resetRollout(t)
+ forbidPlacementCheck(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")
diff --git a/internal/rolloutwords/words.go b/internal/rolloutwords/words.go
index 6652d0901..0e0edb74a 100644
--- a/internal/rolloutwords/words.go
+++ b/internal/rolloutwords/words.go
@@ -85,6 +85,7 @@ var unavailablePhrases = map[string]string{
"no-generation-control": unavailableDeclined,
"generations-off": unavailableDeclined,
"host-claims-on": unavailableDeclined,
+ "cgroup-placement-failed": 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",
@@ -108,7 +109,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", "host-claims-on":
+ case "no-cgroup-delegation", "no-generation-control", "generations-off", "host-claims-on", "cgroup-placement-failed":
return true
default:
return false
diff --git a/internal/rolloutwords/words_test.go b/internal/rolloutwords/words_test.go
index 06e5aaf74..d0e62ba62 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", "host-claims-on"} {
+ for _, code := range []string{"no-cgroup-delegation", "no-generation-control", "generations-off", "host-claims-on", "cgroup-placement-failed"} {
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 four codes under which a box
+// UnavailableDeclines is true for exactly the five 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", "host-claims-on"} {
+ for _, code := range []string{"no-cgroup-delegation", "no-generation-control", "generations-off", "host-claims-on", "cgroup-placement-failed"} {
if !UnavailableDeclines(code) {
t.Errorf("UnavailableDeclines(%q) = false, want true", code)
}
@@ -139,3 +139,11 @@ func TestHostClaimsDeclineReadsAsADecline(t *testing.T) {
t.Fatal("host-claims-on must read and act as a decline")
}
}
+
+// A box whose arm-time test placement failed (#272) declines like the
+// others, in the same approved words: it adds no member-facing text.
+func TestPlacementFailedDeclineReadsAsADecline(t *testing.T) {
+ if Unavailable("cgroup-placement-failed") != unavailableDeclined || !UnavailableDeclines("cgroup-placement-failed") || UnavailableRefuses("cgroup-placement-failed") {
+ t.Fatal("cgroup-placement-failed must read and act as a decline")
+ }
+}
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go
index b031f5bf0..4dd9ef44a 100644
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
@@ -299,14 +299,15 @@ 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") or has host claims on
- // ("host-claims-on"), and stays on today's
+ // "no-generation-control", "generations-off"), has host claims on
+ // ("host-claims-on"), or failed its arm-time test placement
+ // ("cgroup-placement-failed"), 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 a decline code (the first four): what a
+ // FailureBook is set only beside a decline code (the first five): 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.