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>
5 files changed,
+510
−7
internal/activator/backend_exec.go+31 −7internal/activator/backend_unit.go+18 −0internal/activator/containment.go+183 −0internal/activator/containment_test.go+263 −0internal/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. |