Skip to content

release-train: develop -> staging - #519

Merged
tracebloc-release-train[bot] merged 26 commits into
stagingfrom
release-train/to-staging
Jul 31, 2026
Merged

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

Conversation

@LukasWodka

@LukasWodka LukasWodka commented Jul 31, 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

Medium Risk
Chart default changes alter dev/stg ingestor spawn tags on upgrade (prod stays digest-pinned), and optional DB-cred/RBAC paths touch jobs-manager security when enabled; installer hard-fails and resume behavior affect edge provisioning reliability.

Overview
Automated develop → staging promotion carrying a broad set of merged work, centered on client Helm chart 1.9.9 and installer/CI hardening.

Helm chart bumps to 1.9.9 and changes how spawned ingestor images are chosen: empty images.ingestor.tag now resolves via channelTags keyed on a shared tracebloc.clientEnv helper (canonical dev|stg|prod plus development/staging/production aliases), so dev/stg edges float :dev/:stg instead of the 0.7 float. Prod digest pinning still uses the same normalized env. An opt-in perExperimentDbCreds flag wires jobs-manager env, TB_CREDMGR_PASSWORD secret generation, and flag-gated Secret delete RBAC (default off, byte-identical when off).

CI / repo hygiene adds a chart version guard on PRs (template/values changes must bump Chart.yaml), Dependabot config for security-only GitHub Actions updates, .gitleaks-baseline.json wired into code-quality, and helm-ci multi-arch checks for every spawnable tag (tag + channelTags).

Installers (bash + PowerShell) gain aligned memory floor enforcement on Docker/WSL budgets, Linux sudo full-run refusal, Windows resume-after-reboot state + RunOnce, honester image-pull progress/failure messaging, NVIDIA Docker restart skip when already configured, cgroup delegation live verification, and macOS-friendly checksum verification for pinned tool downloads. Docs add RFC-CLIENT-0003 (installer architecture) and update SEAL-CHECK k3d egress substrate status.

PR template cross-repo examples and checklist items (expand-then-contract, client-runtime qualifiers) are refreshed.

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

LukasWodka and others added 17 commits July 30, 2026 17:02
…491)

