Skip to content

Commit e7cb8f1

Browse files
saltas888claude
andcommitted
fix(telemetry): resolve the process cgroup path and honor ancestor limits [INFP-589]
The v2 limit files were read only at /sys/fs/cgroup, which is correct under a private cgroup namespace (the modern Docker/K8s default, where the apparent root is the container's cgroup) but wrong without one — older runtimes on v2, cgroupns: host, systemd services with unit limits. The v2 root cgroup carries no cpu.max/memory.max, so a limited component reported no CPU assignment and the whole host's memory as its capacity, breaking the licensing audit in exactly those environments. The reader now resolves its own cgroup from the 0:: line of /proc/self/cgroup and consults every level up to the root, taking the most restrictive limit; memory usage is read at the level holding the effective limit, since an ancestor limit is shared with siblings. Resolution failures fall back to the previous root-only read, and v1 keeps the root read (runtimes bind-mount the container's own v1 controllers there). Limits above a private namespace root remain invisible by construction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 942df79 commit e7cb8f1

3 files changed

Lines changed: 279 additions & 42 deletions

File tree

backend/infrahub/telemetry/resources.py

Lines changed: 101 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22
33
Each process reports the logical CPU count it can see, the CPU quota enforced on
44
it by its container control group (``None`` when nothing is enforced), and its
5-
memory capacity and free memory. The container control group is consulted first
6-
(cgroup v2, then v1); a host without one — a developer laptop, an unusual mount —
5+
memory capacity and free memory. The process's own control group is resolved
6+
from ``/proc/self/cgroup`` and every level up to the root is consulted, because
7+
a limit may be enforced on an ancestor — under a private cgroup namespace (the
8+
modern container default) that path collapses to the apparent root, while a host
9+
namespace or a systemd service exposes the full hierarchy. cgroup v2 is read
10+
first, then v1; a host with neither — a developer laptop, an unusual mount —
711
falls back to the whole-host figures from psutil and reports no CPU quota.
12+
Limits above a private namespace root (for example a pod-level limit when the
13+
container itself has none) are invisible from inside and cannot be reported.
814
915
The values that cannot change for the lifetime of a process (the host identifier,
1016
the logical CPU count, the enforced CPU quota and the memory capacity) are read
@@ -20,6 +26,7 @@
2026
import math
2127
import socket
2228
from dataclasses import dataclass
29+
from operator import itemgetter
2330
from pathlib import Path
2431
from typing import TYPE_CHECKING
2532

@@ -30,6 +37,7 @@
3037
from collections.abc import Iterable
3138

3239
CGROUP_ROOT = Path("/sys/fs/cgroup")
40+
PROC_SELF_CGROUP = Path("/proc/self/cgroup")
3341

3442
# The default CPU period the kernel uses when a cgroup v2 ``cpu.max`` line omits it.
3543
_DEFAULT_CPU_PERIOD_US = 100000
@@ -132,56 +140,108 @@ def _quota_to_cores(quota: int, period: int) -> int | None:
132140
return math.ceil(quota / period)
133141

134142

135-
def _read_cgroup_cpu_quota(cgroup_root: Path) -> int | None:
136-
"""Return the enforced CPU limit in whole cores, or ``None`` when unbounded.
143+
def _own_cgroup_dirs(cgroup_root: Path, proc_cgroup: Path) -> list[Path]:
144+
"""Return this process's cgroup directory and its ancestors, leaf first.
137145
138-
cgroup v2 ``cpu.max`` ("<quota> <period>", or "max <period>" when unbounded)
139-
is read first; a host on cgroup v1 uses the ``cpu.cfs_quota_us`` /
140-
``cpu.cfs_period_us`` pair, where a quota of ``-1`` means unbounded.
146+
Under a private cgroup namespace (the modern container default) the process
147+
sits at the apparent root and the list collapses to ``[cgroup_root]``. Without
148+
one — an older runtime, an explicit host namespace, a systemd service — the
149+
``0::<path>`` line of the proc file locates the real cgroup, and every level
150+
up to the root is returned because a limit may be enforced on any ancestor.
151+
An unreadable proc file, a missing v2 line, or a path that does not exist
152+
under the root all fall back to ``[cgroup_root]``, preserving the plain read.
141153
"""
142-
v2_line = _read_text_file(cgroup_root / "cpu.max")
143-
if v2_line is not None:
144-
parts = v2_line.split()
145-
if not parts or parts[0] == "max":
146-
return None
147-
try:
148-
v2_quota = int(parts[0])
149-
v2_period = int(parts[1]) if len(parts) > 1 else _DEFAULT_CPU_PERIOD_US
150-
except ValueError:
151-
return None
152-
return _quota_to_cores(quota=v2_quota, period=v2_period)
154+
content = _read_text_file(proc_cgroup)
155+
if content is None:
156+
return [cgroup_root]
157+
for line in content.splitlines():
158+
if not line.startswith("0::"):
159+
continue
160+
relative = line[3:].strip().lstrip("/")
161+
if not relative or ".." in relative.split("/"):
162+
return [cgroup_root]
163+
leaf = cgroup_root / relative
164+
if not leaf.is_dir():
165+
return [cgroup_root]
166+
dirs = [leaf]
167+
for parent in leaf.parents:
168+
dirs.append(parent)
169+
if parent == cgroup_root:
170+
break
171+
return dirs
172+
return [cgroup_root]
173+
174+
175+
def _parse_cpu_max(line: str) -> int | None:
176+
"""Parse one cgroup v2 ``cpu.max`` line ("<quota> <period>", "max" = unbounded)."""
177+
parts = line.split()
178+
if not parts or parts[0] == "max":
179+
return None
180+
try:
181+
quota = int(parts[0])
182+
period = int(parts[1]) if len(parts) > 1 else _DEFAULT_CPU_PERIOD_US
183+
except ValueError:
184+
return None
185+
return _quota_to_cores(quota=quota, period=period)
186+
153187

154-
quota = _read_int_file(cgroup_root / "cpu" / "cpu.cfs_quota_us")
155-
period = _read_int_file(cgroup_root / "cpu" / "cpu.cfs_period_us")
188+
def _read_cgroup_cpu_quota(cgroup_dirs: list[Path]) -> int | None:
189+
"""Return the enforced CPU limit in whole cores, or ``None`` when unbounded.
190+
191+
Every level of the process's cgroup path may carry a v2 ``cpu.max``; the
192+
effective limit is the most restrictive one. A hierarchy with no readable
193+
``cpu.max`` at any level is treated as cgroup v1, whose ``cpu.cfs_quota_us``
194+
/ ``cpu.cfs_period_us`` pair (quota ``-1`` = unbounded) lives at the
195+
controller mount root inside a container.
196+
"""
197+
v2_lines = [line for directory in cgroup_dirs if (line := _read_text_file(directory / "cpu.max")) is not None]
198+
if v2_lines:
199+
cores = [value for line in v2_lines if (value := _parse_cpu_max(line)) is not None]
200+
return min(cores) if cores else None
201+
202+
root = cgroup_dirs[-1]
203+
quota = _read_int_file(root / "cpu" / "cpu.cfs_quota_us")
204+
period = _read_int_file(root / "cpu" / "cpu.cfs_period_us")
156205
if quota is None or period is None:
157206
return None
158207
return _quota_to_cores(quota=quota, period=period)
159208

160209

161-
def _read_cgroup_memory_limit(cgroup_root: Path) -> tuple[int | None, Path | None]:
210+
def _read_cgroup_memory_limit(cgroup_dirs: list[Path]) -> tuple[int | None, Path | None]:
162211
"""Return ``(limit_bytes, current_usage_path)`` for the memory control group.
163212
164-
``limit_bytes`` is ``None`` when memory is unbounded or no control group is
165-
readable; ``current_usage_path`` points at the file holding current usage so
166-
free memory can be recomputed cheaply on each heartbeat. cgroup v2
167-
``memory.max`` ("max" when unbounded) takes precedence over the v1
168-
``memory.limit_in_bytes`` value, which reports a near-``INT64_MAX`` sentinel
169-
when unbounded.
213+
Every level of the process's cgroup path may carry a v2 ``memory.max``
214+
("max" = unbounded); the effective limit is the smallest, and usage is read
215+
from that same level — an ancestor limit is shared with siblings, so free
216+
memory within it is the limit minus the whole subtree's usage. A hierarchy
217+
with no readable ``memory.max`` at any level is treated as cgroup v1, whose
218+
``memory.limit_in_bytes`` reports a near-``INT64_MAX`` sentinel when
219+
unbounded. ``(None, None)`` means no limit is enforced anywhere.
170220
"""
171-
v2_max = _read_text_file(cgroup_root / "memory.max")
172-
if v2_max is not None:
173-
if v2_max == "max":
174-
return None, None
221+
limits: list[tuple[int, Path]] = []
222+
v2_seen = False
223+
for directory in cgroup_dirs:
224+
raw = _read_text_file(directory / "memory.max")
225+
if raw is None:
226+
continue
227+
v2_seen = True
228+
if raw == "max":
229+
continue
175230
try:
176-
v2_limit = int(v2_max)
231+
limits.append((int(raw), directory))
177232
except ValueError:
233+
continue
234+
if v2_seen:
235+
if not limits:
178236
return None, None
179-
return v2_limit, cgroup_root / "memory.current"
237+
limit, directory = min(limits, key=itemgetter(0))
238+
return limit, directory / "memory.current"
180239

181-
limit = _read_int_file(cgroup_root / "memory" / "memory.limit_in_bytes")
182-
if limit is None or limit >= _CGROUP_MEMORY_UNLIMITED_THRESHOLD:
240+
root = cgroup_dirs[-1]
241+
v1_limit = _read_int_file(root / "memory" / "memory.limit_in_bytes")
242+
if v1_limit is None or v1_limit >= _CGROUP_MEMORY_UNLIMITED_THRESHOLD:
183243
return None, None
184-
return limit, cgroup_root / "memory" / "memory.usage_in_bytes"
244+
return v1_limit, root / "memory" / "memory.usage_in_bytes"
185245

186246

187247
def _host_memory_total() -> int | None:
@@ -200,17 +260,19 @@ class ProcessResources:
200260
only free memory, which moves with usage.
201261
"""
202262

203-
def __init__(self, cgroup_root: Path = CGROUP_ROOT) -> None:
263+
def __init__(self, cgroup_root: Path = CGROUP_ROOT, proc_cgroup: Path = PROC_SELF_CGROUP) -> None:
204264
self._cgroup_root = cgroup_root
265+
self._proc_cgroup = proc_cgroup
205266
self._static: _StaticResources | None = None
206267

207268
def _read_static(self) -> _StaticResources:
208-
memory_limit, memory_current_path = _read_cgroup_memory_limit(self._cgroup_root)
269+
cgroup_dirs = _own_cgroup_dirs(cgroup_root=self._cgroup_root, proc_cgroup=self._proc_cgroup)
270+
memory_limit, memory_current_path = _read_cgroup_memory_limit(cgroup_dirs)
209271
memory_total = memory_limit if memory_limit is not None else _host_memory_total()
210272
return _StaticResources(
211273
host=socket.gethostname(),
212274
processor_available=psutil.cpu_count(logical=True),
213-
processor_assigned=_read_cgroup_cpu_quota(self._cgroup_root),
275+
processor_assigned=_read_cgroup_cpu_quota(cgroup_dirs),
214276
memory_total=memory_total,
215277
memory_limit=memory_limit,
216278
memory_current_path=memory_current_path,

backend/tests/unit/telemetry/test_resources.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,179 @@ def test_cgroup_memory(case: MemoryCase, tmp_path: Path) -> None:
158158
assert reading.memory_available == case.expected_available
159159

160160

161+
@dataclass
162+
class CgroupPathCase:
163+
"""A process whose control group is resolved from a ``/proc/self/cgroup`` file.
164+
165+
``files`` are laid out under the cgroup root, so a path such as
166+
``system.slice/app.scope/cpu.max`` places a limit on the process's own
167+
(non-root) control group, the layout seen without a private cgroup namespace.
168+
"""
169+
170+
name: str
171+
proc_content: str
172+
files: dict[str, str]
173+
expected_assigned: int | None
174+
expected_memory_total: int | None
175+
expected_memory_available: int | None
176+
177+
178+
CGROUP_PATH_CASES = [
179+
CgroupPathCase(
180+
name="private_namespace_reads_the_root",
181+
proc_content="0::/\n",
182+
files={
183+
"cpu.max": "400000 100000",
184+
"memory.max": "8589934592",
185+
"memory.current": "1073741824",
186+
},
187+
expected_assigned=4,
188+
expected_memory_total=8589934592,
189+
expected_memory_available=8589934592 - 1073741824,
190+
),
191+
CgroupPathCase(
192+
name="host_namespace_reads_the_process_cgroup",
193+
proc_content="0::/system.slice/app.scope\n",
194+
files={
195+
"system.slice/app.scope/cpu.max": "400000 100000",
196+
"system.slice/app.scope/memory.max": "8589934592",
197+
"system.slice/app.scope/memory.current": "1073741824",
198+
},
199+
expected_assigned=4,
200+
expected_memory_total=8589934592,
201+
expected_memory_available=8589934592 - 1073741824,
202+
),
203+
CgroupPathCase(
204+
name="ancestor_limit_applies_to_an_unlimited_leaf",
205+
proc_content="0::/kubepods.slice/pod1.slice/container\n",
206+
files={
207+
"kubepods.slice/pod1.slice/container/cpu.max": "max 100000",
208+
"kubepods.slice/pod1.slice/container/memory.max": "max",
209+
"kubepods.slice/pod1.slice/cpu.max": "200000 100000",
210+
"kubepods.slice/pod1.slice/memory.max": "4294967296",
211+
"kubepods.slice/pod1.slice/memory.current": "1073741824",
212+
},
213+
expected_assigned=2,
214+
expected_memory_total=4294967296,
215+
expected_memory_available=4294967296 - 1073741824,
216+
),
217+
CgroupPathCase(
218+
name="most_restrictive_level_wins",
219+
proc_content="0::/a/b\n",
220+
files={
221+
"a/b/cpu.max": "400000 100000",
222+
"a/b/memory.max": "8589934592",
223+
"a/b/memory.current": "536870912",
224+
"a/cpu.max": "200000 100000",
225+
"a/memory.max": "4294967296",
226+
"a/memory.current": "1073741824",
227+
},
228+
expected_assigned=2,
229+
expected_memory_total=4294967296,
230+
expected_memory_available=4294967296 - 1073741824,
231+
),
232+
CgroupPathCase(
233+
name="unresolvable_cgroup_path_falls_back_to_the_root",
234+
proc_content="0::/vanished.scope\n",
235+
files={
236+
"cpu.max": "400000 100000",
237+
"memory.max": "8589934592",
238+
"memory.current": "1073741824",
239+
},
240+
expected_assigned=4,
241+
expected_memory_total=8589934592,
242+
expected_memory_available=8589934592 - 1073741824,
243+
),
244+
CgroupPathCase(
245+
name="v1_only_proc_file_reads_the_root_controllers",
246+
proc_content="12:memory:/docker/abc\n3:cpu,cpuacct:/docker/abc\n",
247+
files={
248+
"cpu/cpu.cfs_quota_us": "200000",
249+
"cpu/cpu.cfs_period_us": "100000",
250+
"memory/memory.limit_in_bytes": "8589934592",
251+
"memory/memory.usage_in_bytes": "2147483648",
252+
},
253+
expected_assigned=2,
254+
expected_memory_total=8589934592,
255+
expected_memory_available=8589934592 - 2147483648,
256+
),
257+
]
258+
259+
260+
def _process_resources_for(case: CgroupPathCase, tmp_path: Path) -> ProcessResources:
261+
cgroup_root = tmp_path / "cgroup"
262+
cgroup_root.mkdir()
263+
_write_cgroup_files(cgroup_root, case.files)
264+
proc_cgroup = tmp_path / "proc_self_cgroup"
265+
proc_cgroup.write_text(case.proc_content)
266+
return ProcessResources(cgroup_root=cgroup_root, proc_cgroup=proc_cgroup)
267+
268+
269+
@pytest.mark.parametrize("case", CGROUP_PATH_CASES, ids=[case.name for case in CGROUP_PATH_CASES])
270+
def test_cgroup_path_resolution_cpu(case: CgroupPathCase, tmp_path: Path) -> None:
271+
reading = _process_resources_for(case, tmp_path).read()
272+
273+
assert reading.processor_assigned == case.expected_assigned
274+
275+
276+
@pytest.mark.parametrize("case", CGROUP_PATH_CASES, ids=[case.name for case in CGROUP_PATH_CASES])
277+
def test_cgroup_path_resolution_memory(case: CgroupPathCase, tmp_path: Path) -> None:
278+
reading = _process_resources_for(case, tmp_path).read()
279+
280+
assert reading.memory_total == case.expected_memory_total
281+
assert reading.memory_available == case.expected_memory_available
282+
283+
284+
def test_unlimited_at_every_level_falls_back_to_host(tmp_path: Path) -> None:
285+
case = CgroupPathCase(
286+
name="unlimited_everywhere",
287+
proc_content="0::/a/b\n",
288+
files={
289+
"a/b/cpu.max": "max 100000",
290+
"a/b/memory.max": "max",
291+
"a/cpu.max": "max 100000",
292+
"a/memory.max": "max",
293+
},
294+
expected_assigned=None,
295+
expected_memory_total=None,
296+
expected_memory_available=None,
297+
)
298+
299+
reading = _process_resources_for(case, tmp_path).read()
300+
301+
assert reading.processor_assigned is None
302+
assert reading.memory_total == psutil.virtual_memory().total
303+
assert reading.memory_available is not None
304+
assert reading.memory_available >= 0
305+
306+
307+
def test_binding_ancestor_usage_refreshes_between_reads(tmp_path: Path) -> None:
308+
# Free memory must be recomputed against the level that holds the effective
309+
# limit — here the parent — not against the unlimited leaf.
310+
case = CgroupPathCase(
311+
name="refresh",
312+
proc_content="0::/a/b\n",
313+
files={
314+
"a/b/memory.max": "max",
315+
"a/memory.max": "4294967296",
316+
"a/memory.current": "1073741824",
317+
},
318+
expected_assigned=None,
319+
expected_memory_total=4294967296,
320+
expected_memory_available=4294967296 - 1073741824,
321+
)
322+
reader = _process_resources_for(case, tmp_path)
323+
324+
first = reader.read()
325+
assert first.memory_available == 4294967296 - 1073741824
326+
327+
(tmp_path / "cgroup" / "a" / "memory.current").write_text("2147483648")
328+
second = reader.read()
329+
330+
assert second.memory_total == 4294967296
331+
assert second.memory_available == 4294967296 - 2147483648
332+
333+
161334
def test_host_identifier_is_populated(tmp_path: Path) -> None:
162335
reading = ProcessResources(cgroup_root=tmp_path).read()
163336

dev/specs/infp-589-resource-telemetry/research.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@ Phase 0 decisions. Each resolves an unknown surfaced while planning against the
3232
- **Rationale**: adopting the existing `memory_*` names/semantics for the new components makes DB, server, and workers byte-for-byte comparable (the naming decision, D14), and matches how the DB JMX (`TotalMemorySize` / `FreeMemorySize`) already reports. Pete's "RAM used" is preserved as a derived value, consistent with the DB.
3333
- **No `memory_assigned`**: memory has no enforced-limit field — the container memory limit surfaces as `memory_total` capacity. Only `processor_assigned` carries the FR-003 no-fallback-null rule.
3434

35-
## D5 — cgroup v2 primary, v1 fallback, `null` otherwise
35+
## D5 — cgroup v2 primary, v1 fallback, `null` otherwise; resolve the process's own cgroup path
3636

37-
- **Decision**: Read cgroup v2 first (`/sys/fs/cgroup/cpu.max`, `memory.max`, `memory.current`); fall back to v1 (`/sys/fs/cgroup/cpu/cpu.cfs_quota_us` + `cpu.cfs_period_us`, `/sys/fs/cgroup/memory/memory.limit_in_bytes` + `memory.usage_in_bytes`); return `null` for the affected field where neither is readable (non-Linux dev, unusual mounts).
38-
- **Rationale**: production runs containers (v2 on current hosts, v1 still common); developer machines are macOS (neither) and correctly report `null` for `assigned`. v1 `memory.limit_in_bytes` reports a sentinel near `INT64_MAX` when unlimited — treat values at/above a high threshold as unlimited → `null`.
37+
- **Decision**: Read cgroup v2 first (`cpu.max`, `memory.max`, `memory.current`); fall back to v1 (`/sys/fs/cgroup/cpu/cpu.cfs_quota_us` + `cpu.cfs_period_us`, `/sys/fs/cgroup/memory/memory.limit_in_bytes` + `memory.usage_in_bytes`); return `null` for the affected field where neither is readable (non-Linux dev, unusual mounts).
38+
- **Correction (review finding, post-implementation)**: the v2 files were initially read only at `/sys/fs/cgroup` itself. That is correct under a *private cgroup namespace* (the default on Docker 20.10+/modern K8s, where the apparent root *is* the container's cgroup) but wrong without one (older runtimes on v2, explicit `cgroupns: host`, bare-metal systemd services with unit limits): the v2 root cgroup carries no `cpu.max`/`memory.max` at all, so a limited component reported `processor_assigned = null` and — worse for an audit — the whole **host's** memory as `memory_total`. Fixed by resolving the process's own cgroup from the `0::<path>` line of `/proc/self/cgroup` and consulting **every level up to the root**, taking the most restrictive limit (a limit may be enforced on an ancestor slice); memory usage is read at the level holding the effective limit, since an ancestor limit is shared with siblings. Any resolution failure falls back to the previous root-only read.
39+
- **Known limitation**: limits set *above* a private namespace root (e.g. a K8s pod-level limit when the container itself has none) are invisible from inside the namespace and cannot be self-reported — a fundamental property of self-observation, not an implementation gap.
40+
- **Rationale**: production runs containers (v2 on current hosts, v1 still common); developer machines are macOS (neither) and correctly report `null` for `assigned`. v1 `memory.limit_in_bytes` reports a sentinel near `INT64_MAX` when unlimited — treat values at/above a high threshold as unlimited → `null`. v1 keeps the root-level read: container runtimes bind-mount the container's own v1 controller directories at `/sys/fs/cgroup/<controller>`, so the root read is already container-scoped there.
3941

4042
## D6 — Self-report through the existing heartbeat channel
4143

0 commit comments

Comments
 (0)