Skip to content

release-train: develop -> staging - #952

Merged
tracebloc-release-train[bot] merged 8 commits into
stagingfrom
release-train/to-staging
Sep 1, 2026
Merged

release-train: develop -> staging#952
tracebloc-release-train[bot] merged 8 commits into
stagingfrom
release-train/to-staging

Conversation

@LukasWodka

@LukasWodka LukasWodka commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Automated promotion by the release train (RFC-0008 D14). Head is the train-managed release-train/to-staging branch (a mirror of develop), so it never collides with a human PR. Merged only when the fr-gate is green.


Note

High Risk
Changes affect live-upgrade safety (credential/PV refusal gates), default network egress (locked-down training unless opted out), and auto-upgrade rollback logic—any regression impacts fleet security, data access, or upgrade automation.

Overview
Release train promotion bundles chart 1.9.92 → 1.9.96 with installer/CI hardening, Helm naming safety, deny-by-default egress, and several production-adjacent fixes.

fullnameOverride (backend#2626) adds tracebloc.fullname and routes created resource names through it while keeping Helm release identity, instance labels, and on-disk paths on .Release.Name. Live-cluster template-time refusals in secrets.yaml block credential re-mint when retained MySQL data exists without a matching Secret, and block hostPath PV name drift when a bound PV no longer matches the rendered name. NOTES.txt, values/schema docs, drift guard fullname-override-completeness.sh, and hostpath_pv_rename_test.yaml document and lock the PVC/PV asymmetry. Installers now resolve secrets using the override prefix; bash/PowerShell workload name lists still ignore override (backend#2888).

RFC-0003 D6 deny-by-default egress: defaults flip to networkPolicy.training.allowExternalHttps: false and egressProxy.routeWorkloads: true, with helm tests and docs (SECURITY, SEAL-CHECK) updated for opt-out behavior.

Auto-upgrade / ops: pending_age_seconds gains RFC3339 range and year/offset validation (#2908) so bogus timestamps cannot force wedge rollbacks; Guaranteed QoS on auto-upgrade and image-refresh CronJobs (#2935). Telemetry collector Class A lists add helm and refresh (#2961).

CI / quality: Installer workflow pins Pester 5.x, adds a Pester 6.x job and mutation-check; Makefile drift suite adds control-plane footprint, fullname override completeness, and QoS reachability guards. BUGBOT.md adds review rules for mutation testing and cross-platform installer twins.

Docs / misc: MIGRATIONS.md Option C now requires backing up the credentials Secret before uninstall; extensive comment corrections on training-job QoS claims (#2872) across values, schema, and templates.

Reviewed by Cursor Bugbot for commit c23646b. Bugbot is set up for automated code reviews on this repo. Configure here.

LukasWodka and others added 4 commits September 1, 2026 11:02
…ackend#2870) (#944)

* feat(tests): track and ratchet the control-plane request footprint (backend#2870)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tests): inline the footprint summer with a PyYAML preflight, fail closed on a failed render (backend#2870)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tests): parse the full k8s memory grammar and fail closed on the unparseable (backend#2870)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(footprint): make the DaemonSet x1 arm falsifiable (backend#2870)

The fixture DaemonSet had no replicas, so the special case and the
missing-replicas fallback both yielded 1 and deleting the arm changed
nothing. A stray replicas: 3 makes the two disagree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tests): mktemp the footprint render stderr, not a PID-predictable path (backend#2870)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(footprint): the scheduler max()es init containers, it does not add them (backend#2870)

@aptracebloc nit 1, and it is more than a nit. A pod effective request per
resource is max( sum(app containers), max(init containers) ): init containers run
to completion ONE AT A TIME before the app containers start, so they are never
resident alongside them. This summed both, which CONTRADICTED the guard own
"STEADY STATE ONLY" note and inflated the figure the ceiling is compared against.
Conservative is not harmless here -- this number is the input to
backend#2460/#2461, so an inflated one sends that work after MiB that no scheduler
ever reserves.

PER-RESOURCE, not per-pod: k8s takes the max independently for memory and cpu.

THE NUMBER DOES NOT MOVE: 3136 MiB / 900 m either way, because this chart init
containers carry no requests. So the "64 MiB OVER" finding STANDS -- the method was
wrong, the conclusion was not. Worth saying rather than leaving the doubt open.

WHICH MEANS THE FIX IS INERT ON THE REAL RENDER, and a green run proves nothing
about the formula. So the new fixture is built where the two answers disagree:
app 300Mi/200m, init max 1000Mi/500m -> scheduler 1000 MiB / 500 m against
sum-everything 1350 MiB / 750 m. BOTH numbers are asserted rather than "under the
ceiling", which would pass on either formula. The 1350/750 figure was MEASURED by
running the previous code in place, not computed by hand.

Mutation-proved: max -> + reddens the fixture test.

Only the formula, rebased onto ab756ea. My own mktemp fix for the same Bugbot Low
is DISCARDED -- the co-driver got there first and theirs is better, using
mktemp "${TMPDIR:-/tmp}/cp-footprint-helm.XXXXXX" rather than a bare mktemp.

control-plane-footprint.bats 9 passing. gen-manifest --check clean, shellcheck
-S warning -x clean, bash -n clean. The ~1600-test client bats suite is CI job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… it safe (backend#2626) (#911)

* feat(chart): fullnameOverride, with the completeness guard that makes it safe (backend#2626)

backend#2621 built this helper, proved the default render byte-identical, and
REVERTED IT. The helper is the easy half. The release name appears ~174 times
across these templates and is at least six different things, only two of which
may follow an override -- and a partial routing produces `prod-auto-upgrade`
beside `myrel-jobs-manager`, which is harder to reason about than a release that
is merely badly named.

So the guard is the deliverable and the helper rides along.

WHAT MOVED (39 sites, 15 files): names of resources this chart creates, and two
things that REFERENCE those names --

  * `image-refresh-cronjob.yaml` DEPLOYMENT_NAME. It names a Deployment this
    chart creates; leaving it behind points `kubectl set image` at a workload
    that no longer exists. Not in the ticket's list.
  * `telemetry-collector-configmap.yaml`'s filelog glob,
    `{ns}_{release}-*/{container}/*.log`. It matches POD DIRECTORIES, and pods
    are named after the DaemonSet. Route the DaemonSet without the glob and the
    Collector runs, reports healthy, and collects nothing -- the exact failure
    that file already warns about. Also not in the ticket's list.

WHAT STAYED: `app.kubernetes.io/instance` (Helm convention), the 27
`meta.helm.sh/release-name` annotations (Helm's bookkeeping), the 3
RELEASE_NAME/RELEASE envs (a HELM IDENTITY -- `helm status`, `helm rollback`),
and 4 on-disk paths (a LOCATION: renaming orphans a tenant's data).

THE TICKET PREDICTED THE BUG AND I WROTE IT ANYWAY. "A `sed` over `.Release.Name`
catches it on the first pass; mine did." My first routing pass anchored the
env-var exception with `$` against a CONCATENATED context string, so it never
matched and the RELEASE_NAME env was routed -- backend#2620 re-introduced by the
fix for backend#2621, exactly as written. Two more followed: `$.Release.Name`
substituted as a plain string clipped `$.Release.Namespace` to `...$)space`, and
routing ran over the helper's own body so `tracebloc.fullname` called itself
until helm died. The third is now structural rather than guarded: the helper is
inserted AFTER routing, so its body is never a candidate.

The guard, `scripts/tests/fullname-override-completeness.sh` plus its assertions
module, over EVERY platform profile in `client/ci/*-values.yaml`:

  1. NO-OP     override == release name renders identically to unset
  1b. VERBATIM with the override unset, resource names carry the release name
               WHOLE
  2. MOVED     with a distinctive override, no resource name still carries the
               release name -- misses reported BY NAME
  3. STAYED    every exception still carries it, checked with the right
               predicate per class

Assertions 2 and 3 are a pair: without 3, assertion 2 is satisfied by renaming
Helm's own bookkeeping.

PROFILES MATTER, and a single render hid it: `bm-values.yaml` sets
`hostPath.enabled`, and the hostPath PVs and dataset directory exist in no other
profile. One ad-hoc render checked ONE of the four release-scoped paths and
called itself satisfied.

ASSERTION 1b EXISTS BECAUSE A MUTATION SURVIVED. Assertion 1 diffs two renders
that both pass through the helper, so `| trunc N` cancels out on both sides and
is structurally invisible to it. The mutation was also inert for a three-letter
release name. The release name used by the guard is now 38 characters, and 1b
reads the default render directly.

Non-determinism is MEASURED, not listed: secrets.yaml mints credentials with
`randAlphaNum`, so the guard renders twice with identical inputs and excludes
whatever differs. A hand-kept key list goes stale, and the template's variable
names do not even map to the rendered keys (`$podTokenSecret` renders as
`POD_TOKEN_SIGNING_SECRET`).

Wired into `DRIFT_GUARDS`, which the REQUIRED `Source-of-truth drift` job runs --
a guard in a non-required job is advice.

Evidence
--------
  helm unittest ./client (helm 3.15.4, CI's pin) -> 36 suites, 631 tests, OK
  18 name-sensitive shell gates                  -> all pass
  helm lint / shellcheck -S warning -x           -> clean
  the guard, 4 profiles                          -> 32 assertions OK
  mutations, each anchor asserted to have applied:
    route the RELEASE_NAME env      -> caught (helm-identity)
    un-route one resource name      -> caught (by name)
    route the dataset directory     -> caught (on-disk path)
    trunc 20 on the default         -> caught (verbatim)
    upper on the default            -> caught (verbatim)
    restored                        -> green

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(chart): the guard walks every scalar, NOTES is routed and checked (backend#2626)

All four review points, and the first two were the same defect.

THE GUARD DID NOT COVER THE SITES THIS PR SINGLED OUT. MOVED read doc-root
`metadata.name` only, while STAYED enumerated the exception classes separately -
so the two halves disagreed about what the exceptions were and every
name-REFERENCE site fell through the gap. Un-routing DEPLOYMENT_NAME (which
`kubectl set image` targets) or the Collector filelog glob (which globs pod
directories) rendered both broken and left the guard green on all four profiles.

Now ONE classifier, TWO callers: `classify()` walks every string scalar and
labels each release-name hit with the exception that licenses it, or None. MOVED
is "nothing unlicensed"; STAYED is "every class non-empty and correct". Adding a
class cannot weaken MOVED without adding an obligation to STAYED. 1339-1422
scalars per profile, against a handful of metadata.name before.

NOTES.txt: L6, L24 and L27 routed. It was half-routed seven lines apart - L6
printed the release name while L13 printed the override, in the first thing an
operator reads. Assertion 4 now checks it, and needed its own render: `helm
template` omits NOTES, `--show-only templates/NOTES.txt` answers "could not find
template", and BOTH `--dry-run` and `--dry-run=client` need a cluster on 3.15.4 -
with a reachable one, ownership validation against real objects made the verdict
depend on whose kubeconfig ran it. So it renders a chart copy in which NOTES is
an ordinary template, with --debug (helm refuses to emit output it cannot parse
as YAML, and NOTES carries ANSI escapes) and KUBECONFIG=/dev/null.

PyYAML: a named refusal with EXIT 2, distinct from the 1 that means "the chart is
incomplete" - a missing dependency used to print a traceback and then "[ERROR]
fullnameOverride is incomplete in 4 profile check(s)", sending the reader hunting
un-routed names that do not exist. And `.py` is now in pyyaml-preflight.bats`s
filter: `extract_python` finds python embedded in shell, so the first sidecar in
this tree escaped the class rule entirely and every future one would have too.

values.schema.json: DNS-1123 pattern + maxLength 53, the same constraints Helm
enforces on the value this replaces. Verified - a bad value now fails at template
time by name instead of object-by-object at the API server mid-install.

VERIFIED: make drift 35/35 guards green on all four profiles; CI=true bats
scripts/tests/*.bats 1568 passing, 0 failures; shellcheck -S warning -x clean.

Mutation-proved eight ways, each anchor asserted applied - and two mutations
exposed weaknesses in my own fix: a broadened path allowlist swallowed unrouted
sites out of MOVED (the path class was the only one a non-path could satisfy by
accident - it now asserts its members are paths), and deleting assertion 4`s
"no NOTES supplied" refusal changed nothing because the loop always supplies it
(there is now a self-check that invokes the assertions without it and requires a
refusal).

Part of tracebloc/backend#2626

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(chart): refuse a fullnameOverride rename of a LIVE release (backend#2626)

Bugbot High, and it is a data-loss-shaped bug rather than a naming one.

fullnameOverride now routes tracebloc.secretName. That is correct for an INSTALL
and unsafe for a RENAME: setting the override on a release that already exists
moves the Secret name, so the lookup at the top of secrets.yaml misses and four of
the six credentials fall to their tier-3 randAlphaNum and are MINTED FRESH. The
MySQL PVC is keep-ed and still holds the old ones:

  helm upgrade  -> STATUS: deployed
  mysql         -> ERROR 1045 (28000): Access denied

A successful upgrade that leaves the database unopenable, with no warning at any
layer. The completeness guard this PR adds could not see it -- every rendered NAME
follows the override exactly as designed; what did not follow was the DATA.

secrets.yaml now looks for the Secret under the name it would have had WITHOUT the
override. Present, plus an override that differs, means a live release is being
renamed, and it fails with the migrate-deliberately remedy. A fresh install with
an override, and a release that always had one, both see no old-name Secret and
are untouched.

MEASURED against a live cluster, not reasoned about -- k3d + `--dry-run=server`,
which unlike `helm template` actually performs lookup:

  override + pre-existing un-overridden Secret  -> REFUSED, by the named message
  override, no such Secret (fresh install)      -> silent
  no override at all                            -> silent
  the dangerous case re-run after the controls  -> still REFUSED

The assertions go in client-credentials-have-a-secret-tier.sh rather than the
chart suite, for that file own structural reason: this is a lookup, so
helm-unittest renders it away. Three assertions -- the refusal exists, it derives
the UN-overridden name from .Release.Name, and it gates on the two names
differing. The middle one is the correctness of the whole thing: keyed on the
overridden name it would compare a name against itself, never fire, and still read
as a guard. Collapsed-run floor raised 13 -> 16.

3 mutations, all reddening, every anchor asserted applied. 631 chart tests pass,
drift 35/35, completeness guard green, shellcheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(chart): key the re-mint refusal on the persisted data, not on a name (backend#2626)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(chart): hold the CLASS behind the Secret-lookup finding, not just the instance (backend#2626)

fae5441 fixed the instance, and better than my own attempt twice over. I had
un-routed `tracebloc.secretName` so a rename could not lose the credentials -
UNTESTED, because `lookup` is inert under every client-side renderer I have.
ea6568d refused the dangerous case and MEASURED it with `--dry-run=server`, the
only way to exercise a lookup; fae5441 then re-keyed that refusal on the
persisted DATA after Arturo showed name-keying caught one of three rename
directions and missed reinstall-over-a-kept-PVC entirely. That work is on the
branch. Mine is discarded rather than layered on: with the Secret un-routed, the
refusal would compare a name against itself and fire on every override-set
upgrade.

What nothing covers is the CLASS. A Secret `lookup` keyed on a name that follows
the override misses on a rename, and a missed lookup is not an error - it
silently takes the last resolution tier. This chart has TWO such sites:

  secrets.yaml                     a `fail` reached by the miss (fae5441, held by
                                   client-credentials-have-a-secret-tier.sh)
  tracebloc.telemetryTokenPresent  safe ONLY because it ORs a lookup on the
                                   legacy fixed name - and nothing asserted that

So the second member was one edit from the same silent shape with no check.
Assertion 5 requires every routed Secret lookup to carry one of the two
mitigations in its own file. Which mitigation a site needs is not this
assertion`s call - it is a text-level check and could not have established what
`--dry-run=server` did. It only requires that one is still there.

THE REFUSAL IS DETECTED ON THE INVARIANT, NOT THE ARITHMETIC, and that is the
lesson from writing this against a moving target. My first detector matched
`printf "%s-secrets" .Release.Name` - the shape the refusal had while it keyed on
the un-overridden name. fae5441 improved the refusal and my detector would have
reported it as NO refusal. A detector that breaks when the thing it guards gets
better is worse than none: it pushes back toward the shape it was written
against. It now keys on the property that must hold however the other half is
computed - a miss on THAT lookup reaches a `fail`.

Which names follow the override is DERIVED transitively, closing `include` over
the helper definitions, so a helper added tomorrow that wraps `fullname` is
covered without anyone remembering. Fails closed three ways: a define parser that
matches nothing, zero routed sites in a chart known to have two, and zero Secret
lookups at all.

Mutation-proved four ways, each anchor asserted applied:

  the data-keyed refusal removed              -> flagged
  the refusal no longer negates that lookup   -> flagged
  telemetry legacy-name fallback removed      -> flagged (the unguarded member)
  routed-var detection stops resolving        -> fail closed

VERIFIED on helm v3.15.4 (the CI pin): make drift 35/35; helm unittest 631/631 in
36 suites; client-credentials-have-a-secret-tier 17 assertions OK; shellcheck
clean. No chart files touched, so no version bump.

Part of tracebloc/backend#2626

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(installer): read the client Secret under fullnameOverride, and parse helper bodies whole (backend#2626)

Two Bugbot findings on a043e52, both Medium, both real.

1. THE INSTALLER MISSES AN OVERRIDDEN SECRET NAME. `tracebloc.secretName`
   follows `fullnameOverride`, so on a release installed with one the Secret is
   `<override>-secrets`. `detect_installed_client` and `Get-InstalledClientInfo`
   read `<release>-secrets`, find nothing, and a live client whose id lives only
   in the Secret (the backend#2571 shape the chart now recommends) reads as
   UNIDENTIFIABLE - so `diagnose` and `upgrade` treat it as having no id.

   The override is already in the values both callers have open, so the effective
   prefix costs one more read of the same file rather than a second API call.
   Absent -> the release name, which is the chart`s own
   `default .Release.Name .Values.fullnameOverride`.

   Four tests, two per language, and the CONTROL is the load-bearing half:
   without it the fix is satisfied by always using the override key, which would
   break every ordinary release. Mutation-proved in both directions, in both
   languages - reverting to the release name fails the override case, always
   using the override fails the control.

2. MY DEFINE PARSER STOPPED AT THE FIRST `end`. `{{- define "x" -}}(.*?){{- end
   -}}` is non-greedy, so a helper containing an inner `if`/`range` closed at the
   INNER end and its tail was silently dropped. Measured: 28 of 55 helper bodies
   truncated.

   IT CHANGED NO ANSWER TODAY - no helper`s `tracebloc.fullname` reference happens
   to sit in a dropped tail, so the routed-helper closure came out at 21 either
   way. That is why it was worth fixing rather than noting: the guard`s coverage
   depended on WHERE in a helper an include happened to sit, and one edit moving
   an include below an `if` would silently un-route it - after which a routed
   Secret lookup reads as unrouted and assertion 5 stops requiring a mitigation.

   Replaced with a balanced parse over the `{{ }}` ACTIONS, so an `end` inside a
   string or comment cannot close a block early. A permanent self-test feeds it
   the exact shape the regex dropped - a helper whose `fullname` reference sits
   after an inner `if`/`end` - because the chart contains no such helper today and
   a check only exercisable by a bug already present arrives too late. Both halves
   asserted: the routed probe must be seen, the unrouted probe must not.

VERIFIED on helm v3.15.4 (the CI pin): make drift 35/35 (manifest.sha256
regenerated - the two installer files are hashed there); helm unittest 631/631;
Pester 776 passed / 0 failed / 15 skipped; the two new bats cases green;
shellcheck -S warning -x clean.

Part of tracebloc/backend#2626

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(chart): assert the SPECIFIC refusal, gate the interpreter, and correct the prose the refusal falsified (backend#2626)

Asad`s five remaining items, and two of them were defects in my own guard.

1. THE SELF-CHECK WAS SATISFIED BY ANY NON-ZERO EXIT. Written as `if …; then
   error; else OK; fi`, so with python3 absent (rc 127) the sidecar never ran and
   it printed [OK]. The one assertion whose stated purpose is "an unreachable
   refusal is one nobody notices has stopped refusing" reported green for a
   reason unrelated to what it checks - the bare `assertRaises(Exception)` shape,
   in shell (CLAUDE.md rule 10). It now requires EXIT 1 specifically, and treats
   any other code as a cannot-tell finding.

2. NO INTERPRETER GATE, where 23 siblings have one. python3 absent gave rc 127,
   missed the exit-2 branch, and ended on "fullnameOverride is incomplete in 4
   profile check(s)" - a missing tool reported as a chart defect, the exact
   misdiagnosis the module half was fixed for. Added in the siblings`
   `fail_closed` idiom, for python3 AND helm. Measured: absent python3 now exits
   2 with "THIS IS A MISSING TOOL, NOT A VERDICT ON THE CHART".

   And that work exposed a third: my PyYAML guard caught `ModuleNotFoundError`,
   which is a SUBCLASS of `ImportError` - so it missed the parent, and a plain
   `ImportError` is exactly what `pyyaml-preflight.bats` injects to simulate an
   absent PyYAML. Under the repo`s own simulation the guard gave a TRACEBACK and
   rc 1 while the bats class rule stayed green, because it reads the AST and
   accepts either name. Widened to `ImportError`, which also covers the
   real-world broken-install case. Verified: rc 2 with the named refusal.

3. `fullnameOverride: ""` was schema-INVALID. The pattern required at least one
   character, so the chart`s own commented example at values.yaml would have been
   rejected if uncommented verbatim, while the template treats "" as unset
   (Arturo). Pattern now accepts the empty string; Bad_Name and a 54-char value
   still fail at template time.

4. values.yaml said changing it on an existing install is "a migration, not a
   config tweak". It is REFUSED outright. That was the most misleading line in
   the file - it invited planning a migration the chart will not start.

5. values.yaml said the chart "ALWAYS emits `<release>-secrets`", with pre-create
   commands using that name. False under an override, and the consequence after
   the PVC re-key is a tier-3 hard fail on clientId for an operator who followed
   the doc correctly. It now says the name follows the override and gives a
   command to read it off the render rather than guess.

6. THE REFUSAL BLOCKED A DOCUMENTED RECOVERY PATH. docs/MIGRATIONS.md Option C
   is uninstall -> clear claimRef -> re-create PVCs -> install. Only the PVCs
   carry `resource-policy: keep`, so uninstall deletes the Secret and the
   re-install renders PVC-present + Secret-absent -> refused, with a primary
   remedy ("copy the credentials") that has nothing left to copy from. Option C
   now saves the Secret in a step 0 and restores it under the new effective name
   in a step 4, and the refusal message names `kubectl delete pvc mysql-pvc` as
   the accept-data-loss path so the failure is escapable from the failure itself.

NOT CLAIMED: the `--dry-run=server` matrix was run against ea6568d, before the
PVC re-key. Asad flagged that and he is right - it is not measured for this
version and is not recorded as such.

VERIFIED on helm v3.15.4: make drift 36/36; helm unittest 640/640;
client-credentials-have-a-secret-tier 17 assertions; pyyaml-preflight 3/3;
chart-pull-secret green; shellcheck clean; manifest --check clean.

Part of tracebloc/backend#2626

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(chart): accept a renamed release`s token Secret, and scope the path-class check to the chart (backend#2626)

Three Bugbot findings on f585de1. One demoted with evidence, two fixed.

1. HIGH, DEMOTED: "Path class fails on cloud profiles". It does not, at head.
   Measured on all four CI profiles: aks 1, bm 4, eks 1, oc 1 - the cloud
   profiles each render ONE release-scoped path, the Collector`s queue directory
   `/var/lib/tracebloc/<release>/telemetry`, because `telemetryCollector` is on
   by chart DEFAULT and no client/ci profile sets it. The required drift guard is
   green and does not refuse a complete chart.

   The HAZARD is real though, and the finding named the right one with the wrong
   scope: the gate is tri-state since backend#1906, so a profile that disabled
   the Collector AND hostPath would have no release-scoped path, and a
   PER-PROFILE emptiness check would then refuse a complete chart. So the
   emptiness assertion moved from per-profile to ACROSS profiles - a profile with
   none is a `[note]`, a CHART with none anywhere is still an error, because "no
   path followed the override" is equally true of a chart that stopped scoping
   paths by release. Proved both ways: a 5th profile disabling both keeps the
   guard green (the false positive that would otherwise have fired), and
   neutering the path class reddens it.

2. MEDIUM, FIXED: the token lookup missed a renamed release`s Secret.
   `telemetryTokenPresent` accepted the override-following name and the legacy
   FIXED name, but not `<release>-telemetry-token` - so on a renamed release
   `telemetryCollectorState` hard-FAILED for an operator who had explicitly
   enabled the Collector, naming two names that were never going to match while
   the token sat there under a third. Accepted now, and named in the refusal.

   Accepted rather than refused, which is deliberately the opposite call from the
   credentials Secret: there a name miss means silently minting a password
   against a datadir holding the old one. The token is server-side and
   re-derivable (jobs-manager writes it, backend#2274), so finding the existing
   one is safe and is what the operator meant.

   Nothing pinned the accepted-name set, so nothing would have caught it going
   away again. `telemetry-token-agreement.sh` now asserts an AGREEMENT rather than
   a list: every name the lookup accepts must be reported by the refusal.
   Placeholders are COUNTED against arguments, because the first cut checked that
   the name appeared on the line - and the format string and its args share one
   line, so deleting a `%q` left the substring matching while printf silently
   dropped the argument. Caught by mutation-proving; the counting lives in a `.py`
   sidecar, which is inside pyyaml-preflight`s class rule now that it covers `.py`.

3. MEDIUM, ALREADY FILED: the installer still builds `{namespace}-jobs-manager`
   in six places. That is backend#2888, filed with the measurement - six
   reconstructing sites, two that already DISCOVER by pattern and are
   override-safe, and the structural fix (select on
   `app.kubernetes.io/instance`). It is a change to the installer`s naming model,
   not a patch, and it should land before fullnameOverride is recommended to
   operators.

VERIFIED on helm v3.15.4: make drift 36/36; helm unittest 640/640;
telemetry-token-agreement green and mutation-proved 4 ways; pyyaml-preflight 3/3;
shellcheck clean; manifest --check clean.

Part of tracebloc/backend#2626

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(chart): bump to 1.9.91 — develop reached 1.9.89 while this PR was open

* fix(chart): make the token refusal escapable after an override-to-override rename (backend#2626)

Bugbot, Medium: `telemetryTokenPreOverrideName` covers `<release>-telemetry-token`,
so unset->A and mid-migration are handled, but A->B leaves the token under A`s
name and the render hard-fails naming three names none of which will match.
Correct, and the fix is NOT a fourth candidate name.

WHY NOT ENUMERATE. Measured across the three consumers: the Collector`s volume
(telemetry-collector-daemonset.yaml:197), the RBAC`s resourceNames
(telemetry-token-rbac.yaml:102) and jobs-manager`s writer env
(jobs-manager-deployment.yaml:351) ALL resolve
`tracebloc.telemetryTokenSecretName` and nothing else. So a token discovered
under any other name is one nothing is permitted to read - enumerating the
namespace would turn a loud refusal into a green render with a Collector that
cannot mount its token. That is strictly worse than the hard fail.

What makes the hard fail acceptable is that it is ESCAPABLE, and it was not. The
message now names the rename as the likely cause and gives the two ways out,
neither of which is a reinstall:

  * copy the Secret to the name this render wants (the command is in the message)
  * leave telemetryCollector.enabled unset for one upgrade and let jobs-manager
    re-mint under the new name on its next re-authentication

The second is safe because the Collector`s mount is `optional: true` and the
daemonset says why in terms - "buffers until the token arrives" rather than
"CrashLoopBackOff on every node" - so the window costs buffered telemetry, not
node health.

NO NEW PLACEHOLDERS, so telemetry-token-agreement.sh`s placeholder/argument
arity check still holds; verified green.

One design question left for the reviewer rather than decided here: whether the
gate should skip the refusal entirely on a LIVE release, keying on the persisted
MySQL PVC the way the credentials refusal now does. That covers every rename
direction with no name arithmetic, and it costs the fresh-install protection on
live releases. It changes the gate`s invariant, so it is the reviewer`s call and
the evidence is on the thread.

VERIFIED on helm v3.15.4: make drift 38/38; helm unittest 641/641;
telemetry-token-agreement green; manifest --check clean.

Part of tracebloc/backend#2626

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(guard): make the fallback mitigation the property, not the arity (backend#2626)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(manifest): regenerate after the guard-mitigation change (backend#2626)

`make drift` was red on `gen-manifest.sh --check`: 9eb3c7e changed
scripts/lib/summary.sh and scripts/install-k8s.ps1 without regenerating
scripts/manifest.sha256, so the R8 static-analysis gate saw two stale digests.

Manifest-only. Verified: make drift 39/39, helm unittest 641/641, bats 69/69 over
the manifest/hygiene/style suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(chart): the rename remedy is incomplete on hostPath, and said otherwise (backend#2626)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(guard): a sidecar exit outside {0,1} is could-not-run, not a chart defect (backend#2626)

The last half of @saadqbal`s interpreter finding. 9eb3c7e/head fixed the
fabricated path-class claim - assertion 6 now reports NOT CHECKED rather than "NO
profile rendered a release-scoped on-disk path" - but the final verdict still
blamed the chart:

  $ PATH=<python3 that exits 127> bash scripts/tests/fullname-override-completeness.sh
  [ERROR] fullnameOverride is incomplete in 5 profile check(s)     # rc 1

`if [ "$rc" -eq 2 ]` handled exactly 2, and `[ "$rc" -eq 0 ] || failures++` swept
every other code into "the chart is incomplete" - 127 from a stale pyenv shim or a
dangling symlink, 126 from a non-executable interpreter, 137 from an OOM kill.
Now classified: 0 clean, 1 a finding, ANYTHING ELSE exits 2 with the named
refusal.

  $ PATH=<python3 that exits 127> …
  [ERROR] the assertions exited 127, which is not a verdict …
          NOTHING ABOUT fullnameOverride WAS CHECKED.            # rc 2

AND THE PREFLIGHT I ADDED CANNOT COVER THIS, which the comment now says because
it read as though it could: `command -v python3` answers "is there something on
PATH by that name", not "does it run". A shim that execs a deleted interpreter
passes it and exits 127 here.

Mutation-proved: folding 127/126 back into `failures` restores the false
"incomplete in N profile check(s)" claim and rc 1; the classification restores rc
2 and zero such claims.

VERIFIED at head on helm v3.15.4: make drift 39/39; helm unittest 641/641;
shellcheck -S warning -x clean; manifest --check clean.

Part of tracebloc/backend#2626

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(guard): "not checked" is not a pass, and the fallback detector gets a fixture (backend#2626)

Two from @saadqbal, and they are the same sentence twice: an absence is not
an answer.

1. The pathclass_missing branch printed NOT CHECKED and fell through to exit 0.
   The two causes it enumerates both raise `failures` elsewhere, so the branch
   looked safe -- but a third does not: rename the PATHCLASS marker in the
   sidecar, a pure refactor with every assertion intact, and assertion 6 stops
   asserting while DRIFT_GUARDS reports a pass. Now `could_not_run` -> exit 2,
   the vocabulary the interpreter loop already owns; a confirmed finding still
   outranks it, so a real defect is reported as one and not as "cannot tell".
   Mutation-proved with the anchor asserted: PATHCLASS -> PATH_CLASS took the
   guard from rc=0 to rc=2.

2. _fallback_on_line was pinned by nothing -- regress it to the file-wide
   `count("lookup ") >= 2` and the real chart still renders green, because the
   chart does not contain the shape that separates the two. Written-down
   fixture, same reasoning as _SELFTEST_TEMPLATE: two lookups both routed (the
   half the old form gets wrong), a literal-keyed real fallback, a single
   routed lookup, and the $var indirection. Mutation-proved: the old form
   reddens the first and the $var case.

`make drift`: all 39 guards green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(chart): a path is not an object, and the PV caveat outlived the refusal (backend#2626)

Two Bugbot findings on the pvCaveat added in 91e95f4.

High: the caveat rendered ONLY inside the credential refusal, which is skipped
once a Secret exists under the new name -- so an operator on their second
attempt read the copy-the-Secret remedy with the caveat missing. values.yaml
made it worse by saying "the hostPath PVs keep the RELEASE name": true of the
on-disk PATH, false of the PersistentVolume OBJECTS, which are named through
tracebloc.fullname and carry no helm.sh/resource-policy: keep. Following that
on bare metal deletes the PVs and leaves the retained PVCs Bound to them.
Separated path from object in values.yaml, and NOTES.txt now carries the
caveat on every hostPath render -- the path the refusal cannot reach. Gated on
hostPath alone, not on fullnameOverride being set, because the A->unset rename
is the same hazard and would have gone quiet.

Medium: .Values.hostPath.enabled read without the (default dict ...) guard the
rest of the chart uses. Guarded. Note the stated consequence does not hold as
written: with hostPath nil the render already dies in shared-images-pvc.yaml:3,
before secrets.yaml, both before and after this commit. Nine other unguarded
sites remain and are filed separately -- one of ten does not fix the nil case.

make drift: all 39 guards green. NOTES verified rendered under
--set hostPath.enabled=true and absent when false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(chart): the PV rename hazard gets a tripwire, and a refusal to go with the notice (backend#2626)

Builds on 2cd99cd rather than replacing it. That commit fixed both Bugbot
findings by correcting the values.yaml conflation and repeating the caveat in
NOTES.txt; this adds the two things it left implicit.

A REFUSAL, alongside the notice. NOTES.txt and this guard cover different
halves and neither substitutes for the other. NOTES.txt renders on every
hostPath install including dry-runs, so it is the half that is always visible
-- but it prints after a successful render and cannot stop the upgrade that
deletes the PVs. The new guard in secrets.yaml blocks that upgrade: it compares
the retained claim's spec.volumeName against the volume this render names, and
refuses when they disagree. It reads the claim rather than doing a PV lookup
because spec.volumeName is written by the binding controller, needs no
cluster-scoped RBAC, and cannot come back empty-and-look-renamed the way a
forbidden lookup would. Scoped to hostPath because off hostPath the provisioner
names volumes pvc-<uuid>, which would make it refuse every storage-class
install. It names the observed pair and both causes -- a rename, or hostPath
switched on over a dynamically provisioned claim -- rather than asserting which.

A TRIPWIRE UNDER THE PROSE. Nothing tested any of this. hostpath_pv_rename_test
pins the four facts both comments now assert, from render output rather than
from a restatement: PV names follow the override, claim names do not, claims
carry helm.sh/resource-policy keep, and the PVs do NOT. That last pair is the
tripwire -- annotating the PVs keep is the real fix for this hazard, and when
someone does it those cases go red and force the values.yaml paragraph and this
guard to be revisited in the same PR. Each fact is asserted with the override
both set and unset, since the set-only form would pass on a chart that named
everything renamed-* unconditionally.

Two things measured along the way. The claim's spec.volumeName is set by no
template, so the chart test asserts the binding from the PV's claimRef end and
says so; and on helm-unittest 0.5.2 a documentIndex is silently ignored when a
suite scopes several templates and a test narrows with template:, evaluating
against document 0 while reporting DocumentIndex 0 -- all six claim tests here
ran against the PersistentVolume before switching to documentSelector.

Also: a scoped regression test for the nil-guard, which had none. It is
provable only template-scoped, because a whole-chart render under hostPath: null
still dies at shared-images-pvc.yaml:3 either way. And 2cd99cd's new comment
said the guard was "like every other hostPath read in this chart" -- measured
8 guarded, 9 bare on this tree, so the sentence is corrected and points at
backend#2910. Filed a duplicate of that ticket (#2911) and closed it, moving its
three additional measurements onto #2910.

helm unittest 654/654 (was 641). make drift 39/39. Every claim mutation-proved:
PV gains keep -> 1 red; PV name stops following -> 1 red; claim name starts
following -> 3 red; host path starts following -> 1 red; nil-guard reverted to
the bare read -> the secrets case reddens with the nil pointer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(guard): the identity-env class also has to name the right identity (backend#2626)

Layered on 7dc86c2's `identity_env_sites`, which landed the same Bugbot Medium
concurrently. Both of us reproduced the fail-open and fixed it differently; this
keeps that commit's fix as the base and adds the two cases it cannot see, plus
the chart-level backstop.

WHAT 7dc86c2 ALREADY DOES, kept unchanged: derives the site set from the render
with the override UNSET and requires every site to still be there with it set, so
a routed site is a DISAPPEARANCE rather than an absence. That is the better half
of the two approaches -- it catches a site vanishing for any reason, not only a
routed value -- and replacing it with a same-shaped enumeration of my own would
have been a discard, not a merge.

WHAT IT CANNOT SEE, and both are now checked:

  * `identity_env_sites` accepts a value in (rel, ns) for ANY of the three
    names, so swapping RELEASE_NAME with RELEASE_NAMESPACE keys the same triple
    on both sides and passes -- while a consumer reading RELEASE_NAME gets the
    namespace. Each name is now checked against its own meaning. Assertable only
    because this profile deliberately makes rel and ns differ; under the
    installer's one-string-for-both convention (backend#2621) it would not be.

  * A `valueFrom` env has no literal to read, so it drops silently out of BOTH
    site sets and reads as agreement. It is now reported as a site the check
    cannot see, which is the finding rather than a pass (rule 3).

AND PER-PROFILE EMPTINESS IS NOT A CHART FINDING. 7dc86c2 failed the guard when
the default render named zero identity envs, which would refuse a complete chart
on a profile rendering neither CronJob nor the storage-assertions Job -- the
mistake the PATH class already made and had demoted after measuring. Replaced
with the reviewed PATHCLASS shape: an `ENVCLASS` count out of the sidecar,
asserted once ACROSS profiles by the shell (new assertion 7), where a missing
marker exits 2 (could-not-run) instead of 0. Measured at head: aks 5, bm 5,
eks 5, oc 5.

Also took 7dc86c2's NOTES wording over mine -- same conclusion, tighter -- and
added one clause it lacked: that the destructive upgrade is now refused at
template time, so the notice tells the operator they are protected rather than
only that a hazard exists.

Mutation-proved, and the split is the point: routing one RELEASE_NAME -> exit 1
(both layers fire); swapping RELEASE_NAMESPACE -> exit 1 with ONLY the new
per-name check firing, the site comparison silent; renaming the ENVCLASS marker
-> exit 2 with NOT CHECKED; stripping the envs from all three templates -> exit 1
naming the chart-level cause.

helm unittest 656/656. make drift 39/39. shellcheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(chart): force egress-enforcement and registry-Secret into the completeness denominator (backend#2626)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(chart): bump to 1.9.92 for the completeness denominator change (backend#2626)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(completeness): demote CLS_ENV emptiness, and pin the refusal detector (backend#2626)

Both findings from @saadqbal review, plus the base merge.

1. CLS_ENV EMPTINESS WAS HALF-DEMOTED (Bugbot Medium; he reproduced it). The
identity-env block already treated per-profile emptiness as legitimate and printed
`ENVCLASS 0` -- but the STAYED empty-class arm set `fail = True` first, so that half
could never rescue the run. Reproduced here on aks with autoUpgrade, imageRefresh
and sealCheck.storageAssertions.enabled all off: 38 documents, and the ORIGINAL
prints "[ERROR] found NO RELEASE_NAME ..." and "ENVCLASS 0" in ONE output. A
required drift guard refusing a complete chart.

AND THE ONE-LINE FIX WOULD HAVE BROKEN THE CROSS-PROFILE ASSERTION. Extending the
demotion is right, but it must NOT also print `ENVCLASS 0` the way CLS_PATH prints
`PATHCLASS 0`: the shell parses `grep -E ^ENVCLASS | head -1` and the identity-env
block below prints the REAL count unconditionally, so a zero here would be read
first and the true value discarded -- turning a cross-profile assertion into one
that always sees 0. CLS_PATH can print its count because its two branches are
mutually exclusive; CLS_ENV count is owned by the block below. Stated in the code.

STILL LATENT, and I am not claiming a test covers it: all four CI profiles name 5
identity envs, so disabling the demotion does NOT redden the suite. The evidence is
the live reproduction above, not a guard. Pinning it needs either a fifth profile
or a specimen driving assert_stayed empty arm -- happy to add the latter.

2. THE REFUSAL DETECTOR HAD NO SPECIMENS AT ALL, which is the asymmetry that made
his mutation possible: `_fallback_on_line` has three pinning lines, `_mitigations`
had none. `return {"refusal"}` unconditionally left assertion 5 printing "all 2
routed Secret lookup(s) of 2 carry a mitigation" and exiting 0, both existing
selftests green -- the detector deciding whether a routed credential Secret is
mitigated could be DELETED and nothing in the tree would say so.

Four specimens now, written down rather than hunted for in the chart: a real
refusal, a negation that merely branches, a `fail` reached by negating an UNRELATED
variable (the routed_vars discrimination), and a `fail` below the 4-line window.

Mutations, all reddening: his exact `return {"refusal"}` (3 of 4 halves fire);
widening the window 4 -> 40; dropping the per-var discrimination. A fourth came
back INERT and its anchor assertion said so rather than reporting a pass.

3. BASE MERGED, both conflicts as he specified. Makefile: develop block plus one
line, verified by comparing guard sets programmatically -- 39 on develop, 40 here,
the single delta being fullname-override-completeness.sh -- rather than by eye,
because `--ours` would delete #933 empty-entry refusal and `--theirs` would drop
this guard from the list entirely. manifest.sha256 REGENERATED from the merged tree.

Guard at head: rc=0, 60 OK, 0 ERROR, aks=5 bm=5 eks=5 oc=5 -- the 56/0 baseline he
measured, plus the four new selftest lines. drift-list-integrity 9/9,
gen-manifest --check clean. black is NOT run by this repo CI and the file was
already non-black-clean (14 hunks before and after, none in the added code), so it
is left alone rather than reformatted into an unrelated diff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(completeness): the preflight does NOT make 127 unreachable (backend#2626)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…instant can't force a rollback (backend#2908) (#949)

Closes tracebloc/backend#2908

`pending_age_seconds` gated `last_deployed` on the SHAPE of its RFC3339
fields, not their RANGES:

  match(ts, /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T.../)

so `2026-00-01`, `2026-08-00`, or `...T40:99:99` passed, fed the civil→days
math a nonsensical date, and yielded a finite-but-garbage epoch. A month/day
that rolls the instant into the deep past lands a LARGE POSITIVE age, and the
downstream sanitiser only rejects empty / non-numeric / NEGATIVE:

  case "$AGE" in ''|*[!0-9-]*) AGE="" ;; esac

so the bogus-but-positive age sailed through, cleared WEDGE_MIN_AGE_SECONDS,
and the #554/#2877 recovery path rolled back a release that was never wedged —
discarding an operator's in-flight upgrade values. Pre-existing since #923.

Fix (the class, not just the instance) — three sibling gates so a non-instant
yields NO age (empty), which the caller already treats as "too recent, never
clobber", the safe side:
- Range-check the date/time fields in the awk body before trusting the derived
  age: month 1-12, day 1-31, hour 0-23, minute 0-59, second 0-60 (leap second).
- The zone-offset parse is SHAPE-only in the same way: `+40:00`/`+99:99` are
  accepted and shift the epoch by up to ~4 days — enough to age a genuinely
  RECENT (in-flight) upgrade past the threshold and roll it back. Bound it to a
  real RFC3339 numoffset (hour 0-23, minute 0-59) too.
- The year is the one field the RFC3339 ranges can't bound (any 4 digits are
  in-range), so `0000`/`1970`/`1999` still fabricate a huge positive age. Add a
  fail-safe domain floor: a pre-2000 stamp is "cannot tell" (skip). Worst case
  if a release were somehow genuinely that old: we skip a rollback, never the
  reverse. In-range corruption to another plausible date is undetectable from
  the stamp alone and out of scope.

A real recent in-flight timestamp and a genuine aged wedge are unaffected.

Tests:
- scripts/tests/auto-upgrade-inflight-vs-wedge.sh gains non-instant unit
  assertions (every out-of-range field / offset / pre-2000 year -> empty age,
  asserted on empty-vs-non-empty so they are independent of wall-clock now) and
  decision-level cases (month `00`, day `00`, offset `+40:00`, year `1970` ->
  skip, not rollback). Mutation-proved: reverting any one gate reddens both
  layers — the unit cases return fabricated ages and the decision cases ROLL
  BACK. A leap-second counter-guard pins the ss<=60 bound so the gate is not
  over-tight.
- client/tests/auto_upgrade_test.yaml gains a render-level guard that all three
  gates are present and the field-range gate sits before the epoch math.

Chart.yaml version 1.9.91 -> 1.9.93 (chart-version-guard: template changed;
1.9.92 already taken on develop by #942).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ulling them (backend#2935) (#948)

* fix(chart): pin CronJob memory requests==limits so a node OOM stops culling them (backend#2935)

The ap-workspace-auto-upgrade CronJob was exit-137 (SIGKILL) killed on every
hourly tick on a live edge, and image-refresh failed in lock-step (six paired
failures) — together, which points at ONE shared cause, not two independent
container-limit overruns.

Diagnosis (measured, not assumed):
- Both CronJobs already declare memory limits: auto-upgrade 256Mi, image-refresh
  128Mi.
- Neither workload's own working set approaches its limit. helm's peak on this
  chart is ~50Mi (repo update + search + `helm template` render); kubectl's is
  ~40-80Mi (one `get deployment -o json` + `rollout status`; the curl HEADs
  return headers only; jq ~2Mi). So the limit was never the binding constraint,
  and there is no leak or unbounded read (both scripts read bounded JSON and
  stream through awk).
- What they share is QoS: both are Burstable with requests BELOW real usage
  (128Mi<256Mi, 64Mi<128Mi). A Burstable pod running above its memory request
  draws a high oom_score_adj, so under NODE memory pressure the kernel
  OOM-killer targets it first. The telemetry reason is "Error, exit code 137"
  (not OOMKilled), consistent with a node-level kill rather than a container
  hitting its own cgroup limit. auto-upgrade at :23 and image-refresh at :22
  overlap, so a single pressure event culls both.

Fix — the repo's established remedy for exactly this exit-137 signature:
pin requests.memory == limits.memory on both CronJobs (auto-upgrade 256Mi,
image-refresh 128Mi). This reserves the memory and lowers oom_score_adj (the
pods stay Burstable) so the node OOM-killer no longer prefers them — identical
to jobs-manager/pods-monitor after their exit-137 mass-restart OOM (#642,
backend#1144), the GPU device plugins (#919), and wait-for-mysql (backend#2913).
Limits are UNCHANGED — this is not a "move the cliff" bump; the measured
footprints show >3x headroom already. CPU stays burstable (a share weight,
throttled not killed — backend#2418).

Guard: auto_upgrade_test.yaml and image_refresh_test.yaml now assert
requests.memory == limits.memory on each CronJob pod, so the gap that made them
easy victims cannot silently reopen. Chart version bumped 1.9.91 -> 1.9.92.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(chart): make the two CronJobs Guaranteed QoS, not just memory-pinned (backend#2935)

Addresses @saadqbal's review on client#948. The memory-only pin was the wrong
mechanism for the claim it carried: a Burstable pod's oom_score_adj is
1000 - 1000*memRequest/nodeCapacity, fixed at container start and independent of
usage, so raising the request 128Mi->256Mi moves auto-upgrade ~992->984 on a 16Gi
node and image-refresh 999->998 on a large node — it does NOT "stop the OOM-killer
preferring the pod". Only Guaranteed QoS (oom_score_adj -997) does that.

These two CronJobs are the only chart pods one line from Guaranteed: zero
initContainers, memory already equal, cpu the sole blocker. (#642 pinned
jobs-manager's memory but could not reach Guaranteed there — it renders an
unresourced init container; here we can.) So pin cpu too:
- auto-upgrade:  cpu request 50m->500m (== limit); memory already 256Mi==256Mi.
- image-refresh: cpu request 20m->200m (== limit); memory already 128Mi==128Mi.
Limits never moved — only requests rose to meet them.

The two levers now do two honest things: the memory RESERVATION reduces the node
overcommit that let pressure build (the structural half — #2935's cause), and
Guaranteed's -997 keeps the pod off the OOM-killer's shortlist under pressure (the
mechanism). Reservation-vs-throttle tradeoff stated in values.yaml; helm/kubectl
are I/O-bound so the cpu cap does not slow real work; Forbid + startingDeadlineSeconds
bound the Pending case.

Guards now assert cpu AND memory request==limit on both pods (the in-manifest proxy
for Guaranteed given no initContainers; client#922's checker will assert the class
directly). Comments corrected to credit each lever with what it actually delivers.
Chart 1.9.92 -> 1.9.93 (develop advanced to 1.9.92 under this branch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@LukasWodka
LukasWodka requested a review from saadqbal as a code owner September 1, 2026 10:42
@LukasWodka

Copy link
Copy Markdown
Contributor Author

bugbot run

@waqaskhanroghani
waqaskhanroghani removed the request for review from saadqbal September 1, 2026 10:43
@tracebloc-release-train tracebloc-release-train Bot added the gate-nudge Toggled by the release train to (re-)fire the fr-gate label Sep 1, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 526f4ad. Configure here.

LukasWodka and others added 4 commits September 1, 2026 14:40
…ss for real (backend#2872) (#922)

* docs(qos): correct the false Guaranteed-QoS claims and assert the class for real (backend#2872)

Six claims in this repo said a workload had Guaranteed QoS. None did, and two
files contradicted themselves internally -- install-client-helm.sh:38 said
"requests == limits (Guaranteed QoS)" while :324 said "Guaranteed QoS is lost by
design", and install-k8s.ps1 carried the same pair. The training-envelope claims
went stale when backend#2418 made CPU a request-only share weight; nobody
noticed because no test tier could assert a QoS class.

Corrected: values.yaml, values.schema.json (which SHIPS to users),
jobs-manager-deployment.yaml, mysql-deployment.yaml, install-client-helm.sh,
install-k8s.ps1.

Deleted the false history at values.yaml:964, which called jobs-manager
"BestEffort QoS in older releases". It was Burstable from day one: before #66
introduced the resources block the template hardcoded requests cpu 100m /
memory 256Mi and limits cpu 500m / memory 512Mi
(git show 2d9d013^:client/templates/jobs-manager-deployment.yaml). Requests AND
limits set, so never BestEffort. The pod was under-resourced, which is the real
cause; the class was never the difference.

Amended the ACCURATE comments too. They attributed Burstable to cpu req != lim
alone, which is true but incomplete and would send the next fix attempt down a
dead end: ComputePodQOS requires requests == limits in every container INCLUDING
init containers, and jobs-manager renders an unresourced init-writable-data
whenever hostPath.enabled=true while mysql renders an unresourced
mysql-format-guard UNCONDITIONALLY. Measured on the rendered chart: equalising
cpu buys Guaranteed for jobs-manager only on a CSI cluster, and for mysql
nowhere.

New scripts/tests/pod-qos-class.{py,bats} DERIVES each pod class with the
kubelet rule instead of asserting the values believed to imply it -- the defect
shape that let all of this survive. Asserted per hostPath mode, because the mode
changes the answer. Six mutations proven to redden; two of them (an ignored
memory dimension, a workload silently becoming BestEffort) exposed real gaps in
the first version of the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(qos-check): only cpu and memory count toward the class (backend#2871)

The first version of pod-qos-class.py treated ANY resource key as
qos-relevant. ComputePodQOS skips everything isSupportedQoSComputeResource
rejects, so a container whose only requests are nvidia.com/gpu and
ephemeral-storage -- exactly what client-runtime._get_gpu_resources produces
for every GPU training pod -- has EMPTY qos-relevant maps and the pod is
BestEffort, not Burstable.

So the checker would have reported those pods as Burstable and quietly agreed
they were fine: the same defect shape it exists to catch. Found while verifying
backend#2871 against the real code rather than trusting the ticket.

Two tests added (gpu-only -> BestEffort; extended resources alongside equal
cpu/memory -> still Guaranteed, so the rule is not "ignore unknown keys"), and
the mutation back to the old behaviour reddens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(chart): bump to 1.9.88 — develop reached 1.9.87 while this PR was open

* docs(qos): the class follows DERIVE_JOB_ENVELOPE, not the template (backend#2872)

Review on rfcs#63 (@saadqbal) caught this sweep swapping one wrong QoS claim for
another -- the one thing it cannot afford to do. Three of my own claims here had
the same defect: they read as though the derive path's Burstable envelope were
the DEFAULT.

It is not. DERIVE_JOB_ENVELOPE is off by default (client-runtime
jobs_manager.py:2664), and the fallback assigns the literal cpu=1,memory=2Gi to
requests AND limits, so those pods are GUARANTEED -- client-runtime says so
outright at :2412: "the literal (and a symmetric env-override) has request ==
limit, so those pods are Guaranteed ... The derive path is deliberately Burstable
on CPU instead." There is even a test named
test_derivation_disabled_by_default_uses_literal.

So values.yaml, values.schema.json (which ships) and the template comment now
split by the flag and state BOTH classes, rather than naming one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(chart): bump to 1.9.90 — develop reached 1.9.89 while this PR was open

* test(qos): derive the expected class set from the render, not a hand-list (backend#2872)

Bugbot, and it is right about the guard I built to close this exact shape. The
expected table restated SIX workload names in the bats file. The chart renders TEN:
t-auto-upgrade, t-image-refresh, t-egress-reachability-check and
t-storage-assertions-check were classified by the checker and then ignored by the
assertion, so a silent Burstable<->Guaranteed change on any of them stayed green.
And the init-container check tested that two names APPEARED -- a membership test
where a set comparison was needed -- so init-mysql-data could vanish with nothing
reddening.

A restated list inside a guard against restated claims. CLAUDE.md rule 1, in the
file that cites it.

Now `pod-qos-class.py --expect <file>` compares the render against a declared
expectation by SET EQUALITY IN BOTH DIRECTIONS: a workload the chart starts
rendering fails until someone classifies it, and a row naming a workload the chart
no longer renders fails rather than being satisfied by nothing. The unresourced-init
set is asserted exactly, because that set is what decides whether Guaranteed is
reachable at all.

Three new tests prove the check can fail: a dropped row, a stale row, and a
vanished init container each redden. Five mutations proven, including a REAL chart
mutation -- stripping resources from image-refresh-cronjob.yaml, one of the four
workloads that was previously invisible -- which now reddens both mode assertions.

Verified: bats 53/53, helm unittest 641/641, drift 37/37, version guard ✓.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(qos): render the GPU device plugins — the chart's only Guaranteed pods (backend#2872)

Bugbot, and it is the sharpest gap yet: `_render` never enabled
gpu.devicePlugin, so neither nvidia-device-plugin-daemonset nor
amdgpu-device-plugin-daemonset was ever classified. Measured: both are
GUARANTEED -- which is exactly what client#919 bought. Stripping their
resources would return them to BestEffort, leave every GPU training pod
Pending, and this guard would have stayed green.

So the one place Guaranteed actually exists in this chart was the one place the
QoS suite could not see.

_render now takes extra --set pairs, and the suite asserts a class table PER
VENDOR -- the chart renders one plugin keyed on gpu.devicePlugin.vendor, so
asserting only nvidia would leave the amd template unclassified, the same
partial-coverage mistake one level down.

Side benefit worth naming: the checker's Guaranteed path is now exercised
against REAL chart output rather than only synthetic manifests. A classifier
that could never produce "Guaranteed" from the chart would previously have
passed every assertion in this file.

Proven with a real chart mutation, anchor asserted: renaming both `resources:`
blocks in gpu-device-plugin.yaml reddens the two new tests. My first attempt
used the wrong indentation, matched 0 lines, and reported "gap NOT closed" --
which is why the anchor assertion is there.

Verified: bats 55/55, helm unittest 641/641, drift 37/37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(qos): the "only route" claim was false, and the preflight skipped .py guards (backend#2872)

Review on client#922, all verified on this head rather than taken on trust.

- "Pod-level resources are the only route" was the same false QoS claim in the PR
  whose job is deleting false QoS claims. Resourcing every init container reaches
  Guaranteed too: verified by patching the render so every container including the
  init containers carries requests == limits, at which point pod-qos-class.py
  returns Guaranteed for BOTH t-jobs-manager and mysql-client. Rejected on cost
  (the reservation is held for the pod's whole life), not capability.
- KEP-2837 is beta in 1.34, not 1.36. Checked rather than trusted, as asked; the
  "measured on a real 1.36 cluster" mentions are about our own pin and stand.
- pyyaml-preflight enumerated .sh and .bats only, so its "the denominator is the
  tree" header was false and pod-qos-class.py was invisible to it. Now 23 guards,
  with .py treated as whole-file python -- extract_python finds only embedded
  blocks, so without that a python guard would fail closed while correctly wrapped.
  Mutation-proved: a bare `import yaml` reddens test 1 naming pod-qos-class.py.
- Deleted _class_of: dead after the --expect rewrite, and its unanchored `$1 ~ w`
  would have matched a sibling workload and asserted a different pod's class.
- Six claims, not ten, in both headers. CLAUDE.md rule numbers replaced with the
  rules themselves -- a reader in this repo cannot resolve a number that lives in
  a workspace file.
- _norm's zero-quantity exception stated: requests == limits == 0 derives
  Guaranteed here and is BestEffort on a cluster.

helm unittest 641/641, drift 38/38, check-facts, qos 17/17, preflight 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(qos): classify the lockdown Job, and fail closed on any unreached template (backend#2872)

Bugbot Medium on client#922, verified before fixing: `egress-enforcement-check`
renders only with `networkPolicy.training.enabled=true`,
`allowExternalHttps=false` AND a probe host set -- three values no mode in this
suite set, so the Job was never classified. Stripping its resources demoted it to
BestEffort with the suite green.

TWO FIXES, because one more mode closes the instance and not the class.

- A lockdown render mode plus `pod-qos-expect.lockdown.txt`, compared by set
  equality in both directions like every other mode. Mutation: removing the Job's
  `resources` block reddens test 18.

- A CROSS-MODE COVERAGE ASSERTION, both sides derived. The chart side greps the
  templates for a pod-bearing `kind`; the reached side is the union of a new
  `--sources` mode over all five renders. It reads helm's `# Source:` comment from
  the raw text, because `yaml.safe_load_all` discards it and a workload NAME cannot
  say which file produced it -- a template that renders nothing contributes no name
  to notice the absence of. Empty on either side fails closed. Mutation: a new
  conditionally-rendered Deployment no mode reaches reddens test 19 by name.

The coverage guard earned itself immediately: it caught my own first draft passing
`gpu.enabled` / `gpu.vendor` instead of `gpu.devicePlugin.*`, so the two GPU modes
were rendering no device plugin at all. That is exactly the failure it exists to
report, found on the way in.

Merged develop; the manifest conflict is a hash, so the only correct resolution is
the recomputed value, not either side.

helm unittest 641/641, drift 38/38, qos 19/19, check-facts 14/14, preflight 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(qos): the coverage guard held its own copy of the kind list (backend#2872)

Bugbot on client#922, and it is my own restatement one commit old: the coverage
assertion grepped for a hand-written
`Deployment|StatefulSet|DaemonSet|Job|CronJob` while `POD_KINDS` also carries
`Pod`. A raw Pod template was therefore absent from the denominator, so if no mode
rendered it, coverage stayed green and `--expect` never saw it -- a BestEffort
demotion on that pod would not fail anything.

Fixed by removing the copy, not by adding `Pod` to it: a new `--kinds` mode prints
POD_KINDS and the bats side builds its grep pattern from that, so the two cannot
disagree again. Empty output fails closed -- an empty alternation would make the
grep match every template or none, and either way the set comparison stops meaning
anything.

Mutation, Bugbot's exact scenario: a `kind: Pod` template gated behind a value no
mode sets. With the derived list, test 19 fails naming `zz-raw-pod.yaml`; with the
old hand-written list re-pasted in its place, the same template is invisible and
test 19 passes. Both measured.

helm unittest 641/641, drift 38/38, qos 19/19, preflight 3/3, shellcheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(qos): the class follows the flag AND whether the pod asks for a GPU (backend#2872)

Asad, review of #922. The correction overshot in the three places that ship:
values.schema.json, values.yaml and the jobs-manager comment all said the class
follows the flag, full stop. On a GPU cluster it does not -- client-runtime
resolves a GPU envelope to nvidia.com/gpu and ephemeral-storage only, neither is
QoS-relevant, so the container sets no cpu/memory at all and the pod is
BestEffort under EITHER setting.

That matters because the schema description ships to operators, and the
BestEffort/oom_score_adj argument used to justify Guaranteed elsewhere is exactly
what a GPU operator would conclude does not apply to them -- then flip
DERIVE_JOB_ENVELOPE on to escape Burstable and change nothing on their cluster.
All three now carry the same sentence: cpu-only + off -> Guaranteed, cpu-only +
on -> Burstable, GPU -> BestEffort either way. pod-qos-class.py already encoded
the carve-out (345ab1d in this PR); the prose asserted the opposite of the
chart' own checker.

Dropped the verbatim client-runtime quote that propped up the Guaranteed claim.
A cross-repo prose quote is a dependency nothing checks -- client-runtime#457
exists to delete one in the other direction -- and it pinned the very sentence
being corrected here. Cite the behaviour and where it lives, do not copy it.

Declared the checker' third divergence: a limits-only container reads Burstable
here and is Guaranteed on a cluster, because the API server defaults requests
from limits before the class is computed and this reads the rendered manifest.
Safe direction, no chart pod does it -- written down because the other two are,
and an undeclared divergence is paid for by whoever meets a red CI on correct
code with nothing to read.

helm unittest 641/641 (the body said 631 -- fixed there too). bats
pod-qos-class: 19/19. make drift: all 38 guards green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(chart): bump to 1.9.94 (backend#2872)

develop reached 1.9.92 when #942 merged, so this branch no longer sat above
it and `chart content => Chart.yaml version bump` failed. The gate compares
against the develop tip (BASE_SHA), not the fork point, so a bump goes stale
whenever develop moves under a long-lived branch.

Caught by re-running the check rather than reading it: the PR still showed a
green pass from before #942 landed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(qos): correct the claims #942 falsified, and assert REACHABILITY (backend#2872)

Three things, all from @saadqbal review of client#922.

1. #942 FALSIFIED TWO CLAIMS THIS PR ADDS. Its `wait-for-mysql` is
unconditional and unequal on BOTH dimensions (cpu 10m/100m, memory 16Mi/64Mi)
with no values key, so `values.yaml` and `jobs-manager-deployment.yaml` were
both wrong to say equalising cpu buys Guaranteed for jobs-manager on a CSI
cluster. Measured on the merged tree with hostPath off and every resources.*
knob equalised:

  t-jobs-manager  Burstable  wait-for-mysql:cpu(req=10m,lim=100m);
                             wait-for-mysql:memory(req=16Mi,lim=64Mi)

Guaranteed is now unreachable through values on EVERY cluster. Both comments
corrected.

2. AND THE GUARD STAYED GREEN THROUGH IT -- this PR subject one turn deeper.
The goldens pin the CLASS and the unresourced-init SET; `wait-for-mysql` is
resourced, merely unequal, and jobs-manager was already Burstable, so nothing
moved. `scripts/tests/qos-reachability.sh` asserts reachability: it equalises
every resources.* knob derived from values.schema.json, classifies through
pod-qos-class.py (no second copy of ComputePodQOS), and compares per-pod
verdicts to a golden by set equality both ways.

Proved rather than argued -- adding an unconditional unequal init container to
an ALREADY-Burstable pod (the exact #942 shape) leaves all 19 existing QoS
tests green and reddens this guard, naming the container. The golden records
blocking container NAMES, not quantities, so a resource bump does not churn it.

3. BOTH INSTALLER HEADERS called the training pod BURSTABLE flat. On a GPU edge
it is BestEffort: the GPU path requests only nvidia.com/gpu / amd.com/gpu plus
ephemeral-storage, and client-runtime `_get_gpu_resources` never reads
RESOURCE_REQUESTS / RESOURCE_LIMITS there. backend#2871 raised both GPU
BestEffort workloads; client#919 fixed the device-plugin half and the issue was
CLOSED with the training half unfixed, so the record is written into the two
files rather than left as a reference.

Chart 1.9.94. drift 43/43, helm unittest 657/657, QoS 19/19, Pester 887/887.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(qos): walk EVERY resources node, not just the top-level one (backend#2872)

autoUpgrade, imageRefresh and egressProxy each expose requests and limits
under their own parent, so they were never equalised and their pods read
blocked while an operator could already reach Guaranteed through values.
The guard was agreeing with its own incomplete domain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(qos): probe the free-form resources node instead of recording it blocked (backend#2872)

Builds on 691841e rather than replacing it -- the recursive walk and the
fatal/informational note split are that commit, and they are right.

One verdict was still wrong: `telemetryCollector.resources` is
`{"type": "object"}` with no declared keys, so it fell into #NOTAPAIR and
t-telemetry-collector was recorded `blocked`. But the schema does not forbid
keys either, and the DaemonSet renders the node with `toYaml $tc.resources` -- a
wholesale passthrough -- so a requests/limits pair set there reaches the pod.

Measured, with ONLY that key equalised:

  t-telemetry-collector  Guaranteed  every container has requests == limits
  otel-collector {requests: {cpu: 1000m, memory: 1Gi}, limits: {same}}

So `blocked` was a verdict reached by never having looked -- the one thing this
guard exists to refuse. Free-form nodes are now PROBED with the canonical pair
and the run reports that the verdict came from a probe rather than a
declaration. `gpu.devicePlugin.*` stays #NOTAPAIR: its flat one-pair shape is
applied to both sides by construction (client#919 makes the split form
unexpressible), so there is genuinely nothing to equalise.

Golden: two rows corrected, with the reason recorded beside them.

Mutation-proved: disabling the probe branch returns both rows to `blocked` and
reddens the guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(qos): the pod-level branch needs the cpu/memory carve-out too (backend#2872)

KEP-2837 pod-level resources short-circuit above the container walk, so an
envelope of extended resources only returned Burstable where the kubelet
says BestEffort. The container path already carried this guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Revert "fix(qos): the pod-level branch needs the cpu/memory carve-out too (backend#2872)"

This reverts commit 51681eb.

* test(qos): report the checker output when an expectation fails (backend#2872)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(qos): record that #948 moved both CronJobs to Guaranteed (backend#2872)

The four QoS failures were real and were NOT visible on the branch head. CI
evaluates the pull_request MERGE of head into base, and #948 (526f4ad) landed on
develop after this branch last merged it: pinning CronJob memory to
requests == limits moves t-auto-upgrade and t-image-refresh from Burstable to
Guaranteed. On the head alone every case passes, which is why it first read as an
environment difference; reproduced by merging develop locally.

Measured under CI pinned helm v3.15.4 on the merge -- both CronJobs Guaranteed on
every profile -- and all five expectation files updated. Nothing in the chart
prose called the CronJobs Burstable, so no comment needed correcting.

Rows re-derived from measurement rather than restored from a pre-revert copy:
doing the latter would have reintroduced the TEST that 2d25d1a reverted without
its code. That revert and b0e4500 diagnostic both stand untouched.

Separately worth recording: local helm here is v4.1.1 while CI pins v3.15.4, so
chart renders verified locally were not on the CI configuration. Everything above
ran under 3.15.4.

drift 43/43, helm unittest 660/660, full bats 1647/1647 -- on the merge, v3.15.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(qos): harden the coverage loop renders, and say what the finding did NOT reproduce (backend#2872)

Bugbot Medium: the coverage loop called `_render` in five case arms with no
`|| return 1` -- the only `_render` calls in the file without it -- so a failed
render fell through and whatever it left behind was measured for coverage.

THE MECHANISM AS DESCRIBED DOES NOT REPRODUCE, and that belongs on the record
rather than being implied away. The finding said `helm template` streams, so a
failed render can still emit earlier pod documents. Measured on the pinned
v3.15.4 AND on v4.1.1, across three failure modes -- a late template `fail`, a
values-schema violation, and invalid YAML in the rendered output -- helm buffers
the whole manifest and writes 0 bytes every time. With an empty file the
classifier already refuses ("no pod-bearing template in this render") and the
`--sources` call already carried `|| return 1`.

Fixed anyway, because "safe" was resting on two accidents this test asserts
neither of: helm buffering, and the classifier refusal. A helm that ever did
stream would reopen it in silence, and one word per arm is cheaper than that
dependency.

Mutation-proved: a late `fail` in requests-proxy-service.yaml now reddens
"coverage: every pod-bearing template is classified by at least one mode".
bats-hygiene 14/14 accepts the form.

drift 43/43, helm unittest 660/660, full bats 1647/1647 under v3.15.4 on the merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(qos): re-land the pod-level cpu/memory carve-out, verified under helm 3.15.4 (backend#2962)

KEP-2837 pod-level resources short-circuit above the per-container walk, so a
pod-level envelope of extended resources only (nvidia.com/gpu + ephemeral-storage
and nothing else) returned Burstable where ComputePodQOS says BestEffort: with
pod-level resources set the kubelet derives the class from them alone, filtered to
cpu/memory, and does not fall back to the containers. The per-container path already
carried this guard (backend#2871); this is its missing pod-level half.

The first attempt (51681eb) was reverted after CI reddened four --expect tests,
blamed on a helm-3.15.4-vs-4 render difference. Re-verified under the pinned helm
v3.15.4 (identical build to CI, v3.15.4+gfa9efb0): no chart workload renders
pod-level resources on any hostPath mode under 3.15.4 OR 4, so the carve-out is inert
on every chart render and cannot flip an --expect result. The reverted red was not
reproducible under the pinned helm in isolation, in a matched Linux amd64 container,
or in the full bats suite; its diagnostics were swallowed by the pre-_ok assertion,
so it was reverted without evidence. The suite is now 21/21 green under helm 3.15.4,
and the _ok helper (added since) means a re-land cannot go blind again.

The new pod-level BestEffort case is built so it cannot pass for the wrong reason:
its containers are themselves Guaranteed, so a fall-through would read Guaranteed and
the reverted no-carve-out branch read Burstable -- the case rejects both and only the
carve-out yields BestEffort.

Closes tracebloc/backend#2962

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Revert "fix(qos): re-land the pod-level cpu/memory carve-out, verified under helm 3.15.4 (backend#2962)"

This reverts commit e777910.

The carve-out encoded the wrong kubelet rule. Verified against the Kubernetes
source (pkg/apis/core/v1/helper/qos/qos.go + component-helpers/resource/helpers.go):
ComputePodQOS gates the pod-level branch on IsPodLevelResourcesSet, whose supported
set is {cpu, memory, hugepages} only -- an extended-resources-only envelope
(nvidia.com/gpu + ephemeral-storage) makes it FALSE, so with the
PodLevelResourcesFixKubeletQOSClass gate (Beta ~1.36) the kubelet FALLS THROUGH to
the per-container walk and returns Guaranteed when the containers are Guaranteed --
NOT BestEffort. The reverted commit returned BestEffort and asserted it with a test
built on Guaranteed containers, which is the pre-fix behavior and is wrong for the
1.34-1.36+ clusters this chart targets. Only latent (no chart renders pod-level
resources) kept it from being caught.

backend#2962's premise and acceptance criteria rest on that pre-fix behavior; the
finding is written up on the issue for re-scoping. Removing the wrong claim from this
PR rather than papering over it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Arturo Peroni <arturo@tracebloc.io>
* fix(chart): collect the upgrade path own logs (backend#2935)

When a customer edge stops upgrading we could see THAT it failed and never
WHY: helm and refresh were not in classAContainers. The agreement guard could
not see them either -- it enumerated three kinds and missed CronJob.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tests): preflight PyYAML in the class-A count heredoc (backend#2935)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(telemetry): pin that the upgrade containers are collected, not just agreed with (backend#2961)

@saadqbal removed `- helm` and `- refresh` from `classAContainers` and
nothing went red: the agreement guard passed at 4 globs and all 61
telemetry unittests still passed. The fix was removable without one leg
noticing.

The cause is that the guard checks AGREEMENT -- every glob targets a
container the chart really deploys -- which is a different property from
COVERAGE. Deriving the expected count from the declaration was right in
itself, and it also means the count moves WITH the list, so a shrink is
invisible to it by construction.

Coverage has to be pinned by name. Two matchRegex assertions in the
existing Class A path test, and two more in the partial-map test so a
fleet install that sets some Collector keys but not `classAContainers`
is covered too.

Mutation-proved against the exact removal that motivated this: dropping
either name, or both, now fails 2 tests; restored, 657 pass.

Chart 1.9.94 -> 1.9.95: #922 also wrote 1.9.94 against a 1.9.93 base, and
because both are literally equal git will not conflict and the version
guard -- which only asserts `version:` CHANGED -- passes on each. With
`strict: false` fleet-wide the second merge never re-evaluates, so both
would ship different chart content under one published 1.9.94.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#2849) (#931)

* fix(installer): bound every remaining unbounded wait on the Windows path (backend#2849)

An unbounded external call against a SICK dependency does not fail, it
BLOCKS — and this journey has now spent four runs learning that the hard
way, twice (backend#2675's reboot prompt, backend#2849's empty exit code).
Both were "the caller spent its whole budget and reported a timeout that
named nothing". Auditing the never-executed Windows path for the same
shape turned up three more, all on the route the e2e leg is about to walk
for the first time.

This file already has the rule — Invoke-BoundedProcess, "installer
external-call timeout rule" — so each of these is a site that predates it,
not a new policy.

1. THE ENGINE-UP PROBE (Install-DockerDesktop). The wait that exists to
   survive a bad Docker start read `docker info` natively, so a wedged
   daemon — a half-open \\.\pipe\docker_engine, which is what Docker
   Desktop leaves when it starts and gives up — blocked the probe itself.
   New Test-DockerEngineUp goes through Invoke-DockerCli at 15s. A timeout
   reads as "not up", never as up: mistaking a wedged daemon for a healthy
   one would send the install into Step 3 on an engine that is not there.

2. THAT WAIT'S DEADLINE WAS NOT A DEADLINE. `$maxWait = $waitMin * 20`
   assumed each pass costs exactly its 3s sleep, which stops being true
   the moment a probe blocks — so the 10-minute cap the code believed it
   had was an iteration count. Now wall-clock, checked in the loop guard.

3. THE STEP-3 PREFLIGHT READERS (Get-PfRuntimeMemGb/MemMib/Cpu) are the
   first thing to touch Docker after the engine wait, and had the same
   bare call. An install that got past Step 2 on a sick engine would hang
   at the ENTRY to Step 3, before anything printed, instead of falling
   back to the host reader as their own $null contract promises.

4. THE CLI INSTALLER CHILD had the only `WaitForExit()` in ~7400 lines
   with no bound. It runs `irm <cli install.ps1> | iex` — a network fetch
   we then execute — so a stalled handshake or a slow CDN parked the whole
   install with no output and nothing to kill. 10-minute deadline, then
   killed; a timeout is non-fatal because a failed CLI install already is
   (Step 4 falls back to the legacy credential flow).

Scope, deliberately: this does NOT try to make Docker Desktop supported on
Windows Server. It makes the answer ARRIVE — bounded and named — instead
of arriving as a 30-minute silence. The field test (backend#1232) reached
a working environment on Windows 11, so Steps 3-6 do work; what the e2e
host adds is an unsupported SKU, and one run should be able to say so.

The two Get-Pf* tests that mocked `docker` natively now mock
Invoke-DockerCli — same assertions, seam moved out one layer. Nothing
removed; a timed-out-probe case is added, which was previously unreachable.

958 Pester assertions pass on BOTH 5.5.0 and 6.0.1 (was 948).

Part of tracebloc/backend#2849.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(installer): a reboot-pending Docker install (exit 3010) is a success, not a failure (backend#2849)

Finding 1 of backend#2849: on the Windows Server 2022 journey host Docker
Desktop installed cleanly (full tree on disk, no error log) yet the run
aborted. Docker's installer returns 3010 (ERROR_SUCCESS_REBOOT_REQUIRED)
whenever the WSL2 backend adds Windows features -- our exact --backend=wsl-2
path, and expected success per Docker's enterprise-deployment docs -- but
Invoke-TrackedInstall judged success as `$p.ExitCode -eq 0`, so a completed
install was misfiled 'failed'. That is the client#611 idiom one layer up.

Add an opt-in -SuccessExitCodes to the shared Invoke-TrackedInstall (default
@(0)) and pass the documented reboot-pending SUCCESS codes at the three
installer call sites: Win32 3010/1641 (a direct installer, e.g. Docker) and
winget 0x8A150109/0x8A15010B as Int32 (-1978334967/-1978334965). winget's
0x8A15010A (REBOOT_REQUIRED_FOR_INSTALL, a real failure) is deliberately
excluded. The real code is preserved in the return so a reboot-pending 'ok'
stays visible in the log.

Follows #913 (finding 2, the empty exit-code slot), which made the code
readable in the first place. The WSL-update helper keeps its bare -eq 0 on
purpose: its symptom was the null code (fixed by #913), it is non-fatal, and
a reboot code there is speculative.

Tests: reboot codes accepted end-to-end (incl. the negative winget HRESULTs),
the winget failure reboot code stays 'failed', the default set stays @(0) so
non-installer callers don't start tolerating 3010, and both Docker paths opt
in. Full Pester suite 801/0/15; PSScriptAnalyzer 0 errors on install-k8s.ps1;
scripts/manifest.sha256 regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(installer): distinguish reboot-INITIATED from reboot-required in the Docker install log (backend#2849)

Review follow-up (shujaatTracebloc): the post-install log line claimed
"continuing to bring up the engine" for BOTH reboot-pending families, but
1641 / winget 0x8A15010B (INSTALL_REBOOT_INITIATED) mean the installer has
already started restarting the machine -- so on those codes that line is the
last thing written before the box goes down, reading as a script that carried
on when it didn't. Given this PR exists because a log said the wrong thing,
the message should not.

Add $script:INSTALLER_REBOOT_INITIATED_CODES = @(1641, -1978334965) (a subset
of the OK codes) and branch the Docker log: an INITIATED code says the machine
is restarting and the install resumes via the reboot handoff; a merely-required
reboot (3010 / 0x8A150109, box still up) keeps "continuing to bring up the
engine". Behavior is unchanged -- both remain 'ok' and press on; only the log
text differs.

Test: the INITIATED subset is exactly @(1641, -1978334965), is a subset of the
OK codes, excludes 3010/0x8A150109, and the Docker handler branches on it.
Pester 802/0/15; PSScriptAnalyzer 0 errors; scripts/manifest.sha256 regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(installer): an initiated reboot arms a fresh resume + stops, on both Docker paths (backend#2849)

Bugbot (Medium): the reboot-INITIATED log line claimed "resumes via the reboot
handoff", but Register-ResumeAfterReboot's RunOnce is armed only in Step 1 and
is already spent by the Step-2 Docker install — so no handoff was actually
armed. The winget path (tried first, the one that can return the winget
HRESULT) never logged or stopped either, so an initiated reboot could drop the
box mid-install and leave the run looking interrupted / the engine wait Err-ing.

Replace the inline log with Invoke-PostInstallReboot, called from BOTH Docker
install paths so the handling can't depend on which ran:
 - REQUIRED (3010 / winget 0x8A150109, box still up): log and continue, as before.
 - INITIATED (1641 / winget 0x8A15010B, already restarting): arm a FRESH
   resume-after-reboot and stop with the declared exit 2 — the same handoff Step 1
   uses — so the install genuinely resumes instead of racing the reboot. The
   message no longer promises a handoff that isn't armed.

Our flags never allow a reboot (--quiet; no winget --allow-reboot), so INITIATED
stays the unexpected-but-safe branch; this makes it correct rather than merely
quiet. Tests: the initiated branch arms a resume + exit 2 and both paths route
through the handler (source, since exit 2 would end Pester); the REQUIRED/no-op
branches never arm or exit (behavioral, Should -Invoke ... -Times 0). Pester
804/0/15; PSScriptAnalyzer 0 errors; manifest regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(installer): the bootstrap must not close the user's window (#577, backend#2849)

Reported from a real Windows machine testing from develop: "it just
closed the PowerShell".

#577 -- "the PowerShell installer must never terminate ungracefully,
always show the user a clean what-happened" -- was fixed in
install-k8s.ps1, which runs as a CHILD process where `exit` costs
nothing. install.ps1 was missed, and it is the one that runs INSIDE the
user's console via the documented `irm ... | iex`. A top-level `exit`
there ends THEIR session: the window closes and takes the outcome with
it, which is exactly the symptom #577 closed.

Not only a failure path. `exit $LASTEXITCODE` after the child returns
fires on EVERY run, so a perfectly successful install also slammed the
window shut over its own summary. All three exit sites are affected
(the child's code, the platform gate, the top-level catch).

The exit CODE still propagates untouched. Swapping `exit` for `return`
would have been the obvious fix and is wrong: `powershell.exe -Command`
callers would then read 0 for every outcome, and the e2e harness reads
this code to tell install-k8s.ps1's declared `exit 2` reboot handoff
from a real failure -- a failed install would book as a pass. So
Complete-Bootstrap holds the window open long enough to be read, then
exits exactly as before.

The hold is BOUNDED (60s) and gated on the same Test-CanPrompt predicate
install-k8s.ps1 uses, so CI, a service and any piped/redirected stdin
take the no-hold path -- an unbounded hold is the very class this ticket
has spent its life removing. A host that cannot report keystrokes (ISE,
a redirected console) throws on KeyAvailable and is caught.

977 Pester assertions pass on BOTH 5.5.0 and 6.0.1.

Part of tracebloc/backend#2849.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(installer): a branch ref may contain '/', because every branch does (client#917)

Measured on a real Windows box, testing this very branch:

  Installation stopped: Ref 'fix/2849-bound-the-docker-probes' contains a
  path separator or '..' -- refusing to build a fetch URL from it.

The belt-and-suspenders guard refused '/' outright, which broke the ONLY
flow it exists for. Every real development branch is `fix/1234-thing` or
`feat/...`, so the documented developer override could fetch `develop`,
`staging` and `main` and nothing else -- while the whole point of the
escape hatch is testing UNRELEASED code, which lives on feature branches.

'..' is the actual traversal lever and is still refused on EVERY path,
opt-in or not. The R8 property never rested on '/': a TAG still cannot
carry one, because the vX.Y.Z shape check rejects it -- so the case that
comment names, 'v1.2.3-../../heads/main', is refused twice over. A '/' is
accepted only on the path that has already announced itself as an
unverified branch install and printed the four-line warning.

Multi-segment refs are held to exactly the shape a single segment is:
validated segment by segment, so a leading, trailing or doubled slash
(an empty segment) and a bare '.' are all refused.

985 Pester assertions pass on BOTH 5.5.0 and 6.0.1.

Part of tracebloc/backend#2849.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(installer): bound the docker CLASS, drain the console, stop the off-Windows stall

Addresses @LukasWodka's change-request and @aptracebloc's independent review on
client#917, plus both open Bugbot findings. Each fix is mutation-proven below.

1. THE GUARD DEFENDED ONE VERB, THE TITLE CLAIMED THE CLASS (blocking)

   `Should -Not -Match '\(docker info'` has an input domain of one needle, so a
   new unbounded native call walked past it -- Lukas injected `docker ps -a` and
   watched all 38 tests stay green. Seven native calls were live at head, and not
   in a corner: `Set-ClusterAutostart` (MAIN install path), three cluster-REUSE
   inspects, and the two `-Diagnose` calls, i.e. the bundle a user collects
   BECAUSE Docker is wedged -- bare there hangs the one tool meant to explain the
   hang.

   All seven now go through `Invoke-DockerCli` with a 20s bound. Every timeout
   degrades in the safe direction: autostart logs and skips (k3d already sets
   unless-stopped), the advisories stay silent rather than inverting into a false
   warning, and the dataset-mount precondition is SKIPPED rather than fired --
   inferring "no mount" from a wedged daemon would abort a correct cluster.

   The guard is now the class, asserted on the PowerShell AST rather than text:
   no native `docker` invocation is unbounded, where bounded means
   `Invoke-DockerCli` or a `Start-Job` reaped by `Wait-JobWithProgress`. AST is
   load-bearing -- ~10 Log/return strings in this file contain "docker run" /
   "docker build", so every text version either drowns in false positives or
   gets narrowed back into an instance guard. A second test requires the job
   sites actually be reaped on a deadline, closing "bounded-looking unbounded".
   Both fail closed on a parse error and refuse to pass vacuously.

2. A STALE KEYSTROKE COLLAPSED THE HOLD TO ZERO (install.ps1)

   `[Console]::KeyAvailable` reports what is QUEUED, not a press since the hold
   began, and nothing drained it. install-k8s.ps1 runs as a child on the same
   console with 11 Read-Host sites, so one extra Enter -- or the trailing newline
   of `irm | iex` -- made the wait false on its first evaluation and closed the
   window over the summary: the exact #577 failure this function exists to stop,
   under a line that had just promised to wait. Drained before the deadline wait,
   inside the same try. The test pins the ORDER, since draining after the wait
   would eat the user's real keypress and fix nothing.

3. THE WRONG-PLATFORM BAIL STALLED 60s WHERE NO WINDOW EXISTS

   `[Environment]::UserInteractive` is hardcoded $true on non-Windows .NET, so
   the predicate reduced to "stdin not redirected" and an instant, zero-cost
   bail-out held for a minute -- on the one branch that only runs off Windows,
   where `exit` from a child pwsh closes nothing. Now a bare `exit 1`.

   The `$bare.Count | Should -Be 1` assertion was a count standing in for a rule,
   which made this fix look like a regression. Replaced with the actual property,
   derived: no `exit` outside Complete-Bootstrap or the platform gate.

4. THE ELAPSED LABEL WAS THE LEAST HONEST THING ON SCREEN (Bugbot, both reviews)

   The deadline became wall-clock; the label still divided the iteration counter
   by 20. With the probe capped at 15s a wedged pass costs ~18s, so the loop
   exited at ~10 REAL minutes still reading "1 min elapsed", immediately before
   "didn't come up within 10 minutes". Derived from `$dockerStart`, the same clock
   as the deadline. `$maxWait` is gone entirely -- with the bound expressed as a
   deadline it had no job left, and keeping it invites the slip back in.

5. THE PROMPT PREDICATE WAS A COPY CHECKED AGAINST A COPY

   The old assertion hand-wrote the rule and tested install.ps1 only, so if
   Test-CanPrompt gained a condition the two would diverge silently and it would
   stay green. Now parsed out of BOTH files and required to agree, failing closed
   if either is unreadable. The doc comment no longer asserts the e2e journey
   takes the no-hold path -- an interactive desktop session satisfies both
   conjuncts, and that assumption was already measured false once on this
   journey. Correctness rests on the bound, not the predicate.

Also: the two bare `Should -Throw` now pin `-ExpectedMessage` (the tag-shaped
traversal case was satisfied by any of three refusals), and Set-ClusterAutostart's
tests move their seam to `Invoke-DockerCli` -- same assertions, plus timeout and
blank-line cases that were unreachable while the calls were bare.

The Helm winget gap Lukas raised is fixed in develop: #921's merged version
carried it, and develop's own invariant test (accepts == routes) now covers it.

MUTATION-PROVEN, each reverted in isolation:
  - Lukas's injected `docker ps -a`        -> both class guards redden (was green)
  - unbound the -Diagnose `docker ps -a`   -> both class guards redden
  - remove the input-buffer drain          -> drain-order test reddens
  - platform gate back to Complete-Bootstrap -> gate test reddens
  - diverge the two prompt predicates      -> drift test reddens (was green)
  - elapsed label back to `$i / 20`        -> same-clock test reddens

889 Pester assertions pass on BOTH 5.5.0 and 6.0.1, 0 failures. Both .ps1 parse
clean, check-style.sh clean, manifest regenerated.

Part of tracebloc/backend#2849.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(installer): the dashboard link follows CLIENT_ENV (backend#2849)

Reported from a real dev install on Windows: a `CLIENT_ENV=dev` run told
the operator to create a client and fetch credentials at
https://ai.tracebloc.io/clients -- the PRODUCTION dashboard -- and
credentials from there are then rejected by dev-api. The operator is sent
to the wrong place at exactly the moment they need the right one.

Hardcoded at THIRTEEN sites, while Get-BackendUrl sitting right above them
was correctly env-aware. Both installers had it; this is the PowerShell
half.

The hosts are the BACKEND'S OWN per-environment settings, not a guess --
DEVICE_VERIFICATION_URI / RESET_PASSWORD_URL in
xraybackend/settings/{dev,stg,prod}.py:

  dev  -> https://dev.tracebloc.io
  stg  -> https://stg.tracebloc.io
  prod -> https://ai.tracebloc.io

Get-TraceblocDashboardUrl uses the SAME vocabulary and the same
unknown->prod fallback as Get-BackendUrl, so the two can never disagree
about which environment an install belongs to -- and a test pairs them
per environment rather than asserting each alone, because the defect WAS
precisely those two disagreeing.

The guard that keeps it fixed: each of the three hosts must appear exactly
once in the file (as its switch arm), and no live link may carry a path --
a hardcoded link always has one (/clients, /my-use-cases), while the bare
host is only ever the mapping.

Caught in review of my own change: the bare-host substitution rewrote the
helper's OWN default arm into a call to itself, so the suite hung on
infinite recursion instead of failing. Fixed, and the "appears exactly
once" guard would now catch that shape too.

1019 Pester assertions pass on BOTH 5.5.0 and 6.0.1 (was 1013).

Part of tracebloc/backend#2849.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(installer): bind each docker job's reap to THAT job, not to its function

@LukasWodka's follow-up on the reap guard, and he proved it rather than asserted
it: the check matched `Wait-JobWithProgress -Job $x -TimeoutSec N` anywhere in the
enclosing FUNCTION, so one compliant job vouched for its neighbours. He added a
second, unreaped docker job to a function that already had a good one and watched
all 41 tests stay green.

The guard now walks from each native docker call out to its `Start-Job`, finds the
variable that job is assigned to, and requires a reap naming THAT variable. A job
with no assignment at all fails too -- nothing can reap what nothing names.

Mutation-proven with his exact probe (an unreaped second job inside
Test-ExistingClusterKubeletConfig, which already reaps $job correctly):

  [-] the Start-Job docker sites are actually reaped on a deadline, not merely in a job
      Test-ExistingClusterKubeletConfig starts docker job 'sneaky' but never reaps
      THAT job on a deadline

Was Passed=41 Failed=0. The failure now names the offending job variable.

902 Pester assertions pass on both 5.5.0 and 6.0.1, 0 failures. check-style.sh
clean, manifest regenerated.

Part of tracebloc/backend#2849.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(installer): prove every guard still catches its own bug (backend#2849)

A fix is not fixed until a guard has been SEEN to fail. Both of this
week's Windows defects were "already fixed", and nothing in CI could say
otherwise:

* #577 shipped a graceful-exit boundary and PR #588 DID touch
  install.ps1 -- but only its message. Every `exit` was untouched, so the
  bootstrap kept closing the user's console for another month. Reported
  from a real machine as "it just closed the PowerShell".
* install-k8s.ps1's own comment promised WaitForExit() "guarantees
  ExitCode is populated for every caller". It did not, so every failure
  in Invoke-TrackedInstall rendered `exited ` with an empty slot -- and a
  Docker install that SUCCEEDED with 3010 was filed as a failure.

Both were found by a human running the installer. The structural reason
nothing else could: most installer guards read a property off the SOURCE
TEXT, and such a guard passes forever once the string drifts -- "green"
and "no longer looking" are the same colour. And every exit path sits
behind `if (-not $env:TB_PESTER)` while every suite sets TB_PESTER=1, so
NO test in this repo has ever executed an `exit`.

mutation-check.ps1 reintroduces each fixed defect into a COPY of the tree
and requires the claiming suite to go red. 11 registered, 11 caught.
`-Dry` resolves markers only and says in as many words that it is not
evidence anything bites.

Four things it refuses to do, each because it has gone wrong somewhere:
never mutates the working tree (nothing to restore, nothing a SIGKILL
strands); never trusts a marker matching more than one line (a duplicated
line becomes a loud STALE, and an `After` anchor disambiguates the two
byte-identical Test-CanPrompt guards); never reports a catch without a
green baseline; never lets a mutation be a no-op.

A FRESH PROCESS PER RUN, found the hard way: the first version ran every
suite in one pwsh session and reported two baseline failures that do not
reproduce when the file is run alone -- Pester state survives between
Invoke-Pester calls, so run N is not run 1. A harness whose verdicts
depend on how many times it has already run cannot say whether a guard
bit. It also now NAMES a failing baseline instead of printing a count,
which is the same diagnostic gap these tickets are about.

Wired into installer-tests.yaml, and .cursor/BUGBOT.md gains the six
review rules this week produced -- fix-in-one-twin-only, guards behind
TB_PESTER, comments asserting guarantees, source-text assertions with no
mutation, numbers changed without their consumers, and preferring a pure
function to a source grep.

Part of tracebloc/backend#2849.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(installer): cover the CLI wait's handle and flush, which nothing did (Bugbot on #931)

Bugbot: the registry tracked the handle-cache / empty-ExitCode class only
on Wait-ProcessWithDeadline, and the CLI wait only as a missing deadline
-- so Install-TraceblocCli's own `$null = $p.Handle` and parameterless
`WaitForExit()` flush were source-text assertions with nothing behind
them. Correct, and the hole is exactly where this class keeps recurring:
Bugbot found the missing flush on #917, INSIDE the fix for the same class.

Worse than reported. The existing fakes give WaitForExit a no-arg
ScriptMethod returning NOTHING, so `if ($p.WaitForExit($cliWaitMs))` is
falsy and every one of them takes the TIMEOUT branch. "reports success
only when the installer exits 0" was passing through the kill path -- the
right verdict for the wrong reason -- and the success branch, where the
flush lives, was never executed by any test.

So the new fake models the REAL contract: WaitForExit(ms) returns $true,
and ExitCode stays $null until the PARAMETERLESS overload has drained the
streams. Two directions, so neither can pass by accident: with the flush
present the install reports success and the flush is asserted to have
happened; with ExitCode never readable it must WARN rather than credit an
install whose verdict it cannot read (the client#611 shape -- Step 4 warns,
Step 5 falls back to the legacy credential prompt).

Both lines now carry registered mutations, disambiguated by `After`
anchors since neither is unique in the file.

13/13 mutations caught. 1027 Pester assertions pass on BOTH 5.5.0 and
6.0.1.

Part of tracebloc/backend#2849.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(installer): the harness must catch via the CLAIMING guard, not any failure (@saadqbal on #931)

Asad was right, and the finding is the one this PR exists to prevent,
inside this PR: the harness could not fail for 11 of its 13 entries.

The sandbox copied only `scripts/`, but install-k8s.Tests.ps1 reads six
paths above it (../../docker/k3s-cuda/*,
../../client/templates/resource-monitor-daemonset.yaml). Unmutated in a
scripts-only sandbox that suite is FAIL=7 -- I reproduced it -- and the
predicate was `if ($fails -gt 0) { $caught++ }`, so every install-k8s.ps1
entry cleared on inherited failures alone. My own output said so and I
did not read it: install-k8s entries all reported >=8 failing while the
two install.ps1 entries reported 2 and 3. Asad proved the end state --
dashboard guard made vacuous AND the link reintroduced, run still 13/13.

Five fixes, his order:

1. THE SANDBOX IS THE WHOLE TREE (minus .git, which is 106MB of 122 and
   which no suite reads). Both baselines are now 0 in the sandbox, not
   just in the repo.

2. ATTRIBUTION, which he called the one that matters. Each entry names
   the guard it EXPECTS to fail, only that guard's failure counts, and a
   mutation that reddens the suite via some OTHER test is MISATTRIBUTED
   -- reported as a registry failure, as loud as SURVIVED, never a catch.
   An entry with no `Expect` is refused at marker resolution, because
   without one it could only be scored on a bare count. Every catch now
   prints WHICH test bit.

   It found a real one immediately: removing the CLI handle cache is a
   semantic NO-OP under the mocks (`$p.StartTime` on a pscustomobject
   returns $null silently), so no behavioural test could see it and the
   mutation was reddening the suite through two unrelated flaky tests.
   Added the source-level guard the Wait-ProcessWithDeadline site already
   has, for the same reason: handle reaping is .NET behaviour no fake can
   reproduce.

3. `After` IS A SCOPE, NOT A LOWER BOUND. Uniqueness is enforced with an
   anchor too (it was skipped entirely), and the search is bounded to a
   `Within` window -- otherwise `After 'function Read-RebootChoice {'`
   still saw Read-ClientName's byte-identical guard 3900 lines later.
   That stricter check caught this the moment it was added.

4. THE CHILD'S STDERR IS KEPT. `2>$null` discarded why a run produced no
   RESULT line, and under $ErrorActionPreference='Stop' it killed the
   harness on Asad's machine with fourteen bytes of red and no cause.

5. Pester pinned to the 5 major; `-MinimumVersion` took the newest
   installed and 6 is API-incompatible.

Still open, and I could not settle it either: whether mutation-check is a
REQUIRED check. The protection API 404s on my token too, so that is an
absence rather than a pass -- needs someone with admin scope.

13/13 caught BY the claiming guard. 1028 Pester assertions pass on both
5.5.0 and 6.0.1.

Part of tracebloc/backend#2849.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(installer): a crashed child is not a survived mutation, and the Pester pins agree (Bugbot on #931)

Two findings, both mine, both the same class this harness exists to close.

1. A CRASHED CHILD WAS SCORED "SURVIVED". Invoke-Suite returns
   FailedCount -1 when the child dies before printing RESULT, and -1 is
   not `-gt 0`, so it fell through to SURVIVED -- reporting "this guard
   does not bite" for a run that never reached the guard, and discarding
   the very stderr the previous commit added to capture it. Now its own
   third outcome, printed with the cause and exiting non-zero: a crash
   means the entry proved NOTHING either way, and scoring it as anything
   softer would be the harness lying again.

   Proved both halves rather than reading the branch. A child that throws
   before RESULT during the BASELINE now reports the actual exception text
   instead of a bare count. Injected a sandbox-only death (baseline left
   green) and the per-mutation path reports CRASHED with its cause, where
   it previously said SURVIVED.

2. THE INSTALL AND THE IMPORT PINS DISAGREED. The workflow installed
   Pester with only a minimum, so the gallery hands the runner 6.x, while
   every suite child imports with a 5.x ceiling -- the child then cannot
   load Pester, prints no RESULT, and the job dies at the baseline having
   proven nothing. @saadqbal measured exactly that on his machine, twice.
   Both now carry the same ceiling, and the comment says they move
   together or not at all.

@saadqbal's earlier items were already in ff7d479 (whole-tree sandbox,
per-entry attribution, `After` as a bounded scope, kept stderr) -- his
latest pass predates that push. His independent reproduction matches mine
exactly: scripts-only sandbox is FAIL=7 for install-k8s.Tests.ps1 and
FAIL=0 for install.Tests.ps1, and the control/treatment pair on the
dashboard entry showed `caught` printed with the guard absent from the
failure list.

Still open, and NOT fixable from here: whether mutation-check is a
REQUIRED check. The protection API 404s on both our tokens.

13/13 caught by the claiming guard, no SURVIVED/MISATTRIBUTED/CRASHED.
1064 Pester assertions pass.

Part of tracebloc/backend#2849.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(mutation-check): say what the harness does NOT yet guarantee (@aptracebloc on #931)

Arturo settled the question @saadqbal and I both 404'd on: `mutation-check`
is NOT a required status check on `develop`. The required set is Unit
tests, Lint, quality/*, version-bump-gate, Source-of-truth drift,
chart-version bump, Helm unit tests.

So a guard-death reddens THIS job and still passes the gate the merge
button reads -- and the header claimed "every guard must still catch the
bug it was written for" without that qualifier. A file overstating its own
coverage is precisely the class this harness exists to catch, so it should
not be the first offender.

The header now says it plainly: read the claim as "verified by CI-as-run",
not "by CI-as-enforced", until the context is added.

Adding it is not possible from a PR. Protection is not managed as code
here -- I checked `client` for a settings/ruleset file and `.github`'s
`repo-inventory.yml`, which carries only visibility and release_train for
this repo -- so the required set lives in the API/UI and needs admin
scope.

The alternative, folding the tier into the required `Unit tests`
(standard-checks.yml:149), is left to Arturo's call rather than taken: it
gates by construction but roughly triples a required check every PR pays,
including ones touching no installer code. And the obvious escape from
that cost -- a `paths:` filter on a required context -- is a known trap: a
skipped required check never reports, which is registered as a mutation in
e2e-test-agent because it has bitten before.

Comment-only; 13/13 markers still resolve, style clean.

Part of tracebloc/backend#2849.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(installer): measure the floor where the mutations run (@saadqbal on #931)

Asad measured it rather than argued it, and he was right again. The
whole-tree copy fixed seven drift guards and never touched a second floor:
the sandbox lived under GetTempPath(), and Get-ElevationCommand branches
on `$ScriptPath -notlike "$temp*"` (install-k8s.ps1:81) -- so two tests
fail there that pass at the repo root. Reproduced: 890/FAIL=0 at the root,
888/FAIL=2 under temp, and the two are exactly `Get-ResumeCommand …
durable script path` and `Get-ElevationCommand … QUOTED -File path`.

I HAD SEEN THOSE TWO FAIL AND CALLED THEM FLAKY. They are deterministic,
and the cause was the harness's own choice of location. Writing them off
as flakiness is the same rationalising that let the first floor through,
and attribution only MASKED this one -- when the claiming guard does fail,
it is credited regardless of the two inherited failures. Asad's probe
proves the false green was still constructible: a comment-only reword with
`Expect = 'durable script path'` printed `caught / 1/1 / exit 0`. It now
prints SURVIVED / 0/1, which is the point.

The structural cause was in plain sight in my own script: the baseline ran
`-Root $RepoRoot` while mutations ran `-Root $sandbox`. A floor measured in
a different tree than the mutations run in is not the floor.

Fixes:

1. ONE `New-Sandbox`, used by the baseline AND every mutation, and it lives
   beside the repo rather than in temp. The baseline now reports "green in
   the sandbox" -- 890 and 57, FAIL=0 where the mutations actually run.

2. A LOCAL `ErrorActionPreference = 'Continue'` around the child call.
   Under the file-level `Stop`, the first stderr ErrorRecord kills the
   parent before `if (-not $line)` can quote the cause -- so the kept-stderr
   fix was dead code, and so was the CRASHED branch added for Bugbot's
   finding, in exactly the case Bugbot described. One scope change fixes
   both. (Worth noting: on pwsh 7.6 here `2>&1` yields strings rather than
   ErrorRecords, so I could not reproduce the parent dying; the local
   Continue makes it robust either way rather than depending on that.)

3. The `by:` line prints the full path. `-replace '^.*?\.'` cut at the
   FIRST period, mangling the five-plus Describes that contain one -- on
   the harness's main human-readable output.

4. Refuse below pwsh 7.3, which strips the child's double quotes: CI is on
   7.4+ so only a local run is affected, which is the worst shape for it.

5. The `Pester` job is pinned to the 5 major, and 6.x moved to a NEW
   `Pester next-major` context. Unpinned, it had drifted onto 6.x while the
   mutation tier certifies under 5.x -- and BUGBOT.md rule 6, which this PR
   adds, exists because guards bind differently across those majors. A new
   context rather than a matrix dimension: renaming `Pester (<os>)` would
   break it if it is required, and branch protection is unreadable from any
   of our three tokens.

13/13 caught by the claiming guard, baseline FAIL=0 in the sandbox, style
clean, actionlint clean.

Part of tracebloc/backend#2849.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Arturo Peroni <arturo@tracebloc.io>
* feat(199): ship deny-by-default training egress (RFC-0003 D6)

Flip the chart defaults so new/upgraded installs are locked down out of the
box: egressProxy.routeWorkloads=true + networkPolicy.training.allowExternalHttps
=false. Training egress now flows through the squid allowlist and the direct
0.0.0.0/0:443 rule is dropped by default; set either back to opt a fleet out.

Bumps chart 1.9.90 -> 1.9.91. Updates helm-unittest suites (egress_proxy,
egress_enforcement_check) and the e2e-auto-upgrade path-2 assertions to the new
default (path-1 --reuse-values replays old computed values, unaffected). SECURITY
§8.2 status + changelog updated. All 643 helm-unittest cases pass.

Enforcement still depends on a policy-enforcing CNI and the §8.2 per-fleet
gated rollout; non-enforcing CNIs render the rule without blocking.

Part of client-runtime#199 · epic tracebloc/backend#1151

* docs(security): correct stale §5.1 CNI note — both EKS fleets now enforce

The 2026-06 note said tb-client-dev-templates ran a self-managed VPC CNI
with NetworkPolicy disabled (lockdown cosmetic). Both tracebloc EKS fleets
(tb-client-dev-templates dev/staging, tracebloc-clients-prod prod) have since
moved to the managed vpc-cni add-on with enableNetworkPolicy=true, verified
via aws eks describe-addon. Egress NetworkPolicy is now enforced on both, so
the D6 deny-by-default lockdown is real there, not cosmetic.

Part of tracebloc/client-runtime#199

* test(199): derive e2e-auto-upgrade path-1 egress asserts from the baseline

Path 1 hardcoded the permissive-baseline posture (external 443 present, no
EGRESS_PROXY_URL). Once this chart is the published baseline, --reuse-values
replays deny-by-default and those asserts would false-fail. Capture the
baseline egress posture at install (like BASELINE_PROD_DIGEST) and assert
--reuse-values replays it verbatim, correct in both eras.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(199): record the egress-enforcement seal-check probe in the QoS goldens

Flipping allowExternalHttps=false to the chart DEFAULT makes the
egress-enforcement-check hook render by default, so its training-labelled
probe pod (t-egress-enforcement-check) now appears in every default render.
Record it: Burstable in the pod-qos class goldens (hostpath/csi/gpu-*), and
blocked/probe in the qos-reachability golden (a probe Job, never Guaranteed-
reachable). Bump chart 1.9.94 -> 1.9.95 (develop moved to 1.9.94).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(199): restore opt-out coverage + fix SECURITY.md shipping version

Three items from review:
- SECURITY.md: the lockdown ships in 1.9.95, not 1.9.91 (1.9.91-1.9.94
  released permissive); correct A2/8.2/changelog and the < boundary (Arturo).
- egress_enforcement_check_test.yaml: add the assertion that the seal-check
  hook does NOT render when an operator opts back out (allowExternalHttps=true)
  — the coverage the inverted default-render case dropped (Bugbot).
- e2e-auto-upgrade.sh path 3/4: set allowExternalHttps=true (the opt-out,
  opposite of the new default) so path 4 genuinely tests that
  --reset-then-reuse-values preserves an operator opt-out instead of the next
  hourly auto-upgrade silently re-locking the fleet (Bugbot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(199): flip values.schema.json defaults + correct stale deny-by-default comments

Addresses Asad review:
- values.schema.json: allowExternalHttps.default true->false, routeWorkloads
  .default false->true (were inverted vs values.yaml; helm ignores schema
  defaults but it is the same two-places drift the gate flagged), and reword
  the descriptions for deny-by-default.
- network-policy-training.yaml: the shipped default is now false; the dig TRUE
  is the legacy absent-key fallback only.
- network_policy_test.yaml: true is the opt-out now, not the default; both
  egressProxy.enabled and routeWorkloads default true.
- values.yaml: routeWorkloads no longer ships inert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(199): re-bump chart 1.9.95 -> 1.9.96 after develop collision

develop advanced to 1.9.95 (backend#951), colliding with this branch. Bump
to 1.9.96 (Chart version+appVersion) and sync the SECURITY.md shipping
version refs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(199): opt out of routeWorkloads too, so path 4 covers it (Bugbot)

The prior opt-out fix inverted only allowExternalHttps and dropped the
EGRESS_PROXY_URL assertion, leaving routeWorkloads at the new default and
untested — an auto-upgrade that dropped or forced it would stay green. Path 3
now also sets routeWorkloads=false (a genuine override, opposite the default)
and asserts EGRESS_PROXY_URL is absent; path 4 asserts that opt-out survives
the auto-upgrade alongside allowExternalHttps=true and the canary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(199): correct runbook + comments for deny-by-default renders on install

Review follow-ups (Asad + Arturo):
- SEAL-CHECK.md: the egress-enforcement caveat + EKS/AKS table cells said the
  hook only renders after a per-fleet flip; on 1.9.96 it renders by default, so
  a fresh install is sealed and helm test runs it. Reword for deny-by-default
  (opt-out is allowExternalHttps=true).
- jobs-manager-deployment.yaml: routeWorkloads defaults true now, not inert.
- egress_proxy_test.yaml: fix the inverted test title (Arturo) — the case
  renders the 443 rule AND keeps the gateway path when opted back to true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(199): update SEAL-CHECK does-not-render example for deny-by-default

The example of a check that does not render used "the egress-enforcement
probe before the lockdown is flipped" — but it renders by default on 1.9.96.
Use the current accurate example: it un-renders when an operator opts a fleet
back out with allowExternalHttps=true (Asad nit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Syed Saqlain <syedsaqlain@MacBook-Pro.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 c23646b. Configure here.

@tracebloc-release-train tracebloc-release-train Bot added gate-nudge Toggled by the release train to (re-)fire the fr-gate and removed gate-nudge Toggled by the release train to (re-)fire the fr-gate labels Sep 1, 2026
@tracebloc-release-train
tracebloc-release-train Bot merged commit 29af32c into staging Sep 1, 2026
59 checks passed
@tracebloc-release-train
tracebloc-release-train Bot deleted the release-train/to-staging branch September 1, 2026 16:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gate-nudge Toggled by the release train to (re-)fire the fr-gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants