zdd plan 2 task 3: strict containment per generation child; parent limit through one API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+463
−4
internal/cgroup/strict_gen_linux.go+215 −0internal/cgroup/strict_gen_linux_test.go+213 −0internal/cgroup/strict_linux.go+29 −4internal/cgroup/strict_other.go+6 −0
diff --git a/internal/cgroup/strict_gen_linux.go b/internal/cgroup/strict_gen_linux.go| new file mode 100644 |
| index 000000000..56a392eed |
| --- /dev/null |
| +++ b/internal/cgroup/strict_gen_linux.go |
| @@ -0,0 +1,215 @@ |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "errors" |
| + "fmt" |
| + "os" |
| + "os/exec" |
| + "slices" |
| + "strconv" |
| + "strings" |
| + |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +// tierExpected is the four limit files for l, as the kernel reads them |
| +// back: memory.high rounded down to the page. |
| +func tierExpected(l Limit, oomGroup string) map[string]string { |
| + page := int64(os.Getpagesize()) |
| + return map[string]string{ |
| + "memory.max": strconv.FormatInt(l.MemoryMax, 10), |
| + "memory.high": strconv.FormatInt(HighOf(l.MemoryMax)/page*page, 10), |
| + "memory.swap.max": strconv.FormatInt(l.MemoryMax, 10), |
| + "memory.oom.group": oomGroup, |
| + } |
| +} |
| + |
| +// delegateStrict delegates a generation parent's controllers to its |
| +// children and proves memory is among them: a generation child without it |
| +// could carry no limit. |
| +func delegateStrict(dir *os.File) error { |
| + ctl, e := strictOpen(dir, "cgroup.controllers", unix.O_RDONLY) |
| + if e != nil { |
| + return ErrStrictUnsupported |
| + } |
| + have, e := strictRead(ctl) |
| + ctl.Close() |
| + if e != nil { |
| + return ErrStrictRefused |
| + } |
| + want := delegatedControllers(have) |
| + if !strings.Contains(want, "+memory") { |
| + return ErrStrictUnsupported |
| + } |
| + sub, e := strictOpen(dir, "cgroup.subtree_control", unix.O_RDWR) |
| + if e != nil { |
| + return ErrStrictUnsupported |
| + } |
| + defer sub.Close() |
| + if e = strictWrite(sub, want); e != nil { |
| + return ErrStrictRefused |
| + } |
| + got, e := strictRead(sub) |
| + if e != nil || !slices.Contains(strings.Fields(got), "memory") { |
| + return ErrStrictRefused |
| + } |
| + return nil |
| +} |
| + |
| +// PrepareStrictParent pins host's cgroup as a generation parent for the life |
| +// of one activator entry (zero-downtime deploys spec §7.3): empty, the tier |
| +// written with memory.oom.group=0, its controllers delegated to its |
| +// children. It holds no process: Start refuses it. Each spawn pins and |
| +// reserves its own child with PrepareGen. The swap moves the parent's limit |
| +// with SetParentLimit, which keeps the expected-limit record in step. |
| +func (m *Manager) PrepareStrictParent(host string, tier Limit) (*Prepared, error) { |
| + return m.prepareStrict(host, tier, true) |
| +} |
| + |
| +// PrepareGen pins generation n's child under a generation parent: newly |
| +// empty, reserved for this generation alone, with the tier's full limits |
| +// and memory.oom.group=1 (spec §7.3). tier is this generation's own and may |
| +// differ from the incumbent's when a release changes tier. |
| +func (p *Prepared) PrepareGen(n uint64, tier Limit) (out *Prepared, err error) { |
| + if p == nil || p.strictPreparedState == nil || n == 0 || (tier != TierDefault && tier != TierChildren) { |
| + return nil, ErrStrictRefused |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if !p.genParent || p.closed || p.empty || p.draining.Load() { |
| + return nil, ErrStrictState |
| + } |
| + if err = p.identityOK(); err != nil { |
| + return nil, err |
| + } |
| + name := GenChild(n) |
| + if err = unix.Mkdirat(int(p.dir.Fd()), name, 0755); err != nil && !errors.Is(err, unix.EEXIST) { |
| + return nil, ErrStrictRefused |
| + } |
| + child := &Prepared{strictPreparedState: &strictPreparedState{parent: p, host: p.host, tier: tier, limits: map[string]*os.File{}, expected: map[string]string{}, entryName: name, start: func(cmd *exec.Cmd) error { return cmd.Start() }}} |
| + reserved := false |
| + defer func() { |
| + if err != nil { |
| + child.closeFiles() |
| + if reserved { |
| + strictReservations.Lock() |
| + delete(strictReservations.held, child.identity) |
| + strictReservations.Unlock() |
| + } |
| + } |
| + }() |
| + fd, e := unix.FcntlInt(p.dir.Fd(), unix.F_DUPFD_CLOEXEC, 0) |
| + if e != nil { |
| + return nil, ErrStrictUnsupported |
| + } |
| + child.entryParent = os.NewFile(uintptr(fd), name+"-parent") |
| + if child.dir, e = strictOpen(p.dir, name, unix.O_RDONLY|unix.O_DIRECTORY); e != nil { |
| + return nil, ErrStrictRefused |
| + } |
| + if child.identity, e = strictID(child.dir); e != nil { |
| + return nil, ErrStrictRefused |
| + } |
| + strictReservations.Lock() |
| + if strictReservations.held[child.identity] { |
| + strictReservations.Unlock() |
| + return nil, ErrStrictState |
| + } |
| + strictReservations.held[child.identity] = true |
| + reserved = true |
| + strictReservations.Unlock() |
| + if child.events, e = strictOpen(child.dir, "cgroup.events", unix.O_RDONLY); e != nil { |
| + return nil, ErrStrictUnsupported |
| + } |
| + if child.kill, e = strictOpen(child.dir, "cgroup.kill", unix.O_WRONLY); e != nil { |
| + return nil, ErrStrictUnsupported |
| + } |
| + if populated, e := strictPopulation(child.events); e != nil || populated { |
| + return nil, ErrStrictRefused |
| + } |
| + want := tierExpected(tier, "1") |
| + for name := range want { |
| + if child.limits[name], e = strictOpen(child.dir, name, unix.O_RDWR); e != nil { |
| + return nil, ErrStrictUnsupported |
| + } |
| + } |
| + for _, name := range []string{"memory.max", "memory.high", "memory.swap.max", "memory.oom.group"} { |
| + if e = strictWrite(child.limits[name], want[name]); e != nil { |
| + return nil, ErrStrictRefused |
| + } |
| + child.expected[name] = want[name] |
| + } |
| + if e = child.limitsOK(); e != nil { |
| + return nil, e |
| + } |
| + if e = child.identityOK(); e != nil { |
| + return nil, e |
| + } |
| + return child, nil |
| +} |
| + |
| +// SetParentLimit writes l onto a generation parent and records it as the |
| +// expected value in the same step (spec §7.3). A child's Start checks the |
| +// parent with limitsOK and must never see a limit this API did not set. |
| +// Each file's record moves only after its own write lands, so a partial |
| +// failure leaves the record matching the kernel. A limit someone else set |
| +// is refused, not overwritten. |
| +func (p *Prepared) SetParentLimit(l Limit) error { |
| + if p == nil || p.strictPreparedState == nil || !ValidParentLimit(l) { |
| + return ErrStrictRefused |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if !p.genParent || p.closed || p.empty || p.draining.Load() { |
| + return ErrStrictState |
| + } |
| + if e := p.identityOK(); e != nil { |
| + return e |
| + } |
| + if e := p.limitsOK(); e != nil { |
| + return e |
| + } |
| + want := tierExpected(l, "0") |
| + for _, name := range []string{"memory.max", "memory.high", "memory.swap.max"} { |
| + if e := strictWrite(p.limits[name], want[name]); e != nil { |
| + return ErrStrictRefused |
| + } |
| + p.expected[name] = want[name] |
| + } |
| + return p.limitsOK() |
| +} |
| + |
| +// CloseRemove is Close for a generation child, then removes the child's |
| +// directory through the parent descriptor pinned at PrepareGen. Generation |
| +// numbers are never reused, so a child left behind would stay until the |
| +// agent restarts. Only an empty child is closed. The handle is closed and |
| +// its reservation released even when the rmdir fails; that error is |
| +// returned, and the next agent start's sweep removes the directory. |
| +func (p *Prepared) CloseRemove() error { |
| + if p == nil || p.strictPreparedState == nil { |
| + return ErrStrictState |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + p.draining.Store(true) |
| + if p.closed || p.parent == nil || p.entryParent == nil || !isGenName(p.entryName) { |
| + return ErrStrictState |
| + } |
| + if e := p.identityOK(); e != nil { |
| + return e |
| + } |
| + if populated, e := strictPopulation(p.events); e != nil || populated { |
| + return ErrStrictRefused |
| + } |
| + rmErr := unix.Unlinkat(int(p.entryParent.Fd()), p.entryName, unix.AT_REMOVEDIR) |
| + p.closed = true |
| + p.closeFiles() |
| + strictReservations.Lock() |
| + delete(strictReservations.held, p.identity) |
| + strictReservations.Unlock() |
| + if rmErr != nil { |
| + return fmt.Errorf("cgroup: %s: remove %s: %w", p.host, p.entryName, rmErr) |
| + } |
| + return nil |
| +} |
diff --git a/internal/cgroup/strict_gen_linux_test.go b/internal/cgroup/strict_gen_linux_test.go| new file mode 100644 |
| index 000000000..dc6f873ae |
| --- /dev/null |
| +++ b/internal/cgroup/strict_gen_linux_test.go |
| @@ -0,0 +1,213 @@ |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "syscall" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// genParentFixture is strictFixture turned into a generation parent: the |
| +// tier on its files, memory.oom.group=0, the record matching. |
| +func genParentFixture(t *testing.T) *Prepared { |
| + t.Helper() |
| + p := strictFixture(t) |
| + p.genParent, p.tier = true, TierDefault |
| + for name, value := range tierExpected(TierDefault, "0") { |
| + if err := strictWrite(p.limits[name], value); err != nil { |
| + t.Fatal(err) |
| + } |
| + p.expected[name] = value |
| + } |
| + return p |
| +} |
| + |
| +func makeGenFixture(t *testing.T, p *Prepared, n uint64, events string) string { |
| + t.Helper() |
| + d := filepath.Join(p.dir.Name(), GenChild(n)) |
| + if err := os.Mkdir(d, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, v := range map[string]string{"cgroup.events": events, "cgroup.kill": "", "memory.max": "", "memory.high": "", "memory.swap.max": "", "memory.oom.group": ""} { |
| + if err := os.WriteFile(filepath.Join(d, name), []byte(v), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return d |
| +} |
| + |
| +func TestPrepareGenWritesTheFullTier(t *testing.T) { |
| + p := genParentFixture(t) |
| + d := makeGenFixture(t, p, 1, "populated 0\n") |
| + child, err := p.PrepareGen(1, TierDefault) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer child.Close() |
| + want := tierExpected(TierDefault, "1") |
| + for name, value := range want { |
| + if got := readFixture(t, filepath.Join(d, name)); got != value { |
| + t.Errorf("g1/%s = %q, want %q", name, got, value) |
| + } |
| + } |
| + if want["memory.max"] != "536870912" || want["memory.oom.group"] != "1" { |
| + t.Fatalf("tierExpected = %v", want) |
| + } |
| +} |
| + |
| +func TestPrepareGenReservesPerChild(t *testing.T) { |
| + p := genParentFixture(t) |
| + makeGenFixture(t, p, 1, "populated 0\n") |
| + makeGenFixture(t, p, 2, "populated 0\n") |
| + g1, err := p.PrepareGen(1, TierDefault) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer g1.Close() |
| + if again, err := p.PrepareGen(1, TierDefault); !errors.Is(err, ErrStrictState) || again != nil { |
| + t.Fatalf("second reservation of g1: %v", err) |
| + } |
| + g2, err := p.PrepareGen(2, TierDefault) |
| + if err != nil { |
| + t.Fatalf("g2 beside a live g1: %v", err) |
| + } |
| + defer g2.Close() |
| + if _, err := p.PrepareGen(0, TierDefault); !errors.Is(err, ErrStrictRefused) { |
| + t.Errorf("generation 0: %v", err) |
| + } |
| + if _, err := p.PrepareGen(3, Limit{MemoryMax: 64 << 20}); !errors.Is(err, ErrStrictRefused) { |
| + t.Errorf("a limit that is not a tier: %v", err) |
| + } |
| +} |
| + |
| +func TestPrepareGenAndSetParentLimitRefuseAPlainHostGroup(t *testing.T) { |
| + p := strictFixture(t) |
| + if _, err := p.PrepareGen(1, TierDefault); !errors.Is(err, ErrStrictState) { |
| + t.Errorf("PrepareGen on a leaf host group: %v", err) |
| + } |
| + if err := p.SetParentLimit(TierDefault); !errors.Is(err, ErrStrictState) { |
| + t.Errorf("SetParentLimit on a leaf host group: %v", err) |
| + } |
| +} |
| + |
| +func TestGenerationParentRefusesAProcess(t *testing.T) { |
| + p := genParentFixture(t) |
| + if err := p.Start(exec.Command("/bin/true")); !errors.Is(err, ErrStrictState) { |
| + t.Fatalf("a generation parent accepted a process: %v", err) |
| + } |
| +} |
| + |
| +// The parent's record moves with the limit it raises, so a child's Start |
| +// passes limitsOK after a raise; a limit set by anyone else fails it. |
| +func TestSetParentLimitRecordsWhatItWrites(t *testing.T) { |
| + p := genParentFixture(t) |
| + makeGenFixture(t, p, 1, "populated 0\n") |
| + makeGenFixture(t, p, 2, "populated 0\n") |
| + child, err := p.PrepareGen(1, TierDefault) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer child.Close() |
| + if err := p.SetParentLimit(OverlapLimit(TierDefault, TierDefault)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := readFixture(t, filepath.Join(p.dir.Name(), "memory.max")); got != "1073741824" { |
| + t.Fatalf("parent memory.max = %q", got) |
| + } |
| + // The fixture's start answers ENOSYS: reaching it means every check passed. |
| + child.start = func(*exec.Cmd) error { return syscall.ENOSYS } |
| + if err := child.Start(exec.Command("/bin/true")); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatalf("child start after an API raise: %v, want it to reach the spawn", err) |
| + } |
| + // strictWrite does not truncate a fixture file: "9" over "1073741824" |
| + // leaves "9073741824", a limit this API never set. |
| + if err := strictWrite(p.limits["memory.max"], "9"); err != nil { |
| + t.Fatal(err) |
| + } |
| + other, err := p.PrepareGen(2, TierDefault) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer other.Close() |
| + if err := other.Start(exec.Command("/bin/true")); !errors.Is(err, ErrStrictRefused) { |
| + t.Fatalf("child start under a limit the API did not set: %v", err) |
| + } |
| + if err := p.SetParentLimit(TierDefault); !errors.Is(err, ErrStrictRefused) { |
| + t.Fatalf("SetParentLimit papered over a foreign limit: %v", err) |
| + } |
| +} |
| + |
| +// Review focus 4: a release that changes tier. |
| +func TestSetParentLimitAcceptsATierChange(t *testing.T) { |
| + p := genParentFixture(t) |
| + if err := p.SetParentLimit(OverlapLimit(TierDefault, TierChildren)); err != nil { |
| + t.Fatalf("512M + 1G: %v", err) |
| + } |
| + if got := readFixture(t, filepath.Join(p.dir.Name(), "memory.max")); got != strconv.FormatInt(1536<<20, 10) { |
| + t.Fatalf("parent memory.max = %q", got) |
| + } |
| + if err := p.SetParentLimit(Limit{MemoryMax: 64 << 20}); !errors.Is(err, ErrStrictRefused) { |
| + t.Fatalf("a limit that is no tier sum: %v", err) |
| + } |
| +} |
| + |
| +func TestGenerationKillAndEmptyTouchesOnlyItsChild(t *testing.T) { |
| + p := genParentFixture(t) |
| + d1 := makeGenFixture(t, p, 1, "populated 1\n") |
| + d2 := makeGenFixture(t, p, 2, "populated 0\n") |
| + g1, err := p.PrepareGen(1, TierDefault) |
| + if err == nil { |
| + t.Fatal("PrepareGen pinned a populated group") |
| + } |
| + _ = g1 |
| + if err := os.WriteFile(filepath.Join(d1, "cgroup.events"), []byte("populated 0\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if g1, err = p.PrepareGen(1, TierDefault); err != nil { |
| + t.Fatal(err) |
| + } |
| + defer g1.Close() |
| + g2, err := p.PrepareGen(2, TierDefault) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), time.Second) |
| + defer cancel() |
| + if err := g2.KillAndEmpty(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + for dir, want := range map[string]string{d2: "1", d1: "", p.dir.Name(): ""} { |
| + if got := readFixture(t, filepath.Join(dir, "cgroup.kill")); got != want { |
| + t.Errorf("%s/cgroup.kill = %q, want %q", dir, got, want) |
| + } |
| + } |
| + // CloseRemove: refused while populated; once empty it closes and frees |
| + // the reservation even when rmdir fails (a fixture dir holds files). |
| + if err := os.WriteFile(filepath.Join(d2, "cgroup.events"), []byte("populated 1\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := g2.CloseRemove(); !errors.Is(err, ErrStrictRefused) { |
| + t.Fatalf("CloseRemove of a populated child: %v", err) |
| + } |
| + if err := os.WriteFile(filepath.Join(d2, "cgroup.events"), []byte("populated 0\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := g2.CloseRemove(); err == nil { |
| + t.Fatal("rmdir of a fixture dir holding files reported success") |
| + } |
| + if _, err := g2.Populated(); !errors.Is(err, ErrStrictState) { |
| + t.Fatalf("handle still open after CloseRemove: %v", err) |
| + } |
| + again, err := p.PrepareGen(2, TierDefault) |
| + if err != nil { |
| + t.Fatalf("reservation not released by CloseRemove: %v", err) |
| + } |
| + again.Close() |
| +} |
diff --git a/internal/cgroup/strict_linux.go b/internal/cgroup/strict_linux.go| index 37a057902..581e22466 100644 |
| --- a/internal/cgroup/strict_linux.go |
| +++ b/internal/cgroup/strict_linux.go |
| @@ -9,7 +9,6 @@ import ( |
| "os" |
| "os/exec" |
| "path/filepath" |
| - "strconv" |
| "strings" |
| "sync" |
| "sync/atomic" |
| @@ -44,6 +43,12 @@ type strictPreparedState struct { |
| host string |
| attempted, empty, closed bool |
| start func(*exec.Cmd) error |
| + // genParent marks a generation parent (PrepareStrictParent): it holds |
| + // no process, pins PrepareGen children, and moves its limit only |
| + // through SetParentLimit. tier is the tier it was prepared with, or a |
| + // generation child's own. |
| + genParent bool |
| + tier Limit |
| } |
| func strictOpen(dir *os.File, name string, flags int) (*os.File, error) { |
| @@ -196,7 +201,14 @@ func (m *Manager) StrictPreflight() error { |
| // PrepareStrict never uses accounting's fallback. Only the two existing tiers |
| // are accepted; a zero or missing limit cannot create an uncapped command. |
| -func (m *Manager) PrepareStrict(host string, limit Limit) (out *Prepared, err error) { |
| +func (m *Manager) PrepareStrict(host string, limit Limit) (*Prepared, error) { |
| + return m.prepareStrict(host, limit, false) |
| +} |
| + |
| +// prepareStrict is PrepareStrict and PrepareStrictParent: with genParent the |
| +// group is pinned as a generation parent (memory.oom.group=0, controllers |
| +// delegated to its children, Start refused) instead of a leaf. |
| +func (m *Manager) prepareStrict(host string, limit Limit, genParent bool) (out *Prepared, err error) { |
| if m == nil || m.root == "" { |
| return nil, ErrStrictUnsupported |
| } |
| @@ -249,8 +261,11 @@ func (m *Manager) PrepareStrict(host string, limit Limit) (out *Prepared, err er |
| if populated, e := strictPopulation(p.events); e != nil || populated { |
| return nil, ErrStrictRefused |
| } |
| - high := HighOf(limit.MemoryMax) / int64(os.Getpagesize()) * int64(os.Getpagesize()) |
| - p.expected = map[string]string{"memory.max": strconv.FormatInt(limit.MemoryMax, 10), "memory.high": strconv.FormatInt(high, 10), "memory.swap.max": strconv.FormatInt(limit.MemoryMax, 10), "memory.oom.group": "1"} |
| + oomGroup := "1" |
| + if genParent { |
| + oomGroup = "0" |
| + } |
| + p.expected = tierExpected(limit, oomGroup) |
| for name := range p.expected { |
| p.limits[name], e = strictOpen(p.dir, name, unix.O_RDWR) |
| if e != nil { |
| @@ -265,6 +280,12 @@ func (m *Manager) PrepareStrict(host string, limit Limit) (out *Prepared, err er |
| if e = p.limitsOK(); e != nil { |
| return nil, e |
| } |
| + if genParent { |
| + if e = delegateStrict(p.dir); e != nil { |
| + return nil, e |
| + } |
| + p.genParent, p.tier = true, limit |
| + } |
| if e = p.identityOK(); e != nil { |
| return nil, e |
| } |
| @@ -292,6 +313,10 @@ func (p *Prepared) Start(cmd *exec.Cmd) error { |
| } |
| p.mu.Lock() |
| defer p.mu.Unlock() |
| + if p.genParent { |
| + // A generation parent holds no process (zero-downtime spec §7.3). |
| + return ErrStrictState |
| + } |
| if p.closed || p.attempted || p.empty || p.draining.Load() { |
| return ErrStrictState |
| } |
diff --git a/internal/cgroup/strict_other.go b/internal/cgroup/strict_other.go| index 34ddee7cb..9e9b73c50 100644 |
| --- a/internal/cgroup/strict_other.go |
| +++ b/internal/cgroup/strict_other.go |
| @@ -17,3 +17,9 @@ func (p *Prepared) Start(cmd *exec.Cmd) error { return ErrStrictUns |
| func (p *Prepared) Populated() (bool, error) { return false, ErrStrictUnsupported } |
| func (p *Prepared) KillAndEmpty(ctx context.Context) error { return ErrStrictUnsupported } |
| func (p *Prepared) Close() error { return ErrStrictUnsupported } |
| +func (m *Manager) PrepareStrictParent(string, Limit) (*Prepared, error) { |
| + return nil, ErrStrictUnsupported |
| +} |
| +func (p *Prepared) PrepareGen(uint64, Limit) (*Prepared, error) { return nil, ErrStrictUnsupported } |
| +func (p *Prepared) SetParentLimit(Limit) error { return ErrStrictUnsupported } |
| +func (p *Prepared) CloseRemove() error { return ErrStrictUnsupported } |