fix(installer): a cordoned node must not anchor the training envelope (backend#2237) - #798
fix(installer): a cordoned node must not anchor the training envelope (backend#2237)#798LukasWodka wants to merge 2 commits into
Conversation
… (backend#2237)
envelope_contract.json's skipped_nodes declares `spec.unschedulable
(cordoned)` a node the sizing SKIPS. Neither installer honoured it --
neither even asked the API server for the field. On a heterogeneous
cluster a cordoned large node took the anchor, the installer wrote an
envelope no live node can satisfy, and every training pod sat Pending
with no obvious cause.
The contract's own one-cordoned-out vector did not catch this because
gen-envelope-embed.sh PRE-FILTERED cordoned nodes out of the golden, so
the row replayed as a lone 4c/16Gi node and the code under test was
never handed a cordoned one. Verified inert: with the old golden and the
cordon skip removed, the replay is green.
bash had TWO byte-identical ranking loops. Rather than add the skip
twice they collapse into one _anchor_largest_schedulable, with the
jsonpath as one _TB_NODE_JSONPATH constant -- two copies of a selection
rule is how the (memory,cpu)/(cpu,memory) split in backend#2220
happened. PowerShell has one ranking site; the GPU probes query
allocatable."nvidia.com/gpu" and are left alone.
Both readers key on the literal `true`, never on non-emptiness:
Unschedulable is omitempty, so a live node emits an empty field, and an
API server that ever serialised `false` would otherwise drop every node
from sizing. Pinned in both languages rather than assumed.
The skip is written `!= "true" || continue`, not `== "true" &&
continue`: the latter returns 1 for every schedulable node and aborts
the installer under set -euo pipefail.
Coverage, all fixture-derived: the contract replay now runs on BOTH
sides (Pester only read single_node before, which is how the ps1 stayed
green while ignoring the field); 4 new rows in the shared
installer_parity.json, including the mirror case where the SMALL node is
cordoned -- a filter that just dropped the largest node would otherwise
pass; and named regressions in both suites.
Both mocked suites inject node lines directly, so they exercise the
parser and never the query: reverting only the jsonpath in BOTH
installers left everything green while cordoned nodes were ranked again
in the field. scripts/tests/node-jsonpath-agreement.sh closes that -- it
parses both jsonpaths out of the installers, writes neither down, and
asserts they are byte-identical and both request {.spec.unschedulable}.
Added to DRIFT_GUARDS so it gates rather than advises, and fails closed
when a declaration cannot be read.
The two twins genuinely cannot share code (sourced bash lib vs signed
standalone PowerShell bootstrap), so the string is written twice; the
agreement guard pins the query and installer_parity.json pins the
behaviour.
manifest.sha256 regenerated -- both installer payloads changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
saadqbal
left a comment
There was a problem hiding this comment.
Approving. The best thing in here is the finding underneath the finding: the contract vector existed, the suite was green, and the golden generator was filtering cordoned nodes out before the installer ever saw one — so one-cordoned-out replayed as a lone 4 16Gi node and tested nothing. A fixture that pre-applies the rule under test is the same class as the two other inert-fixture bugs this repo has had, and finding it from a Pending pod rather than from the suite is the point.
Verified the structural claims rather than taking them:
envelope_contract.jsonis genuinely untouched — no diff — so the drift check againstclient-runtimeat the pinned ref still means what it meant. Only the generator moved.- One
_anchor_largest_schedulable(scripts/lib/install-client-helm.sh:204), called from both240and273, and one_TB_NODE_JSONPATHat170emitting the third field. Two byte-identical ranking loops collapsing into one is the right fix given the(memory, cpu)/(cpu, memory)split in #2220 came from exactly that duplication. - The value-domain call is right and is pinned in both languages:
omitemptymeans a live node emits an empty third field, so keying on the literaltrue(install-k8s.ps1:4298, bash:218) rather than on non-emptiness is what stops a future explicitunschedulable: falsefrom dropping every node from sizing. That's the failure that would have been silent and total. - Pester now replays
multi_nodewith aCount | Should -BeGreaterThan 0guard, so the block can't quietly become empty and pass. That gap is why the ps1 could ignorespec.unschedulablewith a fully green suite.
One correction, non-blocking, on the comment rather than the code. The :214-217 note says the == "true" && continue form "evaluates to 1 for every SCHEDULABLE node, which under the installer's set -euo pipefail aborts the whole run". That isn't quite the mechanism, and I checked both shapes:
# does NOT abort — errexit exempts a failing command in a && list
set -euo pipefail; for n in a b; do u=""; [[ "$u" == "true" ]] && continue; echo "body $n"; done
# → body a, body b, exit 0
# DOES abort — the && list is the function's last statement, so f returns 1
set -euo pipefail; f() { for n in a; do u=""; [[ "$u" == "true" ]] && continue; done; }; f
# → exit 1So the hazard is real but positional: it bites when the && list lands in tail position of a function (or of the loop that ends one), not on every schedulable node. As written at :218 there is ranking code after it, so both idioms would have been safe here — which means the reason to prefer || continue is robustness against a later edit moving it to the tail, not that the && form is unconditionally fatal. Worth saying precisely, since the next person will reason from that comment.
saqlainsyed007
left a comment
There was a problem hiding this comment.
Verified against the code, not the description — this is a strong fix.
The fix is real and in both twins. install-client-helm.sh and install-k8s.ps1 now request .spec.unschedulable and skip cordoned nodes before ranking, and both key on the literal true (not non-emptiness), so a future explicit unschedulable: false can't read as cordoned. The bash ranking is extracted into one _anchor_largest_schedulable, so the sizing path and the ceiling/warning path can no longer describe different nodes.
The vacuity catch is the best part. The golden generator used to pre-filter cordoned nodes, so the contract's one-cordoned-out vector replayed as a lone live node and never handed the installer a cordoned one — the rule was untestable. Now the generator emits the whole cluster and lets the code under test apply the skip. The parity fixtures are non-vacuous: cordoned-small-node-ignored exists specifically to defeat an "always drop the largest" cheat, all-nodes-cordoned pins the unreadable-vs-too-small distinction (no false undersized warning), and explicit-unschedulable-false-is-schedulable pins the reader to true.
node-jsonpath-agreement.sh is derived, mutation-proof, and fail-closed. It parses both jsonpaths out of the two installers and compares byte-identity + asserts both request spec.unschedulable — no path is written down in the guard. It documents (and measured) the precise hole it closes: the mocked suites inject node lines directly, so reverting either jsonpath to two fields keeps them green while the real query stops fetching the field. Two absences are a FAIL, not an equal-compare. Added to DRIFT_GUARDS. manifest.sha256 updated for both changed libs.
CI green, no review threads, mergeable clean. LGTM.
DRIFT_GUARDS conflicted additively: develop added collector-redaction-floor.sh (backend#1908), this branch added node-jsonpath-agreement.sh (backend#2237). Both kept -- 16 guards, all green under `make drift`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
732db3e
The defect
scripts/tests/fixtures/envelope_contract.json:91declaresspec.unschedulable (cordoned)a node the envelope sizing skips. Neither installer honoured it — neither even asked the API server for the field.On a heterogeneous cluster a cordoned large node became the sizing anchor, so the installer wrote an envelope no live node can satisfy and every training pod sat
Pendingwith no obvious cause.The contract's own
one-cordoned-outvector did not catch this, becausescripts/gen-envelope-embed.sh:191pre-filtered cordoned nodes out of the golden:That is the golden generator, not the installer. So the row replayed as a lone
4 16Ginode and the code under test was never handed a cordoned one. The fixture was inert, and had been all along.The four acceptance criteria
1.
install-client-helm.shexcludes cordoned nodes at every ranking site ✅There were two ranking sites (
_machine_training_resourcesL173,_machine_training_ceilingL218) running a byte-identical 17-line loop. Rather than add the skip twice, the loop is now one function,_anchor_largest_schedulable, that both call — CLAUDE.md rule 1. Two copies of a selection rule is exactly how the(memory, cpu)/(cpu, memory)split in backend#2220 happened; one copy cannot drift from itself.The jsonpath is also a single constant,
_TB_NODE_JSONPATH, now emitting a third field.One subtlety worth flagging: the skip is written
[[ "$unsched" != "true" ]] || continue, not== "true" && continue. The latter evaluates to 1 for every schedulable node, which under the installer'sset -euo pipefailaborts the whole run — the shapescripts/tests/pipefail-early-close.batsexists to catch.2.
install-k8s.ps1does the same ✅PowerShell has exactly one envelope-ranking site (
Get-TrainingResources, L4273). The otherkubectl get nodescalls the ticket cited (L3204, L4058) queryallocatable."nvidia.com/gpu"— GPU probes, a different question, deliberately untouched.Unschedulableisomitempty, so a live node emits an empty third field that.Trim()drops entirely. Both readers therefore key on the literaltrue, never on non-emptiness — otherwise an API server that ever serialisesfalsewould drop every node from sizing, silently and totally. That value-domain decision is pinned by a test in both languages rather than assumed (CLAUDE.md rule 6).3. The golden stops pre-filtering ✅
gen-envelope-embed.shnow emits the whole cluster and applies no rule of its own.one-cordoned-outwent fromenvelope_contract.jsonitself is unchanged — it is byte-compared againsttracebloc/client-runtimeat a pinned ref byenvelope-contract-drift.yml, so only the generator moved.Pester previously replayed
vectors.single_nodeonly, so the contract's wholemulti_nodeblock — the ANCHOR_LARGEST rule and the cordoned vector — was asserted on the bash side alone. That is how the ps1 could ignorespec.unschedulablewith a fully green suite. It now replaysmulti_nodetoo.4. A regression case that fails before the fix ✅
Covered three ways, all derived from fixtures rather than hand-written per twin:
one-cordoned-out), now live in both languages;installer_parity.json— the shared cluster-state fixture driven through both installers, so a row forces both languages to answer it;Mutation proof
Every mutation was verified to have actually applied before running the tests, per CLAUDE.md rule 5 — "an inert mutation and good coverage look identical in a log." The first attempt at mutation 1 silently failed to apply (the
perlregex matched only a comment); the anchor check caught it.M1 — revert the bash skip (
grep -canchor: 1 → 0)M2 — revert the ps1 skip (anchor: 1 → 0)
an explicit 'false' third field is schedulablecorrectly stays green under M2 — it does not exercise the skip.Proof the fixture really was inert
Restoring the old pre-filtered golden while leaving the bash skip removed:
Green, with the defect present. That is criterion 3 in one line: fixing the installers without fixing the golden would have fixed nothing testable.
A hole the mocks could not see (and the guard that closes it)
Both suites mock
kubectland inject node lines directly, so they exercise the parser and never the query. I reverted only the jsonpath in both installers — leaving both skips intact — and:Everything green, while in the field the field never arrives,
unschedis empty for every node, and cordoned nodes are ranked again. Textbook backend#1729: a mechanism disconnected from the half it claims to check.Closed by
scripts/tests/node-jsonpath-agreement.sh, added toDRIFT_GUARDS(the requiredDrift checks / Source-of-truth driftjob, so it is a gate and not advice — rule 2). It parses both jsonpaths out of the installers, writes neither down, and asserts they are byte-identical and both request{.spec.unschedulable}. Mutation-proven in three directions:Can the rule be derived in one place?
Within bash, yes — and it now is: one
_anchor_largest_schedulable, one_TB_NODE_JSONPATH, replacing two hand-copied loops.Across bash and PowerShell, genuinely no. One is a sourced bash lib; the other is a signed standalone PowerShell bootstrap that must not fetch anything unsigned at install time — the same constraint that forces the envelope constants to be embedded rather than read (
envelope-contract-drift.ymlspells this out). The jsonpath is unavoidably written twice.What is not unavoidable is the two copies drifting, so that is machine-checked in two independent ways rather than left to review:
node-jsonpath-agreement.shpins the query, and the sharedinstaller_parity.jsonpins the behaviour — one table, two readers, a row forces both languages to answer it.Test evidence
scripts/manifest.sha256is regenerated: both installer payloads changed, and the signed-manifest verification above confirms it.Two notes for anyone reproducing locally:
pipefail-early-close.batsearned its keep. The first full run failed on my new guard —grep -oE ... | head -1closes the pipe early, and underset -euo pipefailthat aborts on SIGPIPE. Rewritten to the house capture-then-slice idiom; the gate is green and the guard still mutation-detects after the rewrite.install-client-helm.batsstalls on a workstation that has a realkubectlplus a kubeconfig pointing at an unreachable cluster: several tests don't stubkubectl, so the sizing probe blocks on TCP connect well past--request-timeout. Pre-existing (a pristine-tree baseline stalls in the same test) and invisible in CI, which has no kubeconfig.KUBECONFIG=/dev/null bats ...runs all 191 in 26s.Not done here
cli'snodeLarger(tracebloc/cli, Go) is the third reader of ANCHOR_LARGEST and is out of scope for this repo. Whether it skips cordoned nodes is worth a follow-up — the contract binds it too.envelope_contract.jsonis untouched by design; adopting an upstream contract change is a separate, pinned flow.🤖 Generated with Claude Code
Note
Medium Risk
Changes installer node-selection used to size training resources; a mistake can write unschedulable envelopes on BYO clusters. Not auth or data-handling, but it is core install-time resource logic.
Overview
Cordoned nodes no longer win the training-envelope anchor. Both installers now query
.spec.unschedulableand skip a node only when that field is the literaltrue, so a cordoned large node cannot produce an envelope no live node can schedule.Bash ranking is extracted into one
_anchor_largest_schedulableused by both sizing and ceiling. A fully cordoned (or unreadable) cluster stays unmeasured and keeps the historical literal, without undersized warnings.Goldens stop pre-filtering cordoned nodes, so the contract’s skip is actually tested. Shared parity rows plus bats/Pester regressions cover large-cordoned, small-cordoned, all-cordoned, and explicit
false. A new required drift guard (node-jsonpath-agreement.sh) pins the two jsonpaths as identical and both requestingspec.unschedulable, because mocked kubectl never exercises the real query.Reviewed by Cursor Bugbot for commit 732db3e. Bugbot is set up for automated code reviews on this repo. Configure here.