Uh oh!
There was an error while loading. Please reload this page.
fix(installer): a cordoned node must not anchor the training envelope (backend#2237) - #798
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 && listset -euo pipefail;fornin 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 1set -euo pipefail;f() { fornin 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>
732db3eDRIFT_GUARDS collided: develop added telemetry-token-bootstrap.sh, this branch added node-jsonpath-agreement.sh. Resolved as a union -- 17 guards, all green. The guard-counting interlock in the drift recipe would NOT have caught a dropped entry here: exp comes from the same list ran iterates, so losing one lowers both and still reports green. It defends against the list collapsing, not against a bad merge. So the union was asserted on both difference sets when resolving rather than inferred from the green sweep. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
LukasWodka
commented
Aug 24, 2026
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 1a3b8d6. Configure here.
aptracebloc
left a comment
There was a problem hiding this comment.
Reviewed both installer paths end to end. The fix is right and the test story is honest:
- Bash: the two byte-identical ranking loops collapse into one
_anchor_largest_schedulable, cordon skip added once. The[[ "$unsched" != "true" ]] || continueshape (not== "true" && continue) correctly avoids theset -euo pipefailabort on schedulable nodes. Return-code contract is preserved for both callers (|| return 0→ emits nothing → static default). - PowerShell twin:
"$ln".Trim() -split '\s+'collapses the omitempty trailing field (schedulable → 2 fields), and matching on-eq 'true'rather than non-emptiness keeps an explicitunschedulable: falseschedulable. - Fixtures: the golden generator no longer pre-filters cordoned nodes — the key insight; the old
one-cordoned-outvector was inert. Expected values all check out against the 1c/3Gi overhead. Edge cases covered on both platforms: all-cordoned→static default, explicit-false, trailing-space.node-jsonpath-agreement.sh(wired into DRIFT_GUARDS) pins the two jsonpaths from drifting.
Clean fix for a real "pods stuck Pending with no obvious cause" failure on heterogeneous BYO clusters. LGTM.
— drafted with Claude Code
saadqbal
left a comment
There was a problem hiding this comment.
Re-approving on 1a3b8d65. My earlier approval was dismissed by a stale-review rule, not by a change to this PR — every commit since a5b3d906 is a develop merge (#796, #791, #797, #801 arriving through two merge commits). The cordoned-node change itself is byte-identical to what I reviewed: one _anchor_largest_schedulable called from both ranking sites, one _TB_NODE_JSONPATH, the literal-true check in both languages, envelope_contract.json still untouched, and Pester still replaying multi_node. Nothing to re-litigate.
One thing the merge broke, and it isn't yours — but one instance is.#791 retired this repo's local early-close gate (correctly: .github#300 reached .github's main on 2026-08-22 and the shared quality / pipefail early-close job now runs here — it's green on this PR). It deleted scripts/tests/pipefail-early-close.{awk,bats,sh} and left four references to them behind:
| where | points at | whose |
|---|---|---|
scripts/lib/install-client-helm.sh:217 | pipefail-early-close.bats | this PR's own new comment |
scripts/tests/node-jsonpath-agreement.sh:58 | pipefail-early-close.bats | pre-existing, in a file this PR touches |
scripts/tests/collector-class-a-agreement.sh:166 | pipefail-early-close.sh | pre-existing, untouched here |
scripts/tests/collector-class-a-agreement.sh:195 | pipefail-early-close.sh | pre-existing, untouched here |
Not blocking — the behaviour is right and the rule is still enforced, just from .github now. But the first row is a comment this PR introduces, and it now names a file that does not exist, which is the thing that makes a reader distrust the next comment they read.
Convenient, though: it's the same comment I flagged last time for the other reason, so one edit closes both. As written it 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 mechanism isn't right — errexit exempts a failing left side inside a && list, and it only bites when the list is the last statement of a function. (I tested both shapes while reviewing release-train#116, which carried the identical claim.) So something like:
# Cordoned: skipped BEFORE ranking, so it can never win the anchor.# Written `!= ... || continue`, not `== ... && continue`: errexit exempts a# failing left side inside a `&&` list, but not when that list is a function's# last statement — and this is a function, so the `&&` form would be one edit# away from returning 1 silently. The shared `quality / pipefail early-close`# job (.github#300) is what enforces the wider rule now.The other three rows belong to whoever finishes backend#2264 rather than to you; flagging them here only because this is where I noticed them.
Uh oh!
There was an error while loading. Please reload this page.
…backend#2221) client-runtime#363 merged as 48ccbac, so the temporary pin this PR shipped with is retired: scripts/.client-runtime-ref now points at the post-merge develop sha and the DO-NOT-MERGE block is gone. The fixture is re-vendored from that ref (read out of git, not a working tree), and the embed + manifest regenerated from it rather than hand-resolved. Three conflicts, none of them mechanical: 1. install-client-helm.sh -- develop's #798 (backend#2237) RESTRUCTURED the function my comment fix lived in, extracting the node ranking into a shared _anchor_largest_schedulable so the cordon skip could not be added to one copy and not the other. That structure is strictly better than what I branched from, so it is taken wholesale and only the #2221 correction is re-applied on top. Worth noting: the claim that correction exists to remove -- "installer- provisioned clusters are single-node k3d" -- was RE-INTRODUCED verbatim in #798's new comment. It is wrong for the same reason as before (common.sh defaults SERVERS=1 AGENTS=1, so the default topology is two nodes), and the re-appearance is itself the argument for fixing the underlying bug rather than the sentence: the belief keeps regenerating because the cluster keeps looking single-node from the inside, which is exactly what #2221 is about. The resolved comment now says why the tie-break is a field no-op (both k3d nodes report identical figures because each reports the whole VM) instead of claiming there is only one node. 2. install-client-helm.bats -- purely additive, both sides appended @tests at the same seam. Kept BOTH: develop's four cordoned-node regressions and this PR's five topology-contract tests, 10 in total with the derivation guard. Git had left the trailing `}` outside the conflict markers, so splicing the two blocks dropped the brace off develop's last test; caught by bats reporting a setup_file syntax error, restored, and both blocks verified green. 3. manifest.sha256 -- regenerated, never resolved by hand, then --check'd against the resulting tree. Also picked up: develop's gen-envelope-embed.sh change (the generator now emits whole clusters instead of pre-filtering cordoned nodes) merged cleanly with the topology-table emitter added here, and all three vector tables are still produced. Local: 766 bats pass with 2 failures that also fail on clean develop (assess.bats:40, install-bootstrap.bats:365 -- verified in a develop worktree, not assumed); 891 Pester pass; bats-hygiene clean; shellcheck severity=error and -S warning -x clean; gen-envelope-embed / gen-installer-parity / gen-manifest --check all clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 how both installers size training resources from live cluster nodes. Wrong skip/query logic can leave training pods Pending or fall back to the static literal on BYO clusters.
Overview
Cordoned nodes no longer win the training-envelope anchor. Both installers now request
.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 satisfy.Bash ranking is extracted into one
_anchor_largest_schedulableused by both_machine_training_resourcesand_machine_training_ceiling. A fully cordoned (or unreadable) cluster is treated as unmeasured: keep the historical literal, do not warn “too small”.Goldens now emit the whole cluster instead of pre-filtering cordoned nodes. Shared
installer_parity.jsonand both test suites cover skip, mirror, all-cordoned, and explicitfalse. A new required drift guard,node-jsonpath-agreement.sh, asserts the bash and PowerShell jsonpaths are identical and both request{.spec.unschedulable}— mocked kubectl tests cannot catch a query that never fetches the field.Reviewed by Cursor Bugbot for commit 1a3b8d6. Bugbot is set up for automated code reviews on this repo. Configure here.