From 63fbc25eafd9786eaeb1c75327b9e487fad8db31 Mon Sep 17 00:00:00 2001 From: linrunlong Date: Fri, 23 Oct 2026 00:42:21 +0800 Subject: [PATCH] cgroup2: expose CPU quota and period inspection Add Manager.CPUQuota to read the current cgroup's cpu.max through the public API. Return the quota and period in microseconds, using the existing parser's math.MaxInt64 representation for an unlimited quota. Require both fields on reads and preserve file and parsing errors without changing the shared parser's write behavior. Document that ancestor limits are not combined. Cover finite and unlimited quotas, configuration updates, malformed content, read errors, and kernel readback. Add a README usage example. Fixes #377 Assisted-by: Codex Signed-off-by: linrunlong --- README.md | 24 ++++++++ cgroup2/cpuv2_test.go | 126 ++++++++++++++++++++++++++++++++++++++++++ cgroup2/manager.go | 24 ++++++++ 3 files changed, 174 insertions(+) diff --git a/README.md b/README.md index c7f37c61..e365eba7 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,30 @@ if err != nil { } ``` +### Inspect the CPU quota and period + +`CPUQuota` reads the current cgroup's `cpu.max` and returns the quota and period +in microseconds. A quota of `math.MaxInt64` means no limit is configured for this +cgroup. Ancestor cgroups can impose additional limits; these are not included. +An absent `cpu.max` (for example, at the hierarchy root or with the CPU controller +disabled) returns an error, rather than an unlimited quota. + +```go +m, err := cgroup2.Load("/my-group") +if err != nil { + return err +} +quota, period, err := m.CPUQuota() +if err != nil { + return err +} +if quota == math.MaxInt64 { + fmt.Println("No CPU quota configured for this cgroup") +} else { + fmt.Printf("CPU quota: %d microseconds per %d microseconds\n", quota, period) +} +``` + ### Delete a cgroup ```go diff --git a/cgroup2/cpuv2_test.go b/cgroup2/cpuv2_test.go index 92d157d0..e685d06f 100644 --- a/cgroup2/cpuv2_test.go +++ b/cgroup2/cpuv2_test.go @@ -20,13 +20,128 @@ import ( "fmt" "math" "os" + "path/filepath" "strconv" + "syscall" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +func TestCPUQuota(t *testing.T) { + root := t.TempDir() + group := filepath.Join(root, "test") + require.NoError(t, os.Mkdir(group, 0o755)) + // The parent's limit must not be substituted for the group's own quota. + require.NoError(t, os.WriteFile(filepath.Join(root, "cpu.max"), []byte("50000 100000\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(group, "cpu.max"), []byte("200000 100000\n"), 0o644)) + + c, err := Load("/test", WithMountpoint(root)) + require.NoError(t, err) + quota, period, err := c.CPUQuota() + require.NoError(t, err) + assert.Equal(t, int64(200000), quota) + assert.Equal(t, uint64(100000), period) + + // Re-read the configuration after an update, including a non-default period. + require.NoError(t, os.WriteFile(filepath.Join(group, "cpu.max"), []byte("max 250000\n"), 0o644)) + quota, period, err = c.CPUQuota() + require.NoError(t, err) + assert.Equal(t, int64(math.MaxInt64), quota) + assert.Equal(t, uint64(250000), period) +} + +func TestCPUQuotaWhitespace(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "cpu.max"), []byte(" \t42000 \t4200\n"), 0o644)) + c, err := Load("/", WithMountpoint(root)) + require.NoError(t, err) + + quota, period, err := c.CPUQuota() + require.NoError(t, err) + assert.Equal(t, int64(42000), quota) + assert.Equal(t, uint64(4200), period) +} + +func TestCPUQuotaInvalidContent(t *testing.T) { + for _, tc := range []struct { + name string + content string + wantErr error + }{ + {name: "empty", content: "", wantErr: ErrInvalidFormat}, + {name: "whitespace", content: " \n\t", wantErr: ErrInvalidFormat}, + {name: "missing period", content: "200000\n", wantErr: ErrInvalidFormat}, + {name: "unlimited missing period", content: "max\n", wantErr: ErrInvalidFormat}, + {name: "extra field", content: "200000 100000 1\n", wantErr: ErrInvalidFormat}, + {name: "invalid quota", content: "invalid 100000\n", wantErr: strconv.ErrSyntax}, + {name: "invalid period", content: "200000 invalid\n", wantErr: strconv.ErrSyntax}, + {name: "negative period", content: "200000 -1\n", wantErr: strconv.ErrSyntax}, + {name: "quota overflow", content: "9223372036854775808 100000\n", wantErr: strconv.ErrRange}, + {name: "period overflow", content: "max 18446744073709551616\n", wantErr: strconv.ErrRange}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "cpu.max") + require.NoError(t, os.WriteFile(path, []byte(tc.content), 0o644)) + c, err := Load("/", WithMountpoint(root)) + require.NoError(t, err) + + quota, period, err := c.CPUQuota() + require.ErrorIs(t, err, tc.wantErr) + assert.Contains(t, err.Error(), path) + assert.Zero(t, quota) + assert.Zero(t, period) + }) + } +} + +func TestCPUQuotaReadErrors(t *testing.T) { + for _, tc := range []struct { + name string + setup func(string) error + wantErr error + }{ + {name: "missing file", wantErr: os.ErrNotExist}, + { + name: "permission denied", + setup: func(path string) error { + return os.WriteFile(path, []byte("max 100000\n"), 0) + }, + wantErr: os.ErrPermission, + }, + { + name: "directory", + setup: func(path string) error { + return os.Mkdir(path, 0o755) + }, + wantErr: syscall.EISDIR, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.wantErr == os.ErrPermission && os.Geteuid() == 0 { + t.Skip("root can read files without read permission") + } + root := t.TempDir() + path := filepath.Join(root, "cpu.max") + if tc.setup != nil { + require.NoError(t, tc.setup(path)) + } + c, err := Load("/", WithMountpoint(root)) + require.NoError(t, err) + + quota, period, err := c.CPUQuota() + require.ErrorIs(t, err, tc.wantErr) + var pathErr *os.PathError + require.ErrorAs(t, err, &pathErr) + assert.Equal(t, path, pathErr.Path) + assert.Zero(t, quota) + assert.Zero(t, period) + }) + } +} + func TestCgroupv2CpuStats(t *testing.T) { checkCgroupMode(t) group := "/cpu-test-cg" @@ -54,6 +169,17 @@ func TestCgroupv2CpuStats(t *testing.T) { checkFileContent(t, c.path, "cpu.max", "10000 8000") checkFileContent(t, c.path, "cpuset.cpus", "0") checkFileContent(t, c.path, "cpuset.mems", "0") + + gotQuota, gotPeriod, err := c.CPUQuota() + require.NoError(t, err) + assert.Equal(t, quota, gotQuota) + assert.Equal(t, period, gotPeriod) + + require.NoError(t, c.Update(&Resources{CPU: &CPU{Max: NewCPUMax(nil, &period)}})) + gotQuota, gotPeriod, err = c.CPUQuota() + require.NoError(t, err) + assert.Equal(t, int64(math.MaxInt64), gotQuota) + assert.Equal(t, period, gotPeriod) } func TestSystemdCgroupCpuController(t *testing.T) { diff --git a/cgroup2/manager.go b/cgroup2/manager.go index 8638ab92..d85e8570 100644 --- a/cgroup2/manager.go +++ b/cgroup2/manager.go @@ -311,6 +311,30 @@ func (c *Manager) Controllers() ([]string, error) { return strings.Fields(string(b)), nil } +// CPUQuota returns the CPU quota and period configured for this cgroup, in +// microseconds. A quota of math.MaxInt64 indicates no limit. Limits imposed by +// ancestor cgroups are not considered. +// +// An error is returned if cpu.max cannot be read or parsed. In particular, the +// file may not exist at the hierarchy root or when the CPU controller is disabled. +func (c *Manager) CPUQuota() (int64, uint64, error) { + path := filepath.Join(c.path, "cpu.max") + b, err := os.ReadFile(path) + if err != nil { + return 0, 0, err + } + fields := strings.Fields(string(b)) + // Reads always contain both values, unlike writes which may omit the period. + if len(fields) != 2 { + return 0, 0, fmt.Errorf("failed to parse %s: %w", path, ErrInvalidFormat) + } + quota, period, err := CPUMax(strings.Join(fields, " ")).extractQuotaAndPeriod() + if err != nil { + return 0, 0, fmt.Errorf("failed to parse %s: %w", path, err) + } + return quota, period, nil +} + func (c *Manager) Update(resources *Resources) error { return setResources(c.path, resources) }