diff --git a/packages/orchestrator/pkg/sandbox/cgroup/manager.go b/packages/orchestrator/pkg/sandbox/cgroup/manager.go index 8325b681a5..8e272e291b 100644 --- a/packages/orchestrator/pkg/sandbox/cgroup/manager.go +++ b/packages/orchestrator/pkg/sandbox/cgroup/manager.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "slices" "strconv" "strings" "time" @@ -341,6 +342,11 @@ type Manager interface { // The handle provides access to the cgroup's FD, stats, and cleanup // Returns error if cgroup creation fails Create(ctx context.Context, cgroupName string) (*CgroupHandle, error) + + // Destroy kills any remaining processes in an already-created sandbox + // cgroup and removes it. Intended for reclaiming cgroups leaked by + // sandboxes that did not shut down cleanly. + Destroy(ctx context.Context, cgroupName string) error } type managerImpl struct{} @@ -414,6 +420,44 @@ func (m *managerImpl) Create(ctx context.Context, cgroupName string) (*CgroupHan return handle, nil } +// Destroy kills any remaining processes in an existing sandbox cgroup and +// removes it. +func (m *managerImpl) Destroy(ctx context.Context, cgroupName string) error { + handle, err := m.openExisting(ctx, cgroupName) + if err != nil { + return err + } + + // Remove kills any remaining processes internally and returns nil once the + // cgroup is gone, so it is the authoritative teardown signal. + return handle.Remove(ctx) +} + +// openExisting returns a handle for an already-created sandbox cgroup, intended +// for teardown of leaked cgroups. It deliberately does not open memory.peak +// (unlike Create), since callers only Kill/Remove the cgroup and never read peak +// memory. +func (m *managerImpl) openExisting(ctx context.Context, cgroupName string) (*CgroupHandle, error) { + cgroupPath := m.cgroupPath(cgroupName) + info, err := os.Stat(cgroupPath) + if err != nil { + return nil, fmt.Errorf("failed to stat cgroup directory: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("cgroup path is not a directory: %s", cgroupPath) + } + + logger.L().Debug(ctx, "opened existing cgroup for sandbox", + zap.String("cgroup_name", cgroupName), + zap.String("path", cgroupPath)) + + return &CgroupHandle{ + cgroupName: cgroupName, + path: cgroupPath, + manager: m, + }, nil +} + func (m *managerImpl) getStatsForPath(ctx context.Context, cgroupPath string, memoryPeakFile *os.File) (*Stats, error) { stats := &Stats{} @@ -496,3 +540,31 @@ func (m *managerImpl) readAndResetMemoryPeak(ctx context.Context, memoryPeakFile func (m *managerImpl) cgroupPath(cgroupName string) string { return filepath.Join(RootCgroupPath, cgroupName) } + +func IsSandboxCgroupName(name string) bool { + return strings.HasPrefix(name, "sbx-") && len(name) > len("sbx-") +} + +func ListSandboxCgroups(root string) ([]string, error) { + entries, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, fmt.Errorf("failed to read cgroup root: %w", err) + } + + names := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() || !IsSandboxCgroupName(entry.Name()) { + continue + } + + names = append(names, entry.Name()) + } + + slices.Sort(names) + + return names, nil +} diff --git a/packages/orchestrator/pkg/sandbox/cgroup/noop.go b/packages/orchestrator/pkg/sandbox/cgroup/noop.go index c219965149..6d6c51f01d 100644 --- a/packages/orchestrator/pkg/sandbox/cgroup/noop.go +++ b/packages/orchestrator/pkg/sandbox/cgroup/noop.go @@ -22,6 +22,10 @@ func (m *noopManager) Create(_ context.Context, cgroupName string) (*CgroupHandl return newNoopHandle(cgroupName), nil } +func (m *noopManager) Destroy(_ context.Context, _ string) error { + return nil +} + // newNoopHandle creates a CgroupHandle that performs no real cgroup operations. // GetFD returns NoCgroupFD, GetStats returns (nil, nil), Remove is a no-op. func newNoopHandle(cgroupName string) *CgroupHandle { diff --git a/packages/orchestrator/pkg/sandbox/cgroup/reclaim_test.go b/packages/orchestrator/pkg/sandbox/cgroup/reclaim_test.go new file mode 100644 index 0000000000..3434de3742 --- /dev/null +++ b/packages/orchestrator/pkg/sandbox/cgroup/reclaim_test.go @@ -0,0 +1,33 @@ +//go:build linux + +package cgroup + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsSandboxCgroupName(t *testing.T) { + t.Parallel() + + require.True(t, IsSandboxCgroupName("sbx-sandbox-rand")) + require.False(t, IsSandboxCgroupName("sbx-")) + require.False(t, IsSandboxCgroupName("other-sbx-sandbox")) +} + +func TestListSandboxCgroups(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + for _, name := range []string{"sbx-b", "other", "sbx-a"} { + require.NoError(t, os.Mkdir(filepath.Join(dir, name), 0o700)) + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "sbx-file"), []byte("x"), 0o600)) + + names, err := ListSandboxCgroups(dir) + require.NoError(t, err) + require.Equal(t, []string{"sbx-a", "sbx-b"}, names) +}