Skip to content

release-train: staging -> main - #716

Merged
tracebloc-release-train[bot] merged 18 commits into
mainfrom
release-train/to-main
Aug 14, 2026
Merged

release-train: staging -> main#716
tracebloc-release-train[bot] merged 18 commits into
mainfrom
release-train/to-main

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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


Note

High Risk
Changes fleet control-plane image update semantics, offline-restart behavior, and ingestor/env validation on customer clusters; misconfiguration could freeze images or break ingestion DB auth if floats advance past pinned digests.

Overview
Client chart 1.9.40 ships a rework of image-refresh (#569): the CronJob now kubectl set image jobs-manager, requests-proxy, and resource-monitor (plus node-agents RBAC) instead of rollout restart, with tracebloc.controlPlanePullPolicy so IfNotPresent applies only where digest reconcile or explicit pins provide an update path—Always remains for private mirrors and disabled refresh. Related fixes include requests-proxy following jobsManager.digest, DaemonSet maxUnavailable: 10%, and a higher activeDeadlineSeconds for three sequential rollouts.

Install-time safety tightens CLIENT_ENV (schema enum + tracebloc.clientEnvfail) and per-env ingestor fallbacks (dev/stg/prod instead of prod 0.8 for all). init-writable-data runs chown and chmod independently and verifies other-writable modes (#672). jobs-manager gets a TCP readiness probe on :8080 (readiness only—no liveness/startup) when ingestion HTTP is enabled (#1779). Cluster RBAC adds nodes list for GPU→CPU pending fallback (#1876).

CI/supply chain: daily digest-drift workflow and check-digest-drift.sh, GHCR k3s-cuda publish gated on allowlisted actors and main/staging refs, helm-vocab / env-vocabulary tests in make check, and SECURITY.md updates on the 0.8.8 / #468 ingestor boundary.

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

LukasWodkaand others added 18 commits August 12, 2026 17:33
…690)
The help text advertised a "868-test bats suite". The real number on develop is
946, and nothing anywhere enforces the two agree — so it drifts on every PR that
adds a test and had been wrong for a long time. Re-hardcoding today's value only
resets the drift clock.
BATS_TEST_COUNT is derived from the source of truth instead: bats declares one
test per `@test` at line start, so a grep over scripts/tests/*.bats matches its
own count exactly. Verified against a real run — 946 derived, 946 reported.
Recursively expanded (`=`, not `:=`) so only `help` pays for the grep. Confirmed
by pointing the variable at a marker-touching shell: `make check` — the pre-push
path, budgeted under 60 s — never expands it; `make help` does.
The same stale 868 appeared a second time in the `check` rationale comment.
Prose can't be derived, so the count is simply dropped there; the sentence is
about the two-minute runtime, which is the part that actually justifies keeping
bats out of `check`.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…(backend#1528) (#692)
`resolve-ingestor-digest.sh --write` resolves the `channelTags.prod` float and
knew nothing about the ordering ceiling that float sits above.
While `serviceDbAccountsByEnv.prod` is false, prod still authenticates as the
shared `edgeuser`, so the ingestor it runs must be a release that still HAS the
edgeuser fallback. data-ingestors#468 removed that fallback. values.yaml pins
`prodDigest` DELIBERATELY behind the float and explains why in prose — but it
also tells you to refresh the pin "with the helper, never by hand", and the
helper happily resolved straight past the ceiling. client#490 nearly shipped
exactly that. Today the float resolves to a digest different from the pin, so
the hazard is live, not theoretical.
Fail closed in the helper instead of relying on prose:
- Refuse `--write` unless `serviceDbAccountsByEnv.prod` is a definite `true`.
Absent or unparseable reads also refuse — a chart edit must not be able to
silently disarm the guard.
- Refuse BEFORE the registry round-trip, so the reason isn't buried under
network output and no call is wasted.
- `INGESTOR_PIN_ALLOW_PRE_FLAG=1` overrides for a verified target release.
- The guard reads the live flag rather than hardcoding a version, so it stops
firing on its own once prod flips.
Read-only resolution is untouched.
Tests: scripts/tests/ingestor-pin-ceiling.bats — refusal, both escape hatches,
pin left intact, no-registry-contact ordering, override, post-flip, read-only,
sibling-`prod:`-key scoping, and both fail-closed reads. Removing the guard
turns 7 of the 10 red.
…ect a false schema doc (#695)
* fix(chart): close the CLIENT_ENV + channelTags vocabularies, and correct a false schema doc
Three findings from a mutable-tags sweep, all in the same place: the chart
documented a vocabulary it never enforced, and in one case documented a
fallback it does not have. Follow-up to backend#1723, which fixed the
resolution and left the validation open.
1. env.CLIENT_ENV had no `enum`. tracebloc.clientEnv normalized three aliases
and passed anything else through RAW. `CLIENT_ENV: prd` rendered
jobs-manager:prd, pods-monitor:prd and resource-monitor:prd — tags no
producer publishes — missed images.ingestor.channelTags, missed
serviceDbAccountsByEnv, AND silently dropped the prod digest pin, which
applies only where the env resolves to exactly "prod". Load-bearing in four
places, validated in none. The only validator was client-runtime
jobs_manager.py's sys.exit(1) on "Unknown CLIENT_ENV" — inside the container
that cannot start.
Now closed by an `enum` (the primary gate) plus a `fail` in
tracebloc.clientEnv (the backstop, for --skip-schema-validation and any
repackaging without the schema).
2. channelTags accepted arbitrary keys. `channelTags.staging: 0.7` on a
staging edge validated fine and was then ignored — CLIENT_ENV=staging
normalizes to stg and the lookup reads channelTags.stg — while
`channelTags.stg: 0.7` took effect. The same word, normalized in one place
and meaningless one key over. `additionalProperties: false`.
3. The mysql-client tag description said "Empty falls back to env.CLIENT_ENV".
The template is `| default "prod"`, and dev/stg/prod all render
:prod. tracebloc/mysql-client publishes 8.0, 8.4, four 8.4-<sha> builds,
latest and prod — no dev, no stg — so a reader who believed it and set an
env-derived tag would pull a nonexistent image AND disarm the
mysql-format-guard, which reads "unknown" for an unrecognized tag. Note
values.yaml already stated this correctly; the schema — the copy Helm shows
customers — held the false half. Description corrected; the template is
right and is left alone.
BREAKING for any edge deploying an out-of-vocabulary CLIENT_ENV. Such an edge
is already broken — it is pulling tags that do not exist — but it now fails at
`helm upgrade` rather than at pod start. See the PR body.
Tests: the gates cannot be asserted from helm-unittest, which treats a schema
violation as a plugin-level error rather than a template failure and offers no
way to skip validation and reach the `fail`. So they are exercised from
scripts/tests/chart-env-vocabulary.sh (28 checks, every rejection paired with
an accept control on the same command line), wired into `make check` and the
helm-ci lint job. Five helm-unittest cases pin the mysql-client behaviour the
corrected description now describes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(chart): spell the whitespace-only CLIENT_ENV case out instead of hiding it in a word list
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(chart): close 3 Bugbot gaps in the vocabulary test wiring (backend#1723)
- chart-env-vocabulary.sh: capture helm --help then match, so grep -q
cannot SIGPIPE helm under pipefail and misread the flag as absent
(the capture-then-match rule already used for the usermod help probe).
- helm-ci.yaml: add scripts/tests/chart-env-vocabulary.sh to the push +
pull_request paths filters, so a PR touching only that script still
runs the gate it owns.
- installer-tests.yaml: add the script to the static shellcheck set
(error + warning), re-syncing CI with the Makefile SHELLCHECK_FILES.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* sec(ci): actor-gate the k3s-cuda publish dispatch (backend#1857)
build-k3s-cuda.yaml is dispatch-only and pushes
ghcr.io/tracebloc/k3s-cuda, and until now nothing authorized the
dispatcher. That breaches the decided rule (backend#1422, 2026-08-05):
no un-gated workflow_dispatch that publishes.
It was missed because it was added one day AFTER the 2026-08-05 inventory
was measured, and the client repo had no rows in PUBLISH-PATHS.md at all
-- the org's most customer-visible publish path. Found by
release-train's publish-inventory-check.sh, which now compares the
inventory against every workflow in the fleet.
The gate is the fleet's canonical block, copied not reinvented from
data-ingestors/release-image.yml, backend/docker-build.yml and
client-runtime/publish-images.yml:
- a failing STEP, not a skipped job: red and auditable rather than a
green run that hides the denial (backend#1424)
- BOTH github.actor and github.triggering_actor must pass, because
actor stays the ORIGINAL dispatcher on a re-run while
triggering_actor is whoever clicked it -- checking one lets a
non-allowlisted user replay an allowlisted dispatch (backend#1536)
- gated on `inputs.push`, so build-only validation stays open to
everyone; the rule is about publishing, not about building
Exercised every path against the exact shell the step runs:
actor=LukasWodka trigger=LukasWodka ALLOWED
actor=saadqbal trigger=saadqbal ALLOWED
actor=<other> trigger=<other> DENIED
actor=LukasWodka trigger=<other> DENIED <- the replay
actor=<other> trigger=LukasWodka DENIED
actor=<empty> trigger=<empty> DENIED
NOT fixed here, and worth a separate decision: this workflow has NO ref
restriction. Its only `if:` was `inputs.push`, so a dispatch from ANY
branch could publish, with the image tag built from two user-supplied
inputs -- and that constructed tag is what the GPU installer pins. The
allowlist reduces this from "any writer" to "two admins"; confining
publishes to a reviewed ref (as client-runtime/build-mysql-client.yml
does with `github.ref == 'refs/heads/develop'`) is the natural companion
and is a policy call, not a copy of an existing pattern.
make check: green. actionlint: clean. manifest.sha256: up to date.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* sec(ci): publish k3s-cuda only from a train-owned branch (backend#1857)
The actor allowlist bounded WHO could publish. This bounds WHAT can be
published, which is the control that actually matters here.
install-k8s.ps1:672 defaults $K3S_CUDA_IMAGE to
ghcr.io/tracebloc/k3s-cuda:$K8S_VERSION-cuda-$CUDA_BASE_TAG, and its own
comment states: "The installer PULLS this image automatically at
cluster-create -- the user never builds or pulls anything by hand." So
this tag is fetched onto customer machines. Before this change the only
`if:` on the job was `inputs.push`, so an allowlisted dispatcher could
publish it from ANY branch -- putting unreviewed content behind a
customer-pulled tag.
staging AND main, not develop:
* both are train-owned and protected. develop is the least-reviewed
integration branch and has no business behind a customer-pulled tag.
* staging is included deliberately, to avoid an ordering trap rather
than to be lenient. The tag encodes K8S_VERSION + CUDA_TAG, and
check-facts.sh pins those across install-k8s.ps1, the Dockerfile,
build.sh and this workflow -- so a bump lands in all four at once.
Publishing from staging lets the image for a new tag exist BEFORE
main's installer starts asking for it. main-only would leave a window
where prod installs pull a tag nobody has pushed yet.
Build-only validation is untouched: push=false runs from any branch, by
anyone, and the error message says so. The rule is about publishing.
Both checks live in ONE step under one `inputs.push` condition, so the
gate cannot drift out of sync with the thing it guards.
Full matrix exercised against the exact shell:
LukasWodka / LukasWodka / refs/heads/main ALLOWED
LukasWodka / LukasWodka / refs/heads/staging ALLOWED
saadqbal / saadqbal / refs/heads/staging ALLOWED
LukasWodka / LukasWodka / refs/heads/develop DENIED
LukasWodka / LukasWodka / refs/heads/feat/anything DENIED
LukasWodka / LukasWodka / refs/pull/1/merge DENIED
<other> / <other> / refs/heads/main DENIED
LukasWodka / <other> / refs/heads/main DENIED <- replay
NOT closed by this, and filed as backend#1867: the tag stays mutable and
the installer pins it by TAG, not digest. Two admins on a protected branch
is a process control standing in for a technical guarantee. The org has
solved this twice already -- prodDigest for the ingestor, and
RFC-BACKEND-1246 for training images -- and k3s-cuda is the remaining
customer-pulled image that is neither digest-pinned nor immutable-tagged.
make check: green. actionlint: clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… fails (#672) (#689)
* fix(chart): stop init-writable-data skipping the chmod when the chown fails (#672)
init-writable-data ran `chown 1000:1000 "$d" && chmod "$m" "$d" || echo …`, so a
refused chown short-circuited the chmod and the mode was never applied — while the
message said "leaving as-is", implying nothing could be done.
That inverts the priority. kubelet ignores fsGroup on hostPath
(kubernetes/kubernetes#138411), so the MODE is what makes these trees usable:
/data/shared must be other-writable for the ingestion Job (uid 65534, or HOST_UID)
and the CLI staging/teardown pod (uid 65532), neither of which is 1000 nor shares a
group with it. The chown is cosmetic next to that, and it is also the call most
likely to be refused — on a Windows/Docker-Desktop bind mount or an NFS root_squash
export it is precisely what fails. So the failure that mattered least was cancelling
the one that mattered most, silently nullifying the 2777/3777 split from #667 on the
platform that split was written for. Symptom: #653's
`mkdir: can't create directory '/data/shared/.tracebloc-staging/': Permission denied`.
The chown and the chmod are now separate best-effort statements, each recording
whether it failed, and the per-dir verdict is graded on the mode OBSERVED afterwards
via `ls -ldn` rather than on either exit status — a bind mount can accept a chmod and
ignore it, so an exit code is not evidence. A partial result is reported as such
("chown failed; mode applied anyway") instead of implied. Unchanged: per-dir modes,
per-dir independence, non-fatal behaviour, POSIX sh for busybox. Kept diffable by eye
against the installer's Get-ReleaseDirsPrepCommand, which already does it this way.
Verified by executing the helm-rendered command[2], not by reading it:
- sh -n, dash -n, bash --posix -n all clean
- busybox:1.35 as root: /data/shared drwxrwsrwx, /data/logs drwxrwsrwt, exit 0
- busybox:1.35 with --cap-drop CHOWN (chown refused, chmod permitted): modes STILL
land drwxrwsrwx / drwxrwsrwt; the old command leaves both at drwxr-xr-x
- /data/shared read-only (both calls fail): FAIL reported with the real errno,
/data/logs still fixed, exit 0
- end-to-end on a shared volume after a refused chown: uid 65534 creates
.tracebloc-staging and writes /data/logs; uid 65532 unlinks uid 65534's entries in
/data/shared (no sticky) but not in /data/logs (sticky) — both splits intact
Tests: the new #672 case fails against the old command and passes against the fix.
The obvious comment-scoped guard (`^[^#\n]*chown.*&&.*chmod`) is silently VACUOUS —
`${e#*:}` puts a '#' before the chown — so the guard is unscoped and the template
describes the old shape in words instead. Existing assertions kept, updated for the
multi-line command. jobs_manager_test.yaml 34 -> 35 passing; full suite 379 -> 380
passing with develop's 5 failed / 5 errored baseline unchanged.
Also adds the recurring-finding rule to .cursor/BUGBOT.md per CLAUDE.md.
Refs #672, #667, #653, #654
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(chart): report only what init-writable-data actually observed (#672)
Two overclaims in the first commit's own reporting, both found by running the
failure paths rather than reading them — the same family as the bug being fixed.
1. The verdict grades other-writability alone (correctly: that is what decides
whether uid 65534/65532 can work, and failing a setgid-stripped-but-writable
mount would cry wolf on a working install). But it labelled that bare "OK",
which reads as "the whole mode landed". Now says "OK <dir> other-writable" and
always prints want vs got, so a mount that granted other-write while dropping
S_ISGID is visible instead of implied.
2. Worse: the partial-result note said "mode applied anyway" whenever any call
failed. On a dir that was ALREADY other-writable and where BOTH calls were
refused, that is simply false — nothing this container did applied anything.
Reproduced in busybox:1.35 (pre-set 1777, run as a non-owner uid so chown and
chmod are both refused):
want 2777 got drwxrwxrwt uid 0 (chown+chmod failed; mode applied anyway)
Now reads "(chown+chmod failed; other-writable regardless)" — it claims the
observation, not a causal link it cannot support.
Re-verified on the helm-rendered command[2]: sh -n / dash -n / bash --posix -n
clean; root happy path lands drwxrwsrwx + drwxrwsrwt; chown-refused still lands
both modes; already-1777 with both calls refused now reports truthfully; read-only
/data/shared still FAILs with the real errno while /data/logs is still fixed;
exit 0 throughout. Tests pin both strings, including a notMatchRegex on the old
"mode applied anyway" wording. 35 passing, full suite 380 with develop's
5 failed / 5 errored baseline unchanged.
Refs #672
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(chart): move the verdict rationale out of the container command (#672)
The long "why other-writability alone is the pass condition" prose was inside the
script passed to `sh -c`, so it shipped in the pod spec and showed up in every
`kubectl get deploy -o yaml`. It belongs in the YAML comment above, which does not.
Left a two-line pointer where a script editor will see it.
Also records the one intentional divergence from the installer's
Get-ReleaseDirsPrepCommand: the chart does not redirect chown/chmod stderr to
/dev/null, so the real errno (Operation not permitted vs Read-only file system)
lands in `kubectl logs` next to the verdict. The installer suppresses it because its
output is a user-facing progress line; an init container's log is a debugging surface,
and hiding the errno there would remove the evidence a reader needs.
Comment-only inside command[2]: re-rendered and re-ran the chown-refused path in
busybox:1.35 to confirm byte-identical output and modes (drwxrwsrwx / drwxrwsrwt,
exit 0). 35 passing; full suite 380 passing, baseline unchanged.
Refs #672
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(bugbot): correct how long the chained chown/chmod actually shipped (#672)
The rule said "for two releases". Verified against git instead: the
`chown … && chmod …` shape entered in chart 1.9.20 (#611/#612, commit a07f76b) and
survived every version through 1.9.33 — thirteen chart versions, not two. #667
(7852f02) rewrote the modes on that exact line and left the chain untouched, which
is the more useful half of the lesson: the line was re-read for its modes and not
for its control flow. Also corrects the issue's attribution of the chain to #667.
Refs #672, #667, #611
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…prod's (#694)
tracebloc.ingestorTag's last-resort literal was a bare "0.8" for every
environment. On the `--reuse-values` replay it exists for — a release
predating `channelTags`, where no chart default is adopted — a dev or
staging edge therefore spawned the PROD ingestor line.
That inverts backend#1360: dev/stg channels exist so an ingestor change can
be validated on a real edge without a prod release, and an edge silently
validating prod's image reports on the wrong artifact.
The literal is also no longer merely wrong. The prod float has moved past
the ordering ceiling documented at values.yaml `prodDigest`: the 0.8 line no
longer carries the ingestor's `edgeuser` DB_USER default that
data-ingestors#468 removed (backend#1853). serviceDbAccountsByEnv supplies
DB_USER on dev/stg so those two survive it, but that coupling is accidental,
and the same literal is where an out-of-vocabulary CLIENT_ENV lands — there
serviceDbAccountsByEnv misses too and nothing supplies DB_USER. That is
backend#1752 reconstructed from a typo.
Keyed on the RESOLVED environment, so the documented aliases reach it too.
Also documents the duplication in both directions: the prod literal is a
second copy of values.yaml `channelTags.prod` and cannot read the first
(a values lookup is nil on exactly the releases this branch serves), so
each now points at the other and the suite pins both.
An existing case asserted `0.8` for a DEV edge with channelTags absent —
the bug written down as an expectation. Replaced by four cases plus a prod
control, so "dev falls back to dev" cannot pass by echoing the environment.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ght the crash-loop (backend#1779) (#699)
* fix(chart): give jobs-manager the readiness probe that would have caught the crash-loop (backend#1779)
jobs-manager had no probes at all, so the kubelet called the container Ready the
moment it was running. That is why `kubectl rollout status` printed "successfully
rolled out" over a pod with 4 restarts in 72 seconds on 2026-08-11, and why the
E2E agent's client-health step went green (backend#1723, backend#1756).
Adds startupProbe + readinessProbe (tcpSocket 8080) to the api container, gated
on INGESTION_HTTP_DISABLED with the same truthiness the runtime uses, so an
operator who disables the ingestion server does not get a pod that never becomes
Ready.
Deliberately NO livenessProbe, and the reason is recorded in the template:
jobs_manager.py catches a failed run_server_in_thread on purpose ("must not take
down the rest of jobs-manager — SB polling can still operate independently"), and
a liveness probe on 8080 would reverse that decision from the chart. Readiness
removes the pod from the Service, which is the part that needs to happen.
A port probe rather than GET /healthz, on purpose: jobs-manager opens 8080 only
after backend auth succeeds and the MySQL migration has run, so accepting a
connection already implies both. #1779 argues probing /healthz would be a new
false-green; for the incident it describes that is not so — the process exited
before opening the port, so any probe on 8080 would have been red. See the PR
for the evidence. /healthz being an unconditional 200 is still true, and still
worth fixing for the narrower "listening but a dependency died" case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(chart): drop the startupProbe — it kills the container too (Bugbot)
Bugbot, Medium, and correct: the startupProbe reversed the very decision
this PR argued for when it omitted livenessProbe.
A startupProbe is not the gentle cousin of a liveness probe. The kubelet
KILLS the container when it fails, exactly as liveness does. So on the one
failure mode that motivated backend#1779 -- run_server_in_thread raising,
which jobs_manager.py:3535-3542 catches ON PURPOSE because "Startup
failure here is fatal for the ingestion flow but must not take down the
rest of jobs-manager -- SB polling can still operate independently and
training submissions keep working" -- the 30 x 5s budget would expire and
CrashLoopBackOff a pod that was still training models.
The PR wrote several paragraphs rejecting that outcome for livenessProbe
and then shipped it via a different key.
readinessProbe alone does the part that needed doing: take the pod out of
the Service so nothing routes POST /internal/submit-ingestion-run at a
dead port, and stop `kubectl rollout status` reporting success over a
crash-loop. It never restarts anything, so the runtime's decision to
survive a failed server start stays the runtime's to make.
Tests: the startupProbe values test is replaced by one asserting its
ABSENCE, with the reasoning above, so this cannot be reintroduced quietly.
The disabled-state test no longer asserts notExists on startupProbe --
it does not exist in either state, so that assertion would have passed
without saying anything about the gate.
396/396 helm-unittest across 30 suites, make check green,
gen-manifest --check clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…d the Windows prep (#673) (#700)
_ensure_release_dirs applied a flat, recursive `chmod -R 777` to data and logs,
while Get-ReleaseDirsPrepCommand (#654) and the chart's init-writable-data (#667)
both apply a per-dir 2777/3777 split without recursing. Three implementations of
one intent, two agreeing and one not — and the odd one out was the copy #667 said
should be diffable by eye.
Nothing was user-visibly broken: 777 is other-writable and carries no sticky bit,
so cross-uid `data delete` worked on the bash path, and on Linux the chart's init
container rewrote both dirs at pod start anyway. That absence of a symptom is why
the divergence survived two PRs, and why this lands with a test rather than just a
fix.
- data -> 2777 (setgid, NO sticky: `data delete` unlinks as another uid, #667)
- logs -> 3777 (setgid + sticky: nothing has to delete another writer's logs)
- drop -R: the dir's own mode governs creation and unlink; recursing stamped
setgid/sticky onto every data FILE and walked the whole dataset tree to do it
- mysql keeps its recursive 777 — one writer, its own init container, datadir
permissions are the database's business (out of scope in #654 for the same reason)
- split the pairs on the LAST colon, so a HOST_DATA_DIR containing one can't
silently chmod a path that does not exist
Tests: hostpath-prep.bats now extracts path:mode pairs from all three sources
(bash _release_dirs_spec, the ps1's Get-ReleaseDirsSpec rows + $TB_*_DIR_MODE
constants, the chart's init-writable-data loop) and fails if any pair disagrees;
cluster.bats asserts the applied modes, that a pre-existing file under data/logs
keeps its mode, that mysql stays recursive, and the colon case. Each guard was
mutation-checked in all three sources.
Closes#673
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#701)
* sec(#1876): grant jobs-manager list nodes for GPU->CPU pending fallback
The check_pending_jobs GPU->CPU fallback (client-runtime#217) calls
list_node() to tell an autoscaling-in GPU node from a genuinely absent
one. nodes is cluster-scoped, so the grant can only live in the
clusterScope: true ClusterRole (like tokenreviews). Without it list_node()
403s and the runtime fail-safe assumes a GPU node is present, silently
disabling the fallback — a Pending GPU pod wedges forever while the
backend shows RUNNING.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(chart): bump client 1.9.38 → 1.9.39 for the list-nodes RBAC grant
The rbac.yaml ClusterRole change is packaged chart content, so it only
reaches installs via a new chart version (chart-version-guard). Merge
current develop in and bump version + appVersion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ckend#1853) (#697)
* fix(ci): watch every mutable label that points at a pinned digest (backend#1853)
On 2026-08-12 the ingestor's `channelTags.prod: "0.8"` float moved to
v0.8.8, which removed the legacy edgeuser DB_USER fallback. Nothing
noticed; a manual sweep found it. Default prod edges were spared only
because prodDigest pins v0.8.2 and prodPin defaults to true.
DESIGNED TO SAADQBAL'S DIAGNOSIS, not to the symptom:
"The disease is a moving label pointing at an immutable trust decision,
with no watcher on the label. Anything about the build could have been
the thing that changed -- the fallback just got there first."
So this script asserts NOTHING about a build's contents. There is no
DB_USER check, deliberately: such a check goes green the next time
something else moves, which is the failure this watches for rather than a
variant of it. It asks one property-agnostic question per pin -- does the
float still resolve to the digest we decided to trust?
The trusted versions are registered where they already were: the digest:/
prodDigest: fields of client/values.yaml. No second list to keep in sync;
adding a pin enrols it automatically.
Against the real chart: 3 pins found, squid agrees, the ingestor DRIFTS
(the real finding), and mysqlClient is reported UNWATCHABLE -- it carries
a pin but declares no repository, so nothing can tell you when its trust
decision goes stale. That is a genuine modelling gap, reported rather than
skipped.
TWO BUGS OF MY OWN, both found by running it, not reading it:
1. images:-scoped discovery watched 1 of the 3 pins. squid's pin lives
OUTSIDE images:, and an "empty field means skip" rule dropped
mysqlClient without a word. Rewritten pin-driven over the whole file:
a pin is watched or REPORTED, never skipped.
2. IFS=$'\t' collapses runs of tabs, because tab is IFS whitespace. A
record with an empty repository AND tag slid the pin into the wrong
variable, leaving $pin empty, and the row was skipped in silence.
This is the exact defect release-train's own parse-repos suite pins by
name; 0x1f is not whitespace.
Also in this commit, per review:
* client/values.yaml no longer states the float's version. It said "at
v0.8.4" while the float was at v0.8.8 -- and any version written there
is stale on the next release, reading as reassurance ("two patches
behind") for a gap that may be far larger. The comment now says the
float moves without us and points at this watcher.
* docs/SECURITY.md 4.1.1 is reframed as an explicit CEILING with a table:
v0.8.0-v0.8.4 safe with the flag off, v0.8.8+ NOT (config.py read at
each tag). It previously implied the unsafe release was hypothetical;
it exists and is what the float points at.
Tests: 16 bats cases, registry stubbed via a documented seam that prints
STUBBED on every run so a log cannot pass as a real audit. The repo's
bats-hygiene guard caught that 31 of my assertions were advisory -- a bare
[ ] on a non-final line cannot fail its test -- so all are now || return 1.
Mutation-verified after that fix:
comparison always true -> 4 tests fail
discovery restricted to the images: block -> test 6 fails
a pin with no repository silently skipped -> tests 9 + 11 fail
(An earlier sed-based mutation of the third reported 0 failures; the
pattern contained backticks and never matched. Inert mutation, not
coverage -- re-done with an asserted anchor.)
NOT in `make check`: needs network + docker, and is knowingly red today
(the drift IS the finding). Runs daily via digest-drift.yml; the bats
suite is in `make bats`, which needs neither.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ci): bound the digest-drift registry lookups + bump chart version (backend#1853)
- resolve_index_digest ran docker buildx imagetools inspect / docker
manifest inspect with no timeout, so a wedged daemon or stuck registry
hung the daily job instead of failing closed as UNRESOLVED. Wrap them in
_tmout (timeout/gtimeout, 30s) so a stuck call is non-zero -> UNRESOLVED.
- client/values.yaml changed, so the chart-content gate requires a
Chart.yaml version bump: 1.9.34 -> 1.9.35.
* fix(chart): match appVersion to version (Bugbot, #1853)
The version bump left appVersion at 1.9.34; app.kubernetes.io/version
follows appVersion, so installed objects would advertise the old chart
version. Keep them in lockstep as this chart does: appVersion 1.9.35.
* fix(ci): discover single-quoted and off-structure digest pins, fix checkout label
check-digest-drift.sh only matched a double-quoted sha256 at exactly
four-space indent, so a single-quoted (digest: SQ...SQ) or more-deeply
nested pin was dropped in silence -- and the PINS==0 guard cannot catch
that while any one conforming pin remains, so a run could print 'no drift'
with a pin unwatched. Make discovery quote- and indent-agnostic: a
canonical pin is watched as before; a pin off the structure is REPORTED
unwatchable, never skipped. Adds bats cases for the single-quoted repro,
the good-masks-sneaky case, and the off-structure -> UNWATCHABLE path.
Also correct digest-drift.yml's checkout pin comment: 11d5960a is v4.4.0
(repo-standard, on releases/v4), not v5.0.0; drop the stray double space.
addresses @saadqbal review, Bugbot, client#697.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…on Always (#569) (#705)
* fix(chart): re-image control-plane pods by digest instead of relying on Always (client#569)
The always-running control-plane pods rendered `imagePullPolicy: Always`, so a
Docker Desktop / WSL2 restart forced a registry round-trip and landed in
ImagePullBackOff even with the image already cached in containerd — the edge
came back only once docker.io was reachable, up to ~6h behind Docker Hub's
anonymous pull-rate limit.
`Always` could not simply be flipped: it IS the update mechanism. The
image-refresh CronJob's `kubectl rollout restart` only picks up a new build
because the pull policy re-resolves the floating tag. Offline-safety and
restart-driven updates are mutually exclusive unless the image REFERENCE
changes on update. So the reference is now what changes.
- All four control-plane call sites render IfNotPresent unconditionally:
jobs-manager (api + pods-monitor sidecar), requests-proxy, resource-monitor.
- image-refresh swaps `rollout restart` for `kubectl set image repo@digest`.
- Two workloads come under refresh for the first time, both quietly broken
before: requests-proxy runs the SAME jobs-manager image but was never
reconciled (it skewed until an unrelated restart, then jumped to whatever the
tag pointed at), and resource-monitor had no deliberate update path at all.
- requests-proxy follows the jobs-manager digest in the same tick, no second
registry HEAD. images.requestsProxy.digest opts it out.
- resource-monitor needs a second Role in the node-agents namespace: get/patch
resourceNames-scoped to the one DaemonSet, list/watch namespace-wide and
read-only (RBAC ignores resourceNames for collection verbs, and
`rollout status` requires them). Not rendered when resourceMonitor: false.
- imageRefreshEnabled now requires all three refreshed images pinned before
retiring the CronJob; pinning only the two class-1 images used to render it
away, leaving the DaemonSet with no update path.
- Private mirrors: the script resolves digests from docker.io, so pinning one
onto a mirrored reference could pin an image the mirror does not hold. It
logs and goes inert. Under `rollout restart` that mismatch was merely
useless; with `set image` it has to fail closed.
The first-tick "record without acting" contract is kept deliberately: re-imaging
on the first tick would rewrite repo:tag to repo@digest for byte-identical
content on every fresh install, rolling the Deployment and the DaemonSet on
every node for nothing. A fresh edge therefore runs repo:tag until the first
real digest change — restart-safe offline, just not yet reproducible.
Two bounded limitations are documented in the script header rather than hidden:
a chart version bump re-renders repo:tag and this tick will not re-pin (the
annotation still matches), and skew predating this change is prevented but not
repaired. Both self-heal at the next upstream release and neither can break a
running edge. The proper fix for both is reconciling against each workload's
live container image instead of a shared annotation — which `set image` makes
possible for the first time, and which is a deliberate follow-up.
Verified: helm unittest 405 passed, failures exactly develop's pre-existing
baseline (5 failed / 5 errored, diffed against a stashed baseline run — zero
new). helm lint clean, all four client/ci value sets render, check-style and
check-facts pass, gen-manifest --check up to date. Chart 1.9.38 -> 1.9.39,
version + appVersion in lockstep.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(chart): close two Bugbot findings on the digest-on-update reconcile (client#569)
1. Jobs-manager pin skipped requests-proxy (_helpers.tpl:219).
requests-proxy runs the jobs-manager IMAGE, and both the helper and the
values docs claimed it follows the jobs-manager pin — but the Deployment read
only `images.requestsProxy.digest`. Pinning jobs-manager ALONE was therefore a
silent trap: the proxy kept rendering the floating `repo:tag` while
jobs-manager ran the pinned digest, AND image-refresh skips pinned images, so
nothing ever wrote a `set image` for the proxy either. It froze on the tag
indefinitely, running a different build of the same image — precisely the skew
#569 exists to close, re-introduced through the pinning path.
`images.requestsProxy.digest` now falls back to `images.jobsManager.digest`
when empty, so the claim is true by construction. The proxy key remains an
explicit per-workload override. The helper comment now records that the test
depends on that fallback, so removing it forces requests-proxy back into the
"nothing left to do" test.
2. Refresh budget too small for three rollouts (image-refresh-cronjob.yaml).
A tick now waits on up to three sequential `rollout status` calls, each up to
rolloutTimeout (10m), while activeDeadlineSeconds was a hardcoded 1800 —
exactly 3 x 10m, zero headroom. Blowing it is not a benign timeout: the Job is
killed mid-wait, so under `set -e` the post-success annotate never runs, the
recorded digest never advances, and the shared `refresh-attempt` counter stays
incremented. Three such ticks trip the #563 flap lockout for EVERY
control-plane image at once, while the CronJob still looks healthy.
- activeDeadlineSeconds is now `imageRefresh.activeDeadlineSeconds`, default
3600 (3 x the default rolloutTimeout plus 100% slack), with a schema entry
(minimum 60) and the raise-both-together constraint documented on
rolloutTimeout.
- The resource-monitor DaemonSet gets an explicit
`updateStrategy.rollingUpdate.maxUnavailable: 10%`. Kubernetes defaults to
maxUnavailable: 1, so a digest change converged in (nodes x pull+start) and
blew the 10m wait on any multi-node cluster long before the image was bad.
Safe to widen here specifically: resource-monitor is a read-only node
metrics reader, so a briefly absent pod degrades scheduling telemetry and
nothing else. Kubernetes rounds 10% down and floors it at 1, so small
clusters keep today's one-at-a-time behaviour.
Verified: helm unittest 411 passed, failures still exactly develop's pre-existing
baseline (5 failed / 5 errored, diffed against a stashed baseline run — zero
new). helm lint clean, check-style clean. Rendering confirms requests-proxy
resolves to the jobs-manager digest when only jobsManager is pinned,
activeDeadlineSeconds renders 3600 and honours an override, the DaemonSet
carries maxUnavailable 10%, and the schema rejects a sub-60 deadline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(chart): keep Always where the digest reconcile cannot run (client#569)
Bugbot, High severity — a regression introduced by this PR's first commit.
Making `imagePullPolicy: IfNotPresent` UNCONDITIONAL removed the only update
path from every edge where the replacement mechanism cannot run. `Always` on a
floating tag is not merely offline-fragility; it IS an update path — restart the
pod and the kubelet re-resolves the tag. Two configurations relied on exactly
that and were left frozen on their cached image forever, with a green CronJob
and no signal:
* `global.imageRegistry` (private mirror). The reconcile resolves digests from
docker.io, so this PR deliberately makes it inert there rather than pin a
digest the mirror may not hold. With IfNotPresent on top, syncing the mirror
and restarting kept serving the cached tag.
* `imageRefresh.enabled: false`. values.schema.json has always promised these
operators the image stays put "until manual restart" — true only with Always.
The inert-path log message this PR added made it worse by telling operators to
"sync your mirror and restart the workloads", which under IfNotPresent does
nothing. That guidance is now correct because the policy is correct.
The policy is now resolved per image by one helper,
`tracebloc.controlPlanePullPolicy`, so the four call sites cannot disagree:
1. explicit `digest` pin -> IfNotPresent (immutable reference; updates
come from changing the pin)
2. reconcile can run here -> IfNotPresent (`set image` changes the
REFERENCE, which is what the kubelet pulls)
i.e. the CronJob renders AND images come from docker.io
3. neither -> Always (floating tag + restart is the
only update path that edge has)
The trade is deliberate: offline-restart safety is delivered precisely where the
digest reconcile can deliver updates. An edge that opts out of the mechanism
keeps pre-#569 semantics rather than silently freezing — a frozen control plane
with no signal is worse than a restart that needs the network.
Verified by rendering the full matrix: default and all-pinned resolve
IfNotPresent across jobs-manager (both containers), requests-proxy and
resource-monitor; mirror and refresh-disabled resolve Always across all of them;
a pin plus refresh-disabled correctly splits (pinned container IfNotPresent,
unpinned sibling Always). helm unittest 417 passed, failures still exactly
develop's pre-existing baseline (5 failed / 5 errored — zero new). helm lint,
check-style clean; all four client/ci value sets render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(chart): correct statements #569 made false in values.yaml and the schema (client#569)
Self-audit follow-through, same class as the Bugbot findings on this PR: behaviour
changed and the prose describing it did not. Per CLAUDE.md, a change that makes a
statement false fixes that statement in the same PR.
Docker Hub rate-limit arithmetic (the operationally significant one). #569 raised
the per-tick cost from 2 manifest HEADs to 3 — resource-monitor joined
jobs-manager and pods-monitor; requests-proxy adds none because it reuses the
jobs-manager digest. values.yaml still claimed "2 images x 4/hr = 8/hr, well under
the cap". It is now 12/hr, so 72 per 6h against the anonymous cap of 100, and the
headroom left for OTHER workloads sharing the egress IP fell from ~52 to ~28.
That is worth more than an arithmetic fix: exhausting this cap behind a shared
corporate NAT is one of the failure modes in the incident #569 exists to fix — a
rate-limited edge cannot pull for up to ~6h. The comment now states the real
numbers, names the shared-IP risk, and gives the concrete remedy (a 3/hr schedule
restores ~9/hr, and a digest pin drops that image's HEAD entirely). The default
schedule is deliberately UNCHANGED: it is still within the cap, and quietly
slowing drift pickup fleet-wide is a call for a human, not a side effect of a
doc fix.
values.schema.json descriptions, which were describing the pre-#569 mechanism:
- imageRefresh: said it "rolls the deployment"; now describes `kubectl set image`
across the three workloads, and why that is what permits IfNotPresent.
- imageRefresh.enabled: says explicitly that disabling falls back to Always, so
the long-standing "until manual restart" promise stays true.
- imageRefresh.schedule: three manifests per tick, not two, with the numbers.
- imageRefresh.maxRefreshAttempts: re-imaging, not re-restarting; notes the
counter is shared across workloads.
- images.jobsManager.digest: records that requests-proxy inherits it.
- images.requestsProxy.digest: documents the follow-jobs-manager default (it had
no description at all), so the pinning trap Bugbot found is discoverable from
the schema rather than only from the template comment.
- images.podsMonitor / resourceMonitor.digest: descriptions added.
Checked and deliberately NOT changed: docs/SECURITY.md 6.1's `rollout restart`
after a secret rotation restarts the pod to re-read the Secret, not to pull an
image, so it is unaffected by the pull-policy change.
Verified: helm unittest 417 passed, failures still exactly develop's baseline
(zero new). helm lint and check-style clean; schema is valid JSON.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(chart): one definition for "resource-monitor needs no refresh" (client#569)
Bugbot, Medium — and, as it notes, the SAME helper-vs-runtime disagreement class
this PR already had to fix for requests-proxy. The rule was written twice and the
two copies drifted:
* `tracebloc.imageRefreshEnabled` treated resource-monitor as done only when
`images.resourceMonitor.digest` was set.
* The CronJob's RESOURCE_MONITOR_PINNED env ALSO treated `resourceMonitor:
false` as done — correctly, since with no DaemonSet a cross-namespace
`set image` would just fail the tick.
So `resourceMonitor: false` plus both class-1 images pinned kept rendering a
CronJob that skipped every image and exited green every 15 minutes, forever.
Before #569 that combination retired the CronJob cleanly. It is also exactly the
green-forever-while-doing-nothing failure mode the script's own #571 comment
warns about.
Both consumers now read one helper, `tracebloc.resourceMonitorRefreshPinned`,
which is the single place the "nothing to do for resource-monitor" rule lives:
an explicit digest pin, or the DaemonSet disabled outright. Nil-safe, and an
absent `resourceMonitor` key reads as enabled to match the
`ne .Values.resourceMonitor false` gate on the DaemonSet itself.
Three tests: the combination now retires both the CronJob and its RBAC, and the
opposite direction is guarded too — disabling resource-monitor must NOT retire
the CronJob while jobs-manager or pods-monitor can still drift.
Verified: helm unittest 420 passed, failures still exactly develop's baseline
(zero new). Rendering confirms image-refresh is gone for the retiring
combination (only auto-upgrade's CronJob remains) and present in both keep
cases. helm lint, check-style clean; all four client/ci value sets render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(chart): give the node-agents image-refresh RBAC a distinct name (client#569)
Bugbot, High. The node-agents Role and RoleBinding added by #569 reused
`tracebloc.imageRefreshName` — the same name as the release-namespace pair.
`nodeAgents.namespace.name` pointing back at the release namespace is a
SUPPORTED layout, not a misconfiguration: node-agents-namespace.yaml documents
it and deliberately skips creating the Namespace in that case. In that layout
both pairs land in one namespace, so the chart rendered two Roles and two
RoleBindings with identical names in identical namespaces. Confirmed by
rendering before the fix:
Role tracebloc t-image-refresh x2
RoleBinding tracebloc t-image-refresh x2
Helm then either refuses the release or lets the later DaemonSet-only Role
overwrite the deployments Role. The second outcome is the dangerous one: it is
silent, and it strips image-refresh's patch on jobs-manager and requests-proxy.
Because #569 also moves those pods to IfNotPresent, they would be left with no
update path at all — the exact failure this PR exists to prevent, reached
through a different door.
The Role and RoleBinding now use `tracebloc.imageRefreshNodeAgentsName`
(`<release>-image-refresh-node-agents`). The RoleBinding SUBJECT deliberately
keeps the un-suffixed name: there is only one ServiceAccount and it lives in the
release namespace. A distinct name is correct in BOTH layouts — split namespaces
get one Role each, and the collapsed layout gets two complementary Roles
(deployments, daemonsets) bound to the same SA, which is the intended grant.
Verified by rendering both layouts: no duplicate (kind, namespace, name) in
either, subjects and roleRefs resolve to the right objects in both. Tests pin
the new names, the subject/roleRef split, and add a collapsed-layout regression
case. helm unittest 421 passed, failures still exactly develop's baseline (zero
new); helm lint and check-style clean; all four client/ci value sets render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(chart): collapse the requests-proxy name to one definition, pin the rest (client#569)
Proactive follow-up, not a review finding. Three of the five Bugbot findings on
this PR were the same mistake: one side of a two-sided contract moved and the
other did not (the requests-proxy digest pin, the resource-monitor pin signal,
and the node-agents RBAC name). That is one habit, not three coincidences, so I
audited the rest of the diff for the same shape and found two more live
instances — both in contracts #569 itself created or started depending on.
1. Workload names. #569 made image-refresh reconcile workloads BY NAME with
`kubectl set image`, which gives those names a second consumer. A rename that
reached only the workload template would leave the CronJob patching something
that does not exist: the tick fails, the digest record freezes, and the shared
flap counter eventually locks out refresh for every control-plane image.
`<release>-requests-proxy` had exactly two call sites — the Deployment, and
the CronJob env I added — so it is now one definition,
`tracebloc.requestsProxyName`. resource-monitor already went through
`tracebloc.resourceMonitorName`.
`<release>-jobs-manager` is deliberately NOT unified here. It has six call
sites across five files (NOTES.txt, the PDB, tracebloc.serviceAccountName,
...), most of which this change does not otherwise touch, and the repo
convention is that refactors ship separately from behaviour changes.
Half-migrating it would BE the bug this commit is about. A contract test pins
the two sides in the meantime.
2. Container names. `kubectl set image <workload> <container>=<ref>` fails
outright on a wrong container name, so `api`, `pods-monitor-container`,
`proxy` and `tracebloc-resource-monitor` are a hard contract between the
script and the workload templates, previously asserted from neither side.
Tests now pin both sides of everything still spelled out twice: the three
reconcile target names and all four container names, asserted from the CronJob
AND from jobs_manager / requests_proxy / resource_monitor. Renaming either side
alone now fails CI instead of silently breaking refresh on the fleet.
Verified: rendered names are byte-identical before and after the unification
(t-requests-proxy in both the Deployment and REQUESTS_PROXY_DEPLOYMENT), and the
requests-proxy Service selector is untouched — it matches the pod label
`app: requests-proxy`, not the Deployment name. helm unittest 426 passed,
failures still exactly develop's baseline (zero new); helm lint and check-style
clean; all four client/ci value sets render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…al one (#702)
* fix(installer): last failing command wins, so the report names the real one
The ERR recorder shipped in #683 kept the FIRST failure. That is wrong, and a
field report showed why: the run reported
Stopped at .../lib/common.sh:527 (exit 1). command: sudo -n true
for a failure two steps later. common.sh:527 is _real_sudo, reached from step
a's _probe_privilege, whose `sudo -n true` returns non-zero to mean "a password
is needed" — the installer then PRINTS that as a normal row in the host check.
The trap fires for every failing command, benign ones included, so first-wins
latched onto a routine probe inside a step that SUCCEEDED and refused every
later record. The fatal command was never captured.
A confidently wrong location is worse than the blank screen #683 replaced: it
sends the reader to a line that is working as designed.
Last-wins is precise. errexit stops the script AT the fatal command, and the
trap fires once per failing command with no per-frame re-firing as the error
unwinds — verified on bash 3.2 (macOS) and 5.x.
Also:
- Re-entrancy guard. `set -E` makes the recorder inherit its own trap, and the
new `log` call is exactly the kind of command that fails inside it (its
`[[ -n "${LOG_FILE:-}" ]] && …` form returns non-zero with no log open).
Without the guard that recurses forever.
- install_cleanup disarms the ERR trap before reading the record. Its own lines
fail routinely — a `kill` on a dead pid, a false `[[ … ]]` — and under
last-wins each would overwrite the fatal command with a cleanup detail.
- The full ERR trail now goes to the log. The benign entries are not noise:
reading them in order is what identified this bug.
Five bats tests, mutation-real against the first-wins guard, including the
field shape end to end — a probe that fails inside an `if`, a step that then
succeeds, a fatal command afterwards. 982 bats green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(common): make the _record_err re-entrancy test actually exercise the guard (client#702)
The recursion test drove the ERR trap with `command false || true`, which does
not fire it — a command in a || list is excluded from ERR (bash manual). On
bash 5.3 that form fires the trap zero times, so _record_err never ran and
SURVIVED printed with or without the guard. bash 3.2 does fire it, which is why
it looked green on macOS while being vacuous on Linux CI.
`unset LOG_FILE` was the other half: log() is `[[ -n $LOG_FILE ]] && echo …`,
so with no log open the write never attempts and nothing inside the recorder
fails. The failure has to come from the redirection, so point LOG_FILE at a path
whose parent does not exist (fails for root too, unlike chmod 000).
Fixing only that is not enough. bash re-enters an ERR trap at most once, so
deleting _TB_IN_RECORD_ERR does not hang anything and the survival test passes
either way. Add a test for what the guard actually protects: a re-entrant call
must not overwrite TB_ERR_* with the recorder's own log failure, which would
turn 'died at helm upgrade' into 'died writing its log'.
Verified by mutation — with the guard removed, the new test goes red and the
survival test stays green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Shujaat Hasan <shujaat@tracebloc.io>
… an old CLI (#708)
Once a machine is "already set up", no installer on any platform ever updated
the tracebloc CLI again. The cluster keeps upgrading itself hourly via the
auto-upgrade CronJob; the host binary does not, and nothing noticed. A field
machine was found on CLI v0.5.1 from 6 July against a current 0.10.6 — five
minors behind — on a box whose chart had meanwhile auto-upgraded 1.8.5 -> 1.9.34.
The user re-ran the newest installer and it exited before reaching the CLI step.
macOS and Linux (one file, no platform branch, so identical):
install-k8s.sh:198 assess -> healthy -> hands off -> exit 0
install-k8s.sh:241 install_tracebloc_cli, the ONLY call site, never reached
_assess_cli_present checked presence, not version. Note the asymmetry: a MISSING
CLI was correctly degraded/cli-missing and got installed; only a STALE one
slipped through, the single state nothing checked.
Windows had the same hole plus a worse one. Test-ToolsPresent covers
docker/kubectl/k3d/helm — the CLI is not in it at all — and `completed` is set
purely from ClientState -eq "connected", which says nothing about the CLI, while
Install-TraceblocCli is deliberately non-fatal. So a machine whose CLI install
FAILED was still marked complete and never retried: permanently CLI-less, not
merely stale.
The CLI's own nudge cannot rescue either case. It landed in v0.10.0, is
nudge-only, needs an interactive TTY, and is skipped under CI / without a config
dir — and by definition cannot reach anyone below v0.10.0, which is exactly the
population at risk.
Both fast paths now check the version, with 0.10.0 as the floor: the release from
which the CLI can keep itself current. It is a FLOOR, not a "must be latest", so
it costs no network call per run and never needs raising. Below it ->
degraded/cli-outdated (bash) / fall through to Install-TraceblocCli (Windows),
which upgrades it once.
Both fail OPEN on an unreadable version — that is not evidence of staleness, and
reinstalling the CLI on every run would be worse than the staleness.
_version_lt is self-contained: no `sort -V` (BSD sort predates it) and no jq, and
it orders 0.9.9 below 0.10.0 rather than lexically.
Either fix alone leaves a platform broken, so they land together. 988 bats + 670
Pester green; the fast-path guards on both sides are mutation-real.
Closes#707
Refs tracebloc/backend#1920
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…175 (client#703) (#704)
* fix(macos): give install_macos the Tier 0 path Linux has had since #1175
A Mac where a container runtime is already installed AND running is classified
Tier 0 — "zero root, no privileged steps" — and step b then ran the admin gate
and primed sudo anyway, demanding an administrator password to install a runtime
that was already installed and answering. A field report died exactly there:
Host check
Container runtime Docker 29.7.2 — docker info OK ✓
→ Install tier Tier 0 (zero root) — a container is already runnable.
...
step b: install_macos starting (OS=Darwin ARCH=arm64 tier=0)
step b: admin check passed <- nothing after this
install_linux has short-circuited Tier 0 since RFC 0001 #1175; install_macos was
a flat sequence with no tier branch at all, and install_macos_cli_tools
hardcoded /usr/local/bin + sudo under the comment "macOS has no Tier/rootless
model" — so even skipping the admin gate would still have prompted for a
password to write the tools.
Tier 0 now skips the admin gate, sudo priming, Homebrew and the Docker Desktop
install, and lands the pinned tools in ~/.local/bin with no sudo (the same
target _set_tools_target picks on Linux), then persists it on PATH via
_persist_tools_on_path — which self-gates on that directory and is already
macOS-aware. It still verifies amd64 emulation and still sets up login
autostart, both of which are genuinely needed and neither of which wants admin.
Skipping the admin gate is deliberate, not incidental: Tier 0 is precisely the
case RFC 0001 opened up — a user with NO administrator rights on a machine where
someone else already provisioned the runtime. That user could not install at all
before this.
Every other tier is byte-identical, including an UNSET INSTALL_TIER (a stale
bootstrap that never fetched probe.sh), which keeps the privileged path.
Seven bats tests; the four that matter are mutation-real — removing either the
install_macos branch or the cli-tools target branch fails them. 985 bats green.
Closes#703
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(macos): Tier 0 autostart makes no sudo call (Bugbot, client#704)
The Tier 0 path prints "no administrator rights needed", then called
_install_macos_autostart, whose headless branch writes a system LaunchDaemon via
sudo mkdir / sudo tee / sudo launchctl. On a headless Mac (SSH with a TTY) that
prompts for a password — the exact step-b failure Tier 0 exists to remove — and a
Ctrl-C at the prompt leaves the install half-done before cluster create.
_install_macos_autostart now takes an optional "no-sudo" argument. In that mode
the GUI LaunchAgent path is unchanged (it never needed sudo and is the correct
macOS analogue of a user-level autostart), but the headless LaunchDaemon path —
the only branch that needs root — is skipped with instructions on how to enable
reboot autostart later, instead of calling sudo. The Tier 0 caller passes
"no-sudo"; the privileged path is byte-identical and still installs the daemon.
This mirrors Linux, whose Tier 0 already stays out of privileged autostart.
Four new mutation-real bats tests (24 green): headless no-sudo makes zero sudo
calls and writes no daemon; GUI no-sudo still installs the user LaunchAgent; the
Tier 0 caller passes no-sudo; the privileged caller does not. shellcheck clean at
error severity; scripts/manifest.sha256 regenerated (R8).
Bugbot, client#704.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(macos): headless Tier 0 names colima in the reboot footer, not Docker Desktop (Bugbot, client#704)
Headless Tier 0 skips the boot LaunchDaemon rather than prompt for the password
Tier 0 exists to avoid, so TB_MACOS_AUTOSTART stays unset. But _reboot_note's
not-configured branch is the macOS/Windows GUI fallback: it printed 'open Docker
Desktop to bring tracebloc back'. On a headless Mac that names a runtime which
is not what runs here, an action there is no desktop to perform, and it directly
contradicts the 'colima start' hint the skip printed moments earlier.
That footer is the LAST line of a successful install, so it is the advice the
operator actually leaves with.
Set TB_MACOS_HEADLESS_NO_AUTOSTART at the skip site and give _reboot_note a
branch for it. A configured autostart still wins, so a stale marker cannot
downgrade a real promise.
Tests cover both summary branches, that the skip path actually sets the marker
(otherwise the new branch is unreachable and the tests are theatre), and that a
GUI session does not. The existing golden 'open Docker Desktop' assertion is
unchanged. manifest.sha256 regenerated; make check green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(macos): resolve the headless recovery command, don't assume colima (Bugbot + @saadqbal, client#704)
My previous commit fixed the footer naming Docker Desktop on a headless box, but
replaced it with an unconditional 'colima start' — the same defect in the other
direction, and inconsistent with the privileged path ten lines below, which
already resolves `command -v colima` and softens to a generic message when it
is absent (#430 Bugbot).
Three cases now:
* boot daemon ALREADY installed — Tier 0 means someone else provisioned the
box, so a prior admin install may have left it. Skipping the write is still
right (we hold no sudo), but claiming manual recovery would be false. Set
TB_MACOS_AUTOSTART and return 0.
* colima resolvable — name 'colima start', as before.
* colima absent — say 'start your Docker runtime manually' and name nothing.
The summary reads the command the skip site resolved against this host rather
than hardcoding one, so the two can't disagree.
Tests for all three, plus the generic summary branch. The no-colima test
overrides `command -v` for colima alone rather than emptying PATH, which would
also remove date(1) via log and test the harness instead of the branch.
31/31 lifecycle, 28/28 summary, hygiene 18/18, make check green, manifest
regenerated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Shujaat Hasan <shujaat@tracebloc.io>
…lient pin watchable (#714)
* fix(chart): un-vacuum the order-of-ops assertion, and make the mysqlClient pin watchable
Two Bugbot Mediums on the staging promotion PR #713.
1. ORDER-OF-OPS ASSERTION WAS VACUOUS. It required `kubectl rollout restart`
before `kubectl annotate` -- but #569 REMOVED that command, and a sibling
notMatchRegex forbids it on a command line. So the only text it could still
match was the header comments explaining the old mechanism, and it passed
regardless of what order the real commands ran in.
Measured on the rendered script rather than argued: with the post-re-image
annotates hoisted above the first `kubectl set image` -- the actual regress,
header comments left in place -- the OLD assertion still passes (True) and
the NEW one fails (False). On the real script the new one passes. So a
regress that recorded the digest before re-imaging would have frozen every
workload on its old image with a green suite.
Now anchored to COMMAND lines and to the mechanism that exists:
`set image` -> `rollout status` -> `annotate`.
(Two earlier attempts at that mutation were themselves broken -- one hoisted
the annotates above the comments too, one popped by stale indices after
inserting. Both "proved" the wrong thing. The mutation above is verified to
have actually moved the lines before its result is quoted.)
2. mysqlClient PIN WAS NEVER WATCHED. `images.mysqlClient` carried a real digest
but no `repository` and an empty `tag`, because both live as template
defaults. check-digest-drift.sh pairs a pin with the repo/float in its own
block, found neither, and classified it UNWATCHABLE -- which exits non-zero,
so the DAILY DRIFT WATCH STAYS RED FOREVER while the pin it exists to compare
is never checked. A guard that cannot pass teaches everyone to ignore it.
Stated in values and consumed by the template, so the value is real config
rather than decoration. Both are what the templates already defaulted to.
Verified:
* render byte-identical across aks/bm/eks values (only POD_TOKEN_SIGNING_SECRET
differs, which helm regenerates every run); mysql image line unchanged
* helm lint clean; helm unittest 36/36 including the edited suite
* drift watch: BEFORE "UNWATCHABLE: images.mysqlClient is pinned to sha256:f546…"
AFTER "ok tracebloc/mysql-client:prod sha256:f546e47fb339…"
* test(image-refresh): pin the order guard to the digest annotate, not any annotate
The order-of-ops assertion only required SOME `kubectl annotate deployment`
after set-image/rollout-status. The flap-counter-reset annotate
(`${ATTEMPT_KEY}- ${FLAP_KEY}-`) already sits there by design, so moving ONLY
the digest-recording annotate (`$annotate_args`) above the re-image kept the
suite green while the digest was recorded BEFORE the image was applied — the
freeze path this guard exists to catch. Require the `$annotate_args`
continuation line so the ordering is tied to the digest write itself, distinct
from the flap reset. Verified by mutation: the loose pattern stayed green with
the digest annotate moved above set-image; the tightened one fails. Bugbot, client#714.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore(chart): bump to 1.9.40 for the chart-content changes in this PR
The version-bump-gate (scripts/chart-version-guard.sh) requires a Chart.yaml
version bump when client/templates|values change, since the Helm repo only
publishes a NEW version — an unbumped edit ships dark or overwrites a
published one. develop is at 1.9.39; bump to 1.9.40.
backend#1468 unrelated — this is client#714.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…end#1729 sweep 5) (#706)
* ci(env): one CLIENT_ENV vocabulary, four languages, now checked (backend#1729 sweep 5)
Sweep 5 of the inert-verification epic. The epic states the class as:
"A verification written in the same vocabulary as the thing it verifies
cannot detect a vocabulary error."
and its evidence was 366 chart tests covering dev/stg/prod/unset/unknown
while NOT ONE set `staging` -- the alias the chart's own docs recommend.
That measured gap is now closed (staging has 9 cases, development 2,
production 4). What is NOT closed is the structure underneath it: the same
three alias->canonical mappings are declared FOUR times, in four
languages, and nothing compares them.
1 client/templates/_helpers.tpl $aliases := dict ... Go template
2 client/values.schema.json the CLIENT_ENV enum JSON Schema
3 scripts/lib/common.sh tb_client_env() bash case
4 scripts/install-k8s.ps1 Get-TraceblocClientEnv PowerShell switch
Two of the four have already drifted, separately, and been repaired
separately: backend#1723 fixed the chart, backend#1745 fixed the bash
installer -- whose own comment records the cost, "a raw `staging` fell
through to the prod branch, so verify_credentials() checked staging
credentials against the production backend and reported them invalid".
So adding a seventh spelling to the template leaves both installers
silently not reducing it, which is #1745 reintroduced in a repo that has
already paid for it once.
THE GUARD DERIVES, IT DOES NOT RESTATE. It parses all four declarations
and compares them to each other; it holds no copy of the vocabulary,
because a fifth hand-written list is the defect rather than the fix (the
lesson of backend#1780 and backend#1828, where hand-copied declarations
each claimed the others kept them honest and nothing crossed the
boundary). It also asserts every accepted spelling is exercised by at
least one helm-unittest case -- the specific thing #1729 measured.
ARMED WHILE GREEN, deliberately: all four agree today and all six
spellings are tested, so this imports no backlog. Arming a red check
trains people to skip the tier -- the same reasoning as .github#235 and
the opposite of what happened when a fleet-wide copies: bump reddened the
org audit for 2h20m on 2026-08-12.
Mutations, all five behaving correctly:
template gains a 4th alias, installers unchanged 3 findings
bash reducer drops staging (the #1745 shape) 1 finding
PowerShell maps staging -> dev 1 finding
schema accepts a spelling no reducer maps 2 findings
a parser goes stale (dict renamed) EXIT 2, fail-closed
That last one matters most: zero parsed pairs compares equal to zero
parsed pairs, so a stale parser would report agreement between four
declarations it never read. It exits 2 with a diagnostic instead.
Wired into SHELLCHECK_FILES and `make check` beside its sibling, and into
helm-ci with path filters covering all four declarations -- including
scripts/lib/common.sh and scripts/install-k8s.ps1, so a change to either
installer runs it.
make check green; chart-env-vocabulary 28/28; gen-manifest --check clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(env-check): don't misreport a missing python3 as a closed-vocabulary gap
env-vocabulary-agreement.sh reads the CLIENT_ENV enum out of values.schema.json
with python3, but `make setup` never installed (or even checked for) python3, so
on the pre-push `make check` path a missing interpreter failed closed with the
false diagnosis that the schema has no CLIENT_ENV enum and the vocabulary is no
longer closed. The Go/bash/PowerShell reducers are jq-free by rule, so python3 is
the JSON parser here and was the one unguarded dependency.
Preflight `command -v python3` before the enum is read, and at the call site
branch on the helper's exit status: 3 (its sys.exit for a genuinely-absent enum)
still reports the real closed-vocabulary finding, while any other non-zero (a
python3 that fails to run, malformed JSON) reports a distinct tooling/parse
error. Add python3 to `make setup`'s prereq check so the gap is caught up front.
Bugbot, client#706.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ci(env): shellcheck env-vocabulary-agreement in the static job
The Makefile's SHELLCHECK_FILES already lists env-vocabulary-agreement.sh,
but installer-tests.yaml's static job shellchecked only through
chart-env-vocabulary.sh, so CI never linted the new guard. Append it to
both the error-gate and warning-advisory invocations so the workflow list
matches the Makefile again (same files, same order).
Bugbot, client#706.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@LukasWodka

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@tracebloc-release-traintracebloc-release-trainBot added the gate-nudge Toggled by the release train to (re-)fire the fr-gate label Aug 14, 2026

@cursorcursorBot 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8059981. Configure here.

Comment threadclient/templates/jobs-manager-deployment.yaml
@tracebloc-release-traintracebloc-release-trainBot removed the gate-nudge Toggled by the release train to (re-)fire the fr-gate label Aug 14, 2026
@tracebloc-release-train
tracebloc-release-trainBot merged commit 5751667 into mainAug 14, 2026
81 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@LukasWodka@shujaatTracebloc@saadqbal