aboutsummaryrefslogtreecommitdiff
path: root/cgroup
diff options
context:
space:
mode:
Diffstat (limited to 'cgroup')
-rw-r--r--cgroup/cgroup.go175
-rw-r--r--cgroup/cgroup_test.go164
-rw-r--r--cgroup/testdata/v2-no-limit/expected.json1
-rw-r--r--cgroup/testdata/v2-no-limit/proc/self/cgroup1
-rw-r--r--cgroup/testdata/v2-no-limit/sys/fs/cgroup/cpu.max1
-rw-r--r--cgroup/testdata/v2-no-limit/sys/fs/cgroup/memory.max1
-rw-r--r--cgroup/testdata/v2-quarter-core-64m/expected.json1
-rw-r--r--cgroup/testdata/v2-quarter-core-64m/proc/self/cgroup1
-rw-r--r--cgroup/testdata/v2-quarter-core-64m/sys/fs/cgroup/cpu.max1
-rw-r--r--cgroup/testdata/v2-quarter-core-64m/sys/fs/cgroup/memory.max1
-rw-r--r--cgroup/testdata/v2-two-cores-256m/expected.json1
-rw-r--r--cgroup/testdata/v2-two-cores-256m/proc/self/cgroup1
-rw-r--r--cgroup/testdata/v2-two-cores-256m/sys/fs/cgroup/cpu.max1
-rw-r--r--cgroup/testdata/v2-two-cores-256m/sys/fs/cgroup/memory.max1
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