fix(lxc): parse cgroup2-native limit forms, not just our own emission - #144
Conversation
|
Heads up — I'm still working this one through (rebasing/verifying against the freshly-merged |
9cc96b2 to
cabd74c
Compare
parseResourceConfig inverted only litevirt's own ResourceConfig output — '<quota> <period>' with quota/1000, and '<MiB>M'. The lxc config file is root-editable, and cgroup2 accepts forms litevirt never writes: a hand-written raw-bytes memory.max was read as MiB (a 256 MiB cap charged as 268435456 MiB — conservative, but absurd), and cgroup2's 'max' (unlimited) failed the whole parse, so a legally-edited config made the container's limits unreadable. The parse now accepts the full vocabulary: 'max' reads as unlimited for both keys; a bare memory integer is BYTES (the cgroup2 native unit), rounded UP to MiB so a charge never undercounts a cap; K/M/G/T suffixes in either case scale; and the cpu quota is scaled by the ACTUAL period (defaulting to cgroup2's 100000 when absent) so a hand-written period keeps its ratio instead of silently mis-scaling. litevirt's own emission round-trips unchanged, and genuinely unparseable values still error. Observed live on the lab (2026-08-05): a hand-written raw-bytes memory.max produced the massive overcount this closes. All misread forms were captured red in TestParseResourceConfig_CgroupNativeForms before the fix.
acf8411 to
fec9bba
Compare
|
Rebased onto Verified green on the rebased tip: Drafted and posted by Claude Code. |
Three ways a root-written cpu.max still resolved to a cap litevirt could not justify, all in the same expression: The ceiling was (quota*100 + period - 1) / period, but the only guard bounded quota*100. Adding the period could still overflow int64 and wrap the numerator NEGATIVE, and the division turned that back into a plausible answer instead of an error. This is not confined to exotic input: at the cgroup2 default period of 100000, a quota just under the guard's ceiling produced -92233720368546, and at larger periods it produced 0 — the codebase's UNLIMITED sentinel, i.e. a malformed cap read as no cap. The ceiling is now taken by remainder, which cannot overflow for any pair the guard admits, and the result is bounded to what an int holds on every platform so a 32-bit build cannot truncate a large limit back down into a small or zero one. A zero quota passed the negative check and divided down to 0, unlimited again, for a value the kernel itself rejects (it enforces a minimum bandwidth). Zero now fails with the negatives. "max" returned before the period was parsed at all, so "max 0", "max -1" and "max banana" were accepted as unlimited despite this branch's stated contract that a non-positive period is rejected. The period is validated first now, whatever the quota says; a well-formed "max" is still unlimited. Tests assert the property rather than a list of inputs — over a sweep of quotas and periods, a successful parse must yield a strictly positive limit — plus the specific triggers and the representable counterpart (the largest admitted quota over the largest period ceilings to 1, which the old expression reported as 0). Reverting the parser leaves 24 assertions failing. The single consumer, ContainerLimits in runtime_inventory.go, already routes a parse error to fail(), marking the observation partial. Newly-rejected input therefore lands on "this host's limits are unreliable", not on a false zero.
Adversarial review remediation —
|
| quota | period | old result |
|---|---|---|
92233720368547757 |
100000 |
-92233720368546 |
92233720368547758 |
9 |
-1024819115206086200 |
92233720368547758 |
MaxInt64 |
0 (i.e. unlimited) |
So the trigger is not exotic, and the result is not always the unlimited sentinel — it can be negative, which is not a cap in any sense.
The ceiling is now taken by remainder (num/period, ++ if num%period != 0), which cannot overflow for any pair the multiplication guard admits, and the result is bounded to MaxInt32 so a 32-bit build cannot truncate a large limit back into a small or zero one.
One correction to the review's own arithmetic: 92233720368547758 over MaxInt64 is representable. Its true ceiling is 1, and the old expression's 0 was the bug — that case is now pinned as an expected value rather than an expected error.
Fixed — a zero CPU quota read as unlimited
0 100000 passed the negative check and divided down to 0 — the codebase's unlimited sentinel — for a value the kernel itself rejects, since it enforces a minimum bandwidth. Zero now fails with the negatives.
Fixed — max skipped period validation entirely
The max branch returned before the period was ever parsed, so max 0, max -1 and max banana were accepted as unlimited despite this branch's stated contract that a non-positive period is rejected.
The period is validated first now, whatever the quota says. A well-formed max is still unlimited.
Closed, not a defect — bare 0 memory cap reads as unlimited
The parse is exactly as the review describes, but the impact runs the other way.
runtime_inventory.go sets w.Uncapped = mem == 0, so a zero-byte cap is classified uncapped and trips the rogue gate — a false alarm, not the silent overcommit the Medium rating implied, and the direction this subsystem is meant to fail in. Charging 0 MiB for a container that can allocate nothing is also correct, not an undercount.
Erroring out or changing the return type would add a new failure mode to a parser whose whole job is reading configs litevirt did not write, in exchange for suppressing a rare false positive on a cgroup value that renders the container unable to allocate a single page. Not worth the blast radius.
Test approach and blast radius
Tests assert the property rather than the two named inputs: across a swept grid of quotas × periods, a successful parse must yield a strictly positive limit. Specific triggers and the representable counterpart are pinned alongside it.
Blast radius. ContainerLimits at runtime_inventory.go:158 is the only consumer, and it already routes a parse error to fail(), marking the observation partial. Newly-rejected input lands on "this host's limits are unreliable" rather than on a false zero, so no 0 = unlimited site sees a changed value — only fewer bogus ones.
Drafted and posted by Claude Code.
|
@colonelpanik — ready for review whenever you have time. This branch is done from our side for now: the adversarial-review remediation above is the last change we plan to make, CI is green on Happy to take it further if anything in the remediation — particularly the decision to close the zero-byte memory cap as a non-defect rather than patch it — reads differently to you. Drafted and posted by Claude Code. |
|
I agree with the CPU remediation at One disposition remains: numeric The long-term boundary should use an explicit finite/unlimited type so numeric zero and |
Container cgroup limits are read back from the on-disk lxc config by
parseResourceConfig(internal/lxc/lxc.go), which inverted only litevirt's ownResourceConfigemission —<quota> <period>withquota/1000, and<MiB>M. But that config file is root-editable and cgroup2 accepts forms litevirt never writes, so a legally-edited config made the container's limits unreadable or wildly wrong:memory.max(cgroup2's native unit is bytes) was read as MiB — a hand-written 256 MiB cap became a 268435456 MiB charge, which drove effective host capacity to a huge negative and refused unrelated creates on an idle host;memory.max = max(unlimited) failed the whole parse, blinding the runtime-inventory probe for that container.The parse now accepts the full cgroup2/lxc vocabulary:
max→ unlimited (both keys); a bare memory integer is bytes, rounded up to MiB so a charge never undercounts a cap;K/M/G/Tsuffixes (either case) scale; and the cpu quota is scaled by the actual period (defaulting to cgroup2's 100000 when omitted) so a hand-written period keeps its ratio. litevirt's own emission round-trips unchanged, and genuinely unparseable values still error.All misread forms are pinned red-first in
TestParseResourceConfig_CgroupNativeForms. Verified live on a 4-node lab (red→green: a raw-bytesmemory.maxrefused a 64 MiB create on an idle host before, admits after;maxparses as unlimited instead of erroring).