A Helm chart repo publishes only on a version change, so a template/values
edit without a Chart.yaml bump reaches no installs. That is exactly how the
perIngestionTables flag block shipped to staging yet never rendered (PR #472
changed the template but not the version, so the published 1.9.7 stayed
stale). This blocking gate makes the bump non-optional.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… re-runs) (#501)

* feat(#420): resume after reboot + schema-versioned install state

Two legitimate reboots (Windows feature enablement; Docker/WSL first boot) can
interrupt the install. Before, every interruption ended with "re-find and re-paste
the one-liner", and each re-run re-walked the whole install. Now:

- Schema-versioned state file under %USERPROFILE%\.tracebloc\install-state.json.
  Pure, unit-tested helpers (New-InstallState, ConvertTo-InstallState,
  Add-CompletedStage, Test-StateHasStage, Test-InstallStateCurrent) + thin I/O
  wrappers (Read/Save/Set-StageComplete/Set-InstallComplete). A corrupt or
  incompatible-schema file degrades to a fresh state -- never a throw. The state is
  ADVISORY: every stage still self-verifies (tools re-checked, cluster re-derived),
  so a stale checkpoint can never skip real work.

- Resume-after-reboot via a RunOnce continuation. On a reboot the installer
  checkpoints 'features-reboot-pending' and registers HKCU RunOnce (Get-ResumeCommand
  reuses the #421 elevation arg-builder + -Resume) so the install resumes at next
  sign-in with no re-pasting -- for both auto-reboot and manual -NoReboot. -Resume is
  forwarded through the admin-gate self-elevation. Only the durable -File form carries
  -Resume; the irm|iex shim has no param block (#421), and the state file drives the
  one-liner path anyway. Cleared on success so it never fires spuriously.

- Fast idempotent re-runs. Each of the 6 steps checkpoints on completion; on a fresh
  run where a prior install completed AND the tools + cluster are still present, the
  installer prints "already installed -- nothing to do" and exits 0. The gate verifies
  real presence, not just the checkpoint, so the claim is honest.

Tests: pure state helpers (round-trip, corrupt/empty/wrong-schema -> fresh, dedup),
Get-ResumeCommand (File form carries -File+switches+-Resume; one-liner omits -Resume),
Save/Read I/O round-trip + corrupt-file degradation (mocked path, no home writes),
and source guards for the -Resume plumbing, reboot-exit RunOnce, per-step checkpoints,
success clear+complete, and the honest fast-path gate.

Closes #420

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

* fix(#420): only mark the install complete on success

Bugbot (High): Set-InstallComplete ran unconditionally before the exit-code check,
so a failed client state (bad_creds/crash/image_pull/image_pull_ca) still persisted
completed=true. A re-run then hit the tools+cluster fast path, printed
"already installed -- nothing to do" and exited 0 -- blocking the documented
remediation (the summary tells the operator to re-run).

Add Test-InstallSucceeded (connected/starting = success) as the SINGLE source of
truth shared by the completion checkpoint and the exit code, so they can't drift.
Set-InstallComplete now runs only when Test-InstallSucceeded; the exit code uses the
same predicate. RunOnce is still cleared unconditionally (no reboot is pending once
the walk finishes). Unit test across connected/starting + failure states, plus source
guards for the gated completion and shared-predicate exit.

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

* fix(#420): fast path requires a RUNNING, bounded cluster check

Bugbot round 2 on the new fast path:
- High: the probe only checked the cluster NAME appeared in `k3d cluster list`, not
  that it was running -- so a completed install with a STOPPED cluster (or dead
  node) printed "nothing to do" and exited 0, skipping New-K3dCluster's start/repair.
- Medium: it ran a bare `k3d cluster list` with no deadline, so a wedged Docker
  engine hung the fast path at the start of every re-run (violates the bounded-
  external-command rule).

Replace Test-ClusterPresent with Test-ClusterRunning: BOUNDED via Start-Job +
Wait-JobWithProgress (15s) + Remove-Job, parsing `-o json` through a pure
Test-ClusterRunningInList that requires serversRunning >= 1 for the named cluster
(present-but-stopped -> false -> fall through to the repair walk). Fast-path message
now states what was verified ("installed and the cluster is running").

Tests: Test-ClusterRunningInList (running/stopped/absent/corrupt/no-count), a
source guard that the probe is job+deadline bounded, and the updated fast-path gate.

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

* refactor(#420): address review — connected-only completion, drop dead stages, split-account note

Asad's review on #501:
- Completion counted `starting`, but that's Get-NotReadyState's catch-all for a
  client that isn't Ready yet -- so a client that never comes up got completed=true
  and armed the fast path, skipping remediation. Completion now requires the client
  to be CONNECTED (new Test-InstallConnected); the exit code keeps the more lenient
  connected||starting (Test-InstallSucceeded) so a slow-but-starting client doesn't
  hard-fail the run. The two predicates differ on purpose and say why.
- The per-stage checkpoints were written but never read (the six steps set shared
  $script: state, so a resume must re-walk them; speed comes from each step's own
  self-skip). Dropped the dead machinery -- stages array, Add-CompletedStage,
  Test-StateHasStage, Set-StageComplete, Test-StageComplete, per-step calls, and the
  features-reboot-pending checkpoint. State is now just {schema, completed}.
- Resume scope clarified: the reboot happens in Step 1 during the elevating
  account's session, so HKCU RunOnce is correct for that account. Added a Hint for
  the split -DailyUser case (a different user signing in after the reboot must re-run).

Tests updated: pure helpers (no stages), Set-InstallComplete round-trip, Test-InstallConnected
vs Test-InstallSucceeded across states, split-account Hint guard, and a guard that the
stage machinery stays gone.

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

* fix(#420): fast path verifies client health; clear stale completed on failure

Bugbot round on the review rework:
- High: the fast path claimed "nothing to do" from completed + tools + a running
  cluster WITHOUT checking the client workloads are Ready (the bash assess path
  requires Ready workloads), and `completed` was never cleared when a later walk
  failed. So a re-run started because the client is down could print "already
  installed" and skip the remediation. Now the fast path also requires
  Test-ClientHealthy (finds the release namespace via Get-InstalledClientInfo, then
  a SHORT bounded `kubectl rollout status --timeout=5s` per client deployment), and
  a walk that doesn't end connected calls Clear-InstallCompleted so a stale flag
  can't keep the fast path armed. Extracted Get-ClientDeploymentNames as the shared
  source of truth for the readiness gate and the health check (no duplication).
- Low: the force-reinstall hint hard-coded ~\.tracebloc\install-state.json; it now
  interpolates Get-InstallStatePath so it's correct under an overridden HOST_DATA_DIR.

Tests: Test-ClientHealthy (unknown/no-ns/all-ready/not-ready via mocks),
Get-ClientDeploymentNames, Clear-InstallCompleted round-trip, and updated fast-path +
hint-path + completion source guards.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Two Medium Bugbot findings from the develop->staging promotion (client#499), both
customer-visible on the Windows path.

1. Print-Roadmap listed five steps while the runtime ran six (the added "Install
   system tools" phase was never added to the up-front banner), so every later step
   was mis-numbered -- undercutting the honest-progress split it was meant to show.
   Fix: a single $script:INSTALL_STEPS source of truth. Print-Roadmap renders it,
   and every "Step N/total" header derives its total from INSTALL_STEPS.Count, so
   the roadmap and the runtime step count can't drift again.

2. The tracked winget/installer installs (Docker Desktop via winget, Docker Desktop
   direct, winget k3d, winget helm) had no output redirects, so a failure left only
   a bare exit code in the log / -Diagnose bundle -- expensive when we debug Windows
   installs remotely from a customer log. Fix: a shared Invoke-TrackedInstall helper
   captures stdout+stderr to temp files, waits with a killing deadline, folds the
   output into the log (stderr first, matching #423), and returns a typed outcome.
   All four sites route through it; the WSL / k3d-cluster-start paths already did
   this, so this removes an inconsistency inside one change rather than adding a new
   convention.

Tests: INSTALL_STEPS/Print-Roadmap single-source + no hard-coded /6 + step-count
parity; Invoke-TrackedInstall behavior (ok/failed/timeout/spawn-failed via mocks) +
redirect/ordering source guards + all four installs routed through it. Updated the
#419/#422 guards that pinned the old Start-Process shapes to the wrapper.

Closes #500
…#494)

* feat(ingestor): resolve the spawned tag per environment (backend#1360)

dev and staging edges now spawn ingestion Jobs from the internal channels
published by the matching data-ingestors branch (:dev / :stg) instead of
the 0.7 release float, so an ingestor change can be validated on a real
edge without a production release. Before this, the ingestor image
existed only as a byproduct of a prod release -- on 2026-07-30 testing
one change cost a prod PyPI publish plus an FR-gate override.

- images.ingestor.tag becomes an explicit override, EMPTY by default.
- images.ingestor.channelTags carries the per-environment floats
  (dev/stg/prod), keyed on the resolved CLIENT_ENV.
- New tracebloc.ingestorTag helper mirrors tracebloc.ingestorDigest's
  precedence: explicit tag > channel for CLIENT_ENV > literal 0.7 (so a
  release predating these keys still renders under --reuse-values).
- prod deliberately stays a semver float, NOT a :prod channel -- no such
  tag is published, and prod normally runs prodDigest anyway.
- The ingestor-multiarch CI guard previously hard-failed on an empty tag;
  it now validates the explicit override when set plus every channelTags
  entry, since an edge resolves exactly one of them.

Chart defaults propagate through the fleet auto-upgrade
(--reset-then-reuse-values), and the installer does not pin the tag, so
existing dev/staging edges pick up their channel on the next upgrade.
An operator who set images.ingestor.tag explicitly keeps it.

Verified: 307/307 helm unittest across 27 suites (9 new cases covering
each environment, the override, an unknown CLIENT_ENV, and a
channelTags-less replay); helm lint --strict clean on all four platform
values files; rendered INGESTOR_IMAGE_TAG confirmed as dev/stg/0.7/0.7
for dev/stg/prod/unset; schema still rejects 'latest' in channelTags and
now accepts the empty override.

* fix(review): normalize CLIENT_ENV aliases before the channel lookup

Asad's first note landed on a real defect, not just a stale description.
The schema documents CLIENT_ENV as (dev, staging, prod) while the channel
keys are dev|stg|prod, so CLIENT_ENV=staging -- the documented value --
missed channelTags entirely and fell back to the prod float. Meanwhile
client-runtime normalizes staging->stg at runtime, so that edge would
have talked to the stg backend while spawning the 0.7 release ingestor:
exactly the split-brain client-runtime#227 was filed for, reintroduced
one layer up.

tracebloc.ingestorTag now normalizes development/staging/production
before the lookup, mirroring proxy_config.ENV_ALIASES, and the schema
description states which values are canonical and that it is load-bearing
for channel selection.

Also addresses the second note: values.schema.json is edited surgically
in the file's own style instead of being reformatted by a json round-trip
(576-line diff -> 29).

Verified: 310/310 helm unittest (3 new alias cases); lint --strict clean
on all four platform files; rendered tag is dev/dev/stg/stg/0.7/0.7 for
dev/development/stg/staging/prod/production, and an unknown value still
falls back to the float rather than rendering empty.

* fix(review): one CLIENT_ENV normalizer, and unbreak the digest resolver

Both from Bugbot on #494, and both caused by my own half-applied alias fix.

1. The alias normalization went into tracebloc.ingestorTag only, while
   tracebloc.ingestorDigest still compared the RAW CLIENT_ENV to "prod".
   So CLIENT_ENV=production got the prod float tag but an EMPTY digest --
   silently dropping the reproducibility pin (backend#1028/#1245) on an
   edge that looked correctly configured, which is worse than the bug the
   alias fix was for. Extracted tracebloc.clientEnv as the single
   normalizer and pointed both helpers at it, so they cannot drift again
   (the reason ENV_ALIASES lives once in client-runtime proxy_config).

2. scripts/resolve-ingestor-digest.sh read images.ingestor.tag, now empty
   by default, so the documented no-arg / --write path exited on an empty
   tag -- the exact command the chart comments and the ingestor-multiarch
   CI error tell operators to run. It now falls back to
   images.ingestor.channelTags.prod on both the yq and the yq-free path,
   with a matching sibling awk reader scoped the same way.

Verified: 311/311 helm unittest (a new case pins the production-alias
digest); lint --strict clean on all four platform files; rendered
tag+digest correct for prod/production/stg/staging/dev/development; the
script's no-arg path resolves 0.7 -> the multi-arch digest again, and the
portable reader returns 0.7 with tag empty.

* style(script): keep each yq-free reader with its own header comment

The new read_ingestor_prod_channel landed between read_ingestor_tag's
header comment and read_ingestor_tag itself, so that header described the
wrong function and read_ingestor_tag sat comment-less ~30 lines below.
Moved the new function below the original; each header is now directly
above the function it documents. No behaviour change -- both readers
verified still returning 0.7 / empty, and the no-arg resolver path still
resolves the multi-arch digest.

* fix: bump the chart to 1.9.9, and strip comments before quotes in the channel parser

Two things, both caught by CI/review rather than by me:

- The 'chart content ⇒ Chart.yaml version bump' guard was red: this PR
  edits client/templates and client/values.yaml, and a Helm repo publishes
  only on a version change, so without a bump the whole change would reach
  no install -- exactly how the perIngestionTables block shipped dark in
  #472. Chart 1.9.8 -> 1.9.9.

- read_ingestor_prod_channel stripped quotes BEFORE removing an inline
  comment, the reverse of read_ingestor_tag. A channelTags.prod line with a
  trailing comment therefore parsed as 0.7" -- a stray quote -- and the
  no-arg/--write resolver would look up a nonexistent ref. Reordered to
  match: key, comment, trim, quotes. Verified across all four forms
  (double/single quoted with and without a trailing comment, and bare).
…ackend#1181) (#503)

* feat(chart): provision per-experiment DB credentials (RFC-0003 D10, backend#1181)

The chart half that flips client-runtime#235 from inert to live:
- perExperimentDbCreds value (default false, schema-typed) → renders
  PER_EXPERIMENT_DB_CREDS + TB_CREDMGR_USER + TB_CREDMGR_PASSWORD onto
  jobs-manager (password via secretKeyRef, never plain).
- secrets.yaml: generate-once TB_CREDMGR_PASSWORD (upgrade-stable, same
  lookup pattern as POD_TOKEN_SIGNING_SECRET), emitted only when enabled.
- rbac: secrets verbs gain 'delete' — jobs-manager deletes the per-job
  cred Secret in the revoke path.
- Chart.yaml 1.9.8 -> 1.9.9 (publishes on version change).
Default installs render byte-identically (all flag-gated; 300 helm tests,
both sides pinned). jobs-manager self-provisions the tb_credmgr account
from this Secret on startup.

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

* fix(chart): gate the RBAC delete verb + credmgr pin/tests (Saqlain + Bugbot #503)

- rbac: the Secret `delete` verb is now flag-gated on perExperimentDbCreds
  and scoped to `secrets` alone (was on configmaps+secrets, every install).
  Default-off is byte-for-byte `["create", "get"]` again — a cluster-wide
  delete-on-all-Secrets ClusterRole no longer ships to every install; the
  verb the revoke path needs only appears when the flag is on (Saqlain #A).
- secrets: TB_CREDMGR_PASSWORD gains the 3rd resolution tier — an explicit
  `.Values.credmgrPassword` operator pin (DR / pre-created MySQL account /
  forced rotation), mirroring podTokenSigningSecret; validated alphanumeric
  (jobs-manager's constraint) with a fail-fast. Also documents the
  off->on->off->on regen edge and that the pin is its fix (Saqlain #B/#C).
- Chart appVersion 1.9.8 -> 1.9.9, back in lockstep with version so the
  `app.kubernetes.io/version` labels report the shipped chart (Bugbot).
- tests: secrets.yaml + rbac.yaml helm-unittests for both flag states
  (credmgr key absent/present, pin flows, non-alnum rejected, delete verb
  gated + secrets-only). 307 helm tests pass; helm lint clean (Saqlain #D).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… (backend#1184) (#504)

* docs(seal-check): record k3d/k3s NetworkPolicy substrate verification (backend#1184)

RFC-0003 §8.4 says 'do not assume k3d enforces NetworkPolicy'. Ran the
substrate check on a throwaway k3d v5.8.3 / k3s v1.33.6+k3s1 cluster: a
standalone deny-egress NetworkPolicy on a probe pod took a curl to
1.1.1.1:443 reachable -> BLOCKED -> reachable-again-after-removal
(HTTP 301 -> connect failure -> HTTP 301). The block is attributable to
the policy, so k3s's embedded kube-router controller does enforce egress
NetworkPolicy on this k3d version — the substrate doubt is resolved.

Records the result in the §8.4 runbook + the follow-ups list; the §8.3 k3d
cell now reads 'substrate verified; full-probe run pending'. Recording the
full-chart egress-enforcement probe run against a deployed release stays
open (still backend#1184). Docs-only.

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

* docs(seal-check): fix matrix drift + lead with unsealed framing (Saqlain #504)

- Update the §8.3 k3d matrix cell (was 'verification run pending; do not
  assume') to 'Substrate verified; full-probe run pending', matching the
  prose that cross-references it — the concrete edit the PR promised but
  never made (Saqlain #i).
- Status note now LEADS with 'still UNSEALED for the egress guarantee on k3d
  until the full-chart probe is recorded', honoring this doc's
  unsealed-never-silently-sealed philosophy — a skimmer can no longer read
  the bold VERIFIED as 'sealed' (Saqlain #ii).
- State the run evidence (versions + reachable->blocked->reachable) ONCE in
  the status note; the post-runbook paragraph, the follow-ups bullet, and the
  matrix cell now reference it instead of restating — which is how the cell
  drifted out of sync in the first place (Saqlain #iii).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Records a redacted gitleaks baseline at the repo root so the full-history
dispatch scan runs clean, and wires the code-quality caller to consume it
via the gitleaks-baseline input. Part of tracebloc/backend#1303.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(#425): tell the truth about stuck image pulls, don't sell failures as "downloading"

helm runs without --wait, so the pull-progress helper waited TB_PULL_TIMEOUT and, on
expiry, always printed "Services are still downloading — they'll finish starting in
the background." — even when the pulls had PERMANENTLY failed (x509 / blocked
registry / auth). A hard failure was reported in success-flavored copy.

Bash (scripts/lib/install-client-helm.sh):
- New _pull_failure_detail: on a visible ImagePullBackOff/ErrImagePull, prints the
  failing pod line + the matching pull event (self-contained; no cross-lib source).
- New pure _progress_end_message: maps (pulled,total,max_pulled,has_fail) -> one of
  done|failed|downloading|stalled. A permanent failure NEVER maps to "downloading",
  so it can't be sold as background progress.
- _download_services_progress tracks max_pulled and, on timeout, classifies: a
  failure warns loudly with the event text; "downloading in the background" prints
  ONLY when pulls demonstrably progressed; otherwise a neutral "not pulling yet".

Windows (scripts/install-k8s.ps1) — the Wait-ForClientReady path already classified
pull failures (image_pull/image_pull_ca/crash) into honest red-X summary branches;
"downloading" only shows for a genuine starting state. Now it also carries the event
text: Get-NotReadyState captures the pull event (or the failing pod line) into
$script:NotReadyDetail, and the failure summary branches print it via the new
Write-NotReadyDetail helper — matching the bash acceptance on both platforms.

Tests: bats for _progress_end_message (all four outcomes; failure wins over partial
progress) + _pull_failure_detail (failing prints detail/0, healthy prints nothing/1)
+ a source guard that the end copy routes through the selector; Pester for
Get-NotReadyState detail capture (x509 / non-x509 / no-event fallback) and
Write-NotReadyDetail (prints under a label; no-op when empty).

Closes #425

* fix(#425): scope _pull_failure_detail to pull-failure events only (Bugbot)

Bugbot: the event grep matched bare x509/TLS strings alongside failed to pull/
ErrImagePull, so with tail -n 3 an UNRELATED x509 event elsewhere in the namespace
could displace the real pull-failure line and show the wrong reason. Scope to
failed to pull|ErrImagePull only — matching summary.sh::_diagnose_not_ready and the
PowerShell path. A genuine x509 pull failure is on a "failed to pull ..." line, so
its detail is still captured; an unrelated x509 event is not.

Test: unrelated x509 events don't displace the real pull reason (403 survives; x509
scoped out). Also merges develop (manifest regenerated).

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

* fix(#425): soften stuck-pull wording + bound the failing-pod list (Asad review)

- ImagePullBackOff/ErrImagePull can also be a transient blip or a registry 429 that
  kubelet keeps retrying, so wait_for_client_ready may still reach "connected". The
  absolute "this won't finish on its own" could contradict a later ✔ Connected —
  soften to "look stuck pulling — this usually needs action, not just more time".
- _pull_failure_detail printed $bad unbounded though the header says "Bounded" and
  the PowerShell path caps at 3; add `| head -n 3` so a many-failing-pods cluster
  doesn't print a wall of lines (matches Select-Object -First 3).

Test copy assertion updated to the new wording.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…508)

* fix(#431): idempotent GPU reconfigure — no Docker restart / cluster bounce on re-run

On a GPU host, every re-run of install_nvidia_container_toolkit unconditionally ran
`nvidia-ctk runtime configure --runtime=docker`, then `systemctl restart docker`,
then a CUDA smoke test that re-pulls nvidia/cuda. Restarting Docker takes the live
k3d cluster DOWN mid-reinstall (a correctness hazard, not just wasted time).

- Skip-when-satisfied: reconfigure + restart Docker only when the running daemon
  isn't ALREADY defaulting to the NVIDIA runtime (_docker_default_runtime_is_nvidia
  via `docker info`). A re-run on a configured host does nothing here — no restart,
  no cluster bounce.
- When a restart IS needed and the cluster is live, say so and sequence it: warn,
  restart Docker, then `k3d cluster start` to bring the cluster back deterministically
  rather than relying only on the nodes' restart policy. Cluster-running is detected
  jq-free via _k3d_cluster_running.
- Cache the smoke test: record the toolkit+driver signature (_gpu_stack_signature) of
  the last PASSING test; skip (no image pull) when unchanged, re-verify when it changes.

Also fixes a latent awk bug the tests caught: an `exit 0` in a main rule still runs
END, so the old `END { exit 1 }` overrode a positive result — the running-cluster
check now decides only in END.

Tests: new scripts/tests/gpu-nvidia.bats — the three helpers plus the acceptance
behaviors (already-nvidia -> no reconfigure/restart; not-nvidia -> reconfigure +
restart; live cluster -> warn + k3d cluster start; cached smoke test -> no pull;
first run -> records the signature).

Closes #431

* fix(#431): bound GPU probes, surface restart failure, re-verify after reconfigure

Bugbot round on the idempotent GPU work:
- High: _docker_default_runtime_is_nvidia ran a bare `docker info` with no timeout —
  a wedged daemon would hang a headless re-run at the skip gate. Route it (and the
  k3d probe) through _bounded (timeout(1)/gtimeout(1)) with TB_PROBE_TIMEOUT.
- Medium: after warning the cluster would restart, `k3d cluster start` discarded
  output and `|| true`'d failure, so a failed bring-up was reported as success.
  Capture output, and on failure warn with the k3d error + a manual-start hint.
- Medium: the smoke-test cache keyed only on toolkit+driver signature, so a re-run
  that ACTUALLY reconfigured + restarted Docker could still skip `docker run --gpus
  all` and report a possibly-broken post-restart path as verified. Force the smoke
  test (bypass the cache) whenever the runtime was reconfigured this run.

Tests: bounded-probe source guard; a failed cluster restart is surfaced (warn +
manual hint); a reconfigure re-verifies even with a matching marker.

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

* fix(#431): bound the post-restart k3d cluster start (Bugbot r2)

The `k3d cluster start` added to bring the cluster back after a Docker restart had no
deadline; k3d defaults to --wait with timeout 0 (forever), so a slow post-restart
bring-up could hang a headless re-run. Add --wait --timeout "${min}m"
(TB_CLUSTER_START_TIMEOUT_MIN, default 5) so it aborts with a real k3d error — which
the existing failure path then surfaces (warn + manual-start hint). Mirrors the
bounded `cluster create` (#426). Source guard added.

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

* fix(#431): don't strand a live cluster on an inconclusive probe; clear stale smoke marker

Bugbot round 3:
- Medium: a forced smoke re-verify that FAILS left the old .gpu-smoke-ok marker in
  place, so the next (reconfigured=0) run saw a matching signature and skipped the
  test, treating a failing stack as verified. Remove the marker on smoke failure.
- Medium: _k3d_cluster_running returned "not running" both when the cluster was
  genuinely down AND when the bounded probe failed/timed out. On a wedged daemon
  (which also fails the nvidia check) that meant Docker was restarted WITHOUT a
  cluster start, silently leaving a live cluster down. Make it tri-state
  (0 running / 1 not running / 2 unknown); the caller now attempts recovery on
  running OR unknown, warning "Couldn't confirm … will try to bring it back".

Tests: probe failure -> unknown (rc 2); unknown state still attempts recovery + warns;
a failed forced smoke test clears the stale pass marker.

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

* fix(#431): make the GPU probes set -e safe (installer sources under set -euo)

Bugbot round 4 — the idempotency helpers tripped the installer's `set -e`:
- High: `_k3d_cluster_running; cr=$?` (and the internal `out=$(...); rc=$?`) are bare
  sequences, so a non-zero probe aborts the install before `systemctl restart docker`
  on a first-run GPU host (no cluster yet, probe returns 1) — and made the UNKNOWN(2)
  timeout path unreachable. Use `|| cr=$?` / `|| rc=$?` so the code is captured, not fatal.
- Medium: `_gpu_stack_signature` ended on a failed `[[ -n … ]] && printf`, returning 1
  when no versions are found; `gpu_sig="$(…)"` then aborted under set -e. Add `return 0`
  so an empty signature means "don't cache", not "abort".

Tests: two set -e integration tests (source common.sh + gpu-nvidia.sh under
`set -euo pipefail`) — empty signature and a failed cluster probe both complete
without aborting. These exercise the exact context the unit tests (no set -e) missed.

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

* fix(#431): bound the GPU signature probes too (Bugbot r5)

_gpu_stack_signature shelled out to nvidia-ctk + nvidia-smi with no deadline, and it
runs at the smoke-test skip gate on every re-run — a half-ready driver (installed
without reboot) or a stuck device node could hang a headless re-run, the same class
already bounded for docker info / k3d cluster list. Route both through _bounded
"${TB_PROBE_TIMEOUT:-5}"; an empty signature on timeout already means "don't cache".
Source guard extended to assert both signature probes are bounded.

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

* test(#431): fix _gpu_stack_signature tests under CI (timeout present)

The two _gpu_stack_signature tests didn't mock `has`, so on a runner that HAS
timeout(1) (CI) the newly-_bounded probes exec the real (absent) nvidia-ctk/nvidia-smi
via `timeout` — which can't see shell-function mocks — and the "combines versions"
assertion failed (it passed on macOS, which lacks timeout, so _bounded took its
passthrough branch). Add `has() { return 1; }` to both tests so _bounded runs the
function mocks, matching how the docker/k3d probe tests already suppress timeout.
Verified with a fake `timeout` on PATH: 21/21 pass. Production is unaffected (real
GPU hosts have the binaries); this was purely a test-mocking gap.

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

---------

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

A YAML single-quoted value escapes a quote by doubling it ('' -> '). The unescape
used `${line//\'\'/\'}`, whose `\'` REPLACEMENT literal is bash-version dependent:
bash 4/5 (Linux CI) yields the intended `'`, but bash 3.2 (the macOS system bash)
keeps the backslash and produces `a\'b` — a corrupted clientPassword for any macOS
user whose password contains a doubled quote. The bats test for this case has been
failing on macOS while passing on the Linux CI leg for exactly this reason.

Use a variable for the quote in the pattern + replacement (`local _sq="'"`), which
expands to a bare quote on bash 3.2 and 4/5 alike. Existing test kept; now passes on
both. No behavior change on Linux.
…installs (#511)

* fix(#427): grant docker group on any daemon run; refuse sudo-wrapped installs

Two compounding identity bugs on Linux:

1. The docker-group grant ran ONLY inside the fresh-Docker-install branch. On a box
   where Docker was already present and the user wasn't in the group, the else-branch
   printed "Docker" without granting, and the recovery path dead-ended at "Could not
   connect to Docker. Try logging out and back in…" — which couldn't help, because
   membership was never granted. Re-runs looped on the same message.
   Fix: after the install/else, ensure the invoking user is in the docker group
   whenever the daemon path is chosen, regardless of a fresh install. Skip if already
   a member (no redundant usermod); prepare-host stays exempt (only TB_PREPARE_USER
   is granted, later — least-privilege, #381). The existing sg-docker re-exec then
   activates the new membership in-session, so no dead-end loop.

2. Nothing was SUDO_USER-aware: `sudo bash install.sh` ran the WHOLE provision as
   root — usermod granted root (not the user), and ~/.tracebloc, ~/.kube/config, and
   the chmod-600 credential landed root-owned under /root, with no chown anywhere to
   undo it. The installer's model is to run as the daily user and elevate per-step
   (RFC-0002), so rather than a fragile ownership remap, refuse the sudo-wrapped full
   run early (before any file is created): refuse_sudo_wrapped_install errors when
   EUID 0 AND $SUDO_USER is a real (non-root) user. Exemptions: a genuine root login
   (no SUDO_USER); prepare-host (the admin path, already dispatched+exited in main()).
   Because we never run as root-with-SUDO_USER, every $HOME/$USER path in the tree
   stays correct with no remap.

Also: honor TB_PREPARE_USER as the grant target in the main install (new
_real_install_user helper; the #418 Windows peer).

Tests: install_docker_engine grants on the pre-installed path + when only
TB_PREPARE_USER differs, skips when already a member, never grants the admin in
prepare-host, still grants on a fresh install; refuse_sudo_wrapped_install refuses
sudo+SUDO_USER but allows root-login / sudo -i / non-root; _real_install_user.

Closes #427

* fix(#427): grant the invoking user (not TB_PREPARE_USER); name TB_PREPARE_USER in the refuse hint

Bugbot:
- The main-install docker-group grant targeted _real_install_user (TB_PREPARE_USER
  when set), but socket access and the sg-docker re-exec key off $USER. When they
  differ (e.g. a leftover `export TB_PREPARE_USER=` from prepare-host), the invoking
  user never got membership and hit the same dead-end this PR fixes. Grant $USER
  directly — the sudo-wrapped run is already refused, so $USER is the real daily
  user — and keep TB_PREPARE_USER on the prepare-host path only (where it's granted).
  Removed the now-unused _real_install_user helper + its tests.
- The sudo-refuse hint pointed admins at a BARE `prepare-host`, but run_prepare_host
  only grants when TB_PREPARE_USER is set — so that remedy prepares the daemon and
  grants nobody. Name it: `export TB_PREPARE_USER=<user> && … prepare-host`.

Tests: the grant targets $USER even with a leftover TB_PREPARE_USER; the refuse hint
includes TB_PREPARE_USER=<user>.

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

* fix(#427): refuse hint names a RESEARCHER placeholder, not the admin (Bugbot r2)

The sudo-refuse prepare-host remedy filled TB_PREPARE_USER=${SUDO_USER}, but SUDO_USER
is the ADMIN who ran sudo — the "setting up for someone else" case targets a DIFFERENT
researcher. Following it would grant the admin docker-group access and leave the
intended user locked out (the #377 least-privilege footgun). Use a
<researcher-username> placeholder and say "not yourself". Test updated to require the
placeholder and reject $SUDO_USER.

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

* fix(#427): key the sg-docker re-exec off _grant_user, consistent with the grant (reviewer)

The grant resolved its target with a `${USER:-$(id -un)}` fallback, but the in-session
sg-docker re-exec guard still keyed off bare `$USER`. In the exact USER-unset case the
fallback exists for, the grant landed on `$(id -un)` while that guard saw an empty
`$USER`, skipped the re-exec, and re-introduced the "log out and back in" dead-end.
Hoist _grant_user to the top of install_docker_engine and use it in both places so
they can't disagree. Guard test added.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…isn't writable (#512)

#432's documented core — running the network-FS guard before the log dir is created
in the FULL install — already shipped in #441 (early_data_dir_guard runs before
setup_log_file; validate_config before it only validates, never mkdirs). This closes
the remaining path #441 didn't cover: prepare-host calls setup_log_file BEFORE any
guard, and setup_log_file did a bare `mkdir -p "$HOST_DATA_DIR"` + tee — so on an NFS
home under sudo + root_squash (the exact scenario #432 names) it failed with a cryptic
error before any friendly message.

setup_log_file now chooses its path via _choose_log_file: HOST_DATA_DIR when it's
creatable AND writable, else a temp file (the scope's "start logging in a temp
location" option) — so no install path dies on a bare mkdir/tee. The full install's
early_data_dir_guard still refuses a network DATA dir before this runs, unchanged.
(Also: the temp template uses trailing X's — BSD mktemp on macOS rejects a suffix
after XXXXXX.)

Tests: _choose_log_file returns a path under a writable HOST_DATA_DIR, and falls back
to a temp path (never a bare failure) when the dir is uncreatable/unwritable.

Closes #432
…colima from RAM (#513)

* fix(#428): enforce the macOS memory floor, clamp recommendations, size colima from RAM

On macOS the memory picture was the Windows WSL2 story with weaker guardrails:

- Floor unenforced: the post-Docker runtime recheck (_pf_recheck_runtime_mem, the one
  point the REAL VM size is known) only WARNed, so a Docker VM below the 5 GB floor
  proceeded and OOM-crashlooped the client. It now HARD-FAILS a sub-floor VM with the
  exact fix (Docker Desktop memory / COLIMA_MEMORY) on every OS; a between-floor-and-
  warn VM still only warns.

- Recommendations exceeded physical RAM: "raise to 16 GB" on a ≤16 GB Mac is
  impossible. New _pf_clamp_mem_gb clamps every SHOWN figure (rec AND warn) to
  physical − PF_OS_RESERVE_GB (default 2); all _pf_memory / recheck hints use it.

- Colima never sized: it was hard-coded --memory 6 (too big for a ≤8 GB Mac, never
  scaled up). New _macos_vm_mem_gb derives min(half of physical, clamped rec), floored
  at PF_MIN_MEM_GB, from hw.memsize — the single sizing helper the macOS VM paths
  share. setup-macos.sh's colima start uses it (COLIMA_MEMORY still overrides).

Deferred (noted): writing Docker Desktop's settings.json memoryMiB with consent — the
riskiest piece (mutates a user's Docker config) and not in the acceptance criteria;
better as its own PR.

Tests: _pf_clamp_mem_gb (clamp/headroom/unknown), _macos_vm_mem_gb (8->5 floor, 16->8,
64->16 cap, unknown->default), recheck sub-floor HARD FAIL vs between-floor warn, and a
guard that colima memory is derived not hard-coded.

Closes #428

* fix(#428): floor the memory clamp; correct colima resize command (Bugbot)

- Clamped hints could undershoot the floor: _pf_clamp_mem_gb capped at physical −
  reserve with no lower bound, so a ~6 GB host got "raise to 4 GB" — below the 5 GB
  floor AND below the RAM it already has. Floor the clamp at PF_MIN_MEM_GB so a hint
  never recommends a sub-floor number (mirrors the PowerShell path's "at least min").
- Colima remedy didn't resize: the hard-fail hint said
  `COLIMA_MEMORY=… colima stop && colima start`, but colima doesn't read
  COLIMA_MEMORY and the env prefix applies only to `stop`, so `start` kept the old
  size. Use the real resize: `colima stop && colima start --memory <N>`.

Also fixes a test bug: `_pf_clamp_mem_gb 16 ''` hit the ${2:-host} default and read
real host RAM (passed on a big-RAM Mac, failed on the smaller CI runner) — the
"can't clamp" case now tests 0 and a non-numeric string.

Tests: never-undershoot-floor on a 6 GB host; colima-resize command guard; corrected
unknown-physical test.

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

* fix(#428): size colima above the floor + honest host-too-small message (Bugbot r2)

Catch-22 my sizing introduced: _macos_vm_mem_gb sized colima to EXACTLY the floor
(5 on an 8 GB Mac), but a guest's MemTotal runs a few hundred MiB below the configured
VM size while the recheck allows only 64 MiB grace — so a fresh install started colima
at the derived budget and then hard-failed at cluster-create on the size it had just
chosen. And on ≤7 GB hosts the remedy repeated an unachievable size (no way forward).

- _macos_vm_mem_gb now sizes ≥ PF_MIN_MEM_GB + 1 (headroom so the guest clears the
  recheck floor), but never over-commits the host (capped at physical − reserve). An
  8 GB Mac -> 6 (clears the recheck); a too-small host gets less and the recheck stops
  it honestly.
- _pf_recheck_runtime_mem: when physical − reserve < floor (host can't ever give the
  VM the floor), it now says "this Mac has N GB — too little … use a larger machine"
  instead of a resize remedy that repeats an impossible size. Mirrors the PowerShell
  host-too-small branch.

Tests: 8 GB -> 6; 6 GB host capped to 4 (not over-committed); recheck host-too-small
message with no colima-resize line.

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

* fix(#428): widen the runtime-recheck grace to the guest-vs-configured overhead (reviewer)

The recheck tolerated only 64 MiB below the floor, but a guest's MemTotal runs a few
hundred MiB under its CONFIGURED size — so a Docker Desktop VM hand-set to exactly the
documented 5 GB floor reported ~4.8 GB and hard-failed, making the effective floor a GB
higher than we tell people (colima dodged it by sizing floor+1). Add
PF_VM_MEM_GRACE_MIB (512) and use it in the recheck's floor comparison so a VM at the
documented floor passes (warns), while a genuinely sub-floor VM (e.g. 4 GB) still
hard-fails. Test: a ~4.8 GB guest warns, doesn't hard-fail.

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

---------

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

Get-WslConfigMemoryGb writes the WSL2 memory budget into the daily user's
.wslconfig during elevated provisioning. It did its own arithmetic -- physical
RAM minus a PRIVATE 4 GB reserve, floored at 1 GB -- so it could persist a
budget the client cannot run in, and it contradicted the installer's own advice
in the same run.

Measured before ($env:TB_PESTER="1"; . scripts/install-k8s.ps1):

  host   writes    advises (run / train)
     8      4        6 / 6      <- below the 5 GB floor: guaranteed OOM crashloop
    16     12        8 / 14
    32     28        8 / 16     <- over-committed: Windows left 4 GB
     6      2        4 / 4      <- doomed budget persisted as if intentional
     4      1        2 / 2

8 GB is the important case: a perfectly viable host (8 - 2 GB OS reserve = 6,
clear of the 5 GB floor) got memory=4GB while Show-MemoryStatus in the same run
said "give Docker up to 6 GB".

Rather than re-derive a floor here, the function now DELEGATES to
Get-PfMemRecommendation -- the existing single-source helper the advice path
uses. The written budget is therefore the advised budget by construction, not
by two calculations agreeing:

  host   writes        advises (train)
     7   memory=5GB      5   (= the floor; 5+2 is the stated practical minimum)
     8   memory=6GB      6   (was 4 -> OOM)
    16   memory=14GB    14   (was 12)
    32   memory=16GB    16   (was 28; Windows keeps 16, and the client cannot
    64   memory=16GB    16    use more than the recommended training budget)
     6   skipped         -
     4   skipped         -

Reserve reconciled: the private ReserveGb=4 is gone and the PARAMETER is gone
with it, so no caller can reintroduce the drift. $script:PfOsReserveGb (2) is
now the only reserve -- which is what its own comment already claimed ("used to
cap recommendations AND to reason about the achievable budget in one place, so
the two can't drift", #417 reviewer). Three accessors (Get-PfOsReserveGb /
Get-PfMinMemGb / Get-PfRecMemGb) give the printing and writing paths one read
path for the same numbers; the reserve accessor fails closed (never 0, which
would hand WSL2 the entire host).

Host genuinely too small (physical - reserve < floor): the function returns 0
and the caller SKIPS the memory setting instead of persisting a budget known to
OOM -- which would also bake the failure into the daily account for every later
run and read as intentional to whoever debugs it next. It says so plainly
instead ("about 7 GB physical is the practical minimum ... use a larger
machine"), the same honest framing the macOS path landed in #513. Still
warn-only: Set-DailyUserProvisioning is documented never to fail the install,
and Windows memory preflight is warn-only throughout.

No headroom fudge is needed on Windows, unlike colima in #513: only a 7 GB host
lands exactly on the floor, 7 GB is precisely the practical minimum this code
reports, and the Windows recheck warns rather than hard-failing -- so there is
no catch-22 where the installer hard-fails on the size it just chose.

Scope: Show-MemoryStatus and Get-PfMemRecommendation are deliberately untouched
so this does not collide with the open #444 (which rewrites both). Verified
forward-compatible -- with #444's floored recommender simulated, every host
above returns an identical result, because the too-small gate tests the
achievable ceiling rather than the recommender's output.

Tests: the two assertions that pinned the old cap/floor are replaced by the new
contract -- never below the floor, 8 GB -> 6 not 4, 0 (don't write) when the
host can't reach the floor, never over-commits, caps at the training budget,
PF_MIN_MEM_GB override honoured, no ReserveGb parameter, and a lock asserting
written == advised across seven host sizes. Plus source guards that the caller
gates the write on 0 and prints the honest too-small message.

Gates: Invoke-Pester scripts/tests/ -> 377 passed / 0 failed;
Invoke-ScriptAnalyzer (as CI scopes it) -> 0 errors; check-style.sh clean;
check-drift.sh no drift; manifest.sha256 regenerated.

Refs #418

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… adapters (#434)

Records the strategy decision from the 2026-07 multi-environment installer
sweep: single Linux bash core, thin macOS/Windows adapters (Windows adapter
gated on the rootless-in-WSL validation, backend#1179), facts single-sourcing
+ CI parity gate + a real Windows e2e leg regardless of the gate outcome.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@LukasWodka
LukasWodka requested a review from saadqbal as a code owner July 31, 2026 13:23
@LukasWodka

Copy link
Copy Markdown
Contributor Author

bugbot run

@LukasWodka

Copy link
Copy Markdown
Contributor Author

👋 Heads-up — Code review queue is at 35 / 30

Above the WIP limit. The team convention is to review existing PRs before opening new work.

Open PRs currently in Code review (oldest first):

Pull from review before opening new work. (This is a nudge from the kanban WIP check, not a block.)

Get-WslConfigContent had no production caller. It was superseded by
Add-WslMemorySetting, which merges a memory= line into EXISTING
.wslconfig content instead of overwriting it -- so it preserves other
tuning (processors, swap, ...). Set-DailyUserProvisioning calls
Get-WslConfigMemoryGb + Add-WslMemorySetting; nothing calls the old
whole-file builder.

The only remaining reference was its own Pester test, which asserted the
function worked rather than that anything used it -- so the test kept
dead code alive. Removed both.

Re-verified no caller across scripts/ and .github/ (incl. a
case-insensitive sweep and a check for dynamic invocation) before
removing.

Regenerated scripts/manifest.sha256 for the R8 static-analysis gate.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: shujaat hasan <shujaat@tracebloc.io>
Comment thread .github/workflows/chart-version-guard.yml Outdated
Comment thread scripts/lib/install-client-helm.sh
shujaatTracebloc and others added 4 commits July 31, 2026 15:40
…uence (#514)

* fix(#496): verify cgroup delegation is active, and tell the real consequence when it isn't

_write_cgroup_delegation wrote the user@.service.d/delegate.conf drop-in + ran
`systemctl daemon-reload`, then unconditionally printed "Delegated … A re-login may be
needed." But daemon-reload re-reads unit files WITHOUT restarting the running
user@$(id -u).service, so cpu/cpuset/io aren't in effect for that session — and the
k3d node inherits the delegation state from when it's CREATED. So limit-bearing pods
run unconstrained until a re-login AND a cluster recreate, while the install looks
green. The old message understated this ("may be needed").

Fix (the issue's recommended Option 3 — verify, don't assume; not the session-killing
`systemctl restart user@…`):
- New _cgroup_controllers_active reads the live cgroup.controllers of the user slice
  (/sys/fs/cgroup/user.slice/user-$(id -u).slice/cgroup.controllers; overridable for
  tests) and is true only when cpu+cpuset+io are all delegated.
- After the reload, _write_cgroup_delegation checks it: if active -> success ("active
  in this session"); if not -> a LOUD warn stating limits won't enforce until a
  re-login AND `k3d cluster delete <name>` + re-run.

Tests: _cgroup_controllers_active (all-present / missing / unreadable), and
_write_cgroup_delegation's active-vs-not report.

Closes #496

* fix(#496): verify on every run + mode-aware remediation (Bugbot)

- Silent re-run skipped verification: _cgroup_controllers_active only ran after a fresh
  drop-in write; the idempotent path (file already present) early-returned. So the
  re-run the hint tells operators to do took the silent fast path — and with lingering
  (the rootless path) a re-login often doesn't restart user@.service, leaving inactive
  delegation invisible. Restructured so the report/verify runs on EVERY invocation
  (fresh AND idempotent), and the wording now notes a reboot may be needed with linger.
- Wrong prepare-host remediation: _write_cgroup_delegation is shared with
  run_prepare_host, but the warn told the admin to log out + `k3d cluster delete` —
  prepare-host creates no cluster, and the verify read the ADMIN's user slice, not the
  researcher's. Split out _report_cgroup_delegation, mode-aware: prepare-host just
  confirms the drop-in is written (takes effect at the researcher's next login); the
  full install verifies this session + gives the recreate remedy.

Tests: re-run over an existing drop-in still verifies (no silent fast path); prepare-host
mode uses researcher-login wording with no cluster-delete / no admin-slice judgement.

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

* fix(#496): re-surface inactive delegation on the REAL call paths (#514 reviewer)

The round-2 verify/report worked in _write_cgroup_delegation's own tests but
not the actual wiring:

1. _ensure_cgroup_delegation (the only full-install caller) short-circuits at
   its OWN fast path before ever reaching _write_cgroup_delegation. On a 2nd+
   run over a written-but-not-yet-active drop-in — the exact #496 case — it just
   logged "already present" and returned; the verify/warn never ran. Route the
   fast path through _report_cgroup_delegation (an unprivileged read, so the
   no-sudo property holds) so an inactive drop-in re-surfaces.

2. run_prepare_host resets TB_PREPARE_HOST_MODE right after install_docker_engine,
   before the cgroup write — so the report fell through to the full-install branch
   and printed "recreate the cluster" advice judged on the ADMIN's slice. Set the
   mode around the cgroup write (mirrors install_docker_engine) so it reports in
   prepare-host wording.

Tests exercise the real callers now, not the helper in isolation: fast path
re-surfaces an inactive drop-in (+ stays sudo-free), confirms an active one, and
run_prepare_host reports researcher-login wording with no cluster-delete advice.

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

* fix(#496): verify delegation on user@$UID.service, not the slice (#514 Bugbot High)

_cgroup_controllers_active read user-$UID.slice/cgroup.controllers, but Delegate=
on user@.service enables the controllers INSIDE user@$UID.service (the path
runc/rootless-containers document). The slice node routinely lists cpu/io by
default (DefaultCPUAccounting), so the full-install path could print "active in
this session" while the user manager still lacked the delegation and limit-bearing
pods ran unconstrained. Read the manager's node instead; split the default path into
a unit-testable _cgroup_controllers_path.

Also (reviewer): drop the sudo from _write_cgroup_delegation's idempotent cmp — the
drop-in lives under /etc and is world-readable, so a plain cmp matches
_ensure_cgroup_delegation's unprivileged grep and avoids a needless elevation.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…tmpdir symlink + host-CLI leak) (#443)

* fix(installer): make the bats suite green on stock macOS (bash 3.2 + tmpdir symlink + host-CLI leak)

Three independent reasons the suite failed on a stock macOS box but passed in CI:

- bash 3.2 keeps the backslash in a `\'` REPLACEMENT literal, so
  "${TB_CLIENT_PASSWORD//\'/\'\'}" corrupts a quote-bearing password into
  a\'\'b inside the generated values file. Replace via a $_sq variable,
  which expands to a bare quote on 3.2 and 4/5 alike. (develop already
  landed the same fix for _extract_yaml_value; this is the sibling call
  site, which was still on the broken form.)
- macOS puts BATS_TEST_TMPDIR under the /var -> /private/var symlink while
  validate_config resolves via `cd -P`, so the under-$HOME check failed
  spuriously. Resolve $HOME with `cd -P` like the neighbouring tilde tests.
- the no-REF/BRANCH bootstrap tests exec'd the HOST's real `tracebloc`
  CLI (on PATH or re-prepended ~/.local/bin) and exited 0 through the
  already-installed bail-out, before reaching the gate under test. Add
  run_boot_hermetic (sandboxed HOME + PATH) for those cases.

Also declares bats_require_minimum_version 1.5.0 for the `run -<code>`
syntax, and uses `run -127` where 127 is the asserted outcome (silences BW01).

Verified: 651/651 bats green on stock macOS bash 3.2 (GNU bash 3.2.57), and
shellcheck --severity=error + bash -n + check-style all clean.

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

* fix(installer): centralize the YAML single-quote escaping + address Saqlain's review (#443)

All five review points, verified before acting on each.

1. clientId was interpolated RAW into a double-quoted scalar
   (`clientId: "$TB_CLIENT_ID"`), so a `"` or `\` in the value would corrupt the
   generated values file — the same bug class this PR fixes for the password one
   line below, and unguarded (`_sanitize_credential` only strips paste and
   non-printable characters). Both credentials now go through the shared escaper
   into single-quoted scalars. verify_credentials gates IDs to UUIDs in practice,
   so this is hardening, not a live break.

2. `bats_require_minimum_version 1.5.0` was self-defeating. Per bats-core's
   changelog, `run -<N>` landed in 1.5.0 (2021-10-22) but
   `bats_require_minimum_version` itself only exists from 1.7.0 (2022-05-14) — so
   on a 1.5.x-1.6.x bats that line is an undefined command and kills the file
   before the guard can help. Raised to the real floor, 1.7.0.

3. The `[ "$status" -eq 127 ]` after `run -127` was dead code. Confirmed
   empirically: with a mismatched code bats fails AT the `run` line
   ("failed, expected exit code 127, got 3"), so nothing after it executes.
   Dropped, with a comment saying why.

4. The bash-3.2 quote idiom now lives in exactly one place — `_yaml_sq_escape` /
   `_yaml_sq_unescape`. Previously the escape and the unescape each carried their
   own copy of the rationale and a cross-reference to keep them in sync; the rule
   is now encoded once and both call sites just call it.

5. Tightened the `run_boot_hermetic` comment. The distinguishing condition is "no
   REF/BRANCH *and* no mock tracebloc of its own" — the bail-out tests at ~226/242/254
   also pass no REF/BRANCH but each writes its own mock into $BIN, so they are
   hermetic by construction and correctly stay on plain `run_boot`.

Tests: 7 new — both escaper directions, a round-trip over quote-heavy values
("a'b", "'", "''", "it's a 'test'"), clientId surviving a single-quote round-trip,
a double quote no longer terminating the scalar early, and a guard that the
generated file uses the escaper rather than raw interpolation. Five existing
assertions on the GENERATED values file moved to the single-quoted form; the
double-quoted inputs and mocked `helm get values` output are deliberately left
as-is, since reading that form is the backward-compatibility path for values files
written by older installers.

Full bats suite 658/658 green on stock macOS bash 3.2 (GNU bash 3.2.57) —
including `validate_config: valid config passes`, which this PR fixes.
shellcheck --severity=error and --warning clean, bash -n, check-style,
check-drift and gen-manifest --check all clean.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…hare the good one (#518)

_pf_storage_type told operators to set HOST_DATA_DIR="$HOME/.tracebloc", but on a
network HOME that path is still NFS (the very problem), and validate_config rejects
paths OUTSIDE $HOME — so the printed fix could never work. early_data_dir_guard (#441)
already had a followable remedy for exactly this.

Extract that remedy into a shared _pf_network_fs_remedy and use it in BOTH
_pf_storage_type and early_data_dir_guard, so they can't drift: name the options that
actually work (install as a user whose home is on a local disk, or
TRACEBLOC_ALLOW_NETWORK_FS=1), and note datasets may stay on network storage via
HOST_DATASET_DIR.

Tests: _pf_storage_type NFS now prints the followable remedy and NOT the old
~/.tracebloc advice; a consistency test asserts both callers emit the same remedy.

Closes #479
…n unachievable range (#444)

* fix(installer): memory advice never drops below the client minimum; a too-small host says so (#417 residual)

This PR's ORIGINAL scope is superseded. #483 ("report host RAM consistently +
achievable memory advice (#417)") merged to develop on 2026-07-30 and closed
#417, delivering the same Windows half by a different route: `Get-PfMemGb`
returns host RAM only, `Get-PfRuntimeMemGb` is shown as its own labelled line,
`Get-PfMemRecommendation` caps at host − 2 GB, and `Show-MemoryStatus` is the
single copy shared by Step 1 and the post-Docker recheck. Re-landing this
branch's `Get-PfHostMemGb` / `Get-PfMemTargets` / `Write-PfRuntimeMemStatus`
would only rename what already works.

What survives is the finding behind this branch's second commit, which is STILL
LIVE on develop: `Get-PfMemRecommendation` floors at 1 GB, so on a small host it
returns a number below the client's own minimum. Measured on develop:

    Show-MemoryStatus -HostGb 6 -BudgetGb 3
      ⚠  Memory: 6 GB (Docker's current share: 3 GB) - below the 5 GB the client needs; it will OOM.
      Give Docker at least 5 GB (up to 4 GB): ... [wsl2] memory=4GB ...

"at least 5 GB (up to 4 GB)" is an empty range, and the concrete value it tells
the operator to write is below the 5 GB the same sentence demands — the warning
cannot be cleared by following the advice. #483's Bugbot pass fixed this only for
the host-RAM-unreadable case; a KNOWN small host still hits it.

- `Get-PfMemRecommendation` now floors at PF_MIN_MEM_GB instead of 1, matching
  bash's `_pf_clamp_mem_gb` exactly so both installers advise the same on the
  same hardware.
- `Show-MemoryStatus` treats a host that cannot reach the floor even with the OS
  reserve honoured (host − PF_OS_RESERVE_GB < PF_MIN_MEM_GB) as NOT a budget
  bottleneck, so it gets the honest "use a larger machine" line rather than a
  resize remedy it can never satisfy. This mirrors the bash recheck's
  host-too-small branch, and the sibling fix in #445.

After:

    Show-MemoryStatus -HostGb 6 -BudgetGb 3
      ⚠  Memory: 6 GB (Docker's current share: 3 GB) - below the 5 GB the client needs; it will OOM.
      This machine has 6 GB of RAM total; the client needs at least 5 GB. Free up memory or use a larger machine.

Hosts that can reach the floor are unchanged (8 GB host still offers the resize;
15 GB → 13 and 16 GB → 14 recommendations are untouched).

Tests: 7 new/updated — the floor is the client minimum not 1, a 6 GB host never
yields a sub-floor number, PF_MIN_MEM_GB overrides the floor, an invariant sweep
over hosts 1..24 GB, the too-small host gets "larger machine" with no memory=1-4GB
value, and a host that CAN reach the floor still gets the resize hint. The former
"floors at 1 GB" assertion is replaced — it pinned the bug. Pester 374 passed /
0 failed / 9 skipped; PSScriptAnalyzer 0 errors; check-style, check-drift and
gen-manifest --check clean.

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

* fix(installer): name the OS reserve in the host-too-small hint (Bugbot #444)

The previous commit routed 5-6 GB hosts into `Show-MemoryStatus`'s generic
too-small hint, which compares total RAM to the floor and stops there:

    host=6 GB, budget=3
      ⚠  Memory: 6 GB (Docker's current share: 3 GB) - below the 5 GB the client needs; it will OOM.
      This machine has 6 GB of RAM total; the client needs at least 5 GB. Free up memory or use a larger machine.

6 >= 5, so as written the operator is told they have enough and still need a
bigger machine. The shortfall only adds up once the ~2 GB the OS needs is named —
which is exactly what bash's `_pf_recheck_runtime_mem` already says. That hint
predates this PR, but this PR is what made 5-6 GB hosts reach it, so it fixes it.

    host=6 GB, budget=3      # after
      ⚠  Memory: 6 GB (Docker's current share: 3 GB) - below the 5 GB the client needs; it will OOM.
      This machine has 6 GB of RAM total - too little for tracebloc: the client needs a 5 GB Docker budget and the OS needs ~2 GB, so about 7 GB physical is the practical minimum. Use a larger machine.

Hosts that can reach the floor are untouched (8 GB still gets the resize remedy;
16 GB still gets the training recommendation), and a host below the floor outright
keeps the plain copy.

Tests: 3 new — the reserve and the practical minimum are both named, and the
arithmetic is explained for every too-small host (4-6 GB). Pester 376 passed /
0 failed / 9 skipped; PSScriptAnalyzer 0 errors; parse, check-style, check-drift
and gen-manifest --check all clean.

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

* fix(installer): the too-small-host guard also covers the training branch (Bugbot #444 r2)

`hostTooSmall` was consulted only inside the below-floor branch, so a 5-6 GB host
with Docker DOWN graded as "enough to run" and fell into the TRAINING branch,
which printed a concrete budget to write:

    host=6 GB, budget=<none>
      ⚠  Memory: 6 GB - enough to run the client, but training (~8 GB/job) may OOM; 5 GB recommended to train locally.
      For local training, give Docker up to 5 GB: WSL2 backend - [wsl2] memory=5GB ...

memory=5GB on a 6 GB machine leaves the OS 1 GB — a budget this same function
calls unachievable two branches up. The previous commit's floor change is what
lifted that number from 4 to 5 and made it reachable, so this closes the hole it
opened rather than trading one inconsistency for another.

Such a machine cannot be tuned into a training box at all, so the branch now says
that instead of printing a number:

    host=6 GB, budget=<none>      # after
      ⚠  Memory: 6 GB - enough to run the client, but too little to train locally (~8 GB/job).
      This machine has 6 GB of RAM total and the OS needs ~2 GB, so it cannot give Docker a training-sized budget. Run the client here and train on a larger machine.

Machines that CAN reach the floor keep the actionable number (16 GB host with a
6 GB budget still gets "give Docker up to 14 GB" / memory=14GB), and the healthy
paths are untouched.

Tests: 4 new, including the invariant that closes this class for good — across
every branch and every budget shape (host 1-6 GB x budget none/1-6), no branch may
emit a concrete memory= value for a host that cannot reach the floor while keeping
the OS reserve. Pester 379 passed / 0 failed / 9 skipped; PSScriptAnalyzer
0 errors; parse, check-style, check-drift, gen-manifest --check clean.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
LukasWodka and others added 3 commits July 31, 2026 16:20
… proceed (#520)

* fix(installer): Windows was the one OS that let a sub-floor Docker VM proceed

#513 decided a Docker VM below the client's memory floor must STOP the install
rather than proceed and OOM-crashloop -- "proceeding is worse than the jarring
stop the WARN path used to avoid" -- and implemented that in bash for every OS
(_pf_recheck_runtime_mem -> error -> exit 1). The Windows installer never got it.
Test-PreflightRuntimeMem just called Show-MemoryStatus, which is warn-only, so
Windows printed "it will OOM" and then carried on and OOM-crashlooped. The
platform this whole memory story (#417/#418/#428/#444/#516) is about was the one
platform still shipping the crash.

Enforcement now lives in Test-PreflightRuntimeMem, mirroring bash's split:
Show-MemoryStatus stays purely presentational (its documented job -- and the
function two PRs just contended over), the recheck grades then enforces. It runs
as New-K3dCluster's FIRST statement, so exiting leaves no half-built cluster.

The subtlety that makes this safe: Get-PfRuntimeMemGb floors to whole GB, and a
guest reports a few hundred MiB BELOW its configured size, so a VM set to exactly
the documented 5 GB floor reports ~4.8 and floors to 4. A bare `-lt 5` would have
hard-failed a correctly configured machine -- the same trap #513's reviewer caught
in bash, but worse here because flooring to whole GB discards up to a GB. So the
gate compares MiB against floor - grace:

- New Get-PfRuntimeMemMib: the same `docker info` value at MiB precision.
- New Get-PfVmMemGraceMib (512, PF_VM_MEM_GRACE_MIB) -- the same constant and the
  same comparison bash uses, so both installers put the floor in the same place.
- The recheck now reads the budget ONCE, in MiB, and derives GB from it, so the
  number printed and the number enforced on cannot disagree. Flooring (not
  rounding) is kept deliberately: Step-1 floors too, and #417 exists so the
  reported figure doesn't flip-flop between the two reads.

Remedies stay honest and achievable, matching the copy the advice path already
prints: a host that CAN reach the floor gets a resize target clamped to it
(min(warn, physical - reserve) -- bash's clamped warn target); a host that cannot
(physical - reserve < floor) gets the practical minimum and "run the client on a
larger machine", never a resize that repeats an impossible size. A
between-floor-and-warn budget still only warns -- it can run, just tightly.
TRACEBLOC_SKIP_PREFLIGHT still overrides, and Err names it.

Tests: the Describe that asserted warn-only is replaced by the new contract --
sub-floor 4 GB hard-fails; a floor-sized VM reporting 4800 MiB passes; the grace
band is bounded on both sides (4607 fails, 4608 passes); daemon-silent is a
no-op; between-floor-and-warn only warns; the rec is still capped at host RAM;
big-host vs host-too-small remedies; host RAM unreadable still fails; the skip
env overrides; and the budget is read exactly once. Plus a parity Describe that
reads BOTH sources and asserts bash still hard-fails, Windows hard-fails too and
is no longer warn-only, and both name the same grace constant -- so the next
divergence fails a test instead of shipping.

The old tests mocked Get-PfRuntimeMemGb, which this no longer calls; left as-is
they would have passed while testing nothing, so they now mock the MiB reader.

Mutation-tested rather than trusted: neutering the gate back to warn-only fails 4
enforcement tests; restoring passes all 11.

Gates: Invoke-Pester scripts/tests/ -> 401 passed / 0 failed;
Invoke-ScriptAnalyzer as CI scopes it -> 0 errors; check-style.sh clean;
check-drift.sh no drift; manifest.sha256 regenerated.

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

* fix(installer): make the grade and the gate share one boundary (Bugbot)

Bugbot caught a self-contradiction I had reasoned about and wrongly accepted: the
grade was computed from floor($mib / 1024), so a VM configured at exactly the 5 GB
floor (reporting ~4800 MiB) became budget 4, and Show-MemoryStatus printed
hard-floor "it will OOM" copy plus a resize hint -- for a machine the grace-aware
gate immediately ACCEPTED. We told a correctly configured box it would crash and
then carried on. That is precisely the "installer contradicts itself in the same
run" pattern #418/#516 existed to remove; bash classifies that band warn-only.

I had rejected rounding because Step-1 floors and #417 exists so the reported
figure doesn't flip-flop. The fix avoids that trade-off entirely: fold the SAME
grace in before flooring.

  $budget = floor(($mib + $grace) / 1024)

- 4800 + 512 -> 5: reports the CONFIGURED size (what the user set and can change),
  grades in the warn band, gate passes. Consistent.
- 4096 + 512 -> 4: still sub-floor, still "it will OOM", gate still fails.
  Consistent.

Because the grade and the gate now pivot on the same constant, their boundaries
are the same boundary -- (floor * 1024 - grace) MiB. There is no band that warns
"will OOM" yet proceeds, and none that passes while being called sub-floor. The
contradiction is impossible by construction, not merely absent at the values I
happened to test.

Tests: the floor-sized VM is asserted NOT to be told it will OOM and to report
its configured 5 GB; plus a boundary-coincidence test sweeping 4096/4607/4608/
4800/5120 that asserts at EVERY point the copy and the gate agree.

Mutation-tested: reverting the grade to floor($mib / 1024) fails both new tests;
restoring passes all 13.

Gates: Invoke-Pester scripts/tests/ -> 403 passed / 0 failed; Invoke-ScriptAnalyzer
as CI scopes it -> 0 errors; check-style.sh clean; check-drift.sh no drift;
manifest.sha256 regenerated.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ange (#522)

Three `producer | early-exiting-consumer` pipelines ran under `set -o pipefail`.
When the consumer exits on its first match/line it closes the pipe, and once the
producer has more than the ~64KB pipe buffer left to write it takes SIGPIPE and
the pipeline exits 141 — a *success* case reported as failure. Measured on
ubuntu-24.04 (bash 5.2.21 / GNU grep 3.11 / coreutils 9.4): 65,622 bytes is
already enough.

WHY this matters most in the chart guard: there the 141 lands on the `if !`
branch, so a genuine `client/templates/**` edit is reported as "guard N/A" and
the version-bump check is SKIPPED — the guard fails OPEN and waves through
exactly the dark ship it was added to stop (PR #472 / the perIngestionTables
flag block). Demonstrated end-to-end on a real repo: at 1,301 changed paths
(58,723 bytes) with a template edit and NO version bump, the old body exits 0
"guard N/A"; the new one exits 1 and blocks. The mirror direction is broken too:
a SIGPIPE on the `grep -qx client/Chart.yaml` MATCH short-circuits the `&&`, so
a PR that DID bump the version is failed with a message that says it did not.

Restructured rather than papered over with `|| true`, which would only convert a
fail-open into a different fail-open:
- chart-version-guard.yml — classify the changed-file list with a bash `read`
  loop + `case` (no pipe, no subprocess, so neither SIGPIPE nor a grep rc=2 can
  be mistaken for "no match"), and fail CLOSED with ::error:: on a missing base
  SHA or a failed `git diff`: "don't know" must never read as "nothing changed".
- _pull_failure_detail — `head -n 3 <<< "$bad"`. With errexit live the old
  pipeline aborted the function AT that line, dropping the scoped pull event
  underneath it, i.e. the one actionable reason (x509 / blocked registry / auth).
- _gpu_stack_signature — capture whole, take the first line with `%%$'\n'*`. This
  site was NOT reachable in practice (the trailing `|| true` already absorbed the
  141), but that `|| true` swallowed every real failure code alike; `|| ..._out=""`
  states the actual contract (absent tool / timeout ⇒ empty ⇒ don't cache).

Behaviour is unchanged below the buffer threshold: 669/669 bats tests pass, and
_gpu_stack_signature is byte-identical across normal / multi-line / absent /
timeout / 20k-line-chatty probe output. Verified on bash 3.2 (macOS floor) too.
scripts/manifest.sha256 regenerated via scripts/gen-manifest.sh (R8).

Co-authored-by: Claude <noreply@anthropic.com>
…fied path (not bare brew) (#521)

macOS installed the CLI tools with bare `brew install kubectl/k3d/helm`, which
floated to latest and SILENTLY ignored the K3D_VERSION/HELM_VERSION pins — so Macs
ran different, chart-untested tool versions than the pinned Linux installs (and than
the docs claim), with no checksum of our own.

Route macOS through the SAME pinned, checksum-verified direct-download path as Linux.
Both setup-*.sh are always sourced, so install_macos_cli_tools now calls the shared
install_kubectl/install_k3d/install_helm (setup-linux.sh) after setting OS_DL=darwin
and a macOS tools target (/usr/local/bin, on the default PATH on Intel + Apple
Silicon). The fetchers are made OS-aware via ${OS_DL:-linux} — Linux (and every bats
fetch test that leaves OS_DL unset) stays byte-identical.

- common.sh: portable _verify_sha256 — GNU sha256sum on Linux, shasum -a 256 on
  macOS (which ships a BSD /sbin/sha256sum that lacks GNU --check). A `type -t`
  guard honors the bats mocks' sha256sum shell-function so the Linux fetch tests
  keep passing on macOS dev boxes.
- Execute-gate (#411) is preserved: each shared installer ends in assert_tool_runs,
  so a broken/wrong-arch binary fails the "System tools" step loudly. Updated the
  drift-check contract to accept macOS delegating to the gated install_<tool>.
- brew still delivers Docker Desktop / colima (install_docker_desktop) unchanged.

Tests: new scripts/tests/setup-macos.bats (OS_DL=darwin fetch/verify for all three
tools + _verify_sha256 portability + install_macos_cli_tools delegation, no bare
brew); +2 check-drift self-tests for the delegation contract. shellcheck/style/drift
clean; manifest regenerated.

Closes #429

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 cf9e727. Configure here.

…h VZ/Rosetta (#524)

* fix(#433): verify amd64 emulation on Apple Silicon + start colima with VZ/Rosetta

The client images are amd64-only. On Apple Silicon the installer merely printed a
note and proceeded, ASSUMING Docker Desktop's emulation works — so if "Use Rosetta
for x86_64/amd64 emulation" is off (or colima lacks it), the images crash-loop with
an exec-format error minutes later, with no preflight/setup catch. The headless
colima path was worse: `colima start` passed no arch/Rosetta flags, so an Apple
Silicon Mac got an arm64 VM running amd64 images under slow QEMU or not at all.

- assert_amd64_emulation (setup-macos.sh): post-Docker smoke — force-run a tiny amd64
  binary (`docker run --rm --platform linux/amd64 busybox:1.36 true`) once Docker is
  up, and HARD-FAIL naming the exact Docker Desktop setting + the colima remedy, so
  the problem is caught at setup, never as a crash-looping pod. Wired into install_macos
  right after Docker is confirmed ready. Intel Macs skip it (native amd64);
  TRACEBLOC_ALLOW_ARM64 is the escape hatch; image overridable via TB_AMD64_SMOKE_IMAGE.
- _install_docker_colima: on Apple Silicon + macOS 13+ (VZ), start colima with
  `--vm-type vz --vz-rosetta` for Rosetta-accelerated amd64 (matches Docker Desktop's
  Rosetta setting); older macOS keeps the QEMU default. bash-3.2-safe: the arg vector
  is never empty. New _macos_supports_vz helper (TB_MACOS_VER-overridable).
- _pf_arch (preflight.sh): the macOS note now NAMES the Rosetta setting and says the
  real check runs once Docker is up — instead of "assume it works".

Tests: new scripts/tests/setup-macos-arch.bats (VZ detection, colima flag matrix,
smoke pass/fail/skip/override) + a preflight.bats assertion on the named setting.
Separate test file from setup-macos.bats to avoid a file-add clash with #429/#521.
shellcheck/style/drift clean; manifest regenerated; preflight.bats (82) green.

Closes #433

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

* fix(#433): guard colima VZ flags on existing VMs + time-bound the amd64 smoke (Bugbot)

Two Bugbot findings on the Apple Silicon work:

1. (High) colima refuses to change vmType on an EXISTING instance, so unconditionally
   appending --vm-type vz --vz-rosetta aborted `colima start` on a prior QEMU VM (from
   an earlier install or reboot) with a generic failure. Only request VZ+Rosetta on a
   FRESH start now (new _colima_instance_exists via `colima list --json`); a pre-existing
   VM starts as-is, and if its amd64 emulation is broken the post-Docker smoke already
   names the `colima delete && colima start --vm-type vz --vz-rosetta` recreate remedy.

2. (Medium) assert_amd64_emulation ran `docker run` via unbounded spin_cmd — a wedged
   daemon or stuck pull could hang a headless install forever. Switched to
   spin_cmd_bounded (TB_AMD64_SMOKE_TIMEOUT, default 120s); a 124 timeout falls through
   to the same remediation, per the installer's every-docker-call-is-bounded rule.

Tests: +existing-VM colima test (no VZ flags), + bounded-smoke assertion; 11/11 in
setup-macos-arch.bats. shellcheck/style/drift clean; manifest regenerated.

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

---------

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.

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 f772e62. Configure here.

Comment thread .github/workflows/chart-version-guard.yml
@LukasWodka

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed at source: #530 (against develop).

The premise holds — release-helm-chart.yaml packages both ./client (line 131) and ./ingestor (line 132) into one shared index.yaml (line 193), while the guard classified only client/templates/*|client/values.yaml*. Measuring the consequence made it worse than "unguarded":

  • 6 of 9 commits touching ingestor/templates or ingestor/values.yaml never bumped ingestor/Chart.yaml.
  • ingestor/Chart.yaml version: is 0.2.0 on develop, staging and main — unbumped since 2026-05-20.
  • Because helm package ./ingestor passes no --version override, ingestor's published version is that file. So those edits did not go nowhere: ingestor-0.2.0.tgz was overwritten on gh-pages 5 times (07-14, 07-16, 07-27, 07-29 ×2), with helm repo index --merge refreshing the digest in place each time (index digest 81162cd5… matches the live tarball, created: 2026-07-29).

0.2.0 is therefore a mutable, drifting version: two installs of it months apart are not the same chart, and Helm caches by version so an existing client may never pick the change up. Four content changes since the last bump are affected, including "move the prod ingestor pin into chart defaults" (2026-07-27).

client/values.schema.json was the second half of the finding and is also now covered — it is packaged, and Helm validates user values against the packaged schema at install time.

#530 derives the guarded chart list from the release workflow's helm package lines instead of hardcoding it (hardcoding one chart is precisely how this gap happened), requires each chart to bump its own Chart.yaml, reports every unbumped chart in one run, and fails closed when the chart list can't be read. It also moves the logic to scripts/chart-version-guard.sh so it is covered by the required Unit tests check — 23 cases against a real throwaway git repo, 13 of which fail against the previous inline logic.

Resolving here: the fix is on develop and reaches staging through the normal hop rather than by pushing onto a settling promotion PR.

@tracebloc-release-train tracebloc-release-train Bot added the gate-nudge Toggled by the release train to (re-)fire the fr-gate label Jul 31, 2026
LukasWodka added a commit that referenced this pull request Jul 31, 2026
The guard classified only `client/templates/**` and `client/values.yaml`, but
release-helm-chart.yaml packages BOTH `./client` and `./ingestor` (lines
131-132) into one shared index.yaml (line 193). So an unbumped `ingestor/**`
edit merged green — the exact dark ship the guard exists to stop (PR #472).
Raised by Bugbot on the develop->staging promotion PR #519.

Measured on develop: 6 of 9 commits touching ingestor chart content never
bumped ingestor/Chart.yaml, whose version has been 0.2.0 since 2026-05-20.
Because `helm package ./ingestor` passes no --version override, ingestor's
published version IS that file, so those edits did not go nowhere — they
overwrote an already-published version. ingestor-0.2.0.tgz was replaced 5x
between 2026-05-20 and 2026-07-29 and `helm repo index --merge` refreshed the
digest in place, so two installs of "0.2.0" months apart are not the same
chart. Helm also caches by version, so an existing client may never pick the
change up at all.

- derive the guarded chart list from the release workflow's `helm package`
  lines instead of hardcoding it, so a third chart is guarded on day one
- require a bump of the chart's OWN Chart.yaml (bumping client no longer
  satisfies an ingestor change)
- add client/values.schema.json (packaged, and Helm validates user values
  against it at install time), plus charts/** and crds/** pre-emptively
- report every unbumped chart in one run rather than one per push
- fail closed when the chart list cannot be read, or when a packaged chart
  has no Chart.yaml

Move the logic into scripts/chart-version-guard.sh so it can be tested: the
new scripts/tests/chart-version-guard.bats (23 cases, real throwaway git repo,
no stubbed git) runs under the required `Unit tests` check, which this
workflow is not. 13 of the 23 fail against the previous inline logic; the 10
that pass on both are the client-side semantics, deliberately unchanged.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@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 Jul 31, 2026
@tracebloc-release-train
tracebloc-release-train Bot merged commit 7cb9b3e into staging Jul 31, 2026
48 checks passed
@LukasWodka
LukasWodka deleted the release-train/to-staging branch August 1, 2026 21:44
Sign up for free to 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.

2 participants