zdd plan 2 task 1: flat generation layout: parent cgroup, g<n> children, parent limit
- GenChild(n) returns "g<n>", RoleChild(r) returns "role-<r>" - isChildName validates generation and role child names - OverlapLimit sums two tiers for parent while both generations run - ValidParentLimit accepts one tier or the sum of two - StartChild creates a child cgroup with its own tier, or a role-child under parent's limit - SetParentLimit writes the parent's backstop limit with memory.oom.group=0 - RemoveChild removes an empty child cgroup (nil-safe and name-guarded) - delegatedControllers and enableChildren delegate controllers to the parent's children - ensureParent initializes a generation parent: delegates on cold wake, leaves it alone if live - parentPopulated reads cgroup.events to detect if the parent has had any generations - sweepStale now removes child cgroups first (rmdir refuses non-empty parent dirs) - Start refactored through startIn to support both host and child cgroups - startAndMove refactored through startAndMoveIn for explicit directory argument Dormant: GenContainment flag off, all generation layout code inactive outside tests.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
3 files changed,
+513
−15
internal/cgroup/cgroup.go+36 −15internal/cgroup/gen.go+200 −0internal/cgroup/gen_test.go+277 −0
diff --git a/internal/cgroup/cgroup.go b/internal/cgroup/cgroup.go| index 6194feb67..5c2a74457 100644 |
| --- a/internal/cgroup/cgroup.go |
| +++ b/internal/cgroup/cgroup.go |
| @@ -244,7 +244,20 @@ func (m *Manager) sweepStale() { |
| if !e.IsDir() || !IsExecName(e.Name()) { |
| continue |
| } |
| - if err := os.Remove(filepath.Join(m.root, e.Name())); err != nil { |
| + dir := filepath.Join(m.root, e.Name()) |
| + // A generation parent's children go first: rmdir refuses a cgroup |
| + // that has child cgroups, empty or not (zero-downtime spec §7.3). |
| + if kids, err := os.ReadDir(dir); err == nil { |
| + for _, k := range kids { |
| + if !k.IsDir() || !isChildName(k.Name()) { |
| + continue |
| + } |
| + if err := os.Remove(filepath.Join(dir, k.Name())); err != nil { |
| + m.logf("cgroup: stale %s/%s not removed: %v", e.Name(), k.Name(), err) |
| + } |
| + } |
| + } |
| + if err := os.Remove(dir); err != nil { |
| m.logf("cgroup: stale %s not removed: %v", e.Name(), err) |
| } |
| } |
| @@ -284,7 +297,13 @@ func (m *Manager) Start(host string, limit Limit, build func() *exec.Cmd) (*exec |
| cmd := build() |
| return cmd, startCmd(cmd) |
| } |
| - dir := m.hostDir(host) |
| + return m.startIn(host, m.hostDir(host), limit, build) |
| +} |
| + |
| +// startIn is Start against an explicit cgroup directory: the host's own |
| +// (Start) or one of its children (StartChild). The clone3 attempt, the |
| +// fallback and its latch are Start's, unchanged. |
| +func (m *Manager) startIn(host, dir string, limit Limit, build func() *exec.Cmd) (*exec.Cmd, error) { |
| if err := os.MkdirAll(dir, 0o755); err != nil { |
| m.logf("cgroup: %s: create cgroup: %v — spawning unaccounted", host, err) |
| cmd := build() |
| @@ -295,7 +314,7 @@ func (m *Manager) Start(host string, limit Limit, build func() *exec.Cmd) (*exec |
| f, err := os.Open(dir) |
| if err != nil { |
| m.logf("cgroup: %s: open cgroup dir: %v — using PID-move fallback for this spawn", host, err) |
| - return m.startAndMove(host, build) |
| + return m.startAndMoveIn(host, dir, build) |
| } |
| cmd := build() |
| setInto(cmd, int(f.Fd())) |
| @@ -306,9 +325,9 @@ func (m *Manager) Start(host string, limit Limit, build func() *exec.Cmd) (*exec |
| return cmd, nil |
| } |
| firstErr := err |
| - retried, retryErr := m.startAndMove(host, build) |
| + retried, retryErr := m.startAndMoveIn(host, dir, build) |
| if retryErr != nil { |
| - // startAndMove already dropped the ghost host dir on its own |
| + // startAndMoveIn already dropped the ghost dir on its own |
| // failure; join both errors so the operator can see a clone3 |
| // attempt happened at all, not just that the retry failed. |
| return nil, errors.Join(firstErr, retryErr) |
| @@ -321,7 +340,7 @@ func (m *Manager) Start(host string, limit Limit, build func() *exec.Cmd) (*exec |
| } |
| return retried, nil |
| } |
| - return m.startAndMove(host, build) |
| + return m.startAndMoveIn(host, dir, build) |
| } |
| // applyLimit writes limit into dir. Best-effort in the package's usual |
| @@ -365,27 +384,29 @@ func (m *Manager) applyLimit(host, dir string, limit Limit) { |
| m.logf("cgroup: %s: memory.max=%dM memory.high=%dM", host, limit.MemoryMax>>20, HighOf(limit.MemoryMax)>>20) |
| } |
| -// startAndMove is the fallback spawn: plain start, then write the child's |
| -// PID into the host cgroup. A failed move is logged and tolerated — the |
| -// child runs (unaccounted), which beats not running. |
| func (m *Manager) startAndMove(host string, build func() *exec.Cmd) (*exec.Cmd, error) { |
| + return m.startAndMoveIn(host, m.hostDir(host), build) |
| +} |
| + |
| +// startAndMoveIn is startAndMove into an explicit cgroup directory. |
| +func (m *Manager) startAndMoveIn(host, dir string, build func() *exec.Cmd) (*exec.Cmd, error) { |
| cmd := build() |
| if err := startCmd(cmd); err != nil { |
| - // The spawn never happened: drop the now-ownerless host dir so a |
| - // failed wake doesn't leave the sampler reporting permanent zeros |
| - // for a host with nothing running. rmdir-safe — os.Remove refuses a |
| + // The spawn never happened: drop the now-ownerless dir so a failed |
| + // wake doesn't leave the sampler reporting permanent zeros for a |
| + // host with nothing running. rmdir-safe: os.Remove refuses a |
| // non-empty dir, so this can never take a live sibling's dir. |
| - _ = os.Remove(m.hostDir(host)) |
| + _ = os.Remove(dir) |
| return nil, err |
| } |
| if cmd.Process == nil { |
| // A broken startCmd seam reporting success with no process: this |
| // package must never panic the agent over it. Log and skip the |
| - // move — the child, if any, simply stays unaccounted. |
| + // move; the child, if any, simply stays unaccounted. |
| m.logf("cgroup: %s: start reported success with no process — skipping pid move", host) |
| return cmd, nil |
| } |
| - procs := filepath.Join(m.hostDir(host), "cgroup.procs") |
| + procs := filepath.Join(dir, "cgroup.procs") |
| if err := os.WriteFile(procs, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644); err != nil { |
| m.logf("cgroup: %s: move pid %d: %v — child stays unaccounted", host, cmd.Process.Pid, err) |
| } |
diff --git a/internal/cgroup/gen.go b/internal/cgroup/gen.go| new file mode 100644 |
| index 000000000..997357278 |
| --- /dev/null |
| +++ b/internal/cgroup/gen.go |
| @@ -0,0 +1,200 @@ |
| +package cgroup |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "slices" |
| + "strconv" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| +) |
| + |
| +// The generation layout (zero-downtime deploys spec §7.3). A host's |
| +// carlos-exec@<host> becomes a PARENT that holds no processes. Each |
| +// instance spawn lands in its own generation child g<n>, which carries the |
| +// tier's full limits and memory.oom.group=1, so a successor that overruns |
| +// dies alone and the incumbent is untouched. The legacy co-spawned sidecar |
| +// and the seed land in role- children with no limit of their own. The |
| +// parent carries the tier with memory.oom.group=0: a backstop for the |
| +// instance as a whole, raised to OverlapLimit while two generations run. |
| +// Nothing here runs unless the exec backend's GenContainment is set |
| +// (plan 5's CARLOS_SWAP). |
| + |
| +// GenChild is generation n's child cgroup: "g<n>". |
| +func GenChild(n uint64) string { return "g" + strconv.FormatUint(n, 10) } |
| + |
| +// RoleChild is the child cgroup a non-generation spawn of a host lands in |
| +// under the generation layout: "role-<role>", the name PrepareRoleChild |
| +// pins. |
| +func RoleChild(role string) string { return "role-" + role } |
| + |
| +// isChildName reports whether name is one of the layout's child names: |
| +// g<n> with n canonical and above zero, or role-<label>. Only these are |
| +// ever created, removed or swept. |
| +func isChildName(name string) bool { |
| + if rest, ok := strings.CutPrefix(name, "role-"); ok { |
| + return controlname.ValidLabel(rest) |
| + } |
| + rest, ok := strings.CutPrefix(name, "g") |
| + if !ok || rest == "" { |
| + return false |
| + } |
| + n, err := strconv.ParseUint(rest, 10, 64) |
| + return err == nil && n > 0 && strconv.FormatUint(n, 10) == rest |
| +} |
| + |
| +// isGenName reports whether name is a generation child's name. |
| +func isGenName(name string) bool { return strings.HasPrefix(name, "g") && isChildName(name) } |
| + |
| +// OverlapLimit is the parent's limit while two generations overlap: the two |
| +// tiers summed. With equal tiers that is the spec's "twice the tier". When |
| +// a release changes tier it still has room for both (plan 2, D3). |
| +func OverlapLimit(incumbent, successor Limit) Limit { |
| + return Limit{MemoryMax: incumbent.MemoryMax + successor.MemoryMax} |
| +} |
| + |
| +// ValidParentLimit reports whether l is a limit a generation parent may |
| +// carry: one tier, or the sum of two. Strict containment and the root |
| +// control service accept nothing else. |
| +func ValidParentLimit(l Limit) bool { |
| + tiers := []int64{TierDefault.MemoryMax, TierChildren.MemoryMax} |
| + for _, a := range tiers { |
| + if l.MemoryMax == a { |
| + return true |
| + } |
| + for _, b := range tiers { |
| + if l.MemoryMax == a+b { |
| + return true |
| + } |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// delegatedControllers is the subtree_control write that hands a parent's |
| +// children every controller of cpu, memory, io and pids that the parent |
| +// itself has (its cgroup.controllers). A user manager delegates no io, for |
| +// one, and a write naming a controller the parent lacks fails as a whole. |
| +func delegatedControllers(have string) string { |
| + fields := strings.Fields(have) |
| + var want []string |
| + for _, c := range []string{"cpu", "memory", "io", "pids"} { |
| + if slices.Contains(fields, c) { |
| + want = append(want, "+"+c) |
| + } |
| + } |
| + return strings.Join(want, " ") |
| +} |
| + |
| +// enableChildren delegates dir's controllers to its children. Memory must |
| +// be among them, or a generation's limits could not exist. |
| +func enableChildren(dir string) error { |
| + have, err := os.ReadFile(filepath.Join(dir, "cgroup.controllers")) |
| + if err != nil { |
| + return err |
| + } |
| + want := delegatedControllers(string(have)) |
| + if !strings.Contains(want, "+memory") { |
| + return errors.New("no memory controller to delegate") |
| + } |
| + return os.WriteFile(filepath.Join(dir, "cgroup.subtree_control"), []byte(want), 0o644) |
| +} |
| + |
| +// parentPopulated reads dir's cgroup.events. An absent file means nothing |
| +// has ever run there: not populated. A file that cannot be read counts as |
| +// populated, so a raised limit is never lowered on a guess. |
| +func parentPopulated(dir string) bool { |
| + b, err := os.ReadFile(filepath.Join(dir, "cgroup.events")) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false |
| + } |
| + if err != nil { |
| + return true |
| + } |
| + return !strings.Contains(string(b), "populated 0") |
| +} |
| + |
| +// ensureParent makes carlos-exec@<host> a generation parent. When nothing |
| +// is alive under it (a cold wake), it delegates the controllers and writes |
| +// the tier with memory.oom.group=0. With a live generation under it, the |
| +// parent is left as it is, because a swap raised it on purpose. Best-effort, |
| +// like the rest of the accounting path: a failure is one logged line and |
| +// children that run without their limits, never a refused spawn. |
| +func (m *Manager) ensureParent(host string, tier Limit) { |
| + dir := m.hostDir(host) |
| + if err := os.MkdirAll(dir, 0o755); err != nil { |
| + m.logf("cgroup: %s: create parent cgroup: %v (children run unaccounted)", host, err) |
| + return |
| + } |
| + if parentPopulated(dir) { |
| + return |
| + } |
| + if err := enableChildren(dir); err != nil { |
| + m.logf("cgroup: %s: delegate controllers to generations: %v (children run uncapped)", host, err) |
| + return |
| + } |
| + if tier.MemoryMax > 0 { |
| + if err := m.SetParentLimit(host, tier); err != nil { |
| + m.logf("cgroup: %s: parent limit: %v", host, err) |
| + } |
| + } |
| +} |
| + |
| +// StartChild is Start for the generation layout: the child lands in |
| +// carlos-exec@<host>/<child>. own is the child's own limit: the tier for a |
| +// generation, zero for a role child, which then lives under the parent's |
| +// limit alone. parent is the tier the parent takes when this spawn finds |
| +// nothing alive under it. Nil-safe and best-effort like Start. |
| +func (m *Manager) StartChild(host, child string, parent, own Limit, build func() *exec.Cmd) (*exec.Cmd, error) { |
| + if !isChildName(child) { |
| + return nil, fmt.Errorf("cgroup: %s: %q is not a child cgroup name", host, child) |
| + } |
| + if m == nil { |
| + cmd := build() |
| + return cmd, startCmd(cmd) |
| + } |
| + m.ensureParent(host, parent) |
| + return m.startIn(host, filepath.Join(m.hostDir(host), child), own, build) |
| +} |
| + |
| +// SetParentLimit writes l onto host's parent: memory.max, memory.high |
| +// (HighOf), memory.swap.max, and memory.oom.group=0 (spec §7.3). The swap |
| +// raises it to OverlapLimit before the successor spawns and restores the |
| +// tier once the retiring child is empty. Unlike applyLimit it reports the |
| +// first failure, because a raise that did not land must stop the overlap. |
| +// A nil manager has no parent to set and answers nil. |
| +func (m *Manager) SetParentLimit(host string, l Limit) error { |
| + if m == nil { |
| + return nil |
| + } |
| + if l.MemoryMax <= 0 { |
| + return fmt.Errorf("cgroup: %s: parent limit %d is not a limit", host, l.MemoryMax) |
| + } |
| + dir := m.hostDir(host) |
| + for _, w := range []struct{ name, value string }{ |
| + {"memory.max", strconv.FormatInt(l.MemoryMax, 10)}, |
| + {"memory.high", strconv.FormatInt(HighOf(l.MemoryMax), 10)}, |
| + {"memory.swap.max", strconv.FormatInt(l.MemoryMax, 10)}, |
| + {"memory.oom.group", "0"}, |
| + } { |
| + if err := os.WriteFile(filepath.Join(dir, w.name), []byte(w.value), 0o644); err != nil { |
| + return fmt.Errorf("cgroup: %s: parent %s: %w", host, w.name, err) |
| + } |
| + } |
| + m.logf("cgroup: %s: parent memory.max=%dM", host, l.MemoryMax>>20) |
| + return nil |
| +} |
| + |
| +// RemoveChild removes host's child cgroup once it is empty. Nil-safe and |
| +// rmdir-tolerant like CleanupHost, and it touches only the layout's own |
| +// child names. The parent is CleanupHost's to remove. |
| +func (m *Manager) RemoveChild(host, child string) { |
| + if m == nil || !isChildName(child) { |
| + return |
| + } |
| + _ = os.Remove(filepath.Join(m.hostDir(host), child)) |
| +} |
diff --git a/internal/cgroup/gen_test.go b/internal/cgroup/gen_test.go| new file mode 100644 |
| index 000000000..367938eab |
| --- /dev/null |
| +++ b/internal/cgroup/gen_test.go |
| @@ -0,0 +1,277 @@ |
| +package cgroup |
| + |
| +import ( |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "testing" |
| +) |
| + |
| +// quietOOM points the oom_score_adj write at a temp dir, so a fake start |
| +// that reports our own PID never re-scores the test process. |
| +func quietOOM(t *testing.T) { |
| + t.Helper() |
| + dir := t.TempDir() |
| + orig := oomScoreAdjPath |
| + oomScoreAdjPath = func(pid int) string { return filepath.Join(dir, strconv.Itoa(pid)) } |
| + t.Cleanup(func() { oomScoreAdjPath = orig }) |
| +} |
| + |
| +func writeFixture(t *testing.T, path, body string) { |
| + t.Helper() |
| + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func readFixture(t *testing.T, path string) string { |
| + t.Helper() |
| + b, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatalf("read %s: %v", path, err) |
| + } |
| + return string(b) |
| +} |
| + |
| +// genManager is a delegated fixture root whose host dir already lists the |
| +// four controllers, as a real child of the delegated root would. |
| +func genManager(t *testing.T, host string) (*Manager, string) { |
| + t.Helper() |
| + quietOOM(t) |
| + m, err := SetupAt(newDelegatedRoot(t), t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + parent := m.hostDir(host) |
| + if err := os.MkdirAll(parent, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + writeFixture(t, filepath.Join(parent, "cgroup.controllers"), "cpu io memory pids\n") |
| + return m, parent |
| +} |
| + |
| +func TestGenAndRoleChildNames(t *testing.T) { |
| + if GenChild(7) != "g7" || RoleChild("sidecar") != "role-sidecar" { |
| + t.Fatalf("GenChild(7) = %q, RoleChild(sidecar) = %q", GenChild(7), RoleChild("sidecar")) |
| + } |
| + for name, want := range map[string]bool{ |
| + "g7": true, "g18446744073709551615": true, "role-seed": true, "role-sidecar": true, |
| + "g0": false, "g07": false, "g": false, "gx": false, "g-1": false, |
| + "role-": false, "role-a/b": false, "role-..": false, "child": false, |
| + "carlos-exec@h.example.com": false, "../g7": false, |
| + } { |
| + if got := isChildName(name); got != want { |
| + t.Errorf("isChildName(%q) = %v, want %v", name, got, want) |
| + } |
| + } |
| +} |
| + |
| +func TestOverlapAndValidParentLimits(t *testing.T) { |
| + if got := OverlapLimit(TierDefault, TierChildren); got.MemoryMax != 1536<<20 { |
| + t.Errorf("OverlapLimit(512M, 1G) = %d", got.MemoryMax) |
| + } |
| + for max, want := range map[int64]bool{ |
| + 512 << 20: true, 1 << 30: true, 1536 << 20: true, 2 << 30: true, |
| + 0: false, 64 << 20: false, 3 << 30: false, |
| + } { |
| + if got := ValidParentLimit(Limit{MemoryMax: max}); got != want { |
| + t.Errorf("ValidParentLimit(%d) = %v, want %v", max, got, want) |
| + } |
| + } |
| +} |
| + |
| +func TestStartChildPutsTheTierOnTheGenerationAndTheParent(t *testing.T) { |
| + const host = "h.example.com" |
| + m, parent := genManager(t, host) |
| + fakeStart(t, func(*exec.Cmd) error { return nil }) |
| + if _, err := m.StartChild(host, GenChild(1), TierDefault, TierDefault, buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + high := strconv.FormatInt(HighOf(TierDefault.MemoryMax), 10) |
| + child := filepath.Join(parent, "g1") |
| + for file, want := range map[string]string{ |
| + filepath.Join(child, "memory.max"): "536870912", |
| + filepath.Join(child, "memory.high"): high, |
| + filepath.Join(child, "memory.swap.max"): "536870912", |
| + filepath.Join(child, "memory.oom.group"): "1", |
| + filepath.Join(parent, "memory.max"): "536870912", |
| + filepath.Join(parent, "memory.high"): high, |
| + filepath.Join(parent, "memory.swap.max"): "536870912", |
| + filepath.Join(parent, "memory.oom.group"): "0", |
| + filepath.Join(parent, "cgroup.subtree_control"): "+cpu +memory +io +pids", |
| + } { |
| + if got := readFixture(t, file); got != want { |
| + t.Errorf("%s = %q, want %q", file, got, want) |
| + } |
| + } |
| + if _, err := os.Stat(filepath.Join(parent, "cgroup.procs")); !os.IsNotExist(err) { |
| + t.Errorf("the parent must hold no process, but its cgroup.procs was written (%v)", err) |
| + } |
| +} |
| + |
| +func TestStartChildRoleChildCarriesNoLimitOfItsOwn(t *testing.T) { |
| + const host = "h.example.com" |
| + m, parent := genManager(t, host) |
| + fakeStart(t, func(*exec.Cmd) error { return nil }) |
| + if _, err := m.StartChild(host, RoleChild("sidecar"), TierDefault, Limit{}, buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(filepath.Join(parent, "role-sidecar", "memory.max")); !os.IsNotExist(err) { |
| + t.Errorf("a role child got a limit of its own: %v", err) |
| + } |
| + if got := readFixture(t, filepath.Join(parent, "memory.max")); got != "536870912" { |
| + t.Errorf("parent memory.max = %q, want the tier", got) |
| + } |
| +} |
| + |
| +// A successor spawns while the incumbent lives: the swap has raised the |
| +// parent on purpose, and the spawn must not lower it. |
| +func TestStartChildLeavesALiveParentAlone(t *testing.T) { |
| + const host = "h.example.com" |
| + m, parent := genManager(t, host) |
| + fakeStart(t, func(*exec.Cmd) error { return nil }) |
| + writeFixture(t, filepath.Join(parent, "cgroup.events"), "populated 1\nfrozen 0\n") |
| + writeFixture(t, filepath.Join(parent, "memory.max"), "1073741824") |
| + if _, err := m.StartChild(host, GenChild(2), TierDefault, TierDefault, buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := readFixture(t, filepath.Join(parent, "memory.max")); got != "1073741824" { |
| + t.Errorf("a successor's spawn changed the raised parent to %q", got) |
| + } |
| +} |
| + |
| +// Review focus 1: an agent killed between the raise and the restore leaves |
| +// the parent at two tiers. The next cold wake puts it back. |
| +func TestStartChildResetsAStaleRaisedParentOnAColdWake(t *testing.T) { |
| + const host = "h.example.com" |
| + m, parent := genManager(t, host) |
| + fakeStart(t, func(*exec.Cmd) error { return nil }) |
| + writeFixture(t, filepath.Join(parent, "cgroup.events"), "populated 0\nfrozen 0\n") |
| + writeFixture(t, filepath.Join(parent, "memory.max"), "1073741824") |
| + if _, err := m.StartChild(host, GenChild(3), TierDefault, TierDefault, buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := readFixture(t, filepath.Join(parent, "memory.max")); got != "536870912" { |
| + t.Errorf("cold wake left the parent at %q, want the tier", got) |
| + } |
| +} |
| + |
| +func TestStartChildRefusesAForeignChildName(t *testing.T) { |
| + m, _ := genManager(t, "h.example.com") |
| + calls := fakeStart(t, func(*exec.Cmd) error { return nil }) |
| + if _, err := m.StartChild("h.example.com", "../carlos-exec@other.example.com", TierDefault, TierDefault, buildTrue); err == nil { |
| + t.Fatal("a child name outside the layout was accepted") |
| + } |
| + if len(*calls) != 0 { |
| + t.Fatalf("spawned %d times for a refused child name", len(*calls)) |
| + } |
| +} |
| + |
| +func TestSetParentLimitWritesTheBackstopAndReportsFailure(t *testing.T) { |
| + const host = "h.example.com" |
| + m, parent := genManager(t, host) |
| + if err := m.SetParentLimit(host, OverlapLimit(TierDefault, TierDefault)); err != nil { |
| + t.Fatal(err) |
| + } |
| + for file, want := range map[string]string{ |
| + "memory.max": "1073741824", |
| + "memory.high": strconv.FormatInt(HighOf(1<<30), 10), |
| + "memory.swap.max": "1073741824", |
| + "memory.oom.group": "0", |
| + } { |
| + if got := readFixture(t, filepath.Join(parent, file)); got != want { |
| + t.Errorf("%s = %q, want %q", file, got, want) |
| + } |
| + } |
| + // A raise that did not land must say so: the swap stops on it. |
| + if err := os.Remove(filepath.Join(parent, "memory.max")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(filepath.Join(parent, "memory.max"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := m.SetParentLimit(host, TierDefault); err == nil { |
| + t.Fatal("a limit write that failed reported success") |
| + } |
| + var nilM *Manager |
| + if err := nilM.SetParentLimit(host, TierDefault); err != nil { |
| + t.Fatalf("nil manager: %v, want nil (no delegation, nothing to set)", err) |
| + } |
| +} |
| + |
| +// Review focus 2: rmdir refuses a cgroup with child cgroups, empty or not. |
| +func TestSweepRemovesEmptyGenerationChildrenThenTheParent(t *testing.T) { |
| + root := newDelegatedRoot(t) |
| + stale := filepath.Join(root, ExecName("old.example.com")) |
| + for _, d := range []string{"g3", "role-seed"} { |
| + if err := os.MkdirAll(filepath.Join(stale, d), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + live := filepath.Join(root, ExecName("live.example.com")) |
| + if err := os.MkdirAll(filepath.Join(live, "g4"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A file stands in for a member: rmdir refuses the directory. |
| + writeFixture(t, filepath.Join(live, "g4", "cgroup.procs"), "123\n") |
| + if _, err := SetupAt(root, t.Logf); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(stale); !os.IsNotExist(err) { |
| + t.Errorf("stale generation parent survived the sweep: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(live, "g4")); err != nil { |
| + t.Errorf("a populated generation child must be left alone: %v", err) |
| + } |
| +} |
| + |
| +func TestRemoveChildRemovesOnlyItsOwnNames(t *testing.T) { |
| + const host = "h.example.com" |
| + m, parent := genManager(t, host) |
| + for _, d := range []string{"g5", "keep"} { |
| + if err := os.Mkdir(filepath.Join(parent, d), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + m.RemoveChild(host, "g5") |
| + m.RemoveChild(host, "keep") |
| + m.RemoveChild(host, "../"+ExecName("other.example.com")) |
| + if _, err := os.Stat(filepath.Join(parent, "g5")); !os.IsNotExist(err) { |
| + t.Errorf("empty g5 survived: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(parent, "keep")); err != nil { |
| + t.Errorf("a foreign child was removed: %v", err) |
| + } |
| + var nilM *Manager |
| + nilM.RemoveChild(host, "g5") // must not panic |
| +} |
| + |
| +// Flag off: Start keeps today's single-level layout. |
| +func TestStartKeepsTheSingleLevelLayout(t *testing.T) { |
| + quietOOM(t) |
| + m, err := SetupAt(newDelegatedRoot(t), t.Logf) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + fakeStart(t, func(*exec.Cmd) error { return nil }) |
| + if _, err := m.Start("h.example.com", TierDefault, buildTrue); err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := m.hostDir("h.example.com") |
| + if got := readFixture(t, filepath.Join(dir, "memory.oom.group")); got != "1" { |
| + t.Errorf("host cgroup memory.oom.group = %q, want 1", got) |
| + } |
| + if _, err := os.Stat(filepath.Join(dir, "cgroup.subtree_control")); !os.IsNotExist(err) { |
| + t.Errorf("flag-off Start delegated controllers: %v", err) |
| + } |
| + entries, err := os.ReadDir(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range entries { |
| + if e.IsDir() { |
| + t.Errorf("flag-off Start made a child cgroup %s", e.Name()) |
| + } |
| + } |
| +} |