Skip to content

Commit 74b2a4b

Browse files
committed
Enable cgroup controllers in the parent before creating child cgroups
A child cgroup has no memory.max or cpu.max until the parent lists the controller in cgroup.subtree_control, and a private cgroup namespace root starts empty, so every nested-cgroup scenario aborted before reaching the payload. The launchers now enable the controllers, relocating the leaf process first where the no-internal-process rule requires it, and a failed probe drops its layer instead of killing the run. The unit suite also now runs in CI. It was unreferenced by any workflow while the end-to-end jobs are red by design, leaving nothing that could report green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BEZUSri3baWU58Ge1kAVnT
1 parent c6a3b31 commit 74b2a4b

35 files changed

Lines changed: 1605 additions & 510 deletions

.github/workflows/e2e-linux.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,21 @@ jobs:
4646
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.23.0/kind-linux-amd64
4747
chmod +x ./kind
4848
sudo mv ./kind /usr/local/bin/kind
49-
kind create cluster --wait 120s
49+
# Mount the checked-out repo into the node so the pod
50+
# KubernetesLauncher schedules can hostPath-mount it in turn
51+
# (see tests/e2e/launchers/kubernetes.py) -- without this the
52+
# pod's image has no repo, no PYTHONPATH, and no psutil, and
53+
# every kind scenario is a permanent infra error.
54+
{
55+
echo "kind: Cluster"
56+
echo "apiVersion: kind.x-k8s.io/v1alpha4"
57+
echo "nodes:"
58+
echo "- role: control-plane"
59+
echo " extraMounts:"
60+
echo " - hostPath: ${GITHUB_WORKSPACE}"
61+
echo " containerPath: /host-repo"
62+
} > kind-config.yaml
63+
kind create cluster --config kind-config.yaml --wait 120s
5064
;;
5165
kubectl)
5266
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"

.github/workflows/e2e.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@ on:
2323
workflow_dispatch:
2424

2525
jobs:
26+
# The only job in this workflow that is meant to be, and stay, green: the
27+
# registry-integrity oracle, the composed-minimum math, the composition/argv
28+
# regressions, the CALIBRATION.md-vs-registry cross-check, and the workflow
29+
# lint (138+ tests across tests/unit) never depend on a docker/cgroup2/
30+
# systemd/Windows/macOS runtime, so they run unconditionally on every push
31+
# and PR -- unlike the e2e jobs below, which are expected to be red until
32+
# the real sensor implementation lands. This job must never gain
33+
# `continue-on-error` or any other soft-fail wrapper: with the e2e jobs
34+
# permanently red by design, this is the only signal a broken registry edit
35+
# or composition regression has to surface at all.
36+
unit:
37+
runs-on: ubuntu-latest
38+
steps:
39+
- uses: actions/checkout@v4
40+
- uses: actions/setup-python@v5
41+
with:
42+
python-version: "3.11"
43+
- run: pip install -e ".[test]"
44+
- run: python -m pytest tests/unit -v
45+
2646
emit-matrix:
2747
runs-on: ubuntu-latest
2848
outputs:

README.md

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,72 @@
11
# cgroups-sensor
2-
Utility functions to measure resource limits from cgroups in scenarios where psutils is not sufficient.
2+
3+
Utility functions to measure the resource limits actually enforced on the
4+
current process, in scenarios where `psutil` is not sufficient (it reports
5+
host capacity, not a cgroup limit, a container `--memory`/`--cpus` flag, a
6+
POSIX rlimit, or a Windows Job Object).
7+
8+
## Public contract
9+
10+
```python
11+
from cgroups_sensor import measure_max_available_memory, measure_max_available_cpu
12+
13+
measure_max_available_memory() # -> int, bytes
14+
measure_max_available_cpu() # -> int, millicores (1000 == one full core)
15+
```
16+
17+
- **`measure_max_available_memory()`** returns the maximum memory, in bytes,
18+
this process may use.
19+
- **`measure_max_available_cpu()`** returns the maximum CPU this process may
20+
use, in integer **millicores** -- one full CPU core is `1000`; a `0.5`-core
21+
quota (e.g. Docker's `--cpus=0.5`) is `500`.
22+
- **`0` means "no mechanism is actually enforcing a ceiling on this process
23+
right now"** -- never host capacity, never `None`, never "unknown".
24+
- When more than one mechanism constrains the same process at once (an
25+
rlimit, a cgroup, and a container flag can all apply simultaneously), both
26+
helpers report the **minimum** of the ceilings actually being enforced --
27+
the real, effective ceiling the OS applies, not merely the innermost
28+
mechanism or the last one applied.
29+
30+
This contract is final and will not change when the implementation behind it
31+
does.
32+
33+
## Current status: placeholder implementation
34+
35+
The two functions above are implemented today as thin `psutil`-only
36+
placeholders (see `src/cgroups_sensor/__init__.py`) -- they are **not** yet
37+
cgroup/rlimit/Job-Object-aware. They are expected to return the wrong number
38+
for most container/cgroup/systemd/Kubernetes/Windows-Job-Object scenarios,
39+
because `psutil` itself reports host-scoped values in those cases. That gap
40+
is intentional and is exactly what `tests/e2e` exists to make visible, not to
41+
paper over -- do not read a passing placeholder value as evidence the real
42+
sensor is done.
43+
44+
## Test suite
45+
46+
- **`tests/unit`** -- fast, dependency-light tests of the scenario registry,
47+
the composed-expected-value math, launcher argv construction, and workflow
48+
generation. These never touch a real cgroup/docker/Windows API and run on
49+
any machine: `python -m pytest tests/unit`.
50+
- **`tests/e2e`** -- an end-to-end matrix that imposes real OS-level resource
51+
limits (rlimits, cgroups, `systemd-run --scope`, `docker`/`podman run`,
52+
Kubernetes pods, Windows Job Objects, Windows containers) across
53+
Linux/macOS/Windows GitHub-hosted runners, measures both helpers under each
54+
limit, and asserts the real, composed ceiling. This suite is **expected to
55+
be red** against the current placeholder implementation -- see "Current
56+
status" above -- and stays red until the real sensor lands. It only
57+
executes meaningfully on a matching GitHub-hosted runner; running it
58+
locally mostly produces skips (`pytest tests/e2e --collect-only` is the
59+
right way to sanity-check it outside CI).
60+
- **`tests/e2e/CALIBRATION.md`** is the durable record of which memory
61+
scenarios' expected values were validated, before being committed, by a
62+
one-time build-time over-allocation experiment (never shipped as running
63+
code) -- see that file for the per-scenario evidence.
64+
65+
## CI
66+
67+
`.github/workflows/e2e.yml` dispatches: a `unit` job (always green, runs
68+
`tests/unit` on every push/PR) plus a matrix generated from the scenario
69+
registry (`tests/e2e/scenarios.py --emit-matrix`), routed to one reusable
70+
workflow per runner family (`e2e-linux.yml`, `e2e-macos.yml`,
71+
`e2e-windows.yml`). Adding a scenario is a one-row registry edit; no workflow
72+
file needs to change.

tests/e2e/CALIBRATION.md

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,14 @@ OOM-kill (the cgroup-style signature) or an in-process refusal --
1717
`MemoryError` on Linux/macOS, `ERROR_NOT_ENOUGH_MEMORY`/commit failure on
1818
Windows -- (the rlimit/Job-Object-style signature).
1919

20-
**What could and could not be calibrated in this build environment**, per the
21-
orchestrator's pre-dispatch feasibility probe and my own follow-up
22-
experiments: `RLIMIT_AS` genuinely binds in this sandbox (a plain
23-
`setrlimit(2)` call, no cgroup2/docker/systemd/Windows/macOS needed) and was
24-
exercised directly. Every mechanism requiring cgroup v2, a docker/podman
25-
daemon, systemd as PID 1, a Kubernetes/kind cluster, a Windows host, or a
26-
macOS host could **not** be exercised here (this build environment: Linux,
27-
`/sys/fs/cgroup` is a tmpfs not cgroup2, no docker daemon, systemd is not PID
28-
1) and is marked UNVALIDATED with the specific missing prerequisite, never
29-
fabricated.
20+
**What could and could not be calibrated in this build environment**: `RLIMIT_AS`
21+
genuinely binds in this sandbox (a plain `setrlimit(2)` call, no
22+
cgroup2/docker/systemd/Windows/macOS needed) and was exercised directly.
23+
Every mechanism requiring cgroup v2, a docker/podman daemon, systemd as PID
24+
1, a Kubernetes/kind cluster, a Windows host, or a macOS host could **not** be
25+
exercised here (this build environment: Linux, `/sys/fs/cgroup` is a tmpfs
26+
not cgroup2, no docker daemon, systemd is not PID 1) and is marked
27+
UNVALIDATED with the specific missing prerequisite, never fabricated.
3028

3129
## Calibration table
3230

@@ -42,7 +40,7 @@ fabricated.
4240
| `linux-podman-memory` | `167,772,160` B (160 MiB) if the needs-probe delegation check passes, else `0` | UNVALIDATED | No podman binary / no docker-equivalent daemon in this sandbox. |
4341
| `linux-kubernetes-memory` | `201,326,592` B (192 MiB) if the needs-probe delegation check passes, else `0` | UNVALIDATED | No docker daemon, so no kind cluster is startable here. |
4442
| `linux-stacked-outer-cgroup-tighter` | `134,217,728` B (128 MiB, the outer cgroup layer) | UNVALIDATED as a composed scenario | Its `RLIMIT_AS` (256 MiB) layer alone shares the mechanism validated above, but the *composed* expectation is governed by the tighter cgroup layer, which cannot be exercised here (no cgroup2). The whole scenario's calibration therefore cannot be completed in this environment. |
45-
| `linux-triple-stack-container-tightest` | `536,870,912` B (512 MiB, the docker layer) if the nested-cgroup probe passes | UNVALIDATED | Requires docker `--privileged --cgroupns=private` plus a writable nested cgroup2; neither is available here. |
43+
| `linux-triple-stack-container-tightest` | `536,870,912` B (512 MiB, the docker layer, unconditionally -- it is the tightest of the three layers regardless of whether the nested-cgroup probe passes, per `test_composition.py::test_all_triple_stack_variants_agree_when_probe_omitted_entirely`) | UNVALIDATED | Requires docker `--privileged --cgroupns=private` plus a writable nested cgroup2; neither is available here. |
4644
| `linux-triple-stack-nested-cgroup-tightest` | `536,870,912` B (512 MiB, the nested-cgroup layer) if its probe passes, else `805,306,368` B (768 MiB, the rlimit layer) | UNVALIDATED | Same as above. |
4745
| `linux-triple-stack-rlimit-tightest` | `536,870,912` B (512 MiB, the rlimit layer, unconditionally) | UNVALIDATED as a composed scenario | The rlimit layer's own mechanism is the one validated above, but the composed scenario as a whole (docker + nested cgroup context) cannot be exercised here. |
4846
| `macos-rlimit-as-memory` | `314,572,800` B (300 MiB), `setrlimit(RLIMIT_AS)` on macOS | UNVALIDATED | Requires a macOS host; this sandbox is Linux. The underlying POSIX `setrlimit`/`getrlimit(RLIMIT_AS)` semantics are the same call validated on Linux above, but that is supporting context, not equivalent evidence -- macOS's own allocator/kernel behavior was not independently exercised. |

tests/e2e/conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
import os
1212

13-
from tests.e2e.test_e2e import _SUMMARY_ROWS
13+
from tests.e2e.reporting import SUMMARY_ROWS
1414

1515

1616
def _render_markdown_table(rows: list[dict]) -> str:
@@ -29,8 +29,8 @@ def _render_markdown_table(rows: list[dict]) -> str:
2929

3030
def pytest_sessionfinish(session, exitstatus) -> None: # noqa: ARG001
3131
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
32-
if not summary_path or not _SUMMARY_ROWS:
32+
if not summary_path or not SUMMARY_ROWS:
3333
return
3434
with open(summary_path, "a", encoding="utf-8") as fh:
3535
fh.write("\n## cgroups-sensor e2e scenario results\n\n")
36-
fh.write(_render_markdown_table(_SUMMARY_ROWS))
36+
fh.write(_render_markdown_table(SUMMARY_ROWS))

tests/e2e/inner.py

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424

2525
SENTINEL = "CGROUPS_SENSOR_RESULT::"
2626

27+
_JobObjectExtendedLimitInformation = 9
28+
_JobObjectCpuRateControlInformation = 15
29+
_JOB_OBJECT_LIMIT_JOB_MEMORY = 0x00000200
30+
2731

2832
def _rlimits() -> dict:
2933
try:
@@ -70,8 +74,33 @@ def _read_text(path: str) -> str | None:
7074
return None
7175

7276

77+
def _cgroup_ancestors(cgroup_root: str, own_path: str) -> list[dict]:
78+
"""Every cgroup directory from this process's own cgroup up to (and
79+
including) the root, each with its own ``memory.max``/``cpu.max`` read
80+
back. A layer may be imposed on an *ancestor* of the process's own
81+
leaf cgroup (e.g. a container's own cgroup, one level above a nested
82+
cgroup created inside it) -- walking the whole chain and exact-matching
83+
a declared value against any level is what lets the structural check
84+
find "the nearest ancestor that carries the value" instead of only ever
85+
looking at this process's own immediate cgroup.
86+
"""
87+
ancestors: list[dict] = []
88+
parts = [p for p in own_path.strip("/").split("/") if p]
89+
for depth in range(len(parts), -1, -1):
90+
rel = "/".join(parts[:depth])
91+
base = os.path.join(cgroup_root, rel) if rel else cgroup_root
92+
ancestors.append(
93+
{
94+
"path": base,
95+
"memory_max": _read_text(os.path.join(base, "memory.max")),
96+
"cpu_max": _read_text(os.path.join(base, "cpu.max")),
97+
}
98+
)
99+
return ancestors
100+
101+
73102
def _cgroup_facts() -> dict:
74-
facts: dict = {"version": None, "memory_max": None, "cpu_max": None, "controllers": None}
103+
facts: dict = {"version": None, "memory_max": None, "cpu_max": None, "controllers": None, "ancestors": []}
75104
cgroup_root = "/sys/fs/cgroup"
76105
if not os.path.isdir(cgroup_root):
77106
return facts
@@ -82,12 +111,85 @@ def _cgroup_facts() -> dict:
82111
facts["memory_max"] = _read_text(os.path.join(base, "memory.max"))
83112
facts["cpu_max"] = _read_text(os.path.join(base, "cpu.max"))
84113
facts["controllers"] = _read_text(os.path.join(base, "cgroup.controllers"))
114+
facts["ancestors"] = _cgroup_ancestors(cgroup_root, own_path)
85115
elif os.path.isdir(os.path.join(cgroup_root, "memory")):
86116
facts["version"] = 1
87117
facts["memory_max"] = _read_text(os.path.join(cgroup_root, "memory", "memory.limit_in_bytes"))
88118
return facts
89119

90120

121+
def _query_job_object_limits() -> dict:
122+
"""Read back the *actual* limit state of the Job Object this process is
123+
in via ``QueryInformationJobObject`` -- not just whether it is in *some*
124+
job (``IsProcessInJob``, which says nothing about what, if anything, that
125+
job enforces). Pure ``ctypes`` (no pywin32 dependency for the payload
126+
itself, which may run inside a bare container image with no pywin32
127+
installed): the struct layouts below are stable, decades-old Win32 ABI,
128+
unaffected by which Python/OS build is running.
129+
"""
130+
import ctypes
131+
132+
class _IoCounters(ctypes.Structure):
133+
_fields_ = [
134+
("ReadOperationCount", ctypes.c_ulonglong),
135+
("WriteOperationCount", ctypes.c_ulonglong),
136+
("OtherOperationCount", ctypes.c_ulonglong),
137+
("ReadTransferCount", ctypes.c_ulonglong),
138+
("WriteTransferCount", ctypes.c_ulonglong),
139+
("OtherTransferCount", ctypes.c_ulonglong),
140+
]
141+
142+
class _BasicLimitInformation(ctypes.Structure):
143+
_fields_ = [
144+
("PerProcessUserTimeLimit", ctypes.c_int64),
145+
("PerJobUserTimeLimit", ctypes.c_int64),
146+
("LimitFlags", ctypes.c_uint32),
147+
("MinimumWorkingSetSize", ctypes.c_size_t),
148+
("MaximumWorkingSetSize", ctypes.c_size_t),
149+
("ActiveProcessLimit", ctypes.c_uint32),
150+
("Affinity", ctypes.c_void_p),
151+
("PriorityClass", ctypes.c_uint32),
152+
("SchedulingClass", ctypes.c_uint32),
153+
]
154+
155+
class _ExtendedLimitInformation(ctypes.Structure):
156+
_fields_ = [
157+
("BasicLimitInformation", _BasicLimitInformation),
158+
("IoInfo", _IoCounters),
159+
("ProcessMemoryLimit", ctypes.c_size_t),
160+
("JobMemoryLimit", ctypes.c_size_t),
161+
("PeakProcessMemoryUsed", ctypes.c_size_t),
162+
("PeakJobMemoryUsed", ctypes.c_size_t),
163+
]
164+
165+
class _CpuRateControlInformation(ctypes.Structure):
166+
_fields_ = [("ControlFlags", ctypes.c_uint32), ("CpuRate", ctypes.c_uint32)]
167+
168+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
169+
facts: dict = {}
170+
171+
ext_info = _ExtendedLimitInformation()
172+
# QueryInformationJobObject accepts a NULL job handle to mean "the job
173+
# associated with the calling process" -- no separate handle needed here.
174+
ok = kernel32.QueryInformationJobObject(
175+
None, _JobObjectExtendedLimitInformation, ctypes.byref(ext_info), ctypes.sizeof(ext_info), None,
176+
)
177+
if ok:
178+
limit_flags = ext_info.BasicLimitInformation.LimitFlags
179+
facts["memory_limit_flag_set"] = bool(limit_flags & _JOB_OBJECT_LIMIT_JOB_MEMORY)
180+
facts["job_memory_limit"] = int(ext_info.JobMemoryLimit)
181+
182+
cpu_info = _CpuRateControlInformation()
183+
ok = kernel32.QueryInformationJobObject(
184+
None, _JobObjectCpuRateControlInformation, ctypes.byref(cpu_info), ctypes.sizeof(cpu_info), None,
185+
)
186+
if ok:
187+
facts["cpu_rate_control_flags"] = int(cpu_info.ControlFlags)
188+
facts["cpu_rate"] = int(cpu_info.CpuRate)
189+
190+
return facts
191+
192+
91193
def _windows_job_facts() -> dict:
92194
if platform.system() != "Windows":
93195
return {"in_job_object": None}
@@ -99,7 +201,10 @@ def _windows_job_facts() -> dict:
99201
ok = kernel32.IsProcessInJob(kernel32.GetCurrentProcess(), None, ctypes.byref(result))
100202
if not ok:
101203
return {"in_job_object": None, "error": "IsProcessInJob call failed"}
102-
return {"in_job_object": bool(result.value)}
204+
facts: dict = {"in_job_object": bool(result.value)}
205+
if facts["in_job_object"]:
206+
facts.update(_query_job_object_limits())
207+
return facts
103208
except Exception as exc: # pragma: no cover - Windows-only path
104209
return {"in_job_object": None, "error": str(exc)}
105210

@@ -111,6 +216,7 @@ def gather_platform_facts() -> dict:
111216
"pid": os.getpid(),
112217
"rlimits": _rlimits(),
113218
"cpu_affinity": _cpu_affinity(),
219+
"os_cpu_count": os.cpu_count(),
114220
"cgroup": _cgroup_facts(),
115221
"windows_job": _windows_job_facts(),
116222
}

