carlos / platform Public

zdd plan 2 task 10: exec backend places spawns in generation children; ParentLimit, KillGen

Add ExecBackend.GenContainment (false everywhere outside tests), the
GenControl interface, placeFor, spawnInCgroupAt, genControl, unitGen,
ParentLimit and KillGen, and SplitBackend.KillGen. With containment on,
an instance spawn lands in g<n>, the legacy sidecar in role-sidecar and
the seed in role-seed; startChildAt takes the generation number. With it
off every spawn site makes the same calls as before. A placed spawn
honours the strict host gate and refuses a strict host rather than
starting outside its group.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a813586c831f60eaf66b214d48ed4185af569afa parent cc299b5
5 files changed, +510 −7
  • internal/activator/backend_exec.go +31 −7
  • internal/activator/backend_unit.go +18 −0
  • internal/activator/containment.go +183 −0
  • internal/activator/containment_test.go +263 −0
  • internal/activator/control.go +15 −0
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 7e9745ee4..7e8ad7896 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -112,6 +112,13 @@ type ExecBackend struct {
// tier. Instance, seed and sidecar share the cgroup, so they share the
// tier — a seed's first-boot cost is that instance's work.
Tier func(registry.Route) cgroup.Limit
+ // GenContainment selects the generation layout (zero-downtime spec
+ // §7.3, §7.6): each instance spawn gets its own child cgroup g<n> (or
+ // the carlos-instance-gen@<id>:<n> unit for a nested host) under a
+ // parent that holds no processes. False, the zero value, is today's
+ // layout byte for byte: one cgroup per host and carlos-instance@. Set
+ // only by plan 5's CARLOS_SWAP wiring.
+ GenContainment bool
// Logs, when non-nil, gets each instance and seed child's stdout/stderr
// teed in line-wise for `carlos logs` (spec §5) — journald keeps every
// byte it got before. Nil (tests, a box without the shipping loop)
@@ -799,7 +806,11 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error {
// cost is that instance's work. No CleanupHost here — the instance spawn
// reuses the dir moments later, and its exit path (or the next agent
// start's sweep) removes it.
- cmd, settle, err := b.spawnInCgroup(rt, func() *exec.Cmd {
+ child, _, perr := b.placeFor(spawnSeed, 0)
+ if perr != nil {
+ return perr
+ }
+ cmd, settle, err := b.spawnInCgroupAt(rt, child, false, func() *exec.Cmd {
cmd := exec.CommandContext(ctx, b.liveLink(host), argv...)
// The control-channel vars are agent-owned in BOTH directions here too
// (Start's discipline, see withoutVars), and here they are always
@@ -836,6 +847,7 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error {
if err == nil {
err = cmd.Wait()
settle()
+ b.Cgroups.RemoveChild(host, child) // the seed's own child only; no-op for "" (flag off)
}
if err != nil {
// WaitDelay reports on the SUCCESS path too: a seed that exits 0 after
@@ -1143,7 +1155,7 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin
case LayoutFenced, LayoutConflict:
return nil, "", fmt.Errorf("%s: refusing to wake: %s", rt.Host, p.reason)
}
- return b.startChildAt(ctx, rt, rt.Socket)
+ return b.startChildAt(ctx, rt, rt.Socket, 0)
}
// StartGen is Start for generation n (zero-downtime spec §7.1). perGen false
@@ -1193,7 +1205,7 @@ func (b *ExecBackend) StartGen(ctx context.Context, rt registry.Route, n uint64,
return nil, Gen{}, fmt.Errorf("%s: generation socket: %w", rt.Host, err)
}
}
- proc, ver, err = b.startChildAt(ctx, rt, sock)
+ proc, ver, err = b.startChildAt(ctx, rt, sock, n)
}
if err != nil {
return proc, Gen{}, err
@@ -1225,11 +1237,17 @@ func clearOwnGenSocket(sock string) error {
// startChildAt is Start's exec-child body (flat and foreign layouts):
// exec of the versioned binary directly, exactly as before nested layouts
// existed. sock is the app socket this spawn binds: rt.Socket, or a
-// generation socket (StartGen).
-func (b *ExecBackend) startChildAt(ctx context.Context, rt registry.Route, sock string) (Proc, string, error) {
+// generation socket (StartGen). n is the generation this spawn is (StartGen's
+// n; 0 from Start). It only places the spawn's cgroup, and only under
+// GenContainment (placeFor).
+func (b *ExecBackend) startChildAt(ctx context.Context, rt registry.Route, sock string, n uint64) (Proc, string, error) {
host := rt.Host
paths := b.pathsFor(rt)
dbPath := paths.dbPath
+ child, ownTier, err := b.placeFor(spawnInstance, n)
+ if err != nil {
+ return nil, "", err
+ }
// Exec exactly what a systemd unit would: the per-instance live symlink
// (<OptDir>/live/<host> → a staged version), same --socket/--db flags,
@@ -1342,7 +1360,7 @@ func (b *ExecBackend) startChildAt(ctx context.Context, rt registry.Route, sock
// carlos-exec@<host> and the sampler can account it. build may run
// twice — the manager's clone3-refusal fallback — so everything about
// the Cmd is configured inside it.
- cmd, settle, err := b.spawnInCgroup(rt, func() *exec.Cmd {
+ cmd, settle, err := b.spawnInCgroupAt(rt, child, ownTier, func() *exec.Cmd {
cmd := exec.Command(bin, "--socket", sock, "--db", dbPath)
cmd.Env = childEnv(childVars)
// The child logs through the agent's own stderr/stdout (journald
@@ -1387,6 +1405,7 @@ func (b *ExecBackend) startChildAt(ctx context.Context, rt registry.Route, sock
// A strict child's group is drained and closed before anything
// reads this exit as a stop (p.done closes below).
settle()
+ b.Cgroups.RemoveChild(host, child) // no-op for "" (flag off)
// Last exiter wins: rmdir refuses while the sidecar (same host
// cgroup) is still alive, and that failure is tolerated.
b.cleanupHost(host)
@@ -1794,10 +1813,14 @@ func (b *ExecBackend) startSidecarChild(_ context.Context, rt registry.Route) (P
if err != nil {
return nil, fmt.Errorf("read sidecar env: %w", err)
}
+ child, _, err := b.placeFor(spawnSidecar, 0)
+ if err != nil {
+ return nil, err
+ }
// Same host cgroup as the instance: a sidecar's load is that instance's
// work, and attributing it elsewhere would be a lie in the other
// direction.
- cmd, settle, err := b.spawnInCgroup(rt, func() *exec.Cmd {
+ cmd, settle, err := b.spawnInCgroupAt(rt, child, false, func() *exec.Cmd {
cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run")
cmd.Env = childEnv(fileEnv)
// Like the instance child: logs through the agent's own streams
@@ -1813,6 +1836,7 @@ func (b *ExecBackend) startSidecarChild(_ context.Context, rt registry.Route) (P
go func() {
p.err = cmd.Wait()
settle()
+ b.Cgroups.RemoveChild(rt.Host, child) // no-op for "" (flag off)
b.cleanupHost(rt.Host)
close(p.done)
}()
diff --git a/internal/activator/backend_unit.go b/internal/activator/backend_unit.go
index 5bba2b772..96495f493 100644
--- a/internal/activator/backend_unit.go
+++ b/internal/activator/backend_unit.go
@@ -345,6 +345,24 @@ func (b *SplitBackend) NextDueVia(ctx context.Context, rt registry.Route, socket
return b.pick(rt).NextDue(ctx, rt)
}
+// KillGen dispatches like Start, for an exec row only (zero-downtime spec
+// §8.2 step 1). A unit-backed row has no generation child to kill: systemd
+// owns the unit tenant's one process tree. An exec half without KillGen (a
+// test double) cannot prove a kill either. Both refuse, wrapping
+// errNoGenContainment, so a caller never believes a kill it could not make.
+func (b *SplitBackend) KillGen(ctx context.Context, rt registry.Route, n uint64) error {
+ if rt.Backing == registry.BackingUnit {
+ return fmt.Errorf("%s: a unit-backed row has no generation %d to kill: %w", rt.Host, n, errNoGenContainment)
+ }
+ k, ok := b.Exec.(interface {
+ KillGen(ctx context.Context, rt registry.Route, n uint64) error
+ })
+ if !ok {
+ return fmt.Errorf("%s: this backend cannot kill generation %d: %w", rt.Host, n, errNoGenContainment)
+ }
+ return k.KillGen(ctx, rt, n)
+}
+
// Layout implements Classifier for the composed backend: a unit-backed row
// is always today's flat unit shape (the exec-only layouts do not apply to
// it), and an exec row defers to the exec side's own classification.
diff --git a/internal/activator/containment.go b/internal/activator/containment.go
new file mode 100644
index 000000000..3669bb8df
--- /dev/null
+++ b/internal/activator/containment.go
@@ -0,0 +1,183 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os/exec"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+var (
+ errNoGenContainment = errors.New("activator: generation containment is off")
+ errNoGenControl = errors.New("activator: the instance control channel cannot run generation units")
+)
+
+// spawnKind is which child shape a spawn is, for placeFor.
+type spawnKind int
+
+const (
+ spawnInstance spawnKind = iota
+ spawnSidecar // the legacy co-spawned sidecar (<host>-sidecar.env)
+ spawnSeed
+ spawnWorker // workers and native sidecar members (StartWorker)
+)
+
+// placeFor names the child cgroup a spawn lands in under the generation
+// layout (zero-downtime spec §7.3, plan 2 D4): g<n> with the tier's full
+// limits for an instance generation; role-sidecar and role-seed, with no
+// limit of their own, for the legacy co-spawned sidecar and the seed, which
+// share the instance's host cgroup and so cannot sit in a parent that holds
+// no processes. Workers keep one cgroup per host because they never overlap
+// (§7.9). "" (today's single-level cgroup) for every kind while
+// GenContainment is off.
+func (b *ExecBackend) placeFor(kind spawnKind, n uint64) (child string, ownTier bool, err error) {
+ if !b.GenContainment {
+ return "", false, nil
+ }
+ switch kind {
+ case spawnInstance:
+ if n == 0 {
+ return "", false, errors.New("activator: generation 0 is not a generation")
+ }
+ return cgroup.GenChild(n), true, nil
+ case spawnSidecar:
+ return cgroup.RoleChild("sidecar"), false, nil
+ case spawnSeed:
+ return cgroup.RoleChild("seed"), false, nil
+ }
+ return "", false, nil
+}
+
+// spawnInCgroupAt is spawnInCgroup for a placed child. With no child it IS
+// spawnInCgroup, so a flag-off spawn is byte for byte today's.
+//
+// A placed spawn is never strict: strict containment of a generation layout
+// is the claim path's (PrepareStrictParent, plan 5). It still honours the
+// host gate, though, so a placed spawn can never start beside a strict
+// owner's group, and a host that is strict is refused rather than started
+// outside its strict group. The returned settle is spawnInCgroup's: a no-op
+// here. A spawn that fails removes the child it may have created.
+func (b *ExecBackend) spawnInCgroupAt(rt registry.Route, child string, ownTier bool, build func() *exec.Cmd) (*exec.Cmd, func(), error) {
+ if child == "" {
+ return b.spawnInCgroup(rt, build)
+ }
+ start := func() (*exec.Cmd, func(), error) {
+ tier := b.tier(rt)
+ own := cgroup.Limit{}
+ if ownTier {
+ own = tier
+ }
+ cmd, err := b.Cgroups.StartChild(rt.Host, child, tier, own, build)
+ if err != nil {
+ b.Cgroups.RemoveChild(rt.Host, child)
+ return nil, nil, err
+ }
+ return cmd, func() {}, nil
+ }
+ if b.Strict == nil {
+ return start()
+ }
+ g := b.gate(rt.Host)
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if b.Strict(rt.Host) {
+ return nil, nil, fmt.Errorf("%s: %w: a generation layout is not started strict here", rt.Host, ErrStrictRequired)
+ }
+ if o := g.owner; o != nil {
+ cause := ErrStrictGroupInUse
+ if o.pending {
+ cause = ErrStrictCleanupPending
+ }
+ return nil, nil, fmt.Errorf("%s: %w", rt.Host, cause)
+ }
+ return start()
+}
+
+// genControl is Control's generation half, or errNoGenControl.
+func (b *ExecBackend) genControl() (GenControl, error) {
+ if b.Control == nil {
+ return nil, errNoGenControl
+ }
+ gc, ok := b.Control.(GenControl)
+ if !ok {
+ return nil, errNoGenControl
+ }
+ return gc, nil
+}
+
+// unitGen is the generation a nested run unit is started as: 0 (the
+// original template) while GenContainment is off, n otherwise. n of 0
+// with containment on is refused.
+func (b *ExecBackend) unitGen(n uint64) (uint64, error) {
+ if !b.GenContainment {
+ return 0, nil
+ }
+ if n == 0 {
+ return 0, errors.New("activator: generation 0 is not a generation")
+ }
+ return n, nil
+}
+
+// ParentLimit sets rt's generation parent to l: carlos-exec@<host> for a
+// flat or foreign host, the host slice for a nested one (spec §7.3). The
+// swap raises it to cgroup.OverlapLimit before the successor spawns and
+// restores the tier once the retiring generation's child is empty. With
+// GenContainment off there is no parent, and it refuses, so a caller can
+// never believe it raised a limit that does not exist.
+func (b *ExecBackend) ParentLimit(ctx context.Context, rt registry.Route, l cgroup.Limit) error {
+ if !b.GenContainment {
+ return errNoGenContainment
+ }
+ switch p := b.pathsFor(rt); p.layout {
+ case LayoutNested:
+ gc, err := b.genControl()
+ if err != nil {
+ return err
+ }
+ return gc.Parent(ctx, rt.Host, l.MemoryMax)
+ case LayoutFenced, LayoutConflict:
+ return fmt.Errorf("%s: refusing to set a parent limit: %s", rt.Host, p.reason)
+ }
+ return b.Cgroups.SetParentLimit(rt.Host, l)
+}
+
+// KillGen kills generation n outright and returns once its child is empty
+// (spec §7.3, §8.2 step 1). It acts on that generation's child only.
+func (b *ExecBackend) KillGen(ctx context.Context, rt registry.Route, n uint64) error {
+ if !b.GenContainment || n == 0 {
+ return errNoGenContainment
+ }
+ switch p := b.pathsFor(rt); p.layout {
+ case LayoutNested:
+ gc, err := b.genControl()
+ if err != nil {
+ return err
+ }
+ return gc.StopGen(ctx, rt.Host, n, true)
+ case LayoutFenced, LayoutConflict:
+ return fmt.Errorf("%s: refusing to kill generation %d: %s", rt.Host, n, p.reason)
+ }
+ if b.Cgroups == nil {
+ return cgroup.ErrNoDelegation
+ }
+ child := cgroup.GenChild(n)
+ c, err := b.Cgroups.OpenCleanupChild(rt.Host, child)
+ if errors.Is(err, cgroup.ErrNoCgroup) {
+ return nil // already removed, so already empty
+ }
+ if err != nil {
+ return err
+ }
+ if err := c.KillAndEmpty(ctx); err != nil {
+ _ = c.Close()
+ return err
+ }
+ if err := c.Close(); err != nil {
+ return err
+ }
+ b.Cgroups.RemoveChild(rt.Host, child)
+ return nil
+}
diff --git a/internal/activator/containment_test.go b/internal/activator/containment_test.go
new file mode 100644
index 000000000..37c15e8a4
--- /dev/null
+++ b/internal/activator/containment_test.go
@@ -0,0 +1,263 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// fakeGenControl adds the generation verbs to fakeControl (unitproc_test.go).
+type fakeGenControl struct{ *fakeControl }
+
+func (f fakeGenControl) StartGen(_ context.Context, host string, n uint64, max int64) (string, error) {
+ f.rec(fmt.Sprintf("startgen %s %d %d", host, n, max))
+ if f.onStart != nil {
+ f.onStart()
+ }
+ return f.inv, f.startErr
+}
+func (f fakeGenControl) StopGen(_ context.Context, host string, n uint64, kill bool) error {
+ f.rec(fmt.Sprintf("stopgen %s %d kill=%v", host, n, kill))
+ if f.stopHook != nil {
+ f.stopHook(host, instancectl.RoleRun)
+ }
+ return nil
+}
+func (f fakeGenControl) ResetGen(_ context.Context, host string, n uint64) error {
+ f.rec(fmt.Sprintf("resetgen %s %d", host, n))
+ return nil
+}
+func (f fakeGenControl) Parent(_ context.Context, host string, max int64) error {
+ f.rec(fmt.Sprintf("parent %s %d", host, max))
+ return nil
+}
+
+func TestPlaceForIsSingleLevelWithContainmentOff(t *testing.T) {
+ b := testExecBackend(t)
+ for _, kind := range []spawnKind{spawnInstance, spawnSidecar, spawnSeed, spawnWorker} {
+ child, own, err := b.placeFor(kind, 7)
+ if child != "" || own || err != nil {
+ t.Errorf("kind %d with containment off: %q %v %v", kind, child, own, err)
+ }
+ }
+}
+
+func TestPlaceForUnderContainment(t *testing.T) {
+ b := testExecBackend(t)
+ b.GenContainment = true
+ for _, tc := range []struct {
+ kind spawnKind
+ child string
+ own bool
+ }{
+ {spawnInstance, "g7", true},
+ {spawnSidecar, "role-sidecar", false},
+ {spawnSeed, "role-seed", false},
+ {spawnWorker, "", false},
+ } {
+ child, own, err := b.placeFor(tc.kind, 7)
+ if child != tc.child || own != tc.own || err != nil {
+ t.Errorf("kind %d: %q %v %v, want %q %v", tc.kind, child, own, err, tc.child, tc.own)
+ }
+ }
+ if _, _, err := b.placeFor(spawnInstance, 0); err == nil {
+ t.Error("an instance spawn with generation 0 was placed")
+ }
+}
+
+func delegatedBackend(t *testing.T, host string) (*ExecBackend, string) {
+ 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)
+ }
+ }
+ m, err := cgroup.SetupAt(root, t.Logf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ parent := filepath.Join(root, cgroup.ExecName(host))
+ if err := os.MkdirAll(parent, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(parent, "cgroup.controllers"), []byte("cpu io memory pids\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ b := testExecBackend(t)
+ b.Cgroups = m
+ return b, parent
+}
+
+func TestSpawnInCgroupAtPutsAGenerationUnderItsParent(t *testing.T) {
+ const host = "h.example.com"
+ b, parent := delegatedBackend(t, host)
+ b.GenContainment = true
+ cmd, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, "g7", true, func() *exec.Cmd { return exec.Command("/bin/true") })
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = cmd.Wait()
+ for file, want := range map[string]string{
+ filepath.Join(parent, "g7", "memory.max"): "536870912",
+ filepath.Join(parent, "g7", "memory.oom.group"): "1",
+ filepath.Join(parent, "memory.oom.group"): "0",
+ } {
+ if got, err := os.ReadFile(file); err != nil || string(got) != want {
+ t.Errorf("%s = %q, %v; want %q", file, got, err, want)
+ }
+ }
+}
+
+// Flag off: the spawn lands in the host cgroup itself, as today.
+func TestSpawnInCgroupAtWithNoChildKeepsTheHostCgroup(t *testing.T) {
+ const host = "h.example.com"
+ b, parent := delegatedBackend(t, host)
+ cmd, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, "", false, func() *exec.Cmd { return exec.Command("/bin/true") })
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = cmd.Wait()
+ if got, err := os.ReadFile(filepath.Join(parent, "memory.oom.group")); err != nil || string(got) != "1" {
+ t.Errorf("host cgroup memory.oom.group = %q, %v; want 1", got, err)
+ }
+ entries, _ := os.ReadDir(parent)
+ for _, e := range entries {
+ if e.IsDir() {
+ t.Errorf("a flag-off spawn made child cgroup %s", e.Name())
+ }
+ }
+}
+
+func TestParentLimitRefusesWithContainmentOff(t *testing.T) {
+ b, _ := delegatedBackend(t, "h.example.com")
+ if err := b.ParentLimit(context.Background(), registry.Route{Host: "h.example.com"}, cgroup.TierDefault); !errors.Is(err, errNoGenContainment) {
+ t.Fatalf("ParentLimit with containment off: %v", err)
+ }
+ if err := b.KillGen(context.Background(), registry.Route{Host: "h.example.com"}, 7); !errors.Is(err, errNoGenContainment) {
+ t.Fatalf("KillGen with containment off: %v", err)
+ }
+}
+
+func TestParentLimitFlatWritesTheParent(t *testing.T) {
+ const host = "h.example.com"
+ b, parent := delegatedBackend(t, host)
+ b.GenContainment = true
+ if err := b.ParentLimit(context.Background(), registry.Route{Host: host}, cgroup.OverlapLimit(cgroup.TierDefault, cgroup.TierDefault)); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := os.ReadFile(filepath.Join(parent, "memory.max")); err != nil || string(got) != "1073741824" {
+ t.Errorf("parent memory.max = %q, %v", got, err)
+ }
+}
+
+// A fixture is not cgroup v2: KillGen must refuse rather than claim an
+// empty child it never proved.
+func TestKillGenFlatRefusesWithoutCgroupV2(t *testing.T) {
+ const host = "h.example.com"
+ b, parent := delegatedBackend(t, host)
+ b.GenContainment = true
+ if err := os.Mkdir(filepath.Join(parent, "g7"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := b.KillGen(context.Background(), registry.Route{Host: host}, 7); err == nil {
+ t.Fatal("KillGen reported an unproven kill as done")
+ }
+}
+
+func TestParentLimitAndKillGenNestedAskTheControlService(t *testing.T) {
+ ctl := &fakeControl{}
+ b, rt := nestedBackend(t, ctl, &fakeUnit{})
+ b.GenContainment = true
+ b.Control = fakeGenControl{ctl}
+ if err := b.ParentLimit(context.Background(), rt, cgroup.OverlapLimit(cgroup.TierDefault, cgroup.TierDefault)); err != nil {
+ t.Fatal(err)
+ }
+ if err := b.KillGen(context.Background(), rt, 7); err != nil {
+ t.Fatal(err)
+ }
+ if want := "parent h.example.com 1073741824\nstopgen h.example.com 7 kill=true"; ctl.joined() != want {
+ t.Errorf("calls =\n%s\nwant\n%s", ctl.joined(), want)
+ }
+ b.Control = ctl // a Control without the generation verbs
+ if err := b.ParentLimit(context.Background(), rt, cgroup.TierDefault); !errors.Is(err, errNoGenControl) {
+ t.Errorf("ParentLimit through a Control without generations: %v", err)
+ }
+}
+
+// SplitBackend forwards KillGen to the exec half for an exec row. A
+// unit-backed row, and an exec half that cannot kill a generation, refuse:
+// a caller must never believe a kill it could not make.
+func TestSplitBackendKillGen(t *testing.T) {
+ ctl := &fakeControl{}
+ b, rt := nestedBackend(t, ctl, &fakeUnit{})
+ b.GenContainment = true
+ b.Control = fakeGenControl{ctl}
+ sb := &SplitBackend{Exec: b, Unit: &UnitBackend{}}
+ if err := sb.KillGen(context.Background(), rt, 7); err != nil {
+ t.Fatal(err)
+ }
+ if want := "stopgen h.example.com 7 kill=true"; ctl.joined() != want {
+ t.Errorf("calls =\n%s\nwant\n%s", ctl.joined(), want)
+ }
+ unitRt := rt
+ unitRt.Backing = registry.BackingUnit
+ if err := sb.KillGen(context.Background(), unitRt, 7); !errors.Is(err, errNoGenContainment) {
+ t.Errorf("KillGen of a unit-backed row: %v", err)
+ }
+ if want := "stopgen h.example.com 7 kill=true"; ctl.joined() != want {
+ t.Errorf("a unit-backed KillGen reached the control service:\n%s", ctl.joined())
+ }
+ plain := &SplitBackend{Exec: &fakeBackend{}, Unit: &UnitBackend{}}
+ if err := plain.KillGen(context.Background(), rt, 7); !errors.Is(err, errNoGenContainment) {
+ t.Errorf("KillGen through an exec half without KillGen: %v", err)
+ }
+}
+
+// A placed spawn that fails to start leaves no child cgroup behind.
+func TestSpawnInCgroupAtRemovesTheChildOfAFailedSpawn(t *testing.T) {
+ const host = "h.example.com"
+ b, parent := delegatedBackend(t, host)
+ b.GenContainment = true
+ // A zero tier writes no limit files, so the fixture's child directory is
+ // as empty as a real cgroup's after its control files vanish.
+ b.Tier = func(registry.Route) cgroup.Limit { return cgroup.Limit{} }
+ _, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, "g7", true, func() *exec.Cmd {
+ return exec.Command(filepath.Join(t.TempDir(), "no-such-binary"))
+ })
+ if err == nil {
+ t.Fatal("a spawn of a missing binary succeeded")
+ }
+ if _, err := os.Stat(filepath.Join(parent, "g7")); !os.IsNotExist(err) {
+ t.Errorf("the failed spawn's child cgroup is still there: %v", err)
+ }
+}
+
+// A placed spawn never starts outside a strict host's group: a host that is
+// strict, or one whose group a strict owner holds, is refused.
+func TestSpawnInCgroupAtRefusesAStrictHost(t *testing.T) {
+ const host = "h.example.com"
+ b, parent := delegatedBackend(t, host)
+ b.GenContainment = true
+ b.Strict = func(string) bool { return true }
+ build := func() *exec.Cmd { return exec.Command("/bin/true") }
+ if _, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, "g7", true, build); !errors.Is(err, ErrStrictRequired) {
+ t.Fatalf("a strict host's placed spawn: %v", err)
+ }
+ b.Strict = func(string) bool { return false }
+ b.gate(host).owner = &strictOwner{}
+ if _, _, err := b.spawnInCgroupAt(registry.Route{Host: host}, "role-seed", false, build); !errors.Is(err, ErrStrictGroupInUse) {
+ t.Fatalf("a placed spawn beside a strict owner: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(parent, "g7")); !os.IsNotExist(err) {
+ t.Errorf("a refused spawn made a child cgroup: %v", err)
+ }
+}
diff --git a/internal/activator/control.go b/internal/activator/control.go
index 3fe7bf79c..51d6fd9c8 100644
--- a/internal/activator/control.go
+++ b/internal/activator/control.go
@@ -25,6 +25,21 @@ type Control interface {
var _ Control = (*instancectl.Client)(nil)
+// GenControl is the generation half of the instance-control channel
+// (zero-downtime spec §7.6): the carlos-instance-gen@<id>:<n> template's
+// start, stop and reset, and the host slice's parent limit (§7.3). It is a
+// separate interface so a Control without it (a fake, an older client)
+// still satisfies Control. The backend asks for it only while
+// GenContainment is on.
+type GenControl interface {
+ StartGen(ctx context.Context, host string, n uint64, memoryMax int64) (string, error)
+ StopGen(ctx context.Context, host string, n uint64, kill bool) error
+ ResetGen(ctx context.Context, host string, n uint64) error
+ Parent(ctx context.Context, host string, memoryMax int64) error
+}
+
+var _ GenControl = (*instancectl.Client)(nil)
+
// UnitState answers `systemctl show -p ActiveState,InvocationID <unit>`,
// which needs no privilege. An unloaded unit answers inactive with an
// empty invocation.