diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1975e0c..973666ce 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 a9cc9687..d5422484 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 7c7c0e61..bd4ae284 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 00000000..3d9215bb --- /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": , "memory_limit": } +# 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" < +# +# 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//cpu.max +# sys/fs/cgroup//memory.max + +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&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 00000000..dff81386 --- /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::". +// +// 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 00000000..f1bc8f9b --- /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 00000000..3a6d7edc --- /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 00000000..1e027b2a --- /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 00000000..1c1d3e7c --- /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 00000000..355295a0 --- /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 00000000..41ec96fa --- /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 00000000..1e027b2a --- /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 00000000..6fe34582 --- /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 00000000..e6c68622 --- /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 00000000..04ce067c --- /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 00000000..1e027b2a --- /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 00000000..96856569 --- /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 00000000..853f47e7 --- /dev/null +++ b/cgroup/testdata/v2-two-cores-256m/sys/fs/cgroup/memory.max @@ -0,0 +1 @@ +268435456