tests/e2e/launchers/base.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,31 @@ class Launcher(ABC):
7171
#: needs-probe layer.
7272
probe_key: str | None = None
7373

74+
#: Set by a launcher whose structural probe can only be evaluated from
75+
#: inside the same execution context the payload itself runs in (e.g. a
76+
#: probe that must run inside a container's own mount/cgroup namespace,
77+
#: not from the outside). When set, the harness never calls `probe()` for
78+
#: this launcher; instead it checks whether this exact string appears in
79+
#: the combined stdout+stderr of the same invocation that ran the payload
80+
#: (see `probe_passed_in_output`). None (the default) means the probe is
81+
#: evaluated the ordinary way, via a standalone `probe()` call before the
82+
#: payload runs.
83+
probe_marker: str | None = None
84+
7485
def probe(self) -> ProbeResult:
75-
"""Structural presence/binding check. Default: nothing to probe."""
86+
"""Structural presence/binding check, called standalone before the
87+
payload runs. Only called when `probe_marker` is None -- a launcher
88+
that sets `probe_marker` reports its probe result in-band instead (see
89+
`probe_passed_in_output`) and does not need to override this. Default:
90+
nothing to probe."""
7691
return ProbeResult(True, "no structural probe required for this layer type")
7792

93+
def probe_passed_in_output(self, combined_output: str) -> bool:
94+
"""Whether an in-band probe (`probe_marker` set) reported success,
95+
judged from the combined stdout+stderr of the same run that executed
96+
the payload. Only meaningful when `probe_marker` is not None."""
97+
return self.probe_marker is not None and self.probe_marker in combined_output
98+
7899
@abstractmethod
79100
def run(self, inner_argv: Sequence[str]) -> RunResult:
80101
"""Execute inner_argv under this layer's constraint and capture output."""

0 commit comments

Comments
 (0)