aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMichele Bertasi <405934+mbrt@users.noreply.github.com>2026-03-26 22:19:14 +0100
committerGitHub <noreply@github.com>2026-03-26 14:19:14 -0700
commit298ed2a6c44cde90b4262b884169c53b8deda508 (patch)
tree1838fd3e8ca9913292562ee854d633288e6dfced
parentea916da7fa9844cc3da608e75510f478c7b09f7d (diff)
Add support for cgroup limits (#443)
* Add cgroup package * Refactor procGgroup * Add testdata generation * Add v1 testdata generation * Move scripts around * Add integration test in CI * Remove cgroup v1 * Move to cgroup struct * Remove half-core test as it's redundant
-rw-r--r--.github/workflows/ci.yml20
-rw-r--r--Makefile5
-rw-r--r--actions/config.go30
-rwxr-xr-xbin/gen-cgroup-testdata48
-rwxr-xr-xbin/snapshot-cgroup42
-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
19 files changed, 493 insertions, 3 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e1975e0..973666c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -106,6 +106,26 @@ jobs:
# make test
# make test-teardown
+ test-cgroup-integration:
+ name: Test cgroup integration
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v2
+ with:
+ go-version: '1.25'
+ - name: Build test binary
+ run: go test -c -o cgroup.test ./cgroup/
+ - name: Run cgroup integration test
+ run: |
+ docker run --rm \
+ --cpus=0.5 --memory=128m \
+ -e CGROUP_EXPECTED_CPU_QUOTA=0.5 \
+ -e CGROUP_EXPECTED_MEMORY_LIMIT=134217728 \
+ -v "$PWD/cgroup.test":/cgroup.test:ro \
+ debian:bookworm-slim \
+ /cgroup.test -test.run TestIntegrationCgroupLimits -test.v
+
run-cli-tests:
name: Run command-line interface tests
runs-on: ubuntu-latest
diff --git a/Makefile b/Makefile
index a9cc968..d542248 100644
--- a/Makefile
+++ b/Makefile
@@ -115,6 +115,11 @@ lint: $(BIN)/staticcheck $(BIN)/misspell
clean:
rm -f $(BIN)/$(NAME) $(PAM_MODULE) $(TOOLS) coverage.out $(COVERAGE_FILES) $(PAM_CONFIG)
+###### Cgroup testdata ######
+.PHONY: gen-cgroup-testdata
+gen-cgroup-testdata:
+ bin/gen-cgroup-testdata
+
###### Go tests ######
.PHONY: test test-setup test-teardown
diff --git a/actions/config.go b/actions/config.go
index 7c7c0e6..bd4ae28 100644
--- a/actions/config.go
+++ b/actions/config.go
@@ -24,6 +24,7 @@ import (
"bytes"
"fmt"
"log"
+ "math"
"os"
"runtime"
"time"
@@ -31,6 +32,7 @@ import (
"golang.org/x/sys/unix"
"google.golang.org/protobuf/proto"
+ "github.com/google/fscrypt/cgroup"
"github.com/google/fscrypt/crypto"
"github.com/google/fscrypt/filesystem"
"github.com/google/fscrypt/metadata"
@@ -186,8 +188,9 @@ func getConfig() (*metadata.Config, error) {
func getHashingCosts(target time.Duration) (*metadata.HashingCosts, error) {
log.Printf("Finding hashing costs that take %v\n", target)
- // Start out with the minimal possible costs that use all the CPUs.
- parallelism := int64(runtime.NumCPU())
+ // Start out with the minimal possible costs that use all the available
+ // CPUs, respecting cgroup limits when present.
+ parallelism := int64(effectiveCPUCount())
// golang.org/x/crypto/argon2 only supports parallelism up to 255.
// For compatibility, don't use more than that amount.
if parallelism > metadata.MaxParallelism {
@@ -248,9 +251,25 @@ func getHashingCosts(target time.Duration) (*metadata.HashingCosts, error) {
}
}
+// effectiveCPUCount returns the number of CPUs available to this process,
+// taking cgroup limits into account. Falls back to runtime.NumCPU() when
+// cgroup information is unavailable.
+func effectiveCPUCount() int {
+ cg, err := cgroup.New()
+ if err != nil {
+ return runtime.NumCPU()
+ }
+ quota, err := cg.CPUQuota()
+ if err != nil || quota <= 0 {
+ return runtime.NumCPU()
+ }
+ cpus := int(math.Ceil(quota))
+ return min(cpus, runtime.NumCPU())
+}
+
// memoryBytesLimit returns the maximum amount of memory we will use for
// passphrase hashing. This will never be more than a reasonable maximum (for
-// compatibility) or an 8th the available system RAM.
+// compatibility) or an 8th the available RAM (considering cgroup limits).
func memoryBytesLimit() int64 {
// The sysinfo syscall only fails if given a bad address
var info unix.Sysinfo_t
@@ -258,6 +277,11 @@ func memoryBytesLimit() int64 {
util.NeverError(err)
totalRAMBytes := int64(info.Totalram)
+ if cg, err := cgroup.New(); err == nil {
+ if cgroupMem, err := cg.MemoryLimit(); err == nil && cgroupMem > 0 {
+ totalRAMBytes = util.MinInt64(totalRAMBytes, cgroupMem)
+ }
+ }
return util.MinInt64(totalRAMBytes/8, maxMemoryBytes)
}
diff --git a/bin/gen-cgroup-testdata b/bin/gen-cgroup-testdata
new file mode 100755
index 0000000..3d9215b
--- /dev/null
+++ b/bin/gen-cgroup-testdata
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+#
+# gen-cgroup-testdata - Generate cgroup testdata by running
+# bin/snapshot-cgroup inside Docker containers with known resource limits.
+#
+# Usage: gen-cgroup-testdata
+#
+# Prerequisites: Docker on a host running cgroup v2.
+#
+# Each testdata directory contains:
+# expected.json - {"cpu_quota": <float>, "memory_limit": <int>}
+# proc/ - snapshot of /proc/self/cgroup
+# sys/ - snapshot of cgroup control files
+
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+testdata="cgroup/testdata"
+snapshot_script="bin/snapshot-cgroup"
+
+generate() {
+ local name="$1" cpu_quota="$2" memory_limit="$3"
+ shift 3
+ local outdir="$testdata/$name"
+
+ echo "Generating $name..."
+ rm -rf "$outdir"
+ mkdir -p "$outdir"
+
+ docker run --rm \
+ --user "$(id -u):$(id -g)" \
+ "$@" \
+ -v "$PWD/$snapshot_script:/snapshot:ro" \
+ -v "$PWD/$outdir:/out" \
+ debian:bookworm-slim \
+ /snapshot /out
+
+ cat > "$outdir/expected.json" <<EOF
+{"cpu_quota": $cpu_quota, "memory_limit": $memory_limit}
+EOF
+}
+
+generate "v2-two-cores-256m" 2.0 268435456 --cpus=2 --memory=256m
+generate "v2-quarter-core-64m" 0.25 67108864 --cpus=0.25 --memory=64m
+generate "v2-no-limit" null null
+
+echo "v2 testdata generated successfully."
diff --git a/bin/snapshot-cgroup b/bin/snapshot-cgroup
new file mode 100755
index 0000000..5fed85f
--- /dev/null
+++ b/bin/snapshot-cgroup
@@ -0,0 +1,42 @@
+#!/usr/bin/env bash
+#
+# snapshot-cgroup - Copy cgroup v2 files from the live system into a
+# directory tree suitable for use with TestIntegrationCgroupLimits.
+#
+# Usage: snapshot-cgroup <output-dir>
+#
+# The script reads /proc/self/cgroup to find the v2 group path and copies
+# exactly the files that the cgroup package needs:
+#
+# proc/self/cgroup
+# sys/fs/cgroup/<group>/cpu.max
+# sys/fs/cgroup/<group>/memory.max
+
+set -euo pipefail
+
+if [[ $# -ne 1 ]]; then
+ echo "Usage: $0 <output-dir>" >&2
+ exit 1
+fi
+
+out="$1"
+mkdir -p "$out"
+
+copy_file() {
+ local src="$1" dst="$out/$2"
+ mkdir -p "$(dirname "$dst")"
+ cp "$src" "$dst"
+}
+
+copy_file /proc/self/cgroup proc/self/cgroup
+
+group=$(awk -F: '/^0::/ { print $3 }' /proc/self/cgroup)
+cgdir="/sys/fs/cgroup${group}"
+
+for f in cpu.max memory.max; do
+ if [[ -f "$cgdir/$f" ]]; then
+ copy_file "$cgdir/$f" "sys/fs/cgroup${group}/$f"
+ fi
+done
+
+echo "Snapshot written to $out"
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