cgroup: reopen-only cleanup handle with no spawn authority (claim Plan 3 Task 4)
Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+482
−0
internal/cgroup/cleanup.go+8 −0internal/cgroup/cleanup_linux.go+236 −0internal/cgroup/cleanup_linux_test.go+188 −0internal/cgroup/cleanup_other.go+18 −0internal/cgroup/cleanup_other_test.go+32 −0
diff --git a/internal/cgroup/cleanup.go b/internal/cgroup/cleanup.go| new file mode 100644 |
| index 000000000..c48c9295e |
| --- /dev/null |
| +++ b/internal/cgroup/cleanup.go |
| @@ -0,0 +1,8 @@ |
| +package cgroup |
| + |
| +import "errors" |
| + |
| +var ( |
| + ErrNoCgroup = errors.New("cgroup: existing host cgroup absent") |
| + ErrNoDelegation = errors.New("cgroup: no delegated manager") |
| +) |
diff --git a/internal/cgroup/cleanup_linux.go b/internal/cgroup/cleanup_linux.go| new file mode 100644 |
| index 000000000..b5ceee2db |
| --- /dev/null |
| +++ b/internal/cgroup/cleanup_linux.go |
| @@ -0,0 +1,236 @@ |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/controlname" |
| + "golang.org/x/sys/unix" |
| +) |
| + |
| +type Cleanup struct{ state *cleanupState } |
| +type cleanupState struct { |
| + pin *Prepared |
| + procs *os.File |
| +} |
| + |
| +func cleanupFilesystem(root *os.File) error { |
| + var st unix.Statfs_t |
| + if err := unix.Fstatfs(int(root.Fd()), &st); err != nil { |
| + return err |
| + } |
| + if uint64(st.Type) != uint64(unix.CGROUP2_SUPER_MAGIC) { |
| + return ErrStrictUnsupported |
| + } |
| + return nil |
| +} |
| + |
| +func (m *Manager) OpenCleanup(host string) (*Cleanup, error) { |
| + return m.openCleanup(host, cleanupFilesystem) |
| +} |
| + |
| +func (m *Manager) openCleanup(host string, checkFS func(*os.File) error) (out *Cleanup, err error) { |
| + if m == nil || m.root == "" { |
| + return nil, ErrNoDelegation |
| + } |
| + if !controlname.ValidHostShape(host) { |
| + return nil, ErrStrictRefused |
| + } |
| + root, err := os.OpenFile(m.root, os.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + p := &Prepared{strictPreparedState: &strictPreparedState{root: root, host: host}} |
| + c := &Cleanup{state: &cleanupState{pin: p}} |
| + defer func() { |
| + if err != nil { |
| + if c.state.procs != nil { |
| + c.state.procs.Close() |
| + } |
| + p.closeFiles() |
| + } |
| + }() |
| + if err = checkFS(root); err != nil { |
| + return nil, err |
| + } |
| + p.dir, err = strictOpen(root, ExecName(host), unix.O_RDONLY|unix.O_DIRECTORY) |
| + if errors.Is(err, unix.ENOENT) { |
| + return nil, ErrNoCgroup |
| + } |
| + if err != nil { |
| + return nil, err |
| + } |
| + p.identity, err = strictID(p.dir) |
| + if err != nil { |
| + return nil, err |
| + } |
| + p.events, err = strictOpen(p.dir, "cgroup.events", unix.O_RDONLY) |
| + if err != nil { |
| + return nil, err |
| + } |
| + p.kill, err = strictOpen(p.dir, "cgroup.kill", unix.O_WRONLY) |
| + if err != nil { |
| + return nil, err |
| + } |
| + c.state.procs, err = strictOpen(p.dir, "cgroup.procs", unix.O_RDONLY) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if err = p.identityOK(); err != nil { |
| + return nil, err |
| + } |
| + return c, nil |
| +} |
| + |
| +func (c *Cleanup) Populated() (bool, error) { |
| + if c == nil || c.state == nil { |
| + return false, ErrStrictState |
| + } |
| + p := c.state.pin |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return c.populationLocked() |
| +} |
| + |
| +func (c *Cleanup) populationLocked() (bool, error) { |
| + p := c.state.pin |
| + if err := p.identityOK(); err != nil { |
| + return false, err |
| + } |
| + v, err := strictPopulation(p.events) |
| + if err != nil { |
| + return false, err |
| + } |
| + if err := p.identityOK(); err != nil { |
| + return false, err |
| + } |
| + return v, nil |
| +} |
| + |
| +func (c *Cleanup) Members() ([]int, error) { |
| + if c == nil || c.state == nil { |
| + return nil, ErrStrictState |
| + } |
| + p := c.state.pin |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return c.membersLocked() |
| +} |
| + |
| +func (c *Cleanup) membersLocked() ([]int, error) { |
| + p := c.state.pin |
| + if err := p.identityOK(); err != nil { |
| + return nil, err |
| + } |
| + if _, err := c.state.procs.Seek(0, 0); err != nil { |
| + return nil, err |
| + } |
| + // cgroup.procs may exceed strictRead's 4096-byte bound. Scan every line. |
| + scan := bufio.NewScanner(c.state.procs) |
| + var members []int |
| + for scan.Scan() { |
| + raw := strings.TrimSpace(scan.Text()) |
| + pid, err := strconv.Atoi(raw) |
| + if err != nil || pid <= 0 { |
| + return nil, fmt.Errorf("%w: invalid cgroup member", ErrStrictRefused) |
| + } |
| + members = append(members, pid) |
| + } |
| + if err := scan.Err(); err != nil { |
| + return nil, err |
| + } |
| + if err := p.identityOK(); err != nil { |
| + return nil, err |
| + } |
| + return members, nil |
| +} |
| + |
| +func (c *Cleanup) KillAndEmpty(ctx context.Context) error { |
| + if c == nil || c.state == nil { |
| + return ErrStrictState |
| + } |
| + if ctx == nil { |
| + return ErrStrictDeadline |
| + } |
| + deadline, ok := ctx.Deadline() |
| + if !ok { |
| + return ErrStrictDeadline |
| + } |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + if !time.Now().Before(deadline) { |
| + return context.DeadlineExceeded |
| + } |
| + p := c.state.pin |
| + lockTick := time.NewTicker(time.Millisecond) |
| + defer lockTick.Stop() |
| + for !p.mu.TryLock() { |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-lockTick.C: |
| + } |
| + } |
| + defer p.mu.Unlock() |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + if err := p.identityOK(); err != nil { |
| + return err |
| + } |
| + if err := strictWrite(p.kill, "1"); err != nil { |
| + return err |
| + } |
| + tick := time.NewTicker(5 * time.Millisecond) |
| + defer tick.Stop() |
| + for { |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + populated, err := c.populationLocked() |
| + if err != nil { |
| + return err |
| + } |
| + members, err := c.membersLocked() |
| + if err != nil { |
| + return err |
| + } |
| + if !populated && len(members) == 0 { |
| + return ctx.Err() |
| + } |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-tick.C: |
| + } |
| + } |
| +} |
| + |
| +func (c *Cleanup) Close() error { |
| + if c == nil || c.state == nil { |
| + return ErrStrictState |
| + } |
| + p := c.state.pin |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + if p.closed { |
| + return nil |
| + } |
| + p.closed = true |
| + var errs []error |
| + for _, f := range []*os.File{c.state.procs, p.events, p.kill, p.dir, p.root} { |
| + if f != nil { |
| + errs = append(errs, f.Close()) |
| + } |
| + } |
| + return errors.Join(errs...) |
| +} |
diff --git a/internal/cgroup/cleanup_linux_test.go b/internal/cgroup/cleanup_linux_test.go| new file mode 100644 |
| index 000000000..8e774dcc8 |
| --- /dev/null |
| +++ b/internal/cgroup/cleanup_linux_test.go |
| @@ -0,0 +1,188 @@ |
| +//go:build linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func cleanupFixture(t *testing.T) (*Manager, string, string) { |
| + t.Helper() |
| + host, root := "cleanup.example", t.TempDir() |
| + dir := filepath.Join(root, ExecName(host)) |
| + if err := os.Mkdir(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, body := range map[string]string{"cgroup.events": "populated 1\n", "cgroup.procs": "123\n456\n", "cgroup.kill": ""} { |
| + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return &Manager{root: root}, host, dir |
| +} |
| + |
| +func fixtureCleanup(t *testing.T, m *Manager, host string) *Cleanup { |
| + t.Helper() |
| + c, err := m.openCleanup(host, func(*os.File) error { return nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { _ = c.Close() }) |
| + return c |
| +} |
| + |
| +func TestCleanupReopensAndDrains(t *testing.T) { |
| + m, host, dir := cleanupFixture(t) |
| + c := fixtureCleanup(t, m, host) |
| + if _, exists := reflect.TypeOf(c).MethodByName("Start"); exists { |
| + t.Fatal("cleanup grants spawn") |
| + } |
| + if populated, err := c.Populated(); err != nil || !populated { |
| + t.Fatal(populated, err) |
| + } |
| + if members, err := c.Members(); err != nil || !reflect.DeepEqual(members, []int{123, 456}) { |
| + t.Fatal(members, err) |
| + } |
| + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) |
| + defer cancel() |
| + if err := c.KillAndEmpty(ctx); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatal(err) |
| + } |
| + if b, err := os.ReadFile(filepath.Join(dir, "cgroup.kill")); err != nil || string(b) != "1" { |
| + t.Fatalf("kill: %q %v", b, err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, "cgroup.events"), []byte("populated 0\n"), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + ctx2, cancel2 := context.WithTimeout(t.Context(), time.Second) |
| + defer cancel2() |
| + // Population alone is insufficient: the members still prevent success. |
| + ctx3, cancel3 := context.WithTimeout(t.Context(), 30*time.Millisecond) |
| + defer cancel3() |
| + if err := c.KillAndEmpty(ctx3); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, "cgroup.procs"), nil, 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := c.KillAndEmpty(ctx2); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := c.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := c.Populated(); !errors.Is(err, ErrStrictState) { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(dir); err != nil { |
| + t.Fatal("close removed group", err) |
| + } |
| +} |
| + |
| +func TestCleanupRefusals(t *testing.T) { |
| + var absent *Manager |
| + if _, err := absent.OpenCleanup("cleanup.example"); !errors.Is(err, ErrNoDelegation) { |
| + t.Fatal(err) |
| + } |
| + if _, err := (&Manager{}).OpenCleanup("cleanup.example"); !errors.Is(err, ErrNoDelegation) { |
| + t.Fatal(err) |
| + } |
| + m, host, dir := cleanupFixture(t) |
| + if _, err := m.OpenCleanup(host); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatal("ordinary filesystem admitted", err) |
| + } |
| + if _, err := m.openCleanup("../outside", func(*os.File) error { return nil }); !errors.Is(err, ErrStrictRefused) { |
| + t.Fatal(err) |
| + } |
| + if _, err := m.openCleanup("missing.example", func(*os.File) error { return nil }); !errors.Is(err, ErrNoCgroup) { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(filepath.Join(m.root, ExecName("missing.example"))); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal("created missing group", err) |
| + } |
| + c := fixtureCleanup(t, m, host) |
| + for _, body := range []string{"", "populated 2\n", "populated 0\npopulated 0\n", "broken\n"} { |
| + if err := os.WriteFile(filepath.Join(dir, "cgroup.events"), []byte(body), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := c.Populated(); err == nil { |
| + t.Fatal("malformed events accepted") |
| + } |
| + } |
| + for _, body := range []string{"0\n", "-1\n", "oops\n", "9999999999999999999999999\n"} { |
| + if err := os.WriteFile(filepath.Join(dir, "cgroup.procs"), []byte(body), 0600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := c.Members(); err == nil { |
| + t.Fatal("malformed members accepted") |
| + } |
| + } |
| + if err := c.KillAndEmpty(context.Background()); !errors.Is(err, ErrStrictDeadline) { |
| + t.Fatal(err) |
| + } |
| + if err := c.KillAndEmpty(nil); !errors.Is(err, ErrStrictDeadline) { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(dir, dir+".old"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(dir, 0700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := c.Populated(); err == nil { |
| + t.Fatal("replaced identity accepted") |
| + } |
| + if _, err := c.Members(); err == nil { |
| + t.Fatal("replaced membership accepted") |
| + } |
| + if err := c.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestCleanupDeadlineWhileLocked(t *testing.T) { |
| + m, host, _ := cleanupFixture(t) |
| + c := fixtureCleanup(t, m, host) |
| + c.state.pin.mu.Lock() |
| + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond) |
| + defer cancel() |
| + done := make(chan error, 1) |
| + go func() { done <- c.KillAndEmpty(ctx) }() |
| + select { |
| + case err := <-done: |
| + c.state.pin.mu.Unlock() |
| + if !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatal(err) |
| + } |
| + case <-time.After(time.Second): |
| + c.state.pin.mu.Unlock() |
| + <-done |
| + t.Fatal("cleanup exceeded its deadline waiting for a handle lock") |
| + } |
| +} |
| + |
| +func TestCleanupRejectsMissingControlsAndSymlinks(t *testing.T) { |
| + for _, name := range []string{"cgroup.events", "cgroup.procs", "cgroup.kill"} { |
| + t.Run(name, func(t *testing.T) { |
| + m, host, dir := cleanupFixture(t) |
| + if err := os.Remove(filepath.Join(dir, name)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c, err := m.openCleanup(host, func(*os.File) error { return nil }); err == nil || c != nil || errors.Is(err, ErrNoCgroup) { |
| + t.Fatalf("missing control: %v %v", c, err) |
| + } |
| + if err := os.Symlink(filepath.Join(dir, "elsewhere"), filepath.Join(dir, name)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c, err := m.openCleanup(host, func(*os.File) error { return nil }); err == nil || c != nil { |
| + t.Fatalf("symlink control: %v %v", c, err) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/cgroup/cleanup_other.go b/internal/cgroup/cleanup_other.go| new file mode 100644 |
| index 000000000..6b7c33abf |
| --- /dev/null |
| +++ b/internal/cgroup/cleanup_other.go |
| @@ -0,0 +1,18 @@ |
| +//go:build !linux |
| + |
| +package cgroup |
| + |
| +import "context" |
| + |
| +type Cleanup struct{} |
| + |
| +func (m *Manager) OpenCleanup(string) (*Cleanup, error) { |
| + if m == nil || m.root == "" { |
| + return nil, ErrNoDelegation |
| + } |
| + return nil, ErrStrictUnsupported |
| +} |
| +func (*Cleanup) Populated() (bool, error) { return false, ErrStrictUnsupported } |
| +func (*Cleanup) Members() ([]int, error) { return nil, ErrStrictUnsupported } |
| +func (*Cleanup) KillAndEmpty(context.Context) error { return ErrStrictUnsupported } |
| +func (*Cleanup) Close() error { return ErrStrictUnsupported } |
diff --git a/internal/cgroup/cleanup_other_test.go b/internal/cgroup/cleanup_other_test.go| new file mode 100644 |
| index 000000000..6c6ec9c85 |
| --- /dev/null |
| +++ b/internal/cgroup/cleanup_other_test.go |
| @@ -0,0 +1,32 @@ |
| +//go:build !linux |
| + |
| +package cgroup |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestCleanupUnsupported(t *testing.T) { |
| + if _, err := (&Manager{root: t.TempDir()}).OpenCleanup("shop.example"); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatal(err) |
| + } |
| + var m *Manager |
| + if _, err := m.OpenCleanup("shop.example"); !errors.Is(err, ErrNoDelegation) { |
| + t.Fatal(err) |
| + } |
| + c := &Cleanup{} |
| + if _, err := c.Populated(); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatal(err) |
| + } |
| + if _, err := c.Members(); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatal(err) |
| + } |
| + if err := c.KillAndEmpty(context.Background()); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatal(err) |
| + } |
| + if err := c.Close(); !errors.Is(err, ErrStrictUnsupported) { |
| + t.Fatal(err) |
| + } |
| +} |