diff options
Diffstat (limited to 'cgroup')
| -rw-r--r-- | cgroup/cgroup.go | 175 | ||||
| -rw-r--r-- | cgroup/cgroup_test.go | 164 | ||||
| -rw-r--r-- | cgroup/testdata/v2-no-limit/expected.json | 1 | ||||
| -rw-r--r-- | cgroup/testdata/v2-no-limit/proc/self/cgroup | 1 | ||||
| -rw-r--r-- | cgroup/testdata/v2-no-limit/sys/fs/cgroup/cpu.max | 1 | ||||
| -rw-r--r-- | cgroup/testdata/v2-no-limit/sys/fs/cgroup/memory.max | 1 | ||||
| -rw-r--r-- | cgroup/testdata/v2-quarter-core-64m/expected.json | 1 | ||||
| -rw-r--r-- | cgroup/testdata/v2-quarter-core-64m/proc/self/cgroup | 1 | ||||
| -rw-r--r-- | cgroup/testdata/v2-quarter-core-64m/sys/fs/cgroup/cpu.max | 1 | ||||
| -rw-r--r-- | cgroup/testdata/v2-quarter-core-64m/sys/fs/cgroup/memory.max | 1 | ||||
| -rw-r--r-- | cgroup/testdata/v2-two-cores-256m/expected.json | 1 | ||||
| -rw-r--r-- | cgroup/testdata/v2-two-cores-256m/proc/self/cgroup | 1 | ||||
| -rw-r--r-- | cgroup/testdata/v2-two-cores-256m/sys/fs/cgroup/cpu.max | 1 | ||||
| -rw-r--r-- | cgroup/testdata/v2-two-cores-256m/sys/fs/cgroup/memory.max | 1 |
14 files changed, 351 insertions, 0 deletions
diff --git a/cgroup/cgroup.go b/cgroup/cgroup.go new file mode 100644 index 0000000..dff8138 --- /dev/null +++ b/cgroup/cgroup.go @@ -0,0 +1,175 @@ +/* + * cgroup.go - Read CPU and memory limits from Linux cgroups v2. + * + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +// Package cgroup reads CPU and memory resource limits from Linux control +// groups (cgroup v2). +// +// References: +// - cgroups(7): https://man7.org/linux/man-pages/man7/cgroups.7.html +// - cgroup v2 (cpu.max, memory.max): https://docs.kernel.org/admin-guide/cgroup-v2.html +// - /proc/self/cgroup: https://man7.org/linux/man-pages/man7/cgroups.7.html (see "/proc files") +package cgroup + +import ( + "bufio" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// Errors. +var ( + // ErrNoLimit indicates that no cgroup limit is set. + ErrNoLimit = errors.New("no cgroup limit set") + + // ErrV1Detected indicates that cgroup v1 controllers were found. Only v2 is + // supported. + ErrV1Detected = errors.New("cgroup v1 detected; only v2 is supported") +) + +// Cgroup provides access to cgroup v2 resource limits. Create one with +// New or NewFromRoot. +type Cgroup struct { + // cgroupDir is the resolved filesystem path to the cgroup directory + // (e.g. /sys/fs/cgroup/user.slice/...). + cgroupDir string +} + +// New returns a Cgroup by reading /proc/self/cgroup on the live system. +func New() (Cgroup, error) { + return NewFromRoot("/") +} + +// NewFromRoot is like New but resolves all filesystem paths relative to +// root instead of "/". This is useful for testing with a mock filesystem. +func NewFromRoot(root string) (Cgroup, error) { + groupPath, err := parseProcCgroup(filepath.Join(root, "proc/self/cgroup")) + if err != nil { + return Cgroup{}, err + } + return Cgroup{ + cgroupDir: filepath.Join(root, "sys/fs/cgroup", groupPath), + }, nil +} + +// CPUQuota returns the CPU quota as a fractional number of CPUs (e.g. 0.5 +// means half a core). Returns ErrNoLimit if no CPU limit is configured. +func (c Cgroup) CPUQuota() (float64, error) { + data, err := c.readFile("cpu.max") + if err != nil { + return 0, err + } + return parseCPUMax(data) +} + +// MemoryLimit returns the cgroup memory limit in bytes. Returns ErrNoLimit +// if no memory limit is configured. +func (c Cgroup) MemoryLimit() (int64, error) { + data, err := c.readFile("memory.max") + if err != nil { + return 0, err + } + return parseMemoryMax(data) +} + +func (c Cgroup) readFile(path string) (string, error) { + data, err := os.ReadFile(filepath.Join(c.cgroupDir, path)) + if err != nil { + if os.IsNotExist(err) { + return "", ErrNoLimit + } + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +// parseProcCgroup parses /proc/self/cgroup and returns the cgroup v2 group +// path. The v2 entry is the line with hierarchy-ID "0" and an empty +// controller list: "0::<path>". +// +// Returns an error if v1 controllers are detected or no v2 entry is found. +// +// https://man7.org/linux/man-pages/man7/cgroups.7.html +func parseProcCgroup(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + var v2Path string + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + parts := strings.SplitN(scanner.Text(), ":", 3) + if len(parts) != 3 { + continue + } + if parts[0] == "0" && parts[1] == "" { + v2Path = parts[2] + } else if parts[1] != "" { + return "", ErrV1Detected + } + } + if err := scanner.Err(); err != nil { + return "", err + } + if v2Path == "" { + return "", fmt.Errorf("no cgroup v2 entry found in %s", path) + } + return v2Path, nil +} + +func parseCPUMax(content string) (float64, error) { + fields := strings.Fields(content) + if len(fields) == 0 || len(fields) > 2 { + return 0, fmt.Errorf("unexpected cpu.max format: %q", content) + } + if fields[0] == "max" { + return 0, ErrNoLimit + } + quota, err := strconv.ParseFloat(fields[0], 64) + if err != nil { + return 0, fmt.Errorf("parsing cpu.max quota: %w", err) + } + period := 100000.0 + if len(fields) == 2 { + period, err = strconv.ParseFloat(fields[1], 64) + if err != nil { + return 0, fmt.Errorf("parsing cpu.max period: %w", err) + } + if period == 0 { + return 0, fmt.Errorf("cpu.max period is zero") + } + } + return quota / period, nil +} + +func parseMemoryMax(content string) (int64, error) { + if content == "max" { + return 0, ErrNoLimit + } + v, err := strconv.ParseInt(content, 10, 64) + if err != nil { + return 0, fmt.Errorf("parsing memory.max: %w", err) + } + return v, nil +} diff --git a/cgroup/cgroup_test.go b/cgroup/cgroup_test.go new file mode 100644 index 0000000..f1bc8f9 --- /dev/null +++ b/cgroup/cgroup_test.go @@ -0,0 +1,164 @@ +/* + * cgroup_test.go - Tests for cgroup CPU and memory limit reading. + * + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package cgroup + +import ( + "encoding/json" + "errors" + "math" + "os" + "path/filepath" + "strconv" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestCgroupV1Unsupported(t *testing.T) { + content := `12:memory:/docker/abc123 +11:cpu,cpuacct:/docker/abc123 +` + root := t.TempDir() + writeFile(t, filepath.Join(root, "proc/self/cgroup"), content) + _, err := NewFromRoot(root) + if !errors.Is(err, ErrV1Detected) { + t.Fatalf("NewFromRoot() error = %v, want %v", err, ErrV1Detected) + } +} + +// testdataExpected holds the expected values from a testdata/*/expected.json. +// Null fields indicate that ErrNoLimit is expected. +type testdataExpected struct { + CPUQuota *float64 `json:"cpu_quota"` + MemoryLimit *int64 `json:"memory_limit"` +} + +// TestWithRootFromTestdata runs NewFromRoot, CPUQuota, and MemoryLimit +// against filesystem snapshots captured from real Docker containers by +// bin/snapshot-cgroup. Each subdirectory of testdata/ is a separate test +// case containing a proc/ and sys/ tree plus an expected.json. +// +// Regenerate with: bin/gen-cgroup-testdata +func TestWithRootFromTestdata(t *testing.T) { + entries, err := os.ReadDir("testdata") + if err != nil { + t.Fatalf("no testdata directory: %v", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + root := filepath.Join("testdata", name) + + t.Run(name, func(t *testing.T) { + data, err := os.ReadFile(filepath.Join(root, "expected.json")) + if err != nil { + t.Fatalf("reading expected.json: %v", err) + } + var want testdataExpected + if err := json.Unmarshal(data, &want); err != nil { + t.Fatalf("parsing expected.json: %v", err) + } + + cg, err := NewFromRoot(root) + if err != nil { + t.Fatalf("NewFromRoot(%q): %v", root, err) + } + + gotCPU, err := cg.CPUQuota() + if want.CPUQuota == nil { + if !errors.Is(err, ErrNoLimit) { + t.Errorf("CPUQuota() error = %v, want ErrNoLimit", err) + } + } else if err != nil { + t.Fatalf("CPUQuota(): %v", err) + } else if math.Abs(gotCPU-*want.CPUQuota) > 0.001 { + t.Errorf("CPUQuota() = %v, want %v", gotCPU, *want.CPUQuota) + } + + gotMem, err := cg.MemoryLimit() + if want.MemoryLimit == nil { + if !errors.Is(err, ErrNoLimit) { + t.Errorf("MemoryLimit() error = %v, want ErrNoLimit", err) + } + } else if err != nil { + t.Fatalf("MemoryLimit(): %v", err) + } else if gotMem != *want.MemoryLimit { + t.Errorf("MemoryLimit() = %v, want %v", gotMem, *want.MemoryLimit) + } + }) + } +} + +// TestIntegrationCgroupLimits calls the real New(), CPUQuota(), and +// MemoryLimit() against the live kernel cgroup interface. It is intended to +// run inside a Docker container started with --cpus and --memory flags. +// +// The test is skipped unless CGROUP_EXPECTED_CPU_QUOTA and +// CGROUP_EXPECTED_MEMORY_LIMIT are set in the environment. +func TestIntegrationCgroupLimits(t *testing.T) { + cpuStr := os.Getenv("CGROUP_EXPECTED_CPU_QUOTA") + memStr := os.Getenv("CGROUP_EXPECTED_MEMORY_LIMIT") + if cpuStr == "" && memStr == "" { + t.Skip("set CGROUP_EXPECTED_CPU_QUOTA and CGROUP_EXPECTED_MEMORY_LIMIT to run") + } + + cg, err := New() + if err != nil { + t.Fatalf("New() error: %v", err) + } + + if cpuStr != "" { + wantCPU, err := strconv.ParseFloat(cpuStr, 64) + if err != nil { + t.Fatalf("bad CGROUP_EXPECTED_CPU_QUOTA %q: %v", cpuStr, err) + } + gotCPU, err := cg.CPUQuota() + if err != nil { + t.Fatalf("CPUQuota() error: %v", err) + } + if math.Abs(gotCPU-wantCPU) > 0.001 { + t.Errorf("CPUQuota() = %v, want %v", gotCPU, wantCPU) + } + } + + if memStr != "" { + wantMem, err := strconv.ParseInt(memStr, 10, 64) + if err != nil { + t.Fatalf("bad CGROUP_EXPECTED_MEMORY_LIMIT %q: %v", memStr, err) + } + gotMem, err := cg.MemoryLimit() + if err != nil { + t.Fatalf("MemoryLimit() error: %v", err) + } + if gotMem != wantMem { + t.Errorf("MemoryLimit() = %v, want %v", gotMem, wantMem) + } + } +} diff --git a/cgroup/testdata/v2-no-limit/expected.json b/cgroup/testdata/v2-no-limit/expected.json new file mode 100644 index 0000000..3a6d7ed --- /dev/null +++ b/cgroup/testdata/v2-no-limit/expected.json @@ -0,0 +1 @@ +{"cpu_quota": null, "memory_limit": null} diff --git a/cgroup/testdata/v2-no-limit/proc/self/cgroup b/cgroup/testdata/v2-no-limit/proc/self/cgroup new file mode 100644 index 0000000..1e027b2 --- /dev/null +++ b/cgroup/testdata/v2-no-limit/proc/self/cgroup @@ -0,0 +1 @@ +0::/ diff --git a/cgroup/testdata/v2-no-limit/sys/fs/cgroup/cpu.max b/cgroup/testdata/v2-no-limit/sys/fs/cgroup/cpu.max new file mode 100644 index 0000000..1c1d3e7 --- /dev/null +++ b/cgroup/testdata/v2-no-limit/sys/fs/cgroup/cpu.max @@ -0,0 +1 @@ +max 100000 diff --git a/cgroup/testdata/v2-no-limit/sys/fs/cgroup/memory.max b/cgroup/testdata/v2-no-limit/sys/fs/cgroup/memory.max new file mode 100644 index 0000000..355295a --- /dev/null +++ b/cgroup/testdata/v2-no-limit/sys/fs/cgroup/memory.max @@ -0,0 +1 @@ +max diff --git a/cgroup/testdata/v2-quarter-core-64m/expected.json b/cgroup/testdata/v2-quarter-core-64m/expected.json new file mode 100644 index 0000000..41ec96f --- /dev/null +++ b/cgroup/testdata/v2-quarter-core-64m/expected.json @@ -0,0 +1 @@ +{"cpu_quota": 0.25, "memory_limit": 67108864} diff --git a/cgroup/testdata/v2-quarter-core-64m/proc/self/cgroup b/cgroup/testdata/v2-quarter-core-64m/proc/self/cgroup new file mode 100644 index 0000000..1e027b2 --- /dev/null +++ b/cgroup/testdata/v2-quarter-core-64m/proc/self/cgroup @@ -0,0 +1 @@ +0::/ diff --git a/cgroup/testdata/v2-quarter-core-64m/sys/fs/cgroup/cpu.max b/cgroup/testdata/v2-quarter-core-64m/sys/fs/cgroup/cpu.max new file mode 100644 index 0000000..6fe3458 --- /dev/null +++ b/cgroup/testdata/v2-quarter-core-64m/sys/fs/cgroup/cpu.max @@ -0,0 +1 @@ +25000 100000 diff --git a/cgroup/testdata/v2-quarter-core-64m/sys/fs/cgroup/memory.max b/cgroup/testdata/v2-quarter-core-64m/sys/fs/cgroup/memory.max new file mode 100644 index 0000000..e6c6862 --- /dev/null +++ b/cgroup/testdata/v2-quarter-core-64m/sys/fs/cgroup/memory.max @@ -0,0 +1 @@ +67108864 diff --git a/cgroup/testdata/v2-two-cores-256m/expected.json b/cgroup/testdata/v2-two-cores-256m/expected.json new file mode 100644 index 0000000..04ce067 --- /dev/null +++ b/cgroup/testdata/v2-two-cores-256m/expected.json @@ -0,0 +1 @@ +{"cpu_quota": 2.0, "memory_limit": 268435456} diff --git a/cgroup/testdata/v2-two-cores-256m/proc/self/cgroup b/cgroup/testdata/v2-two-cores-256m/proc/self/cgroup new file mode 100644 index 0000000..1e027b2 --- /dev/null +++ b/cgroup/testdata/v2-two-cores-256m/proc/self/cgroup @@ -0,0 +1 @@ +0::/ diff --git a/cgroup/testdata/v2-two-cores-256m/sys/fs/cgroup/cpu.max b/cgroup/testdata/v2-two-cores-256m/sys/fs/cgroup/cpu.max new file mode 100644 index 0000000..9685656 --- /dev/null +++ b/cgroup/testdata/v2-two-cores-256m/sys/fs/cgroup/cpu.max @@ -0,0 +1 @@ +200000 100000 diff --git a/cgroup/testdata/v2-two-cores-256m/sys/fs/cgroup/memory.max b/cgroup/testdata/v2-two-cores-256m/sys/fs/cgroup/memory.max new file mode 100644 index 0000000..853f47e --- /dev/null +++ b/cgroup/testdata/v2-two-cores-256m/sys/fs/cgroup/memory.max @@ -0,0 +1 @@ +268435456 |