diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 121673db..f1ce8977 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -112,6 +112,11 @@ for *what the operator sees and can act on*, not code elegance. identifier support needs, not a secret (`scripts/lib/diagnose.sh:13`). - Existing `# shellcheck disable=…` lines each carry a stated reason — don't re-litigate them. - `docs/`, chart lockfiles, and generated sections of `client/values.schema.json`. +- A `code-quality-caller.yml` that passes **no `secrets:` line** is correct, not an omission. + The shared `code-quality.yml` reusable references no secrets by contract + (RFC-BACKEND-1405 Q5, backend#1526): secretless callees get no secrets line, and if the + reusable ever gains one, callers switch to explicit per-secret passing — never + `secrets: inherit`. Flag the *addition* of `secrets: inherit` on this caller instead. ## Tone diff --git a/.github/workflows/add-to-kanban.yml b/.github/workflows/add-to-kanban.yml index 45aa70ac..3b6d6935 100644 --- a/.github/workflows/add-to-kanban.yml +++ b/.github/workflows/add-to-kanban.yml @@ -10,7 +10,7 @@ jobs: add-to-project: runs-on: ubuntu-latest steps: - - uses: actions/add-to-project@v1.0.2 + - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2 with: project-url: https://github.com/orgs/tracebloc/projects/2 github-token: ${{ secrets.PROJECTS_KANBAN_TOKEN }} diff --git a/.github/workflows/chart-version-guard.yml b/.github/workflows/chart-version-guard.yml index 2263d474..2964af40 100644 --- a/.github/workflows/chart-version-guard.yml +++ b/.github/workflows/chart-version-guard.yml @@ -23,7 +23,7 @@ jobs: name: chart content ⇒ Chart.yaml version bump runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Require a Chart.yaml version bump when chart content changes diff --git a/.github/workflows/drift-checks.yaml b/.github/workflows/drift-checks.yaml index 71e69874..109346b9 100644 --- a/.github/workflows/drift-checks.yaml +++ b/.github/workflows/drift-checks.yaml @@ -28,9 +28,9 @@ jobs: name: Source-of-truth drift runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: version: v3.15.4 - name: check-drift diff --git a/.github/workflows/helm-ci.yaml b/.github/workflows/helm-ci.yaml index f1fdf2f8..a318ecea 100644 --- a/.github/workflows/helm-ci.yaml +++ b/.github/workflows/helm-ci.yaml @@ -8,6 +8,8 @@ on: - 'ingestor/**' - 'scripts/tests/e2e-auto-upgrade.sh' - 'scripts/tests/e2e-seal-check.sh' + - 'scripts/tests/e2e-full-seal.sh' + - 'scripts/tests/lib/e2e-common.sh' - 'scripts/lib/**' - '.github/workflows/helm-ci.yaml' pull_request: @@ -17,8 +19,13 @@ on: - 'ingestor/**' - 'scripts/tests/e2e-auto-upgrade.sh' - 'scripts/tests/e2e-seal-check.sh' + - 'scripts/tests/e2e-full-seal.sh' + - 'scripts/tests/lib/e2e-common.sh' - 'scripts/lib/**' - '.github/workflows/helm-ci.yaml' + # Manual runs — mainly to fire full-seal-e2e on demand (e.g. right after the + # e2e-test-agent secrets are provisioned, or to re-record a seal run). + workflow_dispatch: concurrency: # Cancel superseded runs on PRs — this is the repo's heaviest workflow @@ -33,10 +40,10 @@ jobs: name: Helm lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: version: v3.15.4 @@ -72,10 +79,10 @@ jobs: KUBECONFORM_VERSION: "0.8.0" KUBECONFORM_SHA256: "9bc2bffbf71f261128533edaf912153948b7ff238f9a531ae6d34466ec287883" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: version: v3.15.4 @@ -109,8 +116,10 @@ jobs: TARBALL="kubeconform-linux-amd64.tar.gz" URL="https://github.com/yannh/kubeconform/releases/download/v${KUBECONFORM_VERSION}/${TARBALL}" # -f so an HTTP error is a curl failure rather than an error page - # written to disk and handed to sha256sum. - curl -fsSL --retry 3 --retry-delay 2 -o "$RUNNER_TEMP/$TARBALL" "$URL" + # written to disk and handed to sha256sum. --connect-timeout/--max-time + # bound the download so a stalled endpoint fails the step fast instead of + # hanging the template matrix to the GitHub Actions cap (backend#1497). + curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 --max-time 120 -o "$RUNNER_TEMP/$TARBALL" "$URL" # Any mismatch exits non-zero here, and `set -e` fails the step before # the binary is extracted or placed on PATH. echo "${KUBECONFORM_SHA256} $RUNNER_TEMP/$TARBALL" | sha256sum -c - @@ -131,10 +140,10 @@ jobs: name: Helm unit tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: version: v3.15.4 @@ -160,7 +169,7 @@ jobs: name: Spawned ingestor image is multi-arch runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Assert the ingestor tag and pinned digests are multi-arch run: | repo=$(yq '.images.ingestor.repository' client/values.yaml) @@ -233,7 +242,7 @@ jobs: name: Fleet auto-upgrade E2E (k3d) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Upgrade from last published release through both flag paths run: bash scripts/tests/e2e-auto-upgrade.sh @@ -252,10 +261,46 @@ jobs: name: Seal-check egress-enforcement (k3d) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Run the live egress-enforcement seal-check run: bash scripts/tests/e2e-seal-check.sh + full-seal-e2e: + # backend#1184 deferred fast-follow: the FULL seal suite — egress- + # enforcement + backend-reachability + bound-PVC storage-assertions — on a + # real k3d cluster installed against the DEV backend as the dedicated + # e2e-test-agent client. Real credentials, so it runs on push/dispatch + # only, never on PRs: fork PRs can't read the secrets anyway, and a real + # dev-backend login per PR push would be platform churn + runner cost for + # no extra signal (the secret-free enforcement probe above covers PRs). + # Until the secrets are provisioned the job SKIPS green with a notice — + # see docs/SEAL-CHECK.md § Full-suite dev harness. + if: github.event_name != 'pull_request' + # 45m, deliberately above the sibling's 30m: this script stacks a 300s PVC + # wait, two 300s rollouts and a 600s unfiltered helm test on top of + # create_cluster's 15m bound — a slow-but-healthy run must not be killed + # by GHA while inside every one of its own timeouts (Bugbot). + timeout-minutes: 45 + # One dev-agent session at a time: two clusters authenticating as the same + # client id would read as a duplicate/capacity anomaly on the platform. + concurrency: + group: full-seal-e2e-dev-agent + cancel-in-progress: false + name: Full seal suite vs dev (e2e-test-agent) + runs-on: ubuntu-latest + env: + TB_E2E_CLIENT_ID: ${{ secrets.TB_E2E_CLIENT_ID }} + TB_E2E_CLIENT_PASSWORD: ${{ secrets.TB_E2E_CLIENT_PASSWORD }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Run the full seal suite (skips until the e2e-test-agent is provisioned) + run: | + if [ -z "$TB_E2E_CLIENT_ID" ] || [ -z "$TB_E2E_CLIENT_PASSWORD" ]; then + echo "::notice::TB_E2E_CLIENT_ID / TB_E2E_CLIENT_PASSWORD are not set — skipping the full seal suite. Provision the dedicated dev e2e-test-agent client and add both repo Actions secrets to activate this job (backend#1184 residual; docs/SEAL-CHECK.md)." + exit 0 + fi + bash scripts/tests/e2e-full-seal.sh + # Installer script tests (bats + Pester) + the cross-distro prerequisite matrix # live in their own workflow: .github/workflows/installer-tests.yaml # (triggered on scripts/** changes). diff --git a/.github/workflows/installer-tests.yaml b/.github/workflows/installer-tests.yaml index 3fe4f3b7..828b50a3 100644 --- a/.github/workflows/installer-tests.yaml +++ b/.github/workflows/installer-tests.yaml @@ -45,7 +45,7 @@ jobs: name: Static analysis runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: bash -n (syntax) on every shell script run: | @@ -67,11 +67,11 @@ jobs: # below for visibility but don't fail the gate. shellcheck --severity=error --shell=bash \ scripts/install.sh scripts/install-k8s.sh scripts/gen-manifest.sh scripts/check-facts.sh scripts/check-style.sh scripts/resolve-ingestor-digest.sh scripts/lib/*.sh \ - scripts/tests/check-drift.sh scripts/tests/distro-prereqs.sh scripts/tests/e2e-auto-upgrade.sh scripts/tests/e2e-seal-check.sh scripts/tests/e2e-cluster.sh scripts/tests/e2e-journey.sh scripts/tests/e2e-proxy.sh scripts/tests/lib/e2e-common.sh scripts/tests/path-persist.sh + scripts/tests/check-drift.sh scripts/tests/distro-prereqs.sh scripts/tests/e2e-auto-upgrade.sh scripts/tests/e2e-seal-check.sh scripts/tests/e2e-full-seal.sh scripts/tests/e2e-cluster.sh scripts/tests/e2e-journey.sh scripts/tests/e2e-proxy.sh scripts/tests/lib/e2e-common.sh scripts/tests/path-persist.sh echo "── shellcheck warnings (advisory, non-blocking) ──" shellcheck --severity=warning --shell=bash \ scripts/install.sh scripts/install-k8s.sh scripts/gen-manifest.sh scripts/check-facts.sh scripts/check-style.sh scripts/resolve-ingestor-digest.sh scripts/lib/*.sh \ - scripts/tests/check-drift.sh scripts/tests/distro-prereqs.sh scripts/tests/e2e-auto-upgrade.sh scripts/tests/e2e-seal-check.sh scripts/tests/e2e-cluster.sh scripts/tests/e2e-journey.sh scripts/tests/e2e-proxy.sh scripts/tests/lib/e2e-common.sh scripts/tests/path-persist.sh || true + scripts/tests/check-drift.sh scripts/tests/distro-prereqs.sh scripts/tests/e2e-auto-upgrade.sh scripts/tests/e2e-seal-check.sh scripts/tests/e2e-full-seal.sh scripts/tests/e2e-cluster.sh scripts/tests/e2e-journey.sh scripts/tests/e2e-proxy.sh scripts/tests/lib/e2e-common.sh scripts/tests/path-persist.sh || true - name: Installer manifest is current (supply-chain, R8) # The bootstrap verifies each sub-script against scripts/manifest.sha256 @@ -122,7 +122,7 @@ jobs: name: bats (bash unit, mocked) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install bats run: sudo apt-get update -qq && sudo apt-get install -y -qq bats - name: Run bats @@ -139,7 +139,7 @@ jobs: os: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Run Pester shell: pwsh env: @@ -183,7 +183,26 @@ jobs: - 'fedora:latest' # dnf, falls through to get.docker.com - 'opensuse/leap:15.6' # zypper steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + # Pull the distro image FIRST, bounded and retried. `docker run` pulls + # implicitly with no timeout, so Docker Hub connectivity trouble either + # fails the job in seconds (registry-1.docker.io timeout, exit 125) or + # stalls it until timeout-minutes kills it at 20 -- both hit three times + # on 2026-08-04 (#525/#592), always infra, never the diff. Three bounded + # attempts turn that into a cheap retry with an honest error. Bounds are + # sized so the WORST case (~5.5 min) still leaves the 20-minute job most + # of its budget for the real work (Bugbot): a healthy pull takes seconds. + - name: Pull ${{ matrix.distro }} (bounded, retried) + env: + DISTRO: ${{ matrix.distro }} + run: | + for i in 1 2 3; do + timeout 90 docker pull -q "$DISTRO" && exit 0 + echo "::warning::pull of $DISTRO stalled or failed (attempt $i/3)" + sleep $((i*10)) + done + echo "::error::could not pull $DISTRO from Docker Hub in 3 bounded attempts - runner-to-registry connectivity, not this PR. Re-run this job." + exit 1 - name: Install prerequisites in ${{ matrix.distro }} env: DISTRO: ${{ matrix.distro }} @@ -206,7 +225,7 @@ jobs: os: [ubuntu-22.04, ubuntu-24.04, ubuntu-24.04-arm] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Bring up a real k3d cluster + run a workload run: bash scripts/tests/e2e-cluster.sh @@ -221,7 +240,7 @@ jobs: name: E2E auth-proxy (squid) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Cluster up through an authenticated proxy run: bash scripts/tests/e2e-proxy.sh @@ -253,7 +272,26 @@ jobs: - 'opensuse/leap:15.6' # zypper - 'alpine:3' # busybox sh + apk (optional, minimal) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + # Pull the distro image FIRST, bounded and retried. `docker run` pulls + # implicitly with no timeout, so Docker Hub connectivity trouble either + # fails the job in seconds (registry-1.docker.io timeout, exit 125) or + # stalls it until timeout-minutes kills it at 20 -- both hit three times + # on 2026-08-04 (#525/#592), always infra, never the diff. Three bounded + # attempts turn that into a cheap retry with an honest error. Bounds are + # sized so the WORST case (~5.5 min) still leaves the 20-minute job most + # of its budget for the real work (Bugbot): a healthy pull takes seconds. + - name: Pull ${{ matrix.distro }} (bounded, retried) + env: + DISTRO: ${{ matrix.distro }} + run: | + for i in 1 2 3; do + timeout 90 docker pull -q "$DISTRO" && exit 0 + echo "::warning::pull of $DISTRO stalled or failed (attempt $i/3)" + sleep $((i*10)) + done + echo "::error::could not pull $DISTRO from Docker Hub in 3 bounded attempts - runner-to-registry connectivity, not this PR. Re-run this job." + exit 1 - name: Fresh-shell PATH check in ${{ matrix.distro }} env: DISTRO: ${{ matrix.distro }} @@ -290,7 +328,7 @@ jobs: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'e2e') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install → CLI → cluster info (fresh shell) → dataset push --dry-run env: TRACEBLOC_CLI_REF: ${{ vars.TRACEBLOC_CLI_REF }} diff --git a/.github/workflows/release-helm-chart.yaml b/.github/workflows/release-helm-chart.yaml index b4f8c7ce..89d26535 100644 --- a/.github/workflows/release-helm-chart.yaml +++ b/.github/workflows/release-helm-chart.yaml @@ -92,7 +92,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@v4 + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 with: version: v3.15.4 @@ -134,7 +134,7 @@ jobs: ls -la *.tgz - name: Upload chart artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: helm-charts # Glob picks up both client-*.tgz and ingestor-*.tgz. @@ -170,7 +170,7 @@ jobs: fi - name: Download chart artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: helm-charts @@ -267,9 +267,17 @@ jobs: COSIGN_YES: 'true' run: | cd scripts + # Emit an offline Sigstore BUNDLE (#584) alongside the .sig/.cert. The + # bundle carries the Rekor inclusion proof (SET), so the installer can + # `verify-blob --bundle --offline` with NO live Rekor call — the fix for + # sigstore-blocked / TLS-inspecting networks, where the short-lived keyless + # cert is expired by install time and only the bundle's embedded timestamp + # proves it was valid at signing. The .sig/.cert stay for older installers' + # online path (backward compatible). cosign sign-blob \ --output-certificate manifest.sha256.cert \ --output-signature manifest.sha256.sig \ + --bundle manifest.sha256.bundle \ manifest.sha256 echo "Signed manifest.sha256" ls -l manifest.sha256* @@ -436,6 +444,7 @@ jobs: scripts/manifest.sha256 scripts/manifest.sha256.sig scripts/manifest.sha256.cert + scripts/manifest.sha256.bundle env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Post-publish invariant check: turns the 2026-07-29 manual leak catch into diff --git a/.github/workflows/stale-backlog.yml b/.github/workflows/stale-backlog.yml index 318e3729..14d689ea 100644 --- a/.github/workflows/stale-backlog.yml +++ b/.github/workflows/stale-backlog.yml @@ -13,7 +13,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9 + - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 with: days-before-issue-stale: 42 # 6 weeks of no activity → warning days-before-issue-close: 14 # +2 weeks of silence → close diff --git a/.github/workflows/standard-checks.yml b/.github/workflows/standard-checks.yml index 8d6660f5..20934463 100644 --- a/.github/workflows/standard-checks.yml +++ b/.github/workflows/standard-checks.yml @@ -30,7 +30,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: bash -n (syntax) on every shell script run: | @@ -50,7 +50,7 @@ jobs: name: Unit tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Install bats run: sudo apt-get update -qq && sudo apt-get install -y -qq bats diff --git a/.github/workflows/windows-e2e.yaml b/.github/workflows/windows-e2e.yaml index 59ddaa37..6106cc89 100644 --- a/.github/workflows/windows-e2e.yaml +++ b/.github/workflows/windows-e2e.yaml @@ -42,7 +42,7 @@ jobs: # else — and $USERPROFILE isn't available in the ${{ env }} context, only at runtime). CLUSTER_NAME: tbe2ewin steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Resolve isolated data dir + tool PATH shell: pwsh @@ -65,7 +65,7 @@ jobs: # that's exactly when the log matters most. !success() covers failed + cancelled/timed # out; this runs before the teardown below so the log survives the data-dir cleanup. if: ${{ !success() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: windows-e2e-install-log path: ${{ env.HOST_DATA_DIR }}\install-*.log diff --git a/client/Chart.yaml b/client/Chart.yaml index 16ad2299..672adbd6 100644 --- a/client/Chart.yaml +++ b/client/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: client description: A unified Helm chart for tracebloc on AKS, EKS, bare-metal, and OpenShift type: application -version: 1.9.12 -appVersion: "1.9.12" +version: 1.9.15 +appVersion: "1.9.15" keywords: - tracebloc - kubernetes diff --git a/client/templates/_helpers.tpl b/client/templates/_helpers.tpl index d2020364..165dc9c6 100644 --- a/client/templates/_helpers.tpl +++ b/client/templates/_helpers.tpl @@ -247,6 +247,19 @@ Usage: {{ include "tracebloc.image" (dict "repository" "tracebloc/jobs-manager" {{- end -}} {{- end }} +{{/* +tracebloc.mirrorPrefix — registry prefix for images whose repository is a +registry-less path (docker.io implicit), e.g. the alpine/* utility-pod images. +When a private mirror is set via global.imageRegistry (#585), returns +"/" so an air-gapped install re-homes those images onto the mirror; +returns "" when no mirror is set, so default installs render byte-identically. +Nil-guarded for --reset-then-reuse-values upgrades that predate global. Call +with the ROOT context (e.g. `include "tracebloc.mirrorPrefix" $`). +*/}} +{{- define "tracebloc.mirrorPrefix" -}} +{{- with (dig "imageRegistry" "" (.Values.global | default dict)) }}{{ . }}/{{ end -}} +{{- end -}} + {{/* tracebloc.ingestorDigest — the ONE effective digest for the spawned ingestor image. Renders the digest to pin to, or nothing at all to float on diff --git a/client/templates/auto-upgrade-cronjob.yaml b/client/templates/auto-upgrade-cronjob.yaml index 5cf652f0..ce78fdc8 100644 --- a/client/templates/auto-upgrade-cronjob.yaml +++ b/client/templates/auto-upgrade-cronjob.yaml @@ -128,7 +128,7 @@ spec: type: RuntimeDefault containers: - name: helm - image: "{{ .Values.autoUpgrade.image.repository }}:{{ .Values.autoUpgrade.image.tag }}" + image: "{{ include "tracebloc.mirrorPrefix" $ }}{{ .Values.autoUpgrade.image.repository }}:{{ .Values.autoUpgrade.image.tag }}" imagePullPolicy: {{ .Values.autoUpgrade.image.pullPolicy }} # `helm` is the entrypoint on alpine/helm; override so we run # our script instead. diff --git a/client/templates/egress-enforcement-check.yaml b/client/templates/egress-enforcement-check.yaml index 222ee8b7..557dbeab 100644 --- a/client/templates/egress-enforcement-check.yaml +++ b/client/templates/egress-enforcement-check.yaml @@ -47,7 +47,7 @@ spec: type: RuntimeDefault containers: - name: probe - image: {{ include "tracebloc.image" (dict "repository" "curlimages/curl" "tag" "8.20.0" "digest" "sha256:b3f1fb2a51d923260350d21b8654bbc607164a987e2f7c84a0ac199a67df812a" "registry" "docker.io") | quote }} + image: {{ include "tracebloc.image" (dict "repository" "curlimages/curl" "tag" "8.20.0" "digest" "sha256:b3f1fb2a51d923260350d21b8654bbc607164a987e2f7c84a0ac199a67df812a" "registry" (dig "imageRegistry" "docker.io" (.Values.global | default dict))) | quote }} imagePullPolicy: IfNotPresent securityContext: allowPrivilegeEscalation: false diff --git a/client/templates/egress-proxy-deployment.yaml b/client/templates/egress-proxy-deployment.yaml index b811bf15..d80469de 100644 --- a/client/templates/egress-proxy-deployment.yaml +++ b/client/templates/egress-proxy-deployment.yaml @@ -43,7 +43,14 @@ spec: type: RuntimeDefault containers: - name: squid - image: {{ include "tracebloc.image" (dict "repository" ($img.repository | default "ubuntu/squid") "tag" ($img.tag | default "6.6-24.04_beta") "digest" ($img.digest | default "") "registry" ($img.registry | default "docker.io")) | quote }} + # Registry precedence (#585): the global mirror wins when set; else the + # per-image egressProxy.image.registry; else docker.io. The outer + # `| default` is required because the chart-default global.imageRegistry + # is "" and `dig` treats a present-but-empty value as found — without it, + # an empty global would silently drop an explicit per-image registry + # (Bugbot). dig's own default is "" so the fall-through applies to both an + # empty and an absent global. + image: {{ include "tracebloc.image" (dict "repository" ($img.repository | default "ubuntu/squid") "tag" ($img.tag | default "6.6-24.04_beta") "digest" ($img.digest | default "") "registry" ((dig "imageRegistry" "" (.Values.global | default dict)) | default ($img.registry | default "docker.io"))) | quote }} imagePullPolicy: IfNotPresent command: ["squid"] # -N: no daemon (run in foreground). Logs go to the std streams via diff --git a/client/templates/egress-reachability-check.yaml b/client/templates/egress-reachability-check.yaml index 065ab417..065f020c 100644 --- a/client/templates/egress-reachability-check.yaml +++ b/client/templates/egress-reachability-check.yaml @@ -55,7 +55,7 @@ spec: type: RuntimeDefault containers: - name: probe - image: {{ include "tracebloc.image" (dict "repository" "curlimages/curl" "tag" "8.20.0" "digest" "sha256:b3f1fb2a51d923260350d21b8654bbc607164a987e2f7c84a0ac199a67df812a" "registry" "docker.io") | quote }} + image: {{ include "tracebloc.image" (dict "repository" "curlimages/curl" "tag" "8.20.0" "digest" "sha256:b3f1fb2a51d923260350d21b8654bbc607164a987e2f7c84a0ac199a67df812a" "registry" (dig "imageRegistry" "docker.io" (.Values.global | default dict))) | quote }} imagePullPolicy: IfNotPresent securityContext: allowPrivilegeEscalation: false diff --git a/client/templates/image-refresh-cronjob.yaml b/client/templates/image-refresh-cronjob.yaml index e2959933..b71dbbc0 100644 --- a/client/templates/image-refresh-cronjob.yaml +++ b/client/templates/image-refresh-cronjob.yaml @@ -266,7 +266,7 @@ spec: type: RuntimeDefault containers: - name: refresh - image: "{{ .Values.imageRefresh.image.repository }}:{{ .Values.imageRefresh.image.tag }}" + image: "{{ include "tracebloc.mirrorPrefix" $ }}{{ .Values.imageRefresh.image.repository }}:{{ .Values.imageRefresh.image.tag }}" imagePullPolicy: {{ .Values.imageRefresh.image.pullPolicy }} # alpine/k8s entrypoint is kubectl; override to run our script. command: ["/bin/sh", "/scripts/image-refresh.sh"] diff --git a/client/templates/jobs-manager-deployment.yaml b/client/templates/jobs-manager-deployment.yaml index 4f419386..71ca1db3 100644 --- a/client/templates/jobs-manager-deployment.yaml +++ b/client/templates/jobs-manager-deployment.yaml @@ -39,7 +39,7 @@ spec: type: RuntimeDefault containers: - name: api - image: {{ include "tracebloc.image" (dict "repository" "tracebloc/jobs-manager" "tag" .Values.env.CLIENT_ENV "digest" .Values.images.jobsManager.digest "registry" "docker.io") | quote }} + image: {{ include "tracebloc.image" (dict "repository" "tracebloc/jobs-manager" "tag" .Values.env.CLIENT_ENV "digest" .Values.images.jobsManager.digest "registry" (dig "imageRegistry" "docker.io" (.Values.global | default dict))) | quote }} imagePullPolicy: {{ if .Values.images.jobsManager.digest }}IfNotPresent{{ else }}Always{{ end }} securityContext: allowPrivilegeEscalation: false @@ -113,6 +113,17 @@ spec: - name: PER_INGESTION_TABLES value: "1" {{- end }} + {{- if .Values.perDatasetPvcs }} + # RFC-0003 D9 Track B (client-runtime#203 / #261): per-dataset + # hostPath PV/PVCs. When on, jobs-manager mounts each training pod's + # dataset(s) as dedicated read-only dataset- PVCs instead of + # a subPath view of the shared data PVC (single-node/hostPath + # clusters only — elastic clusters log a warning and fall back to + # subPath). Needs the flag-gated PV/PVC grants in rbac.yaml. Rendered + # only when enabled so default installs stay byte-identical. + - name: PER_DATASET_PVCS + value: "1" + {{- end }} {{- if .Values.perExperimentDbCreds }} # RFC-0003 D10 (backend#1181): per-experiment MySQL credentials. When # on, jobs-manager mints a short-lived MySQL user per experiment scoped @@ -156,7 +167,17 @@ spec: # nil-guarded so `--reuse-values` from a release that predates the # `images.ingestor` key still renders. - name: INGESTOR_IMAGE_REPOSITORY - value: {{ (default dict .Values.images.ingestor).repository | default "ghcr.io/tracebloc/ingestor" | quote }} + # Precedence (#585): an explicit images.ingestor.repository always wins + # (someone who names a full repo means it). Otherwise, when a private + # mirror is set via global.imageRegistry, the still-default repository is + # re-homed onto that mirror (/tracebloc/ingestor) so a single + # knob points the ingestor at the same registry as the chart's own + # images. With neither, it falls back to the ghcr.io default. Nil-guarded + # for --reset-then-reuse-values upgrades that predate images.ingestor. + {{- $ingRepo := (default dict .Values.images.ingestor).repository | default "ghcr.io/tracebloc/ingestor" }} + {{- $ingMirror := dig "imageRegistry" "" (.Values.global | default dict) }} + {{- if and $ingMirror (eq $ingRepo "ghcr.io/tracebloc/ingestor") }}{{ $ingRepo = printf "%s/tracebloc/ingestor" $ingMirror }}{{ end }} + value: {{ $ingRepo | quote }} - name: INGESTOR_IMAGE_TAG value: {{ include "tracebloc.ingestorTag" . | quote }} - name: INGESTOR_IMAGE_DIGEST @@ -170,8 +191,14 @@ spec: - name: EGRESS_PROXY_URL value: "http://egress-proxy-service:{{ (default dict .Values.egressProxy).port | default 3128 }}" {{- end }} + # JOB_IMAGE_HOST is the registry prefix jobs-manager stamps onto every + # training-job image it spawns. Honour a private mirror set via + # global.imageRegistry (#585) so an air-gapped/mirrored install pulls job + # images from the same registry as the chart's own images; defaults to + # docker.io/ when no mirror is configured. Nil-guarded for pre-this-key + # --reset-then-reuse-values upgrades. - name: JOB_IMAGE_HOST - value: "docker.io/" + value: {{ printf "%s/" (dig "imageRegistry" "docker.io" (.Values.global | default dict) | default "docker.io") | quote }} - name: CLIENT_ENV value: {{ .Values.env.CLIENT_ENV | default "prod" | quote }} - name: RESOURCE_REQUESTS @@ -203,7 +230,7 @@ spec: {{- end }} {{- end }} - name: pods-monitor-container - image: {{ include "tracebloc.image" (dict "repository" "tracebloc/pods-monitor" "tag" .Values.env.CLIENT_ENV "digest" .Values.images.podsMonitor.digest "registry" "docker.io") | quote }} + image: {{ include "tracebloc.image" (dict "repository" "tracebloc/pods-monitor" "tag" .Values.env.CLIENT_ENV "digest" .Values.images.podsMonitor.digest "registry" (dig "imageRegistry" "docker.io" (.Values.global | default dict))) | quote }} imagePullPolicy: {{ if .Values.images.podsMonitor.digest }}IfNotPresent{{ else }}Always{{ end }} securityContext: allowPrivilegeEscalation: false @@ -233,8 +260,10 @@ spec: key: CLIENT_PASSWORD - name: CLIENT_LOGS_PVC value: {{ include "tracebloc.clientLogsPvc" . | quote }} + # See the api container's JOB_IMAGE_HOST note (#585): honour a private + # mirror set via global.imageRegistry, defaulting to docker.io/. - name: JOB_IMAGE_HOST - value: "docker.io/" + value: {{ printf "%s/" (dig "imageRegistry" "docker.io" (.Values.global | default dict) | default "docker.io") | quote }} - name: CLIENT_ENV value: {{ .Values.env.CLIENT_ENV | default "prod" | quote }} - name: RESOURCE_REQUESTS diff --git a/client/templates/mysql-deployment.yaml b/client/templates/mysql-deployment.yaml index f0ec3a27..378cafa1 100644 --- a/client/templates/mysql-deployment.yaml +++ b/client/templates/mysql-deployment.yaml @@ -39,7 +39,7 @@ spec: # deployments (EKS/AKS/OC) skip this and rely on fsGroup. initContainers: - name: init-mysql-data - image: {{ include "tracebloc.image" (dict "repository" "library/busybox" "tag" .Values.images.busybox.tag "digest" .Values.images.busybox.digest "registry" "docker.io") | quote }} + image: {{ include "tracebloc.image" (dict "repository" "library/busybox" "tag" .Values.images.busybox.tag "digest" .Values.images.busybox.digest "registry" (dig "imageRegistry" "docker.io" (.Values.global | default dict))) | quote }} # Must run as root to chown the hostPath mount; kubelet does not apply # fsGroup to hostPath volumes (kubernetes/kubernetes#138411). # Scope privilege tightly: only CHOWN is needed — no FOWNER, so do not @@ -62,7 +62,7 @@ spec: mountPath: /var/lib/mysql/ {{- end }} containers: - - image: {{ include "tracebloc.image" (dict "repository" "tracebloc/mysql-client" "tag" (.Values.images.mysqlClient.tag | default "prod") "digest" .Values.images.mysqlClient.digest "registry" "docker.io") | quote }} + - image: {{ include "tracebloc.image" (dict "repository" "tracebloc/mysql-client" "tag" (.Values.images.mysqlClient.tag | default "prod") "digest" .Values.images.mysqlClient.digest "registry" (dig "imageRegistry" "docker.io" (.Values.global | default dict))) | quote }} imagePullPolicy: IfNotPresent name: mysql-client securityContext: diff --git a/client/templates/rbac.yaml b/client/templates/rbac.yaml index e100f221..237f4d1e 100644 --- a/client/templates/rbac.yaml +++ b/client/templates/rbac.yaml @@ -1,3 +1,6 @@ +{{- if and .Values.perDatasetPvcs (eq .Values.clusterScope false) }} +{{- fail "perDatasetPvcs requires clusterScope: PersistentVolumes are cluster-scoped, so the namespaced Role cannot grant the PV provisioning/GC verbs jobs-manager needs — Job spawns would 403 at runtime. Set clusterScope: true (the default) or disable perDatasetPvcs." }} +{{- end }} --- apiVersion: v1 kind: ServiceAccount @@ -59,6 +62,22 @@ rules: resources: ["secrets"] verbs: ["delete"] {{- end }} +{{- if .Values.perDatasetPvcs }} + # Per-dataset PVCs only (RFC-0003 D9 Track B, client-runtime#203/#261): + # jobs-manager provisions one static hostPath PV + pre-bound PVC per + # dataset handle before spawning a training Job (`create`; PV `get`/`patch` + # to recover a Released PV's claimRef for reuse) and garbage-collects + # PV/PVCs no live Job references (`list` + `delete`). Cluster-scope only: + # PVs cannot be granted by a namespace Role, so the Role branch below + # omits this and rendering fails on the combination (guard at the top). + # Flag-gated so a default install grants nothing extra — least privilege. + - apiGroups: [""] + resources: ["persistentvolumes"] + verbs: ["create", "get", "list", "patch", "delete"] + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["create", "list", "delete"] +{{- end }} --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding diff --git a/client/templates/requests-proxy-deployment.yaml b/client/templates/requests-proxy-deployment.yaml index 640c1727..ee3ffb5e 100644 --- a/client/templates/requests-proxy-deployment.yaml +++ b/client/templates/requests-proxy-deployment.yaml @@ -22,8 +22,12 @@ spec: type: RuntimeDefault containers: - name: proxy - image: {{ include "tracebloc.image" (dict "repository" "tracebloc/jobs-manager" "tag" .Values.env.CLIENT_ENV "digest" (dig "requestsProxy" "digest" "" .Values.images) "registry" "docker.io") | quote }} - imagePullPolicy: Always + {{- $rpDigest := (dig "requestsProxy" "digest" "" .Values.images) }} + image: {{ include "tracebloc.image" (dict "repository" "tracebloc/jobs-manager" "tag" .Values.env.CLIENT_ENV "digest" $rpDigest "registry" (dig "imageRegistry" "docker.io" (.Values.global | default dict))) | quote }} + # digest set -> repo@digest + IfNotPresent (restart-safe offline); empty -> + # repo:tag + Always. Was hardcoded Always, which ignored a pinned digest and + # re-pulled on every restart even when the image was already cached (#552). + imagePullPolicy: {{ if $rpDigest }}IfNotPresent{{ else }}Always{{ end }} workingDir: /app command: ["python", "-m", "gunicorn"] args: diff --git a/client/templates/resource-monitor-daemonset.yaml b/client/templates/resource-monitor-daemonset.yaml index 946f8004..91c689df 100644 --- a/client/templates/resource-monitor-daemonset.yaml +++ b/client/templates/resource-monitor-daemonset.yaml @@ -50,7 +50,7 @@ spec: entry). `dig` is not usable here — it rejects chartutil.Values. */}} {{- $rmDigest := (default (dict) (default (dict) .Values.images).resourceMonitor).digest | default "" }} - image: {{ include "tracebloc.image" (dict "repository" "tracebloc/resource-monitor" "tag" .Values.env.CLIENT_ENV "digest" $rmDigest "registry" "docker.io") | quote }} + image: {{ include "tracebloc.image" (dict "repository" "tracebloc/resource-monitor" "tag" .Values.env.CLIENT_ENV "digest" $rmDigest "registry" (dig "imageRegistry" "docker.io" (.Values.global | default dict))) | quote }} imagePullPolicy: {{ if $rmDigest }}IfNotPresent{{ else }}Always{{ end }} securityContext: allowPrivilegeEscalation: false diff --git a/client/templates/storage-assertions-check.yaml b/client/templates/storage-assertions-check.yaml index 378d06bc..14deb21f 100644 --- a/client/templates/storage-assertions-check.yaml +++ b/client/templates/storage-assertions-check.yaml @@ -100,7 +100,7 @@ spec: - name: assert # Same kubectl-capable image the image-refresh CronJob uses; the tag # pins kubectl so behaviour cannot drift if upstream re-tags. - image: "{{ $img.repository | default "alpine/k8s" }}:{{ $img.tag | default "1.30.5" }}" + image: "{{ include "tracebloc.mirrorPrefix" $ }}{{ $img.repository | default "alpine/k8s" }}:{{ $img.tag | default "1.30.5" }}" imagePullPolicy: {{ $img.pullPolicy | default "IfNotPresent" }} securityContext: allowPrivilegeEscalation: false diff --git a/client/tests/global_image_registry_test.yaml b/client/tests/global_image_registry_test.yaml new file mode 100644 index 00000000..1c6f1e49 --- /dev/null +++ b/client/tests/global_image_registry_test.yaml @@ -0,0 +1,204 @@ +suite: global.imageRegistry private-mirror re-homing (#585) +# One knob, --set global.imageRegistry=, must re-home EVERY image the +# chart pulls onto that mirror so an air-gapped / registry-mirrored install +# pulls nothing from a public registry. This is the Bitnami `global.imageRegistry` +# convention. Two invariants are pinned here: +# 1. Set -> every image (tracebloc/*, the spawned ingestor + training jobs, +# and the alpine/*, ubuntu/squid utility images) carries the mirror prefix. +# 2. Unset -> byte-identical to the pre-#585 chart: tracebloc/* + squid on +# docker.io, ingestor on ghcr.io, alpine/* unprefixed (docker.io implicit). +# Precedence guards: an explicit images.ingestor.repository still wins over the +# mirror (someone who names a full repo means it); an explicit per-image +# registry (egressProxy.image.registry) is overridden by the global mirror. +templates: + - templates/jobs-manager-deployment.yaml + - templates/resource-monitor-daemonset.yaml + - templates/egress-proxy-deployment.yaml + - templates/egress-proxy-configmap.yaml + - templates/image-refresh-cronjob.yaml + - templates/auto-upgrade-cronjob.yaml + - templates/storage-assertions-check.yaml +release: + name: t + namespace: tracebloc +set: + clientId: "test-id" + clientPassword: "test" +tests: + # --------------------------------------------------------------------------- + # 1. Mirror SET -> every image re-homed + # --------------------------------------------------------------------------- + - it: re-homes the jobs-manager (tracebloc.image) container onto the mirror + template: templates/jobs-manager-deployment.yaml + set: + global: + imageRegistry: mirror.corp.example + asserts: + - matchRegex: + path: spec.template.spec.containers[0].image + pattern: "^mirror\\.corp\\.example/tracebloc/jobs-manager" + + - it: re-homes the resource-monitor DaemonSet onto the mirror + template: templates/resource-monitor-daemonset.yaml + set: + global: + imageRegistry: mirror.corp.example + asserts: + - matchRegex: + path: spec.template.spec.containers[0].image + pattern: "^mirror\\.corp\\.example/tracebloc/resource-monitor" + + - it: re-homes the squid egress gateway onto the mirror + template: templates/egress-proxy-deployment.yaml + set: + global: + imageRegistry: mirror.corp.example + asserts: + - matchRegex: + path: spec.template.spec.containers[0].image + pattern: "^mirror\\.corp\\.example/ubuntu/squid@" + + - it: re-homes the alpine/k8s image-refresh CronJob onto the mirror + template: templates/image-refresh-cronjob.yaml + documentSelector: {path: kind, value: CronJob} + set: + global: + imageRegistry: mirror.corp.example + asserts: + - equal: + path: spec.jobTemplate.spec.template.spec.containers[0].image + value: "mirror.corp.example/alpine/k8s:1.30.5" + + - it: re-homes the alpine/helm auto-upgrade CronJob onto the mirror + template: templates/auto-upgrade-cronjob.yaml + documentSelector: {path: kind, value: CronJob} + set: + global: + imageRegistry: mirror.corp.example + asserts: + - equal: + path: spec.jobTemplate.spec.template.spec.containers[0].image + value: "mirror.corp.example/alpine/helm:3.16.4" + + - it: re-homes the alpine/k8s storage-assertions Job onto the mirror + template: templates/storage-assertions-check.yaml + documentSelector: {path: kind, value: Job} + set: + global: + imageRegistry: mirror.corp.example + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: "mirror.corp.example/alpine/k8s:1.30.5" + + - it: re-homes the spawned-ingestor repository env onto the mirror + template: templates/jobs-manager-deployment.yaml + set: + global: + imageRegistry: mirror.corp.example + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: INGESTOR_IMAGE_REPOSITORY + value: "mirror.corp.example/tracebloc/ingestor" + + - it: re-homes the training-job image host (JOB_IMAGE_HOST) onto the mirror + template: templates/jobs-manager-deployment.yaml + set: + global: + imageRegistry: mirror.corp.example + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: JOB_IMAGE_HOST + value: "mirror.corp.example/" + + # --------------------------------------------------------------------------- + # 2. Mirror UNSET -> byte-identical to pre-#585 defaults + # --------------------------------------------------------------------------- + - it: leaves tracebloc.image on docker.io when no mirror is set + template: templates/jobs-manager-deployment.yaml + asserts: + - matchRegex: + path: spec.template.spec.containers[0].image + pattern: "^docker\\.io/tracebloc/jobs-manager" + + - it: leaves the ingestor repository on ghcr.io when no mirror is set + template: templates/jobs-manager-deployment.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: INGESTOR_IMAGE_REPOSITORY + value: "ghcr.io/tracebloc/ingestor" + + - it: leaves JOB_IMAGE_HOST on docker.io when no mirror is set + template: templates/jobs-manager-deployment.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: JOB_IMAGE_HOST + value: "docker.io/" + + - it: leaves the alpine/helm auto-upgrade image unprefixed when no mirror is set + template: templates/auto-upgrade-cronjob.yaml + documentSelector: {path: kind, value: CronJob} + asserts: + - equal: + path: spec.jobTemplate.spec.template.spec.containers[0].image + value: "alpine/helm:3.16.4" + + - it: leaves the squid gateway on docker.io when no mirror is set + template: templates/egress-proxy-deployment.yaml + asserts: + - matchRegex: + path: spec.template.spec.containers[0].image + pattern: "^docker\\.io/ubuntu/squid@" + + # --------------------------------------------------------------------------- + # 3. Precedence guards + # --------------------------------------------------------------------------- + - it: an explicit images.ingestor.repository wins over the global mirror + template: templates/jobs-manager-deployment.yaml + set: + global: + imageRegistry: mirror.corp.example + images: + ingestor: + repository: priv.example/team/ingestor + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: INGESTOR_IMAGE_REPOSITORY + value: "priv.example/team/ingestor" + + - it: the global mirror overrides an explicit egressProxy.image.registry + template: templates/egress-proxy-deployment.yaml + set: + global: + imageRegistry: mirror.corp.example + egressProxy: + image: + registry: quay.io + asserts: + - matchRegex: + path: spec.template.spec.containers[0].image + pattern: "^mirror\\.corp\\.example/ubuntu/squid@" + + - it: honors an explicit egressProxy.image.registry when no global mirror is set (Bugbot) + # The chart-default global.imageRegistry is "", and dig treats a present-but- + # empty value as found — so without the `| default` fall-through the empty + # global silently dropped this per-image registry down to docker.io. + template: templates/egress-proxy-deployment.yaml + set: + egressProxy: + image: + registry: quay.io + asserts: + - matchRegex: + path: spec.template.spec.containers[0].image + pattern: "^quay\\.io/ubuntu/squid@" diff --git a/client/tests/jobs_manager_test.yaml b/client/tests/jobs_manager_test.yaml index 7142b8d8..fe0991a9 100644 --- a/client/tests/jobs_manager_test.yaml +++ b/client/tests/jobs_manager_test.yaml @@ -361,6 +361,24 @@ tests: name: PER_INGESTION_TABLES value: "1" + - it: does not render PER_DATASET_PVCS by default (RFC-0003 D9 Track B knob off) + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: PER_DATASET_PVCS + value: "1" + + - it: renders PER_DATASET_PVCS=1 when perDatasetPvcs is enabled + set: + perDatasetPvcs: true + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: PER_DATASET_PVCS + value: "1" + - it: does not render per-experiment DB-cred env by default (RFC-0003 D10 knob off) asserts: - notContains: diff --git a/client/tests/rbac_test.yaml b/client/tests/rbac_test.yaml index ebd6feba..fb1585fc 100644 --- a/client/tests/rbac_test.yaml +++ b/client/tests/rbac_test.yaml @@ -128,3 +128,50 @@ tests: apiGroups: [""] resources: ["secrets"] verbs: ["delete"] + + # RFC-0003 D9 Track B (client-runtime#203/#261): the PV/PVC provisioning + + # GC verbs jobs-manager needs are flag-gated and cluster-scope-only. + - it: default-off grants nothing on persistentvolumes/persistentvolumeclaims (byte-for-byte) + set: + clusterScope: true + documentIndex: 1 + asserts: + - notContains: + path: rules + content: + apiGroups: [""] + resources: ["persistentvolumes"] + verbs: ["create", "get", "list", "patch", "delete"] + - notContains: + path: rules + content: + apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["create", "list", "delete"] + + - it: perDatasetPvcs adds PV provisioning/GC + PVC grants (ClusterRole) + set: + clusterScope: true + perDatasetPvcs: true + documentIndex: 1 + asserts: + - contains: + path: rules + content: + apiGroups: [""] + resources: ["persistentvolumes"] + verbs: ["create", "get", "list", "patch", "delete"] + - contains: + path: rules + content: + apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["create", "list", "delete"] + + - it: perDatasetPvcs with clusterScope false fails the render (PVs not grantable by a namespace Role) + set: + clusterScope: false + perDatasetPvcs: true + asserts: + - failedTemplate: + errorPattern: "perDatasetPvcs requires clusterScope" diff --git a/client/tests/requests_proxy_test.yaml b/client/tests/requests_proxy_test.yaml index 3c5f3f88..237d2ff7 100644 --- a/client/tests/requests_proxy_test.yaml +++ b/client/tests/requests_proxy_test.yaml @@ -79,6 +79,30 @@ tests: path: spec.template.spec.containers[0].image pattern: "^docker\\.io/tracebloc/jobs-manager[:@]" + - it: floats on the tag with imagePullPolicy Always when no digest is pinned (default) + asserts: + - matchRegex: + path: spec.template.spec.containers[0].image + pattern: "^docker\\.io/tracebloc/jobs-manager:" + - equal: + path: spec.template.spec.containers[0].imagePullPolicy + value: Always + + - it: pins repo@digest with imagePullPolicy IfNotPresent when a digest is set (#552) + # Was hardcoded Always — a pinned digest was ignored for the pull policy, so a + # restart re-pulled instead of using the cached image. Now digest-aware. + set: + images: + requestsProxy: + digest: "sha256:dfdaa7e633a8df0f46b403e9422eba5c16bdb7d9c39047e6d3738f9e38fbdba8" + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: docker.io/tracebloc/jobs-manager@sha256:dfdaa7e633a8df0f46b403e9422eba5c16bdb7d9c39047e6d3738f9e38fbdba8 + - equal: + path: spec.template.spec.containers[0].imagePullPolicy + value: IfNotPresent + - it: should expose the proxy on container port 8888 asserts: - equal: diff --git a/client/values.schema.json b/client/values.schema.json index ed3ccf1b..79a43801 100644 --- a/client/values.schema.json +++ b/client/values.schema.json @@ -299,6 +299,10 @@ "type": "boolean", "description": "RFC-0003 D16 (backend#1204/#1205): stamp PER_INGESTION_TABLES=1 into every ingestion Job. Flip per environment, dev first; file-bearing categories are refused under the flag until client-runtime#203 phase 2." }, + "perDatasetPvcs": { + "type": "boolean", + "description": "RFC-0003 D9 Track B (client-runtime#203/#261): stamp PER_DATASET_PVCS=1 into jobs-manager — training pods get one read-only hostPath PV/PVC per dataset handle instead of a subPath view of the shared PVC. Single-node/hostPath clusters only; requires clusterScope (PVs are cluster-scoped)." + }, "ingestionAuthz": { "type": "object", "description": "Authorization policy for POST /internal/submit-ingestion-run on jobs-manager (client-runtime#21). Rendered into the ingestion-authz ConfigMap.", diff --git a/client/values.yaml b/client/values.yaml index e1803a2d..962a2306 100644 --- a/client/values.yaml +++ b/client/values.yaml @@ -5,9 +5,21 @@ # Platform-specific behaviour is controlled by the values below. # Use per-environment value files (e.g. ci/aks-values.yaml) to override. +# -- Cross-cutting settings shared by all sub-resources. +global: + # Private registry mirror for restricted-network / air-gapped installs (#585). + # A BARE host (no scheme), e.g. "mirror.corp.example" or "mirror.corp.example:5000". + # When set, EVERY image the chart pulls is re-homed onto this host — the + # tracebloc services, the spawned ingestor + training-job images, and the + # alpine/*, ubuntu/squid, busybox, curl helper images — which must already host + # the same repositories + tags/digests. Unset (the default) pulls tracebloc/* + # and the helper images from docker.io and the ingestor from ghcr.io. If the + # mirror needs authentication, also set dockerRegistry below. The standalone + # installer writes this from TRACEBLOC_IMAGE_REGISTRY. See docs/INSTALL.md. + imageRegistry: "" + # -- Environment variables passed to containers env: { - # CLIENT_ENV: prod # Optional proxy settings # HTTP_PROXY_HOST: "" # HTTP_PROXY_PORT: "" @@ -789,6 +801,27 @@ imageRefresh: # Default false: legacy shared label-named tables, byte-for-byte. perIngestionTables: false +# ============================================================ +# Per-dataset PVC mounts (RFC-0003 D9 Track B — client-runtime#203 / #261) +# ============================================================ +# When true, jobs-manager stamps PER_DATASET_PVCS=1 into its own environment: +# each spawned training pod then gets one dedicated READ-ONLY hostPath PV/PVC +# per dataset handle, mounted at /data/shared/, and the shared data +# volume is removed from the pod entirely — the mount table references only +# the pod's own dataset(s), so no other dataset on the edge is even nameable. +# Off (default), training pods keep the subPath-scoped view of the shared PVC +# (Track A) — that is also the rollback: flip off and pods revert on the next +# spawn; the static PV/PVCs left behind are garbage-collected by jobs-manager. +# +# Effective only on single-node/hostPath clusters (node-local Option C, +# client#368): on elastic clusters the runtime logs a warning and falls back +# to subPath scoping. Requires clusterScope (the default): PersistentVolumes +# are cluster-scoped, so the namespaced Role cannot grant them — rendering +# fails on the invalid combination rather than letting Job spawns 403. +# Grants jobs-manager PV/PVC provisioning + GC verbs only while on (see +# templates/rbac.yaml). Default false: byte-for-byte identical rendering. +perDatasetPvcs: false + # ============================================================ # Per-experiment DB credentials (RFC-0003 D10 — backend#1181) # ============================================================ diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 4e414a84..7c033bb9 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -49,6 +49,66 @@ On **Linux**, the installer also fetches tooling from `get.docker.com`, `raw.git > - **Docker Desktop (macOS / Windows / Linux):** the daemon runs in a VM the installer can't reach. Trust the CA in the host OS store — the **macOS keychain** (set "Always Trust"), the **Windows Trusted Root** store (`certlm.msc`), or the **Linux system trust store** (the native-Docker commands above) — then restart Docker Desktop, which re-reads the host store on start. See [docs.docker.com](https://docs.docker.com/). > - **Colima (headless macOS):** the daemon runs in a Lima VM that does **not** read the macOS keychain. Add the CA *inside* the VM — `colima ssh`, copy the PEM into the VM's system trust store and refresh it — then `colima restart`. +### Blocked container registry (mirror / air-gapped) + +Some sites hard-block Docker Hub / GHCR outright — the images aren't reachable directly at all (this is different from a proxy or TLS-inspection, which the section above covers). The preflight check detects a blocked registry and points you here rather than failing with a raw pull error. + +The chart follows the **`global.imageRegistry`** convention: set it once and **every** image the chart pulls — the tracebloc services, the spawned ingestor, the training-job images, and the `alpine/*`, `ubuntu/squid`, `busybox`, `curl` helper images — is re-homed onto your registry. No per-image overrides. + +**1. A private/mirror registry your site *can* reach.** + +First mirror the images into it. List exactly what to copy (tags/digests stay authoritative across chart versions) with: + +```bash +# Every image this chart pulls, at the version you're installing: +helm template tracebloc/client | grep -oE 'image: "[^"]+"' | sort -u +``` + +Copy each into your registry under the **same repository path and tag/digest** (e.g. `mirror.corp.example/tracebloc/jobs-manager:`, `mirror.corp.example/library/busybox:1.35`). The spawned **ingestor** (`ghcr.io/tracebloc/ingestor`) and its floating tag also need mirroring — jobs-manager pulls it at run time. + +Then point the install at the mirror. **With the standalone installer**, one environment variable does it: + +```bash +# bash (Linux/macOS) — the installer writes global.imageRegistry into the +# generated values.yaml, so every image resolves to the mirror. +export TRACEBLOC_IMAGE_REGISTRY=mirror.corp.example +# If the mirror needs credentials, add these and it mints the pull secret too: +export TRACEBLOC_REGISTRY_USERNAME= +export TRACEBLOC_REGISTRY_PASSWORD= +``` + +```powershell +# Windows (install-k8s.ps1) — same knobs: +$env:TRACEBLOC_IMAGE_REGISTRY = "mirror.corp.example" +$env:TRACEBLOC_REGISTRY_USERNAME = "" +$env:TRACEBLOC_REGISTRY_PASSWORD = "" +``` + +**With plain Helm** (no installer), set the same value directly: + +```bash +helm install my-tracebloc tracebloc/client -n tracebloc --create-namespace \ + -f my-values.yaml \ + --set global.imageRegistry=mirror.corp.example +``` + +```yaml +# ...or in my-values.yaml. If the mirror needs credentials, add the pull secret. +global: + imageRegistry: mirror.corp.example +dockerRegistry: + create: true + server: https://mirror.corp.example # must be a URL (scheme required) + username: + password: +``` + +> `global.imageRegistry` is a **bare host** (no scheme); `dockerRegistry.server` is the pull-secret's auths key and must be a **URL**. The installer derives `https://` for you. + +**2. A fully air-gapped site (no reachable mirror).** Stand up a registry the cluster *can* reach (an internal Harbor/Nexus/Artifactory, or a registry running inside the cluster), mirror the images into it as in option 1 — moving the tarballs across the air gap with `docker save` / `docker load` or `skopeo copy` — then install with `global.imageRegistry` pointed at that internal registry. The single knob is the whole air-gap story: there is no separate offline code path to configure. + +> **Honest limit:** a site that blocks the registries **and** offers no reachable mirror (not even an internal one) cannot be served — the software isn't reachable by definition. Everything short of that is handleable. + --- ## 1. Add the Helm repository (recommended for production) diff --git a/docs/SEAL-CHECK.md b/docs/SEAL-CHECK.md index ac9cae49..6b7d2c48 100644 --- a/docs/SEAL-CHECK.md +++ b/docs/SEAL-CHECK.md @@ -237,26 +237,41 @@ the RFC-0003 §8.3 matrix. The **substrate** enforcement it builds on is already verified — see the Status note at the top of this section (the single record of that run). -## What the suite does not cover yet (follow-ups) +## CI coverage — what runs where + +- **`egress-enforcement`, live on every push/PR** — helm-ci's `seal-check-e2e` + job (`scripts/tests/e2e-seal-check.sh`, client#541 + #566): real k3d + cluster, lockdown engaged, positive control, then the probe via + `helm test --filter`. Zero secrets, so it runs everywhere. +- **The FULL suite vs the dev backend** — helm-ci's `full-seal-e2e` job + (`scripts/tests/e2e-full-seal.sh`, the backend#1184 deferred fast-follow): + installs the working-tree chart on k3d **as the dedicated dev + `e2e-test-agent` client with real credentials** (`CLIENT_ENV=dev`), waits + for every release PVC to Bind and for jobs-manager to hold a real backend + session, then runs `helm test` **unfiltered** — `egress-enforcement` + + `backend-reachability` + `storage-assertions` in one release, with a + guard that all three hooks are present so a regated check can never + vanish silently. Push/`workflow_dispatch` only (never PRs), one run at a + time (the platform sees one agent session). + + **Activation:** the job skips green with a `::notice` until the dev + platform has a dedicated `e2e-test-agent` client and the repo carries its + two Actions secrets — `TB_E2E_CLIENT_ID` / `TB_E2E_CLIENT_PASSWORD`. + Never use a real customer's or a person's shared dev identity (login + churn invalidates tokens — the backend#1180 failure class). Record the + first green run here, with date + run link. + +## What the suite does not cover (by design or elsewhere) -Tracked under backend#1184 unless noted: - -- **The live k3d verification run** (§8.4) — the *substrate* enforcement is - verified (see the §8.4 Status note for the recorded evidence); the - *full-chart* `egress-enforcement` probe run against a deployed release is - still pending, and the §8.3 k3d cell reads "substrate verified; full-probe - run pending" until it is recorded. - **A single aggregated sealed/unsealed verdict with per-guarantee detail** - — surfaced by the tracebloc CLI on top of this label contract - (tracebloc/cli#393). Today the aggregate is `helm test`'s exit status - plus per-Job logs. + — shipped in the tracebloc CLI on this label contract + (tracebloc/cli#393, v0.10.0); `helm test` remains the raw substrate. - **`~/.tracebloc` host-tree check** (post-Option-C: nothing of the environment left under the operator's home) — host-side by construction, not observable from in-cluster; belongs to the CLI/installer offboard verification lineage (cli#389), not to a helm-test Job. -- **Wiring the suite into the e2e harness dev runs.** -- **Filling the RFC-0003 §8.3 matrix precisely** in the RFC itself — the - table above is the chart-side input. +- **The RFC-0003 §8.3 matrix** is filled in the RFC (tracebloc/cli#449) — + the table above remains the chart-side input it is derived from. - **The Option C storage flip on local installs** (client#368) — the storage-assertions check is forward-compatible either way: it gates on `hostPath.enabled` and verifies whichever model the install declares. diff --git a/scripts/check-style.sh b/scripts/check-style.sh index 61dfa9c4..241dc765 100755 --- a/scripts/check-style.sh +++ b/scripts/check-style.sh @@ -101,6 +101,19 @@ report "bare 'curl' — call curl_secure() from ${ENGINE} so the TLS floor and t | grep -vE '(has curl|command -v curl)' \ | grep -vE 'curl[^|]*\|[[:space:]]*(sh|bash)' || true)" +# 4) Product name is lowercase 'tracebloc' in user-facing text — never capital-T +# 'Tracebloc'. The installer copy must feel one-to-one across platforms; the +# bash script is the gold standard (#576). Exempt: comments, and PascalCase code +# identifiers — function names (Get-Tracebloc… , matched by a leading '-') and +# the 'TraceblocInstallerResume' resume key (matched by a following uppercase +# letter). Opt out a real edge with a trailing `# style-guard: allow`. +scan 'Tracebloc' +report "capital-T 'Tracebloc' in user-facing text — the product name is lowercase 'tracebloc' (see STYLE.md)" \ + "$(printf '%s' "$hits" \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' \ + | grep -vE 'Tracebloc[A-Z]' \ + | grep -vE '[-]Tracebloc' || true)" + if [[ "$guard_error" -ne 0 ]]; then echo " [!] the guard hit an internal error — failing closed (exit 2)" >&2 exit 2 diff --git a/scripts/install-k8s.ps1 b/scripts/install-k8s.ps1 index 3c5f1d43..2d58bb8a 100644 --- a/scripts/install-k8s.ps1 +++ b/scripts/install-k8s.ps1 @@ -112,9 +112,9 @@ if (-not $env:TB_PESTER) { # HELPERS — logging functions matching bash UX # ============================================================================= -function Info($m) { Write-Host " " -NoNewline; Write-Host ([char]0x00B7) -ForegroundColor DarkGray -NoNewline; Write-Host " $m" -ForegroundColor DarkGray } -function Ok($m) { Write-Host " " -NoNewline; Write-Host ([char]0x2714) -ForegroundColor Green -NoNewline; Write-Host " $m" } -function Warn($m) { Write-Host " " -NoNewline; Write-Host ([char]0x26A0) -ForegroundColor Yellow -NoNewline; Write-Host " $m" -ForegroundColor Yellow } +function Info($m) { Write-Host " " -NoNewline; Write-Host ([char]0x00B7) -ForegroundColor DarkGray -NoNewline; Write-Host " $m" -ForegroundColor DarkGray; Log $m } +function Ok($m) { Write-Host " " -NoNewline; Write-Host ([char]0x2714) -ForegroundColor Green -NoNewline; Write-Host " $m"; Log "OK: $m" } +function Warn($m) { Write-Host " " -NoNewline; Write-Host ([char]0x26A0) -ForegroundColor Yellow -NoNewline; Write-Host " $m" -ForegroundColor Yellow; Log "WARN: $m" } # Build the trailing lines every fatal error shows (#423): a short excerpt of the # real tool output (last few non-empty lines — the actual reason, not a generic # line), then the log path and the -Diagnose support-bundle hint as first-class @@ -150,15 +150,50 @@ function Err($m, $Detail) { # @(...) forces array enumeration: a single-line result unwraps to a scalar # string, and enumerating that explicitly keeps each line intact (defensive — # the `foreach` statement already iterates a scalar once, not per-char). - foreach ($l in @(Get-ErrDetailLines $Detail)) { Write-Host " $l" -ForegroundColor DarkGray } + $det = @(Get-ErrDetailLines $Detail) + foreach ($l in $det) { Write-Host " $l" -ForegroundColor DarkGray } + # Mirror to the curated log too (#576) — Get-ErrDetailLines already strips the + # `At : char:` / `+ …` source-dump lines, so nothing internal leaks. + Log "ERROR: $m"; foreach ($l in $det) { Log $l } + $script:OutcomeReported = $true # Err IS a reported outcome (guards the finally) exit 1 } -function Step($n, $t, $l) { Write-Host ""; Write-Host "Step $n/$t" -ForegroundColor Cyan -NoNewline; Write-Host " $l" -ForegroundColor White } -function Log($m) { if ($script:LOG_FILE) { Add-Content -Path $script:LOG_FILE -Value "[$(Get-Date -Format 'HH:mm:ss')] $m" -ErrorAction SilentlyContinue } } -function PromptHeader($m) { Write-Host ""; Write-Host " $m" -ForegroundColor White } -function Hint($m) { Write-Host " $m" -ForegroundColor DarkGray } +function Step($n, $t, $l) { Write-Host ""; Write-Host "Step $n/$t" -ForegroundColor Cyan -NoNewline; Write-Host " $l" -ForegroundColor White; Log "== Step $n/$t : $l ==" } +function Log($m) { if ($script:LOG_FILE) { Add-Content -Path $script:LOG_FILE -Value "[$(Get-Date -Format 'HH:mm:ss')] $m" -Encoding UTF8 -ErrorAction SilentlyContinue } } +function PromptHeader($m) { Write-Host ""; Write-Host " $m" -ForegroundColor White; Log $m } +function Hint($m) { Write-Host " $m" -ForegroundColor DarkGray; Log $m } function Has($cmd) { [bool](Get-Command $cmd -ErrorAction SilentlyContinue) } +# Top-level fatal handler (#577): convert ANY unhandled terminating error into a +# clean, branded message — never PowerShell's raw source line + stack trace — then +# the caller exits non-zero. The reason shown is the exception MESSAGE (curated at +# the throw sites, #576); the stack trace is deliberately NOT shown or logged, so +# no tracebloc internals leak. The user always sees what happened + what to do. +function Show-FatalError($err) { + $script:OutcomeReported = $true # this IS the reported outcome (guards the finally) + $reason = "" + try { $reason = [string]$err.Exception.Message } catch {} + if (-not $reason) { $reason = [string]$err } + Log "FATAL: $reason" + Write-Host "" + Write-Host " " -NoNewline; Write-Host ([char]0x2716) -ForegroundColor Red -NoNewline; Write-Host " Installation stopped." -ForegroundColor Red + if ($reason) { Write-Host " $reason" -ForegroundColor DarkGray } + if ($script:LOG_FILE) { Hint "Details saved to: $script:LOG_FILE" } + Hint "It's safe to re-run this installer. If it keeps failing, send that log to tracebloc support." +} + +# The guaranteed finally's closer (#577): fires ONLY when the run ended without +# reporting an outcome — i.e. an interruption (Ctrl-C) or an abnormal termination +# that wasn't a handled Err, a caught crash, or a normal finish — so the window +# never just vanishes. Mirrors bash's exit-code-guarded install_cleanup. +function Show-Interrupted { + Log "Installation interrupted before completion." + Write-Host "" + Write-Host " " -NoNewline; Write-Host ([char]0x26A0) -ForegroundColor Yellow -NoNewline; Write-Host " Installation was interrupted before it finished." -ForegroundColor Yellow + if ($script:LOG_FILE) { Hint "Log: $script:LOG_FILE" } + Hint "It's safe to re-run this installer." +} + function RefreshPath { $env:PATH = [System.Environment]::GetEnvironmentVariable("PATH","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("PATH","User") @@ -385,6 +420,91 @@ function Invoke-WithRetry { } } +# Is the file at $Path a COMPLETE download (#607)? Returns $null when it is, else a +# short reason. A proxy/AV that truncates or rewrites a binary mid-transfer leaves a +# short error page or partial file that Invoke-WebRequest reports as "success"; the +# only old signal was the downstream checksum, so a user dead-ended at the cryptic +# "System tool checksum verification failed". Validate the payload is present, at +# least $MinBytes, and (when given) starts with the expected magic bytes -- "MZ" for +# a Windows .exe, "PK" for a .zip -- so a bad TRANSFER is caught distinctly from a +# checksum mismatch on a complete file. Pure (file in, reason out) so Pester can +# exercise every branch without a network. +function Test-DownloadComplete { + param( + [Parameter(Mandatory)][string]$Path, + [int]$MinBytes = 1MB, + [string]$Magic = '' + ) + if (-not (Test-Path -LiteralPath $Path)) { return "no file was written" } + $len = (Get-Item -LiteralPath $Path).Length + if ($len -lt $MinBytes) { return "got $len bytes (expected at least $MinBytes) -- the transfer was truncated or blocked" } + if ($Magic) { + $fs = [System.IO.File]::OpenRead($Path) + try { + $buf = New-Object byte[] ($Magic.Length) + $n = $fs.Read($buf, 0, $Magic.Length) + } finally { $fs.Close() } + $got = -join (@($buf)[0..([Math]::Max(0, $n - 1))] | ForEach-Object { [char][int]$_ }) + if ($got -ne $Magic) { return "the file is not a valid '$Magic' file (starts with '$got') -- likely an error page or an altered binary" } + } + return $null +} + +# Resilient tool download (#607): fetch $Url to $Dest and only return once a +# COMPLETE file has landed. The transfer runs under the heartbeat spinner +# (Invoke-WithHeartbeat) as before, but is now tried over several transports in +# turn -- Invoke-WebRequest, then curl.exe, then BITS. Each uses a different HTTP +# stack, so when a proxy/AV blocks or truncates one, another commonly succeeds. +# After each transport Test-DownloadComplete gates the result, so an incomplete +# transfer moves on to the next method instead of poisoning the downstream +# checksum. Every transport failing throws one specific, actionable message. +function Get-VerifiedDownload { + param( + [Parameter(Mandatory)][string]$Url, + [Parameter(Mandatory)][string]$Dest, + [int]$MinBytes = 1MB, + [string]$Magic = '', + [string]$Label = 'download', + [string]$Message = 'Downloading' + ) + $iwr = { param($u, $d); $ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest $u -OutFile $d -UseBasicParsing -MaximumRedirection 5 } + # style-guard: allow -- curl.exe is a deliberate FALLBACK transport here; curl_secure() is a bash helper and cannot exist in PowerShell. --tlsv1.2 mirrors its TLS floor (Bugbot). + $curl = { param($u, $d); & curl.exe --tlsv1.2 -fSL --retry 2 --retry-delay 2 --connect-timeout 30 --max-time 900 -o $d $u 2>$null; if ($LASTEXITCODE -ne 0) { throw "curl.exe exited $LASTEXITCODE" } } # style-guard: allow + $bits = { param($u, $d); Import-Module BitsTransfer -ErrorAction SilentlyContinue; Start-BitsTransfer -Source $u -Destination $d -ErrorAction Stop } + + $transports = @( ,@('Invoke-WebRequest', $iwr) ) + if (Get-Command curl.exe -ErrorAction SilentlyContinue) { $transports += ,@('curl.exe', $curl) } # style-guard: allow -- presence check + fallback registration, not a bare fetch + $transports += ,@('BITS', $bits) + + $problems = @() + foreach ($t in $transports) { + $name = $t[0]; $block = $t[1] + Remove-Item -LiteralPath $Dest -Force -ErrorAction SilentlyContinue + try { + Invoke-WithHeartbeat -Message $Message -ArgumentList @($Url, $Dest) -Script $block + } catch { + $problems += "${name}: $($_.Exception.Message)" + continue + } + # Validation must not escape the loop: Get-Item/OpenRead can throw if AV locks + # or quarantines the just-written file, and that is exactly a case where the + # NEXT transport should be tried, not the whole download aborted (Bugbot). + try { + $bad = Test-DownloadComplete -Path $Dest -MinBytes $MinBytes -Magic $Magic + } catch { + $bad = "could not read the downloaded file ($($_.Exception.Message)) -- it may be locked or quarantined by antivirus" + } + if (-not $bad) { return } # complete + valid -- done + $problems += "${name}: $bad" + Warn "$Label via $name looked incomplete ($bad); trying another method..." + } + Remove-Item -LiteralPath $Dest -Force -ErrorAction SilentlyContinue + throw ("Couldn't download a complete file from $Url (tried: $(($transports | ForEach-Object { $_[0] }) -join ', ')). " + + ($problems -join ' | ') + ". On a filtered network a proxy or antivirus may be blocking or " + + "rewriting the binary -- allowlist github.com, objects.githubusercontent.com, dl.k8s.io and " + + "get.helm.sh (or exclude the tools folder from AV scanning), then re-run.") +} + # Execute-gate a freshly-installed tool (#411). The old post-install "check" was a # Log interpolation whose failure is non-terminating, so a corrupt or wrong-arch # binary (winget shims / partial installs skip the direct path's checksum verify) @@ -575,11 +695,22 @@ function Start-InstallLog { New-Item -ItemType Directory -Path $HOST_DATA_DIR -Force | Out-Null } $script:LOG_FILE = "$HOST_DATA_DIR\install-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" + # Curated, PII-free log (#576). We deliberately DO NOT use Start-Transcript: its + # fixed header records Username / RunAs / Machine / PID (a real client's shared + # log leaked their Windows identity), and it also captures PowerShell's raw error + # rendering — source lines, internal identifiers. Instead the message helpers + # (Info/Ok/Warn/Err/Step/…) route through Log(), so the log mirrors the curated + # on-screen output: no user PII, no tracebloc internals. Best-effort — if the + # file can't be created, logging silently no-ops and the install continues. try { - Start-Transcript -Path $LOG_FILE -Append | Out-Null + # UTF-8 without BOM, matching the file's other writers (UTF8Encoding($false) at + # L1780/L1829/L4226): Set-Content -Encoding UTF8 prepends a BOM on PS 5.1, so the + # log would start with EF BB BF (Saqlain, #591). WriteAllText throws into the catch + # on failure like -ErrorAction Stop; the trailing CRLF keeps Set-Content's newline. + [System.IO.File]::WriteAllText($LOG_FILE, "tracebloc client installer log - $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')`r`n", (New-Object System.Text.UTF8Encoding($false))) Log "Install log: $LOG_FILE" } catch { - Log "Could not start transcript logging: $_" + $script:LOG_FILE = $null } } @@ -1075,6 +1206,11 @@ function Enable-VirtualisationFeatures { if ($rebootNeeded) { Warn "A reboot is required to finish enabling system features." + # A reboot-pending stop IS a reported outcome (Bugbot): the guidance below tells + # the user exactly what happens next. Set the flag so the top-level finally does + # not then append a contradictory "interrupted" line. Covers every exit from this + # block (both `exit 2` paths and the Restart-Computer path). + $script:OutcomeReported = $true # Arm the RunOnce continuation so the install resumes at next sign-in with no # re-pasting -- both for auto-reboot and manual -NoReboot (#420). RunOnce is # written to the CURRENT (elevating) account's hive: the reboot happens here in @@ -1458,13 +1594,8 @@ function Install-Kubectl { $kUrl = "https://dl.k8s.io/release/$kVer/bin/windows/$arch/kubectl.exe" $t0 = Get-Date # Heartbeat during the otherwise-silent transfer (#422); retry wraps it. - Invoke-WithRetry -Label "download" -ScriptBlock { - Invoke-WithHeartbeat -Message "Downloading kubectl $kVer (~60 MB)" ` - -ArgumentList @($kUrl, $kubectlDest) -Script { - param($u, $d); $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest $u -OutFile $d -UseBasicParsing - } - } + Get-VerifiedDownload -Url $kUrl -Dest $kubectlDest -MinBytes 20MB -Magic 'MZ' ` + -Label "kubectl download" -Message "Downloading kubectl $kVer (~60 MB)" $expectedHash = Invoke-WithRetry -Label "checksum" -ScriptBlock { (Invoke-WebRequest "https://dl.k8s.io/release/$kVer/bin/windows/$arch/kubectl.exe.sha256" ` -UseBasicParsing).Content.Trim() @@ -1550,20 +1681,14 @@ function Resolve-ToolVersion { function Install-K3dAndHelm { # -- k3d -- + # No winget path: k3d has no manifest in the winget community repo (verified + # #607 — `Rancher.k3d` and every id variant 404), so `winget install` only ever + # returned "No package found", burning ~4s and muddying diagnosis before the + # direct download ran anyway. The direct download below is now resilient + # (Get-VerifiedDownload: multi-transport + completeness validation), so it is the + # single, reliable path. Re-add a winget branch here only if k3d is ever + # published to winget. if (-not (Has "k3d")) { - if (Has "winget") { - Log "Installing k3d via winget..." - # winget install is console-silent; run it as a killable tracked process - # (not a job — Stop-Job would orphan the child on timeout) with a spinner + - # deadline, capturing output so a failure is diagnosable (#500). Best-effort: - # on any non-ok outcome the direct download below takes over (#422). - $r = Invoke-TrackedInstall -FilePath "winget" -Label "Installing k3d (winget)" -TimeoutMinutes 10 -Tag "k3d-winget" ` - -ArgumentList @("install","-e","--id","Rancher.k3d","--accept-package-agreements","--accept-source-agreements","--silent") - if ($r.State -ne 'ok') { Log "k3d winget install: state=$($r.State) exit=$($r.ExitCode)" } - } - RefreshPath - - if (-not (Has "k3d")) { $arch = Get-WindowsArch $t0k3d = Get-Date Log "Downloading k3d binary directly ($arch)..." @@ -1576,13 +1701,8 @@ function Install-K3dAndHelm { } $k3dDest = "$TOOL_DIR\k3d.exe" $k3dUrl = "https://github.com/k3d-io/k3d/releases/download/$k3dVer/k3d-windows-$arch.exe" - Invoke-WithRetry -Label "k3d download" -ScriptBlock { - Invoke-WithHeartbeat -Message "Downloading k3d $k3dVer (~25 MB)" ` - -ArgumentList @($k3dUrl, $k3dDest) -Script { - param($u, $d); $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest $u -OutFile $d -UseBasicParsing - } - } + Get-VerifiedDownload -Url $k3dUrl -Dest $k3dDest -MinBytes 10MB -Magic 'MZ' ` + -Label "k3d download" -Message "Downloading k3d $k3dVer (~25 MB)" # Fail-closed verification, matching the Linux path and the kubectl # precedent: an unfetchable checksums.txt, a missing asset line, or a # mismatch all abort and remove the download — never install unverified @@ -1617,7 +1737,6 @@ function Install-K3dAndHelm { # execute-gate passes — a corrupt/wrong-arch binary must not show a green # "ready" line before Assert-ToolRuns (#422 Bugbot; kubectl gates first too). $k3dSummary = Get-ToolSummaryLine -Name "k3d" -Version $k3dVer -Size "~25 MB" -ElapsedSec ([int]((Get-Date) - $t0k3d).TotalSeconds) - } } Assert-ToolRuns -Name "k3d" -VersionArgs @("version") -BinPath "$TOOL_DIR\k3d.exe" if ($k3dSummary) { Ok $k3dSummary } @@ -1648,13 +1767,8 @@ function Install-K3dAndHelm { $t0helm = Get-Date $helmZip = "$env:TEMP\helm-$helmVer-windows-$arch.zip" $helmUrl = "https://get.helm.sh/helm-$helmVer-windows-$arch.zip" - Invoke-WithRetry -Label "helm download" -ScriptBlock { - Invoke-WithHeartbeat -Message "Downloading Helm $helmVer (~20 MB)" ` - -ArgumentList @($helmUrl, $helmZip) -Script { - param($u, $d); $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest $u -OutFile $d -UseBasicParsing - } - } + Get-VerifiedDownload -Url $helmUrl -Dest $helmZip -MinBytes 5MB -Magic 'PK' ` + -Label "helm download" -Message "Downloading Helm $helmVer (~20 MB)" $helmExtract = "$env:TEMP\helm-extract" if (Test-Path $helmExtract) { Remove-Item $helmExtract -Recurse -Force } Expand-Archive -Path $helmZip -DestinationPath $helmExtract -Force @@ -1763,6 +1877,29 @@ function Resolve-CaBundle { return $null } +# Wire the resolved corporate CA into git (Git-for-Windows is OpenSSL-backed and honors +# GIT_SSL_CAINFO) (#583). cosign & helm are Go, and Go on Windows reads the certificate +# store and IGNORES SSL_CERT_FILE (Bugbot) — so we do NOT set it (it would be inert and +# misleading); those trust the CA only when it's in the Windows store. curl/ +# Invoke-WebRequest already use the Windows store, and we never re-export CURL_CA_BUNDLE +# (replace-not-augment). The k3d nodes are trusted at cluster-create (#424). No-op when +# unconfigured; Resolve-CaBundle fails fast on an unreadable bundle. +function Set-ToolTrust { + $ca = Resolve-CaBundle + if (-not $ca) { return } + # Don't clobber a fuller pre-set GIT_SSL_CAINFO (replace-not-augment): only set it + # when the user hasn't already (Bugbot). And say only what actually happened: a + # green "Trusting..." while the export was skipped reported wiring that did not + # happen - masking a pre-set bundle that may still lack the corporate CA (Bugbot). + if (-not $env:GIT_SSL_CAINFO) { + $env:GIT_SSL_CAINFO = $ca + Ok "Trusting your company's certificate for git." + } else { + Hint "Keeping your pre-set GIT_SSL_CAINFO - make sure that bundle includes your company's CA, or git will still fail x509." + } + Hint "On Windows, cosign, helm and the installer's downloads read the certificate store, not a PEM file - import your corporate CA into Cert:\LocalMachine\Root (or use the offline installer) so they trust it too." +} + # Build a k3d registries.yaml pointing containerd at the mounted CA for every # registry in $TbCaRegistries, and return its path. $NodeCa = the CA path INSIDE # the node (where the -v mount lands). Written UTF-8 without BOM. Caller removes @@ -2524,26 +2661,57 @@ function New-K3dCluster { # ============================================================================= function Install-GpuDevicePlugin { - if ($GPU_VENDOR -ne "nvidia" -or -not $NVIDIA_DRIVER_OK -or $K3D_GPU_FLAG -eq "") { return } + # Returns $true when the GPU plugin is (believed) deployed, $false otherwise, so + # the caller can skip Confirm-GpuNode's ~90s wait for a plugin never applied + # (Bugbot). Every message helper uses Write-Host, so the only pipeline output is + # the boolean below (the Invoke-WithRetry result is sunk to $null to be safe). + if ($GPU_VENDOR -ne "nvidia" -or -not $NVIDIA_DRIVER_OK -or $K3D_GPU_FLAG -eq "") { return $false } Log "Deploying NVIDIA k8s device plugin" - $dpExists = kubectl get daemonset -n kube-system nvidia-device-plugin-daemonset 2>&1 + # --request-timeout bounds the existence probe so a wedged API server can't hang + # here before the bounded apply is reached (reviewer; parity with bash + verify). + $dpExists = kubectl get daemonset -n kube-system nvidia-device-plugin-daemonset --request-timeout=5s 2>&1 if ($LASTEXITCODE -eq 0) { Ok "GPU acceleration enabled." + return $true } else { $dpUrl = "https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.14.5/nvidia-device-plugin.yml" $dpTmp = [System.IO.Path]::GetTempFileName() try { - Invoke-WithRetry -Label "GPU plugin download" -ScriptBlock { + $null = Invoke-WithRetry -Label "GPU plugin download" -ScriptBlock { Invoke-WebRequest -Uri $dpUrl -OutFile $dpTmp -UseBasicParsing } + $gpuOk = $false if ((Get-Item $dpTmp).Length -gt 0) { - kubectl apply -f $dpTmp - $null = (kubectl rollout status daemonset/nvidia-device-plugin-daemonset ` - -n kube-system --timeout=120s 2>&1) + # kubectl is a native command: a non-zero exit does NOT throw, so without an + # explicit $LASTEXITCODE gate a failed apply/rollout would fall through to a + # false "GPU acceleration enabled." Capture each call's output to the log and + # gate the success message on the exit code (mirrors bash gpu-plugins.sh). + # --request-timeout bounds the API call so a wedged API server fails into the + # CPU-mode warn below instead of hanging silently (Bugbot; parity with bash). + $applyOut = (kubectl apply -f $dpTmp --request-timeout=30s 2>&1 | Out-String) + Log "GPU plugin apply: $applyOut" + if ($LASTEXITCODE -eq 0) { + $rollOut = (kubectl rollout status daemonset/nvidia-device-plugin-daemonset ` + -n kube-system --timeout=120s 2>&1 | Out-String) + Log "GPU plugin rollout: $rollOut" + $gpuOk = ($LASTEXITCODE -eq 0) + } + } + if ($gpuOk) { Ok "GPU acceleration enabled." - } else { Err "Failed to enable GPU acceleration." } + } else { + Warn "Couldn't enable GPU acceleration - continuing in CPU mode. Re-run the installer later to retry." + } + return $gpuOk + } catch { + # GPU is OPTIONAL: a plugin download/apply failure must NOT abort the install + # (#577 fatal-vs-recoverable) — otherwise the throw would reach the top-level + # boundary and stop everything. Warn and continue in CPU mode. + Warn "Couldn't enable GPU acceleration - continuing in CPU mode. Re-run the installer later to retry." + Log "GPU device-plugin setup error: $($_.Exception.Message)" + return $false } finally { Remove-Item $dpTmp -Force -ErrorAction SilentlyContinue } @@ -2558,7 +2726,7 @@ function Confirm-GpuNode { $gpuCount = 0 for ($i = 1; $i -le 18; $i++) { Start-Sleep -Seconds 5 - $alloc = kubectl get node -o jsonpath='{.items[0].status.allocatable}' 2>$null + $alloc = kubectl get node -o jsonpath='{.items[0].status.allocatable}' --request-timeout=5s 2>$null if ($alloc -match '"nvidia\.com/gpu":"?(\d+)') { $gpuCount = [int]$Matches[1]; break } } @@ -2589,6 +2757,54 @@ $TRACEBLOC_CHART_NAME = "client" # overhead (a pod schedules onto ONE node; k3d's server+agent are the same # machine, so summing would double-count) # 4. the historic static default (tiny or undeterminable machines) +# Get-ImageMirrorYaml — top-level chart values that re-home every image the chart +# pulls onto a private registry mirror (#585 / restricted-network + air-gapped +# installs). Bash parity: lib/install-client-helm.sh::_image_mirror_yaml. +# TRACEBLOC_IMAGE_REGISTRY sets global.imageRegistry (the chart's convention that +# re-homes tracebloc/*, the spawned ingestor + training-job images, and the +# alpine/* + ubuntu/squid utility images). When the mirror needs auth, +# TRACEBLOC_REGISTRY_USERNAME / TRACEBLOC_REGISTRY_PASSWORD also mint the chart's +# imagePullSecret (dockerRegistry), whose server defaults to https://. +# Returns "" when nothing is configured, so a default install's values are byte- +# identical. Pure (env in, string out) so it is unit-testable under Pester. +function Get-ImageMirrorYaml { + $mirrorRaw = $env:TRACEBLOC_IMAGE_REGISTRY + $regUser = $env:TRACEBLOC_REGISTRY_USERNAME + $regPass = $env:TRACEBLOC_REGISTRY_PASSWORD + if (-not ($mirrorRaw -or $regUser -or $regPass)) { return "" } + + $block = "" + # global.imageRegistry is a BARE host (mirror.corp.example[:port]); strip a + # pasted scheme so the image ref (/repo) stays well-formed. + $mirrorHost = $mirrorRaw -replace '^[a-zA-Z][a-zA-Z0-9+.\-]*://', '' + if ($mirrorHost) { + $mh = $mirrorHost -replace "'", "''" + $block += "global:`n imageRegistry: '$mh'`n" + } + if ($regUser -or $regPass) { + # dockerRegistry.server is the imagePullSecret auths key and the chart schema + # REQUIRES it whenever create is true (format:uri), so it must ALWAYS be + # emitted. Precedence: an explicit TRACEBLOC_REGISTRY_SERVER wins; else derive + # https:// when a mirror is set; else fall back to Docker Hub so + # creds-only (authenticate to docker.io, no mirror) still renders a valid + # secret instead of a schema error. + $server = $env:TRACEBLOC_REGISTRY_SERVER + if (-not $server) { + if ($mirrorHost) { $server = "https://$mirrorHost" } else { $server = "https://index.docker.io/v1/" } + } + $userE = $regUser -replace "'", "''" + $passE = $regPass -replace "'", "''" + $emailE = ($env:TRACEBLOC_REGISTRY_EMAIL) -replace "'", "''" + $srvE = $server -replace "'", "''" + $block += "`ndockerRegistry:`n create: true`n" + $block += " server: '$srvE'`n" + $block += " username: '$userE'`n" + $block += " password: '$passE'`n" + $block += " email: '$emailE'`n" + } + return $block +} + function Get-TrainingResources { if ($env:TRACEBLOC_TRAINING_RESOURCES) { return $env:TRACEBLOC_TRAINING_RESOURCES } try { @@ -3267,6 +3483,26 @@ function Install-ClientHelm { if (-not $adoptedReuse) { $passwordEscaped = $TB_CLIENT_PASSWORD -replace "'", "''" + # Private registry mirror (#585): re-home every image the chart pulls onto a + # private mirror for restricted-network / air-gapped installs. Bash parity: + # lib/install-client-helm.sh::_image_mirror_yaml. TRACEBLOC_IMAGE_REGISTRY sets + # global.imageRegistry (the chart's convention that re-homes tracebloc/*, the + # spawned ingestor + training-job images, and the alpine/* + ubuntu/squid + # utility images). When the mirror needs auth, TRACEBLOC_REGISTRY_USERNAME / + # TRACEBLOC_REGISTRY_PASSWORD also mint the chart's imagePullSecret + # (dockerRegistry). Empty when no mirror is configured, so default installs are + # unchanged. + # Private registry mirror (#585): re-home every image onto the mirror for + # restricted-network / air-gapped installs. Empty when no mirror is configured. + $imageMirrorBlock = Get-ImageMirrorYaml + if ($env:TRACEBLOC_IMAGE_REGISTRY) { + $mirrorHostLog = $env:TRACEBLOC_IMAGE_REGISTRY -replace '^[a-zA-Z][a-zA-Z0-9+.\-]*://', '' + Log "Image registry mirror configured -- pulling all images from $mirrorHostLog." + } + if ($env:TRACEBLOC_REGISTRY_USERNAME -or $env:TRACEBLOC_REGISTRY_PASSWORD) { + Log "Mirror credentials provided -- minting an imagePullSecret for the mirror." + } + $gpuVal = "" if ($GPU_VENDOR -eq "nvidia" -and $NVIDIA_DRIVER_OK) { $gpuVal = "nvidia.com/gpu=1" @@ -3310,7 +3546,7 @@ pvc: pvcAccessMode: ReadWriteOnce clusterScope: true - +$imageMirrorBlock clientId: "$TB_CLIENT_ID" clientPassword: '$passwordEscaped' @@ -3482,6 +3718,11 @@ function Print-Summary { $line = [string]([char]0x2501) * 46 Write-Host "" + # Central outcome log (#576 / Bugbot #579): record the classified final state + # for EVERY branch, so no summary case can silently miss the log after the + # Start-Transcript removal (connected / starting / bad_creds / image_pull_ca / + # image_pull / crash / other). + Log "Final client state: $script:ClientState" switch ($script:ClientState) { "connected" { Write-Host " $line" -ForegroundColor Green @@ -3519,14 +3760,14 @@ function Print-Summary { Hint "Re-running this installer is safe." } "bad_creds" { - Write-Host " " -NoNewline; Write-Host "$([char]0x2716) Couldn't connect - your Client ID or password was rejected." -ForegroundColor Red + Write-Host " " -NoNewline; Write-Host "$([char]0x2716) Couldn't connect - your Client ID or password was rejected." -ForegroundColor Red; Log "Couldn't connect - Client ID or password rejected by tracebloc." Write-Host "" Write-Host " The environment installed, but tracebloc refused those credentials." Write-Host " 1. Re-check them at https://ai.tracebloc.io/clients" -ForegroundColor Cyan Write-Host " 2. Re-run this installer (safe to re-run)" } "image_pull_ca" { - Write-Host " " -NoNewline; Write-Host "$([char]0x2716) Setup didn't finish - the cluster does not trust your network's TLS-inspection CA." -ForegroundColor Red + Write-Host " " -NoNewline; Write-Host "$([char]0x2716) Setup didn't finish - the cluster does not trust your network's TLS-inspection CA." -ForegroundColor Red; Log "Setup did not finish - cluster does not trust the network's TLS-inspection CA (in-cluster image pulls fail x509)." Write-Host "" Write-Host " Your network intercepts HTTPS (break-and-inspect), so the in-cluster image" Write-Host " pulls fail certificate validation (x509). CA trust is baked in at" @@ -3542,7 +3783,7 @@ function Print-Summary { $reason = "a component didn't start" if ($script:ClientState -eq "image_pull") { $reason = "an image couldn't be pulled" } if ($script:ClientState -eq "crash") { $reason = "a container is restarting (crash loop)" } - Write-Host " " -NoNewline; Write-Host "$([char]0x2716) Setup didn't finish - $reason." -ForegroundColor Red + Write-Host " " -NoNewline; Write-Host "$([char]0x2716) Setup didn't finish - $reason." -ForegroundColor Red; Log "Setup did not finish - $reason." Write-Host "" Write-Host " Inspect: " -NoNewline; Write-Host "kubectl get pods -n $ns" -ForegroundColor Green Write-Host " Logs: ~\.tracebloc\install-*.log" @@ -3576,7 +3817,7 @@ function Print-Summary { # ============================================================================= # Non-exiting failure line (Err exits; preflight must finish all checks first). -function Write-PfFail($m) { Write-Host " " -NoNewline; Write-Host ([char]0x2716) -ForegroundColor Red -NoNewline; Write-Host " $m" -ForegroundColor Red } +function Write-PfFail($m) { Write-Host " " -NoNewline; Write-Host ([char]0x2716) -ForegroundColor Red -NoNewline; Write-Host " $m" -ForegroundColor Red; Log "PREFLIGHT FAIL: $m" } # Probe a URL for reachability. Returns: ok|tls|dns|timeout|blocked (or "http " # under -RequireSuccess). By default any HTTP response (incl. 401/403/404) counts as @@ -3812,6 +4053,133 @@ function Get-PfVirtualization { } catch { return $null } } +# ── Network profile (#582) ─────────────────────────────────────────────────── +# A plain-language read of the network BEFORE the endpoint probes, so a user on a +# restricted/corporate network sees what's happening up front instead of a cryptic +# failure minutes in. Detects an explicit proxy, a configured corporate CA bundle, +# and (best-effort) TLS inspection. Never fatal, PII-free (proxy credentials are +# stripped and never printed/logged). One-to-one with preflight.sh's _pf_network_*. + +# Strip scheme:// and any user:pass@ credentials from a proxy URL; return bare +# host:port. Credentials must NEVER reach the screen or log (#576). +function Get-EnvProxyHostPort { + param([string]$Url) + $u = $Url + $u = $u -replace '^[a-zA-Z][a-zA-Z0-9+.-]*://', '' # drop scheme:// + $u = $u -replace '^[^@/]*@', '' # drop user:pass@ (PII) + $u = $u -replace '/.*$', '' # drop any /path + return $u +} + +# First explicit proxy from the environment as bare host:port (creds stripped), or +# $null when none is set. HTTPS takes precedence (our egress is all HTTPS). +function Get-EnvProxy { + foreach ($name in @('HTTPS_PROXY','https_proxy','HTTP_PROXY','http_proxy')) { + $val = [Environment]::GetEnvironmentVariable($name) + if ($val) { return (Get-EnvProxyHostPort $val) } + } + return $null +} + +# First explicit proxy from the environment VERBATIM (scheme + any user:pass intact), +# or $null. For the probe CONNECTION only — an authenticated proxy needs the +# credentials to answer the CONNECT, or it 407s and the inspection probe silently +# returns 'unknown' (Bugbot). NEVER print/log this; display uses Get-EnvProxy. +function Get-EnvProxyRaw { + foreach ($name in @('HTTPS_PROXY','https_proxy','HTTP_PROXY','http_proxy')) { + $val = [Environment]::GetEnvironmentVariable($name) + if ($val) { return $val } + } + return $null +} + +# Configured corporate CA bundle path when TRACEBLOC_CA_BUNDLE/CURL_CA_BUNDLE points +# at a readable file; $null otherwise. SOFT (never errors) — Resolve-CaBundle does +# the hard validation at cluster-create. +function Get-EnvCaBundle { + foreach ($name in @('TRACEBLOC_CA_BUNDLE','CURL_CA_BUNDLE')) { + $val = [Environment]::GetEnvironmentVariable($name) + if ($val -and (Test-Path -LiteralPath $val -PathType Leaf)) { return $val } + } + return $null +} + +# $true if an X.509 issuer string names a well-known PUBLIC CA (a normal direct +# chain); $false otherwise (a corporate re-signer — i.e. TLS inspection). +function Test-IssuerIsPublic { + param([string]$Issuer) + return ($Issuer -imatch "DigiCert|Sectigo|Comodo|Let'?s Encrypt|ISRG|Google Trust|GTS |GlobalSign|Amazon|Entrust|GeoTrust|Baltimore|USERTrust|Actalis|Buypass|SSL\.com|Certum|IdenTrust|Microsoft (Azure|RSA|ECC)") +} + +# Best-effort affirmative TLS-inspection probe. Returns 'yes'|'no'|'unknown'. Reads +# the issuer of the cert served for a well-known public host (through the proxy when +# one is set); a non-public issuer means a corporate CA is re-signing TLS. Bounded +# (8s) and non-throwing; 'unknown' on any error. +function Get-TlsInspectionState { + $prev = [System.Net.ServicePointManager]::ServerCertificateValidationCallback + $script:TbProbeIssuer = $null + try { + # Accept the cert for THIS probe only, capturing its issuer, so we can name the + # inspection even when the corporate CA isn't trusted here. + [System.Net.ServicePointManager]::ServerCertificateValidationCallback = { + param($theSender, $cert, $chain, $errors) + if ($cert) { $script:TbProbeIssuer = $cert.Issuer } + return $true + } + $req = [System.Net.HttpWebRequest]::Create("https://github.com/") + $req.Method = "HEAD" + $req.Timeout = 8000 + $req.AllowAutoRedirect = $false + # Connect THROUGH the proxy using the raw value: an authenticated proxy needs + # its credentials on the CONNECT or it 407s and issuer capture fails → a false + # 'unknown' on the exact TLS-inspecting networks this exists to detect (Bugbot). + # Credentials go to the WebProxy only; display still uses the stripped Get-EnvProxy. + $raw = Get-EnvProxyRaw + if ($raw) { + try { + # [System.Uri] rejects schemeless curl-style values (proxy.corp:8080); prepend a + # scheme so the probe matches the display path (which already strips schemeless + # URLs) -- otherwise corporate proxies get a false 'unknown' (Bugbot, client#589). + $rawUri = if ($raw -match '^[A-Za-z][A-Za-z0-9+.\-]*://') { $raw } else { "http://$raw" } + $u = [System.Uri]$rawUri + $wp = New-Object System.Net.WebProxy(("{0}://{1}:{2}" -f $u.Scheme, $u.Host, $u.Port)) + if ($u.UserInfo) { + $ui = $u.UserInfo.Split(":", 2) + $puser = [System.Uri]::UnescapeDataString($ui[0]) + $ppass = if ($ui.Count -gt 1) { [System.Uri]::UnescapeDataString($ui[1]) } else { "" } + $wp.Credentials = New-Object System.Net.NetworkCredential($puser, $ppass) + } + $req.Proxy = $wp + } catch { } + } + try { $resp = $req.GetResponse(); $resp.Close() } catch { } # issuer captured in the callback regardless + if (-not $script:TbProbeIssuer) { return "unknown" } + if (Test-IssuerIsPublic $script:TbProbeIssuer) { return "no" } else { return "yes" } + } catch { + return "unknown" + } finally { + [System.Net.ServicePointManager]::ServerCertificateValidationCallback = $prev + $script:TbProbeIssuer = $null + } +} + +# Print the plain-language network profile line (only when noteworthy — a plain +# direct connection stays silent; the reachability lines already confirm egress). +# Sets $script:NetProxy / $script:NetCa / $script:NetInspect for reuse. +function Show-NetworkProfile { + $script:NetProxy = Get-EnvProxy + $script:NetCa = Get-EnvCaBundle + $script:NetInspect = Get-TlsInspectionState + + if (-not $script:NetProxy -and $script:NetInspect -ne "yes") { return } + + $parts = @() + if ($script:NetProxy) { $parts += "corporate proxy detected ($script:NetProxy)" } + if ($script:NetInspect -eq "yes") { $parts += "TLS inspection detected" } + if ($script:NetCa) { $parts += "your company's certificate is configured" } + Info ("Network: " + ($parts -join "; ") + ".") +} + function Test-Preflight { if ($env:TRACEBLOC_SKIP_PREFLIGHT) { Info "Preflight checks skipped (TRACEBLOC_SKIP_PREFLIGHT set)."; return } @@ -3886,6 +4254,7 @@ function Test-Preflight { } else { Ok "Storage: $HOST_DATA_DIR local disk" } + Show-NetworkProfile # #582: announce the network profile before the probes Info "Checking outbound connectivity to required services..." $backendHost = (Get-BackendUrl) -replace '^https?://','' -replace '/$','' $criticals = @( @@ -3914,7 +4283,7 @@ function Test-Preflight { $criticals += @{ label = "k3d download (github.com)"; url = "https://github.com/" } $criticals += @{ label = "k3d assets (objects.githubusercontent.com)"; url = "https://objects.githubusercontent.com/" } } - $tlsSeen = $false; $cfail = 0 + $tlsSeen = $false; $cfail = 0; $regBlocked = $false foreach ($c in $criticals) { $status = Test-PfUrl $c.url -RequireSuccess:([bool]$c.strict) if ($status -ne "ok") { $status = Test-PfUrl $c.url -RequireSuccess:([bool]$c.strict) } # one retry for transient blips @@ -3923,6 +4292,8 @@ function Test-Preflight { Write-PfFail "$($c.label) unreachable ($status)" $hardFail++; $cfail++ if ($status -eq "tls") { $tlsSeen = $true } + # #585: was it a CONTAINER REGISTRY that's blocked (images can't be pulled)? + if ($c.url -match 'registry-1\.docker\.io|auth\.docker\.io|ghcr\.io') { $regBlocked = $true } } } if ($tlsSeen) { @@ -3930,6 +4301,9 @@ function Test-Preflight { Hint "Fix THESE host checks by importing the CA into the Windows certificate store (Cert:\LocalMachine\Root) - Invoke-WebRequest uses the system store, not an env var. The k3d nodes are trusted separately via `$env:TRACEBLOC_CA_BUNDLE='C:\path\to\corporate-ca.pem' (CURL_CA_BUNDLE also honored) at cluster-create. Ask IT for the bundle if unsure." } if ($cfail -gt 0){ Hint "Allow HTTPS (443) egress to the host(s) named above - the always-needed set is registry-1.docker.io, auth.docker.io, ghcr.io, $backendHost, tracebloc.github.io, plus any tool-download host listed (desktop.docker.com / dl.k8s.io / get.helm.sh / github.com / objects.githubusercontent.com) - or configure your corporate proxy." } + # #585: when the CONTAINER REGISTRIES themselves are blocked, images can't be pulled + # directly at all - surface the mirror / offline options in plain language. + if ($regBlocked) { Hint "The container registries (Docker Hub / GHCR) look blocked here, so the images can't be pulled directly. If your site runs a mirror you CAN reach, point the install at it; for a fully offline site, an air-gapped image bundle is the alternative. See the 'Blocked container registry' section of docs/INSTALL.md." } if ($hardFail -gt 0) { Write-Host "" @@ -4009,7 +4383,13 @@ function Test-PreflightRuntimeMem { function Edit-Redaction([string]$Path) { if (-not (Test-Path $Path)) { return } try { - $t = Get-Content -Path $Path -Raw -ErrorAction Stop + # -Encoding UTF8 so the read matches how these files were written. The curated + # install log is now UTF-8 WITHOUT a BOM (Start-InstallLog), and on PS 5.1 a + # bare Get-Content -Raw would decode a BOM-less file as ANSI and mojibake every + # non-ASCII host path/message in the -Diagnose bundle -- the exact corruption + # this change set out to fix (Bugbot, #591). UTF8 also reads the BOM'd Out-File + # outputs here correctly (the BOM is detected and stripped). + $t = Get-Content -Path $Path -Raw -Encoding UTF8 -ErrorAction Stop # First rule redacts ANY *password key (clientPassword, dockerRegistry # password, HTTP_PROXY_PASSWORD, ...) in : or = form, not just clientPassword. $t = $t -replace '(?i)([A-Za-z0-9_.-]*password\s*[:=]\s*).*', '$1[REDACTED]' @@ -4082,7 +4462,7 @@ function Invoke-DiagnoseBundle { Write-Host " $bundle" Hint "Send this file to tracebloc support -- it has logs + status with passwords removed." } else { - Write-Host " Could not create the diagnostics archive." -ForegroundColor Red + Write-Host " Could not create the diagnostics archive." -ForegroundColor Red; Log "Could not create the diagnostics archive." } } @@ -4199,9 +4579,23 @@ function Install-TraceblocCli { # ============================================================================= if (-not $env:TB_PESTER) { - -if ($Help) { Print-Help } -if ($Diagnose) { Invoke-DiagnoseBundle; exit 0 } +# Top-level error boundary (#577): any unhandled terminating error in the install +# run below is converted to a clean "Installation stopped" message + exit — never +# PowerShell's raw stack/source dump, and the session never just dies. Intentional +# `exit` calls (fast-path, Err, final) pass straight through; only real crashes are +# caught. $ErrorActionPreference is left as-is so existing non-terminating-error +# flows are unchanged — this catches the throw-based crashes that leaked/killed. +# The `finally` is the guaranteed closer (mirrors bash's install_cleanup): it fires +# on every exit, and shows the interrupted line only when no outcome was reported +# (Ctrl-C / abnormal termination). The `trap` is the last-resort net for anything +# that terminates OUTSIDE the try below (defined inside the guard so it never fires +# under the test dot-source). +$script:OutcomeReported = $false +trap { Show-FatalError $_; exit 1 } +try { + +if ($Help) { $script:OutcomeReported = $true; Print-Help } +if ($Diagnose) { Invoke-DiagnoseBundle; $script:OutcomeReported = $true; exit 0 } # flag AFTER the long collection: an interrupt mid-diagnose must still hit Show-Interrupted (Bugbot) Confirm-Config Initialize-ToolDir @@ -4226,10 +4620,17 @@ if ((-not $Resume) -and $script:InstallState.completed -and (Test-ToolsPresent) Test-K3sVersionDrift Hint "Delete $(Get-InstallStatePath) (or set a fresh HOST_DATA_DIR) to force a full reinstall." Unregister-ResumeAfterReboot - try { Stop-Transcript | Out-Null } catch {} + Log "Already installed and healthy - nothing to do." + $script:OutcomeReported = $true exit 0 } +# Trust an explicit corporate CA across every host tool (cosign/helm/git) BEFORE the +# preflight probes and any tool download, so a TLS-inspecting proxy is handled +# end-to-end (#583). Invoke-WebRequest already uses the Windows store; the k3d nodes +# are trusted at cluster-create (#424). +Set-ToolTrust + # -- Step 1/6: Check system requirements (honest split from tool install, #422) -- Step 1 $script:INSTALL_STEPS.Count "Checking system requirements" Test-Preflight @@ -4248,8 +4649,10 @@ Install-K3dAndHelm # -- Step 3/6: Set up secure compute environment -- Step 3 $script:INSTALL_STEPS.Count "Setting up secure compute environment" New-K3dCluster -Install-GpuDevicePlugin -Confirm-GpuNode +# Only verify the GPU on the node when the plugin actually deployed; a failed/ +# CPU-mode deploy returns $false, so skipping verify avoids a ~90s wait and a +# contradictory "still initializing" warning for a plugin never applied (Bugbot). +if (Install-GpuDevicePlugin) { Confirm-GpuNode } # -- Step 4/6: install the tracebloc CLI FIRST (#388) — it mints the machine # credential in Step 5; a CLI-install hiccup degrades Step 5 to the legacy @@ -4279,10 +4682,24 @@ Unregister-ResumeAfterReboot if (Test-InstallConnected) { Set-InstallComplete } else { Clear-InstallCompleted } Print-Summary +$script:OutcomeReported = $true # Print-Summary reported the outcome (guards the finally) -try { Stop-Transcript | Out-Null } catch {} +Log "Install finished." # Exit code reflects reality: connected/starting are OK; failures are non-zero. if (-not (Test-InstallSucceeded)) { exit 1 } +} catch { + # Any crash the run didn't handle itself lands here as a clean message, not a + # raw stack (#577). Show-FatalError sets $script:OutcomeReported. + Show-FatalError $_ + exit 1 +} finally { + # Guaranteed closer: this runs on EVERY exit above. Every reported path (normal + # finish, Err, caught crash, fast-path, help/diagnose) set OutcomeReported; if it + # is still false we were interrupted (Ctrl-C / abnormal), so surface a clean line + # rather than letting the window vanish silently (#577). + if (-not $script:OutcomeReported) { Show-Interrupted } +} + } # end TB_PESTER guard (skipped when the test suite dot-sources this file) diff --git a/scripts/install-k8s.sh b/scripts/install-k8s.sh index fdd8f2a7..4082163a 100755 --- a/scripts/install-k8s.sh +++ b/scripts/install-k8s.sh @@ -191,6 +191,14 @@ main() { print_roadmap + # Trust an explicit corporate CA across every host tool (cosign/helm/git/curl) + # BEFORE preflight's HTTPS probes and any tool download, so a TLS-inspecting proxy + # is handled end-to-end (#583). Guarded so a stale bootstrap without the helper + # proceeds as before (same pattern as the assess/early_data_dir gates). + if declare -F wire_ca_trust >/dev/null 2>&1; then + wire_ca_trust + fi + # ── a) Check your machine ──────────────────────────────────────────────── step_header a "Checking your machine" run_preflight @@ -229,8 +237,13 @@ main() { # ── c) Create your secure environment ──────────────────────────────────── step_header c "Creating your secure environment" create_cluster - deploy_gpu_device_plugin - verify_gpu + # Only verify the GPU on the node when the device plugin actually deployed; a + # failed/CPU-mode deploy returns non-zero, so skipping verify avoids a ~90s wait + # and a contradictory "still initializing" warning for a plugin never applied + # (Bugbot). The `if` also keeps a non-zero deploy from tripping set -e. + if deploy_gpu_device_plugin; then + verify_gpu + fi echo ""; echo "" # ── d) Register this machine ───────────────────────────────────────────── diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e7394428..511b6fd0 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -116,7 +116,7 @@ function Resolve-InstallRef { Warn "production box. A moved ref here can run arbitrary privileged code." Warn "============================================================================" } else { - throw "'$ref' is not an immutable release tag (expected vX.Y.Z). The bootstrap only trusts content-addressable release tags so a moved branch ref can't change what runs as Administrator on your box (RFC-0001 R8). Use a release tag, or for local dev set `$env:TRACEBLOC_ALLOW_UNVERIFIED = '1'`." + throw "'$ref' is not an immutable release tag (expected vX.Y.Z). The bootstrap only trusts content-addressable release tags so a moved branch ref can't change what runs as Administrator on your box. Use a release tag, or for local dev set `$env:TRACEBLOC_ALLOW_UNVERIFIED = '1'`." } } @@ -125,7 +125,7 @@ function Resolve-InstallRef { # A '/' or '..' here is a path-traversal lever (it could escape the pinned tag # onto a mutable branch) — independent of which branch above let it through. if ($ref -match '/' -or $ref -match '\.\.') { - throw "Ref '$ref' contains a path separator or '..' -- refusing to build a fetch URL from it (path-traversal guard, RFC-0001 R8)." + throw "Ref '$ref' contains a path separator or '..' -- refusing to build a fetch URL from it (path-traversal guard)." } return $ref @@ -308,6 +308,30 @@ function Resolve-Cosign { return $bin } +# Run cosign verify-blob with the fail-closed sentinel + stderr suppression, returning +# $true iff it verified. Shared by Confirm-ManifestSignature's offline-bundle and online +# sig/cert paths so the hardening lives in ONE place: +# - $LASTEXITCODE is seeded to a NONZERO sentinel first, so a cosign that returns +# WITHOUT setting it (corrupt / AV-quarantined / wrong exec format) fails closed, +# never a stale 0 read as "verified". +# - stderr is merged to stdout and discarded: a native tool writing to stderr would +# otherwise surface as a NativeCommandError dumping this script's source line + +# internal identifiers into the console/transcript (#576). +function Invoke-CosignVerifyBlob { + param([Parameter(Mandatory)][string]$Cosign, [Parameter(Mandatory)][string[]]$VerifyArgs) + $global:LASTEXITCODE = 255 + $prevEAP = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + & $Cosign @VerifyArgs 2>&1 | Out-Null + } catch { + return $false + } finally { + $ErrorActionPreference = $prevEAP + } + return ($LASTEXITCODE -eq 0) +} + # Authenticate manifest.sha256 with cosign keyless before trusting a single digest # in it. The signing identity is the client release workflow's OIDC certificate # (same chain as install.sh + the CLI binary). Fail-closed unless the operator @@ -319,14 +343,58 @@ function Confirm-ManifestSignature { [string]$TmpDir, [bool]$AllowUnverified ) + # An explicit corporate CA can't be wired into cosign via env on Windows: cosign is + # Go, and Go on Windows reads the certificate store and IGNORES SSL_CERT_FILE + # (Bugbot). So there's nothing to export here — the CA must live in the Windows + # store (Cert:\LocalMachine\Root), or use the offline installer path (#584). + # We still validate the path so a typo fails fast with a clear message rather than + # a later generic cosign authenticity error. + $ca = if ($env:TRACEBLOC_CA_BUNDLE) { $env:TRACEBLOC_CA_BUNDLE } elseif ($env:CURL_CA_BUNDLE) { $env:CURL_CA_BUNDLE } else { $null } + if ($ca) { + if (-not (Test-Path -LiteralPath $ca -PathType Leaf)) { + throw "A CA bundle is set (TRACEBLOC_CA_BUNDLE/CURL_CA_BUNDLE) but no such file exists at '$ca' - fix its path and re-run." + } + # Existence isn't enough: a present-but-unreadable file must fail here too (mirrors + # bash's -r and Resolve-CaBundle), not as a later generic cosign error (Bugbot). + try { [System.IO.File]::OpenRead($ca).Dispose() } + catch { throw "A CA bundle is set (TRACEBLOC_CA_BUNDLE/CURL_CA_BUNDLE) but '$ca' can't be read ($($_.Exception.Message)) - fix its permissions and re-run." } + } + $cosign = Resolve-Cosign -TmpDir $TmpDir if (-not $cosign) { if ($AllowUnverified) { - Warn "cosign unavailable -- manifest signature NOT verified (TRACEBLOC_ALLOW_UNVERIFIED=1)." + Warn "cosign unavailable -- the installer's signature NOT verified (TRACEBLOC_ALLOW_UNVERIFIED=1)." Warn "Proceeding on checksum-only integrity. Not for production." return } - throw "cosign is required to verify the installer's signed manifest and couldn't be found or bootstrapped. Refusing to fall back to an unauthenticated, same-channel checksum (RFC-0001 R8). Fix: install cosign (https://docs.sigstore.dev/cosign/installation/) and re-run, or for local development only set `$env:TRACEBLOC_ALLOW_UNVERIFIED = '1'`." + throw "cosign is required to verify the installer's signature and couldn't be found or bootstrapped. Refusing to fall back to an unauthenticated, same-channel checksum. Fix: install cosign (https://docs.sigstore.dev/cosign/installation/) and re-run, or for local development only set `$env:TRACEBLOC_ALLOW_UNVERIFIED = '1'`." + } + + # The keyless signing identity: the release workflow's OIDC cert. SAME pins as + # install.sh; shared by both verification paths below. + $idRe = 'https://github.com/tracebloc/client/\.github/workflows/.*@.*' + $issuer = 'https://token.actions.githubusercontent.com' + + # OFFLINE Sigstore bundle first (#584): the bundle carries the Rekor inclusion + # proof, so this verifies signature + cert identity + tlog inclusion with NO live + # Rekor call — immune to networks that block/TLS-inspect sigstore, and the only + # path that verifies our short-lived keyless cert once it has expired (its embedded + # timestamp proves the cert was valid at signing). Releases cut before the bundle + # existed 404 here and fall through to the online .sig/.cert path; so does a bundle + # that doesn't verify — the online path does the SAME full keyless check, just + # needing live Rekor, so this is a fallback, never a downgrade. + $bundle = Join-Path $TmpDir "manifest.sha256.bundle" + if (Get-Optional "$RepoRel/manifest.sha256.bundle" $bundle) { + if (Invoke-CosignVerifyBlob $cosign @( + 'verify-blob', + '--bundle', $bundle, + '--certificate-identity-regexp', $idRe, + '--certificate-oidc-issuer', $issuer, + '--offline', + $Manifest)) { + Ok "Download verified as published by tracebloc." + return + } } $sig = Join-Path $TmpDir "manifest.sha256.sig" @@ -334,37 +402,23 @@ function Confirm-ManifestSignature { if (-not (Get-Optional "$RepoRel/manifest.sha256.sig" $sig) -or -not (Get-Optional "$RepoRel/manifest.sha256.cert" $cert)) { if ($AllowUnverified) { - Warn "manifest signature/cert not published for this ref -- not verified (TRACEBLOC_ALLOW_UNVERIFIED=1)." + Warn "The installer's signature isn't published for this ref -- not verified (TRACEBLOC_ALLOW_UNVERIFIED=1)." return } - throw "manifest.sha256.sig / .cert not published for this release -- can't authenticate the manifest. Pin a release tag that ships them (RFC-0001 R8)." + throw "The installer's signature isn't published for this release -- can't confirm the download is authentic. Pin a release tag that ships it." } - # The identity is the client release workflow (release-helm-chart.yaml) — the - # keyless signer that produced the manifest. SAME pins as install.sh. - $cosignArgs = @( - 'verify-blob', - '--certificate-identity-regexp', 'https://github.com/tracebloc/client/\.github/workflows/.*@.*', - '--certificate-oidc-issuer', 'https://token.actions.githubusercontent.com', - '--certificate', $cert, - '--signature', $sig, - $Manifest - ) - # Reset to a NONZERO sentinel first: a cosign that exists but can't launch - # (corrupt, AV-quarantined, wrong exec format) can return WITHOUT setting - # $LASTEXITCODE, leaving a stale prior value — a stale 0 would read as "verified" - # (fail-open). The sentinel + the catch below make BOTH the won't-launch and the - # returns-nonzero cases fail closed (parity with install.sh's `if cosign; else`). - $global:LASTEXITCODE = 255 - try { - & $cosign @cosignArgs 2>$null 1>$null - } catch { - throw "cosign could not be executed to verify manifest.sha256 -- refusing to install (RFC-0001 R8): $_" - } - if ($LASTEXITCODE -ne 0) { - throw "cosign signature verification FAILED for manifest.sha256 -- refusing to install (RFC-0001 R8)." + if (Invoke-CosignVerifyBlob $cosign @( + 'verify-blob', + '--certificate-identity-regexp', $idRe, + '--certificate-oidc-issuer', $issuer, + '--certificate', $cert, + '--signature', $sig, + $Manifest)) { + Ok "Download verified as published by tracebloc." + } else { + throw "Couldn't confirm the installer download is authentic, so the install stopped before changing anything on your machine." } - Ok "manifest signature verified (cosign keyless)" } # Verify each fetched sub-script against the signed manifest. A missing manifest @@ -380,11 +434,11 @@ function Confirm-ScriptIntegrity { $local = Join-Path $TmpDir ($f -replace '^scripts/', '') $expected = Find-ManifestDigest -ManifestPath $Manifest -Key $rel if (-not $expected) { - throw "$rel has no entry in manifest.sha256 -- refusing to run it (RFC-0001 R8)." + throw "$rel isn't in the installer's signed checksum list -- refusing to run it." } $actual = Get-Sha256 -Path $local if ($actual -ne $expected) { - throw "Integrity check FAILED for $rel`n expected: $expected`n actual: $actual`n Someone may have tampered with the installer. Aborting before any privileged step runs (RFC-0001 R8)." + throw "Integrity check FAILED for $rel`n expected: $expected`n actual: $actual`n Someone may have tampered with the installer. Aborting before any privileged step runs." } } Ok "all installer scripts verified against the signed manifest" @@ -411,11 +465,11 @@ function Invoke-Bootstrap { # before the integrity check (parity with install.sh's `mktemp -d`, 0700). $tmpDir = Join-Path $env:TEMP ("tracebloc-installer-" + [guid]::NewGuid().ToString('N')) if (Test-Path -LiteralPath $tmpDir) { - throw "temp dir $tmpDir already exists -- refusing to reuse it (RFC-0001 R8)." + throw "temp dir $tmpDir already exists -- refusing to reuse it." } New-Item -ItemType Directory -Path $tmpDir | Out-Null try { - Info "Downloading Tracebloc client installer (ref: $ref)..." + Info "Downloading tracebloc client installer (ref: $ref)..." # ── Fetch the sub-scripts from the immutable tag tree ── foreach ($f in $Files) { @@ -427,12 +481,12 @@ function Invoke-Bootstrap { $manifest = Join-Path $tmpDir "manifest.sha256" if (-not (Get-Optional "$repoRel/manifest.sha256" $manifest)) { if ($allowUnverified -and (Get-Optional "$repoRaw/scripts/manifest.sha256" $manifest)) { - Warn "Using in-repo manifest.sha256 from ref '$ref' (TRACEBLOC_ALLOW_UNVERIFIED=1)." + Warn "Using in-repo integrity checksums from ref '$ref' (TRACEBLOC_ALLOW_UNVERIFIED=1)." } elseif ($allowUnverified) { - Warn "No manifest.sha256 for ref '$ref' -- skipping integrity check (TRACEBLOC_ALLOW_UNVERIFIED=1)." + Warn "No integrity checksums for ref '$ref' -- skipping the integrity check (TRACEBLOC_ALLOW_UNVERIFIED=1)." $manifest = $null } else { - throw "Couldn't fetch manifest.sha256 for ref '$ref' -- refusing to run unverified installer scripts (RFC-0001 R8). If this ref pre-dates signed manifests, pin a newer release tag." + throw "Couldn't fetch the installer's integrity checksums for ref '$ref' -- refusing to run unverified installer scripts. If this ref pre-dates signed releases, pin a newer release tag." } } @@ -443,7 +497,7 @@ function Invoke-Bootstrap { # ── Run the verified main installer ── $k8s = Join-Path $tmpDir "install-k8s.ps1" - Info "Running Tracebloc environment setup..." + Info "Running tracebloc environment setup..." if ($ChildArgs -and $ChildArgs.Count -gt 0) { & powershell.exe -ExecutionPolicy Bypass -File $k8s @ChildArgs } else { @@ -474,7 +528,11 @@ if (-not $env:TB_PESTER) { try { Invoke-Bootstrap -ChildArgs $args } catch { - Err "$_" + # Clean, branded failure — never a raw stack (#577). "$_" stringifies to the + # exception MESSAGE (curated at the throw sites, #576), not the source/stack. + Write-Host "" + Err "Installation stopped: $_" + Write-Host " It's safe to re-run this installer. If it keeps failing, share the output above with tracebloc support." -ForegroundColor DarkGray exit 1 } } diff --git a/scripts/install.sh b/scripts/install.sh index 4e533bc1..a3dd3c9c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -330,9 +330,40 @@ _sha256_of() { # A missing manifest, a missing line, or a digest mismatch ABORTS — before any # privileged sub-script (provision.sh mints+writes the credential; install- # client-helm.sh runs Helm) is executed. +# Wire an explicitly-provided corporate CA into cosign so a TLS-inspecting proxy that +# re-signs HTTPS doesn't fail the signature check with an x509 error (#583). cosign is +# Go: it reads SSL_CERT_FILE on LINUX, but on macOS Go uses the system Keychain and +# IGNORES SSL_CERT_FILE (Bugbot) — so on macOS the CA must live in the Keychain, or +# use the offline installer path (#584). curl ALREADY honors the user's own +# CURL_CA_BUNDLE natively, and we must NOT re-export it from a corp-root-only +# TRACEBLOC_CA_BUNDLE (CURL_CA_BUNDLE is replace-not-augment). Fail fast on a +# set-but-unreadable bundle rather than a later generic cosign error. +_bootstrap_wire_ca() { + local var ca + for var in TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE; do + ca="${!var:-}"; [[ -z "$ca" ]] && continue + if [[ ! -f "$ca" || ! -r "$ca" ]]; then + echo "[ERROR] $var is set to '$ca' but that CA bundle file can't be read —" >&2 + echo " fix its path/permissions and re-run." >&2 + exit 1 + fi + # Don't clobber a fuller pre-set SSL_CERT_FILE (replace-not-augment): only set it + # when the user hasn't already (Bugbot). Effective for cosign on Linux (note above). + # NOT on macOS: Go reads the Keychain there, so the export helps cosign not at all, + # while OpenSSL curl honors SSL_CERT_FILE replace-not-augment — a corp-root-only + # bundle would shrink download trust for zero gain (Bugbot). The readability + # fail-fast above still runs on every platform. + [[ "$(uname -s)" != "Darwin" && -z "${SSL_CERT_FILE:-}" ]] && export SSL_CERT_FILE="$ca" + return 0 + done +} + verify_against_manifest() { local manifest="$TMPDIR/manifest.sha256" + # Trust an explicit corporate CA before cosign's HTTPS calls (#583). + _bootstrap_wire_ca + printf " %sVerifying it's authentic (cosign)…%s\n" "$_D" "$_R" if ! _sha256_of "$TMPDIR/install-k8s.sh" >/dev/null 2>&1; then @@ -344,12 +375,12 @@ verify_against_manifest() { if ! download_manifest "$manifest"; then if [[ "$ALLOW_UNVERIFIED" == "1" ]]; then - echo "[WARN] No manifest.sha256 at ref '$REF' — skipping integrity check (TRACEBLOC_ALLOW_UNVERIFIED=1)." >&2 + echo "[WARN] No integrity checksums published at ref '$REF' — skipping the integrity check (TRACEBLOC_ALLOW_UNVERIFIED=1)." >&2 return 0 fi - echo "[ERROR] Couldn't fetch manifest.sha256 for ref '$REF' — refusing to run" >&2 + echo "[ERROR] Couldn't fetch the installer's integrity checksums for ref '$REF' — refusing to run" >&2 echo " unverified installer scripts. If this ref pre-dates" >&2 - echo " signed manifests, pin a newer release tag." >&2 + echo " signed releases, pin a newer release tag." >&2 exit 1 fi @@ -364,7 +395,7 @@ verify_against_manifest() { # many spaces the sha tool emits); take its first field as the digest. expected="$(awk -v p="$rel" '$NF == p {print $1; exit}' "$manifest")" if [[ -z "$expected" ]]; then - echo "[ERROR] $rel has no entry in manifest.sha256 — refusing to run it." >&2 + echo "[ERROR] $rel isn't in the installer's signed checksum list — refusing to run it." >&2 exit 1 fi actual="$(_sha256_of "$TMPDIR/${f#scripts/}")" @@ -404,14 +435,19 @@ verify_manifest_signature() { local manifest="$1" local sig="$TMPDIR/manifest.sha256.sig" local cert="$TMPDIR/manifest.sha256.cert" + local bundle="$TMPDIR/manifest.sha256.bundle" + # The keyless signing identity: the release workflow's OIDC cert. Shared by both + # the offline-bundle and the online sig/cert verification paths below. + local id_re='https://github.com/tracebloc/client/\.github/workflows/.*@.*' + local issuer='https://token.actions.githubusercontent.com' if ! ensure_cosign; then if [[ "$ALLOW_UNVERIFIED" == "1" ]]; then - echo "[WARN] cosign unavailable — manifest signature NOT verified (TRACEBLOC_ALLOW_UNVERIFIED=1)." >&2 + echo "[WARN] cosign unavailable — the installer's signature NOT verified (TRACEBLOC_ALLOW_UNVERIFIED=1)." >&2 echo "[WARN] Proceeding on checksum-only integrity. Not for production." >&2 return 0 fi - echo "[ERROR] cosign is required to verify the installer's signed manifest and" >&2 + echo "[ERROR] cosign is required to verify the installer's signature and" >&2 echo " couldn't be found or bootstrapped. Refusing to fall back to an" >&2 echo " unauthenticated, same-channel checksum." >&2 echo " Fix: install cosign (https://docs.sigstore.dev/cosign/installation/)" >&2 @@ -419,28 +455,48 @@ verify_manifest_signature() { exit 1 fi + # OFFLINE Sigstore bundle first (#584): the bundle carries the Rekor inclusion + # proof, so this verifies signature + cert identity + tlog inclusion with NO live + # Rekor call — immune to networks that block/TLS-inspect sigstore, and the ONLY + # path that verifies our short-lived keyless cert once it has expired (its embedded + # timestamp proves the cert was valid at signing). Releases cut before the bundle + # existed simply 404 here and fall through to the online .sig/.cert path below; + # so does any bundle that doesn't verify — the online path does the SAME full + # keyless check, just needing live Rekor, so this is a fallback, never a downgrade. + if curl -fsSL --tlsv1.2 --connect-timeout 30 --max-time 300 "$REPO_REL/manifest.sha256.bundle" -o "$bundle" 2>/dev/null; then + if "$COSIGN_BIN" verify-blob \ + --bundle "$bundle" \ + --certificate-identity-regexp "$id_re" \ + --certificate-oidc-issuer "$issuer" \ + --offline \ + "$manifest" >/dev/null 2>&1; then + printf ' %s✔%s Download verified as published by tracebloc\n' "$_G" "$_R" + return 0 + fi + fi + if ! curl -fsSL --tlsv1.2 --connect-timeout 30 --max-time 300 "$REPO_REL/manifest.sha256.sig" -o "$sig" 2>/dev/null \ || ! curl -fsSL --tlsv1.2 --connect-timeout 30 --max-time 300 "$REPO_REL/manifest.sha256.cert" -o "$cert" 2>/dev/null; then if [[ "$ALLOW_UNVERIFIED" == "1" ]]; then - echo "[WARN] manifest signature/cert not published for ref '$REF' — not verified (TRACEBLOC_ALLOW_UNVERIFIED=1)." >&2 + echo "[WARN] The installer's signature isn't published for ref '$REF' — not verified (TRACEBLOC_ALLOW_UNVERIFIED=1)." >&2 return 0 fi - echo "[ERROR] manifest.sha256.sig / .cert not published for release '$REF' — can't" >&2 - echo " authenticate the manifest. Pin a release tag that ships them." >&2 + echo "[ERROR] The installer's signature isn't published for release '$REF' — can't" >&2 + echo " confirm the download is authentic. Pin a release tag that ships it." >&2 exit 1 fi if "$COSIGN_BIN" verify-blob \ - --certificate-identity-regexp \ - 'https://github.com/tracebloc/client/\.github/workflows/.*@.*' \ - --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ + --certificate-identity-regexp "$id_re" \ + --certificate-oidc-issuer "$issuer" \ --certificate "$cert" \ --signature "$sig" \ "$manifest" >/dev/null 2>&1; then - printf ' %s✔%s Signature verified — published by tracebloc (Sigstore keyless)\n' "$_G" "$_R" + printf ' %s✔%s Download verified as published by tracebloc\n' "$_G" "$_R" else - echo "[ERROR] cosign signature verification FAILED for manifest.sha256 — refusing" >&2 - echo " to install." >&2 + # Plain language, no internal identifiers — parity with install.ps1 (#576). + echo "[ERROR] Couldn't confirm the installer download is authentic, so the install" >&2 + echo " stopped before changing anything on your machine." >&2 exit 1 fi } diff --git a/scripts/lib/cluster.sh b/scripts/lib/cluster.sh index 0f78da51..6b0d3e34 100755 --- a/scripts/lib/cluster.sh +++ b/scripts/lib/cluster.sh @@ -142,6 +142,58 @@ _resolve_ca_bundle() { return 0 } +# Wire the resolved corporate CA into the HOST tools that do NOT honor a CA env on +# their own (#583). git (OpenSSL-backed) honors GIT_SSL_CAINFO on Linux + macOS. The +# Go tools (cosign/helm) read SSL_CERT_FILE on LINUX only — on macOS Go uses the +# system Keychain and IGNORES SSL_CERT_FILE (Bugbot), so there the CA must live in the +# Keychain (or use the offline path, #584). curl ALREADY honors the user's own +# CURL_CA_BUNDLE natively, so we do NOT re-export it (it's replace-not-augment, and a +# corp-root-only bundle would drop the public roots). The k3d NODES are trusted +# separately at cluster-create (#424). Idempotent; no-op when no CA is configured; +# fails fast on a set-but-unreadable bundle. The announce names only what actually +# takes effect on this platform. +wire_ca_trust() { + local ca rc=0 + ca="$(_resolve_ca_bundle)" || rc=$? + if [[ "$rc" -eq 2 ]]; then + error "$ca is set but its CA bundle file can't be read — fix its path/permissions and re-run." + fi + [[ -z "$ca" ]] && return 0 + # On macOS, wire NOTHING (same decision as Windows, and for the same reason): + # Go reads the Keychain, not SSL_CERT_FILE, so exporting it helps neither + # cosign nor helm — while OpenSSL-backed curl DOES honor it, replace-not- + # augment, so a corp-root-only bundle would shrink download trust for zero + # gain (Bugbot). And Apple's system git (SecureTransport) ignores + # GIT_SSL_CAINFO, so claiming git trust from it was false — the clone that + # matters most, Homebrew's own bootstrap, runs system git (Bugbot). + if [[ "$OS" == "Darwin" ]]; then + hint "On macOS, git, cosign and helm read the system Keychain, not a PEM file — add your company's CA to the login Keychain (or use the offline installer) so they trust the proxy." + return 0 + fi + # Only set trust vars the user hasn't already set: SSL_CERT_FILE and GIT_SSL_CAINFO + # are replace-not-augment (Go / OpenSSL), so overwriting a fuller pre-set bundle with + # a corp-root-only one would drop the public roots those tools need elsewhere (Bugbot). + # + # And SAY only what actually happened: a green "Trusting…" while every export was + # skipped reported wiring that did not happen — masking a pre-set bundle that may + # still lack the corporate CA (Bugbot). curl "downloads" trust the user's own + # CURL_CA_BUNDLE, which we deliberately don't touch, so it is never claimed here. + local wired="" kept="" + if [[ -z "${SSL_CERT_FILE:-}" ]]; then + export SSL_CERT_FILE="$ca"; wired="cosign, helm" + else + kept="SSL_CERT_FILE (cosign/helm)" + fi + if [[ -z "${GIT_SSL_CAINFO:-}" ]]; then + export GIT_SSL_CAINFO="$ca"; wired="${wired:+$wired and }git" + else + kept="${kept:+$kept and }GIT_SSL_CAINFO (git)" + fi + [[ -n "$wired" ]] && success "Trusting your company's certificate for $wired." + [[ -n "$kept" ]] && hint "Keeping your pre-set $kept — make sure that bundle includes your company's CA, or those tools will still fail x509." + return 0 +} + # Write a k3d registries.yaml pointing containerd at the mounted CA for every # registry in TB_CA_REGISTRIES, and echo its path. $1 = the CA path INSIDE the # node (where the -v mount lands). Caller removes the temp dir. @@ -565,7 +617,15 @@ _handle_existing_cluster() { success "Secure environment already running." else log "Cluster '$CLUSTER_NAME' exists but is stopped — starting it..." - k3d cluster start "$CLUSTER_NAME" + # Capture the tool's raw stderr to the log and surface only a curated line on + # failure — graceful failure, not a raw k3d dump before the closer (#577). + # Bounded start (Bugbot): `k3d cluster start` waits for the server with no + # deadline by default, so behind the log redirect a wedged Docker would hang a + # headless install forever instead of reaching the curated error below. --wait + # --timeout bounds it (parity with the Windows installer's 5-minute start + # deadline) so a stuck start fails cleanly into that message. + k3d cluster start "$CLUSTER_NAME" --wait --timeout 5m >> "${LOG_FILE:-/dev/null}" 2>&1 \ + || error "Couldn't start your existing secure environment. Check Docker is running, then re-run." success "Secure environment started." fi diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 5db0d207..52f6d312 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -87,6 +87,29 @@ _verify_sha256() { esac } +# Fail-fast when a just-downloaded FILE is shorter than a real tool binary can be +# (#607). On a filtered network a proxy or antivirus can return a truncated stream +# or a small error page under HTTP 200 — `curl -f` can't see it (it's not an HTTP +# error), and the only downstream signal was _verify_sha256, which misreports a +# blocked TRANSFER as "checksum verification failed" (tampering). Catching the short +# payload here gives the user the real, actionable reason. `wc -c` is the portable +# size read (GNU `stat -c%s` vs BSD `stat -f%z` differ). FAIL-CLOSED: a missing or +# unreadable file reads as 0 bytes and fails. +_assert_download_size() { + # TB_MIN_DOWNLOAD_BYTES overrides the floor (set to 0 by the bats fetch tests, + # whose curl mocks write tiny fixture files); unset in production, so the real + # per-tool floor passed as $2 applies. $4 (optional) is the caller's mktemp -d + # tree to remove before erroring, so a truncated transfer cleans up its partial + # payload exactly like the checksum-mismatch branches do (Bugbot). + local file="$1" min="${TB_MIN_DOWNLOAD_BYTES:-$2}" label="$3" cleanup="${4:-}" size=0 + [ -f "$file" ] && size="$(wc -c < "$file" 2>/dev/null | tr -d '[:space:]')" + [ -n "$size" ] || size=0 + if [ "$size" -lt "$min" ]; then + [ -n "$cleanup" ] && rm -rf "$cleanup" + error "Download of ${label} was truncated or blocked — got ${size} bytes (expected at least ${min}). On a filtered network a proxy or antivirus may be cutting the transfer; allowlist the download host (github.com / objects.githubusercontent.com / dl.k8s.io / get.helm.sh) or exclude the tools directory from AV scanning, then re-run." + fi +} + # ── Colours ────────────────────────────────────────────────────────────────── # One brand-grounded palette (design-system tokens): cyan #01a5cc = structure, # lime #91e947 = action — mirrors the Go CLI's internal/ui engine. Each tone @@ -781,7 +804,7 @@ validate_config() { local ddir="$HOST_DATASET_DIR" rddir [[ "$ddir" == /* ]] || error "HOST_DATASET_DIR must be an absolute path (got '$HOST_DATASET_DIR')" [[ -d "$ddir" ]] || error "HOST_DATASET_DIR does not exist: $ddir (mount the dataset volume before installing)" - [[ -w "$ddir" ]] || error "HOST_DATASET_DIR is not writable by $(id -un) (uid $(id -u)): $ddir" + [[ -w "$ddir" ]] || error "HOST_DATASET_DIR is not writable (uid $(id -u)): $ddir — check its permissions." rddir="$(cd -P "$ddir" 2>/dev/null && pwd)" || error "HOST_DATASET_DIR could not be resolved: $ddir" case "$rddir" in /) error "HOST_DATASET_DIR cannot be root (/)" ;; diff --git a/scripts/lib/gpu-plugins.sh b/scripts/lib/gpu-plugins.sh index cbdecb90..33797f38 100755 --- a/scripts/lib/gpu-plugins.sh +++ b/scripts/lib/gpu-plugins.sh @@ -25,39 +25,77 @@ _apply_remote_manifest() { trap "rm -f '$tmp_yml'" RETURN retry 3 5 curl_secure -fsSL "$url" -o "$tmp_yml" || { rm -f "$tmp_yml"; return 1; } [[ -s "$tmp_yml" ]] || { warn "Downloaded $label manifest is empty"; rm -f "$tmp_yml"; return 1; } - kubectl apply -f "$tmp_yml" + # Raw stderr to the log; the caller surfaces a curated line on failure (#577). + # --request-timeout bounds the API call so a wedged API server fails into that + # curated warn instead of hanging silently behind the log redirect (Bugbot); + # mirrors the node-probe's --request-timeout below. + kubectl apply -f "$tmp_yml" --request-timeout=30s >> "${LOG_FILE:-/dev/null}" 2>&1 +} + +# Wait for a just-applied device-plugin daemonset to become Ready and gate the +# success message on it: success + return 0 when it rolls out, else warn + continue +# in CPU mode + return 1. Shared by the nvidia and amd paths (including the amd +# master fallback) so "no false enabled, no dead ~90s verify wait" behaves +# identically everywhere (reviewer). Output goes to the log; the caller surfaces +# only the curated line. +_gpu_rollout_gate() { + local ds="$1" + if kubectl rollout status "daemonset/$ds" -n kube-system --timeout=120s \ + >> "${LOG_FILE:-/dev/null}" 2>&1; then + success "GPU acceleration enabled." + return 0 + fi + warn "Couldn't confirm GPU acceleration is ready — continuing in CPU mode. Re-run the installer later to retry." + return 1 } _deploy_nvidia_plugin() { log "Deploying NVIDIA k8s device plugin" - if kubectl get daemonset -n kube-system nvidia-device-plugin-daemonset &>/dev/null 2>&1; then + # --request-timeout bounds the existence probe so a wedged API server can't hang + # here before the bounded apply is reached (reviewer; parity with verify_gpu). + if kubectl get daemonset -n kube-system nvidia-device-plugin-daemonset --request-timeout=5s &>/dev/null 2>&1; then success "GPU acceleration enabled." - return + return 0 fi log "Downloading and applying NVIDIA device plugin DaemonSet..." - _apply_remote_manifest "$NVIDIA_DEVICE_PLUGIN_URL" "NVIDIA device plugin" || error "Failed to enable GPU acceleration." - kubectl rollout status daemonset/nvidia-device-plugin-daemonset \ - -n kube-system --timeout=120s \ - || warn "GPU setup still in progress — it may take a moment to finish." - success "GPU acceleration enabled." + # GPU is OPTIONAL: a plugin download/apply failure must NOT abort the install + # (#577 fatal-vs-recoverable). Warn and continue in CPU mode — same spirit as the + # NVIDIA-container-toolkit timeout, which already warns and carries on. Return + # non-zero on every CPU-mode path so the caller skips the GPU verify wait for a + # plugin that was never deployed (Bugbot). + if ! _apply_remote_manifest "$NVIDIA_DEVICE_PLUGIN_URL" "NVIDIA device plugin"; then + warn "Couldn't enable GPU acceleration — continuing in CPU mode. Re-run the installer later to retry." + return 1 + fi + _gpu_rollout_gate nvidia-device-plugin-daemonset } _deploy_amd_plugin() { log "Deploying AMD GPU k8s device plugin" - if kubectl get daemonset -n kube-system amdgpu-device-plugin &>/dev/null 2>&1; then + # --request-timeout bounds the existence probe (reviewer; parity with verify_gpu). + if kubectl get daemonset -n kube-system amdgpu-device-plugin --request-timeout=5s &>/dev/null 2>&1; then success "GPU acceleration enabled." - return + return 0 fi + # Return non-zero on every CPU-mode path so the caller skips the GPU verify wait + # for a plugin that was never deployed (Bugbot). log "Downloading and applying AMD GPU device plugin DaemonSet..." if _apply_remote_manifest "$AMD_DEVICE_PLUGIN_URL" "AMD device plugin"; then - kubectl rollout status daemonset/amdgpu-device-plugin -n kube-system --timeout=120s 2>/dev/null || true - success "GPU acceleration enabled." - else - log "Pinned AMD plugin ${AMD_DEVICE_PLUGIN_VERSION} failed; trying master..." - _apply_remote_manifest "https://raw.githubusercontent.com/RadeonOpenCompute/k8s-device-plugin/master/k8s-ds-amdgpu-dp.yaml" "AMD device plugin (master)" || warn "GPU acceleration setup may need manual attention." + _gpu_rollout_gate amdgpu-device-plugin + return $? + fi + log "Pinned AMD plugin ${AMD_DEVICE_PLUGIN_VERSION} failed; trying master..." + # Master fallback: gate on rollout too (reviewer) — a master apply that never + # rolls out must warn + continue in CPU mode, not return success and make the + # caller's verify poll ~90s before a vaguer "still initializing". + if _apply_remote_manifest "https://raw.githubusercontent.com/RadeonOpenCompute/k8s-device-plugin/master/k8s-ds-amdgpu-dp.yaml" "AMD device plugin (master)"; then + _gpu_rollout_gate amdgpu-device-plugin + return $? fi + warn "GPU acceleration setup may need manual attention — continuing in CPU mode." + return 1 } # ── Node-level GPU verification ───────────────────────────────────────────── diff --git a/scripts/lib/install-client-helm.sh b/scripts/lib/install-client-helm.sh index 05573947..df7417cb 100644 --- a/scripts/lib/install-client-helm.sh +++ b/scripts/lib/install-client-helm.sh @@ -152,10 +152,30 @@ _yaml_sq_unescape() { # body of a '...' scalar -> raw value printf '%s' "${1//$_sq$_sq/$_sq}" } +# _extract_yaml_value — value of top-level scalar key $2 in values file $1. +# CONTRACT: echoes nothing and returns 0 when the key is absent (or the file is +# unreadable). Callers rely on "empty means no value"; they must not have to +# distinguish absent-key from read-error, and none of them do. _extract_yaml_value() { local file="$1" key="$2" local line - line=$(grep -E "^${key}:" "$file" 2>/dev/null | head -1) + # `|| line=""`: on an ABSENT key grep exits 1 and, under `set -o pipefail`, + # that rc propagates out of the pipeline and out of the assignment — so under + # `set -e` the installer would abort HERE and never reach the empty-check on + # the next line, the very line that exists to handle "key not found" (#523). + # Latent until now only because every call site wraps this in `$( )`, which + # suspends errexit for the function body; a bare call aborts the install + # mid-step. Same house idiom as assess.sh / common.sh `_chart_version`. + # + # NO `| head -1` on the pipeline: with head in play, a DUPLICATE key makes + # head exit after the first line and SIGPIPE grep (141) — and under pipefail + # `|| line=""` would then wipe the successfully captured value, so + # detect_installed_client could miss a clientId and fail open toward + # overwrite (Bugbot, #525). Capture every match, then take the first line in + # the shell, where nothing can signal anything: grep's rc is 1 only when + # there is genuinely no match. + line=$(grep -E "^${key}:" "$file" 2>/dev/null) || line="" + line="${line%%$'\n'*}" [[ -z "$line" ]] && return line="${line#*:}" line="${line#"${line%%[![:space:]]*}"}" @@ -357,6 +377,55 @@ _chart_proxy_env_yaml() { return 0 } +# _image_mirror_yaml — emit the top-level chart values that point every image the +# chart pulls at a private registry mirror (#585 / restricted-network installs). +# TRACEBLOC_IMAGE_REGISTRY sets global.imageRegistry: the chart's +# global.imageRegistry convention re-homes tracebloc/*, the spawned ingestor and +# training-job images, and the alpine/* + ubuntu/squid utility images onto that +# host, so an air-gapped / mirror-only network pulls nothing from a public +# registry. When the mirror needs authentication, TRACEBLOC_REGISTRY_USERNAME / +# TRACEBLOC_REGISTRY_PASSWORD also mint the chart's imagePullSecret +# (dockerRegistry), whose server defaults to the mirror host. Emits nothing when +# no mirror is configured, so a default install's values are unchanged. +_image_mirror_yaml() { + local mirror="${TRACEBLOC_IMAGE_REGISTRY:-}" + local reg_user="${TRACEBLOC_REGISTRY_USERNAME:-}" + local reg_pass="${TRACEBLOC_REGISTRY_PASSWORD:-}" + [[ -z "$mirror" && -z "$reg_user" && -z "$reg_pass" ]] && return 0 + + # global.imageRegistry is a BARE host (mirror.corp.example[:port]) — it becomes the + # prefix of every image reference, so strip a pasted scheme to keep /repo + # well-formed. + local mirror_host="${mirror#*://}" + + if [[ -n "$mirror_host" ]]; then + printf '\nglobal:\n imageRegistry: '\''%s'\''\n' "$(_yaml_sq_escape "$mirror_host")" + fi + if [[ -n "$reg_user" || -n "$reg_pass" ]]; then + # dockerRegistry.server is the imagePullSecret's auths key and the chart schema + # REQUIRES it whenever create is true (format:uri), so it must ALWAYS be + # emitted here. Precedence: an explicit TRACEBLOC_REGISTRY_SERVER wins (e.g. a + # registry whose auth realm differs from the image host); else derive + # https:// when a mirror is set; else fall back to Docker Hub so + # creds-only (authenticate to docker.io, no mirror) still renders a valid + # secret instead of a schema error. + local server="${TRACEBLOC_REGISTRY_SERVER:-}" + if [[ -z "$server" ]]; then + if [[ -n "$mirror_host" ]]; then + server="https://$mirror_host" + else + server="https://index.docker.io/v1/" + fi + fi + printf '\ndockerRegistry:\n create: true\n' + printf ' server: '\''%s'\''\n' "$(_yaml_sq_escape "$server")" + printf ' username: '\''%s'\''\n' "$(_yaml_sq_escape "$reg_user")" + printf ' password: '\''%s'\''\n' "$(_yaml_sq_escape "$reg_pass")" + printf ' email: '\''%s'\''\n' "$(_yaml_sq_escape "${TRACEBLOC_REGISTRY_EMAIL:-}")" + fi + return 0 +} + # _resolve_chart_ref — resolve the chart reference (local dev path or remote repo) # and set `chart_ref` in the caller's scope (bash dynamic scope). Extracted so a # fresh install and an adopt reconcile resolve it identically. Logging is a side @@ -844,6 +913,15 @@ install_client_helm() { proxy_env_yaml="$(_chart_proxy_env_yaml)" [[ -n "$proxy_env_yaml" ]] && log "Corporate proxy detected on host — propagating to client workloads via chart values." + # Private registry mirror (#585): re-home every image onto TRACEBLOC_IMAGE_REGISTRY + # for restricted-network / air-gapped installs. Empty when unset (values unchanged). + local image_mirror_yaml + image_mirror_yaml="$(_image_mirror_yaml)" + if [[ -n "${TRACEBLOC_IMAGE_REGISTRY:-}" ]]; then + log "Image registry mirror configured — pulling all images from ${TRACEBLOC_IMAGE_REGISTRY}." + [[ -n "${TRACEBLOC_REGISTRY_USERNAME:-}" ]] && log "Mirror credentials provided — minting an imagePullSecret for the mirror." + fi + # backend#1236 (option A): size the default training budget to this machine. local training_size training_size="$(_training_resources)" @@ -906,7 +984,7 @@ pvc: pvcAccessMode: ReadWriteOnce clusterScope: true - +${image_mirror_yaml} clientId: '$TB_CLIENT_ID_ESCAPED' clientPassword: '$TB_CLIENT_PASSWORD_ESCAPED' diff --git a/scripts/lib/preflight.sh b/scripts/lib/preflight.sh index e8383bfd..67c0e6f2 100644 --- a/scripts/lib/preflight.sh +++ b/scripts/lib/preflight.sh @@ -645,7 +645,151 @@ _pf_storage_type() { return 0 } +# ── Network profile (#582) ─────────────────────────────────────────────────── +# A plain-language read of the network BEFORE the endpoint probes, so a user on a +# restricted/corporate network sees what's happening up front instead of a cryptic +# failure minutes in. Detects an explicit proxy, a configured corporate CA bundle, +# and (best-effort) TLS inspection. Never fatal, bounded, PII-free (proxy +# credentials are stripped and never printed/logged). The connectivity probes and +# the break-and-inspect hint below still own the actionable fix guidance. + +# Strip scheme:// and any user:pass@ credentials from a proxy URL; echo bare +# host:port. Credentials must NEVER reach the screen or log (#576). +_pf_proxy_hostport() { + local u="${1:-}" + u="${u#*://}" # drop scheme:// + u="${u#*@}" # drop user:pass@ credentials (PII) + u="${u%%/*}" # drop any /path + echo "$u" +} + +# Echo the first explicit proxy from the environment as a bare host:port (creds +# stripped), or empty when none is set. HTTPS takes precedence (that's the one that +# matters for our all-HTTPS egress). +_pf_env_proxy() { + local v val + for v in HTTPS_PROXY https_proxy HTTP_PROXY http_proxy; do + val="${!v:-}"; [[ -n "$val" ]] && { _pf_proxy_hostport "$val"; return 0; } + done + return 0 +} + +# Echo the first explicit proxy from the environment VERBATIM (scheme + any +# user:pass credentials intact), or empty. For the probe CONNECTION only — an +# authenticated proxy needs the credentials to answer the CONNECT, or it 407s and +# the inspection probe silently returns 'unknown' (Bugbot). NEVER print/log this; +# display always uses the credential-stripped _pf_env_proxy. +_pf_env_proxy_raw() { + local v val + for v in HTTPS_PROXY https_proxy HTTP_PROXY http_proxy; do + val="${!v:-}"; [[ -n "$val" ]] && { printf '%s' "$val"; return 0; } + done + return 0 +} + +# Percent-decode a URL-encoded string (proxy userinfo). Mirrors PowerShell's +# [Uri]::UnescapeDataString so an encoded credential (e.g. a "%40" in a password) +# authenticates identically on both platforms (Bugbot). Percent-only: userinfo does +# not use '+'-for-space, so '+' is left literal. +_pf_urldecode() { + local s="${1:-}" + # Escape literal backslashes first so printf '%b' expands ONLY the percent-escapes + # we turn into \xHH below -- otherwise a '\' (or a '\n'/'\t') in the password is + # taken as an escape, diverging from the PS peer's percent-only UnescapeDataString + # (Bugbot, client#589). + s="${s//\\/\\\\}" + printf '%b' "${s//%/\\x}" +} + +# Echo the configured corporate CA bundle path when TRACEBLOC_CA_BUNDLE or +# CURL_CA_BUNDLE points at a readable file; empty otherwise. SOFT (never errors) — +# the hard validation lives in cluster.sh's _resolve_ca_bundle at cluster-create. +_pf_env_ca_bundle() { + local v val + for v in TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE; do + val="${!v:-}"; [[ -n "$val" && -f "$val" && -r "$val" ]] && { echo "$val"; return 0; } + done + return 0 +} + +# Return 0 if an X.509 issuer string names a well-known PUBLIC CA (a normal direct +# chain); 1 otherwise (a corporate re-signer — i.e. TLS inspection). Pure/testable. +_pf_issuer_is_public() { + printf '%s' "${1:-}" | grep -qiE "DigiCert|Sectigo|Comodo|Let'?s Encrypt|ISRG|Google Trust|GTS |GlobalSign|Amazon|Entrust|GeoTrust|Baltimore|USERTrust|Actalis|Buypass|SSL\.com|Certum|IdenTrust|Microsoft (Azure|RSA|ECC)" +} + +# Best-effort affirmative TLS-inspection probe. Echo yes|no|unknown. Reads the +# issuer of the cert served for a well-known public host (through the proxy when +# one is set); a non-public issuer means a corporate CA is re-signing TLS. Bounded +# and non-hanging: needs openssl AND a timeout tool, else 'unknown' (we never run +# an unbounded openssl s_client that a blackholed 443 could hang forever). +_pf_detect_tls_inspection() { + has openssl || { echo "unknown"; return 0; } + { has timeout || has gtimeout; } || { echo "unknown"; return 0; } + local host="github.com" raw issuer creds user="" pass="" + local -a args=(s_client -connect "${host}:443" -servername "$host") + # Connect THROUGH the proxy using the raw value: -proxy takes host:port (creds + # stripped), and an authenticated proxy additionally needs -proxy_user/-proxy_pass + # (openssl >= 3.0) or it 407s and issuer capture fails → a false 'unknown' on the + # exact TLS-inspecting networks this exists to detect (Bugbot). Credentials go to + # openssl only, URL-decoded (parity with the PS peer) and NEVER printed/logged. + raw="$(_pf_env_proxy_raw)" + if [[ -n "$raw" ]]; then + args+=(-proxy "$(_pf_proxy_hostport "$raw")") + # Capture-then-match, not `openssl -help | grep -q`: under pipefail, grep -q closing + # the pipe early makes openssl take SIGPIPE and the pipeline fail, dropping the + # credential flags on exactly the authenticated proxies this handles (Bugbot, client#589). + if [[ "$raw" == *"@"* ]] && { _pf_ossl_help="$(openssl s_client -help 2>&1 || true)"; [[ "$_pf_ossl_help" == *"-proxy_user"* ]]; }; then + creds="${raw#*://}"; creds="${creds%%@*}" # user[:pass], percent-encoded + if [[ "$creds" == *:* ]]; then + user="$(_pf_urldecode "${creds%%:*}")"; pass="$(_pf_urldecode "${creds#*:}")" + else + user="$(_pf_urldecode "$creds")"; pass="" # user@ with no password + fi + # Pass the password via env: (openssl reads $_TB_PROXY_PASS) so it never lands + # in argv / ps / /proc/*/cmdline on a shared host (Bugbot). Username isn't secret. + args+=(-proxy_user "$user" -proxy_pass "env:_TB_PROXY_PASS") + fi + fi + # echo | : send EOF so s_client returns after the handshake. We deliberately do + # NOT pass -verify_return_error — we want the served cert's issuer even when the + # corporate CA isn't trusted, so we can name the inspection. The password is + # exported ONLY inside this command-substitution subshell (openssl reads it via + # env:), so it never reaches argv or the parent shell. `|| issuer=""` keeps a + # failed/timed-out probe from aborting under `set -euo pipefail` (like _pf_probe_url). + issuer="$( + export _TB_PROXY_PASS="$pass" + echo | _bounded 8 openssl "${args[@]}" 2>/dev/null | openssl x509 -noout -issuer 2>/dev/null + )" || true # keep a captured issuer: s_client often exits non-zero (SIGPIPE after + # x509 finishes) even on a good handshake; a genuinely empty capture is + # still caught by the [[ -z ]] below (Bugbot High, client#589). + [[ -z "$issuer" ]] && { echo "unknown"; return 0; } + if _pf_issuer_is_public "$issuer"; then echo "no"; else echo "yes"; fi +} + +# Print the plain-language network profile line (only when noteworthy — a plain +# direct connection stays silent; the "Connected:" line already confirms egress). +# Exports PF_NET_PROXY / PF_NET_CA / PF_NET_INSPECT for later reuse. +_pf_network_profile() { + PF_NET_PROXY="$(_pf_env_proxy)" + PF_NET_CA="$(_pf_env_ca_bundle)" + PF_NET_INSPECT="$(_pf_detect_tls_inspection)" + + # Nothing noteworthy: no proxy and inspection not affirmatively detected. + [[ -z "$PF_NET_PROXY" && "$PF_NET_INSPECT" != "yes" ]] && return 0 + + local -a parts=() + [[ -n "$PF_NET_PROXY" ]] && parts+=("corporate proxy detected (${PF_NET_PROXY})") + [[ "$PF_NET_INSPECT" == "yes" ]] && parts+=("TLS inspection detected") + [[ -n "$PF_NET_CA" ]] && parts+=("your company's certificate is configured") + local joined="" p + for p in "${parts[@]}"; do joined="${joined:+$joined; }$p"; done + info "Network: ${joined}." + return 0 +} + _pf_connectivity() { + _pf_network_profile # #582: announce the network profile before the probes # Can't probe without curl — and on the direct ./install-k8s.sh path the # installer hasn't installed it yet. Skip with a warning rather than hard-fail # with a misleading "egress blocked" (curl is installed downstream). @@ -769,6 +913,18 @@ _pf_connectivity() { if [[ "$cfail" -gt 0 ]]; then hint "Allow HTTPS (443) egress to the host(s) named above — the always-needed set is registry-1.docker.io, auth.docker.io, ghcr.io, ${backend_host}, tracebloc.github.io, plus any tool-download host listed (dl.k8s.io / get.helm.sh / github.com / objects.githubusercontent.com) — or set HTTP_PROXY if you use a corporate proxy." fi + # #585: when the CONTAINER REGISTRIES themselves are blocked (not just any host), + # the images can't be pulled directly at all — surface the mirror / offline options + # in plain language instead of leaving the generic egress hint as the only guidance. + local ff reg_blocked=0 + for ff in ${fails[@]+"${fails[@]}"}; do + case "${ff%%|*}" in + *registry-1.docker.io*|*auth.docker.io*|*ghcr.io*) reg_blocked=1 ;; + esac + done + if [[ "$reg_blocked" -eq 1 ]]; then + hint "The container registries (Docker Hub / GHCR) look blocked here, so the images can't be pulled directly. If your site runs a mirror you CAN reach, point the install at it; for a fully offline site, an air-gapped image bundle is the alternative. See the 'Blocked container registry' section of docs/INSTALL.md." + fi return 0 } diff --git a/scripts/lib/setup-linux.sh b/scripts/lib/setup-linux.sh index 6c836f6d..c7964aa6 100644 --- a/scripts/lib/setup-linux.sh +++ b/scripts/lib/setup-linux.sh @@ -226,8 +226,29 @@ install_docker_engine() { chmod +x "$docker_script" # Same needrestart guard as setup_pm: get.docker.com runs `apt-get install` # internally, so under spin_cmd it can hit the same hidden prompt and hang. - spin_cmd "Installing Docker…" sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a bash "$docker_script" + # + # And BOUND it: the script's internal apt/download.docker.com fetches carry + # no timeout of their own, so a stalled connection hung here silently behind + # the spinner until something else killed the process — in CI that was the + # 20-minute job timeout, three times in one day, with nothing in the log but + # "Installing Docker…" (backend, 2026-08-04). A healthy install takes 1-3 + # minutes; 10 is network trouble, not a slow link. spin_cmd_bounded returns + # 124 ONLY on the deadline, so a fast real apt/script failure keeps its own + # error instead of being mislabelled as a stall (Bugbot). + local _dk_rc=0 + spin_cmd_bounded 600 "Installing Docker…" sudo env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a bash "$docker_script" || _dk_rc=$? rm -f "$docker_script" + # Re-run advice by mode, matching the daemon-check errors below: telling a + # prepare-host ADMIN to "re-run the installer" points them at a full + # provision as themselves — the exact outcome prepare-host exists to + # prevent (Bugbot). + local _rerun="the installer" + [[ -n "${TB_PREPARE_HOST_MODE:-}" ]] && _rerun="prepare-host" + if (( _dk_rc == 124 )); then + error "Docker install did not finish within 10 minutes — the download from get.docker.com/download.docker.com stalled. Check your network/proxy and re-run ${_rerun}; it resumes safely." + elif (( _dk_rc != 0 )); then + error "Docker install failed (the log tail above has the reason). Fix the reported problem and re-run ${_rerun}." + fi fi # Enable for boot only (no --now): starting is handled below, where a start # failure is diagnosed instead of aborting the whole script under `set -e`. @@ -432,6 +453,9 @@ _fetch_kubectl() { "https://dl.k8s.io/release/${ver}/bin/${os}/${arch}/kubectl" -o "${tmpdir}/kubectl" retry 3 5 curl_secure -fsSL --connect-timeout 15 --speed-limit 1024 --speed-time 60 \ "https://dl.k8s.io/release/${ver}/bin/${os}/${arch}/kubectl.sha256" -o "${tmpdir}/kubectl.sha256" + # Catch a truncated/blocked transfer as a TRANSFER failure before the checksum + # misreports it as tampering (#607). kubectl is ~50 MB; 20 MB is a safe floor. + _assert_download_size "${tmpdir}/kubectl" 20000000 "kubectl" "$tmpdir" _verify_sha256 "$(cat "${tmpdir}/kubectl.sha256")" "${tmpdir}/kubectl" \ || { rm -rf "$tmpdir"; error "System tool checksum verification failed"; } chmod +x "${tmpdir}/kubectl" @@ -490,6 +514,9 @@ _fetch_k3d_release() { want="$(awk -v asset="k3d-${os}-${arch}" \ '{ n = split($2, p, "/"); if (p[n] == asset) { print $1; exit } }' \ "${tmpdir}/checksums.txt" 2>/dev/null)" + # Transfer-vs-checksum distinction (#607): k3d is ~50 MB; 10 MB floor catches a + # truncated/blocked download before the checksum misreports it as tampering. + _assert_download_size "${tmpdir}/k3d" 10000000 "k3d" "$tmpdir" if [ -z "$want" ] || ! _verify_sha256 "$want" "${tmpdir}/k3d"; then rm -rf "$tmpdir" error "System tool checksum verification failed" @@ -639,6 +666,9 @@ _fetch_helm_release() { # tarball with the portable checker (sha256sum on Linux, shasum on macOS; #429). local want want="$(awk 'NR==1{print $1}' "${tmpdir}/${tarball}.sha256sum" 2>/dev/null)" + # Transfer-vs-checksum distinction (#607): the Helm tarball is ~17 MB; 5 MB floor + # catches a truncated/blocked download before the checksum misreports tampering. + _assert_download_size "${tmpdir}/${tarball}" 5000000 "Helm" "$tmpdir" if [ -z "$want" ] || ! _verify_sha256 "$want" "${tmpdir}/${tarball}"; then rm -rf "$tmpdir" error "System tool checksum verification failed" diff --git a/scripts/manifest.sha256 b/scripts/manifest.sha256 index c0b52d03..161eff88 100644 --- a/scripts/manifest.sha256 +++ b/scripts/manifest.sha256 @@ -1,18 +1,18 @@ -8e256be14eff4b50e088a54062356110e50fbcafb583849242f2a0de086e29c3 scripts/install-k8s.sh -1bc9e544b56b54ce346a9c6c3a7e8781169a70c50ac5877732587b991e85075d scripts/lib/common.sh -99e8149769cb08d9a26d35aa9714c33b23380951bbaab682c8dcb6e277e93bb7 scripts/lib/preflight.sh +07642f9f2637d20c75a14515545db60ae03f8e0e6818b08af9c2e991750b48d0 scripts/install-k8s.sh +6b6b3a0f07ac62b54c243a5ee8a8de82a3a08a093e6762a374b2457531c02d0c scripts/lib/common.sh +c68bc05b568b3a54005c22cf885a3dfb2db7b57d7f39c73b958480bff6b8f80e scripts/lib/preflight.sh 19be2771df0e1a41b4fa9678e1cf6a77492304f66f73cf705a2ae42b1dac2ba3 scripts/lib/detect-gpu.sh d8c29bc8bd1f4633300940894da0f6527ca0a1dd7a3cfcbc80aad19dfd4d88cb scripts/lib/gpu-nvidia.sh b569eec2d8ffb9673da287a2a59d249a7dbc7236c98ab6a5062136bcc69a942c scripts/lib/gpu-amd.sh fc3dacf419b66373a7e1b7c3ce0f44ec47fdfa3f7039cb2697ac2dd065344596 scripts/lib/setup-macos.sh -f8f398191e03d750f61eca4e867b4f8ccb8b447c34c9803daa0ad4f3a49701aa scripts/lib/setup-linux.sh -b7b19dea83b6ee988a563105082c233264b7a3c35b82753405a4d76f011548d2 scripts/lib/cluster.sh -045caf6efeb583e5005d881d9281edae6a6aedf8f318b0a4021964b3b4b29cc5 scripts/lib/gpu-plugins.sh -f902abc5a9f2f65467b1a324804b5205b6a30fa246b4bd8328066d79595ffb05 scripts/lib/install-client-helm.sh +2a58a1f90c90d6759d7a8116331d7bc52b71ab80a74f37e8fbf15507ba98ebe0 scripts/lib/setup-linux.sh +90750df5be3bb0266b06404df7dc38c0a7919e1487a34e1a02005de71736ba0e scripts/lib/cluster.sh +3c539322b19b31f21ff7c20594b52fcb825547c84046a45c2c382da99712a4ec scripts/lib/gpu-plugins.sh +e2c87c056afa99e8d98cdeb8f04e78a7eed54f1f7321538844506e7b5b62943e scripts/lib/install-client-helm.sh 61c1c887d158af52d4da4734b3bfa83205b2600ae7a291bfb3074daf3d9ffb55 scripts/lib/install-cli.sh 725a85e4927761d8362221012ad1b69b380e36ec7fcfcf4c04b801f0994bce5c scripts/lib/provision.sh e373403d7bb5ce3728b8d21af89e6bf672cc35bbf8938541eb527ae19cb9473b scripts/lib/assess.sh 911fd0714b17357bb205fc8a8fa8e13eedc1a9632a2f63d4ead9f8d8c7ee546f scripts/lib/probe.sh 38761a6c56dc85b3f5742df036e6a2ec2baa0adb0c90b3753b6706779528b7be scripts/lib/summary.sh 77e03332ebfab1ef759c6148a57afcf479c02c5dc6cc7b0e0e680f58e20cd364 scripts/lib/diagnose.sh -50f382bb1503af4167cbd5738b3d6755be1ca4096fcd14fc6286db3f5100c28e scripts/install-k8s.ps1 +f3c3591c466a3959e06e26657d6e4403b97bebafeb9c0ee0375256687bb03e6d scripts/install-k8s.ps1 diff --git a/scripts/tests/assess.bats b/scripts/tests/assess.bats index e43286c0..64c7d83a 100644 --- a/scripts/tests/assess.bats +++ b/scripts/tests/assess.bats @@ -62,20 +62,20 @@ _depname() { @test "_assess_cluster_servers_running: running cluster -> >=1" { k3d() { printf 'tracebloc 1/1 0/0\n'; } run _assess_cluster_servers_running - [ "$status" -eq 0 ] - [ "$output" = "1" ] + [ "$status" -eq 0 ] || return 1 + [ "$output" = "1" ] || return 1 } @test "_assess_cluster_servers_running: stopped cluster -> 0" { k3d() { printf 'tracebloc 0/1 0/0\n'; } run _assess_cluster_servers_running - [ "$output" = "0" ] + [ "$output" = "0" ] || return 1 } @test "_assess_cluster_servers_running: k3d error -> 0 (never non-numeric)" { k3d() { return 1; } run _assess_cluster_servers_running - [ "$output" = "0" ] + [ "$output" = "0" ] || return 1 } # ── _assess_workload_ready (ALL shared workloads; bounded, read-only) ─────── @@ -88,54 +88,54 @@ _depname() { has() { [ "$1" = kubectl ]; } kubectl() { echo 1; } # every workload reports 1 ready run _assess_workload_ready tracebloc - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "_assess_workload_ready: mysql-client down -> not ready (1)" { has() { [ "$1" = kubectl ]; } kubectl() { case "$(_depname "$@")" in mysql-client) echo "";; *) echo 1;; esac; } run _assess_workload_ready tracebloc - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } @test "_assess_workload_ready: jobs-manager has 0 ready -> not ready (1)" { has() { [ "$1" = kubectl ]; } kubectl() { case "$(_depname "$@")" in *-jobs-manager) echo 0;; *) echo 1;; esac; } run _assess_workload_ready tracebloc - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } @test "_assess_workload_ready: requests-proxy down (training egress) -> not ready (1)" { has() { [ "$1" = kubectl ]; } kubectl() { case "$(_depname "$@")" in *-requests-proxy) echo "";; *) echo 1;; esac; } run _assess_workload_ready tracebloc - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } @test "_assess_workload_ready: a deployment absent (kubectl errors) -> not ready (1)" { has() { [ "$1" = kubectl ]; } kubectl() { case "$(_depname "$@")" in *-requests-proxy) return 1;; *) echo 1;; esac; } run _assess_workload_ready tracebloc - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } @test "_assess_workload_ready: kubectl absent -> not ready (1)" { has() { return 1; } run _assess_workload_ready tracebloc - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } @test "_assess_workload_ready: empty namespace -> not ready (1)" { has() { return 0; } run _assess_workload_ready "" - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } @test "_assess_workload_ready: probes ALL three, each with a bounded --request-timeout" { has() { [ "$1" = kubectl ]; } kubectl() { printf '%s | %s\n' "$(_depname "$@")" "$*" >>"$MOCK_CALLS"; echo 1; } run _assess_workload_ready tracebloc - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls assert_has "mysql-client" "$output" assert_has "tracebloc-jobs-manager" "$output" @@ -147,7 +147,7 @@ _depname() { @test "_assess_cli_present: on PATH -> present (0)" { has() { [ "$1" = tracebloc ]; } run _assess_cli_present - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "_assess_cli_present: only in ~/.local/bin -> present (0)" { @@ -155,14 +155,14 @@ _depname() { HOME="$BATS_TEST_TMPDIR/h"; mkdir -p "$HOME/.local/bin" printf '#!/bin/sh\n' > "$HOME/.local/bin/tracebloc"; chmod +x "$HOME/.local/bin/tracebloc" run _assess_cli_present - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "_assess_cli_present: absent everywhere -> not present (1)" { has() { return 1; } HOME="$BATS_TEST_TMPDIR/empty"; mkdir -p "$HOME" run _assess_cli_present - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } # ── _assess_classify (decision logic; leaf probes forced) ─────────────────── @@ -170,8 +170,8 @@ _depname() { has() { return 1; } # no k3d _cluster_exists() { return 1; } _assess_classify - [ "$INSTALL_STATE" = fresh ] - [ "$INSTALL_STATE_REASON" = no-cluster ] + [ "$INSTALL_STATE" = fresh ] || return 1 + [ "$INSTALL_STATE_REASON" = no-cluster ] || return 1 } @test "_assess_classify: running cluster but no tracebloc release -> fresh (cluster-no-release)" { @@ -180,8 +180,8 @@ _depname() { _assess_cluster_servers_running() { echo 1; } # running: reached only after the servers check detect_installed_client() { INSTALLED_CLIENT_ID=""; INSTALLED_CLIENT_NS=""; } _assess_classify - [ "$INSTALL_STATE" = fresh ] - [ "$INSTALL_STATE_REASON" = cluster-no-release ] + [ "$INSTALL_STATE" = fresh ] || return 1 + [ "$INSTALL_STATE_REASON" = cluster-no-release ] || return 1 } @test "_assess_classify: release present but cluster stopped -> degraded (cluster-stopped)" { @@ -190,8 +190,8 @@ _depname() { detect_installed_client() { INSTALLED_CLIENT_ID=uuid; INSTALLED_CLIENT_NS=tracebloc; } _assess_cluster_servers_running() { echo 0; } _assess_classify - [ "$INSTALL_STATE" = degraded ] - [ "$INSTALL_STATE_REASON" = cluster-stopped ] + [ "$INSTALL_STATE" = degraded ] || return 1 + [ "$INSTALL_STATE_REASON" = cluster-stopped ] || return 1 } # Ordering guard (Bugbot: "Assess probes Helm before cluster runs"): a stopped @@ -203,8 +203,8 @@ _depname() { _assess_cluster_servers_running() { echo 0; } # stopped detect_installed_client() { touch "$BATS_TEST_TMPDIR/helm-probed"; } # must NOT run _assess_classify - [ "$INSTALL_STATE_REASON" = cluster-stopped ] - [ ! -f "$BATS_TEST_TMPDIR/helm-probed" ] # Helm was never touched + [ "$INSTALL_STATE_REASON" = cluster-stopped ] || return 1 + [ ! -f "$BATS_TEST_TMPDIR/helm-probed" ] || return 1 # Helm was never touched } # healthy requires ALL three workloads: with the REAL _assess_workload_ready @@ -217,8 +217,8 @@ _depname() { _assess_cli_present() { return 0; } kubectl() { case "$(_depname "$@")" in *-requests-proxy) echo "";; *) echo 1;; esac; } _assess_classify - [ "$INSTALL_STATE" = degraded ] - [ "$INSTALL_STATE_REASON" = workload-not-ready ] + [ "$INSTALL_STATE" = degraded ] || return 1 + [ "$INSTALL_STATE_REASON" = workload-not-ready ] || return 1 } @test "_assess_classify: up + all workloads Ready but CLI missing -> degraded (cli-missing)" { @@ -229,8 +229,8 @@ _depname() { kubectl() { echo 1; } # all workloads Ready HOME="$BATS_TEST_TMPDIR/nocli"; mkdir -p "$HOME" # and no ~/.local/bin/tracebloc _assess_classify - [ "$INSTALL_STATE" = degraded ] - [ "$INSTALL_STATE_REASON" = cli-missing ] + [ "$INSTALL_STATE" = degraded ] || return 1 + [ "$INSTALL_STATE_REASON" = cli-missing ] || return 1 } @test "_assess_classify: all signals true (all three workloads Ready + CLI) -> healthy" { @@ -240,7 +240,7 @@ _depname() { _assess_cluster_servers_running() { echo 1; } kubectl() { echo 1; } # every workload Ready _assess_classify - [ "$INSTALL_STATE" = healthy ] + [ "$INSTALL_STATE" = healthy ] || return 1 assert_has "munich" "$INSTALL_STATE_REASON" } @@ -255,7 +255,7 @@ _depname() { tracebloc() { echo "HOME_SCREEN"; } # writes to whatever stdout it's given TB_TTY="$BATS_TEST_TMPDIR/tty"; : > "$TB_TTY" run _assess_handoff - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 assert_has "Already set up on this machine" "$output" # the success line: script stdout assert_has "HOME_SCREEN" "$(cat "$TB_TTY")" # home screen: the terminal, not the pipe refute_has "HOME_SCREEN" "$output" # proves stdout was redirected off the pipe @@ -266,7 +266,7 @@ _depname() { tracebloc() { echo "ARGS=[$*]"; } TB_TTY="$BATS_TEST_TMPDIR/tty"; : > "$TB_TTY" run _assess_handoff - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 assert_has "ARGS=[]" "$(cat "$TB_TTY")" # invoked bare, not a subcommand } @@ -275,7 +275,7 @@ _depname() { tracebloc() { echo "HOME_SCREEN"; } TB_TTY="$BATS_TEST_TMPDIR/nope/tty" # parent dir absent -> not openable run _assess_handoff - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 assert_has "Already set up on this machine" "$output" assert_has "HOME_SCREEN" "$output" # fallback leaves stdout on the (captured) pipe } @@ -284,7 +284,7 @@ _depname() { has() { return 1; } HOME="$BATS_TEST_TMPDIR/emptyhome"; mkdir -p "$HOME" run _assess_handoff - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 assert_has "Already set up on this machine" "$output" assert_has "tracebloc" "$output" # tells the user the command to run } @@ -296,7 +296,7 @@ _depname() { tracebloc() { echo "HOME_SCREEN"; } TB_TTY="$BATS_TEST_TMPDIR/tty"; : > "$TB_TTY" run assess_existing_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 assert_has "Already set up on this machine" "$output" assert_has "HOME_SCREEN" "$(cat "$TB_TTY")" } @@ -309,7 +309,7 @@ _depname() { tracebloc() { echo "HANDED_OFF"; } TB_TTY="$BATS_TEST_TMPDIR/nope/tty" # unopenable -> fallback keeps stdout captured run assess_existing_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 assert_has "HANDED_OFF" "$output" } @@ -318,7 +318,7 @@ _depname() { _assess_classify() { echo "CLASSIFY_RAN"; INSTALL_STATE=healthy; } # must NOT run tracebloc() { echo "HOME_SCREEN"; } run assess_existing_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 refute_has "CLASSIFY_RAN" "$output" refute_has "HOME_SCREEN" "$output" } @@ -327,7 +327,7 @@ _depname() { _assess_classify() { INSTALL_STATE=degraded; INSTALL_STATE_REASON=cluster-stopped; } tracebloc() { echo "HOME_SCREEN"; } # must NOT be called on a fall-through run assess_existing_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 assert_has "secure environment is stopped" "$output" refute_has "HOME_SCREEN" "$output" } @@ -335,14 +335,14 @@ _depname() { @test "assess_existing_install: degraded (cli-missing) -> names the CLI, returns 0" { _assess_classify() { INSTALL_STATE=degraded; INSTALL_STATE_REASON=cli-missing; } run assess_existing_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 assert_has "CLI isn't installed" "$output" } @test "assess_existing_install: degraded says 'secure environment', never 'client'" { _assess_classify() { INSTALL_STATE=degraded; INSTALL_STATE_REASON=workload-not-ready; } run assess_existing_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 assert_has "secure environment" "$output" refute_has "client" "$output" } @@ -351,7 +351,7 @@ _depname() { _assess_classify() { INSTALL_STATE=fresh; INSTALL_STATE_REASON=no-cluster; } tracebloc() { echo "HOME_SCREEN"; } run assess_existing_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 assert_has "first time" "$output" refute_has "HOME_SCREEN" "$output" } @@ -360,7 +360,7 @@ _depname() { _assess_classify() { INSTALL_STATE=fresh; INSTALL_STATE_REASON=cluster-no-release; } tracebloc() { echo "HOME_SCREEN"; } run assess_existing_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 refute_has "first time" "$output" # no ceremony when a cluster already exists refute_has "HOME_SCREEN" "$output" } @@ -375,7 +375,7 @@ _depname() { _check_existing_cluster_k8s_version() { echo "DRIFT_CHECK_RAN"; } _assess_handoff() { echo "HANDOFF_RAN"; } # stub: don't exit under `run` run assess_existing_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 assert_has "DRIFT_CHECK_RAN" "$output" assert_has "HANDOFF_RAN" "$output" } @@ -384,6 +384,6 @@ _depname() { export TB_FORCE_REINSTALL=1 _check_existing_cluster_k8s_version() { echo "DRIFT_CHECK_RAN"; } run assess_existing_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 refute_has "DRIFT_CHECK_RAN" "$output" } diff --git a/scripts/tests/bats-hygiene.bats b/scripts/tests/bats-hygiene.bats new file mode 100644 index 00000000..96890edf --- /dev/null +++ b/scripts/tests/bats-hygiene.bats @@ -0,0 +1,445 @@ +#!/usr/bin/env bats +# ============================================================================= +# bats-hygiene.bats — keep every assertion in this suite ENFORCING. +# +# Bats (verified on 1.13.0) runs a test body under errexit, but two classes of +# assertion escape it, so a failing one that is NOT the last command in the body +# is silently ignored: +# +# [[ ... ]] on bash 3.2 — the system bash on macOS — errexit does not fire +# for a failing conditional expression +# ! cmd POSIX: a status inverted with '!' is never propagated, so this +# escapes on EVERY bash, not just 3.2 +# +# So a body like +# +# run _augment_no_proxy +# [[ "$output" == *"localhost"* ]] # FALSE -> ignored +# [[ "$output" == *"host.k3d.internal"* ]] # TRUE -> test passes +# +# passes while ignoring the first assertion. That is not theoretical: with the +# pre-hardening suite, deleting `localhost` from TB_NO_PROXY_DEFAULTS — the entry +# that keeps a corporate proxy from intercepting loopback — left that exact test +# green. Hardened, it fails. Same story for the R8 tag gate: blanking install.sh's +# "not an immutable release tag" message left install-bootstrap.bats's two +# path-traversal tests green, because their message assertion was a multi-line +# `[[ a || b ]]` the scanner used to skip (Bugbot). Hardened, both fail. +# +# Convention: every standalone assertion inside an @test body ends in +# `|| return 1`. The scanner lives in unenforced-assertions.awk (one +# implementation, shared by the guard and its own self-test below). +# ============================================================================= + +setup() { + SCANNER="${BATS_TEST_DIRNAME}/unenforced-assertions.awk" + TESTS_DIR="${BATS_TEST_DIRNAME}" +} + +@test "every standalone assertion in an @test body ends in '|| return 1' (else it is advisory)" { + local offenders count + offenders="$(awk -f "$SCANNER" "$TESTS_DIR"/*.bats)" + count="$(printf '%s' "$offenders" | grep -c . || true)" + if [[ "$count" != "0" ]]; then + printf 'Found %s assertion(s) that cannot fail their test:\n\n' "$count" >&2 + printf '%s\n\n' "$offenders" >&2 + printf 'Append "|| return 1" to each. See the header of this file for why.\n' >&2 + return 1 + fi + [[ "$count" == "0" ]] || return 1 +} + +@test "the scanner flags an un-hardened assertion and spares a hardened one (guard is not vacuous)" { + # A guard nobody has watched fail is not a guard. Build the fixture with printf, + # not a heredoc, so this file contains no line that looks like a bare assertion. + local fixture="$BATS_TEST_TMPDIR/fixture.bats" out + { + printf '@test "example" {\n' + printf ' [[ "abc" == *"zzz"* ]]\n' # line 2: un-hardened -> flagged + printf ' [ "1" = "2" ]\n' # line 3: un-hardened -> flagged + printf ' [[ "abc" == *"abc"* ]] || return 1\n' # line 4: enforcing -> spared + printf '}\n' + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + [[ "$out" == *":2:"* ]] || return 1 + [[ "$out" == *":3:"* ]] || return 1 + [[ "$out" != *":4:"* ]] || return 1 +} + +@test "the scanner flags an internal-OR assertion: || inside the brackets is not enforcing (Bugbot)" { + # `[[ a || b ]]` is ONE assertion whose ||/&& is internal; it exits non-zero on + # failure exactly like a plain one, so it needs `|| return 1` too. Skipping every + # line that merely CONTAINS ||/&& let this class through, on one line and across + # several — including `||` that is only text inside a quoted pattern. + local fixture="$BATS_TEST_TMPDIR/internal-or.bats" out + { + printf '@test "example" {\n' + printf ' [[ "abc" == *"zzz"* || "abc" == *"yyy"* ]]\n' # 2: internal || -> flagged + printf ' [[ "abc" == *"zzz"* && "abc" == *"yyy"* ]]\n' # 3: internal && -> flagged + printf ' [ "$(grep -c \x27|| rc=$?\x27 f)" -eq 2 ]\n' # 4: || only in a pattern -> flagged + printf ' [[ "abc" == *"zzz"* \\\n || "abc" == *"yyy"* ]]\n' # 5: continued, backslash -> flagged + printf ' [[ "abc" == *"zzz"* ||\n "abc" == *"yyy"* ]]\n' # 7: continued, no backslash -> flagged + printf ' [[ "abc" == *"zzz"* || "abc" == *"abc"* ]] || return 1\n' # 9: enforcing -> spared + printf ' [[ "abc" == *"zzz"* \\\n || "abc" == *"a"* ]] || return 1\n' # 10: enforcing -> spared + printf ' [[ 1 == 1 ]] || [[ 2 == 2 ]]\n' # 12: TOP-level chain -> spared + printf '}\n' + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + # multi-line assertions are reported at their FIRST line + local n + for n in 2 3 4 5 7; do + [[ "$out" == *":$n:"* ]] || { printf 'expected line %s to be flagged, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + for n in 9 10 12; do + [[ "$out" != *":$n:"* ]] || { printf 'line %s should be spared, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + # each offender is one output line, and a joined one stays on one line + [[ "$(printf '%s' "$out" | grep -c .)" == "5" ]] || return 1 +} + +@test "the scanner is not fooled by '|| return 1' inside a pattern or comment (Bugbot)" { + # The enforcing check was a line-wide substring match, so any assertion whose + # quoted pattern or trailing comment merely MENTIONED `|| return 1` was treated as + # hardened though neither enforces. Both must still be flagged; a real top-level + # `|| return 1` is still spared. + local fixture="$BATS_TEST_TMPDIR/substr.bats" out + { + printf '@test "example" {\n' + printf ' [[ "$output" == *"|| return 1"* ]]\n' # 2: marker in a pattern -> flagged + printf ' [ "$x" = "y" ] # remember to add || return 1\n' # 3: marker in a comment -> flagged + printf ' [[ "$output" == *"ok"* ]] || return 1\n' # 4: really hardened -> spared + printf '}\n' + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + [[ "$out" == *":2:"* ]] || return 1 + [[ "$out" == *":3:"* ]] || return 1 + [[ "$out" != *":4:"* ]] || return 1 + [[ "$(printf '%s' "$out" | grep -c .)" == "2" ]] || return 1 +} + +@test "the scanner flags an un-hardened negated bare command (Bugbot)" { + # `! cmd` is the one class that escapes errexit on EVERY bash — POSIX says a + # status inverted with '!' is never propagated — so an unhardened one is + # advisory everywhere, not just on bash 3.2. The suite has 61 of them. + local fixture="$BATS_TEST_TMPDIR/negated.bats" out + { + printf '@test "example" {\n' + printf ' ! mock_calls | grep -q preflight_sudo\n' # 2: un-hardened -> flagged + printf ' ! grep -q needle "$f"\n' # 3: un-hardened -> flagged + printf ' ! mock_calls | grep -q install_docker || return 1\n' # 4: enforcing -> spared + printf ' if ! grep -q needle "$f"; then :; fi\n' # 5: control flow -> spared + printf ' grep -q needle "$f"\n' # 6: bare cmd, errexit fires -> spared + printf ' run ! grep -q needle "$f"\n' # 7: bats run -> spared + printf '}\n' + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + [[ "$out" == *":2:"* ]] || return 1 + [[ "$out" == *":3:"* ]] || return 1 + local n + for n in 4 5 6 7; do + [[ "$out" != *":$n:"* ]] || { printf 'line %s should be spared, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + [[ "$(printf '%s' "$out" | grep -c .)" == "2" ]] || return 1 +} + +@test "the scanner flags a bracket / negated assertion that is the last command of a compound line (Bugbot)" { + # `run x; [[ ... ]]` puts the `[[` mid-line, not at line start; on bash 3.2 the + # `[[` still cannot fail the test, so it is advisory. The preflight suite is + # full of these (`run _pf_disk; [[ "$output" == *…* ]]`). + local fixture="$BATS_TEST_TMPDIR/compound.bats" out + { + printf '@test "example" {\n' + printf ' run _pf_disk; [[ "$output" == *"free"* ]]\n' # 2: bracket last -> flagged + printf ' x=0; check >/dev/null; [ "$x" -eq 0 ]\n' # 3: single-bracket last -> flagged + printf ' run _pf_disk; ! grep -q needle "$f"\n' # 4: negated last -> flagged + printf ' run x; [[ "$output" == *"free"* ]] || return 1\n' # 5: enforcing -> spared + printf ' run x; [[ "$o" == *"a"* ]] || fail msg\n' # 6: top-level chain -> spared + printf ' a=1; b=2\n' # 7: bare cmds -> spared + printf '}\n' + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + [[ "$out" == *":2:"* ]] || return 1 + [[ "$out" == *":3:"* ]] || return 1 + [[ "$out" == *":4:"* ]] || return 1 + local n + for n in 5 6 7; do + [[ "$out" != *":$n:"* ]] || { printf 'line %s should be spared, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + [[ "$(printf '%s' "$out" | grep -c .)" == "3" ]] || return 1 +} + +@test "the scanner does not mistake a quoted < NOT an opener + printf ' [[ "abc" == *"zzz"* ]]\n' # 3: flagged (was invisible) + printf ' run guard <<< "r"\n' # 4: herestring -> NOT an opener + printf ' [ "1" = "2" ]\n' # 5: flagged (was invisible) + printf ' cat > g <<\x27EOF\x27\n' # 6: a REAL heredoc opener + printf ' [[ "embedded" == "fixture" ]]\n' # 7: heredoc body -> spared + printf 'EOF\n' # 8: terminator + printf ' [[ "abc" == *"yyy"* ]]\n' # 9: flagged + printf '}\n' + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + for n in 3 5 9; do + [[ "$out" == *":$n:"* ]] || { printf 'expected line %s to be flagged, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + # line 7 is a genuine heredoc body and must STILL be spared — otherwise the fix + # would just be "stop tracking heredocs at all" + [[ "$out" != *":7:"* ]] || { printf 'line 7 (real heredoc body) must be spared, got:\n%s\n' "$out" >&2; return 1; } + [[ "$(printf '%s' "$out" | grep -c .)" == "3" ]] || { printf 'expected exactly 3 offenders, got:\n%s\n' "$out" >&2; return 1; } +} + +@test "an unterminated heredoc cannot swallow past the next @test (Bugbot)" { + # Belt for the same failure mode: whatever else is ever misread as an opener, the + # skip must end at an @test in column 0, so it can never hide more than one test. + local fixture="$BATS_TEST_TMPDIR/unterminated.bats" out + { + printf '@test "unterminated" {\n' + printf ' cat > f <<\x27NOPE\x27\n' # 2: real opener, never terminated + printf ' [[ "swallowed" == "ok" ]]\n' # 3: genuinely in the body -> spared + printf '}\n' + printf '@test "next" {\n' # 5: safety valve fires here + printf ' [ "1" = "2" ]\n' # 6: flagged + printf '}\n' + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + [[ "$out" == *":6:"* ]] || { printf 'expected line 6 to be flagged, got:\n%s\n' "$out" >&2; return 1; } + [[ "$out" != *":3:"* ]] || { printf 'line 3 is inside the heredoc body, got:\n%s\n' "$out" >&2; return 1; } + [[ "$(printf '%s' "$out" | grep -c .)" == "1" ]] || { printf 'expected exactly 1 offender, got:\n%s\n' "$out" >&2; return 1; } +} + +@test "the scanner ignores control flow, chained lines, helpers and heredoc bodies" { + local fixture="$BATS_TEST_TMPDIR/quiet.bats" out + { + printf 'helper() {\n' + printf ' [[ -n "$x" ]]\n' # outside @test -> ignored + printf '}\n' + printf '@test "example" {\n' + printf ' if [[ -n "$x" ]]; then :; fi\n' # control flow -> ignored + printf ' [[ -n "$x" ]] || fail "nope"\n' # already chained -> ignored + printf " cat > f <<'EOF'\n" + printf ' [[ "embedded" == "fixture" ]]\n' # heredoc body -> ignored + printf 'EOF\n' + printf ' [[ 1 == 1 ]] || return 1\n' + printf '}\n' + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + [[ -z "$out" ]] || return 1 +} + +@test "the scanner follows the test body across a nested function's braces (Bugbot)" { + # A `name() { ... }` stub inside an @test — e.g. a `helm() { cat <<'YAML' ... }` + # mock — closes with a column-0 `}`. Ending the scan at the FIRST such `}` (instead + # of tracking brace depth) skipped every assertion after the stub. check-drift.bats + # had exactly this: an un-hardened `[ "$_drift" -ge 1 ]` after a helm() stub. + local fixture="$BATS_TEST_TMPDIR/nested.bats" out + { + printf '@test "nested" {\n' # 1 + printf ' helm() { cat <<\x27YAML\x27\n' # 2: nested-fn brace + heredoc opener + printf 'kind: Deployment\n' # 3: heredoc body -> spared + printf 'YAML\n' # 4: terminator + printf '}\n' # 5: closes helm() at column 0 + printf ' [ "$x" -ge 1 ]\n' # 6: AFTER the nested } -> flagged + printf ' [[ "$y" == ok ]] || return 1\n' # 7: hardened -> spared + printf '}\n' # 8: real end of the @test + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + [[ "$out" == *":6:"* ]] || { printf 'expected line 6 to be flagged, got:\n%s\n' "$out" >&2; return 1; } + [[ "$out" != *":3:"* ]] || { printf 'line 3 (heredoc body) must be spared, got:\n%s\n' "$out" >&2; return 1; } + [[ "$out" != *":7:"* ]] || { printf 'line 7 (hardened) must be spared, got:\n%s\n' "$out" >&2; return 1; } + [[ "$(printf '%s' "$out" | grep -c .)" == "1" ]] || { printf 'expected exactly 1 offender, got:\n%s\n' "$out" >&2; return 1; } +} + +@test "the scanner scans one-line @test bodies (Bugbot)" { + # `@test "x" { run foo; [ "$status" -eq 0 ]; }` puts the whole body on the @test + # line. Consuming that line as merely an opener never scanned the inline assertion. + # common.bats has two of these (`has: present command` / `has: absent command`). + local fixture="$BATS_TEST_TMPDIR/oneline.bats" out + { + printf '@test "unhardened" { run has bash; [ "$status" -eq 0 ]; }\n' # 1: flagged + printf '@test "hardened" { run has bash; [ "$status" -eq 0 ] || return 1; }\n' # 2: spared + printf '@test "no assertion" { run has bash; }\n' # 3: spared + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + [[ "$out" == *":1:"* ]] || { printf 'expected line 1 to be flagged, got:\n%s\n' "$out" >&2; return 1; } + [[ "$out" != *":2:"* ]] || { printf 'line 2 (hardened) must be spared, got:\n%s\n' "$out" >&2; return 1; } + [[ "$out" != *":3:"* ]] || { printf 'line 3 (no assertion) must be spared, got:\n%s\n' "$out" >&2; return 1; } + [[ "$(printf '%s' "$out" | grep -c .)" == "1" ]] || { printf 'expected exactly 1 offender, got:\n%s\n' "$out" >&2; return 1; } +} + +@test "a semicolon inside a subshell does not split a hardened assertion (Bugbot)" { + # Splitting a compound line on ';' to check each statement must ignore a ';' inside + # a ( ) subshell or $( ) substitution, or `! ( a; b ) || return 1` is torn into + # `! ( a` and reported though it is hardened. probe.bats has three of these. + local fixture="$BATS_TEST_TMPDIR/subshell.bats" out n + { + printf '@test "subshell" {\n' # 1 + printf ' ! ( cd /tmp; grep -q needle f ) || return 1\n' # 2: ; in ( ) -> spared + printf ' x=$(a; b); [ -n "$x" ] || return 1\n' # 3: ; in $( ) -> spared + printf ' ! ( cd /tmp; grep -q needle f )\n' # 4: UNHARDENED -> flagged + printf '}\n' # 5 + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + [[ "$out" == *":4:"* ]] || { printf 'expected line 4 to be flagged, got:\n%s\n' "$out" >&2; return 1; } + for n in 2 3; do + [[ "$out" != *":$n:"* ]] || { printf 'line %s should be spared, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + [[ "$(printf '%s' "$out" | grep -c .)" == "1" ]] || { printf 'expected exactly 1 offender, got:\n%s\n' "$out" >&2; return 1; } +} + +@test "a ||/&& only inside quotes or a subshell is not a top-level chain (Bugbot)" { + # The chain exemption must see a REAL top-level `||`/`&&`, not one that appears only + # inside a quoted pattern (`! grep -q "a||b"`) or a `( )` subshell — else an + # unhardened negated command is silently treated as already chained. + local fixture="$BATS_TEST_TMPDIR/chain-quotes.bats" out n + { + printf '@test "chain" {\n' + printf ' ! grep -q "a||b" f\n' # 2: || in quotes -> flagged + printf ' ! grep -q "a&&b" f\n' # 3: && in quotes -> flagged + printf ' ! ( cd /tmp; grep -q x f )\n' # 4: unhardened subshell -> flagged + printf ' ! grep -q "x||y" f || return 1\n' # 5: really hardened -> spared + printf ' ! grep -q x f || fail msg\n' # 6: top-level chain -> spared + printf '}\n' + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + for n in 2 3 4; do + [[ "$out" == *":$n:"* ]] || { printf 'expected line %s flagged, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + for n in 5 6; do + [[ "$out" != *":$n:"* ]] || { printf 'line %s should be spared, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + [[ "$(printf '%s' "$out" | grep -c .)" == "3" ]] || { printf 'expected 3 offenders, got:\n%s\n' "$out" >&2; return 1; } +} + +@test "a bracket that opens mid-line and continues across lines is joined and flagged (Bugbot)" { + # `run x; [[ a ||` continued onto the next line opens the bracket mid-line, not at + # line start; the join must still assemble it, or a multi-line compound bracket is + # never seen. Reported at its FIRST line. + local fixture="$BATS_TEST_TMPDIR/compound-multiline.bats" out + { + printf '@test "compound" {\n' + printf ' run foo; [[ "$o" == *"a"* ||\n' # 2: opens mid-line, continues + printf ' "$o" == *"b"* ]]\n' # 3: closes -> unhardened -> flagged at 2 + printf ' run bar; [[ "$o" == *"c"* ||\n' # 4: continues + printf ' "$o" == *"d"* ]] || return 1\n' # 5: hardened -> spared + printf '}\n' + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + [[ "$out" == *":2:"* ]] || { printf 'expected line 2 flagged, got:\n%s\n' "$out" >&2; return 1; } + [[ "$out" != *":4:"* ]] || { printf 'line 4 (hardened) should be spared, got:\n%s\n' "$out" >&2; return 1; } + [[ "$(printf '%s' "$out" | grep -c .)" == "1" ]] || { printf 'expected 1 offender, got:\n%s\n' "$out" >&2; return 1; } +} + +@test "one-line tests with a bare or no-space closer are still scanned (Bugbot)" { + # A one-liner whose bracket sits directly before the group-closing `}` (`[ a ] }`, + # or the no-space `[ a ]}` / `[[ a ]]}` where the closer is not even recognised) + # must still be flagged. Valid bats needs a `;` before `}`, which already splits + # the assertion off — this is belt-and-suspenders for the degenerate shapes. + local fixture="$BATS_TEST_TMPDIR/bare-closer.bats" out n + { + printf '@test "a" { run foo; [ "$s" -eq 0 ] }\n' # 1: bare } -> flagged + printf '@test "b" { run foo; [ "$s" -eq 0 ]}\n' # 2: no-space ]} -> flagged + printf '@test "c" { run foo; [[ "$o" == x ]]}\n' # 3: ]]} -> flagged + printf '@test "d" { run foo; [ "$s" -eq 0 ] || return 1; }\n' # 4: hardened -> spared + printf '@test "e" { run foo; }\n' # 5: no assertion -> spared + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + for n in 1 2 3; do + [[ "$out" == *":$n:"* ]] || { printf 'expected line %s flagged, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + for n in 4 5; do + [[ "$out" != *":$n:"* ]] || { printf 'line %s should be spared, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + [[ "$(printf '%s' "$out" | grep -c .)" == "3" ]] || { printf 'expected 3 offenders, got:\n%s\n' "$out" >&2; return 1; } +} + +@test "a bracket closer inside a quoted pattern is not mistaken for the end (Bugbot)" { + # after_close finds `]]`/`]` by a blank-before/blank-after heuristic; it must also + # skip a closer that sits INSIDE quotes (`[[ "$x" == "a ]] b" ]]`), or the real + # closer at the end is missed and an unhardened assertion looks non-standalone. + local fixture="$BATS_TEST_TMPDIR/quoted-closer.bats" out n + { + printf '@test "q" {\n' + printf ' [[ "$x" == "a ]] b" ]]\n' # 2: quoted ]] -> real closer at end -> flagged + printf ' [ "$x" = "] y" ]\n' # 3: quoted ] -> flagged + printf ' run z; [[ "$o" == "p ]] q" ]]\n' # 4: compound + quoted ]] -> flagged + printf ' [[ "$x" == "a ]] b" ]] || return 1\n' # 5: hardened -> spared + printf '}\n' + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + for n in 2 3 4; do + [[ "$out" == *":$n:"* ]] || { printf 'expected line %s flagged, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + [[ "$out" != *":5:"* ]] || { printf 'line 5 (hardened) should be spared, got:\n%s\n' "$out" >&2; return 1; } + [[ "$(printf '%s' "$out" | grep -c .)" == "3" ]] || { printf 'expected 3 offenders, got:\n%s\n' "$out" >&2; return 1; } +} + +@test "a || return 1 inside a subshell is not hardening (Bugbot)" { + # In `! ( cmd || return 1 )` the return only exits the SUBSHELL while the `!` + # still escapes errexit — the statement stays advisory. is_enforcing must + # require the `|| return 1` at paren depth 0, or this shape is spared. + local fixture="$BATS_TEST_TMPDIR/subshell-return.bats" out n + { + printf '@test "s" {\n' + printf ' ! ( grep -q x f || return 1 )\n' # 2: return inside ( ) -> flagged + printf ' [ "$(cmd || return 1)" = y ]\n' # 3: return inside $( ) -> flagged + printf ' ! ( grep -q x f ) || return 1\n' # 4: top-level return -> spared + printf ' [ "$(cmd || true)" = y ] || return 1\n' # 5: top-level return -> spared + printf '}\n' + } > "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + for n in 2 3; do + [[ "$out" == *":$n:"* ]] || { printf 'expected line %s flagged, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + for n in 4 5; do + [[ "$out" != *":$n:"* ]] || { printf 'line %s (hardened) should be spared, got:\n%s\n' "$n" "$out" >&2; return 1; } + done + [[ "$(printf '%s' "$out" | grep -c .)" == "2" ]] || { printf 'expected 2 offenders, got:\n%s\n' "$out" >&2; return 1; } +} + +@test "a < "$fixture" + + out="$(awk -f "$SCANNER" "$fixture")" + [[ "$out" == *":3:"* ]] || { printf 'expected line 3 flagged, got:\n%s\n' "$out" >&2; return 1; } + [[ "$out" == *":7:"* ]] || { printf 'expected line 7 flagged, got:\n%s\n' "$out" >&2; return 1; } + [[ "$out" != *":5:"* ]] || { printf 'heredoc body line 5 must not be flagged, got:\n%s\n' "$out" >&2; return 1; } + [[ "$(printf '%s' "$out" | grep -c .)" == "2" ]] || { printf 'expected 2 offenders, got:\n%s\n' "$out" >&2; return 1; } +} diff --git a/scripts/tests/chart-version-guard.bats b/scripts/tests/chart-version-guard.bats index e0950642..7931965e 100644 --- a/scripts/tests/chart-version-guard.bats +++ b/scripts/tests/chart-version-guard.bats @@ -54,8 +54,8 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } printf 'kind: Job\nnew: true\n' >ingestor/templates/job.yaml commit guard - [ "$status" -eq 1 ] - [[ "$output" == *"ingestor/Chart.yaml 'version:' was NOT bumped"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"ingestor/Chart.yaml 'version:' was NOT bumped"* ]] || return 1 } @test "ingestor/templates change WITH an ingestor bump passes" { @@ -63,24 +63,24 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } bump ingestor commit guard - [ "$status" -eq 0 ] - [[ "$output" == *"ingestor chart content changed and ingestor/Chart.yaml 'version:' was bumped"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"ingestor chart content changed and ingestor/Chart.yaml 'version:' was bumped"* ]] || return 1 } @test "ingestor/values.yaml change without a bump is REJECTED" { printf 'replicas: 3\n' >ingestor/values.yaml commit guard - [ "$status" -eq 1 ] - [[ "$output" == *"ingestor/Chart.yaml"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"ingestor/Chart.yaml"* ]] || return 1 } @test "deleting an ingestor template without a bump is REJECTED" { git rm -q ingestor/templates/job.yaml commit guard - [ "$status" -eq 1 ] - [[ "$output" == *"ingestor/Chart.yaml"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"ingestor/Chart.yaml"* ]] || return 1 } @test "bumping the WRONG chart does not satisfy the other chart" { @@ -88,8 +88,8 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } bump client # client bumped, ingestor is the one that changed commit guard - [ "$status" -eq 1 ] - [[ "$output" == *"ingestor/Chart.yaml 'version:' was NOT bumped"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"ingestor/Chart.yaml 'version:' was NOT bumped"* ]] || return 1 } # ── client chart: preserved behaviour + the newly covered schema ───────────── @@ -98,8 +98,8 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } printf 'kind: Deployment\nnew: true\n' >client/templates/app.yaml commit guard - [ "$status" -eq 1 ] - [[ "$output" == *"client/Chart.yaml 'version:' was NOT bumped"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"client/Chart.yaml 'version:' was NOT bumped"* ]] || return 1 } @test "client/templates change WITH a client bump passes" { @@ -107,15 +107,15 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } bump client commit guard - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "client/values.schema.json change without a bump is REJECTED" { printf '{"type":"object","required":["x"]}\n' >client/values.schema.json commit guard - [ "$status" -eq 1 ] - [[ "$output" == *"client/Chart.yaml 'version:' was NOT bumped"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"client/Chart.yaml 'version:' was NOT bumped"* ]] || return 1 } @test "an appVersion-only edit is NOT a version bump" { @@ -123,7 +123,7 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } sed -i.bak 's/^appVersion: .*/appVersion: "2.0.0"/' client/Chart.yaml && rm -f client/Chart.yaml.bak commit guard - [ "$status" -eq 1 ] + [ "$status" -eq 1 ] || return 1 } # ── both charts in one PR ──────────────────────────────────────────────────── @@ -133,9 +133,9 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } printf 'kind: Job\nnew: true\n' >ingestor/templates/job.yaml commit guard - [ "$status" -eq 1 ] - [[ "$output" == *"client/Chart.yaml 'version:' was NOT bumped"* ]] - [[ "$output" == *"ingestor/Chart.yaml 'version:' was NOT bumped"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"client/Chart.yaml 'version:' was NOT bumped"* ]] || return 1 + [[ "$output" == *"ingestor/Chart.yaml 'version:' was NOT bumped"* ]] || return 1 } @test "both charts changed, only one bumped: fails naming just the unbumped one" { @@ -144,9 +144,9 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } bump client commit guard - [ "$status" -eq 1 ] - [[ "$output" == *"client chart content changed and client/Chart.yaml 'version:' was bumped"* ]] - [[ "$output" == *"ingestor/Chart.yaml 'version:' was NOT bumped"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"client chart content changed and client/Chart.yaml 'version:' was bumped"* ]] || return 1 + [[ "$output" == *"ingestor/Chart.yaml 'version:' was NOT bumped"* ]] || return 1 } @test "both charts changed and both bumped passes" { @@ -156,7 +156,7 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } bump ingestor commit guard - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } # ── not chart content ──────────────────────────────────────────────────────── @@ -165,8 +165,8 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } printf '# migration notes\nmore\n' >client/MIGRATION.md commit guard - [ "$status" -eq 0 ] - [[ "$output" == *"guard N/A"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"guard N/A"* ]] || return 1 } @test "ci/ and tests/ values are packaged but never rendered: N/A" { @@ -174,16 +174,16 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } printf 'public: false\n' >client/tests/values-public-images.yaml commit guard - [ "$status" -eq 0 ] - [[ "$output" == *"guard N/A"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"guard N/A"* ]] || return 1 } @test "a Chart.yaml bump with no content change is N/A, not an error" { bump client commit guard - [ "$status" -eq 0 ] - [[ "$output" == *"guard N/A"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"guard N/A"* ]] || return 1 } # ── the chart list is DERIVED, so a new published chart is guarded on day one ─ @@ -199,8 +199,8 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } printf 'kind: ConfigMap\nnew: true\n' >extra/templates/cm.yaml commit guard - [ "$status" -eq 1 ] - [[ "$output" == *"extra/Chart.yaml 'version:' was NOT bumped"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"extra/Chart.yaml 'version:' was NOT bumped"* ]] || return 1 } @test "a chart removed from the release workflow stops being guarded" { @@ -208,22 +208,22 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } printf 'kind: Job\nnew: true\n' >ingestor/templates/job.yaml commit guard - [ "$status" -eq 0 ] - [[ "$output" == *"guard N/A"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"guard N/A"* ]] || return 1 } # ── fail closed: a guard that cannot verify must not claim it did ──────────── @test "missing BASE_SHA fails closed" { run env -u BASE_SHA bash "$GUARD_SH" - [ "$status" -eq 1 ] - [[ "$output" == *"could not determine the PR base SHA"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"could not determine the PR base SHA"* ]] || return 1 } @test "unusable BASE_SHA fails closed" { run env BASE_SHA=0000000000000000000000000000000000000000 bash "$GUARD_SH" - [ "$status" -eq 1 ] - [[ "$output" == *"refusing to report N/A without checking"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"refusing to report N/A without checking"* ]] || return 1 } @test "an absent release workflow fails closed" { @@ -231,8 +231,8 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } printf 'kind: Job\nnew: true\n' >ingestor/templates/job.yaml commit guard - [ "$status" -eq 1 ] - [[ "$output" == *"could not read the packaged chart list"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"could not read the packaged chart list"* ]] || return 1 } @test "a release workflow that packages nothing fails closed" { @@ -240,16 +240,16 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } printf 'kind: Job\nnew: true\n' >ingestor/templates/job.yaml commit guard - [ "$status" -eq 1 ] - [[ "$output" == *"could not read the packaged chart list"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"could not read the packaged chart list"* ]] || return 1 } @test "a packaged chart with no Chart.yaml fails closed" { seed_workflow './client' './ingestor' './ghost' commit guard - [ "$status" -eq 1 ] - [[ "$output" == *"ghost/Chart.yaml does not exist"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"ghost/Chart.yaml does not exist"* ]] || return 1 } # ── the SIGPIPE class this guard must never regress into ──────────────────── @@ -265,8 +265,8 @@ guard() { run env BASE_SHA="$BASE" bash "$GUARD_SH"; } commit # Prove the list really is past the buffer that flipped the old pipeline. bytes="$(git diff --name-only "${BASE}...HEAD" | wc -c)" - [ "$bytes" -gt 65622 ] + [ "$bytes" -gt 65622 ] || return 1 guard - [ "$status" -eq 1 ] - [[ "$output" == *"ingestor/Chart.yaml 'version:' was NOT bumped"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"ingestor/Chart.yaml 'version:' was NOT bumped"* ]] || return 1 } diff --git a/scripts/tests/check-drift.bats b/scripts/tests/check-drift.bats index 5625db98..0998d8a8 100644 --- a/scripts/tests/check-drift.bats +++ b/scripts/tests/check-drift.bats @@ -39,33 +39,33 @@ YAML # ── Check 1: backend host parity ───────────────────────────────────────────── @test "backend hosts: all three files agree -> no drift" { - _drift=0; _drift_backend_hosts >/dev/null; [ "$_drift" -eq 0 ] + _drift=0; _drift_backend_hosts >/dev/null; [ "$_drift" -eq 0 ] || return 1 } @test "backend hosts: one file diverges (missing stg) -> drift" { printf '_backend_url(){ printf https://dev-api.tracebloc.io/; printf https://api.tracebloc.io/; }\n' > "$DRIFT_ROOT/scripts/lib/install-client-helm.sh" - _drift=0; _drift_backend_hosts >/dev/null; [ "$_drift" -ge 1 ] + _drift=0; _drift_backend_hosts >/dev/null; [ "$_drift" -ge 1 ] || return 1 } @test "backend hosts: prod host renamed in one file -> drift" { printf 'function Get-BackendUrl { "dev-api.tracebloc.io"; "stg-api.tracebloc.io"; "prod.tracebloc.io" }\n' > "$DRIFT_ROOT/scripts/install-k8s.ps1" - _drift=0; _drift_backend_hosts >/dev/null; [ "$_drift" -ge 1 ] + _drift=0; _drift_backend_hosts >/dev/null; [ "$_drift" -ge 1 ] || return 1 } @test "backend hosts: function removed (no hosts) -> drift" { echo '# backend function gone' > "$DRIFT_ROOT/scripts/lib/preflight.sh" - _drift=0; _drift_backend_hosts >/dev/null; [ "$_drift" -ge 1 ] + _drift=0; _drift_backend_hosts >/dev/null; [ "$_drift" -ge 1 ] || return 1 } # ── Check 2: workload-name contract ────────────────────────────────────────── @test "workloads: scripts + chart both carry all names -> no drift" { - _drift=0; _drift_workload_names >/dev/null 2>&1; [ "$_drift" -eq 0 ] + _drift=0; _drift_workload_names >/dev/null 2>&1; [ "$_drift" -eq 0 ] || return 1 } @test "workloads: a contract name dropped from the scripts -> drift (2a)" { printf 'deploys=("mysql-client")\n' > "$DRIFT_ROOT/scripts/lib/summary.sh" printf 'echo no-workloads-here\n' > "$DRIFT_ROOT/scripts/lib/diagnose.sh" - _drift=0; _drift_workload_names >/dev/null 2>&1; [ "$_drift" -ge 1 ] + _drift=0; _drift_workload_names >/dev/null 2>&1; [ "$_drift" -ge 1 ] || return 1 } @test "workloads: chart render missing a name -> drift (2b)" { @@ -81,26 +81,26 @@ metadata: name: tracebloc-resource-monitor YAML } # tracebloc-requests-proxy is absent - _drift=0; _drift_workload_names >/dev/null 2>&1; [ "$_drift" -ge 1 ] + _drift=0; _drift_workload_names >/dev/null 2>&1; [ "$_drift" -ge 1 ] || return 1 } @test "workloads: helm unavailable -> 2b skipped, no drift from the render half" { command() { if [[ "${2:-}" == helm ]]; then return 1; fi; builtin command "$@"; } - _drift=0; _drift_workload_names >/dev/null 2>&1; [ "$_drift" -eq 0 ] + _drift=0; _drift_workload_names >/dev/null 2>&1; [ "$_drift" -eq 0 ] || return 1 } # ── Check 4: in-node CA trust parity (#424) ────────────────────────────────── @test "ca trust: both installers wire the CA -> no drift (#424)" { printf 'TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE _resolve_ca_bundle --registry-config tracebloc-mitm-ca.crt _host_ca_create_hint\n' > "$ROOT/scripts/lib/cluster.sh" printf 'TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE Resolve-CaBundle --registry-config tracebloc-mitm-ca.crt Write-HostCaCreateHint\n' > "$ROOT/scripts/install-k8s.ps1" - _drift=0; _drift_ca_trust >/dev/null; [ "$_drift" -eq 0 ] + _drift=0; _drift_ca_trust >/dev/null; [ "$_drift" -eq 0 ] || return 1 } @test "ca trust: an installer missing the host-daemon CA hint -> drift (#474)" { printf 'TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE _resolve_ca_bundle --registry-config tracebloc-mitm-ca.crt _host_ca_create_hint\n' > "$ROOT/scripts/lib/cluster.sh" # ps1 wires the in-node CA but drops the host-daemon create hint (Write-HostCaCreateHint) printf 'TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE Resolve-CaBundle --registry-config tracebloc-mitm-ca.crt\n' > "$ROOT/scripts/install-k8s.ps1" - _drift=0; _drift_ca_trust >/dev/null 2>&1; [ "$_drift" -ge 1 ] + _drift=0; _drift_ca_trust >/dev/null 2>&1; [ "$_drift" -ge 1 ] || return 1 } @test "ca trust: an installer missing the registry-config -> drift (#424)" { @@ -108,7 +108,7 @@ YAML # that single omission (all other required tokens, incl the host-CA hints, present). printf 'TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE _resolve_ca_bundle --registry-config tracebloc-mitm-ca.crt _host_ca_create_hint\n' > "$ROOT/scripts/lib/cluster.sh" printf 'TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE Resolve-CaBundle tracebloc-mitm-ca.crt Write-HostCaCreateHint\n' > "$ROOT/scripts/install-k8s.ps1" - _drift=0; _drift_ca_trust >/dev/null 2>&1; [ "$_drift" -ge 1 ] + _drift=0; _drift_ca_trust >/dev/null 2>&1; [ "$_drift" -ge 1 ] || return 1 } @test "ca trust: --registry-config only in a COMMENT does NOT count -> drift (Bugbot #424)" { @@ -118,7 +118,7 @@ YAML # a whole-file grep would pass. (Bugbot: don't let a missing new token mask it.) printf '# uses --registry-config to point containerd at the CA\nTRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE _resolve_ca_bundle tracebloc-mitm-ca.crt _host_ca_create_hint\n' > "$ROOT/scripts/lib/cluster.sh" printf 'TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE Resolve-CaBundle --registry-config tracebloc-mitm-ca.crt Write-HostCaCreateHint\n' > "$ROOT/scripts/install-k8s.ps1" - _drift=0; _drift_ca_trust >/dev/null 2>&1; [ "$_drift" -ge 1 ] + _drift=0; _drift_ca_trust >/dev/null 2>&1; [ "$_drift" -ge 1 ] || return 1 } # ── Check 4b: tool execute-gate parity (#411) ──────────────────────────────── @@ -126,21 +126,21 @@ YAML printf 'assert_tool_runs kubectl version --client\nassert_tool_runs k3d version\nassert_tool_runs helm version --short\n' > "$ROOT/scripts/lib/setup-linux.sh" printf 'assert_tool_runs kubectl version --client\nassert_tool_runs k3d version\nassert_tool_runs helm version --short\n' > "$ROOT/scripts/lib/setup-macos.sh" printf 'Assert-ToolRuns -Name "kubectl" -VersionArgs @("version","--client")\nAssert-ToolRuns -Name "k3d" -VersionArgs @("version")\nAssert-ToolRuns -Name "helm" -VersionArgs @("version","--short")\n' >> "$ROOT/scripts/install-k8s.ps1" - _drift=0; _drift_execute_gates >/dev/null; [ "$_drift" -eq 0 ] + _drift=0; _drift_execute_gates >/dev/null; [ "$_drift" -eq 0 ] || return 1 } @test "execute-gates: an installer missing a tool gate -> drift (#411)" { printf 'assert_tool_runs kubectl version --client\nassert_tool_runs k3d version\nassert_tool_runs helm version\n' > "$ROOT/scripts/lib/setup-linux.sh" printf 'assert_tool_runs kubectl version --client\nassert_tool_runs k3d version\n' > "$ROOT/scripts/lib/setup-macos.sh" # no helm gate printf 'Assert-ToolRuns -Name "kubectl" -VersionArgs @("version","--client")\nAssert-ToolRuns -Name "k3d" -VersionArgs @("version")\nAssert-ToolRuns -Name "helm" -VersionArgs @("version")\n' >> "$ROOT/scripts/install-k8s.ps1" - _drift=0; _drift_execute_gates >/dev/null 2>&1; [ "$_drift" -ge 1 ] + _drift=0; _drift_execute_gates >/dev/null 2>&1; [ "$_drift" -ge 1 ] || return 1 } @test "execute-gates: the --rm form is recognized as a gate (#411 review)" { printf 'assert_tool_runs --rm "$TB_TOOLS_DIR/kubectl" kubectl version --client\nassert_tool_runs --rm "$TB_TOOLS_DIR/k3d" k3d version\nassert_tool_runs helm version\n' > "$ROOT/scripts/lib/setup-linux.sh" printf 'assert_tool_runs kubectl version --client\nassert_tool_runs k3d version\nassert_tool_runs helm version\n' > "$ROOT/scripts/lib/setup-macos.sh" printf 'Assert-ToolRuns -Name "kubectl" -VersionArgs @("version","--client")\nAssert-ToolRuns -Name "k3d" -VersionArgs @("version")\nAssert-ToolRuns -Name "helm" -VersionArgs @("version")\n' >> "$ROOT/scripts/install-k8s.ps1" - _drift=0; _drift_execute_gates >/dev/null; [ "$_drift" -eq 0 ] + _drift=0; _drift_execute_gates >/dev/null; [ "$_drift" -eq 0 ] || return 1 } @test "execute-gates: a COMMENTED-OUT gate does NOT count -> drift (Bugbot #411)" { @@ -149,7 +149,7 @@ YAML printf 'assert_tool_runs kubectl version --client\nassert_tool_runs k3d version\n# assert_tool_runs helm version\n' > "$ROOT/scripts/lib/setup-linux.sh" printf 'assert_tool_runs kubectl version --client\nassert_tool_runs k3d version\nassert_tool_runs helm version\n' > "$ROOT/scripts/lib/setup-macos.sh" printf 'Assert-ToolRuns -Name "kubectl" -VersionArgs @("version","--client")\nAssert-ToolRuns -Name "k3d" -VersionArgs @("version")\n# Assert-ToolRuns -Name "helm" -VersionArgs @("version")\n' >> "$ROOT/scripts/install-k8s.ps1" - _drift=0; _drift_execute_gates >/dev/null 2>&1; [ "$_drift" -ge 1 ] + _drift=0; _drift_execute_gates >/dev/null 2>&1; [ "$_drift" -ge 1 ] || return 1 } @test "execute-gates: macOS delegating to the gated install_ counts as a gate -> no drift (#429)" { @@ -159,14 +159,14 @@ YAML printf 'assert_tool_runs --rm "$D/kubectl" kubectl version --client\nassert_tool_runs --rm "$D/k3d" k3d version\nassert_tool_runs --rm "$D/helm" helm version\n' > "$ROOT/scripts/lib/setup-linux.sh" printf 'install_macos_cli_tools() {\n install_kubectl\n install_k3d\n install_helm\n}\n' > "$ROOT/scripts/lib/setup-macos.sh" printf 'Assert-ToolRuns -Name "kubectl" -VersionArgs @("version","--client")\nAssert-ToolRuns -Name "k3d" -VersionArgs @("version")\nAssert-ToolRuns -Name "helm" -VersionArgs @("version")\n' >> "$ROOT/scripts/install-k8s.ps1" - _drift=0; _drift_execute_gates >/dev/null; [ "$_drift" -eq 0 ] + _drift=0; _drift_execute_gates >/dev/null; [ "$_drift" -eq 0 ] || return 1 } @test "execute-gates: macOS with NEITHER a direct gate NOR the delegating call -> drift (#429)" { printf 'assert_tool_runs kubectl version --client\nassert_tool_runs k3d version\nassert_tool_runs helm version\n' > "$ROOT/scripts/lib/setup-linux.sh" printf 'install_macos_cli_tools() {\n install_kubectl\n install_k3d\n}\n' > "$ROOT/scripts/lib/setup-macos.sh" # no helm gate NOR install_helm printf 'Assert-ToolRuns -Name "kubectl" -VersionArgs @("version","--client")\nAssert-ToolRuns -Name "k3d" -VersionArgs @("version")\nAssert-ToolRuns -Name "helm" -VersionArgs @("version")\n' >> "$ROOT/scripts/install-k8s.ps1" - _drift=0; _drift_execute_gates >/dev/null 2>&1; [ "$_drift" -ge 1 ] + _drift=0; _drift_execute_gates >/dev/null 2>&1; [ "$_drift" -ge 1 ] || return 1 } # ── Check 5: preflight download-host parity (#416) ─────────────────────────── @@ -180,7 +180,7 @@ YAML printf ' "L (%s)|https://%s/"\n' "$h" "$h" >> "$ROOT/scripts/lib/preflight.sh" printf ' @{ label = "L (%s)"; url = "https://%s/" }\n' "$h" "$h" >> "$ROOT/scripts/install-k8s.ps1" done - _drift=0; _drift_preflight_hosts >/dev/null; [ "$_drift" -eq 0 ] + _drift=0; _drift_preflight_hosts >/dev/null; [ "$_drift" -eq 0 ] || return 1 } @test "preflight hosts: a probe entry missing from ps1 -> drift (#416)" { @@ -189,7 +189,7 @@ YAML printf ' "L (%s)|https://%s/"\n' "$h" "$h" >> "$ROOT/scripts/lib/preflight.sh" [[ "$h" == "dl.k8s.io" ]] || printf ' @{ label = "L (%s)"; url = "https://%s/" }\n' "$h" "$h" >> "$ROOT/scripts/install-k8s.ps1" done - _drift=0; _drift_preflight_hosts >/dev/null 2>&1; [ "$_drift" -ge 1 ] + _drift=0; _drift_preflight_hosts >/dev/null 2>&1; [ "$_drift" -ge 1 ] || return 1 } @test "preflight hosts: a host present only in a COMMENT/hint is NOT counted -> drift (reviewer #416)" { @@ -204,7 +204,7 @@ YAML printf ' @{ label = "L (%s)"; url = "https://%s/" }\n' "$h" "$h" >> "$ROOT/scripts/install-k8s.ps1" fi done - _drift=0; _drift_preflight_hosts >/dev/null 2>&1; [ "$_drift" -ge 1 ] + _drift=0; _drift_preflight_hosts >/dev/null 2>&1; [ "$_drift" -ge 1 ] || return 1 } @test "preflight hosts: an unrelated \$url= download line does NOT mask a deleted probe -> drift (Bugbot #416)" { @@ -219,5 +219,5 @@ YAML printf ' @{ label = "L (%s)"; url = "https://%s/" }\n' "$h" "$h" >> "$ROOT/scripts/install-k8s.ps1" fi done - _drift=0; _drift_preflight_hosts >/dev/null 2>&1; [ "$_drift" -ge 1 ] + _drift=0; _drift_preflight_hosts >/dev/null 2>&1; [ "$_drift" -ge 1 ] || return 1 } diff --git a/scripts/tests/check-facts.bats b/scripts/tests/check-facts.bats index f11eac93..6eb4ca8a 100644 --- a/scripts/tests/check-facts.bats +++ b/scripts/tests/check-facts.bats @@ -41,8 +41,8 @@ _set_spec() { local tmp; tmp="$(mktemp)"; sed "s|^$1=.*|$1=$2|" "$REPO/scripts/s @test "check-facts --check: all consumers match the spec -> passes (#435)" { run _facts --check - [ "$status" -eq 0 ] - [[ "$output" == *"all installer facts match"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"all installer facts match"* ]] || return 1 } @test "check-facts --check: K8S_VERSION drift in PowerShell (not just bash) -> RED (#435 Bugbot)" { @@ -51,8 +51,8 @@ _set_spec() { local tmp; tmp="$(mktemp)"; sed "s|^$1=.*|$1=$2|" "$REPO/scripts/s local tmp; tmp="$(mktemp)" sed 's|"v1.29.4-k3s1"|"v1.30.0-k3s1"|' "$REPO/scripts/install-k8s.ps1" > "$tmp" && mv "$tmp" "$REPO/scripts/install-k8s.ps1" run _facts --check - [ "$status" -ne 0 ] - [[ "$output" == *"install-k8s.ps1:K8S_VERSION"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"install-k8s.ps1:K8S_VERSION"* ]] || return 1 } @test "check-facts --write: a K8S_VERSION bump stamps BOTH bash and PowerShell (#435 Bugbot)" { @@ -60,7 +60,7 @@ _set_spec() { local tmp; tmp="$(mktemp)"; sed "s|^$1=.*|$1=$2|" "$REPO/scripts/s _facts --write grep -q 'K8S_VERSION="${K8S_VERSION:-v1.31.0-k3s1}"' "$REPO/scripts/lib/common.sh" # bash grep -q 'else { "v1.31.0-k3s1" }' "$REPO/scripts/install-k8s.ps1" # PowerShell - run _facts --check; [ "$status" -eq 0 ] + run _facts --check; [ "$status" -eq 0 ] || return 1 } @test "check-facts --check: #410 incident — bash pin bumped, PowerShell NOT -> RED (#435)" { @@ -69,27 +69,27 @@ _set_spec() { local tmp; tmp="$(mktemp)"; sed "s|^$1=.*|$1=$2|" "$REPO/scripts/s local tmp; tmp="$(mktemp)" sed 's|v5.9.0|v5.9.9|' "$REPO/scripts/lib/common.sh" > "$tmp" && mv "$tmp" "$REPO/scripts/lib/common.sh" run _facts --check - [ "$status" -ne 0 ] # red CI check - [[ "$output" == *"common.sh:K3D_VERSION"* ]] - [[ "$output" == *"drifted"* ]] + [ "$status" -ne 0 ] || return 1 # red CI check + [[ "$output" == *"common.sh:K3D_VERSION"* ]] || return 1 + [[ "$output" == *"drifted"* ]] || return 1 } @test "check-facts --check: PowerShell pin drifts from bash+spec -> RED (#435)" { local tmp; tmp="$(mktemp)" sed 's|"v5.9.0"|"v5.8.0"|' "$REPO/scripts/install-k8s.ps1" > "$tmp" && mv "$tmp" "$REPO/scripts/install-k8s.ps1" run _facts --check - [ "$status" -ne 0 ] - [[ "$output" == *"install-k8s.ps1:K3dVersion"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"install-k8s.ps1:K3dVersion"* ]] || return 1 } @test "check-facts --write: bumping the spec stamps EVERY consumer, then --check passes (#435)" { _set_spec K3D_VERSION v9.9.9 run _facts --write - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 grep -q 'K3D_VERSION="${K3D_VERSION:-v9.9.9}"' "$REPO/scripts/lib/common.sh" # bash stamped grep -q 'else { "v9.9.9" }' "$REPO/scripts/install-k8s.ps1" # PowerShell stamped run _facts --check # now consistent - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "check-facts --write: HELM + K8S bumps stamp their consumers (#435)" { @@ -99,15 +99,15 @@ _set_spec() { local tmp; tmp="$(mktemp)"; sed "s|^$1=.*|$1=$2|" "$REPO/scripts/s grep -q 'HELM_VERSION="${HELM_VERSION:-v5.0.0}"' "$REPO/scripts/lib/common.sh" grep -q 'else { "v5.0.0" }' "$REPO/scripts/install-k8s.ps1" grep -q 'K8S_VERSION="${K8S_VERSION:-v1.30.0-k3s1}"' "$REPO/scripts/lib/common.sh" - run _facts --check; [ "$status" -eq 0 ] + run _facts --check; [ "$status" -eq 0 ] || return 1 } @test "check-facts --check: READY_TIMEOUT (a real timeout budget) drift in ps1 -> RED (#435)" { local tmp; tmp="$(mktemp)" sed 's|"300"|"600"|' "$REPO/scripts/install-k8s.ps1" > "$tmp" && mv "$tmp" "$REPO/scripts/install-k8s.ps1" run _facts --check - [ "$status" -ne 0 ] - [[ "$output" == *"install-k8s.ps1:ReadyTimeout"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"install-k8s.ps1:ReadyTimeout"* ]] || return 1 } @test "check-facts --write: bumping the READY_TIMEOUT budget stamps bash + PowerShell (#435)" { @@ -115,19 +115,19 @@ _set_spec() { local tmp; tmp="$(mktemp)"; sed "s|^$1=.*|$1=$2|" "$REPO/scripts/s _facts --write grep -q 'READY_TIMEOUT="${READY_TIMEOUT:-600}"' "$REPO/scripts/lib/summary.sh" # bash consumer grep -q 'else { "600" }' "$REPO/scripts/install-k8s.ps1" # PowerShell consumer - run _facts --check; [ "$status" -eq 0 ] + run _facts --check; [ "$status" -eq 0 ] || return 1 } @test "check-facts: a missing pattern (consumer refactored away) fails closed, not silently green (#435)" { echo "# no k3d pin here anymore" > "$REPO/scripts/lib/common.sh" run _facts --check - [ "$status" -ne 0 ] - [[ "$output" == *"no pinned value found"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"no pinned value found"* ]] || return 1 } @test "check-facts: an unknown mode is rejected (#435)" { run _facts --bogus - [ "$status" -eq 2 ] + [ "$status" -eq 2 ] || return 1 } # --- pipefail + `sed | head -1` SIGPIPE regressions (#542 Bugbot) ----------- @@ -148,8 +148,8 @@ _set_spec() { local tmp; tmp="$(mktemp)"; sed "s|^$1=.*|$1=$2|" "$REPO/scripts/s # even if a future edit drops the trailing printf that masked the old pipe's exit code. { i=0; while [ "$i" -lt 20000 ]; do echo "K3D_VERSION=v5.9.0"; i=$((i + 1)); done; } >> "$REPO/scripts/spec/facts.env" run _facts --check - [ "$status" -eq 0 ] - [[ "$output" == *"all installer facts match"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"all installer facts match"* ]] || return 1 } @test "check-facts --check: a duplicated consumer pin does not SIGPIPE _extract (#542 Bugbot)" { @@ -158,15 +158,15 @@ _set_spec() { local tmp; tmp="$(mktemp)"; sed "s|^$1=.*|$1=$2|" "$REPO/scripts/s # and aborted the gate. The first line still equals the spec, so drift is zero. { i=0; while [ "$i" -lt 20000 ]; do echo 'K3D_VERSION="${K3D_VERSION:-v5.9.0}"'; i=$((i + 1)); done; } >> "$REPO/scripts/lib/common.sh" run _facts --check - [ "$status" -eq 0 ] - [[ "$output" == *"all installer facts match"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"all installer facts match"* ]] || return 1 } @test "check-facts --check: a missing create-time --image pin fails with a WIRING message, not the --write hint (#547 / Bugbot)" { # versions all still correct, but strip the k3s --image wiring from cluster.sh printf '%s\n' '# stub without the k3s --image pin' > "$REPO/scripts/lib/cluster.sh" run _facts --check - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 # must NOT point the dev at --write (it cannot restore create-time wiring) if printf '%s\n' "$output" | grep -qF "Run 'scripts/check-facts.sh --write'"; then echo "unexpected --write hint for a wiring failure:" >&2; printf '%s\n' "$output" >&2; return 1 diff --git a/scripts/tests/cluster.bats b/scripts/tests/cluster.bats index b5c5a3be..d35c5784 100644 --- a/scripts/tests/cluster.bats +++ b/scripts/tests/cluster.bats @@ -16,6 +16,7 @@ setup() { HOST_DATA_DIR="$BATS_TEST_TMPDIR/data" SERVERS=1; AGENTS=0; K8S_VERSION=""; K3D_GPU_FLAGS=() unset HTTP_PROXY HTTPS_PROXY NO_PROXY http_proxy https_proxy no_proxy + unset TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE SSL_CERT_FILE GIT_SSL_CAINFO # k3d mock: record argv; if a --config is present, snapshot the file so # a test can assert its contents (cluster.sh deletes the temp dir after create). @@ -39,33 +40,33 @@ setup() { # ── _augment_no_proxy (Gap B) ─────────────────────────────────────────────── @test "_augment_no_proxy: empty host NO_PROXY -> cluster-internal defaults" { run _augment_no_proxy - [ "$status" -eq 0 ] - [[ "$output" == *"localhost"* ]] - [[ "$output" == *"169.254.169.254"* ]] - [[ "$output" == *"127.0.0.1"* ]] - [[ "$output" == *"10.0.0.0/8"* ]] - [[ "$output" == *".svc"* ]] - [[ "$output" == *".cluster.local"* ]] - [[ "$output" == *"host.k3d.internal"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"localhost"* ]] || return 1 + [[ "$output" == *"169.254.169.254"* ]] || return 1 + [[ "$output" == *"127.0.0.1"* ]] || return 1 + [[ "$output" == *"10.0.0.0/8"* ]] || return 1 + [[ "$output" == *".svc"* ]] || return 1 + [[ "$output" == *".cluster.local"* ]] || return 1 + [[ "$output" == *"host.k3d.internal"* ]] || return 1 } @test "_augment_no_proxy: host entries kept first and de-duplicated" { NO_PROXY="foo.com,127.0.0.1" run _augment_no_proxy - [[ "$output" == "foo.com,127.0.0.1,"* ]] # host entries first - [ "$(grep -o '127\.0\.0\.1' <<<"$output" | wc -l | tr -d ' ')" -eq 1 ] # deduped + [[ "$output" == "foo.com,127.0.0.1,"* ]] || return 1 # host entries first + [ "$(grep -o '127\.0\.0\.1' <<<"$output" | wc -l | tr -d ' ')" -eq 1 ] || return 1 # deduped } @test "_augment_no_proxy: lowercase no_proxy is honoured" { no_proxy="bar.internal" run _augment_no_proxy - [[ "$output" == "bar.internal,"* ]] + [[ "$output" == "bar.internal,"* ]] || return 1 } # ── _write_k3d_proxy_config (Gap A + B) ───────────────────────────────────── @test "_write_k3d_proxy_config: no proxy set -> empty (no file)" { run _write_k3d_proxy_config - [ -z "$output" ] + [ -z "$output" ] || return 1 } @test "_write_k3d_proxy_config: auth creds preserved (Gap A) + augmented NO_PROXY (Gap B)" { @@ -73,9 +74,9 @@ setup() { HTTPS_PROXY="http://user:pass@proxy.example.com:8080" NO_PROXY="corp.internal" run _write_k3d_proxy_config - [ -n "$output" ] + [ -n "$output" ] || return 1 local cfg="$output" - [ -f "$cfg" ] + [ -f "$cfg" ] || return 1 grep -q 'apiVersion: k3d.io/v1alpha5' "$cfg" grep -q 'nodeFilters' "$cfg" # the whole point of Gap A: the embedded '@' credentials survive intact @@ -92,7 +93,7 @@ setup() { HTTP_PROXY="http://proxy:8080" run _write_k3d_proxy_config local cfg="$output" - [ -f "$cfg" ] + [ -f "$cfg" ] || return 1 grep -Eq 'NO_PROXY=.*127\.0\.0\.1' "$cfg" rm -rf "${cfg%/*}" } @@ -101,107 +102,107 @@ setup() { @test "_export_host_no_proxy: exports augmented NO_PROXY when a proxy is set" { HTTP_PROXY="http://proxy:8080" _export_host_no_proxy - [[ "$NO_PROXY" == *"127.0.0.1"* ]] - [[ "$no_proxy" == *".svc"* ]] + [[ "$NO_PROXY" == *"127.0.0.1"* ]] || return 1 + [[ "$no_proxy" == *".svc"* ]] || return 1 } @test "_export_host_no_proxy: no-op when no proxy is set" { _export_host_no_proxy - [ -z "${NO_PROXY:-}" ] + [ -z "${NO_PROXY:-}" ] || return 1 } # ── _create_new_cluster: proxy propagation via --config (Gap A integration) ── @test "_create_new_cluster: auth proxy propagated via --config, not skipped" { HTTP_PROXY="http://user:pass@proxy.example.com:8080" run _create_new_cluster - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"k3d cluster create"* ]] - [[ "$output" == *"--config"* ]] - [[ "$output" != *"Skipping"* ]] # old @-skip path is gone + [[ "$output" == *"k3d cluster create"* ]] || return 1 + [[ "$output" == *"--config"* ]] || return 1 + [[ "$output" != *"Skipping"* ]] || return 1 # old @-skip path is gone grep -q 'user:pass@proxy.example.com' "$CFG_CAPTURE" } @test "_create_new_cluster: no proxy -> no --config flag" { run _create_new_cluster - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"k3d cluster create"* ]] - [[ "$output" != *"--config"* ]] + [[ "$output" == *"k3d cluster create"* ]] || return 1 + [[ "$output" != *"--config"* ]] || return 1 } # ── HOST_DATASET_DIR: second bind-mount + dataset dir split (backend#743) ──── @test "_create_new_cluster: HOST_DATASET_DIR unset -> single /tracebloc mount" { run _create_new_cluster - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"${HOST_DATA_DIR}:/tracebloc@all"* ]] - [[ "$output" != *"/tracebloc-data@all"* ]] + [[ "$output" == *"${HOST_DATA_DIR}:/tracebloc@all"* ]] || return 1 + [[ "$output" != *"/tracebloc-data@all"* ]] || return 1 } @test "_create_new_cluster: HOST_DATASET_DIR set -> adds a distinct /tracebloc-data mount" { HOST_DATASET_DIR="$BATS_TEST_TMPDIR/ds"; mkdir -p "$HOST_DATASET_DIR" run _create_new_cluster - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"${HOST_DATA_DIR}:/tracebloc@all"* ]] # mysql/logs stay local - [[ "$output" == *"${HOST_DATASET_DIR}:/tracebloc-data@all"* ]] # datasets on the mount + [[ "$output" == *"${HOST_DATA_DIR}:/tracebloc@all"* ]] || return 1 # mysql/logs stay local + [[ "$output" == *"${HOST_DATASET_DIR}:/tracebloc-data@all"* ]] || return 1 # datasets on the mount } # ── RFC-0003 Option C: node-local storage (client#367) ────────────────────── @test "_create_new_cluster: node-local -> no host bind-mount, keeps k3s local-storage" { TB_STORAGE_MODE="node-local" run _create_new_cluster - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"k3d cluster create"* ]] - [[ "$output" != *"/tracebloc@all"* ]] # no ~/.tracebloc bind-mount - [[ "$output" != *"--disable=local-storage"* ]] # keep local-path provisioner + [[ "$output" == *"k3d cluster create"* ]] || return 1 + [[ "$output" != *"/tracebloc@all"* ]] || return 1 # no ~/.tracebloc bind-mount + [[ "$output" != *"--disable=local-storage"* ]] || return 1 # keep local-path provisioner } @test "_create_new_cluster: hostpath (default) -> bind-mount + disables local-storage" { run _create_new_cluster - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"${HOST_DATA_DIR}:/tracebloc@all"* ]] - [[ "$output" == *"--disable=local-storage"* ]] + [[ "$output" == *"${HOST_DATA_DIR}:/tracebloc@all"* ]] || return 1 + [[ "$output" == *"--disable=local-storage"* ]] || return 1 } @test "_ensure_release_dirs: HOST_DATASET_DIR set -> data on dataset dir, mysql+logs local" { HOST_DATASET_DIR="$BATS_TEST_TMPDIR/ds"; mkdir -p "$HOST_DATASET_DIR" _ensure_release_dirs tracebloc - [ -d "$HOST_DATASET_DIR/tracebloc/data" ] # dataset on the (network) mount - [ -d "$HOST_DATA_DIR/tracebloc/logs" ] # logs stay local - [ -d "$HOST_DATA_DIR/tracebloc/mysql" ] # mysql stays local - [ ! -d "$HOST_DATA_DIR/tracebloc/data" ] # data NOT created on the local tree + [ -d "$HOST_DATASET_DIR/tracebloc/data" ] || return 1 # dataset on the (network) mount + [ -d "$HOST_DATA_DIR/tracebloc/logs" ] || return 1 # logs stay local + [ -d "$HOST_DATA_DIR/tracebloc/mysql" ] || return 1 # mysql stays local + [ ! -d "$HOST_DATA_DIR/tracebloc/data" ] || return 1 # data NOT created on the local tree } @test "_ensure_release_dirs: HOST_DATASET_DIR unset -> data stays local (unchanged)" { _ensure_release_dirs tracebloc - [ -d "$HOST_DATA_DIR/tracebloc/data" ] - [ -d "$HOST_DATA_DIR/tracebloc/logs" ] - [ -d "$HOST_DATA_DIR/tracebloc/mysql" ] + [ -d "$HOST_DATA_DIR/tracebloc/data" ] || return 1 + [ -d "$HOST_DATA_DIR/tracebloc/logs" ] || return 1 + [ -d "$HOST_DATA_DIR/tracebloc/mysql" ] || return 1 } # ── _check_existing_cluster_bind (Gap C) ──────────────────────────────────── @test "_check_existing_cluster_bind: 0.0.0.0 bind -> warns (created outside installer)" { docker() { echo "0.0.0.0 0.0.0.0 "; } run _check_existing_cluster_bind - [[ "$output" == *"0.0.0.0"* ]] - [[ "$output" == *"created outside this installer"* ]] + [[ "$output" == *"0.0.0.0"* ]] || return 1 + [[ "$output" == *"created outside this installer"* ]] || return 1 } @test "_check_existing_cluster_bind: 127.0.0.1 bind -> silent" { docker() { echo "127.0.0.1 "; } run _check_existing_cluster_bind - [ -z "$output" ] + [ -z "$output" ] || return 1 } @test "_check_existing_cluster_bind: inspect fails -> silent no-op" { docker() { return 1; } run _check_existing_cluster_bind - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } # ── _check_existing_cluster_proxy: drift + auth-bucket regression ──────────── @@ -209,15 +210,15 @@ setup() { HTTP_PROXY="http://u:p@proxy:8080" docker() { echo "HTTP_PROXY=http://u:p@proxy:8080"; } # baked into the cluster run _check_existing_cluster_proxy - [[ "$output" != *"embedded credentials"* ]] - [[ "$output" != *"can't carry an"* ]] + [[ "$output" != *"embedded credentials"* ]] || return 1 + [[ "$output" != *"can't carry an"* ]] || return 1 } @test "_check_existing_cluster_proxy: cluster missing a host proxy var -> drift warning" { HTTP_PROXY="http://proxy:8080" docker() { echo "PATH=/usr/bin"; } # HTTP_PROXY not baked run _check_existing_cluster_proxy - [[ "$output" == *"missing: HTTP_PROXY"* ]] + [[ "$output" == *"missing: HTTP_PROXY"* ]] || return 1 } # ── _check_existing_cluster_ca (Bugbot #424 r4) ───────────────────────────── @@ -225,23 +226,23 @@ setup() { unset TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE docker() { echo "/should-not-be-read"; } run _check_existing_cluster_ca - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_check_existing_cluster_ca: CA set but existing cluster lacks the mount -> recreate warning" { export TRACEBLOC_CA_BUNDLE="/some/ca.pem" docker() { printf '/tracebloc\n/etc/ssl/certs/ca-certificates.crt\n'; } # no mitm-ca mount run _check_existing_cluster_ca - [[ "$output" == *"created without it"* ]] - [[ "$output" == *"k3d cluster delete"* ]] + [[ "$output" == *"created without it"* ]] || return 1 + [[ "$output" == *"k3d cluster delete"* ]] || return 1 } @test "_check_existing_cluster_ca: CA set and mount present -> no warning" { export TRACEBLOC_CA_BUNDLE="/some/ca.pem" docker() { printf '/tracebloc\n/etc/ssl/certs/tracebloc-mitm-ca.crt\n'; } run _check_existing_cluster_ca - [ -z "$output" ] + [ -z "$output" ] || return 1 } @test "_check_existing_cluster_ca: a mount that only embeds the CA path -> still warns (Bugbot #424)" { @@ -249,33 +250,33 @@ setup() { # substring but NOT the exact mount destination — must not be treated as our CA mount docker() { printf '/tracebloc\n/etc/ssl/certs/tracebloc-mitm-ca.crt.bak\n'; } run _check_existing_cluster_ca - [[ "$output" == *"created without it"* ]] - [[ "$output" == *"k3d cluster delete"* ]] + [[ "$output" == *"created without it"* ]] || return 1 + [[ "$output" == *"k3d cluster delete"* ]] || return 1 } # ── _host_ca_create_hint (host Docker daemon x509 at create, #474) ─────────── @test "_host_ca_create_hint: no x509 in output -> silent" { run _host_ca_create_hint "FATA[0000] Failed to create cluster: docker daemon not running" - [ -z "$output" ] + [ -z "$output" ] || return 1 } @test "_host_ca_create_hint: x509 on Linux -> host daemon + Debian AND RHEL paths + DD-for-Linux (#474)" { OS=Linux run _host_ca_create_hint 'FATA Failed to pull image "rancher/k3s": x509: certificate signed by unknown authority' - [[ "$output" == *"HOST Docker daemon"* ]] - [[ "$output" == *"update-ca-certificates"* ]] # Debian/Ubuntu - [[ "$output" == *"update-ca-trust"* ]] # RHEL/Fedora (Bugbot: not Debian-only) - [[ "$output" == *"Docker Desktop for Linux"* ]] # Bugbot: no dangling "Docker Desktop step" - [[ "$output" != *"macOS keychain"* ]] + [[ "$output" == *"HOST Docker daemon"* ]] || return 1 + [[ "$output" == *"update-ca-certificates"* ]] || return 1 # Debian/Ubuntu + [[ "$output" == *"update-ca-trust"* ]] || return 1 # RHEL/Fedora (Bugbot: not Debian-only) + [[ "$output" == *"Docker Desktop for Linux"* ]] || return 1 # Bugbot: no dangling "Docker Desktop step" + [[ "$output" != *"macOS keychain"* ]] || return 1 } @test "_host_ca_create_hint: x509 on macOS -> Docker Desktop keychain AND Colima VM (#474 Bugbot)" { OS=Darwin run _host_ca_create_hint 'Error response from daemon: tls: failed to verify certificate' - [[ "$output" == *"Docker Desktop"* ]] - [[ "$output" == *"macOS keychain"* ]] - [[ "$output" == *"Colima"* ]] # headless macOS uses Colima, which ignores the keychain - [[ "$output" != *"update-ca-certificates"* ]] + [[ "$output" == *"Docker Desktop"* ]] || return 1 + [[ "$output" == *"macOS keychain"* ]] || return 1 + [[ "$output" == *"Colima"* ]] || return 1 # headless macOS uses Colima, which ignores the keychain + [[ "$output" != *"update-ca-certificates"* ]] || return 1 } @test "_host_ca_create_hint: large (>64KB) x509 output under pipefail still fires (reviewer #474)" { @@ -287,8 +288,8 @@ setup() { local big; big="$(printf 'noise line %s\n' $(seq 1 8000))" # well over 64KB big+=$'\nFATA Failed to pull image "rancher/k3s": x509: certificate signed by unknown authority' run _host_ca_create_hint "$big" - [ "$status" -eq 0 ] - [[ "$output" == *"HOST Docker daemon"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"HOST Docker daemon"* ]] || return 1 } # ── _check_existing_cluster_dataset_mount (backend#743) ───────────────────── @@ -296,34 +297,34 @@ setup() { unset HOST_DATASET_DIR docker() { echo "/should-not-be-read"; } run _check_existing_cluster_dataset_mount - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_check_existing_cluster_dataset_mount: /tracebloc-data mount present -> silent pass" { HOST_DATASET_DIR=/mnt/nfs/datasets docker() { printf '%s\n' /tracebloc /tracebloc-data; } run _check_existing_cluster_dataset_mount - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_check_existing_cluster_dataset_mount: mount ABSENT -> fail fast (no ephemeral datasets)" { HOST_DATASET_DIR=/mnt/nfs/datasets docker() { printf '%s\n' /tracebloc; } # no /tracebloc-data bind run _check_existing_cluster_dataset_mount - [ "$status" -ne 0 ] - [[ "$output" == *"no /tracebloc-data bind mount"* ]] - [[ "$output" == *"ephemeral"* ]] - [[ "$output" == *"k3d cluster delete"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"no /tracebloc-data bind mount"* ]] || return 1 + [[ "$output" == *"ephemeral"* ]] || return 1 + [[ "$output" == *"k3d cluster delete"* ]] || return 1 } @test "_check_existing_cluster_dataset_mount: inspect fails -> silent no-op" { HOST_DATASET_DIR=/mnt/nfs/datasets docker() { return 1; } run _check_existing_cluster_dataset_mount - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } # ── _check_existing_cluster_storage_mode (RFC-0003 Option C) ──────────────── @@ -331,44 +332,44 @@ setup() { TB_STORAGE_MODE=node-local docker() { printf '%s\n' /var/lib/rancher; } # no /tracebloc mount run _check_existing_cluster_storage_mode - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_check_existing_cluster_storage_mode: hostpath matches hostpath cluster -> silent pass" { TB_STORAGE_MODE=hostpath docker() { printf '%s\n' /tracebloc; } run _check_existing_cluster_storage_mode - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_check_existing_cluster_storage_mode: node-local onto hostpath cluster -> fail fast (no local-path SC)" { TB_STORAGE_MODE=node-local docker() { printf '%s\n' /tracebloc; } # hostpath cluster run _check_existing_cluster_storage_mode - [ "$status" -ne 0 ] - [[ "$output" == *"built for hostpath storage"* ]] - [[ "$output" == *"Pending"* ]] - [[ "$output" == *"k3d cluster delete"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"built for hostpath storage"* ]] || return 1 + [[ "$output" == *"Pending"* ]] || return 1 + [[ "$output" == *"k3d cluster delete"* ]] || return 1 } @test "_check_existing_cluster_storage_mode: hostpath onto node-local cluster -> fail fast (ephemeral)" { TB_STORAGE_MODE=hostpath docker() { printf '%s\n' /var/lib/rancher; } # node-local cluster, no /tracebloc run _check_existing_cluster_storage_mode - [ "$status" -ne 0 ] - [[ "$output" == *"built for node-local storage"* ]] - [[ "$output" == *"ephemeral"* ]] - [[ "$output" == *"k3d cluster delete"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"built for node-local storage"* ]] || return 1 + [[ "$output" == *"ephemeral"* ]] || return 1 + [[ "$output" == *"k3d cluster delete"* ]] || return 1 } @test "_check_existing_cluster_storage_mode: inspect fails -> silent no-op" { TB_STORAGE_MODE=node-local docker() { return 1; } run _check_existing_cluster_storage_mode - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } # ── ensure_cluster_autostart (reboot persistence) ─────────────────────────── @@ -380,9 +381,9 @@ setup() { has() { return 0; } ensure_cluster_autostart run mock_calls - [[ "$output" == *"docker update --restart unless-stopped k3d-tracebloc-server-0"* ]] - [[ "$output" == *"docker update --restart unless-stopped k3d-tracebloc-serverlb"* ]] - [[ "$output" == *"sudo systemctl enable docker"* ]] + [[ "$output" == *"docker update --restart unless-stopped k3d-tracebloc-server-0"* ]] || return 1 + [[ "$output" == *"docker update --restart unless-stopped k3d-tracebloc-serverlb"* ]] || return 1 + [[ "$output" == *"sudo systemctl enable docker"* ]] || return 1 } @test "ensure_cluster_autostart: Tier 0 sets restart policy but does NOT sudo-enable docker.service (#375)" { @@ -393,8 +394,8 @@ setup() { has() { return 0; } ensure_cluster_autostart run mock_calls - [[ "$output" == *"docker update --restart unless-stopped"* ]] # reboot policy still set (no privilege) - [[ "$output" != *"systemctl enable docker"* ]] # but no sudo autostart on the zero-root path + [[ "$output" == *"docker update --restart unless-stopped"* ]] || return 1 # reboot policy still set (no privilege) + [[ "$output" != *"systemctl enable docker"* ]] || return 1 # but no sudo autostart on the zero-root path } # Tier 0 never runs `systemctl enable`, but a normal Docker package install @@ -408,9 +409,9 @@ setup() { systemctl() { [[ "$1 $2" == "is-enabled docker" ]] && { echo "enabled"; return 0; }; record "systemctl $*"; } has() { return 0; } ensure_cluster_autostart - [ "${TB_DOCKER_AUTOSTART:-0}" = "1" ] # summary can honestly promise auto-restart + [ "${TB_DOCKER_AUTOSTART:-0}" = "1" ] || return 1 # summary can honestly promise auto-restart run mock_calls - [[ "$output" != *"systemctl enable docker"* ]] # still no privileged enable on the zero-root path + [[ "$output" != *"systemctl enable docker"* ]] || return 1 # still no privileged enable on the zero-root path } @test "ensure_cluster_autostart: Tier 0 with docker.service disabled -> no false auto-restart promise" { @@ -420,7 +421,7 @@ setup() { systemctl() { [[ "$1 $2" == "is-enabled docker" ]] && { echo "disabled"; return 1; }; record "systemctl $*"; } has() { return 0; } ensure_cluster_autostart - [ "${TB_DOCKER_AUTOSTART:-0}" != "1" ] # summary tells the user to start Docker + [ "${TB_DOCKER_AUTOSTART:-0}" != "1" ] || return 1 # summary tells the user to start Docker } # `enabled-runtime` is a transient enable that does NOT survive a reboot, so it @@ -432,7 +433,7 @@ setup() { systemctl() { [[ "$1 $2" == "is-enabled docker" ]] && { echo "enabled-runtime"; return 0; }; record "systemctl $*"; } has() { return 0; } ensure_cluster_autostart - [ "${TB_DOCKER_AUTOSTART:-0}" != "1" ] + [ "${TB_DOCKER_AUTOSTART:-0}" != "1" ] || return 1 } @test "ensure_cluster_autostart: macOS does not enable docker.service" { @@ -442,8 +443,8 @@ setup() { has() { return 0; } ensure_cluster_autostart run mock_calls - [[ "$output" == *"docker update --restart unless-stopped"* ]] - [[ "$output" != *"systemctl enable docker"* ]] + [[ "$output" == *"docker update --restart unless-stopped"* ]] || return 1 + [[ "$output" != *"systemctl enable docker"* ]] || return 1 } @test "ensure_cluster_autostart: TRACEBLOC_NO_AUTOSTART -> no-op" { @@ -452,7 +453,7 @@ setup() { sudo() { record "sudo $*"; } TRACEBLOC_NO_AUTOSTART=1 ensure_cluster_autostart run mock_calls - [ -z "$output" ] + [ -z "$output" ] || return 1 } @test "ensure_cluster_autostart: no nodes -> no docker update" { @@ -460,7 +461,7 @@ setup() { docker() { if [[ "$1 $2" == "ps -a" ]]; then echo ""; else record "docker $*"; fi; } ensure_cluster_autostart run mock_calls - [[ "$output" != *"docker update"* ]] + [[ "$output" != *"docker update"* ]] || return 1 } # ── bounded create (#426) ──────────────────────────────────────────────────── @@ -468,6 +469,13 @@ setup() { grep -q -- '--wait --timeout' "$BATS_TEST_DIRNAME/../lib/cluster.sh" } +@test "k3d cluster start is bounded: --timeout, so a wedged start can't hang (Bugbot)" { + # The start output is redirected to the log; without a deadline a stuck start + # would hang headless forever instead of reaching the curated error line. + grep -Eq 'k3d cluster start "\$CLUSTER_NAME" .*--timeout' \ + "$BATS_TEST_DIRNAME/../lib/cluster.sh" +} + @test "create spin carries the backstop deadline (#426)" { grep -q 'spin "\$!" "Creating your secure environment…" "\$(( (_create_timeout_min + 5) \* 60 ))"' \ "$BATS_TEST_DIRNAME/../lib/cluster.sh" @@ -489,65 +497,65 @@ setup() { @test "_resolve_ca_bundle: no CA var set -> empty, rc 0" { unset TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE run _resolve_ca_bundle - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_resolve_ca_bundle: TRACEBLOC_CA_BUNDLE readable -> absolute path (#424)" { export TRACEBLOC_CA_BUNDLE="$BATS_TEST_TMPDIR/ca.pem"; : > "$TRACEBLOC_CA_BUNDLE" run _resolve_ca_bundle - [ "$status" -eq 0 ] - [[ "$output" == /*ca.pem ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == /*ca.pem ]] || return 1 } @test "_resolve_ca_bundle: CURL_CA_BUNDLE is the fallback (#424)" { unset TRACEBLOC_CA_BUNDLE export CURL_CA_BUNDLE="$BATS_TEST_TMPDIR/curlca.pem"; : > "$CURL_CA_BUNDLE" run _resolve_ca_bundle - [ "$status" -eq 0 ] - [[ "$output" == *curlca.pem ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *curlca.pem ]] || return 1 } @test "_resolve_ca_bundle: set but unreadable -> var name + rc 2 (#424)" { export TRACEBLOC_CA_BUNDLE="/no/such/ca.pem" run _resolve_ca_bundle - [ "$status" -eq 2 ] - [ "$output" = "TRACEBLOC_CA_BUNDLE" ] + [ "$status" -eq 2 ] || return 1 + [ "$output" = "TRACEBLOC_CA_BUNDLE" ] || return 1 } @test "_resolve_ca_bundle: a directory (readable but not a file) -> var name + rc 2 (#424 review)" { export TRACEBLOC_CA_BUNDLE="$BATS_TEST_TMPDIR/ca-dir"; mkdir -p "$TRACEBLOC_CA_BUNDLE" run _resolve_ca_bundle - [ "$status" -eq 2 ] - [ "$output" = "TRACEBLOC_CA_BUNDLE" ] + [ "$status" -eq 2 ] || return 1 + [ "$output" = "TRACEBLOC_CA_BUNDLE" ] || return 1 } @test "_write_k3d_registries_config: ca_file for every registry (#424)" { run _write_k3d_registries_config /etc/ssl/certs/tracebloc-mitm-ca.crt - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 local cfg="$output" grep -q 'registry-1.docker.io' "$cfg" grep -q 'auth.docker.io' "$cfg" # Docker Hub token host — also TLS-handshakes (Bugbot #424) grep -q 'ghcr.io' "$cfg" - [ "$(grep -c 'ca_file: "/etc/ssl/certs/tracebloc-mitm-ca.crt"' "$cfg")" -eq 4 ] + [ "$(grep -c 'ca_file: "/etc/ssl/certs/tracebloc-mitm-ca.crt"' "$cfg")" -eq 4 ] || return 1 rm -rf "${cfg%/*}" } @test "_write_k3d_registries_config: mktemp failure -> non-zero, no path (no fail-open; #424 Bugbot)" { mktemp() { return 1; } run _write_k3d_registries_config /etc/ssl/certs/tracebloc-mitm-ca.crt - [ "$status" -ne 0 ] - [ -z "$output" ] + [ "$status" -ne 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_create_new_cluster: CA supplied -> mounts CA + --registry-config (#424)" { export TRACEBLOC_CA_BUNDLE="$BATS_TEST_TMPDIR/ca.pem"; : > "$TRACEBLOC_CA_BUNDLE" run _create_new_cluster - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"k3d cluster create"* ]] - [[ "$output" == *":/etc/ssl/certs/tracebloc-mitm-ca.crt@all"* ]] # CA mounted into nodes - [[ "$output" == *"--registry-config"* ]] # containerd pointed at it + [[ "$output" == *"k3d cluster create"* ]] || return 1 + [[ "$output" == *":/etc/ssl/certs/tracebloc-mitm-ca.crt@all"* ]] || return 1 # CA mounted into nodes + [[ "$output" == *"--registry-config"* ]] || return 1 # containerd pointed at it } @test "_create_new_cluster: CA supplied but registries config unwritable -> hard error, never fail-open (#424 Bugbot)" { @@ -555,26 +563,26 @@ setup() { # only the registries temp dir fails; other mktemp uses delegate to the real one mktemp() { case "$*" in *tracebloc-k3d-reg*) return 1 ;; *) command mktemp "$@" ;; esac; } run _create_new_cluster - [ "$status" -ne 0 ] - [[ "$output" == *"registries config"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"registries config"* ]] || return 1 run mock_calls - [[ "$output" != *"k3d cluster create"* ]] # aborted before create — never claims success + [[ "$output" != *"k3d cluster create"* ]] || return 1 # aborted before create — never claims success } @test "_create_new_cluster: no CA var -> no registry-config, no mitm mount (#424)" { unset TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE run _create_new_cluster - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" != *"tracebloc-mitm-ca.crt"* ]] - [[ "$output" != *"--registry-config"* ]] + [[ "$output" != *"tracebloc-mitm-ca.crt"* ]] || return 1 + [[ "$output" != *"--registry-config"* ]] || return 1 } @test "_create_new_cluster: CA var set but file missing -> hard error (#424)" { export TRACEBLOC_CA_BUNDLE="/no/such/ca.pem" run _create_new_cluster - [ "$status" -ne 0 ] - [[ "$output" == *"can't be read"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"can't be read"* ]] || return 1 } @test "CA resolve capture is errexit-safe under set -euo pipefail — reaches the guidance, not a bare exit (#424 Bugbot)" { @@ -586,9 +594,9 @@ setup() { _resolve_ca_bundle() { echo TRACEBLOC_CA_BUNDLE; return 2; } ca_rc=0; ca_bundle="$(_resolve_ca_bundle)" || ca_rc=$? printf "rc=%s bundle=%s\n" "$ca_rc" "$ca_bundle"' - [ "$status" -eq 0 ] # no bare errexit exit - [[ "$output" == *"rc=2"* ]] - [[ "$output" == *"bundle=TRACEBLOC_CA_BUNDLE"* ]] + [ "$status" -eq 0 ] || return 1 # no bare errexit exit + [[ "$output" == *"rc=2"* ]] || return 1 + [[ "$output" == *"bundle=TRACEBLOC_CA_BUNDLE"* ]] || return 1 grep -qE 'ca_bundle="\$\(_resolve_ca_bundle\)" \|\| ca_rc=' "$BATS_TEST_DIRNAME/../lib/cluster.sh" } @@ -610,9 +618,9 @@ _stub_create_cluster_deps() { _stub_create_cluster_deps _create_new_cluster() { record "create_saw DOCKER_HOST=$DOCKER_HOST"; } # what k3d/docker would see create_cluster - [ "$DOCKER_HOST" = "unix:///run/user/12345/docker.sock" ] + [ "$DOCKER_HOST" = "unix:///run/user/12345/docker.sock" ] || return 1 run mock_calls - [[ "$output" == *"create_saw DOCKER_HOST=unix:///run/user/12345/docker.sock"* ]] + [[ "$output" == *"create_saw DOCKER_HOST=unix:///run/user/12345/docker.sock"* ]] || return 1 } @test "create_cluster: rootless flag OFF -> DOCKER_HOST left untouched (legacy host daemon)" { @@ -621,7 +629,7 @@ _stub_create_cluster_deps() { _stub_create_cluster_deps _create_new_cluster() { :; } create_cluster - [ -z "${DOCKER_HOST:-}" ] + [ -z "${DOCKER_HOST:-}" ] || return 1 } @test "ensure_cluster_autostart: Tier 1 rootless -> systemctl --user + linger (both OK) => honest autostart promise, NOT sudo enable" { @@ -632,12 +640,12 @@ _stub_create_cluster_deps() { loginctl() { record "loginctl $*"; return 0; } # linger OK has() { return 0; } ensure_cluster_autostart - [ "${TB_DOCKER_AUTOSTART:-0}" = "1" ] # both succeeded -> honest promise + [ "${TB_DOCKER_AUTOSTART:-0}" = "1" ] || return 1 # both succeeded -> honest promise run mock_calls - [[ "$output" == *"docker update --restart unless-stopped k3d-tracebloc-server-0"* ]] # node loop still runs - [[ "$output" == *"systemctl --user enable docker"* ]] - [[ "$output" == *"loginctl enable-linger"* ]] - [[ "$output" != *"sudo systemctl enable docker"* ]] # never the system unit + [[ "$output" == *"docker update --restart unless-stopped k3d-tracebloc-server-0"* ]] || return 1 # node loop still runs + [[ "$output" == *"systemctl --user enable docker"* ]] || return 1 + [[ "$output" == *"loginctl enable-linger"* ]] || return 1 + [[ "$output" != *"sudo systemctl enable docker"* ]] || return 1 # never the system unit } @test "ensure_cluster_autostart: Tier 1 rootless -> user-enable fails => NO false reboot promise, both still attempted (#375)" { @@ -648,11 +656,11 @@ _stub_create_cluster_deps() { loginctl() { record "loginctl $*"; return 0; } has() { return 0; } ensure_cluster_autostart - [ "${TB_DOCKER_AUTOSTART:-0}" != "1" ] # honest: can't promise reboot-survival + [ "${TB_DOCKER_AUTOSTART:-0}" != "1" ] || return 1 # honest: can't promise reboot-survival run mock_calls - [[ "$output" == *"systemctl --user enable docker"* ]] # attempted (best-effort) - [[ "$output" == *"loginctl enable-linger"* ]] # linger still attempted (not short-circuited) - [[ "$output" != *"sudo systemctl enable docker"* ]] + [[ "$output" == *"systemctl --user enable docker"* ]] || return 1 # attempted (best-effort) + [[ "$output" == *"loginctl enable-linger"* ]] || return 1 # linger still attempted (not short-circuited) + [[ "$output" != *"sudo systemctl enable docker"* ]] || return 1 } @test "ensure_cluster_autostart: Tier 1 rootless -> system docker.service enabled does NOT seed a false promise when user-enable fails (Bugbot #478)" { @@ -665,7 +673,7 @@ _stub_create_cluster_deps() { loginctl() { record "loginctl $*"; return 0; } has() { return 0; } ensure_cluster_autostart - [ "${TB_DOCKER_AUTOSTART:-0}" != "1" ] # system-unit seed ignored on the rootless socket path + [ "${TB_DOCKER_AUTOSTART:-0}" != "1" ] || return 1 # system-unit seed ignored on the rootless socket path } @test "ensure_cluster_autostart: Tier 1 flag OFF -> legacy sudo systemctl enable docker (unchanged)" { @@ -678,8 +686,8 @@ _stub_create_cluster_deps() { has() { return 0; } ensure_cluster_autostart run mock_calls - [[ "$output" == *"sudo systemctl enable docker"* ]] - [[ "$output" != *"systemctl --user enable docker"* ]] + [[ "$output" == *"sudo systemctl enable docker"* ]] || return 1 + [[ "$output" != *"systemctl --user enable docker"* ]] || return 1 } # ── _check_existing_cluster_k8s_version (#547 — k3s pin drift on reuse) ────── @@ -687,56 +695,157 @@ _stub_create_cluster_deps() { K8S_VERSION="" docker() { echo "rancher/k3s:v1.35.5-k3s1"; } # would mismatch, but pin unset run _check_existing_cluster_k8s_version - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_check_existing_cluster_k8s_version: K8S_VERSION=latest -> no-op (explicit opt-out)" { K8S_VERSION="latest" docker() { echo "rancher/k3s:v1.35.5-k3s1"; } run _check_existing_cluster_k8s_version - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_check_existing_cluster_k8s_version: running k3s matches the pin -> silent pass" { K8S_VERSION="v1.29.4-k3s1" docker() { echo "rancher/k3s:v1.29.4-k3s1"; } run _check_existing_cluster_k8s_version - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_check_existing_cluster_k8s_version: running k3s drifted from the pin -> recreate warning" { K8S_VERSION="v1.29.4-k3s1" docker() { echo "rancher/k3s:v1.35.5-k3s1"; } # the #547 observation run _check_existing_cluster_k8s_version - [ "$status" -eq 0 ] - [[ "$output" == *"v1.35.5-k3s1"* ]] - [[ "$output" == *"not the validated pin"* ]] - [[ "$output" == *"k3d cluster delete"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"v1.35.5-k3s1"* ]] || return 1 + [[ "$output" == *"not the validated pin"* ]] || return 1 + [[ "$output" == *"k3d cluster delete"* ]] || return 1 } @test "_check_existing_cluster_k8s_version: registry-qualified + digest suffix still compares the tag" { K8S_VERSION="v1.29.4-k3s1" docker() { echo "docker.io/rancher/k3s:v1.29.4-k3s1@sha256:deadbeef"; } run _check_existing_cluster_k8s_version - [ "$status" -eq 0 ] - [ -z "$output" ] # tag matches -> no warning + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 # tag matches -> no warning } @test "_check_existing_cluster_k8s_version: unparseable image ref -> silent no-op (no false warn)" { K8S_VERSION="v1.29.4-k3s1" docker() { echo "some-mirror/other-image:tag"; } # not rancher/k3s run _check_existing_cluster_k8s_version - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_check_existing_cluster_k8s_version: docker inspect fails -> silent no-op" { K8S_VERSION="v1.29.4-k3s1" docker() { return 1; } run _check_existing_cluster_k8s_version - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 +} + +# ── wire_ca_trust: extend the corporate CA to every host tool (#583) ───────── +@test "wire_ca_trust: exports SSL_CERT_FILE + GIT_SSL_CAINFO from a CA (#583)" { + local ca="$BATS_TEST_TMPDIR/ca.pem"; echo pem > "$ca" + TRACEBLOC_CA_BUNDLE="$ca"; OS="Linux" + wire_ca_trust >/dev/null + [ "$SSL_CERT_FILE" = "$ca" ] || return 1 + [ "$GIT_SSL_CAINFO" = "$ca" ] || return 1 +} + +@test "wire_ca_trust: Linux announce names cosign/helm/git (SSL_CERT_FILE effective there)" { + local ca="$BATS_TEST_TMPDIR/ca.pem"; echo pem > "$ca" + TRACEBLOC_CA_BUNDLE="$ca"; OS="Linux" + run wire_ca_trust + [[ "$output" == *"cosign, helm and git"* ]] || return 1 + [[ "$output" != *"downloads"* ]] || return 1 # curl trust is the user's CURL_CA_BUNDLE; don't over-claim +} + +@test "wire_ca_trust: macOS announce names only git + hints Keychain for cosign/helm (Bugbot)" { + # Go reads the Keychain on macOS (not SSL_CERT_FILE) and Apple's system git + # (SecureTransport) ignores GIT_SSL_CAINFO — so on Darwin the function wires + # NOTHING and claims nothing: it points at the Keychain for all three tools + # (Bugbot ×2: inert-but-hazardous SSL_CERT_FILE, false git claim). + local ca="$BATS_TEST_TMPDIR/ca.pem"; echo pem > "$ca" + TRACEBLOC_CA_BUNDLE="$ca"; OS="Darwin" + run wire_ca_trust + [[ "$output" != *"Trusting"* ]] || return 1 # no success claim — nothing was wired + [[ "$output" == *"git, cosign and helm"* ]] || return 1 + [[ "$output" == *"Keychain"* ]] || return 1 +} + +@test "wire_ca_trust: Darwin exports NEITHER trust var (inert for Go, hazardous for curl, Bugbot)" { + # SSL_CERT_FILE would shrink OpenSSL-curl's download trust to the corp root + # while helping neither cosign nor helm; GIT_SSL_CAINFO is ignored by the + # system git that runs Homebrew's own bootstrap clone. + local ca="$BATS_TEST_TMPDIR/ca.pem"; echo pem > "$ca" + TRACEBLOC_CA_BUNDLE="$ca"; OS="Darwin" + wire_ca_trust >/dev/null + [ -z "${SSL_CERT_FILE:-}" ] || return 1 + [ -z "${GIT_SSL_CAINFO:-}" ] || return 1 +} + +@test "wire_ca_trust: does NOT clobber a user's CURL_CA_BUNDLE (replace-not-augment, Bugbot)" { + local ca="$BATS_TEST_TMPDIR/corp.pem"; echo pem > "$ca" + local full="$BATS_TEST_TMPDIR/full-bundle.pem"; echo pem > "$full" + TRACEBLOC_CA_BUNDLE="$ca"; CURL_CA_BUNDLE="$full"; OS="Linux" + wire_ca_trust >/dev/null + [ "$SSL_CERT_FILE" = "$ca" ] || return 1 # cosign/helm/git get the corp CA + [ "$CURL_CA_BUNDLE" = "$full" ] || return 1 # curl's own bundle is left intact (not overwritten) +} + +@test "wire_ca_trust: does NOT clobber pre-set SSL_CERT_FILE / GIT_SSL_CAINFO (replace-not-augment, Bugbot)" { + local ca="$BATS_TEST_TMPDIR/corp.pem"; echo pem > "$ca" + local uf="$BATS_TEST_TMPDIR/user-full.pem"; echo pem > "$uf" + TRACEBLOC_CA_BUNDLE="$ca"; SSL_CERT_FILE="$uf"; GIT_SSL_CAINFO="$uf"; OS="Linux" + wire_ca_trust >/dev/null + [ "$SSL_CERT_FILE" = "$uf" ] || return 1 # user's fuller bundles are left intact... + [ "$GIT_SSL_CAINFO" = "$uf" ] || return 1 # ...not overwritten with the corp-root-only one +} + +@test "wire_ca_trust: skipped exports are not claimed as success (Bugbot)" { + # With both vars pre-set every export is skipped — a green "Trusting…" then + # reports wiring that did not happen and masks a pre-set bundle that may + # still lack the corporate CA. Say what was kept, claim nothing. + local ca="$BATS_TEST_TMPDIR/corp.pem"; echo pem > "$ca" + local uf="$BATS_TEST_TMPDIR/user-full.pem"; echo pem > "$uf" + TRACEBLOC_CA_BUNDLE="$ca"; SSL_CERT_FILE="$uf"; GIT_SSL_CAINFO="$uf"; OS="Linux" + run wire_ca_trust + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"Trusting"* ]] || return 1 + [[ "$output" == *"Keeping your pre-set"* ]] || return 1 + [[ "$output" == *"make sure that bundle includes your company's CA"* ]] || return 1 +} + +@test "wire_ca_trust: partial pre-set claims only the wired half (Bugbot)" { + # SSL_CERT_FILE pre-set, GIT_SSL_CAINFO free: success must name git alone, + # and the kept half gets the check-your-bundle hint. + local ca="$BATS_TEST_TMPDIR/corp.pem"; echo pem > "$ca" + local uf="$BATS_TEST_TMPDIR/user-full.pem"; echo pem > "$uf" + TRACEBLOC_CA_BUNDLE="$ca"; SSL_CERT_FILE="$uf"; OS="Linux" + unset GIT_SSL_CAINFO + run wire_ca_trust + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Trusting your company's certificate for git."* ]] || return 1 + [[ "$output" != *"for cosign"* ]] || return 1 # the wired claim must not cover the kept half + [[ "$output" == *"Keeping your pre-set SSL_CERT_FILE"* ]] || return 1 +} + +@test "wire_ca_trust: no-op when no CA is configured (#583)" { + unset TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE SSL_CERT_FILE GIT_SSL_CAINFO + wire_ca_trust >/dev/null + [ -z "${SSL_CERT_FILE:-}" ] || return 1 + [ -z "${GIT_SSL_CAINFO:-}" ] || return 1 +} + +@test "wire_ca_trust: hard-fails early on a set-but-unreadable bundle (#583)" { + TRACEBLOC_CA_BUNDLE="/no/such/corporate-ca.pem" + run wire_ca_trust + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"can't be read"* ]] || return 1 } diff --git a/scripts/tests/common.bats b/scripts/tests/common.bats index 8897f8c3..672853e1 100644 --- a/scripts/tests/common.bats +++ b/scripts/tests/common.bats @@ -20,15 +20,15 @@ setup() { CLUSTER_NAME=tracebloc; SERVERS=1; AGENTS=1 HOST_DATA_DIR="$HOME/.tracebloc" run validate_config - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "validate_config: empty HOST_DATA_DIR fails closed (#384 bugbot)" { HOME="$BATS_TEST_TMPDIR"; USER=tester CLUSTER_NAME=tracebloc; SERVERS=1; AGENTS=1; HOST_DATA_DIR="" run validate_config - [ "$status" -ne 0 ] - [[ "$output" == *"must not be empty"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"must not be empty"* ]] || return 1 } @test "validate_config: leading tilde in HOST_DATA_DIR expands to \$HOME (#384 bugbot)" { @@ -40,8 +40,8 @@ setup() { run validate_config # Pre-fix, `~/x` became the literal "$HOME/~/x" and failed parent resolution; # now it resolves to $HOME/tracebloc-new and validates. No `~` may survive. - [ "$status" -eq 0 ] - [[ "$output" != *"~"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"~"* ]] || return 1 } @test "validate_config: HOST_DATA_DIR == \$HOME is rejected, not adopted (#384 bugbot)" { @@ -50,40 +50,40 @@ setup() { HOME="$(cd -P "$BATS_TEST_TMPDIR" && pwd)"; USER=tester CLUSTER_NAME=tracebloc; SERVERS=1; AGENTS=1; HOST_DATA_DIR="$HOME" run validate_config - [ "$status" -ne 0 ] - [[ "$output" == *"not \$HOME itself"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"not \$HOME itself"* ]] || return 1 } @test "validate_config: bare ~ is rejected (resolves to \$HOME) (#384 bugbot)" { HOME="$(cd -P "$BATS_TEST_TMPDIR" && pwd)"; USER=tester CLUSTER_NAME=tracebloc; SERVERS=1; AGENTS=1; HOST_DATA_DIR="~" run validate_config - [ "$status" -ne 0 ] - [[ "$output" == *"not \$HOME itself"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"not \$HOME itself"* ]] || return 1 } @test "validate_config: invalid CLUSTER_NAME -> error" { HOME="$BATS_TEST_TMPDIR"; USER=tester CLUSTER_NAME="1nope"; SERVERS=1; AGENTS=1; HOST_DATA_DIR="$HOME/x" run validate_config - [ "$status" -ne 0 ] - [[ "$output" == *"CLUSTER_NAME"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"CLUSTER_NAME"* ]] || return 1 } @test "validate_config: invalid SERVERS -> error" { HOME="$BATS_TEST_TMPDIR"; USER=tester CLUSTER_NAME=ok; SERVERS=0; AGENTS=1; HOST_DATA_DIR="$HOME/x" run validate_config - [ "$status" -ne 0 ] - [[ "$output" == *"SERVERS"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"SERVERS"* ]] || return 1 } @test "validate_config: HOST_DATA_DIR outside HOME -> error" { HOME="$BATS_TEST_TMPDIR"; USER=tester CLUSTER_NAME=ok; SERVERS=1; AGENTS=1; HOST_DATA_DIR="/tmp/not-under-home-$$" run validate_config - [ "$status" -ne 0 ] - [[ "$output" == *"HOST_DATA_DIR"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"HOST_DATA_DIR"* ]] || return 1 } # ── validate_config: HOST_DATASET_DIR (backend#743) ────────────────────────── @@ -95,7 +95,7 @@ setup() { CLUSTER_NAME=ok; SERVERS=1; AGENTS=1; HOST_DATA_DIR="$HOME/.tracebloc" HOST_DATASET_DIR="$HOME/dataset-mount"; mkdir -p "$HOST_DATASET_DIR" run validate_config - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "validate_config: HOST_DATASET_DIR does not exist -> error (never mkdir a share root)" { @@ -103,8 +103,8 @@ setup() { CLUSTER_NAME=ok; SERVERS=1; AGENTS=1; HOST_DATA_DIR="$HOME/.tracebloc" HOST_DATASET_DIR="$HOME/nope-$$" run validate_config - [ "$status" -ne 0 ] - [[ "$output" == *"does not exist"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"does not exist"* ]] || return 1 } @test "validate_config: HOST_DATASET_DIR not writable -> error" { @@ -114,8 +114,8 @@ setup() { HOST_DATASET_DIR="$HOME/ro-mount"; mkdir -p "$HOST_DATASET_DIR"; chmod 555 "$HOST_DATASET_DIR" run validate_config chmod 755 "$HOST_DATASET_DIR" # restore so bats can clean up the tmpdir - [ "$status" -ne 0 ] - [[ "$output" == *"not writable"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"not writable"* ]] || return 1 } @test "validate_config: HOST_DATA_DIR still rejected outside HOME when dataset dir is set" { @@ -123,8 +123,8 @@ setup() { CLUSTER_NAME=ok; SERVERS=1; AGENTS=1; HOST_DATA_DIR="/tmp/not-under-home-$$" HOST_DATASET_DIR="$HOME/dataset-mount"; mkdir -p "$HOST_DATASET_DIR" run validate_config - [ "$status" -ne 0 ] - [[ "$output" == *"HOST_DATA_DIR"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"HOST_DATA_DIR"* ]] || return 1 } @test "validate_config: node-local + HOST_DATASET_DIR -> error (unsupported combo)" { @@ -133,8 +133,8 @@ setup() { TB_STORAGE_MODE=node-local HOST_DATASET_DIR="$HOME/dataset-mount"; mkdir -p "$HOST_DATASET_DIR" run validate_config - [ "$status" -ne 0 ] - [[ "$output" == *"HOST_DATASET_DIR is not supported with TB_STORAGE_MODE=node-local"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"HOST_DATASET_DIR is not supported with TB_STORAGE_MODE=node-local"* ]] || return 1 } # ── C1 single-node guarantee (RFC-0003 Option C, load-time) ───────────────── @@ -144,57 +144,57 @@ setup() { @test "C1: node-local forces single-node — AGENTS=0 AND SERVERS=1" { run env TB_STORAGE_MODE=node-local AGENTS=4 SERVERS=3 \ bash -c "source '${LIB_DIR}/common.sh' >/dev/null 2>&1; echo \"\$AGENTS \$SERVERS\"" - [ "$status" -eq 0 ] - [ "$output" = "0 1" ] + [ "$status" -eq 0 ] || return 1 + [ "$output" = "0 1" ] || return 1 } @test "C1: hostpath (default) leaves AGENTS/SERVERS untouched" { run env TB_STORAGE_MODE=hostpath AGENTS=4 SERVERS=3 \ bash -c "source '${LIB_DIR}/common.sh' >/dev/null 2>&1; echo \"\$AGENTS \$SERVERS\"" - [ "$status" -eq 0 ] - [ "$output" = "4 3" ] + [ "$status" -eq 0 ] || return 1 + [ "$output" = "4 3" ] || return 1 } # ── install_cleanup: the CLIENT_STATE guard (#716) ───────────────────────── @test "install_cleanup: exit 0 -> silent" { out="$( ( exit 0 ); install_cleanup 2>&1 )" - [[ "$out" != *"did not complete"* ]] + [[ "$out" != *"did not complete"* ]] || return 1 } @test "install_cleanup: failure + CLIENT_STATE set -> suppresses generic message" { CLIENT_STATE=connected out="$( ( exit 1 ); install_cleanup 2>&1 )" - [[ "$out" != *"did not complete"* ]] + [[ "$out" != *"did not complete"* ]] || return 1 } @test "install_cleanup: failure + CLIENT_STATE unset -> shows generic message" { unset CLIENT_STATE out="$( ( exit 1 ); install_cleanup 2>&1 )" - [[ "$out" == *"did not complete"* ]] + [[ "$out" == *"did not complete"* ]] || return 1 } @test "install_cleanup: exit 2 -> re-run hint" { unset CLIENT_STATE out="$( ( exit 2 ); install_cleanup 2>&1 )" - [[ "$out" == *"Re-run required"* || "$out" == *"Complete the step"* ]] + [[ "$out" == *"Re-run required"* || "$out" == *"Complete the step"* ]] || return 1 } # ── retry ────────────────────────────────────────────────────────────────── @test "retry: succeeds on first attempt" { run retry 3 1 true - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "retry: gives up after max attempts" { run retry 2 0 false - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } @test "retry: succeeds after a transient failure" { marker="$BATS_TEST_TMPDIR/m" flaky() { if [ -f "$marker" ]; then return 0; fi; touch "$marker"; return 1; } run retry 3 0 flaky - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } # ── curl_secure (backend#1252) ───────────────────────────────────────────── @@ -206,23 +206,23 @@ setup() { @test "curl_secure: always passes the minimum TLS version, caller args intact" { curl() { printf '%s' "$*"; } run curl_secure -fsSL https://example.com - [ "$status" -eq 0 ] - [[ "$output" == *"--tlsv1.2"* ]] - [[ "$output" == *"-fsSL https://example.com"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"--tlsv1.2"* ]] || return 1 + [[ "$output" == *"-fsSL https://example.com"* ]] || return 1 } @test "curl_secure: supplies default time bounds when the caller sets none" { curl() { printf '%s' "$*"; } run curl_secure -fsSL https://example.com - [[ "$output" == *"--connect-timeout 30"* ]] - [[ "$output" == *"--max-time 300"* ]] + [[ "$output" == *"--connect-timeout 30"* ]] || return 1 + [[ "$output" == *"--max-time 300"* ]] || return 1 } @test "curl_secure: a caller's own deadline wins (lands after the default)" { curl() { printf '%s' "$*"; } run curl_secure -sS -m 60 https://example.com # curl honours the LAST occurrence, so -m 60 must come after --max-time 300. - [[ "$output" == *"--max-time 300"*"-m 60"* ]] + [[ "$output" == *"--max-time 300"*"-m 60"* ]] || return 1 } @test "curl_secure: a stall-bounded transfer gets NO overall deadline" { @@ -231,8 +231,8 @@ setup() { # slow-but-healthy link on a big download. The wrapper must not add one. curl() { printf '%s' "$*"; } run curl_secure -fSL --speed-limit 1024 --speed-time 60 -o /tmp/x https://example.com - [[ "$output" == *"--tlsv1.2"* ]] - [[ "$output" != *"--max-time"* ]] + [[ "$output" == *"--tlsv1.2"* ]] || return 1 + [[ "$output" != *"--max-time"* ]] || return 1 } @test "curl_secure: default bounds are overridable by env" { @@ -240,8 +240,8 @@ setup() { TB_CURL_CONNECT_TIMEOUT=5 TB_CURL_MAX_TIME=7 run curl_secure https://example.com - [[ "$output" == *"--connect-timeout 5"* ]] - [[ "$output" == *"--max-time 7"* ]] + [[ "$output" == *"--connect-timeout 5"* ]] || return 1 + [[ "$output" == *"--max-time 7"* ]] || return 1 } @test "curl_secure: dispatches through curl, so the suite can still mock it" { @@ -249,60 +249,60 @@ setup() { # substitutes a curl shell function, which `command` would bypass. curl() { return 42; } run curl_secure https://example.com - [ "$status" -eq 42 ] + [ "$status" -eq 42 ] || return 1 } @test "curl_secure: CURL_SECURE stays defined for out-of-tree callers" { - [ "$CURL_SECURE" = "--tlsv1.2" ] + [ "$CURL_SECURE" = "--tlsv1.2" ] || return 1 } # ── has ──────────────────────────────────────────────────────────────────── -@test "has: present command" { run has bash; [ "$status" -eq 0 ]; } -@test "has: absent command" { run has nope-not-a-real-cmd-xyz; [ "$status" -ne 0 ]; } +@test "has: present command" { run has bash; [ "$status" -eq 0 ] || return 1; } +@test "has: absent command" { run has nope-not-a-real-cmd-xyz; [ "$status" -ne 0 ] || return 1; } # ── count_bar (first-run: honest N-of-M for multi-image pulls) ─────────────── @test "count_bar: renders 'N of M '" { run count_bar 3 6 services - [ "$status" -eq 0 ] - [[ "$output" == *"3 of 6 services"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"3 of 6 services"* ]] || return 1 } @test "count_bar: clamps current above total (never over-reports)" { run count_bar 9 6 services - [[ "$output" == *"6 of 6 services"* ]] - [[ "$output" != *"9 of 6"* ]] + [[ "$output" == *"6 of 6 services"* ]] || return 1 + [[ "$output" != *"9 of 6"* ]] || return 1 } @test "count_bar: non-numeric current -> 0 (no crash)" { run count_bar nope 6 services - [ "$status" -eq 0 ] - [[ "$output" == *"0 of 6 services"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"0 of 6 services"* ]] || return 1 } @test "count_bar: total<1 floored to 1 (no divide-by-zero)" { run count_bar 0 0 services - [ "$status" -eq 0 ] - [[ "$output" == *"0 of 1 services"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"0 of 1 services"* ]] || return 1 } # ── step_header (first-run: bold a–f running headers) ──────────────────────── @test "step_header: renders ') '" { run step_header a "Checking your machine" - [ "$status" -eq 0 ] - [[ "$output" == *"a) Checking your machine"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"a) Checking your machine"* ]] || return 1 } # ── print_roadmap (the '2. Installing' a–f plan) ───────────────────────────── @test "print_roadmap: lists the a–f plan under '2. Installing'" { run print_roadmap - [ "$status" -eq 0 ] - [[ "$output" == *"2. Installing"* ]] - [[ "$output" == *"a) Check your machine"* ]] - [[ "$output" == *"b) Install what tracebloc needs"* ]] - [[ "$output" == *"c) Create your secure environment"* ]] - [[ "$output" == *"d) Register this machine"* ]] - [[ "$output" == *"e) Install tracebloc"* ]] - [[ "$output" == *"f) Connect to the tracebloc network"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"2. Installing"* ]] || return 1 + [[ "$output" == *"a) Check your machine"* ]] || return 1 + [[ "$output" == *"b) Install what tracebloc needs"* ]] || return 1 + [[ "$output" == *"c) Create your secure environment"* ]] || return 1 + [[ "$output" == *"d) Register this machine"* ]] || return 1 + [[ "$output" == *"e) Install tracebloc"* ]] || return 1 + [[ "$output" == *"f) Connect to the tracebloc network"* ]] || return 1 } # ── print_banner (title + version; suppressed after the bootstrap drew it) ─── @@ -311,10 +311,10 @@ setup() { TB_VERSION="v1.9.3"; OS=Darwin; ARCH=arm64 CLUSTER_NAME=tracebloc; SERVERS=1; AGENTS=1; HOST_DATA_DIR="$BATS_TEST_TMPDIR/.tracebloc" run print_banner - [ "$status" -eq 0 ] - [[ "$output" == *"Setting up"* ]] - [[ "$output" == *"tracebloc"* ]] - [[ "$output" == *"v1.9.3"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Setting up"* ]] || return 1 + [[ "$output" == *"tracebloc"* ]] || return 1 + [[ "$output" == *"v1.9.3"* ]] || return 1 } @test "print_banner: suppressed when the bootstrap already drew it (TRACEBLOC_BANNER_SHOWN)" { @@ -322,8 +322,8 @@ setup() { OS=Darwin; ARCH=arm64; CLUSTER_NAME=tracebloc; SERVERS=1; AGENTS=1 HOST_DATA_DIR="$BATS_TEST_TMPDIR/.tracebloc" run print_banner - [ "$status" -eq 0 ] - [[ "$output" != *"Setting up"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"Setting up"* ]] || return 1 unset TRACEBLOC_BANNER_SHOWN } @@ -337,9 +337,9 @@ setup() { modprobe() { record "modprobe $*"; } _real_sudo() { record "real_sudo $*"; } run sudo modprobe overlay - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q "modprobe overlay" - ! mock_calls | grep -q "real_sudo" + ! mock_calls | grep -q "real_sudo" || return 1 } @test "sudo(): non-root with sudo present defers to the real sudo" { @@ -348,7 +348,7 @@ setup() { _have_sudo_bin() { return 0; } _real_sudo() { record "real_sudo $*"; } run sudo modprobe overlay - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q "real_sudo modprobe overlay" } @@ -365,14 +365,14 @@ setup() { _have_sudo_bin() { return 1; } # even with NO sudo, root is fine _real_sudo() { echo "must-not-run"; return 1; } run preflight_sudo - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "preflight_sudo: non-root + no sudo => accurate error, not 'no sudo access'" { id() { echo 1000; } _have_sudo_bin() { return 1; } run preflight_sudo - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 printf '%s\n' "$output" | grep -qF "isn't installed" } @@ -381,7 +381,7 @@ setup() { _have_sudo_bin() { return 0; } _real_sudo() { case "$*" in "-n true") return 0 ;; *) return 1 ;; esac; } run preflight_sudo - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "sudo(): exported so a bash -c subshell inherits the shadow (#372)" { @@ -389,8 +389,8 @@ setup() { # RHEL-rebuild Docker install) must route through OUR shadow, not the real sudo. # A child bash sees the function only if it was exported (export -f in common.sh). run bash -c 'declare -F sudo >/dev/null && declare -f sudo | grep -q _real_sudo && echo INHERITED' - [ "$status" -eq 0 ] - [[ "$output" == *"INHERITED"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"INHERITED"* ]] || return 1 } @test "_have_sudo_bin: set -e safe when sudo is absent (no command substitution, #372)" { @@ -400,9 +400,9 @@ setup() { # no-sudo path aborted before preflight_sudo could print its clear message. # The whole-body `type -P` form has no substitution and must survive. run bash -c "set -e; PATH=/nonexistent; $(declare -f _have_sudo_bin); if _have_sudo_bin; then echo yes; else echo no; fi; echo survived" - [ "$status" -eq 0 ] - [[ "$output" == *"no"* ]] - [[ "$output" == *"survived"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"no"* ]] || return 1 + [[ "$output" == *"survived"* ]] || return 1 } # ── spin deadline + spin_cmd_bounded (#426) ────────────────────────────────── @@ -410,9 +410,9 @@ setup() { sleep 30 & local stuck_pid=$! run spin "$stuck_pid" "waiting…" 1 - [ "$status" -eq 124 ] + [ "$status" -eq 124 ] || return 1 # the stuck process is gone (kill -0 fails) - ! kill -0 "$stuck_pid" 2>/dev/null + ! kill -0 "$stuck_pid" 2>/dev/null || return 1 } @test "spin: deadline kills the wrapper's CHILDREN too, not just the subshell (Bugbot #442)" { @@ -423,11 +423,11 @@ setup() { sleep 0.3 # let the subshell fork its child local child child="$(pgrep -P "$wrapper" | head -1)" - [ -n "$child" ] + [ -n "$child" ] || return 1 run spin "$wrapper" "waiting…" 1 - [ "$status" -eq 124 ] - ! kill -0 "$wrapper" 2>/dev/null - ! kill -0 "$child" 2>/dev/null + [ "$status" -eq 124 ] || return 1 + ! kill -0 "$wrapper" 2>/dev/null || return 1 + ! kill -0 "$child" 2>/dev/null || return 1 } @test "spin: deadline KILLs a TERM-immune child even after the wrapper died (Bugbot #442 r2)" { @@ -438,18 +438,18 @@ setup() { sleep 0.4 local child child="$(pgrep -P "$wrapper" | head -1)" - [ -n "$child" ] + [ -n "$child" ] || return 1 run spin "$wrapper" "waiting…" 1 - [ "$status" -eq 124 ] - ! kill -0 "$child" 2>/dev/null + [ "$status" -eq 124 ] || return 1 + ! kill -0 "$child" 2>/dev/null || return 1 } @test "tb_minutes_or: base-10 normalization defuses the octal trap (Bugbot #442 r6)" { - [ "$(tb_minutes_or 08 15)" = "8" ] # would abort $(( )) as invalid octal - [ "$(tb_minutes_or 010 15)" = "10" ] # would silently read as 8 - [ "$(tb_minutes_or 25 15)" = "25" ] - [ "$(tb_minutes_or '' 15)" = "15" ] - [ "$(tb_minutes_or 20m 15)" = "15" ] + [ "$(tb_minutes_or 08 15)" = "8" ] || return 1 # would abort $(( )) as invalid octal + [ "$(tb_minutes_or 010 15)" = "10" ] || return 1 # would silently read as 8 + [ "$(tb_minutes_or 25 15)" = "25" ] || return 1 + [ "$(tb_minutes_or '' 15)" = "15" ] || return 1 + [ "$(tb_minutes_or 20m 15)" = "15" ] || return 1 } @test "spin: deadline path survives set -e end-to-end (Bugbot #442 r3)" { @@ -458,7 +458,7 @@ setup() { # before `return 124` and the caller sees 143/1 — no timeout copy, no # partial-cluster cleanup. The whole path must still deliver 124. run bash -c "set -euo pipefail; source '${BATS_TEST_DIRNAME}/../lib/common.sh'; LOG_FILE=/dev/null; sleep 30 & spin \$! 'waiting…' 1" - [ "$status" -eq 124 ] + [ "$status" -eq 124 ] || return 1 } @test "spin: without a deadline behaviour is unchanged (returns the pid's rc)" { @@ -467,26 +467,26 @@ setup() { bash -c 'exit 7' & local rc=0 spin "$!" "quick…" >/dev/null || rc=$? - [ "$rc" -eq 7 ] + [ "$rc" -eq 7 ] || return 1 } @test "spin_cmd_bounded: fast success passes through rc 0, no output" { run spin_cmd_bounded 5 "quick…" true - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "spin_cmd_bounded: failure preserves the command's exit code + tails the log" { LOG_FILE="$BATS_TEST_TMPDIR/spin.log" # load_lib pins LOG_FILE=/dev/null; tail needs a real file run spin_cmd_bounded 5 "failing…" bash -c 'echo boom; exit 3' - [ "$status" -eq 3 ] - [[ "$output" == *"Last 10 lines of log:"* ]] - [[ "$output" == *"boom"* ]] + [ "$status" -eq 3 ] || return 1 + [[ "$output" == *"Last 10 lines of log:"* ]] || return 1 + [[ "$output" == *"boom"* ]] || return 1 } @test "spin_cmd_bounded: deadline -> 124 with an explicit timeout note" { run spin_cmd_bounded 1 "stuck…" sleep 30 - [ "$status" -eq 124 ] - [[ "$output" == *"timed out after 1s"* ]] + [ "$status" -eq 124 ] || return 1 + [[ "$output" == *"timed out after 1s"* ]] || return 1 } # ── assert_tool_runs (execute-gate, #411) ──────────────────────────────────── @@ -496,8 +496,8 @@ setup() { chmod +x "$BATS_TEST_TMPDIR/bin/k3d" PATH="$BATS_TEST_TMPDIR/bin:$PATH" run assert_tool_runs k3d version - [ "$status" -eq 0 ] - [ -f "$BATS_TEST_TMPDIR/bin/k3d" ] + [ "$status" -eq 0 ] || return 1 + [ -f "$BATS_TEST_TMPDIR/bin/k3d" ] || return 1 } @test "assert_tool_runs: a broken tool with --rm errors and removes the binary WE placed (#411)" { @@ -506,9 +506,9 @@ setup() { chmod +x "$BATS_TEST_TMPDIR/bin/k3d" PATH="$BATS_TEST_TMPDIR/bin:$PATH" run assert_tool_runs --rm "$BATS_TEST_TMPDIR/bin/k3d" k3d version - [ "$status" -ne 0 ] - [[ "$output" == *"won't run"* ]] - [ ! -f "$BATS_TEST_TMPDIR/bin/k3d" ] # the binary we placed was removed + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"won't run"* ]] || return 1 + [ ! -f "$BATS_TEST_TMPDIR/bin/k3d" ] || return 1 # the binary we placed was removed } @test "assert_tool_runs: a broken tool WITHOUT --rm errors but leaves the binary (#411 review)" { @@ -519,9 +519,9 @@ setup() { chmod +x "$BATS_TEST_TMPDIR/bin/k3d" PATH="$BATS_TEST_TMPDIR/bin:$PATH" run assert_tool_runs k3d version - [ "$status" -ne 0 ] - [[ "$output" == *"won't run"* ]] - [ -f "$BATS_TEST_TMPDIR/bin/k3d" ] # NOT removed — we didn't place it + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"won't run"* ]] || return 1 + [ -f "$BATS_TEST_TMPDIR/bin/k3d" ] || return 1 # NOT removed — we didn't place it } @test "assert_tool_runs: --rm removes ONLY the binary that actually ran, not a decoy copy (#411 Bugbot)" { @@ -532,23 +532,70 @@ setup() { : > "$BATS_TEST_TMPDIR/tools/k3d" PATH="$BATS_TEST_TMPDIR/bin:$PATH" run assert_tool_runs --rm "$BATS_TEST_TMPDIR/tools/k3d" k3d version - [ "$status" -ne 0 ] - [ -f "$BATS_TEST_TMPDIR/tools/k3d" ] # NOT removed — it isn't the binary that ran + [ "$status" -ne 0 ] || return 1 + [ -f "$BATS_TEST_TMPDIR/tools/k3d" ] || return 1 # NOT removed — it isn't the binary that ran } # ── setup_log_file / _choose_log_file temp fallback (#432 prepare-host residual) ── @test "_choose_log_file: writable HOST_DATA_DIR -> a path under it" { HOST_DATA_DIR="$BATS_TEST_TMPDIR/data" run _choose_log_file - [ "$status" -eq 0 ] - [[ "$output" == "$HOST_DATA_DIR"* ]] - [ -f "$output" ] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == "$HOST_DATA_DIR"* ]] || return 1 + [ -f "$output" ] || return 1 } @test "_choose_log_file: uncreatable HOST_DATA_DIR -> temp fallback, never a bare failure (#432)" { ro="$BATS_TEST_TMPDIR/ro"; mkdir -p "$ro"; chmod 500 "$ro" HOST_DATA_DIR="$ro/cannot/make" run _choose_log_file chmod 700 "$ro" - [ "$status" -eq 0 ] - [[ "$output" == *tracebloc-install-* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *tracebloc-install-* ]] || return 1 +} + +# ── _assert_download_size (resilient tool download, #607) ──────────────────── +# Catches a proxy/AV-truncated or blocked binary transfer as a TRANSFER failure, +# before _verify_sha256 misreports it as a checksum ("tampering") failure. +@test "_assert_download_size: a complete file above the floor passes" { + local f="$BATS_TEST_TMPDIR/big.bin"; head -c 1200000 /dev/zero > "$f" + run _assert_download_size "$f" 1000000 "kubectl" + [ "$status" -eq 0 ] || return 1 +} + +@test "_assert_download_size: a truncated/blocked payload fails with a transfer message" { + local f="$BATS_TEST_TMPDIR/tiny.html"; printf '<html>blocked by proxy</html>' > "$f" + run _assert_download_size "$f" 1000000 "k3d" + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"truncated or blocked"* ]] || return 1 + [[ "$output" == *"proxy or antivirus"* ]] || return 1 +} + +@test "_assert_download_size: a missing file fails closed" { + run _assert_download_size "$BATS_TEST_TMPDIR/nope.bin" 100 "helm" + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"truncated or blocked"* ]] || return 1 +} + +@test "_assert_download_size: TB_MIN_DOWNLOAD_BYTES=0 relaxes the floor (bats fetch-mock hook)" { + export TB_MIN_DOWNLOAD_BYTES=0 + local f="$BATS_TEST_TMPDIR/small.bin"; printf 'x' > "$f" + run _assert_download_size "$f" 1000000 "k3d" + [ "$status" -eq 0 ] || return 1 +} + +@test "_assert_download_size: removes the caller's tmp tree on a truncated transfer (#607 Bugbot)" { + local tmp; tmp="$(mktemp -d)" + printf '<html>blocked</html>' > "$tmp/k3d" + run _assert_download_size "$tmp/k3d" 1000000 "k3d" "$tmp" + [ "$status" -ne 0 ] || return 1 + [ ! -d "$tmp" ] || { rm -rf "$tmp"; return 1; } +} + +@test "_assert_download_size: a complete file leaves the caller's tmp tree intact" { + local tmp; tmp="$(mktemp -d)" + head -c 1200000 /dev/zero > "$tmp/k3d" + run _assert_download_size "$tmp/k3d" 1000000 "k3d" "$tmp" + [ "$status" -eq 0 ] || { rm -rf "$tmp"; return 1; } + [ -d "$tmp" ] || return 1 + rm -rf "$tmp" } diff --git a/scripts/tests/diagnose.bats b/scripts/tests/diagnose.bats index 0e6f5333..25bffcff 100644 --- a/scripts/tests/diagnose.bats +++ b/scripts/tests/diagnose.bats @@ -16,7 +16,7 @@ setup() { f="$BATS_TEST_TMPDIR/v.yaml" printf 'clientId: "abc-123"\nclientPassword: '\''S3cr3tP@ss'\''\n' > "$f" _redact_file "$f" - ! grep -q 'S3cr3tP@ss' "$f" + ! grep -q 'S3cr3tP@ss' "$f" || return 1 grep -q 'clientPassword: \[REDACTED\]' "$f" grep -q 'abc-123' "$f" } @@ -25,7 +25,7 @@ setup() { f="$BATS_TEST_TMPDIR/p.txt" echo 'HTTP_PROXY=http://user:s3cr3t@proxy.corp:8080' > "$f" _redact_file "$f" - ! grep -q 's3cr3t' "$f" + ! grep -q 's3cr3t' "$f" || return 1 grep -q 'http://\[REDACTED\]@proxy.corp:8080' "$f" } @@ -33,8 +33,8 @@ setup() { f="$BATS_TEST_TMPDIR/l.txt" printf 'POST password=hunter2&x=1\ntoken: ghp_SECRETTOKEN\n' > "$f" _redact_file "$f" - ! grep -q 'hunter2' "$f" - ! grep -q 'ghp_SECRETTOKEN' "$f" + ! grep -q 'hunter2' "$f" || return 1 + ! grep -q 'ghp_SECRETTOKEN' "$f" || return 1 } @test "_redact_file: non-secret content left intact" { @@ -50,14 +50,14 @@ setup() { f="$BATS_TEST_TMPDIR/g.yaml" printf 'dockerRegistry:\n password: dckr_REGTOKEN\nHTTP_PROXY_PASSWORD: PROXYPW123\nMYSQL_ROOT_PASSWORD=ROOTPW123\n' > "$f" _redact_file "$f" - ! grep -q 'dckr_REGTOKEN' "$f" - ! grep -q 'PROXYPW123' "$f" - ! grep -q 'ROOTPW123' "$f" + ! grep -q 'dckr_REGTOKEN' "$f" || return 1 + ! grep -q 'PROXYPW123' "$f" || return 1 + ! grep -q 'ROOTPW123' "$f" || return 1 } @test "_redact_file: missing file is a no-op (no error)" { run _redact_file "$BATS_TEST_TMPDIR/nope.txt" - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } # ── run_diagnose (end-to-end, the headline security proof) ────────────────── @@ -66,12 +66,12 @@ setup() { echo "installer log line" > "$HOST_DATA_DIR/install-20260101-000000.log" has() { return 1; } # no kubectl/docker/helm -> best-effort path run run_diagnose - [ "$status" -eq 0 ] - [[ "$output" == *"Diagnostics saved"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Diagnostics saved"* ]] || return 1 tgz="$(ls "$HOST_DATA_DIR"/tracebloc-diagnose-*.tgz 2>/dev/null | head -1)" - [ -n "$tgz" ] + [ -n "$tgz" ] || return 1 # extract to stdout and confirm the secret was redacted before archiving - ! tar -xzOf "$tgz" 2>/dev/null | grep -q 'LEAKME123' + ! tar -xzOf "$tgz" 2>/dev/null | grep -q 'LEAKME123' || return 1 # but the bundle still contains useful content (the host section) tar -tzf "$tgz" 2>/dev/null | grep -q '00-host.txt' } @@ -79,8 +79,8 @@ setup() { @test "run_diagnose: best-effort with no cluster (does not crash)" { has() { return 1; } run run_diagnose - [ "$status" -eq 0 ] - [[ "$output" == *"Diagnostics saved"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Diagnostics saved"* ]] || return 1 } @test "run_diagnose: exercises the cluster-data collection when tools are present" { @@ -94,24 +94,24 @@ setup() { docker() { printf 'docker %s\n' "$*"; } helm() { printf 'helm %s\n' "$*"; } run run_diagnose - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 tgz="$(ls "$HOST_DATA_DIR"/tracebloc-diagnose-*.tgz 2>/dev/null | head -1)" - [ -n "$tgz" ] + [ -n "$tgz" ] || return 1 # the kubectl + helm + per-workload-log collection branches ran tar -tzf "$tgz" | grep -q '02-kubectl.txt' tar -tzf "$tgz" | grep -q '04-helm.txt' tar -tzf "$tgz" | grep -q 'logs/mysql-client.log' # Finding 2 (security review): `helm get manifest` (base64 Secrets) is NOT collected - ! tar -xzOf "$tgz" 2>/dev/null | grep -q 'get manifest' + ! tar -xzOf "$tgz" 2>/dev/null | grep -q 'get manifest' || return 1 } @test "run_diagnose: surfaces + records the client version" { has() { case "$1" in helm) return 0 ;; *) return 1 ;; esac; } # only helm present helm() { echo "tracebloc tracebloc 1 now deployed client-1.4.4 1.4.4"; } run run_diagnose - [ "$status" -eq 0 ] - [[ "$output" == *"client version: 1.4.4"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"client version: 1.4.4"* ]] || return 1 tgz="$(ls "$HOST_DATA_DIR"/tracebloc-diagnose-*.tgz 2>/dev/null | head -1)" - [ -n "$tgz" ] + [ -n "$tgz" ] || return 1 tar -xzOf "$tgz" 2>/dev/null | grep -q 'CLIENT VERSION: 1.4.4' } diff --git a/scripts/tests/e2e-full-seal.sh b/scripts/tests/e2e-full-seal.sh new file mode 100755 index 00000000..22197d14 --- /dev/null +++ b/scripts/tests/e2e-full-seal.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# ============================================================================= +# e2e-full-seal.sh — the FULL seal suite vs the dev backend (backend#1184 residual) +# ----------------------------------------------------------------------------- +# e2e-seal-check.sh proves the egress-enforcement probe alone — deliberately +# the one check that needs zero secrets. This script closes the deferred +# fast-follow recorded when backend#1184 closed: run the WHOLE conformance +# suite — egress-enforcement + backend-reachability + bound-PVC +# storage-assertions — on a real k3d cluster installed with the dev-env +# e2e-test-agent's REAL credentials, so every `helm.sh/hook: test` seal-check +# in the chart is exercised live, not just the secret-free one. +# +# Why real credentials change what is verifiable: +# • backend-reachability round-trips to the real dev API from a non-training +# pod (the required-egress complement of the enforcement probe). +# • jobs-manager genuinely authenticates, consumers start, and the release +# PVCs BIND — so storage-assertions verifies bound storage on the expected +# class instead of failing on Pending WaitForFirstConsumer claims. +# +# Credentials contract (the CI job provides both from repo Actions secrets, +# and skips green with a notice when they are absent): +# TB_E2E_CLIENT_ID / TB_E2E_CLIENT_PASSWORD — a DEDICATED dev-platform test +# client ("e2e-test-agent"), never a real customer identity. jobs-manager +# authenticates against the dev backend as this client for the lifetime of +# the run; the cluster is deleted on exit either way. +# +# Usage: +# TB_E2E_CLIENT_ID=… TB_E2E_CLIENT_PASSWORD=… bash scripts/tests/e2e-full-seal.sh +# ============================================================================= +set -euo pipefail + +[ -n "${TB_E2E_CLIENT_ID:-}" ] && [ -n "${TB_E2E_CLIENT_PASSWORD:-}" ] || { + echo "TB_E2E_CLIENT_ID / TB_E2E_CLIENT_PASSWORD are required (dev e2e-test-agent" >&2 + echo "credentials; the CI job skips with a notice instead when they are absent)." >&2 + exit 2 +} + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB="$HERE/../lib" +CHART_DIR="$HERE/../../client" + +# Shared bring-up contract (isolation env + tool-install prereqs) + the shared +# egress positive control, same as the sibling e2e-*.sh. +# shellcheck source=/dev/null +source "$HERE/lib/e2e-common.sh" +e2e_isolate_env tbfullseal +# NS follows CLUSTER_NAME so a CLUSTER_NAME override isolates a whole run under +# ONE name — cluster + release + namespace move together (same as the sibling). +NS="$CLUSTER_NAME" + +# shellcheck source=/dev/null +source "$LIB/common.sh" +# shellcheck source=/dev/null +source "$LIB/setup-linux.sh" +# shellcheck source=/dev/null +source "$LIB/cluster.sh" +# shellcheck source=/dev/null +source "$LIB/preflight.sh" # provides _pf_recheck_runtime_mem (called by create_cluster) + +# The credentials travel in a mode-0600 values file, never on argv (a shared +# runner's process list is world-readable, and helm --set mangles commas/ +# braces a password may contain) — same stance as the installer's generated +# values.yaml. Removed on every exit path together with the cluster. +CREDS_FILE="" +cleanup() { + [ -n "$CREDS_FILE" ] && rm -f "$CREDS_FILE" + k3d cluster delete "$CLUSTER_NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } + +e2e_install_prereqs + +echo "── create_cluster() — real k3d bring-up ──" +create_cluster +kubectl wait --for=condition=Ready nodes --all --timeout=180s + +# The probe host — pinned on the install so the enforcement probe and the +# positive control can never target different hosts (same stance as the +# sibling, from the Saqlain nit on #541). +HOST=1.1.1.1 + +echo "── helm install (dev backend, REAL credentials, lockdown ENGAGED — full suite renders) ──" +# Same base profile as e2e-seal-check.sh (local working-tree chart, public +# images, local-path storage, lockdown on with the 240s probe budget), plus the +# real credentials and CLIENT_ENV=dev: the backend the reachability check must +# round-trip to, and the login that makes the release come up for real. +# Single-quoted YAML scalars with the standard '' escape, matching the +# installer's _yaml_sq_escape treatment of the same two values. +# pvcAccessMode=ReadWriteOnce: the chart's PVC default is ReadWriteMany, which +# local-path never provisions — claims would sit Pending forever and both the +# Bound pre-wait and storage-assertions would fail (Bugbot). The installer +# writes exactly this value for the same storage path. +_sq() { printf %s "$1" | sed "s/'/''/g"; } +CREDS_FILE="$(mktemp)" +chmod 600 "$CREDS_FILE" +{ + printf "clientId: '%s'\n" "$(_sq "$TB_E2E_CLIENT_ID")" + printf "clientPassword: '%s'\n" "$(_sq "$TB_E2E_CLIENT_PASSWORD")" +} > "$CREDS_FILE" +helm install "$NS" "$CHART_DIR" --namespace "$NS" --create-namespace \ + -f "$CHART_DIR/tests/values-public-images.yaml" \ + -f "$CREDS_FILE" \ + --set env.CLIENT_ENV=dev \ + --set storageClass.provisioner=rancher.io/local-path \ + --set pvcAccessMode=ReadWriteOnce \ + --set networkPolicy.training.allowExternalHttps=false \ + --set networkPolicy.training.enforcementProbeHost="$HOST" \ + --set networkPolicy.training.enforcementProbeTimeoutSeconds=240 + +echo "── wait: every release PVC Bound (storage-assertions' precondition, asserted crisply first) ──" +# storage-assertions itself waits sealCheck.storageAssertions.timeoutSeconds +# (120s default) — pre-asserting here with a longer budget separates "the +# cluster was slow to bind" from "the assertions are wrong", and names the +# offending claim in the failure instead of a generic helm-test error. +total=0 +deadline=$(( $(date +%s) + 300 )) +while :; do + # One guarded fetch per iteration: a transient kubectl failure yields an + # empty snapshot (total=0, keep waiting) instead of aborting the script + # under set -euo pipefail mid-wait (Bugbot). + pvcs="$(kubectl --request-timeout=10s get pvc -n "$NS" --no-headers 2>/dev/null || true)" + unbound="$(awk '$2 != "Bound" {print $1" ("$2")"}' <<<"$pvcs")" + total="$(grep -c . <<<"$pvcs" || true)" + [ "$total" -gt 0 ] && [ -z "$unbound" ] && break + [ "$(date +%s)" -ge "$deadline" ] && + fail "release PVCs not all Bound after 300s (total=${total}): ${unbound:-none listed} — storage-assertions would fail; failing here with the crisper reason." + sleep 5 +done +echo "all ${total} release PVCs Bound" + +echo "── wait: the deployments that hold the real backend session ──" +kubectl -n "$NS" rollout status deploy/mysql-client --timeout=300s +kubectl -n "$NS" rollout status "deploy/${NS}-jobs-manager" --timeout=300s + +e2e_egress_positive_control "$HOST" + +# Guard the silent-pass trap for the FULL suite: assert every expected hook is +# in the release BEFORE `helm test`. The sibling guards its single --filter for +# the same reason — and an UNFILTERED helm test equally "passes" a release +# whose hooks silently stopped rendering (a regated check just vanishes from +# the run). The three names below are pinned by the helm-unittest suites. +echo "── verify all three seal-check hooks are in the release ──" +hooks="$(helm get hooks "$NS" --namespace "$NS")" +for check in egress-enforcement-check egress-reachability-check storage-assertions-check; do + grep -q "name: ${NS}-${check}" <<<"$hooks" || + fail "expected hook ${NS}-${check} not found in the release — the full suite would run incomplete" +done + +echo "── helm test (unfiltered — the whole seal suite) ──" +# Drive off the EXIT CODE, not --logs (same rationale as the sibling: hook pods +# carry generated suffixes and hook-succeeded deletes passing Jobs). On failure +# the failed Jobs persist — dump every seal-check pod log via the enumeration +# label contract (RFC-0003 §8.2) for triage. +if ! helm test "$NS" --namespace "$NS" --timeout 600s; then + echo "── seal-check job logs (failed hooks persist) ──" + kubectl --request-timeout=10s logs -n "$NS" -l "tracebloc.io/seal-check=true" --tail=-1 --prefix 2>/dev/null || true + kubectl --request-timeout=10s get jobs -n "$NS" 2>/dev/null || true + fail "helm test reported failure — the full seal suite did NOT pass against the dev backend" +fi + +echo "PASS: full seal suite — egress-enforcement + backend-reachability + storage-assertions all green vs the dev backend." diff --git a/scripts/tests/e2e-seal-check.sh b/scripts/tests/e2e-seal-check.sh index cd1bdcca..f5eaf450 100755 --- a/scripts/tests/e2e-seal-check.sh +++ b/scripts/tests/e2e-seal-check.sh @@ -81,38 +81,9 @@ helm install "$NS" "$CHART_DIR" --namespace "$NS" --create-namespace \ --set networkPolicy.training.enforcementProbeHost="$HOST" \ --set networkPolicy.training.enforcementProbeTimeoutSeconds=240 -# Positive control (Saqlain review): before trusting a BLOCKED probe result, -# prove the cluster can actually REACH the probe host. Otherwise egress failing -# for an unrelated reason (a runner firewall, a target outage, a rate-limit) -# makes the probe print OK and the seal-check pass green while the NetworkPolicy -# did nothing. A pod in `default` is governed by NO training-egress policy (the -# policy is namespace-scoped to the release ns), so if IT reaches the host, the -# training pod's block below is attributable to the policy, not the environment. -# Same image + curl invocation as the probe, targeting the SAME $HOST pinned on -# the install above — so a reachable positive here is attributable to exactly -# the host the probe is blocked from (no hardcoded-vs-chart-default drift). -echo "── positive control: a non-policied pod must REACH ${HOST}:443 ──" -# A fast runner can schedule the pod before the `default` ServiceAccount is -# created ("serviceaccount default not found"), which aborts under set -e before -# the attribution failure below. Wait for the SA to exist first (Bugbot). -for _ in $(seq 1 20); do - kubectl get serviceaccount default -n default >/dev/null 2>&1 && break - sleep 1 -done -kubectl run seal-poscheck --namespace default --restart=Never \ - --image="curlimages/curl:8.20.0" \ - --command -- curl --noproxy '*' --tlsv1.2 -k -sS -m 15 -o /dev/null "https://${HOST}" -posphase="" -for _ in $(seq 1 40); do - posphase="$(kubectl get pod seal-poscheck -n default -o jsonpath='{.status.phase}' 2>/dev/null || true)" - { [ "$posphase" = "Succeeded" ] || [ "$posphase" = "Failed" ]; } && break - sleep 3 -done -kubectl logs seal-poscheck -n default 2>/dev/null || true -kubectl delete pod seal-poscheck -n default --ignore-not-found --now >/dev/null 2>&1 || true -[ "$posphase" = "Succeeded" ] || - fail "positive control FAILED — a non-policied pod could not reach ${HOST}:443 (phase=${posphase:-none}). A blocked training pod would NOT be attributable to the NetworkPolicy (runner egress / target issue), so the seal-check is inconclusive — refusing to report a false PASS." -echo "positive control OK — ${HOST}:443 reachable; a training-pod block is now attributable to the policy." +# Positive control — factored into scripts/tests/lib/e2e-common.sh verbatim +# (shared with e2e-full-seal.sh); rationale + #541 review provenance live there. +e2e_egress_positive_control "$HOST" # The one probe we exercise. Its Job is `<release>-egress-enforcement-check` # (templates/egress-enforcement-check.yaml). The helm-unittest suite pins this @@ -140,8 +111,8 @@ echo "── helm test --filter name=${PROBE} ──" # pod log for triage. if ! helm test "$NS" --namespace "$NS" --filter "name=${PROBE}" --timeout 360s; then echo "── probe pod log (Job persists on failure) ──" - kubectl logs -n "$NS" -l "job-name=${PROBE}" --tail=-1 2>/dev/null || - kubectl describe job -n "$NS" "${PROBE}" 2>/dev/null || true + kubectl --request-timeout=10s logs -n "$NS" -l "job-name=${PROBE}" --tail=-1 2>/dev/null || + kubectl --request-timeout=10s describe job -n "$NS" "${PROBE}" 2>/dev/null || true fail "helm test reported failure for ${PROBE} — egress lockdown NOT verified" fi diff --git a/scripts/tests/gpu-nvidia.bats b/scripts/tests/gpu-nvidia.bats index 47b119ad..7b0d7271 100644 --- a/scripts/tests/gpu-nvidia.bats +++ b/scripts/tests/gpu-nvidia.bats @@ -238,3 +238,86 @@ _gpu_mocks() { [ "$status" -eq 0 ] || { echo "$output"; return 1; } [[ "$output" == *"DONE:cr=2"* ]] || return 1 } + +# ── bounded GPU apply (Bugbot) ─────────────────────────────────────────────── +@test "the GPU manifest apply is bounded: --request-timeout, so a wedged API can't hang it (Bugbot)" { + # _apply_remote_manifest redirects apply output to the log; without a request + # timeout a wedged API server would hang silently instead of failing into the + # caller's recoverable CPU-mode warn. + grep -Eq 'kubectl apply -f "\$tmp_yml" --request-timeout=' \ + "$BATS_TEST_DIRNAME/../lib/gpu-plugins.sh" +} + +@test "GPU success is gated on the rollout exit code — no false 'enabled' after a failed rollout (Bugbot)" { + # A timed-out/failed rollout means the plugin isn't confirmed ready. Gating now + # lives in the shared _gpu_rollout_gate (the nvidia path delegates to it); the gate + # puts success in the rollout then-branch with a CPU-mode warn on failure. + grep -q '_gpu_rollout_gate nvidia-device-plugin-daemonset' \ + "$BATS_TEST_DIRNAME/../lib/gpu-plugins.sh" + grep -q 'rollout status "daemonset/' \ + "$BATS_TEST_DIRNAME/../lib/gpu-plugins.sh" + grep -q "Couldn't confirm GPU acceleration is ready" \ + "$BATS_TEST_DIRNAME/../lib/gpu-plugins.sh" +} + +@test "GPU verify is gated on a successful deploy so CPU-mode skips the ~90s wait (Bugbot)" { + grep -Eq 'if deploy_gpu_device_plugin; then' "$BATS_TEST_DIRNAME/../install-k8s.sh" +} + +@test "_deploy_nvidia_plugin returns non-zero on a CPU-mode (apply-failure) path (Bugbot)" { + # A failed deploy must signal non-zero so the caller skips verify_gpu. + run bash -c ' + set -euo pipefail + GPU_VENDOR=nvidia; NVIDIA_DEVICE_PLUGIN_URL=http://example/x.yml; LOG_FILE=/dev/null + log(){ :; }; success(){ :; }; warn(){ :; } + source "'"$BATS_TEST_DIRNAME"'/../lib/gpu-plugins.sh" + _apply_remote_manifest(){ return 1; } # override AFTER source: simulate apply failure + kubectl(){ return 1; } # get daemonset -> not present + _deploy_nvidia_plugin && echo RC0 || echo "RC$?" + ' + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"RC1"* ]] || { echo "expected non-zero return; got: $output"; return 1; } +} + +@test "_gpu_rollout_gate: rollout failure -> warn CPU-mode + non-zero, no success (reviewer)" { + run bash -c ' + set -euo pipefail + LOG_FILE=/dev/null + log(){ :; }; success(){ echo "SUCCESS:$*"; }; warn(){ echo "WARN:$*"; } + source "'"$BATS_TEST_DIRNAME"'/../lib/gpu-plugins.sh" + kubectl(){ return 1; } # rollout status fails + _gpu_rollout_gate amdgpu-device-plugin && echo RC0 || echo "RC$?" + ' + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"WARN:"* ]] || return 1 + [[ "$output" == *"RC1"* ]] || return 1 + [[ "$output" != *"SUCCESS:"* ]] || return 1 +} + +@test "_gpu_rollout_gate: rollout success -> success + return 0" { + run bash -c ' + set -euo pipefail + LOG_FILE=/dev/null + log(){ :; }; success(){ echo "SUCCESS:$*"; }; warn(){ echo "WARN:$*"; } + source "'"$BATS_TEST_DIRNAME"'/../lib/gpu-plugins.sh" + kubectl(){ return 0; } # rollout status ok + _gpu_rollout_gate nvidia-device-plugin-daemonset && echo RC0 || echo "RC$?" + ' + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [[ "$output" == *"SUCCESS:"* ]] || return 1 + [[ "$output" == *"RC0"* ]] || return 1 +} + +@test "GPU existence probes are bounded with --request-timeout (reviewer parity)" { + # Both the nvidia and amd 'already installed?' checks must carry a request timeout + # so a wedged API can't hang before the bounded apply is ever reached. + run grep -cE 'kubectl get daemonset .*--request-timeout=' "$BATS_TEST_DIRNAME/../lib/gpu-plugins.sh" + [ "$output" -ge 2 ] || return 1 +} + +@test "amd primary AND master fallback both gate on rollout (reviewer)" { + # A master apply that never rolls out must warn CPU-mode via the shared gate, not + # return a false success that makes the caller's verify poll ~90s. + run grep -c '_gpu_rollout_gate amdgpu-device-plugin' "$BATS_TEST_DIRNAME/../lib/gpu-plugins.sh" + [ "$output" -eq 2 ] || return 1 +} diff --git a/scripts/tests/index-invariants.bats b/scripts/tests/index-invariants.bats index 91c76f7c..e2625d1f 100644 --- a/scripts/tests/index-invariants.bats +++ b/scripts/tests/index-invariants.bats @@ -55,22 +55,22 @@ guard() { run env INDEX_FILE="$IDX" TAG="${TAG:-}" PRERELEASE="${PRERELEASE:-}" @test "a stable-only index passes" { guard - [ "$status" -eq 0 ] - [[ "$output" == *"Index invariants hold"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Index invariants hold"* ]] || return 1 } @test "a prerelease-shaped version in the index is REJECTED" { seed_index '1.9.9-rc1' guard - [ "$status" -eq 1 ] - [[ "$output" == *"prerelease-shaped versions"* ]] - [[ "$output" == *"1.9.9-rc1"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"prerelease-shaped versions"* ]] || return 1 + [[ "$output" == *"1.9.9-rc1"* ]] || return 1 } @test "a hyphen in a chart NAME is not a prerelease version" { printf ' my-ingestor-chart:\n - name: my-ingestor-chart\n version: 0.2.0\n' >>"$IDX" guard - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "a hyphen in a URL is not a prerelease version" { @@ -78,7 +78,7 @@ guard() { run env INDEX_FILE="$IDX" TAG="${TAG:-}" PRERELEASE="${PRERELEASE:-}" # line; the url lines it skipped are the reason that shape looked safe. Pin # that the tightened single-grep form still ignores them. guard - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } # ── invariant 2: a prerelease run must not index its own version ───────────── @@ -86,51 +86,51 @@ guard() { run env INDEX_FILE="$IDX" TAG="${TAG:-}" PRERELEASE="${PRERELEASE:-}" @test "a prerelease run whose version IS indexed is REJECTED" { seed_index '2.0.0' TAG='v2.0.0' PRERELEASE='true' guard - [ "$status" -eq 1 ] - [[ "$output" == *"leaked into the public index"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"leaked into the public index"* ]] || return 1 } @test "a prerelease run whose version is NOT indexed passes" { TAG='v2.0.0-rc1' PRERELEASE='true' guard - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "a STABLE run does not trip invariant 2 on its own indexed version" { TAG='v1.9.8' PRERELEASE='false' guard - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "the tag is matched literally, not as a regex" { # 1.9x8 must not match the indexed 1.9.8 through a live `.` metacharacter. TAG='v1.9x8' PRERELEASE='true' guard - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } # ── fail closed: a guard that cannot check must not claim the index is clean ── @test "a missing INDEX_FILE fails closed" { run env -u INDEX_FILE bash "$GUARD_SH" - [ "$status" -eq 1 ] - [[ "$output" == *"INDEX_FILE is not set"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"INDEX_FILE is not set"* ]] || return 1 } @test "a nonexistent index file fails closed" { run env INDEX_FILE="$BATS_TEST_TMPDIR/absent.yaml" bash "$GUARD_SH" - [ "$status" -eq 1 ] - [[ "$output" == *"does not exist"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"does not exist"* ]] || return 1 } @test "an empty index read fails closed" { : >"$IDX" guard - [ "$status" -eq 1 ] - [[ "$output" == *"empty read"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"empty read"* ]] || return 1 } @test "PRERELEASE=true with no TAG fails closed" { TAG='' PRERELEASE='true' guard - [ "$status" -eq 1 ] - [[ "$output" == *"TAG is empty"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"TAG is empty"* ]] || return 1 } @test "grep erroring out (exit >= 2) fails closed, it is not 'no leak'" { @@ -138,8 +138,8 @@ guard() { run env INDEX_FILE="$IDX" TAG="${TAG:-}" PRERELEASE="${PRERELEASE:-}" printf '#!/usr/bin/env bash\nexit 2\n' >"$BATS_TEST_TMPDIR/bin/grep" chmod +x "$BATS_TEST_TMPDIR/bin/grep" run env PATH="$BATS_TEST_TMPDIR/bin:$PATH" INDEX_FILE="$IDX" bash "$GUARD_SH" - [ "$status" -eq 1 ] - [[ "$output" == *"grep exited 2"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"grep exited 2"* ]] || return 1 } # ── the SIGPIPE class this guard must never regress into (Bugbot #515) ─────── @@ -148,29 +148,29 @@ guard() { run env INDEX_FILE="$IDX" TAG="${TAG:-}" PRERELEASE="${PRERELEASE:-}" seed_index '2.0.0' # the match is in the first few hundred bytes... pad_index # ...and the rest is far past the pipe buffer bytes="$(wc -c <"$IDX")" - [ "$bytes" -gt 65622 ] # prove the input really is past the buffer + [ "$bytes" -gt 65622 ] || return 1 # prove the input really is past the buffer TAG='v2.0.0' PRERELEASE='true' guard - [ "$status" -eq 1 ] - [[ "$output" == *"leaked into the public index"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"leaked into the public index"* ]] || return 1 } @test "a prerelease-shaped version at the TOP of a large index is still caught" { seed_index '1.9.9-rc1' pad_index bytes="$(wc -c <"$IDX")" - [ "$bytes" -gt 65622 ] + [ "$bytes" -gt 65622 ] || return 1 guard - [ "$status" -eq 1 ] - [[ "$output" == *"prerelease-shaped versions"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"prerelease-shaped versions"* ]] || return 1 } @test "a large CLEAN index still passes (the fix did not just invert the verdict)" { pad_index bytes="$(wc -c <"$IDX")" - [ "$bytes" -gt 65622 ] + [ "$bytes" -gt 65622 ] || return 1 TAG='v2.0.0' PRERELEASE='true' guard - [ "$status" -eq 0 ] - [[ "$output" == *"Index invariants hold"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Index invariants hold"* ]] || return 1 } # ── the workflow actually calls the script (extraction stays wired up) ─────── @@ -184,5 +184,5 @@ guard() { run env INDEX_FILE="$IDX" TAG="${TAG:-}" PRERELEASE="${PRERELEASE:-}" # is written in full before grep can close the pipe, so SIGPIPE is # unreachable there. The index read is the one that could exceed the buffer.) run grep -n '\$idx' "$wf" - [ "$status" -eq 1 ] + [ "$status" -eq 1 ] || return 1 } diff --git a/scripts/tests/install-bootstrap.bats b/scripts/tests/install-bootstrap.bats index cd610e0f..a070dbda 100644 --- a/scripts/tests/install-bootstrap.bats +++ b/scripts/tests/install-bootstrap.bats @@ -66,8 +66,9 @@ done serve="$SERVE"; serve_rel="$SERVE_REL" case "\$url" in *"/releases/download/"*/manifest.sha256) src="\$serve_rel/manifest.sha256" ;; - *"/releases/download/"*/manifest.sha256.sig) src="\$serve_rel/manifest.sha256.sig" ;; - *"/releases/download/"*/manifest.sha256.cert) src="\$serve_rel/manifest.sha256.cert" ;; + *"/releases/download/"*/manifest.sha256.sig) src="\$serve_rel/manifest.sha256.sig" ;; + *"/releases/download/"*/manifest.sha256.cert) src="\$serve_rel/manifest.sha256.cert" ;; + *"/releases/download/"*/manifest.sha256.bundle) src="\$serve_rel/manifest.sha256.bundle" ;; *raw.githubusercontent.com/*/scripts/*) src="\$serve/scripts/\${url#*/scripts/}" ;; *) echo "mock curl: unmapped \$url" >&2; exit 22 ;; esac @@ -128,9 +129,9 @@ run_boot_hermetic() { @test "mutable BRANCH ref fails closed without the opt-in" { REF="" BRANCH="develop" run_boot - [ "$status" -ne 0 ] - [[ "$output" == *"not an immutable release tag"* ]] - [ ! -f "$SBX/k8s-ran" ] # never reached the privileged step + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"not an immutable release tag"* ]] || return 1 + [ ! -f "$SBX/k8s-ran" ] || return 1 # never reached the privileged step } @test "path-traversal ref disguised as a tag fails closed without the opt-in" { @@ -139,21 +140,21 @@ run_boot_hermetic() { # branch — the immutable-tag pin bypassed with no opt-in (RFC-0001 R8). It must # now be REJECTED: exit non-zero, never fetch, never reach the privileged step. REF="v1.2.3-../../heads/main" COSIGN_RESULT=0 run_boot - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 # Either the tag-shape gate or the path-separator belt rejects it; both name R8. [[ "$output" == *"not an immutable release tag"* \ - || "$output" == *"path separator or '..'"* ]] - [ ! -f "$SBX/k8s-ran" ] # privileged step never reached + || "$output" == *"path separator or '..'"* ]] || return 1 + [ ! -f "$SBX/k8s-ran" ] || return 1 # privileged step never reached } @test "tag with a bare path separator fails closed without the opt-in" { # A '/' in the ref (e.g. a heads/ ref dressed as a tag) is a traversal lever # into a mutable location; reject it like the '..' case above. REF="v1.2.3/heads/main" COSIGN_RESULT=0 run_boot - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 [[ "$output" == *"not an immutable release tag"* \ - || "$output" == *"path separator or '..'"* ]] - [ ! -f "$SBX/k8s-ran" ] + || "$output" == *"path separator or '..'"* ]] || return 1 + [ ! -f "$SBX/k8s-ran" ] || return 1 } @test "un-stamped DEFAULT_REF fails closed (placeholder still present)" { @@ -161,26 +162,141 @@ run_boot_hermetic() { # running it directly (no REF/BRANCH) must refuse rather than guess. Hermetic: # with no REF/BRANCH set, a host tracebloc CLI would trip the healthy-bailout. run_boot_hermetic - [ "$status" -ne 0 ] - [[ "$output" == *"wasn't stamped with a pinned release tag"* ]] - [ ! -f "$SBX/k8s-ran" ] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"wasn't stamped with a pinned release tag"* ]] || return 1 + [ ! -f "$SBX/k8s-ran" ] || return 1 } @test "happy path: immutable tag + valid manifest + good signature runs install-k8s.sh" { REF="v9.9.9" COSIGN_RESULT=0 run_boot - [ "$status" -eq 0 ] - [[ "$output" == *"files intact"* ]] # first-run copy: "All N files intact — nothing was altered" - [ -f "$SBX/k8s-ran" ] # privileged step reached only after verify + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"files intact"* ]] || return 1 # first-run copy: "All N files intact — nothing was altered" + [ -f "$SBX/k8s-ran" ] || return 1 # privileged step reached only after verify +} + +@test "bootstrap wires an explicit CA into cosign via SSL_CERT_FILE (#583)" { + # cosign's Go HTTPS client reads SSL_CERT_FILE; behind a TLS-inspecting proxy the + # bootstrap must set it from TRACEBLOC_CA_BUNDLE before running cosign, or the + # signature check fails x509. Record what SSL_CERT_FILE cosign actually saw. + # The export is Linux-only (Go reads the Keychain on macOS — next test), so pin + # the platform: without the stub this test flips by whichever OS runs the suite. + local ca="$SBX/corp-ca.pem"; printf 'PEM\n' > "$ca" + rm -f "$BIN/uname"; printf '#!/usr/bin/env bash\necho Linux\n' > "$BIN/uname"; chmod +x "$BIN/uname" + cat > "$BIN/cosign" <<EOF +#!/usr/bin/env bash +printf '%s' "\${SSL_CERT_FILE:-}" > "$SBX/cosign-ssl" +exit 0 +EOF + chmod +x "$BIN/cosign" + REF="v9.9.9" TRACEBLOC_CA_BUNDLE="$ca" run_boot + [ "$status" -eq 0 ] || return 1 + [ "$(cat "$SBX/cosign-ssl")" = "$ca" ] || return 1 +} + +@test "bootstrap on macOS does NOT export SSL_CERT_FILE (inert for Go, shrinks curl trust, Bugbot)" { + # On Darwin, Go reads the Keychain — the export would help cosign not at all, + # while OpenSSL curl honors SSL_CERT_FILE replace-not-augment, so a corp-root-only + # bundle would cut download trust for zero gain. Validation still runs (next test + # covers fail-fast); only the export is platform-gated. + local ca="$SBX/corp-ca.pem"; printf 'PEM\n' > "$ca" + rm -f "$BIN/uname"; cat > "$BIN/uname" <<'EOF' +#!/usr/bin/env bash +echo Darwin +EOF + chmod +x "$BIN/uname" + cat > "$BIN/cosign" <<EOF +#!/usr/bin/env bash +printf '%s' "\${SSL_CERT_FILE:-}" > "$SBX/cosign-ssl" +exit 0 +EOF + chmod +x "$BIN/cosign" + REF="v9.9.9" TRACEBLOC_CA_BUNDLE="$ca" run_boot + [ "$status" -eq 0 ] || return 1 + [ -z "$(cat "$SBX/cosign-ssl")" ] || return 1 +} + +@test "bootstrap prefers the offline Sigstore bundle: verify-blob --bundle --offline (#584)" { + # When a manifest.sha256.bundle is published, the bootstrap must verify it OFFLINE + # (no live Rekor) and NOT fall back to the .sig/.cert online path — that's what makes + # a fresh install work on a sigstore-blocked / TLS-inspecting network. + printf 'BUNDLE\n' > "$SERVE_REL/manifest.sha256.bundle" + cat > "$BIN/cosign" <<EOF +#!/usr/bin/env bash +printf '%s\n' "\$@" >> "$SBX/cosign-args" +exit 0 +EOF + chmod +x "$BIN/cosign" + REF="v9.9.9" run_boot + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [ -f "$SBX/k8s-ran" ] || return 1 + grep -q -- '--bundle' "$SBX/cosign-args" || return 1 + grep -q -- '--offline' "$SBX/cosign-args" || return 1 + # bundle verified => the online sig/cert path is NOT taken + ! grep -q -- '--signature' "$SBX/cosign-args" || return 1 +} + +@test "bootstrap falls back to sig+cert when the bundle is present but fails offline verify (#584, reviewer)" { + # Exercise the bundle-present-but-verify-fails -> sig/cert fallback (not covered by + # the exit-0 bundle tests). cosign REJECTS the --bundle call but ACCEPTS sig/cert. + printf 'BUNDLE\n' > "$SERVE_REL/manifest.sha256.bundle" + cat > "$BIN/cosign" <<EOF +#!/usr/bin/env bash +printf '%s\n' "\$@" >> "$SBX/cosign-args" +for a in "\$@"; do [ "\$a" = "--bundle" ] && exit 1; done # offline bundle verify fails +exit 0 # online sig/cert verify passes +EOF + chmod +x "$BIN/cosign" + REF="v9.9.9" run_boot + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [ -f "$SBX/k8s-ran" ] || return 1 + grep -q -- '--bundle' "$SBX/cosign-args" || return 1 # bundle path WAS attempted + grep -q -- '--signature' "$SBX/cosign-args" || return 1 # ...then fell back to sig/cert +} + +@test "bootstrap fails closed when BOTH the bundle and sig/cert verify fail (#584, reviewer)" { + # Bundle present, but every cosign verify fails -> must abort, never reach the + # privileged step (no silent fall-through to running unverified scripts). + printf 'BUNDLE\n' > "$SERVE_REL/manifest.sha256.bundle" + COSIGN_RESULT=1 REF="v9.9.9" run_boot + [ "$status" -ne 0 ] || { echo "$output"; return 1; } + [ ! -f "$SBX/k8s-ran" ] || return 1 + [[ "$output" == *"Couldn't confirm the installer download is authentic"* ]] || return 1 +} + +@test "bootstrap falls back to sig+cert when no bundle is published (older release) (#584)" { + # No bundle asset (a release cut before #584): the bundle fetch 404s and the + # bootstrap must fall through to the online .sig/.cert keyless verification. + [ ! -f "$SERVE_REL/manifest.sha256.bundle" ] || return 1 # precondition: no bundle + cat > "$BIN/cosign" <<EOF +#!/usr/bin/env bash +printf '%s\n' "\$@" >> "$SBX/cosign-args" +exit 0 +EOF + chmod +x "$BIN/cosign" + REF="v9.9.9" run_boot + [ "$status" -eq 0 ] || { echo "$output"; return 1; } + [ -f "$SBX/k8s-ran" ] || return 1 + grep -q -- '--signature' "$SBX/cosign-args" || return 1 # online path used + ! grep -q -- '--bundle' "$SBX/cosign-args" || return 1 # bundle path not taken +} + +@test "bootstrap fails fast on a set-but-unreadable CA bundle (#583 Bugbot)" { + # A bad CA path must fail here with a clear message, not silently no-op and surface + # later as a generic cosign authenticity error. + REF="v9.9.9" TRACEBLOC_CA_BUNDLE="/no/such/corporate-ca.pem" run_boot + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"can't be read"* ]] || return 1 + [ ! -f "$SBX/k8s-ran" ] || return 1 } @test "tampered sub-script aborts before the privileged step" { # Mutate a fetched file AFTER the manifest was built → digest mismatch. echo "rm -rf / # evil" >> "$SERVE/scripts/lib/provision.sh" REF="v9.9.9" COSIGN_RESULT=0 run_boot - [ "$status" -ne 0 ] - [[ "$output" == *"Integrity check FAILED"* ]] - [[ "$output" == *"provision.sh"* ]] - [ ! -f "$SBX/k8s-ran" ] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"Integrity check FAILED"* ]] || return 1 + [[ "$output" == *"provision.sh"* ]] || return 1 + [ ! -f "$SBX/k8s-ran" ] || return 1 } @test "a file missing from the manifest aborts" { @@ -188,32 +304,34 @@ run_boot_hermetic() { grep -v 'scripts/lib/provision.sh' "$SERVE_REL/manifest.sha256" > "$SERVE_REL/m.tmp" mv "$SERVE_REL/m.tmp" "$SERVE_REL/manifest.sha256" REF="v9.9.9" COSIGN_RESULT=0 run_boot - [ "$status" -ne 0 ] - [[ "$output" == *"no entry in manifest"* ]] - [ ! -f "$SBX/k8s-ran" ] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"isn't in the installer's signed checksum list"* ]] || return 1 + [ ! -f "$SBX/k8s-ran" ] || return 1 } @test "cosign signature failure aborts (no degrade to same-channel sha256)" { REF="v9.9.9" COSIGN_RESULT=1 run_boot - [ "$status" -ne 0 ] - [[ "$output" == *"signature verification FAILED"* ]] - [ ! -f "$SBX/k8s-ran" ] + [ "$status" -ne 0 ] || return 1 + # Message sanitized for #576 (no internal identifiers); behaviour coverage + # (aborts + never degrades to a same-channel sha256) is unchanged. + [[ "$output" == *"Couldn't confirm the installer download is authentic"* ]] || return 1 + [ ! -f "$SBX/k8s-ran" ] || return 1 } @test "cosign absent on default path fails closed (can't bootstrap in sandbox)" { # cosign genuinely absent (PATH=$BIN only). The cosign download is unmapped in # mock curl (exit 22), so ensure_cosign fails → fail-closed on the default path. REF="v9.9.9" run_boot_no_cosign - [ "$status" -ne 0 ] - [[ "$output" == *"cosign is required"* ]] - [ ! -f "$SBX/k8s-ran" ] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"cosign is required"* ]] || return 1 + [ ! -f "$SBX/k8s-ran" ] || return 1 } @test "unverified opt-in degrades gracefully when cosign is absent" { REF="v9.9.9" TRACEBLOC_ALLOW_UNVERIFIED=1 run_boot_no_cosign - [ "$status" -eq 0 ] - [[ "$output" == *"manifest signature NOT verified"* ]] - [ -f "$SBX/k8s-ran" ] # checksum integrity still enforced; runs + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"the installer's signature NOT verified"* ]] || return 1 + [ -f "$SBX/k8s-ran" ] || return 1 # checksum integrity still enforced; runs } # ── Early bailout: already-healthy machine skips the whole download ────────── @@ -233,10 +351,10 @@ run_boot_hermetic() { EOF chmod +x "$BIN/tracebloc" run_boot # NO REF -> bailout is eligible - [ "$status" -eq 0 ] - [[ "$output" == *"Already set up and healthy"* ]] - [ -f "$SBX/home-ran" ] # handed off to the home screen - [ ! -f "$SBX/k8s-ran" ] # never downloaded / ran install-k8s.sh + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Already set up and healthy"* ]] || return 1 + [ -f "$SBX/home-ran" ] || return 1 # handed off to the home screen + [ ! -f "$SBX/k8s-ran" ] || return 1 # never downloaded / ran install-k8s.sh } @test "early bailout: unhealthy tracebloc doctor -> does NOT bail" { @@ -247,8 +365,8 @@ EOF chmod +x "$BIN/tracebloc" run_boot # NO REF: proceeds past bailout, then # hits the un-stamped-ref refusal - [[ "$output" != *"Already set up and healthy"* ]] - [ ! -f "$SBX/home-ran" ] # no hand-off + [[ "$output" != *"Already set up and healthy"* ]] || return 1 + [ ! -f "$SBX/home-ran" ] || return 1 # no hand-off } @test "early bailout: --force skips the bailout even when healthy" { @@ -258,8 +376,8 @@ EOF EOF chmod +x "$BIN/tracebloc" run_boot --force - [[ "$output" != *"Already set up and healthy"* ]] - [ ! -f "$SBX/home-ran" ] + [[ "$output" != *"Already set up and healthy"* ]] || return 1 + [ ! -f "$SBX/home-ran" ] || return 1 } # ── Reinstall intent reaches install-k8s.sh's stop-and-check gate ──────────── @@ -283,16 +401,16 @@ EOF @test "pinned REF exports TB_FORCE_REINSTALL so the assess gate can't short-circuit" { _capture_k8s_force REF="v9.9.9" COSIGN_RESULT=0 run_boot - [ "$status" -eq 0 ] - [ -f "$SBX/k8s-ran" ] - [[ "$(cat "$SBX/k8s-ran")" == "TB_FORCE_REINSTALL=1" ]] + [ "$status" -eq 0 ] || return 1 + [ -f "$SBX/k8s-ran" ] || return 1 + [[ "$(cat "$SBX/k8s-ran")" == "TB_FORCE_REINSTALL=1" ]] || return 1 } @test "--force also exports TB_FORCE_REINSTALL to install-k8s.sh" { _capture_k8s_force REF="v9.9.9" COSIGN_RESULT=0 run_boot --force - [ "$status" -eq 0 ] - [[ "$(cat "$SBX/k8s-ran")" == "TB_FORCE_REINSTALL=1" ]] + [ "$status" -eq 0 ] || return 1 + [[ "$(cat "$SBX/k8s-ran")" == "TB_FORCE_REINSTALL=1" ]] || return 1 } # prepare-host is useful precisely on a machine that is already set up (grant @@ -313,8 +431,8 @@ EOF # bootstrap would still see itself as unstamped. sed 's/^DEFAULT_REF=.*/DEFAULT_REF="v9.9.9"/' "$BOOT" > "$SBX/boot.stamped" PATH="$BIN:$PATH" run bash "$SBX/boot.stamped" prepare-host - [ "$status" -eq 0 ] - [[ "$output" != *"Already set up and healthy"* ]] - [ -f "$SBX/k8s-ran" ] - [[ "$(cat "$SBX/k8s-ran")" == "TB_FORCE_REINSTALL=unset" ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"Already set up and healthy"* ]] || return 1 + [ -f "$SBX/k8s-ran" ] || return 1 + [[ "$(cat "$SBX/k8s-ran")" == "TB_FORCE_REINSTALL=unset" ]] || return 1 } diff --git a/scripts/tests/install-cli.bats b/scripts/tests/install-cli.bats index d3948cb2..c4014d73 100644 --- a/scripts/tests/install-cli.bats +++ b/scripts/tests/install-cli.bats @@ -27,21 +27,21 @@ setup() { @test "install_tracebloc_cli: download failure is non-fatal (returns 0, warns)" { curl() { return 22; } # curl HTTP failure (exit 22) run install_tracebloc_cli - [ "$status" -eq 0 ] - [[ "$output" == *"WARN: Couldn't download"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"WARN: Couldn't download"* ]] || return 1 } @test "install_tracebloc_cli: installer-script failure is non-fatal (returns 0, warns)" { curl() { : > "${@: -1}"; return 0; } # 'download' OK (creates the -o target) sh() { return 1; } # the CLI installer itself fails run install_tracebloc_cli - [ "$status" -eq 0 ] - [[ "$output" == *"WARN: Couldn't install"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"WARN: Couldn't install"* ]] || return 1 # This step is by-design non-fatal, so a failure must NOT show spin_cmd's hard # red "✖ …" + log dump (which would look like a hard failure). We drive `spin` # directly to keep the failure path soft (Bugbot: fatal-looking CLI install UX). - [[ "$output" != *"Last 10 lines"* ]] - [[ "$output" != *"✖ Installing the tracebloc CLI"* ]] + [[ "$output" != *"Last 10 lines"* ]] || return 1 + [[ "$output" != *"✖ Installing the tracebloc CLI"* ]] || return 1 } @test "install_tracebloc_cli: success path reports installed" { @@ -51,10 +51,10 @@ setup() { _cli_on_fresh_path() { return 0; } # a fresh terminal finds it (don't spawn real shells) tracebloc() { echo "tracebloc 0.2.0"; } run install_tracebloc_cli - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 # tracebloc was already present at the same version → "up to date" (a re-run # that bumped the version would say "updated (vOLD → vNEW)"). - [[ "$output" == *"SUCCESS: tracebloc CLI up to date"* ]] + [[ "$output" == *"SUCCESS: tracebloc CLI up to date"* ]] || return 1 } # ── Self-verification (#738) ──────────────────────────────────────────────── @@ -71,14 +71,14 @@ setup() { _cli_at_system_dir() { return 0; } # installed to a system dir → usable in THIS shell too tracebloc() { echo "tracebloc 0.2.0"; } run install_tracebloc_cli - [ "$status" -eq 0 ] - [[ "$output" == *"to use it"* ]] # usable-now verdict ("… — run tb to use it") - [[ "$output" == *'`tb`'* ]] # prefers the short alias when it's present - [[ "$output" == *"0.2.0"* ]] # real proof via `tracebloc version` - [[ "$output" != *"open a new terminal"* ]] # not the new-terminal (edge) message + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"to use it"* ]] || return 1 # usable-now verdict ("… — run tb to use it") + [[ "$output" == *'`tb`'* ]] || return 1 # prefers the short alias when it's present + [[ "$output" == *"0.2.0"* ]] || return 1 # real proof via `tracebloc version` + [[ "$output" != *"open a new terminal"* ]] || return 1 # not the new-terminal (edge) message # The canonical dataset-push next step lives in summary.sh — don't duplicate it # here on the fully-verified path (#738: "don't duplicate; keep consistent"). - [[ "$output" != *"tracebloc dataset push"* ]] + [[ "$output" != *"tracebloc dataset push"* ]] || return 1 } @test "install_tracebloc_cli: names 'tracebloc' when the 'tb' alias wasn't created" { @@ -92,9 +92,9 @@ setup() { _cli_at_system_dir() { return 0; } # system dir → usable-now verdict path tracebloc() { echo "tracebloc 0.2.0"; } run install_tracebloc_cli - [ "$status" -eq 0 ] - [[ "$output" == *'run `tracebloc` to use it'* ]] # named the real binary - [[ "$output" != *'`tb`'* ]] # never a bare `tb` when it doesn't resolve + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *'run `tracebloc` to use it'* ]] || return 1 # named the real binary + [[ "$output" != *'`tb`'* ]] || return 1 # never a bare `tb` when it doesn't resolve } @test "install_tracebloc_cli: on PATH via ~/.local/bin (not a system dir) → new-terminal verdict, never 'run it now' (#371)" { @@ -111,9 +111,9 @@ setup() { SHELL="/bin/zsh"; OS="Linux" tracebloc() { echo "tracebloc 0.2.0"; } run install_tracebloc_cli - [ "$status" -eq 0 ] - [[ "$output" == *"open a new terminal"* ]] # matches the summary CTA - [[ "$output" != *"to use it"* ]] # NEVER the usable-now verdict on this path + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"open a new terminal"* ]] || return 1 # matches the summary CTA + [[ "$output" != *"to use it"* ]] || return 1 # NEVER the usable-now verdict on this path } @test "install_tracebloc_cli: fresh shell finds it but the CURRENT shell can't → 'new terminals' verdict + load-it-now hint (#304)" { @@ -127,12 +127,12 @@ setup() { SHELL="/bin/zsh"; OS="Linux" # zsh → ~/.zshrc tracebloc() { echo "tracebloc 0.2.0"; } run install_tracebloc_cli - [ "$status" -eq 0 ] # still non-fatal - [[ "$output" == *"open a new terminal"* ]] # honest: persisted, but not usable in THIS shell - [[ "$output" == *"source $HOME/.zshrc"* ]] # how to use it in THIS shell now - [[ "$output" != *"to use it"* ]] # never claim the usable-now verdict for this shell + [ "$status" -eq 0 ] || return 1 # still non-fatal + [[ "$output" == *"open a new terminal"* ]] || return 1 # honest: persisted, but not usable in THIS shell + [[ "$output" == *"source $HOME/.zshrc"* ]] || return 1 # how to use it in THIS shell now + [[ "$output" != *"to use it"* ]] || return 1 # never claim the usable-now verdict for this shell # It's already in the rc (fresh shell found it) — don't tell the user to re-append. - [[ "$output" != *"echo '"* ]] + [[ "$output" != *"echo '"* ]] || return 1 } @test "install_tracebloc_cli: CLI-missing-from-fresh-shell prints an actionable, shell-correct PATH hint" { @@ -141,13 +141,13 @@ setup() { _cli_on_fresh_path() { return 1; } # installed, but a fresh terminal does NOT find it SHELL="/bin/zsh"; OS="Linux" # zsh → ~/.zshrc (rc routing under test) run install_tracebloc_cli - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 # Append the exact PATH line to the rc, THEN source it — fixes this terminal # and every new one (the old code printed a bare `export` + a `source` of an # rc that didn't contain the line, so nothing persisted). - [[ "$output" == *"echo 'export PATH=\"$HOME/.local/bin:\$PATH\"' >> $HOME/.zshrc"* ]] - [[ "$output" == *"source $HOME/.zshrc"* ]] # the right rc for zsh - [[ "$output" != *"open a new terminal"* ]] # never the generic line + [[ "$output" == *"echo 'export PATH=\"$HOME/.local/bin:\$PATH\"' >> $HOME/.zshrc"* ]] || return 1 + [[ "$output" == *"source $HOME/.zshrc"* ]] || return 1 # the right rc for zsh + [[ "$output" != *"open a new terminal"* ]] || return 1 # never the generic line } @test "install_tracebloc_cli: fish gets a fish-correct fix (fish_add_path, no source needed)" { @@ -156,12 +156,12 @@ setup() { _cli_on_fresh_path() { return 1; } SHELL="/usr/bin/fish"; OS="Linux" run install_tracebloc_cli - [ "$status" -eq 0 ] - [[ "$output" == *"fish_add_path \"$HOME/.local/bin\""* ]] # fish's idiom, not POSIX export - [[ "$output" != *"export PATH"* ]] # never a POSIX export for fish + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"fish_add_path \"$HOME/.local/bin\""* ]] || return 1 # fish's idiom, not POSIX export + [[ "$output" != *"export PATH"* ]] || return 1 # never a POSIX export for fish # fish_add_path persists (universal var) AND applies to the running shell, so # fish users must NOT be told to `source` anything (the old guidance did). - [[ "$output" != *"source "* ]] + [[ "$output" != *"source "* ]] || return 1 } @test "install_tracebloc_cli: verification failure is still NON-FATAL (status 0)" { @@ -171,7 +171,7 @@ setup() { _cli_on_fresh_path() { return 2; } _cli_rc_for_shell() { return 7; } # even if rc resolution itself errors run install_tracebloc_cli - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "install_tracebloc_cli: NON-FATAL even under the orchestrator's set -e" { @@ -185,7 +185,7 @@ setup() { install_tracebloc_cli local rc=$? set +e - [ "$rc" -eq 0 ] + [ "$rc" -eq 0 ] || return 1 } # ── _cli_at_system_dir: the summary-CTA usable-now gate (Bugbot #371) ───────── @@ -193,9 +193,9 @@ setup() { HOME=/home/tester _cli_at_system_dir /usr/local/bin/tracebloc # system → usable now _cli_at_system_dir /usr/bin/tracebloc - ! _cli_at_system_dir /home/tester/.local/bin/tracebloc # $HOME → conservative - ! _cli_at_system_dir /home/tester/bin/tracebloc - ! _cli_at_system_dir "" # unresolved → conservative + ! _cli_at_system_dir /home/tester/.local/bin/tracebloc || return 1 # $HOME → conservative + ! _cli_at_system_dir /home/tester/bin/tracebloc || return 1 + ! _cli_at_system_dir "" || return 1 # unresolved → conservative } # ── TB_CLI_USABLE_NOW default seeded from pre-install state (Bugbot #371) ───── @@ -209,7 +209,7 @@ setup() { mktemp() { return 1; } # force the early "(no temp dir)" return TB_CLI_USABLE_NOW= install_tracebloc_cli >/dev/null 2>&1 || true - [ "$TB_CLI_USABLE_NOW" = "1" ] + [ "$TB_CLI_USABLE_NOW" = "1" ] || return 1 } @test "install_tracebloc_cli: pre-existing ~/.local/bin tracebloc + install step fails → TB_CLI_USABLE_NOW=0 (#371)" { @@ -222,5 +222,5 @@ setup() { mktemp() { return 1; } TB_CLI_USABLE_NOW= install_tracebloc_cli >/dev/null 2>&1 || true - [ "$TB_CLI_USABLE_NOW" = "0" ] + [ "$TB_CLI_USABLE_NOW" = "0" ] || return 1 } diff --git a/scripts/tests/install-client-helm.bats b/scripts/tests/install-client-helm.bats index cce5db0b..8fc291a2 100644 --- a/scripts/tests/install-client-helm.bats +++ b/scripts/tests/install-client-helm.bats @@ -25,101 +25,143 @@ setup() { @test "_backend_url: default (unset) -> prod" { unset CLIENT_ENV run _backend_url - [ "$output" = "https://api.tracebloc.io/" ] + [ "$output" = "https://api.tracebloc.io/" ] || return 1 } @test "_backend_url: dev" { CLIENT_ENV=dev run _backend_url - [ "$output" = "https://dev-api.tracebloc.io/" ] + [ "$output" = "https://dev-api.tracebloc.io/" ] || return 1 } @test "_backend_url: stg" { CLIENT_ENV=stg run _backend_url - [ "$output" = "https://stg-api.tracebloc.io/" ] + [ "$output" = "https://stg-api.tracebloc.io/" ] || return 1 } @test "_backend_url: unknown -> prod" { CLIENT_ENV=whatever run _backend_url - [ "$output" = "https://api.tracebloc.io/" ] + [ "$output" = "https://api.tracebloc.io/" ] || return 1 } # ── verify_credentials (mock curl's http_code on stdout) ─────────────────── @test "verify_credentials: HTTP 200 -> valid" { curl() { echo 200; } run verify_credentials id pw - [ "$output" = valid ] + [ "$output" = valid ] || return 1 } @test "verify_credentials: HTTP 400 -> invalid" { curl() { echo 400; } run verify_credentials id pw - [ "$output" = invalid ] + [ "$output" = invalid ] || return 1 } @test "verify_credentials: HTTP 401 -> inactive" { curl() { echo 401; } run verify_credentials id pw - [ "$output" = inactive ] + [ "$output" = inactive ] || return 1 } @test "verify_credentials: HTTP 429 -> unverified" { curl() { echo 429; } run verify_credentials id pw - [ "$output" = unverified ] + [ "$output" = unverified ] || return 1 } @test "verify_credentials: connection failure -> unverified" { curl() { return 7; } run verify_credentials id pw - [ "$output" = unverified ] + [ "$output" = unverified ] || return 1 } # ── sanitizers ───────────────────────────────────────────────────────────── @test "_strip_paste_garbage: unwraps bracketed-paste ESC markers" { run _strip_paste_garbage "$(printf '\e[200~secret\e[201~')" - [ "$output" = "secret" ] + [ "$output" = "secret" ] || return 1 } @test "_strip_paste_garbage: strips C0 control chars, keeps text" { run _strip_paste_garbage "$(printf 'ab\001cd')" - [ "$output" = "abcd" ] + [ "$output" = "abcd" ] || return 1 } @test "_sanitize_workspace_name: lowercases + dashes" { run _sanitize_workspace_name "My Team_1" - [ "$output" = "my-team-1" ] + [ "$output" = "my-team-1" ] || return 1 } @test "_sanitize_workspace_name: all-invalid -> default" { run _sanitize_workspace_name "@@@" - [ "$output" = "default" ] + [ "$output" = "default" ] || return 1 } @test "_sanitize_workspace_name: collapses + trims dashes" { run _sanitize_workspace_name "a--b-" - [ "$output" = "a-b" ] + [ "$output" = "a-b" ] || return 1 } # ── _extract_yaml_value ──────────────────────────────────────────────────── @test "_extract_yaml_value: double-quoted" { f="$BATS_TEST_TMPDIR/v"; printf 'clientId: "abc-123"\n' >"$f" run _extract_yaml_value "$f" clientId - [ "$output" = "abc-123" ] + [ "$output" = "abc-123" ] || return 1 } @test "_extract_yaml_value: single-quoted with '' escape" { f="$BATS_TEST_TMPDIR/v"; printf "clientPassword: 'a''b'\n" >"$f" run _extract_yaml_value "$f" clientPassword - [ "$output" = "a'b" ] + [ "$output" = "a'b" ] || return 1 } @test "_extract_yaml_value: missing key -> empty" { f="$BATS_TEST_TMPDIR/v"; printf 'other: x\n' >"$f" run _extract_yaml_value "$f" clientId - [ "$output" = "" ] + [ "$output" = "" ] || return 1 +} + +# The BARE-statement shape is the one that used to die (#523): on an absent key +# grep exits 1, `pipefail` carries that out of the assignment, and `set -e` kills +# the installer before the empty-check on the next line — the line that exists +# precisely to handle "key not found" — can run. Every call site wraps the +# function in `$( )` today, which suspends errexit for the body, so asserting +# those still work proves nothing about this. Exercise the bare call directly: +# under `set -e` a non-zero rc from it would abort, so reaching the sentinel IS +# the proof the not-found path is reachable. +@test "_extract_yaml_value: absent key under set -euo pipefail, bare call, does not abort (#523)" { + f="$BATS_TEST_TMPDIR/v"; printf 'other: x\n' >"$f" + run bash -c ' + set -euo pipefail + source "'"${LIB_DIR}"'/common.sh" + source "'"${LIB_DIR}"'/install-client-helm.sh" + LOG_FILE=/dev/null + _extract_yaml_value "'"$f"'" clientId + echo "REACHED_NOT_FOUND_PATH" + ' + [ "$status" -eq 0 ] || return 1 + # Sole output => the absent key emitted nothing, and execution continued. + [ "$output" = "REACHED_NOT_FOUND_PATH" ] || return 1 +} + +# The first fix used `grep | head -1 || line=""`. On a DUPLICATE key, head +# exits after line one and SIGPIPEs grep (141); under pipefail the fallback +# then wiped the successfully captured value, so detect_installed_client could +# miss a clientId and fail open toward overwrite (Bugbot). The pipeline is gone +# — grep captures every match, the shell takes the first — so a duplicate key +# must yield the FIRST value, under the same bare-call errexit shape as above. +@test "_extract_yaml_value: duplicate key under set -euo pipefail keeps the first value (Bugbot #525)" { + f="$BATS_TEST_TMPDIR/v"; printf 'clientId: "first"\nclientId: "second"\n' >"$f" + run bash -c ' + set -euo pipefail + source "'"${LIB_DIR}"'/common.sh" + source "'"${LIB_DIR}"'/install-client-helm.sh" + LOG_FILE=/dev/null + _extract_yaml_value "'"$f"'" clientId + ' + [ "$status" -eq 0 ] || return 1 + [ "$output" = "first" ] || return 1 } # ── _yaml_sq_escape / _yaml_sq_unescape (Saqlain review, #443) ────────────── @@ -127,23 +169,23 @@ setup() { # directions and their round-trip are pinned here. @test "_yaml_sq_escape: doubles a quote (no stray backslash on bash 3.2)" { run _yaml_sq_escape "a'b" - [ "$output" = "a''b" ] + [ "$output" = "a''b" ] || return 1 } @test "_yaml_sq_escape: leaves a quote-free value untouched" { run _yaml_sq_escape 'plain-uuid-123' - [ "$output" = 'plain-uuid-123' ] + [ "$output" = 'plain-uuid-123' ] || return 1 } @test "_yaml_sq_unescape: collapses a doubled quote" { run _yaml_sq_unescape "a''b" - [ "$output" = "a'b" ] + [ "$output" = "a'b" ] || return 1 } @test "_yaml_sq_escape then _yaml_sq_unescape round-trips quote-heavy values" { for v in "a'b" "'" "''" "it's a 'test'" "no-quotes"; do esc="$(_yaml_sq_escape "$v")" - [ "$(_yaml_sq_unescape "$esc")" = "$v" ] + [ "$(_yaml_sq_unescape "$esc")" = "$v" ] || return 1 done } @@ -155,7 +197,7 @@ setup() { raw="ab'cd" printf "clientId: '%s'\n" "$(_yaml_sq_escape "$raw")" >"$f" run _extract_yaml_value "$f" clientId - [ "$output" = "$raw" ] + [ "$output" = "$raw" ] || return 1 } @test "a double-quote in clientId no longer breaks the scalar" { @@ -165,20 +207,20 @@ setup() { # In a single-quoted YAML scalar a double quote is literal — no escaping needed, # and crucially it can no longer terminate the scalar early. run _extract_yaml_value "$f" clientId - [ "$output" = "$raw" ] + [ "$output" = "$raw" ] || return 1 } @test "the generated values file quotes clientId with the escaper, not raw interpolation" { f="$BATS_TEST_DIRNAME/../lib/install-client-helm.sh" grep -qE "^clientId: '\\\$TB_CLIENT_ID_ESCAPED'" "$f" - ! grep -qE '^clientId: "\$TB_CLIENT_ID"' "$f" + ! grep -qE '^clientId: "\$TB_CLIENT_ID"' "$f" || return 1 } # ── _ensure_helm_runnable (happy path) ───────────────────────────────────── @test "_ensure_helm_runnable: helm runs -> ok" { helm() { return 0; } run _ensure_helm_runnable - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } # ── install_client_helm: full flow with mocks ────────────────────────────── @@ -190,9 +232,9 @@ setup() { helm() { record "helm $*"; return 0; } verify_credentials() { printf valid; } run install_client_helm <<< $'myid\nmypw' - [ "$status" -eq 0 ] - [[ "$output" == *"Credentials verified"* ]] - [[ "$output" == *"tracebloc installed"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Credentials verified"* ]] || return 1 + [[ "$output" == *"tracebloc installed"* ]] || return 1 grep -q "clientId: 'myid'" "$HOST_DATA_DIR/values.yaml" grep -q "clientPassword: 'mypw'" "$HOST_DATA_DIR/values.yaml" # client-runtime#92: installer-provisioned k3d is a fixed single-host cluster, @@ -213,7 +255,7 @@ setup() { helm() { record "helm $*"; return 0; } verify_credentials() { printf valid; } run install_client_helm <<< $'myid\nmypw' - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 grep -q 'datasetPath: /tracebloc-data' "$HOST_DATA_DIR/values.yaml" grep -qE 'HOST_UID: "[0-9]+"' "$HOST_DATA_DIR/values.yaml" grep -qE 'HOST_GID: "[0-9]+"' "$HOST_DATA_DIR/values.yaml" @@ -228,9 +270,9 @@ setup() { helm() { record "helm $*"; return 0; } verify_credentials() { printf valid; } run install_client_helm <<< $'myid\nmypw' - [ "$status" -eq 0 ] - ! grep -q 'datasetPath:' "$HOST_DATA_DIR/values.yaml" - ! grep -q 'HOST_UID:' "$HOST_DATA_DIR/values.yaml" + [ "$status" -eq 0 ] || return 1 + ! grep -q 'datasetPath:' "$HOST_DATA_DIR/values.yaml" || return 1 + ! grep -q 'HOST_UID:' "$HOST_DATA_DIR/values.yaml" || return 1 } @test "install_client_helm: TRACEBLOC_CLIENT_* env -> non-interactive (no prompt), writes values.yaml + helm" { @@ -242,9 +284,9 @@ setup() { verify_credentials() { printf valid; } export TRACEBLOC_CLIENT_ID=envid TRACEBLOC_CLIENT_PASSWORD=envpw run install_client_helm </dev/null # no stdin: must not prompt - [ "$status" -eq 0 ] - [[ "$output" == *"Credentials verified"* ]] - [[ "$output" != *"Client ID:"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Credentials verified"* ]] || return 1 + [[ "$output" != *"Client ID:"* ]] || return 1 grep -q "clientId: 'envid'" "$HOST_DATA_DIR/values.yaml" grep -q "clientPassword: 'envpw'" "$HOST_DATA_DIR/values.yaml" mock_calls | grep -q "helm upgrade --install tracebloc" @@ -269,18 +311,18 @@ setup() { # heal a cli#125-era numeric clientId on the existing release. export TRACEBLOC_CLIENT_ADOPTED=1 TRACEBLOC_CLIENT_ID=0e9db54e-c9c0-4bf3-9ff2-1646da307019 run install_client_helm </dev/null # no stdin: must not prompt - [ "$status" -eq 0 ] - [[ "$output" != *"Client ID:"* ]] # no credential prompt - [[ "$output" != *"VERIFY_CALLED"* ]] # no verify - [[ "$output" == *"reconciling"* ]] - [[ "$output" == *"tracebloc installed"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"Client ID:"* ]] || return 1 # no credential prompt + [[ "$output" != *"VERIFY_CALLED"* ]] || return 1 # no verify + [[ "$output" == *"reconciling"* ]] || return 1 + [[ "$output" == *"tracebloc installed"* ]] || return 1 # Reconciled the LIVE release in place (name 'munich') AND healed clientId to the # adopted UUID, reusing the stored password — NOT a fresh --install, no duplicate. mock_calls | grep -q "helm upgrade munich" mock_calls | grep -q -- "--reset-then-reuse-values" mock_calls | grep -q -- "--set clientId=0e9db54e-c9c0-4bf3-9ff2-1646da307019" run mock_calls - [[ "$output" != *"helm upgrade --install"* ]] + [[ "$output" != *"helm upgrade --install"* ]] || return 1 } @test "install_client_helm: adopt with NO client id (rebuilt host / R7) reconciles WITHOUT a heal — no prompt, no bail" { @@ -299,15 +341,15 @@ setup() { # R7 orphan). Reconcile the LIVE release WITHOUT a heal — must not bail to a prompt. export TRACEBLOC_CLIENT_ADOPTED=1 run install_client_helm </dev/null - [ "$status" -eq 0 ] - [[ "$output" != *"Client ID:"* ]] # no prompt (no bail) - [[ "$output" != *"VERIFY_CALLED"* ]] # no verify - [[ "$output" == *"tracebloc installed"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"Client ID:"* ]] || return 1 # no prompt (no bail) + [[ "$output" != *"VERIFY_CALLED"* ]] || return 1 # no verify + [[ "$output" == *"tracebloc installed"* ]] || return 1 mock_calls | grep -q "helm upgrade munich" mock_calls | grep -q -- "--reset-then-reuse-values" run mock_calls - [[ "$output" != *"helm upgrade --install"* ]] - [[ "$output" != *"--set clientId"* ]] # nothing to heal with → no --set + [[ "$output" != *"helm upgrade --install"* ]] || return 1 + [[ "$output" != *"--set clientId"* ]] || return 1 # nothing to heal with → no --set } @test "install_client_helm: adopt on older Helm (no --reset-then-reuse-values) falls back to --reuse-values" { @@ -324,10 +366,10 @@ setup() { verify_credentials() { echo "VERIFY_CALLED"; printf invalid; } export TRACEBLOC_CLIENT_ADOPTED=1 run install_client_helm </dev/null - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q -- "--reuse-values" run mock_calls - [[ "$output" != *"--reset-then-reuse-values"* ]] + [[ "$output" != *"--reset-then-reuse-values"* ]] || return 1 } @test "install_client_helm: adopted but no live release -> falls back to the normal connect (fresh install)" { @@ -343,8 +385,8 @@ setup() { verify_credentials() { printf valid; } export TRACEBLOC_CLIENT_ADOPTED=1 run install_client_helm <<< $'typed-id\ntyped-pw' # must fall through to the prompt - [ "$status" -eq 0 ] - [[ "$output" == *"no live tracebloc release"* ]] # explained the fallback + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"no live tracebloc release"* ]] || return 1 # explained the fallback mock_calls | grep -q "helm upgrade --install tracebloc" } @@ -357,10 +399,10 @@ setup() { verify_credentials() { printf invalid; } export TRACEBLOC_CLIENT_ID=envid TRACEBLOC_CLIENT_PASSWORD=envpw run install_client_helm </dev/null - [ "$status" -ne 0 ] - [[ "$output" == *"rejected"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"rejected"* ]] || return 1 run mock_calls - [[ "$output" != *"helm upgrade"* ]] + [[ "$output" != *"helm upgrade"* ]] || return 1 } @test "install_client_helm: no credentials + no terminal -> actionable error, no helm (curl|bash)" { @@ -376,10 +418,10 @@ setup() { unset TRACEBLOC_CLIENT_ID TRACEBLOC_CLIENT_PASSWORD export TB_TTY="$BATS_TEST_TMPDIR/no-such-tty" run install_client_helm </dev/null - [ "$status" -ne 0 ] - [[ "$output" == *"TRACEBLOC_CLIENT_ID"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"TRACEBLOC_CLIENT_ID"* ]] || return 1 run mock_calls - [[ "$output" != *"helm upgrade"* ]] + [[ "$output" != *"helm upgrade"* ]] || return 1 } @test "install_client_helm: readable-but-dead-input tty (EOF) fails fast, doesn't abort mid-read (#326 review)" { @@ -397,10 +439,10 @@ setup() { unset TRACEBLOC_CLIENT_ID TRACEBLOC_CLIENT_PASSWORD TB_TTY=/dev/stdin run install_client_helm </dev/null # tty is readable, but yields EOF immediately - [ "$status" -ne 0 ] - [[ "$output" == *"TRACEBLOC_CLIENT_ID"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"TRACEBLOC_CLIENT_ID"* ]] || return 1 run mock_calls - [[ "$output" != *"helm upgrade"* ]] + [[ "$output" != *"helm upgrade"* ]] || return 1 } @test "install_client_helm: points kubeconfig at the client namespace (so the CLI needs no -n)" { @@ -412,7 +454,7 @@ setup() { kubectl() { record "kubectl $*"; return 0; } verify_credentials() { printf valid; } run install_client_helm <<< $'myid\nmypw' - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q "kubectl config set-context --current --namespace tracebloc" } @@ -427,9 +469,9 @@ setup() { if [ "$n" -ge 2 ]; then printf valid; else printf invalid; fi } run install_client_helm <<< $'badid\nbadpw\ngoodid\ngoodpw' - [ "$status" -eq 0 ] - [[ "$output" == *"rejected"* ]] - [[ "$output" == *"Credentials verified"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"rejected"* ]] || return 1 + [[ "$output" == *"Credentials verified"* ]] || return 1 grep -q "clientId: 'goodid'" "$HOST_DATA_DIR/values.yaml" } @@ -441,10 +483,10 @@ setup() { helm() { record "helm $*"; return 0; } verify_credentials() { printf inactive; } run install_client_helm <<< $'myid\nmypw' - [ "$status" -ne 0 ] - [[ "$output" == *"not active"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"not active"* ]] || return 1 run mock_calls - [[ "$output" != *"helm upgrade"* ]] + [[ "$output" != *"helm upgrade"* ]] || return 1 } @test "install_client_helm: unverified backend -> proceeds with install" { @@ -455,10 +497,10 @@ setup() { helm() { record "helm $*"; return 0; } verify_credentials() { printf unverified; } run install_client_helm <<< $'myid\nmypw' - [ "$status" -eq 0 ] - [[ "$output" == *"Couldn't reach tracebloc"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Couldn't reach tracebloc"* ]] || return 1 run mock_calls - [[ "$output" == *"helm upgrade --install"* ]] + [[ "$output" == *"helm upgrade --install"* ]] || return 1 } @test "install_client_helm: dev-mode uses caller values file, skips prompts" { @@ -470,9 +512,9 @@ setup() { helm() { record "helm $*"; return 0; } TRACEBLOC_VALUES_FILE="$vf"; TB_NAMESPACE=devns run install_client_helm - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"helm upgrade --install devns"* ]] + [[ "$output" == *"helm upgrade --install devns"* ]] || return 1 } @test "install_client_helm: reuses previous clientId/password defaults" { @@ -485,7 +527,7 @@ setup() { verify_credentials() { printf valid; } # use-previous=y, ClientID=Enter(keep previd), password=Enter(keep prevpw) run install_client_helm <<< $'y\n\n\n' - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 grep -q "clientId: 'previd'" "$HOST_DATA_DIR/values.yaml" grep -q "clientPassword: 'prevpw'" "$HOST_DATA_DIR/values.yaml" } @@ -498,10 +540,10 @@ setup() { helm() { record "helm $*"; return 0; } verify_credentials() { printf invalid; } run install_client_helm <<< $'i1\np1\ni2\np2\ni3\np3\ni4\np4\ni5\np5' - [ "$status" -ne 0 ] - [[ "$output" == *"Too many failed attempts"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"Too many failed attempts"* ]] || return 1 run mock_calls - [[ "$output" != *"helm upgrade"* ]] + [[ "$output" != *"helm upgrade"* ]] || return 1 } # ── One-client-per-machine guard ──────────────────────────────────────────── @@ -522,11 +564,11 @@ setup() { } verify_credentials() { printf valid; } run install_client_helm <<< $'newclient\nmypw' - [ "$status" -ne 0 ] - [[ "$output" == *"already runs the tracebloc client 'otherclient'"* ]] - [[ "$output" == *"one client per machine"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"already runs the tracebloc client 'otherclient'"* ]] || return 1 + [[ "$output" == *"one client per machine"* ]] || return 1 run mock_calls - [[ "$output" != *"helm upgrade"* ]] + [[ "$output" != *"helm upgrade"* ]] || return 1 } @test "install_client_helm: helm list failure -> fails CLOSED (refuses, no upgrade)" { @@ -543,10 +585,10 @@ setup() { } verify_credentials() { printf valid; } run install_client_helm <<< $'newclient\nmypw' - [ "$status" -ne 0 ] - [[ "$output" == *"Couldn't determine which tracebloc client"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"Couldn't determine which tracebloc client"* ]] || return 1 run mock_calls - [[ "$output" != *"helm upgrade"* ]] + [[ "$output" != *"helm upgrade"* ]] || return 1 } @test "install_client_helm: unreadable client values -> fails CLOSED (refuses, no upgrade)" { @@ -568,10 +610,10 @@ setup() { } verify_credentials() { printf valid; } run install_client_helm <<< $'newclient\nmypw' - [ "$status" -ne 0 ] - [[ "$output" == *"Couldn't determine which tracebloc client"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"Couldn't determine which tracebloc client"* ]] || return 1 run mock_calls - [[ "$output" != *"helm upgrade"* ]] + [[ "$output" != *"helm upgrade"* ]] || return 1 } @test "install_client_helm: same client re-run is allowed (upgrade in place)" { @@ -590,9 +632,9 @@ setup() { } verify_credentials() { printf valid; } run install_client_helm <<< $'sameid\nmypw' - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"helm upgrade --install tracebloc"* ]] + [[ "$output" == *"helm upgrade --install tracebloc"* ]] || return 1 } @test "install_client_helm: same client in a different namespace -> upgrades in place, no duplicate" { @@ -615,10 +657,10 @@ setup() { } verify_credentials() { printf valid; } run install_client_helm <<< $'sameid\nmypw' - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"helm upgrade --install tracebloc"* ]] # reused existing namespace - [[ "$output" != *"acme-corp"* ]] # no second release forked + [[ "$output" == *"helm upgrade --install tracebloc"* ]] || return 1 # reused existing namespace + [[ "$output" != *"acme-corp"* ]] || return 1 # no second release forked } @test "install_client_helm: different-namespace reconcile works WITHOUT jq (Bugbot #284)" { @@ -644,61 +686,61 @@ setup() { } verify_credentials() { printf valid; } run install_client_helm <<< $'sameid\nmypw' - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"helm upgrade --install tracebloc"* ]] # reused existing namespace - [[ "$output" != *"acme-corp"* ]] # no second release forked + [[ "$output" == *"helm upgrade --install tracebloc"* ]] || return 1 # reused existing namespace + [[ "$output" != *"acme-corp"* ]] || return 1 # no second release forked } # ── _chart_proxy_env_yaml (#242: host proxy -> split chart keys) ───────────── @test "_chart_proxy_env_yaml: no proxy on host -> empty" { run _chart_proxy_env_yaml - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_chart_proxy_env_yaml: host:port -> HTTP_PROXY_HOST + HTTP_PROXY_PORT" { HTTP_PROXY="http://proxy.charite.de:8080" run _chart_proxy_env_yaml - [[ "$output" == *'HTTP_PROXY_HOST: "proxy.charite.de"'* ]] - [[ "$output" == *'HTTP_PROXY_PORT: "8080"'* ]] - [[ "$output" != *"HTTP_PROXY_USERNAME"* ]] + [[ "$output" == *'HTTP_PROXY_HOST: "proxy.charite.de"'* ]] || return 1 + [[ "$output" == *'HTTP_PROXY_PORT: "8080"'* ]] || return 1 + [[ "$output" != *"HTTP_PROXY_USERNAME"* ]] || return 1 } @test "_chart_proxy_env_yaml: prefers HTTPS_PROXY when HTTP_PROXY unset" { HTTPS_PROXY="http://proxy.example.com:3128" run _chart_proxy_env_yaml - [[ "$output" == *'HTTP_PROXY_HOST: "proxy.example.com"'* ]] - [[ "$output" == *'HTTP_PROXY_PORT: "3128"'* ]] + [[ "$output" == *'HTTP_PROXY_HOST: "proxy.example.com"'* ]] || return 1 + [[ "$output" == *'HTTP_PROXY_PORT: "3128"'* ]] || return 1 } @test "_chart_proxy_env_yaml: authenticated proxy -> username/password split" { HTTPS_PROXY="http://user:s3cr3t@proxy.example.com:3128" run _chart_proxy_env_yaml - [[ "$output" == *'HTTP_PROXY_HOST: "proxy.example.com"'* ]] - [[ "$output" == *'HTTP_PROXY_PORT: "3128"'* ]] - [[ "$output" == *'HTTP_PROXY_USERNAME: "user"'* ]] - [[ "$output" == *'HTTP_PROXY_PASSWORD: "s3cr3t"'* ]] + [[ "$output" == *'HTTP_PROXY_HOST: "proxy.example.com"'* ]] || return 1 + [[ "$output" == *'HTTP_PROXY_PORT: "3128"'* ]] || return 1 + [[ "$output" == *'HTTP_PROXY_USERNAME: "user"'* ]] || return 1 + [[ "$output" == *'HTTP_PROXY_PASSWORD: "s3cr3t"'* ]] || return 1 } @test "_chart_proxy_env_yaml: '@' in password tolerated (split on last @)" { http_proxy="http://user:p@ss@proxy.example.com:8080" run _chart_proxy_env_yaml - [[ "$output" == *'HTTP_PROXY_HOST: "proxy.example.com"'* ]] - [[ "$output" == *'HTTP_PROXY_PASSWORD: "p@ss"'* ]] + [[ "$output" == *'HTTP_PROXY_HOST: "proxy.example.com"'* ]] || return 1 + [[ "$output" == *'HTTP_PROXY_PASSWORD: "p@ss"'* ]] || return 1 } @test "_chart_proxy_env_yaml: no port -> HTTP_PROXY_HOST only, no PORT line" { HTTP_PROXY="http://proxy.example.com" run _chart_proxy_env_yaml - [[ "$output" == *'HTTP_PROXY_HOST: "proxy.example.com"'* ]] - [[ "$output" != *"HTTP_PROXY_PORT"* ]] + [[ "$output" == *'HTTP_PROXY_HOST: "proxy.example.com"'* ]] || return 1 + [[ "$output" != *"HTTP_PROXY_PORT"* ]] || return 1 } @test "_chart_proxy_env_yaml: passes host NO_PROXY through (proxyEnv unions cluster ranges)" { HTTP_PROXY="http://proxy:8080"; NO_PROXY="myinternal.example,.corp" run _chart_proxy_env_yaml - [[ "$output" == *'NO_PROXY: "myinternal.example,.corp"'* ]] + [[ "$output" == *'NO_PROXY: "myinternal.example,.corp"'* ]] || return 1 } # ── install_client_helm: host proxy propagated into the generated values ──── @@ -711,7 +753,7 @@ setup() { verify_credentials() { printf valid; } HTTP_PROXY="http://proxy.charite.de:8080"; NO_PROXY=".charite.de" run install_client_helm <<< $'myid\nmypw' - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 # NB: the "Corporate proxy detected" notice goes through log(), which the test # harness routes to /dev/null — so assert on the generated file, not $output. grep -q 'HTTP_PROXY_HOST: "proxy.charite.de"' "$HOST_DATA_DIR/values.yaml" @@ -730,8 +772,8 @@ setup() { helm() { record "helm $*"; return 0; } verify_credentials() { printf valid; } run install_client_helm <<< $'myid\nmypw' - [ "$status" -eq 0 ] - ! grep -q 'HTTP_PROXY_HOST' "$HOST_DATA_DIR/values.yaml" + [ "$status" -eq 0 ] || return 1 + ! grep -q 'HTTP_PROXY_HOST' "$HOST_DATA_DIR/values.yaml" || return 1 } @test "install_client_helm: TRACEBLOC_TRAINING_RESOURCES overrides the training size in generated values" { @@ -743,7 +785,7 @@ setup() { verify_credentials() { printf valid; } export TRACEBLOC_TRAINING_RESOURCES="cpu=4,memory=16Gi" run install_client_helm <<< $'myid\nmypw' - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 grep -q 'RESOURCE_LIMITS: "cpu=4,memory=16Gi"' "$HOST_DATA_DIR/values.yaml" grep -q 'RESOURCE_REQUESTS: "cpu=4,memory=16Gi"' "$HOST_DATA_DIR/values.yaml" } @@ -758,7 +800,7 @@ setup() { verify_credentials() { printf valid; } unset TRACEBLOC_TRAINING_RESOURCES run install_client_helm <<< $'myid\nmypw' - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 grep -q 'RESOURCE_LIMITS: "cpu=2,memory=8Gi"' "$HOST_DATA_DIR/values.yaml" grep -q 'RESOURCE_REQUESTS: "cpu=2,memory=8Gi"' "$HOST_DATA_DIR/values.yaml" } @@ -769,9 +811,9 @@ setup() { helm() { record "helm $*"; return 1; } kubectl() { record "kubectl $*"; return 1; } run _training_resources - [ "$output" = "cpu=4,memory=16Gi" ] + [ "$output" = "cpu=4,memory=16Gi" ] || return 1 run mock_calls - [ -z "$output" ] + [ -z "$output" ] || return 1 unset TRACEBLOC_TRAINING_RESOURCES } @@ -786,13 +828,13 @@ setup() { case "$*" in *"get namespace"*--request-timeout=*) return 0 ;; *) return 1 ;; esac } run _training_resources - [ "$output" = "cpu=4,memory=12Gi" ] + [ "$output" = "cpu=4,memory=12Gi" ] || return 1 run mock_calls - [[ "$output" != *"get nodes"* ]] # machine sizing never consulted + [[ "$output" != *"get nodes"* ]] || return 1 # machine sizing never consulted # and the QUOTED form (our own values file style) parses identically helm() { printf 'env:\n RESOURCE_LIMITS: "cpu=4,memory=12Gi"\n'; } run _training_resources - [ "$output" = "cpu=4,memory=12Gi" ] + [ "$output" = "cpu=4,memory=12Gi" ] || return 1 } @test "training size: the historic static default is NOT carried — re-install gets sized" { @@ -810,7 +852,7 @@ setup() { esac } run _training_resources - [ "$output" = "cpu=11,memory=3Gi" ] + [ "$output" = "cpu=11,memory=3Gi" ] || return 1 } @test "training size: fresh install sized to the largest node minus overhead" { @@ -829,7 +871,7 @@ setup() { esac } run _training_resources - [ "$output" = "cpu=11,memory=3Gi" ] # 12−1 CPU; 6.76−3 GiB floored + [ "$output" = "cpu=11,memory=3Gi" ] || return 1 # 12−1 CPU; 6.76−3 GiB floored } @test "training size: below-floor machine falls back to the static default" { @@ -839,7 +881,7 @@ setup() { has() { return 0; } kubectl() { printf '2 4Gi\n'; } # 4−3 GiB = 1 GiB < the 2 GiB floor run _training_resources - [ "$output" = "cpu=2,memory=8Gi" ] + [ "$output" = "cpu=2,memory=8Gi" ] || return 1 } @test "training size: kubectl absent falls back to the static default" { @@ -849,42 +891,42 @@ setup() { kubectl() { return 1; } # the probe also fails -> carry skipped hermetically has() { case "$1" in kubectl) return 1 ;; *) return 0 ;; esac; } run _training_resources - [ "$output" = "cpu=2,memory=8Gi" ] + [ "$output" = "cpu=2,memory=8Gi" ] || return 1 } # ── _download_services_progress (step-e count bar; must never hang/fail) ───── @test "_download_services_progress: TB_NO_SERVICE_PROGRESS set -> immediate no-op" { export TB_NO_SERVICE_PROGRESS=1 run _download_services_progress tracebloc - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_download_services_progress: kubectl absent -> silent skip (never fatal)" { unset TB_NO_SERVICE_PROGRESS has() { return 1; } # kubectl not present run _download_services_progress tracebloc - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_download_services_progress: empty namespace -> no-op" { unset TB_NO_SERVICE_PROGRESS has() { return 0; } run _download_services_progress "" - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } # ── bounded helm calls (#426) ──────────────────────────────────────────────── @test "both helm invocations run under a deadline, none unbounded (#426)" { local f="$BATS_TEST_DIRNAME/../lib/install-client-helm.sh" - ! grep -qE 'spin_cmd "(Reconciling the existing client|Installing the tracebloc client)' "$f" + ! grep -qE 'spin_cmd "(Reconciling the existing client|Installing the tracebloc client)' "$f" || return 1 # rc is captured (|| _helm_rc=$?) rather than tested via `if !` so the 124 # timeout case can print its unwedge guidance before error (Bugbot #442). # Match the invocation lines only (comments also mention the helper). - [ "$(grep -c 'spin_cmd_bounded "\$(( _helm_timeout_min \* 60 ))"' "$f")" -eq 2 ] - [ "$(grep -c '|| _helm_rc=\$?' "$f")" -eq 2 ] + [ "$(grep -c 'spin_cmd_bounded "\$(( _helm_timeout_min \* 60 ))"' "$f")" -eq 2 ] || return 1 + [ "$(grep -c '|| _helm_rc=\$?' "$f")" -eq 2 ] || return 1 } @test "helm timeout (124) names the pending-release unwedge commands (Bugbot #442)" { @@ -892,7 +934,7 @@ setup() { # fails with "another operation is in progress" — both call sites must # point at the unwedge command. Match the hint lines, not the comments. local f="$BATS_TEST_DIRNAME/../lib/install-client-helm.sh" - [ "$(grep -c "reports 'another operation is in progress'" "$f")" -eq 2 ] + [ "$(grep -c "reports 'another operation is in progress'" "$f")" -eq 2 ] || return 1 grep -q 'helm -n \$TB_NAMESPACE uninstall \$TB_NAMESPACE' "$f" # The adopt path tracks release and namespace separately — the rollback hint # must name the RELEASE (\$_rel), not the namespace (Bugbot #442 r5). @@ -902,19 +944,19 @@ setup() { # ── #425: honest pull status (never sell a permanent failure as "downloading") ── @test "_progress_end_message: complete -> done" { run _progress_end_message 3 3 3 "" - [ "$output" = "done" ] + [ "$output" = "done" ] || return 1 } @test "_progress_end_message: a pull failure -> failed, even with partial progress" { run _progress_end_message 1 3 1 "pod/foo ImagePullBackOff" - [ "$output" = "failed" ] + [ "$output" = "failed" ] || return 1 } @test "_progress_end_message: progress, no failure -> downloading" { run _progress_end_message 2 3 2 "" - [ "$output" = "downloading" ] + [ "$output" = "downloading" ] || return 1 } @test "_progress_end_message: no progress, no failure -> stalled (not 'downloading')" { run _progress_end_message 0 3 0 "" - [ "$output" = "stalled" ] + [ "$output" = "stalled" ] || return 1 } @test "_pull_failure_detail: ImagePullBackOff -> prints pod + event, returns 0" { has() { [ "$1" = kubectl ]; } @@ -925,16 +967,16 @@ setup() { esac } run _pull_failure_detail tracebloc - [ "$status" -eq 0 ] - [[ "$output" == *"ImagePullBackOff"* ]] - [[ "$output" == *"x509"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"ImagePullBackOff"* ]] || return 1 + [[ "$output" == *"x509"* ]] || return 1 } @test "_pull_failure_detail: healthy pods -> returns 1, prints nothing" { has() { [ "$1" = kubectl ]; } kubectl() { case "$*" in *"get pods"*) printf '%s\n' "foo-abc 1/1 Running 0 1m" ;; esac; } run _pull_failure_detail tracebloc - [ "$status" -ne 0 ] - [ -z "$output" ] + [ "$status" -ne 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_pull_failure_detail: unrelated x509 events don't displace the real pull reason (#425 Bugbot)" { has() { [ "$1" = kubectl ]; } @@ -949,9 +991,9 @@ setup() { esac } run _pull_failure_detail tracebloc - [ "$status" -eq 0 ] - [[ "$output" == *"403 Forbidden"* ]] # the real pull reason survives the tail - [[ "$output" != *"x509"* ]] # unrelated x509 events are scoped out + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"403 Forbidden"* ]] || return 1 # the real pull reason survives the tail + [[ "$output" != *"x509"* ]] || return 1 # unrelated x509 events are scoped out } @test "_download_services_progress routes the end copy through the honest selector (#425)" { # The end-of-progress copy is chosen by the pure _progress_end_message selector, @@ -961,5 +1003,86 @@ setup() { grep -q 'outcome="\$(_progress_end_message' "$f" grep -qE '^\s*failed\)' "$f" grep -q 'look stuck pulling' "$f" - [ "$(grep -c 'Services are still downloading' "$f")" -eq 1 ] + [ "$(grep -c 'Services are still downloading' "$f")" -eq 1 ] || return 1 +} + +# ── _image_mirror_yaml (private registry mirror / air-gap, #585) ──────────── +# Emits the top-level chart values that re-home every image onto a private +# mirror. Empty unless TRACEBLOC_IMAGE_REGISTRY / TRACEBLOC_REGISTRY_* are set. +@test "_image_mirror_yaml: no knobs -> empty (default install unchanged)" { + unset TRACEBLOC_IMAGE_REGISTRY TRACEBLOC_REGISTRY_USERNAME TRACEBLOC_REGISTRY_PASSWORD TRACEBLOC_REGISTRY_SERVER TRACEBLOC_REGISTRY_EMAIL + run _image_mirror_yaml + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 +} + +@test "_image_mirror_yaml: mirror only -> global.imageRegistry, no dockerRegistry" { + unset TRACEBLOC_REGISTRY_USERNAME TRACEBLOC_REGISTRY_PASSWORD TRACEBLOC_REGISTRY_SERVER TRACEBLOC_REGISTRY_EMAIL + export TRACEBLOC_IMAGE_REGISTRY=mirror.corp.example + run _image_mirror_yaml + [ "$status" -eq 0 ] || return 1 + echo "$output" | grep -q "imageRegistry: 'mirror.corp.example'" || return 1 + echo "$output" | grep -q "^global:" || return 1 + ! echo "$output" | grep -q "dockerRegistry:" || return 1 +} + +@test "_image_mirror_yaml: strips a pasted scheme from the mirror host" { + unset TRACEBLOC_REGISTRY_USERNAME TRACEBLOC_REGISTRY_PASSWORD TRACEBLOC_REGISTRY_SERVER TRACEBLOC_REGISTRY_EMAIL + export TRACEBLOC_IMAGE_REGISTRY=https://mirror.corp.example + run _image_mirror_yaml + echo "$output" | grep -q "imageRegistry: 'mirror.corp.example'" || return 1 + ! echo "$output" | grep -q "https://mirror.corp.example'" || return 1 +} + +@test "_image_mirror_yaml: mirror + creds -> dockerRegistry with derived https server" { + export TRACEBLOC_IMAGE_REGISTRY=mirror.corp.example + export TRACEBLOC_REGISTRY_USERNAME=svc + export TRACEBLOC_REGISTRY_PASSWORD=secret + unset TRACEBLOC_REGISTRY_SERVER TRACEBLOC_REGISTRY_EMAIL + run _image_mirror_yaml + [ "$status" -eq 0 ] || return 1 + echo "$output" | grep -q "^dockerRegistry:" || return 1 + echo "$output" | grep -q "create: true" || return 1 + echo "$output" | grep -q "server: 'https://mirror.corp.example'" || return 1 + echo "$output" | grep -q "username: 'svc'" || return 1 + echo "$output" | grep -q "password: 'secret'" || return 1 +} + +@test "_image_mirror_yaml: an explicit TRACEBLOC_REGISTRY_SERVER wins over the derived URI" { + export TRACEBLOC_IMAGE_REGISTRY=mirror.corp.example + export TRACEBLOC_REGISTRY_USERNAME=svc + export TRACEBLOC_REGISTRY_PASSWORD=secret + export TRACEBLOC_REGISTRY_SERVER=https://auth.corp.example/v2/ + unset TRACEBLOC_REGISTRY_EMAIL + run _image_mirror_yaml + echo "$output" | grep -q "server: 'https://auth.corp.example/v2/'" || return 1 +} + +@test "_image_mirror_yaml: doubles single quotes in the password (YAML-safe)" { + export TRACEBLOC_IMAGE_REGISTRY=mirror.corp.example + export TRACEBLOC_REGISTRY_USERNAME=svc + export TRACEBLOC_REGISTRY_PASSWORD="s3cr3t'q" + unset TRACEBLOC_REGISTRY_SERVER TRACEBLOC_REGISTRY_EMAIL + run _image_mirror_yaml + echo "$output" | grep -q "password: 's3cr3t''q'" || return 1 +} + +@test "_image_mirror_yaml: creds without a mirror -> dockerRegistry only, no global" { + unset TRACEBLOC_IMAGE_REGISTRY TRACEBLOC_REGISTRY_SERVER TRACEBLOC_REGISTRY_EMAIL + export TRACEBLOC_REGISTRY_USERNAME=svc + export TRACEBLOC_REGISTRY_PASSWORD=secret + run _image_mirror_yaml + [ "$status" -eq 0 ] || return 1 + ! echo "$output" | grep -q "^global:" || return 1 + echo "$output" | grep -q "^dockerRegistry:" || return 1 +} + +@test "_image_mirror_yaml: creds without a mirror still emit server (Docker Hub) - schema requires it (Bugbot)" { + # The chart schema requires dockerRegistry.server whenever create is true, so a + # creds-only config must NOT omit it (else helm install fails with a schema error). + unset TRACEBLOC_IMAGE_REGISTRY TRACEBLOC_REGISTRY_SERVER TRACEBLOC_REGISTRY_EMAIL + export TRACEBLOC_REGISTRY_USERNAME=svc + export TRACEBLOC_REGISTRY_PASSWORD=secret + run _image_mirror_yaml + echo "$output" | grep -q "server: 'https://index.docker.io/v1/'" || return 1 } diff --git a/scripts/tests/install-k8s.Tests.ps1 b/scripts/tests/install-k8s.Tests.ps1 index e26da2f3..3b14263a 100644 --- a/scripts/tests/install-k8s.Tests.ps1 +++ b/scripts/tests/install-k8s.Tests.ps1 @@ -553,11 +553,21 @@ Describe "Invoke-TrackedInstall (#500 capture installer output)" { $script:ISRC | Should -Match 'function Invoke-TrackedInstall[\s\S]*Get-Content \$errF[\s\S]*Get-Content \$outF' $script:ISRC | Should -Match 'function Invoke-TrackedInstall[\s\S]*if \(\$log\) \{ Log' } - It "all four winget/installer installs go through the capturing wrapper" { - foreach ($tag in 'docker-winget','docker-direct','k3d-winget','helm-winget') { + It "every winget/installer install goes through the capturing wrapper" { + # k3d-winget was removed in #607: k3d has no winget manifest, so that branch + # only ever logged "No package found" before the (now resilient) direct + # download ran. The remaining installs must still go through the wrapper. + foreach ($tag in 'docker-winget','docker-direct','helm-winget') { $script:ISRC | Should -Match "Invoke-TrackedInstall[\s\S]{0,300}-Tag `"$tag`"" } } + It "no longer runs a k3d winget install (#607: k3d has no winget manifest)" { + # The comment in install-k8s.ps1 still names Rancher.k3d to explain the removal, + # so assert on the tracked-install TAG (unique to the actual invocation), not + # on any mention of the id. + $script:ISRC | Should -Not -Match '-Tag "k3d-winget"' + $script:ISRC | Should -Not -Match 'install","-e","--id","Rancher\.k3d"' + } It "returns ok with the exit code when the process succeeds" { Mock Start-Process { [pscustomobject]@{ ExitCode = 0; HasExited = $true } } Mock Wait-ProcessWithDeadline { $true } @@ -2992,3 +3002,420 @@ Describe "k3s version pin: create + reuse drift (#547 source guards)" { $script:PSRC | Should -Match 'pinned \+ validated' } } + +Describe "Log hygiene: no Start-Transcript, helpers feed the curated log (#576)" { + BeforeAll { $script:PSRC576 = Get-Content "$PSScriptRoot/../install-k8s.ps1" -Raw } + + It "does not use Start-Transcript / Stop-Transcript (the transcript header is the PII leak)" { + $script:PSRC576 | Should -Not -Match 'Start-Transcript -Path' + $script:PSRC576 | Should -Not -Match 'Stop-Transcript' + } + + It "routes the message helpers through Log() so the log stays useful without a transcript" { + $log = Join-Path $TestDrive "install-576.log" + $script:LOG_FILE = $log + try { + Ok "route-check-ok" + Warn "route-check-warn" + Info "route-check-info" + Step 1 6 "route-check-step" + Hint "route-check-hint" + $content = Get-Content $log -Raw + $content | Should -Match 'route-check-ok' + $content | Should -Match 'route-check-warn' + $content | Should -Match 'route-check-info' + $content | Should -Match 'route-check-step' + $content | Should -Match 'route-check-hint' + # curated by construction: the PowerShell transcript identity header (the PII) + # can never appear, because Log() only ever writes what we pass it. + $content | Should -Not -Match 'Username:' + $content | Should -Not -Match 'Machine:' + $content | Should -Not -Match 'PowerShell transcript' + } finally { $script:LOG_FILE = $null } + } +} + +Describe "Preflight + summary failures reach the curated log (#576 Bugbot)" { + It "Write-PfFail routes preflight hard-fail lines to the log (not screen-only)" { + $log = Join-Path $TestDrive "install-pf.log" + $script:LOG_FILE = $log + try { + Write-PfFail "Disk: only 5 GB free (need 40)" + (Get-Content $log -Raw) | Should -Match 'PREFLIGHT FAIL: Disk: only 5 GB free' + } finally { $script:LOG_FILE = $null } + } +} + +Describe "Print-Summary logs the classified outcome for every state (#576 Bugbot)" { + BeforeEach { $script:TB_NAMESPACE = "ns"; $GPU_VENDOR = "none"; $NVIDIA_DRIVER_OK = $false } + It "records the final client state in the log (covers the default/image_pull/crash branch)" { + $log = Join-Path $TestDrive "install-sum.log" + $script:LOG_FILE = $log + $script:ClientState = "image_pull" + try { + Print-Summary 6>&1 | Out-Null + (Get-Content $log -Raw) | Should -Match 'Final client state: image_pull' + } finally { $script:LOG_FILE = $null } + } +} + +Describe "Network profile: plain-language proxy / TLS-inspection read (#582)" { + BeforeEach { + $env:HTTP_PROXY = $null; $env:HTTPS_PROXY = $null + $env:http_proxy = $null; $env:https_proxy = $null + $env:TRACEBLOC_CA_BUNDLE = $null; $env:CURL_CA_BUNDLE = $null + } + AfterAll { + $env:HTTP_PROXY = $null; $env:HTTPS_PROXY = $null + $env:http_proxy = $null; $env:https_proxy = $null + $env:TRACEBLOC_CA_BUNDLE = $null; $env:CURL_CA_BUNDLE = $null + } + + It "Get-EnvProxyHostPort strips scheme + user:pass credentials (PII)" { + Get-EnvProxyHostPort "http://user:pass@proxy.corp:8080/x" | Should -Be "proxy.corp:8080" + } + + It "Get-EnvProxy: HTTPS wins and credentials are stripped" { + $env:HTTP_PROXY = "http://h:1"; $env:HTTPS_PROXY = "http://user:secret@sproxy.corp:3128" + $p = Get-EnvProxy + $p | Should -Be "sproxy.corp:3128" + $p | Should -Not -Match "secret" + } + + It "Test-IssuerIsPublic: public CA true, corporate re-signer false" { + Test-IssuerIsPublic "CN=DigiCert Global G2, O=DigiCert Inc" | Should -BeTrue + Test-IssuerIsPublic "CN=Acme Corp Proxy CA, O=Acme Corp" | Should -BeFalse + } + + It "Get-EnvCaBundle: readable CA file returned, null when unset" { + $ca = Join-Path $TestDrive "ca.pem"; "x" | Set-Content -LiteralPath $ca + $env:TRACEBLOC_CA_BUNDLE = $ca + Get-EnvCaBundle | Should -Be $ca + $env:TRACEBLOC_CA_BUNDLE = $null + Get-EnvCaBundle | Should -BeNullOrEmpty + } + + It "Show-NetworkProfile: direct connection is silent" { + Mock Get-TlsInspectionState { "no" } + $out = Show-NetworkProfile 6>&1 | Out-String + $out.Trim() | Should -BeNullOrEmpty + } + + It "Show-NetworkProfile: proxy + inspection -> one PII-free line" { + $env:HTTPS_PROXY = "http://u:p@proxy.corp:8080" + Mock Get-TlsInspectionState { "yes" } + $out = Show-NetworkProfile 6>&1 | Out-String + $out | Should -Match "corporate proxy detected \(proxy\.corp:8080\)" + $out | Should -Match "TLS inspection detected" + $out | Should -Not -Match "u:p" + } + + It "Show-NetworkProfile: a configured CA bundle is announced" { + $ca = Join-Path $TestDrive "ca2.pem"; "x" | Set-Content -LiteralPath $ca + $env:HTTPS_PROXY = "http://proxy.corp:8080"; $env:TRACEBLOC_CA_BUNDLE = $ca + Mock Get-TlsInspectionState { "yes" } + $out = Show-NetworkProfile 6>&1 | Out-String + $out | Should -Match "your company's certificate is configured" + } + + It "Get-EnvProxyRaw: preserves credentials (probe connection only, never displayed)" { + $env:HTTPS_PROXY = "http://user:secret@px.corp:3128" + Get-EnvProxyRaw | Should -Be "http://user:secret@px.corp:3128" + Get-EnvProxy | Should -Be "px.corp:3128" # display path still strips + } + + It "the TLS probe connects with proxy credentials, but display strips them (Bugbot)" { + $src = Get-Content "$PSScriptRoot/../install-k8s.ps1" -Raw + $probeFn = (($src -split "function Get-TlsInspectionState")[1] -split "`nfunction ")[0] + $probeFn | Should -Match 'Get-EnvProxyRaw' # connect uses the raw (credentialed) proxy + $probeFn | Should -Match 'NetworkCredential' + $showFn = (($src -split "function Show-NetworkProfile")[1] -split "`nfunction ")[0] + $showFn | Should -Match 'Get-EnvProxy\b' # display uses the stripped proxy + $showFn | Should -Not -Match 'Get-EnvProxyRaw' + } +} + +Describe "Top-level error boundary: crashes become a clean message, never a stack (#577)" { + BeforeAll { $script:PSRC577 = Get-Content "$PSScriptRoot/../install-k8s.ps1" -Raw } + + It "the main run is wrapped in a top-level try/catch that calls Show-FatalError" { + $script:PSRC577 | Should -Match 'Show-FatalError \$_' + $script:PSRC577 | Should -Match 'if \(-not \$env:TB_PESTER\)[\s\S]{0,600}?try \{' + } + + It "Show-FatalError renders a clean 'stopped' message with reason + re-run hint, no stack" { + $er = $null; try { throw "widget exploded" } catch { $er = $_ } + $out = Show-FatalError $er 6>&1 | Out-String + $out | Should -Match 'Installation stopped' + $out | Should -Match 'widget exploded' + $out | Should -Match 're-run' + $out | Should -Not -Match 'char:\d' + $out | Should -Not -Match 'ScriptStackTrace' + } + + It "Show-FatalError logs the reason but never the stack" { + $log = Join-Path $TestDrive "fatal-577.log"; $script:LOG_FILE = $log + try { + $er = $null; try { throw "disk on fire" } catch { $er = $_ } + Show-FatalError $er 6>&1 | Out-Null + $c = Get-Content $log -Raw + $c | Should -Match 'FATAL: disk on fire' + $c | Should -Not -Match 'ScriptStackTrace' + } finally { $script:LOG_FILE = $null } + } +} + +Describe "Graceful failure: guaranteed finally + trap, guarded closer (#577)" { + BeforeAll { $script:PSRC577b = Get-Content "$PSScriptRoot/../install-k8s.ps1" -Raw } + + It "wraps the main run in try/catch/finally with a last-resort trap" { + $script:PSRC577b | Should -Match 'trap \{ Show-FatalError \$_; exit 1 \}' + $script:PSRC577b | Should -Match '\} finally \{' + $script:PSRC577b | Should -Match 'if \(-not \$script:OutcomeReported\) \{ Show-Interrupted \}' + } + + It "marks the outcome reported on every terminal path (guards against a spurious interrupted line)" { + ([regex]::Matches($script:PSRC577b, '\$script:OutcomeReported = \$true')).Count | Should -BeGreaterOrEqual 5 + } + + It "the -Diagnose path marks the outcome reported only after the bundle completes (Bugbot)" { + # Setting the flag before the long collection would skip Show-Interrupted on an + # interrupt mid-diagnose - the silent death this boundary exists to prevent. + $script:PSRC577b | Should -Match 'Invoke-DiagnoseBundle; \$script:OutcomeReported = \$true' + } + + It "the reboot-pending stop marks the outcome reported before exiting (Bugbot)" { + # A reboot-pending exit is an intentional, reported stop (guidance is printed), + # not an interruption; without the flag the finally appends a contradictory + # Show-Interrupted line. The flag must be set before the block's exit 2. + $script:PSRC577b | Should -Match '(?s)if \(\$rebootNeeded\) \{.*?\$script:OutcomeReported = \$true.*?exit 2' + } + + It "Show-Interrupted renders a clean interrupted line (log + re-run, no stack)" { + $log = Join-Path $TestDrive "int-577.log"; $script:LOG_FILE = $log + try { + $out = Show-Interrupted 6>&1 | Out-String + $out | Should -Match 'interrupted' + $out | Should -Match 're-run' + $out | Should -Not -Match 'ScriptStackTrace' + (Get-Content $log -Raw) | Should -Match 'interrupted' + } finally { $script:LOG_FILE = $null } + } +} + +Describe "GPU device-plugin failure is recoverable, not fatal (#577)" { + BeforeAll { $script:PSRCGPU = Get-Content "$PSScriptRoot/../install-k8s.ps1" -Raw } + It "warns + continues in CPU mode instead of a fatal Err on a plugin failure" { + $script:PSRCGPU | Should -Not -Match 'Err "Failed to enable GPU acceleration' + $script:PSRCGPU | Should -Match 'continuing in CPU mode' + $script:PSRCGPU | Should -Match 'GPU device-plugin setup error' + } + It "gates the success message on the kubectl exit code — never a false 'enabled' (Bugbot)" { + # Native kubectl doesn't throw on a non-zero exit, so the GPU apply/rollout must be + # $LASTEXITCODE-checked; otherwise a failed apply prints "GPU acceleration enabled." + $gpuFn = ($script:PSRCGPU -split "function Install-GpuDevicePlugin")[1] + # No fire-and-forget discard of the apply into $null (the false-success pattern). + $gpuFn | Should -Not -Match '\$null = \(kubectl apply' + # The success message is guarded, and the apply output is written to the log. + $gpuFn | Should -Match '\$LASTEXITCODE' + $gpuFn | Should -Match 'Log "GPU plugin apply' + } + It "bounds the GPU apply with --request-timeout so a wedged API can't hang it (Bugbot)" { + # Parity with bash gpu-plugins.sh: the apply output is captured to the log, so + # without a request timeout a wedged API server would hang instead of falling + # through to the CPU-mode warn. + $gpuFn = ($script:PSRCGPU -split "function Install-GpuDevicePlugin")[1] + $gpuFn | Should -Match 'kubectl apply -f \$dpTmp --request-timeout=' + } + It "verify runs only when the plugin deployed - CPU-mode skips Confirm-GpuNode (Bugbot)" { + # A failed/CPU-mode deploy returns $false; the caller must gate Confirm-GpuNode + # on it so the user doesn't wait ~90s for a plugin that was never applied. + $script:PSRCGPU | Should -Match 'if \(Install-GpuDevicePlugin\) \{ Confirm-GpuNode \}' + $gpuFn = ($script:PSRCGPU -split "function Install-GpuDevicePlugin")[1] + $gpuFn | Should -Match 'return \$true' + $gpuFn | Should -Match 'return \$false' + } + It "the PS GPU kubectl probes are bounded with --request-timeout (reviewer parity)" { + # The existence check and Confirm-GpuNode's node probe must carry a request + # timeout so a wedged API can't hang before/around the bounded apply (bash parity). + $script:PSRCGPU | Should -Match 'kubectl get daemonset -n kube-system nvidia-device-plugin-daemonset --request-timeout=' + $script:PSRCGPU | Should -Match 'kubectl get node -o jsonpath.*--request-timeout=' + } +} + +Describe "Set-ToolTrust: wire the corporate CA into cosign/helm/git (#583)" { + BeforeEach { $env:TRACEBLOC_CA_BUNDLE=$null; $env:CURL_CA_BUNDLE=$null; $env:SSL_CERT_FILE=$null; $env:GIT_SSL_CAINFO=$null } + AfterAll { $env:TRACEBLOC_CA_BUNDLE=$null; $env:CURL_CA_BUNDLE=$null; $env:SSL_CERT_FILE=$null; $env:GIT_SSL_CAINFO=$null } + + It "exports GIT_SSL_CAINFO but NOT SSL_CERT_FILE (Go ignores it on Windows), points cosign/helm at the store (Bugbot)" { + $ca = Join-Path $TestDrive "ca.pem"; "pem" | Set-Content -LiteralPath $ca + $env:TRACEBLOC_CA_BUNDLE = $ca + $out = Set-ToolTrust 6>&1 | Out-String + $env:GIT_SSL_CAINFO | Should -Be (Resolve-Path -LiteralPath $ca).Path + $env:SSL_CERT_FILE | Should -BeNullOrEmpty # inert on Windows; deliberately not set + $out | Should -Match 'certificate for git' # success names only what's wired + $out | Should -Match 'downloads read the certificate store' # downloads/cosign/helm -> store + } + + It "no-op when no CA is configured" { + Set-ToolTrust *> $null + $env:GIT_SSL_CAINFO | Should -BeNullOrEmpty + } + + It "does NOT clobber a user's pre-set GIT_SSL_CAINFO (replace-not-augment, Bugbot)" { + $ca = Join-Path $TestDrive "corp.pem"; "pem" | Set-Content -LiteralPath $ca + $uf = Join-Path $TestDrive "user-full.pem"; "pem" | Set-Content -LiteralPath $uf + $env:TRACEBLOC_CA_BUNDLE = $ca + $env:GIT_SSL_CAINFO = $uf + Set-ToolTrust *> $null + $env:GIT_SSL_CAINFO | Should -Be $uf # user's fuller bundle left intact + } + + It "a skipped export is not claimed as success (Bugbot)" { + # With GIT_SSL_CAINFO pre-set the export is skipped — a green "Trusting..." + # would report wiring that did not happen and mask a pre-set bundle that + # still lacks the corporate CA. Say what was kept, claim nothing. + $ca = Join-Path $TestDrive "corp.pem"; "pem" | Set-Content -LiteralPath $ca + $uf = Join-Path $TestDrive "user-full.pem"; "pem" | Set-Content -LiteralPath $uf + $env:TRACEBLOC_CA_BUNDLE = $ca + $env:GIT_SSL_CAINFO = $uf + $out = Set-ToolTrust 6>&1 | Out-String + $out | Should -Not -Match 'Trusting' + $out | Should -Match 'Keeping your pre-set GIT_SSL_CAINFO' + } +} + +Describe "Registry-block detection + guidance (#585)" { + It "Test-Preflight flags a blocked container registry and points to the mirror/offline docs" { + $src = Get-Content "$PSScriptRoot/../install-k8s.ps1" -Raw + $fn = (($src -split "function Test-Preflight")[1] -split "`nfunction ")[0] + $fn | Should -Match '\$regBlocked' # detection flag + $fn | Should -Match 'ghcr\.io' # registry match in the detection + $fn | Should -Match 'container registries' # the guidance line + $fn | Should -Match 'docs/INSTALL\.md' # points at the mirror/offline docs + } +} + +Describe "Get-ImageMirrorYaml (private registry mirror / air-gap, #585)" { + # Bash parity: lib/install-client-helm.sh::_image_mirror_yaml + its bats tests. + AfterEach { + $env:TRACEBLOC_IMAGE_REGISTRY = $null + $env:TRACEBLOC_REGISTRY_USERNAME = $null + $env:TRACEBLOC_REGISTRY_PASSWORD = $null + $env:TRACEBLOC_REGISTRY_SERVER = $null + $env:TRACEBLOC_REGISTRY_EMAIL = $null + } + + It "returns empty when no mirror/creds are set (default install unchanged)" { + Get-ImageMirrorYaml | Should -BeExactly "" + } + + It "emits global.imageRegistry for a mirror-only install and no dockerRegistry" { + $env:TRACEBLOC_IMAGE_REGISTRY = "mirror.corp.example" + $out = Get-ImageMirrorYaml + $out | Should -Match "(?m)^global:" + $out | Should -Match "imageRegistry: 'mirror.corp.example'" + $out | Should -Not -Match "dockerRegistry:" + } + + It "strips a pasted scheme from the mirror host" { + $env:TRACEBLOC_IMAGE_REGISTRY = "https://mirror.corp.example" + $out = Get-ImageMirrorYaml + $out | Should -Match "imageRegistry: 'mirror.corp.example'" + $out | Should -Not -Match "imageRegistry: 'https://" + } + + It "mints a dockerRegistry with a derived https:// server when creds are given" { + $env:TRACEBLOC_IMAGE_REGISTRY = "mirror.corp.example" + $env:TRACEBLOC_REGISTRY_USERNAME = "svc" + $env:TRACEBLOC_REGISTRY_PASSWORD = "secret" + $out = Get-ImageMirrorYaml + $out | Should -Match "(?m)^dockerRegistry:" + $out | Should -Match "create: true" + $out | Should -Match "server: 'https://mirror.corp.example'" + $out | Should -Match "username: 'svc'" + $out | Should -Match "password: 'secret'" + } + + It "lets an explicit TRACEBLOC_REGISTRY_SERVER win over the derived URI" { + $env:TRACEBLOC_IMAGE_REGISTRY = "mirror.corp.example" + $env:TRACEBLOC_REGISTRY_USERNAME = "svc" + $env:TRACEBLOC_REGISTRY_PASSWORD = "secret" + $env:TRACEBLOC_REGISTRY_SERVER = "https://auth.corp.example/v2/" + (Get-ImageMirrorYaml) | Should -Match "server: 'https://auth.corp.example/v2/'" + } + + It "doubles single quotes in the password (YAML-safe)" { + $env:TRACEBLOC_IMAGE_REGISTRY = "mirror.corp.example" + $env:TRACEBLOC_REGISTRY_USERNAME = "svc" + $env:TRACEBLOC_REGISTRY_PASSWORD = "s3cr3t'q" + (Get-ImageMirrorYaml) | Should -Match "password: 's3cr3t''q'" + } + + It "emits dockerRegistry but no global when creds are given without a mirror" { + $env:TRACEBLOC_REGISTRY_USERNAME = "svc" + $env:TRACEBLOC_REGISTRY_PASSWORD = "secret" + $out = Get-ImageMirrorYaml + $out | Should -Not -Match "(?m)^global:" + $out | Should -Match "(?m)^dockerRegistry:" + } + + It "creds without a mirror still emit server (Docker Hub) - schema requires it (Bugbot)" { + # The chart schema requires dockerRegistry.server whenever create is true. + $env:TRACEBLOC_REGISTRY_USERNAME = "svc" + $env:TRACEBLOC_REGISTRY_PASSWORD = "secret" + (Get-ImageMirrorYaml) | Should -Match "server: 'https://index.docker.io/v1/'" + } +} + +Describe "Test-DownloadComplete (resilient tool download, #607)" { + BeforeAll { + $script:dl = Join-Path ([System.IO.Path]::GetTempPath()) ("tbdl-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $script:dl | Out-Null + $script:exe = Join-Path $script:dl "k3d.exe" + [System.IO.File]::WriteAllBytes($script:exe, ([byte[]](0x4D,0x5A) + (New-Object byte[] 2000000))) # MZ + 2MB + $script:zip = Join-Path $script:dl "helm.zip" + [System.IO.File]::WriteAllBytes($script:zip, ([byte[]](0x50,0x4B,0x03,0x04) + (New-Object byte[] 2000000))) # PK + 2MB + $script:err = Join-Path $script:dl "err.html" + [System.IO.File]::WriteAllText($script:err, "<html>blocked by proxy</html>") + } + AfterAll { Remove-Item $script:dl -Recurse -Force -ErrorAction SilentlyContinue } + + It "passes a complete .exe (MZ) above the size floor" { + Test-DownloadComplete -Path $script:exe -MinBytes 1MB -Magic 'MZ' | Should -BeNullOrEmpty + } + It "passes a complete .zip (PK) above the size floor" { + Test-DownloadComplete -Path $script:zip -MinBytes 1MB -Magic 'PK' | Should -BeNullOrEmpty + } + It "flags a truncated/blocked transfer (below the size floor) as a transfer failure" { + Test-DownloadComplete -Path $script:err -MinBytes 1MB -Magic 'MZ' | Should -Match 'truncated or blocked' + } + It "flags a complete-but-too-small file (size floor not met)" { + Test-DownloadComplete -Path $script:exe -MinBytes 5MB -Magic 'MZ' | Should -Match 'expected at least' + } + It "flags a wrong magic (an error page or altered binary), not a checksum problem" { + Test-DownloadComplete -Path $script:exe -MinBytes 1MB -Magic 'PK' | Should -Match "not a valid 'PK'" + } + It "flags a missing file" { + Test-DownloadComplete -Path (Join-Path $script:dl "nope.bin") -MinBytes 1MB -Magic 'MZ' | Should -Match 'no file was written' + } + It "skips the magic check when no magic is given (size floor only)" { + Test-DownloadComplete -Path $script:err -MinBytes 10 | Should -BeNullOrEmpty + } +} + +Describe "Get-VerifiedDownload resilience guards (#607, Bugbot)" { + BeforeAll { $script:GVD = Get-Content "$PSScriptRoot/../install-k8s.ps1" -Raw } + + It "the curl.exe fallback names the TLS 1.2 floor (parity with curl_secure)" { + # Bugbot: the fallback must not be able to negotiate below TLS 1.2 on the very + # proxy networks this targets. + $script:GVD | Should -Match 'curl\.exe --tlsv1\.2' + } + + It "wraps the post-download validation so an I/O error tries the next transport, not aborts" { + # Bugbot: Get-Item/OpenRead can throw if AV locks the just-written file; that + # must fall through to curl.exe/BITS, not escape Get-VerifiedDownload. + $script:GVD | Should -Match 'try \{\s*\$bad = Test-DownloadComplete[\s\S]{0,220}catch \{\s*\$bad =' + } +} diff --git a/scripts/tests/install.Tests.ps1 b/scripts/tests/install.Tests.ps1 index 01841992..5f2764eb 100644 --- a/scripts/tests/install.Tests.ps1 +++ b/scripts/tests/install.Tests.ps1 @@ -146,6 +146,74 @@ Describe "Confirm-ScriptIntegrity — integrity gate before any privileged step" $mf = Join-Path $TestDrive 'missing.sha256' "zzzz scripts/other.ps1" | Set-Content -LiteralPath $mf { Confirm-ScriptIntegrity -Manifest $mf -TmpDir $script:tmp -Files @('scripts/install-k8s.ps1') } | - Should -Throw -ExpectedMessage "*no entry in manifest*" + Should -Throw -ExpectedMessage "*isn't in the installer's signed checksum list*" + } +} + +Describe "Bootstrap log hygiene: cosign output captured, no internals leaked (#576)" { + BeforeAll { $script:BOOTSRC = Get-Content "$PSScriptRoot/../install.ps1" -Raw } + + It "captures cosign output instead of letting PowerShell dump the raw native error + source line" { + # The capture hardening now lives in the shared Invoke-CosignVerifyBlob helper (#584); + # the leaky discard form must be gone and the stderr-merged capture present. + $script:BOOTSRC | Should -Not -Match '2>\$null 1>\$null' + $script:BOOTSRC | Should -Match '& \$Cosign @VerifyArgs 2>&1 \| Out-Null' + } + It "the verification-failure message carries no internal identifiers (no source, no RFC/manifest codes)" { + $script:BOOTSRC | Should -Not -Match 'cosign signature verification FAILED for manifest\.sha256' + $script:BOOTSRC | Should -Match "Couldn't confirm the installer download is authentic" + } + It "still fails closed (throws, stops before changing the machine)" { + $script:BOOTSRC | Should -Match 'install stopped before changing anything on your machine' + } +} + +Describe "Bootstrap CA handling for cosign on Windows (#583)" { + It "validates the CA path (fail fast) but does NOT set SSL_CERT_FILE (Go ignores it on Windows)" { + $src = Get-Content "$PSScriptRoot/../install.ps1" -Raw + $fn = (($src -split "function Confirm-ManifestSignature")[1] -split "`nfunction ")[0] + $fn | Should -Match 'TRACEBLOC_CA_BUNDLE' + $fn | Should -Match "can't be read" # fail fast on a bad path + $fn | Should -Not -Match '\$env:SSL_CERT_FILE = \$ca' # inert on Windows; not wired + } +} + +Describe "Bootstrap prefers the offline Sigstore bundle (#584)" { + It "Confirm-ManifestSignature verifies --bundle --offline first, with a sig/cert fallback" { + $src = Get-Content "$PSScriptRoot/../install.ps1" -Raw + $fn = (($src -split "function Confirm-ManifestSignature")[1] -split "`nfunction ")[0] + $fn | Should -Match 'manifest\.sha256\.bundle' + $fn | Should -Match "'--bundle'" + $fn | Should -Match "'--offline'" + $fn | Should -Match "'--signature'" # online fallback path retained + } + It "Invoke-CosignVerifyBlob is fail-closed (nonzero LASTEXITCODE sentinel + stderr suppressed)" { + $src = Get-Content "$PSScriptRoot/../install.ps1" -Raw + $fn = (($src -split "function Invoke-CosignVerifyBlob")[1] -split "`nfunction ")[0] + $fn | Should -Match '\$global:LASTEXITCODE = 255' + $fn | Should -Match '2>&1 \| Out-Null' + } +} + +Describe "Confirm-ManifestSignature: offline-bundle -> sig/cert fallback behaviour (#584, reviewer)" { + # Behavioural (not source-text): drive the fallback + fail-closed branches directly. + BeforeEach { + $env:TRACEBLOC_CA_BUNDLE = $null; $env:CURL_CA_BUNDLE = $null # skip the CA fast-fail + Mock Resolve-Cosign { "cosign" } + Mock Get-Optional { $true } # bundle + sig + cert all "published/fetched" + Mock Ok {}; Mock Warn {} + } + + It "falls back to the sig/cert path when the bundle verify fails, and verifies" { + Mock Invoke-CosignVerifyBlob { if ($VerifyArgs -contains '--bundle') { $false } else { $true } } + { Confirm-ManifestSignature -Manifest 'm' -RepoRel 'r' -TmpDir $TestDrive -AllowUnverified $false } | + Should -Not -Throw + Should -Invoke Invoke-CosignVerifyBlob -Times 2 -Exactly # bundle attempt + sig/cert fallback + } + + It "fails closed when BOTH the bundle and the sig/cert verify fail" { + Mock Invoke-CosignVerifyBlob { $false } + { Confirm-ManifestSignature -Manifest 'm' -RepoRel 'r' -TmpDir $TestDrive -AllowUnverified $false } | + Should -Throw -ExpectedMessage "*Couldn't confirm the installer download is authentic*" } } diff --git a/scripts/tests/leftover-guard.bats b/scripts/tests/leftover-guard.bats index 801f0715..435692a6 100644 --- a/scripts/tests/leftover-guard.bats +++ b/scripts/tests/leftover-guard.bats @@ -24,8 +24,8 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA # ── _leftover_data_dirs (detection) ────────────────────────────────────────── @test "_leftover_data_dirs: nonexistent HOST_DATA_DIR -> nothing" { run _leftover_data_dirs - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_leftover_data_dirs: empty dirs / values.yaml / log are not data" { @@ -33,20 +33,20 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA : >"$HOST_DATA_DIR/values.yaml" : >"$HOST_DATA_DIR/install-20260101-000000.log" run _leftover_data_dirs - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "_leftover_data_dirs: flat mysql data detected" { seed_flat_mysql run _leftover_data_dirs - [[ "$output" == *"$HOST_DATA_DIR/mysql"* ]] + [[ "$output" == *"$HOST_DATA_DIR/mysql"* ]] || return 1 } @test "_leftover_data_dirs: per-release layout detected" { seed_release_data run _leftover_data_dirs - [[ "$output" == *"$HOST_DATA_DIR/tracebloc/data"* ]] + [[ "$output" == *"$HOST_DATA_DIR/tracebloc/data"* ]] || return 1 } @test "_leftover_data_dirs: flat MySQL datadir's nested 'mysql' schema is not a second root (#384 bugbot)" { @@ -57,8 +57,8 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA : >"$HOST_DATA_DIR/mysql/ibdata1" : >"$HOST_DATA_DIR/mysql/mysql/user.frm" run _leftover_data_dirs - [ "$status" -eq 0 ] - [ "$output" = "$HOST_DATA_DIR/mysql" ] # exactly one root; nested schema not double-reported + [ "$status" -eq 0 ] || return 1 + [ "$output" = "$HOST_DATA_DIR/mysql" ] || return 1 # exactly one root; nested schema not double-reported } @test "_leftover_data_dirs: unreadable (root/container-owned) dir is detected, not skipped (#384 bugbot)" { @@ -67,8 +67,8 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA chmod 000 "$HOST_DATA_DIR/mysql" # host user can't list it (find errors) run _leftover_data_dirs chmod 755 "$HOST_DATA_DIR/mysql" # restore for teardown - [ "$status" -eq 0 ] - [[ "$output" == *"$HOST_DATA_DIR/mysql"* ]] # detected via find's error, not skipped + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"$HOST_DATA_DIR/mysql"* ]] || return 1 # detected via find's error, not skipped } @test "_leftover_data_dirs: readable dir with an unreadable subdir is detected (#384 bugbot)" { @@ -77,8 +77,8 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA chmod 000 "$HOST_DATA_DIR/mysql/sub" # top readable, subdir not -> find errors run _leftover_data_dirs chmod 755 "$HOST_DATA_DIR/mysql/sub" # restore for teardown - [ "$status" -eq 0 ] - [[ "$output" == *"$HOST_DATA_DIR/mysql"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"$HOST_DATA_DIR/mysql"* ]] || return 1 } @test "_leftover_data_dirs: large multi-file MySQL dir detected under pipefail (#384 bugbot)" { @@ -90,27 +90,27 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA set -o pipefail run _leftover_data_dirs set +o pipefail - [ "$status" -eq 0 ] - [[ "$output" == *"$HOST_DATA_DIR/mysql"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"$HOST_DATA_DIR/mysql"* ]] || return 1 } # ── guard_leftover_data (decision) ─────────────────────────────────────────── @test "guard: clean slate -> proceeds silently" { run guard_leftover_data - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "guard: TRACEBLOC_SKIP_LEFTOVER_GUARD bypasses even with data present" { seed_flat_mysql TRACEBLOC_SKIP_LEFTOVER_GUARD=1 run guard_leftover_data - [ "$status" -eq 0 ] - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] # untouched + [ "$status" -eq 0 ] || return 1 + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 # untouched } @test "guard: --reuse-data (TB_LEFTOVER_ACTION=reuse) keeps the data and proceeds" { seed_flat_mysql TB_LEFTOVER_ACTION=reuse guard_leftover_data - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] # kept + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 # kept } # node-local has no /tracebloc host bind-mount, so "reuse" cannot adopt the host @@ -119,29 +119,29 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA @test "guard: node-local reuse keeps data on disk but says it is NOT adopted" { seed_flat_mysql TB_STORAGE_MODE=node-local TB_LEFTOVER_ACTION=reuse run guard_leftover_data - [ "$status" -eq 0 ] - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] # left on disk (not wiped) - [[ "$output" == *"can't adopt"* ]] # honest: not adopted - [[ "$output" == *"starts empty"* ]] - [[ "$output" != *"keep and adopt the existing data"* ]] + [ "$status" -eq 0 ] || return 1 + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 # left on disk (not wiped) + [[ "$output" == *"can't adopt"* ]] || return 1 # honest: not adopted + [[ "$output" == *"starts empty"* ]] || return 1 + [[ "$output" != *"keep and adopt the existing data"* ]] || return 1 } @test "guard: node-local interactive reuse ('r') -> honest label, no false adopt claim" { seed_flat_mysql TB_STORAGE_MODE=node-local TB_TTY=/dev/stdin run guard_leftover_data <<< "r" - [ "$status" -eq 0 ] - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] # kept, not wiped - [[ "$output" == *"NOT adopted"* ]] # honest option label - [[ "$output" != *"reuse — keep and adopt the existing data"* ]] + [ "$status" -eq 0 ] || return 1 + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 # kept, not wiped + [[ "$output" == *"NOT adopted"* ]] || return 1 # honest option label + [[ "$output" != *"reuse — keep and adopt the existing data"* ]] || return 1 } # hostpath reuse is unchanged: it still adopts, no node-local warning. @test "guard: hostpath reuse still adopts (no node-local 'can't adopt' warning)" { seed_flat_mysql TB_STORAGE_MODE=hostpath TB_LEFTOVER_ACTION=reuse run guard_leftover_data - [ "$status" -eq 0 ] - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] - [[ "$output" != *"can't adopt"* ]] + [ "$status" -eq 0 ] || return 1 + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 + [[ "$output" != *"can't adopt"* ]] || return 1 } # The node-local prompt shows "[r] keep …" — the parser must accept the shown @@ -151,9 +151,9 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA for word in keep k reuse r KEEP Keep K R Reuse; do seed_flat_mysql TB_STORAGE_MODE=node-local TB_TTY=/dev/stdin run guard_leftover_data <<< "$word" - [ "$status" -eq 0 ] # continues (reuse action), not abort - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] # data kept - [[ "$output" == *"starts empty"* ]] # took the honest node-local reuse branch + [ "$status" -eq 0 ] || return 1 # continues (reuse action), not abort + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 # data kept + [[ "$output" == *"starts empty"* ]] || return 1 # took the honest node-local reuse branch done } @@ -162,40 +162,40 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA @test "guard: non-interactive node-local --reuse-data guidance is honest (NOT adopted)" { seed_flat_mysql TB_STORAGE_MODE=node-local TB_TTY=/no/such/tty run guard_leftover_data - [ "$status" -eq 1 ] - [[ "$output" == *"no choice was given"* ]] - [[ "$output" == *"NOT adopted"* ]] # honest --reuse-data description - [[ "$output" != *"--reuse-data adopt the existing data"* ]] - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] # fail-safe: data untouched + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"no choice was given"* ]] || return 1 + [[ "$output" == *"NOT adopted"* ]] || return 1 # honest --reuse-data description + [[ "$output" != *"--reuse-data adopt the existing data"* ]] || return 1 + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 # fail-safe: data untouched } @test "guard: non-interactive hostpath --reuse-data still says 'adopt' (unchanged)" { seed_flat_mysql TB_STORAGE_MODE=hostpath TB_TTY=/no/such/tty run guard_leftover_data - [ "$status" -eq 1 ] - [[ "$output" == *"adopt the existing data"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"adopt the existing data"* ]] || return 1 } @test "guard: --wipe-data (TB_LEFTOVER_ACTION=wipe) removes the detected data dirs" { seed_flat_mysql seed_release_data TB_LEFTOVER_ACTION=wipe guard_leftover_data - [ ! -e "$HOST_DATA_DIR/mysql/ibdata1" ] - [ ! -e "$HOST_DATA_DIR/tracebloc/data/ds1/rows.csv" ] + [ ! -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 + [ ! -e "$HOST_DATA_DIR/tracebloc/data/ds1/rows.csv" ] || return 1 } @test "_wipe_leftover_data: refuses when HOST_DATA_DIR is empty (#384 wipe-safety)" { HOST_DATA_DIR="" run _wipe_leftover_data "/some/path/mysql" - [ "$status" -ne 0 ] - [[ "$output" == *"Refusing to wipe"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"Refusing to wipe"* ]] || return 1 } @test "_wipe_leftover_data: refuses when HOST_DATA_DIR is outside \$HOME (#384 wipe-safety)" { HOST_DATA_DIR="/var/tmp/evil" run _wipe_leftover_data "/var/tmp/evil/mysql" - [ "$status" -ne 0 ] - [[ "$output" == *"Refusing to wipe"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"Refusing to wipe"* ]] || return 1 } @test "guard: wipe that cannot remove data fails closed, does not adopt (#384 bugbot)" { @@ -204,9 +204,9 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA chmod a-w "$HOST_DATA_DIR/mysql" # make ibdata1 unremovable TB_LEFTOVER_ACTION=wipe TB_TTY=/dev/null run guard_leftover_data chmod u+w "$HOST_DATA_DIR/mysql" # restore so teardown can clean up - [ "$status" -eq 1 ] # aborted, did not proceed - [[ "$output" == *"Could not fully wipe"* ]] - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] # survivor NOT silently adopted + [ "$status" -eq 1 ] || return 1 # aborted, did not proceed + [[ "$output" == *"Could not fully wipe"* ]] || return 1 + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 # survivor NOT silently adopted } @test "_leftover_data_dirs: symlinked subdir is not a wipeable candidate (#384 bugbot)" { @@ -214,8 +214,8 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA local target="$BATS_TEST_TMPDIR/outside"; mkdir -p "$target/mysql"; : >"$target/mysql/ibdata1" ln -s "$target" "$HOST_DATA_DIR/evil" # $HOST_DATA_DIR/evil -> outside the data dir run _leftover_data_dirs - [ "$status" -eq 0 ] - [[ "$output" != *"/evil/"* ]] # symlink not walked into a candidate + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"/evil/"* ]] || return 1 # symlink not walked into a candidate } @test "_wipe_leftover_data: refuses to delete a symlink, target preserved (#384 bugbot)" { @@ -223,9 +223,9 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA local target="$BATS_TEST_TMPDIR/outside"; mkdir -p "$target"; : >"$target/keep" ln -s "$target" "$HOST_DATA_DIR/link" run _wipe_leftover_data "$HOST_DATA_DIR/link" - [ "$status" -ne 0 ] - [ -e "$target/keep" ] # target outside HOST_DATA_DIR untouched - [ -L "$HOST_DATA_DIR/link" ] # symlink itself left for the user + [ "$status" -ne 0 ] || return 1 + [ -e "$target/keep" ] || return 1 # target outside HOST_DATA_DIR untouched + [ -L "$HOST_DATA_DIR/link" ] || return 1 # symlink itself left for the user } @test "guard: wipe never touches HOST_DATASET_DIR (shared mount)" { @@ -233,16 +233,16 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA HOST_DATASET_DIR="$BATS_TEST_TMPDIR/netmount" mkdir -p "$HOST_DATASET_DIR/data"; : >"$HOST_DATASET_DIR/data/keep.csv" TB_LEFTOVER_ACTION=wipe guard_leftover_data - [ ! -e "$HOST_DATA_DIR/mysql/ibdata1" ] # local data wiped - [ -e "$HOST_DATASET_DIR/data/keep.csv" ] # network mount preserved + [ ! -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 # local data wiped + [ -e "$HOST_DATASET_DIR/data/keep.csv" ] || return 1 # network mount preserved } @test "guard: no terminal + no action -> fail-safe abort (exit 1, data untouched)" { seed_flat_mysql TB_TTY=/no/such/tty run guard_leftover_data - [ "$status" -eq 1 ] - [[ "$output" == *"no choice was given"* ]] - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] # abort leaves data as-is + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"no choice was given"* ]] || return 1 + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 # abort leaves data as-is } @test "guard: readable-but-unopenable TB_TTY -> non-interactive guidance, not generic abort (#384 bugbot)" { @@ -255,31 +255,31 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA { : </dev/tty; } 2>/dev/null && skip "/dev/tty is openable here (interactive shell)" seed_flat_mysql TB_TTY=/dev/tty run guard_leftover_data - [ "$status" -eq 1 ] - [[ "$output" == *"no choice was given"* ]] # non-interactive guidance, not generic abort - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] # fail-safe: data untouched + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"no choice was given"* ]] || return 1 # non-interactive guidance, not generic abort + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 # fail-safe: data untouched } # ── interactive prompt (input fed via TB_TTY=/dev/stdin) ───────────────────── @test "guard: interactive 'w' wipes" { seed_flat_mysql TB_TTY=/dev/stdin run guard_leftover_data <<< "w" - [ "$status" -eq 0 ] - [ ! -e "$HOST_DATA_DIR/mysql/ibdata1" ] + [ "$status" -eq 0 ] || return 1 + [ ! -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 } @test "guard: interactive 'a' (and unrecognised input) aborts" { seed_flat_mysql TB_TTY=/dev/stdin run guard_leftover_data <<< "a" - [ "$status" -eq 1 ] - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] + [ "$status" -eq 1 ] || return 1 + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 } @test "guard: interactive default (empty input) aborts" { seed_flat_mysql TB_TTY=/dev/stdin run guard_leftover_data <<< "" - [ "$status" -eq 1 ] - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] + [ "$status" -eq 1 ] || return 1 + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 } # ── input sanitizing (#384 bugbot: paste garbage + whitespace) ─────────────── @@ -287,20 +287,20 @@ seed_release_data() { mkdir -p "$HOST_DATA_DIR/tracebloc/data/ds1"; : >"$HOST_DA TB_TTY=/dev/stdin local got="unset" _read_sanitized "" got <<< "$(printf ' \033[Dhello world ')" - [ "$got" = "hello world" ] + [ "$got" = "hello world" ] || return 1 } @test "_read_sanitized: whitespace-only input -> empty" { TB_TTY=/dev/stdin local got="unset" _read_sanitized "" got <<< " " - [ -z "$got" ] + [ -z "$got" ] || return 1 } @test "guard: new-dir choice with whitespace-only path aborts (#384 bugbot)" { seed_flat_mysql TB_LEFTOVER_ACTION=newdir TB_TTY=/dev/stdin run guard_leftover_data <<< " " - [ "$status" -eq 1 ] - [[ "$output" == *"No new directory given"* ]] - [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] # untouched + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"No new directory given"* ]] || return 1 + [ -e "$HOST_DATA_DIR/mysql/ibdata1" ] || return 1 # untouched } diff --git a/scripts/tests/lib/e2e-common.sh b/scripts/tests/lib/e2e-common.sh index 9715eb4c..ce1ef64d 100644 --- a/scripts/tests/lib/e2e-common.sh +++ b/scripts/tests/lib/e2e-common.sh @@ -49,3 +49,43 @@ e2e_install_prereqs() { install_k3d install_helm } + +# ── e2e_egress_positive_control <host> ────────────────────────────────────── +# Positive control for the egress seal-checks (Saqlain review on #541): before +# trusting a BLOCKED probe result, prove the cluster can actually REACH the +# probe host. Otherwise egress failing for an unrelated reason (a runner +# firewall, a target outage, a rate-limit) makes the probe print OK and the +# seal-check pass green while the NetworkPolicy did nothing. A pod in `default` +# is governed by NO training-egress policy (the policy is namespace-scoped to +# the release ns), so if IT reaches the host, a training pod's block is +# attributable to the policy, not the environment. Same image + curl invocation +# as the probe, targeting the SAME host pinned on the caller's install — so a +# reachable positive is attributable to exactly the host the probe is blocked +# from (no hardcoded-vs-chart-default drift). +# Moved verbatim from e2e-seal-check.sh (#541) so e2e-full-seal.sh shares the +# one copy. Contract: the caller defines fail() (every e2e-*.sh does). +e2e_egress_positive_control() { + local host="$1" + echo "── positive control: a non-policied pod must REACH ${host}:443 ──" + # A fast runner can schedule the pod before the `default` ServiceAccount is + # created ("serviceaccount default not found"), which aborts under set -e + # before the attribution failure below. Wait for the SA first (Bugbot). + for _ in $(seq 1 20); do + kubectl --request-timeout=10s get serviceaccount default -n default >/dev/null 2>&1 && break + sleep 1 + done + kubectl --request-timeout=10s run seal-poscheck --namespace default --restart=Never \ + --image="curlimages/curl:8.20.0" \ + --command -- curl --noproxy '*' --tlsv1.2 -k -sS -m 15 -o /dev/null "https://${host}" + local posphase="" + for _ in $(seq 1 40); do + posphase="$(kubectl --request-timeout=10s get pod seal-poscheck -n default -o jsonpath='{.status.phase}' 2>/dev/null || true)" + { [ "$posphase" = "Succeeded" ] || [ "$posphase" = "Failed" ]; } && break + sleep 3 + done + kubectl --request-timeout=10s logs seal-poscheck -n default 2>/dev/null || true + kubectl --request-timeout=10s delete pod seal-poscheck -n default --ignore-not-found --now >/dev/null 2>&1 || true + [ "$posphase" = "Succeeded" ] || + fail "positive control FAILED — a non-policied pod could not reach ${host}:443 (phase=${posphase:-none}). A blocked training pod would NOT be attributable to the NetworkPolicy (runner egress / target issue), so the seal-check is inconclusive — refusing to report a false PASS." + echo "positive control OK — ${host}:443 reachable; a training-pod block is now attributable to the policy." +} diff --git a/scripts/tests/preflight.bats b/scripts/tests/preflight.bats index b702f9d3..6d502222 100644 --- a/scripts/tests/preflight.bats +++ b/scripts/tests/preflight.bats @@ -11,6 +11,10 @@ setup() { PF_HARD_FAIL=0 # Default-safe stubs (a healthy amd64 box); individual tests override. _pf_probe_url() { echo ok; } + # Hermetic default: the TLS-inspection probe does a live openssl call to github.com + # in production, so stub it here (like _pf_probe_url) — nothing touches the real + # network. Tests of the REAL probe source preflight.sh fresh in a subshell (Bugbot). + _pf_detect_tls_inspection() { echo "unknown"; } _pf_free_kb() { echo $((50 * 1024 * 1024)); } # 50 GB _pf_fstype() { echo ext4; } # local disk (storage check passes) _pf_host_mem_kb() { echo $((8 * 1024 * 1024)); } # 8 GB @@ -28,72 +32,72 @@ setup() { @test "_pf_arch: amd64 -> success, no hard fail" { ARCH=x86_64 run _pf_arch - [ "$status" -eq 0 ] - [[ "$output" == *"amd64"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"amd64"* ]] || return 1 } @test "_pf_arch: arm64 Linux without emulation -> hard fail + binfmt remedy" { ARCH=aarch64; OS=Linux _pf_amd64_emulation_available() { return 1; } run _pf_arch - [[ "$output" == *"amd64-only"* ]] - [[ "$output" == *"tonistiigi/binfmt"* ]] - PF_HARD_FAIL=0; _pf_arch >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] + [[ "$output" == *"amd64-only"* ]] || return 1 + [[ "$output" == *"tonistiigi/binfmt"* ]] || return 1 + PF_HARD_FAIL=0; _pf_arch >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] || return 1 } @test "_pf_arch: arm64 Linux WITH emulation -> info, no hard fail" { ARCH=aarch64; OS=Linux _pf_amd64_emulation_available() { return 0; } - PF_HARD_FAIL=0; _pf_arch >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] + PF_HARD_FAIL=0; _pf_arch >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_arch: arm64 macOS -> info (Desktop emulation), no hard fail" { ARCH=arm64; OS=Darwin - PF_HARD_FAIL=0; _pf_arch >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] + PF_HARD_FAIL=0; _pf_arch >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_arch: arm64 macOS note names the Rosetta setting + defers to the post-Docker smoke (#433)" { ARCH=arm64; OS=Darwin run _pf_arch - [ "$status" -eq 0 ] - [[ "$output" == *"Use Rosetta for x86_64/amd64 emulation"* ]] # names the exact setting, not "assume it works" - [[ "$output" == *"verified once Docker is running"* ]] # real check is the post-Docker smoke (#433) + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Use Rosetta for x86_64/amd64 emulation"* ]] || return 1 # names the exact setting, not "assume it works" + [[ "$output" == *"verified once Docker is running"* ]] || return 1 # real check is the post-Docker smoke (#433) } @test "_pf_arch: arm64 + TRACEBLOC_ALLOW_ARM64 -> warn, no hard fail" { ARCH=aarch64; OS=Linux; export TRACEBLOC_ALLOW_ARM64=1 _pf_amd64_emulation_available() { return 1; } run _pf_arch - [[ "$output" == *"proceeding"* ]] - PF_HARD_FAIL=0; _pf_arch >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] + [[ "$output" == *"proceeding"* ]] || return 1 + PF_HARD_FAIL=0; _pf_arch >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 unset TRACEBLOC_ALLOW_ARM64 } # ── _pf_connectivity ───────────────────────────────────────────────────────── @test "_pf_connectivity: all reachable -> no hard fail" { _pf_probe_url() { echo ok; } - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_connectivity: a critical host blocked -> hard fail + allowlist hint" { _pf_probe_url() { case "$1" in *ghcr*) echo blocked ;; *) echo ok ;; esac; } run _pf_connectivity - [[ "$output" == *"ghcr.io) unreachable"* ]] - [[ "$output" == *"Allow HTTPS"* ]] - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] + [[ "$output" == *"ghcr.io) unreachable"* ]] || return 1 + [[ "$output" == *"Allow HTTPS"* ]] || return 1 + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] || return 1 } @test "_pf_connectivity: TLS error -> break-and-inspect (Gap D) hint" { _pf_probe_url() { case "$1" in *registry-1.docker*) echo tls ;; *) echo ok ;; esac; } run _pf_connectivity - [[ "$output" == *"break-and-inspect"* ]] + [[ "$output" == *"break-and-inspect"* ]] || return 1 } @test "_pf_connectivity: tool host skipped when the tool is present" { _pf_probe_url() { echo ok; } has() { return 0; } run _pf_connectivity - [[ "$output" != *"get.docker.com"* ]] + [[ "$output" != *"get.docker.com"* ]] || return 1 } @test "_pf_connectivity: Docker-engine host is WARN not hard — path-dependent (Bugbot #416)" { @@ -104,8 +108,8 @@ setup() { has() { [[ "$1" == "curl" ]]; } # docker + all tools missing OS=Linux run _pf_connectivity - [[ "$output" == *"get.docker.com) unreachable"* ]] # still surfaced… - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] # …but NOT a hard fail + [[ "$output" == *"get.docker.com) unreachable"* ]] || return 1 # still surfaced… + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 # …but NOT a hard fail } @test "_pf_connectivity: kubectl host (dl.k8s.io) blocked -> HARD fail (#416)" { @@ -113,8 +117,8 @@ setup() { has() { [[ "$1" == "curl" ]]; } # tools missing -> their download hosts probed OS=Linux run _pf_connectivity - [[ "$output" == *"dl.k8s.io) unreachable"* ]] - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -ge 1 ] + [[ "$output" == *"dl.k8s.io) unreachable"* ]] || return 1 + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -ge 1 ] || return 1 } @test "_pf_connectivity: k3d asset host objects.githubusercontent.com is probed (#416)" { @@ -124,7 +128,7 @@ setup() { has() { [[ "$1" == "curl" ]]; } OS=Linux run _pf_connectivity - [[ "$output" == *"objects.githubusercontent.com) unreachable"* ]] + [[ "$output" == *"objects.githubusercontent.com) unreachable"* ]] || return 1 } @test "_pf_connectivity: auth.docker.io (Docker Hub token host) is probed hard (#416)" { @@ -132,7 +136,7 @@ setup() { has() { return 0; } # all tools present -> only always-critical hosts probed OS=Linux run _pf_connectivity - [[ "$output" == *"auth.docker.io) unreachable"* ]] + [[ "$output" == *"auth.docker.io) unreachable"* ]] || return 1 } @test "_pf_connectivity: macOS hard-probes formulae.brew.sh when a brew tool is absent (reviewer #416)" { @@ -142,8 +146,8 @@ setup() { has() { case "$1" in curl|brew|docker) return 0 ;; *) return 1 ;; esac; } # brew+docker present, kubectl/k3d/helm absent OS=Darwin run _pf_connectivity - [[ "$output" == *"formulae.brew.sh) unreachable"* ]] - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -ge 1 ] + [[ "$output" == *"formulae.brew.sh) unreachable"* ]] || return 1 + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -ge 1 ] || return 1 } @test "_pf_connectivity: GUI Mac, only docker missing -> formulae.brew.sh NOT probed (Bugbot #416)" { @@ -154,8 +158,8 @@ setup() { _pf_has_gui_session() { return 0; } # GUI session -> Docker Desktop path OS=Darwin run _pf_connectivity - [[ "$output" != *"formulae.brew.sh"* ]] # not probed at all - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] + [[ "$output" != *"formulae.brew.sh"* ]] || return 1 # not probed at all + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_connectivity: headless Mac, only docker missing -> formulae.brew.sh IS probed (Bugbot #416)" { @@ -166,8 +170,8 @@ setup() { _pf_has_gui_session() { return 1; } # headless -> colima via brew OS=Darwin run _pf_connectivity - [[ "$output" == *"formulae.brew.sh) unreachable"* ]] - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -ge 1 ] + [[ "$output" == *"formulae.brew.sh) unreachable"* ]] || return 1 + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -ge 1 ] || return 1 } @test "_pf_connectivity: macOS hard-probes github.com for the Homebrew clone when brew absent (Bugbot #416)" { @@ -177,8 +181,8 @@ setup() { has() { [[ "$1" == "curl" ]]; } # brew missing -> clone host probed OS=Darwin run _pf_connectivity - [[ "$output" == *"github.com) unreachable"* ]] - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -ge 1 ] + [[ "$output" == *"github.com) unreachable"* ]] || return 1 + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -ge 1 ] || return 1 } @test "_pf_connectivity: GUI Mac, docker missing -> desktop.docker.com HARD (Bugbot #416)" { @@ -189,8 +193,8 @@ setup() { _pf_has_gui_session() { return 0; } # GUI -> Docker Desktop OS=Darwin run _pf_connectivity - [[ "$output" == *"desktop.docker.com) unreachable"* ]] - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -ge 1 ] + [[ "$output" == *"desktop.docker.com) unreachable"* ]] || return 1 + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -ge 1 ] || return 1 } @test "_pf_connectivity: headless Mac, docker missing -> desktop.docker.com NOT probed (Colima path; Bugbot #416)" { @@ -201,8 +205,8 @@ setup() { _pf_has_gui_session() { return 1; } # headless -> colima via brew OS=Darwin run _pf_connectivity - [[ "$output" != *"desktop.docker.com"* ]] - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] + [[ "$output" != *"desktop.docker.com"* ]] || return 1 + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_connectivity: a download host is NOT probed when its tool is present (#416)" { @@ -210,81 +214,81 @@ setup() { has() { case "$1" in curl|kubectl) return 0 ;; *) return 1 ;; esac; } # kubectl present OS=Linux run _pf_connectivity - [[ "$output" != *"dl.k8s.io"* ]] # present tool is never re-downloaded -> host not probed - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] + [[ "$output" != *"dl.k8s.io"* ]] || return 1 # present tool is never re-downloaded -> host not probed + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } # ── _pf_disk / _pf_memory / _pf_cpu ────────────────────────────────────────── @test "_pf_disk: ample free space -> success" { OS=Linux; _pf_free_kb() { echo $((50 * 1024 * 1024)); } - run _pf_disk; [[ "$output" == *"50 GB free"* ]] - PF_HARD_FAIL=0; _pf_disk >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] + run _pf_disk; [[ "$output" == *"50 GB free"* ]] || return 1 + PF_HARD_FAIL=0; _pf_disk >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_disk: low (<20 GB) -> warn, no hard fail" { OS=Linux; _pf_free_kb() { echo $((10 * 1024 * 1024)); } - run _pf_disk; [[ "$output" == *"recommended"* ]] - PF_HARD_FAIL=0; _pf_disk >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] + run _pf_disk; [[ "$output" == *"recommended"* ]] || return 1 + PF_HARD_FAIL=0; _pf_disk >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_disk: critically low (<5 GB) -> hard fail" { OS=Linux; _pf_free_kb() { echo $((2 * 1024 * 1024)); } - run _pf_disk; [[ "$output" == *"need"* ]] - PF_HARD_FAIL=0; _pf_disk >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] + run _pf_disk; [[ "$output" == *"need"* ]] || return 1 + PF_HARD_FAIL=0; _pf_disk >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] || return 1 } @test "_pf_disk: macOS -> info only (Desktop VM disk is opaque)" { OS=Darwin; _pf_free_kb() { echo $((2 * 1024 * 1024)); } # even 'low' must not fail - PF_HARD_FAIL=0; _pf_disk >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] + PF_HARD_FAIL=0; _pf_disk >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_memory: below floor on Linux -> hard fail + resize hint" { OS=Linux; _pf_host_mem_kb() { echo $((3 * 1024 * 1024)); } # 3 GB - run _pf_memory; [[ "$output" == *"to run the tracebloc client"* ]] - PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] + run _pf_memory; [[ "$output" == *"to run the tracebloc client"* ]] || return 1 + PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] || return 1 } @test "_pf_memory: between floor and warn -> warn, no hard fail" { OS=Linux; _pf_host_mem_kb() { echo $((6 * 1024 * 1024)); } # 6 GB - run _pf_memory; [[ "$output" == *"recommended to train"* ]] - PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] + run _pf_memory; [[ "$output" == *"recommended to train"* ]] || return 1 + PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_memory: ample RAM -> success" { OS=Linux; _pf_host_mem_kb() { echo $((16 * 1024 * 1024)); } - run _pf_memory; [[ "$output" == *"16 GB"* ]] - PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] + run _pf_memory; [[ "$output" == *"16 GB"* ]] || return 1 + PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_memory: macOS below floor -> WARN only, never hard fail" { OS=Darwin; _pf_host_mem_kb() { echo $((3 * 1024 * 1024)); } run _pf_memory - [[ "$output" == *"below the"* ]] - [[ "$output" == *"it will OOM"* ]] + [[ "$output" == *"below the"* ]] || return 1 + [[ "$output" == *"it will OOM"* ]] || return 1 # The MACHINE is under the floor — no Docker setting fixes that, so this branch # offers no resize remedy (#417); the post-Docker recheck owns the honest # "use a larger machine" stop. - [[ "$output" != *"Settings"* ]] - PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] + [[ "$output" != *"Settings"* ]] || return 1 + PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_memory: 64 MiB grace -> a hair under the floor still passes" { OS=Linux; _pf_host_mem_kb() { echo $(( 5 * 1024 * 1024 - 1000 )); } # ~5 GB minus a bit - PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] + PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_memory: PF_MIN_MEM_GB override relaxes the floor" { OS=Linux; PF_MIN_MEM_GB=2; PF_WARN_MEM_GB=2 _pf_host_mem_kb() { echo $((3 * 1024 * 1024)); } # 3 GB now passes - run _pf_memory; [[ "$output" == *"3 GB"* ]] - PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] + run _pf_memory; [[ "$output" == *"3 GB"* ]] || return 1 + PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_memory: Linux MemAvailable tight -> extra warn (total fine)" { OS=Linux; _pf_host_mem_kb() { echo $((16 * 1024 * 1024)); } # total fine _pf_avail_mem_kb() { echo $((2 * 1024 * 1024)); } # only 2 GB free now - run _pf_memory; [[ "$output" == *"available right now"* ]] - PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] + run _pf_memory; [[ "$output" == *"available right now"* ]] || return 1 + PF_HARD_FAIL=0; _pf_memory >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } # ── memory truth: machine RAM vs Docker's budget (#417) ────────────────────── @@ -293,8 +297,8 @@ setup() { _pf_host_mem_kb() { echo $((16 * 1024 * 1024)); } # 16 GB Mac _pf_runtime_mem_kb() { echo $((6 * 1024 * 1024)); } # Docker VM only 6 GB run _pf_memory - [[ "$output" == *"16 GB (machine)"* ]] # the gate line reports the MACHINE - [[ "$output" != *"6 GB (Docker VM)"* ]] # the old flip-flopped label is gone + [[ "$output" == *"16 GB (machine)"* ]] || return 1 # the gate line reports the MACHINE + [[ "$output" != *"6 GB (Docker VM)"* ]] || return 1 # the old flip-flopped label is gone } @test "_pf_memory: a smaller Docker budget gets its OWN second line (#417)" { @@ -302,8 +306,8 @@ setup() { _pf_host_mem_kb() { echo $((16 * 1024 * 1024)); } _pf_runtime_mem_kb() { echo $((6 * 1024 * 1024)); } run _pf_memory - [[ "$output" == *"16 GB (machine)"* ]] - [[ "$output" == *"Docker's memory budget: 6 GB"* ]] + [[ "$output" == *"16 GB (machine)"* ]] || return 1 + [[ "$output" == *"Docker's memory budget: 6 GB"* ]] || return 1 } @test "_pf_memory: native Linux does NOT duplicate the same number as a budget line (#417)" { @@ -312,8 +316,8 @@ setup() { _pf_host_mem_kb() { echo $((16 * 1024 * 1024)); } _pf_runtime_mem_kb() { echo $((16 * 1024 * 1024)); } run _pf_memory - [[ "$output" == *"16 GB (machine)"* ]] - [[ "$output" != *"Docker's memory budget"* ]] + [[ "$output" == *"16 GB (machine)"* ]] || return 1 + [[ "$output" != *"Docker's memory budget"* ]] || return 1 } @test "_pf_memory: host unreadable -> falls back to the VM budget, labelled honestly (#417)" { @@ -321,8 +325,8 @@ setup() { _pf_host_mem_kb() { echo ""; } # hw.memsize unreadable _pf_runtime_mem_kb() { echo $((6 * 1024 * 1024)); } run _pf_memory - [[ "$output" == *"6 GB (Docker VM)"* ]] # labelled as the VM, not "machine" - [[ "$output" != *"Docker's memory budget"* ]] # and not also as a second line + [[ "$output" == *"6 GB (Docker VM)"* ]] || return 1 # labelled as the VM, not "machine" + [[ "$output" != *"Docker's memory budget"* ]] || return 1 # and not also as a second line } @test "_pf_memory: budget advice is clamped to the machine and floored at the minimum (#417/#428)" { @@ -330,26 +334,26 @@ setup() { _pf_host_mem_kb() { echo $((8 * 1024 * 1024)); } # 8 GB Mac -> cap 8-2 = 6 _pf_runtime_mem_kb() { echo $((5 * 1024 * 1024)); } # 5 GB budget: >= floor, < warn run _pf_memory - [[ "$output" == *"Docker's memory budget: 5 GB"* ]] - [[ "$output" == *"6 GB"* ]] # clamped rec, not the raw PF_REC_MEM_GB=16 - [[ "$output" != *"16 GB"* ]] # never advise more than the machine has + [[ "$output" == *"Docker's memory budget: 5 GB"* ]] || return 1 + [[ "$output" == *"6 GB"* ]] || return 1 # clamped rec, not the raw PF_REC_MEM_GB=16 + [[ "$output" != *"16 GB"* ]] || return 1 # never advise more than the machine has } @test "_pf_runtime_mem_status: Linux hint avoids the Docker Desktop dead end (Bugbot #445)" { OS=Linux _pf_host_mem_kb() { echo $((16 * 1024 * 1024)); } run _pf_runtime_mem_status $((4 * 1024)) # sub-floor budget, in MiB - [[ "$output" == *"below the"* ]] - [[ "$output" != *"Docker Desktop"* ]] # headless boxes have no Desktop UI - [[ "$output" == *"VM/cgroup limit"* ]] + [[ "$output" == *"below the"* ]] || return 1 + [[ "$output" != *"Docker Desktop"* ]] || return 1 # headless boxes have no Desktop UI + [[ "$output" == *"VM/cgroup limit"* ]] || return 1 } @test "_pf_runtime_mem_status: macOS hint names Docker Desktop AND a real colima resize" { OS=Darwin _pf_host_mem_kb() { echo $((16 * 1024 * 1024)); } run _pf_runtime_mem_status $((4 * 1024)) - [[ "$output" == *"Docker Desktop"* ]] - [[ "$output" == *"colima stop && colima start --memory"* ]] + [[ "$output" == *"Docker Desktop"* ]] || return 1 + [[ "$output" == *"colima stop && colima start --memory"* ]] || return 1 } @test "_pf_runtime_mem_status: healthy budget -> ok line, no latch set" { @@ -357,7 +361,7 @@ setup() { _pf_host_mem_kb() { echo $((32 * 1024 * 1024)); } PF_RUNTIME_MEM_WARNED="" _pf_runtime_mem_status $((16 * 1024)) >/dev/null - [ -z "$PF_RUNTIME_MEM_WARNED" ] # nothing was warned, so nothing to suppress + [ -z "$PF_RUNTIME_MEM_WARNED" ] || return 1 # nothing was warned, so nothing to suppress } # ── Bugbot #445 r2: one threshold, one copy, no dead-end advice ─────────────── @@ -365,18 +369,18 @@ setup() { OS=Darwin _pf_host_mem_kb() { echo $((4 * 1024 * 1024)); } # 4 GB Mac: 4 − 2 reserve = 2 < 5 floor run _pf_runtime_mem_status $((2 * 1024)) - [[ "$output" == *"larger machine"* ]] + [[ "$output" == *"larger machine"* ]] || return 1 # No "give Docker N GB" dead end, and no concrete size the machine can't provide. - [[ "$output" != *"Give Docker"* ]] - [[ "$output" != *"colima start --memory"* ]] + [[ "$output" != *"Give Docker"* ]] || return 1 + [[ "$output" != *"colima start --memory"* ]] || return 1 } @test "_pf_runtime_mem_status: a host that CAN reach the floor still gets the resize remedy" { OS=Darwin _pf_host_mem_kb() { echo $((16 * 1024 * 1024)); } run _pf_runtime_mem_status $((4 * 1024)) - [[ "$output" == *"Give Docker"* ]] - [[ "$output" != *"larger machine"* ]] + [[ "$output" == *"Give Docker"* ]] || return 1 + [[ "$output" != *"larger machine"* ]] || return 1 } @test "_pf_memory + recheck: a budget preflight OK'd is never re-warned by the recheck (Bugbot #445 r2)" { @@ -391,14 +395,14 @@ setup() { error() { printf 'ERR: %s\n' "$*"; exit 1; } PF_RUNTIME_MEM_WARNED="" run _pf_memory - [[ "$output" == *"Docker's memory budget: 6 GB"* ]] - [[ "$output" != *"recommended ≥"* ]] # ticked, not warned + [[ "$output" == *"Docker's memory budget: 6 GB"* ]] || return 1 + [[ "$output" != *"recommended ≥"* ]] || return 1 # ticked, not warned # Now the recheck, same run: must be silent about the identical budget. PF_RUNTIME_MEM_WARNED="" run _pf_recheck_runtime_mem - [ "$status" -eq 0 ] - [[ "$output" != *"recommended ≥"* ]] - [[ "$output" != *"memory budget"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"recommended ≥"* ]] || return 1 + [[ "$output" != *"memory budget"* ]] || return 1 } @test "_pf_recheck_runtime_mem: healthy budget -> silent, no duplicate tick (#417)" { @@ -409,8 +413,8 @@ setup() { error() { printf 'ERR: %s\n' "$*"; exit 1; } PF_RUNTIME_MEM_WARNED="" run _pf_recheck_runtime_mem - [ "$status" -eq 0 ] - [ -z "$output" ] # quiet_ok: preflight already ticked it + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 # quiet_ok: preflight already ticked it } @test "_pf_recheck_runtime_mem: cold install carries the colima/cgroup guidance (Bugbot #445 r2)" { @@ -424,9 +428,9 @@ setup() { error() { printf 'ERR: %s\n' "$*"; exit 1; } PF_RUNTIME_MEM_WARNED="" run _pf_recheck_runtime_mem - [ "$status" -eq 0 ] - [[ "$output" == *"Docker's memory budget: 6 GB"* ]] - [[ "$output" == *"colima stop && colima start --memory"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Docker's memory budget: 6 GB"* ]] || return 1 + [[ "$output" == *"colima stop && colima start --memory"* ]] || return 1 } @test "_pf_recheck_runtime_mem: latch suppresses the DUPLICATE warn (#417)" { @@ -435,8 +439,8 @@ setup() { error() { printf 'ERR: %s\n' "$*"; exit 1; } PF_RUNTIME_MEM_WARNED=1 # preflight already reported this budget run _pf_recheck_runtime_mem - [ "$status" -eq 0 ] - [ -z "$output" ] # silent — no second warning for one condition + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 # silent — no second warning for one condition } @test "_pf_recheck_runtime_mem: the latch must NEVER gate the sub-floor HARD FAIL (#417/#513)" { @@ -447,24 +451,24 @@ setup() { error() { printf 'ERR: %s\n' "$*"; exit 1; } PF_RUNTIME_MEM_WARNED=1 # latch set — must not buy a pass run _pf_recheck_runtime_mem - [ "$status" -ne 0 ] # still hard-fails: the floor is enforced - [[ "$output" == *"below the"* ]] + [ "$status" -ne 0 ] || return 1 # still hard-fails: the floor is enforced + [[ "$output" == *"below the"* ]] || return 1 } @test "_pf_cpu: too few cores -> warn" { _pf_ncpu() { echo 1; } - run _pf_cpu; [[ "$output" == *"recommended"* ]] + run _pf_cpu; [[ "$output" == *"recommended"* ]] || return 1 } @test "_pf_cpu: enough cores -> success" { _pf_ncpu() { echo 4; } - run _pf_cpu; [[ "$output" == *"4 cores"* ]] + run _pf_cpu; [[ "$output" == *"4 cores"* ]] || return 1 } @test "_pf_cpu: between min and recommended -> warn (train), no hard fail" { _pf_ncpu() { echo 3; } - run _pf_cpu; [[ "$output" == *"recommended to train"* ]] - PF_HARD_FAIL=0; _pf_cpu >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] # CPU never hard-fails + run _pf_cpu; [[ "$output" == *"recommended to train"* ]] || return 1 + PF_HARD_FAIL=0; _pf_cpu >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 # CPU never hard-fails } # ── selectors ──────────────────────────────────────────────────────────────── @@ -474,7 +478,7 @@ setup() { # distinct truths and each caller names the one it means. Guard that it stays gone. @test "no _pf_total_mem_kb memory selector: the two truths stay separate (#417)" { f="$BATS_TEST_DIRNAME/../lib/preflight.sh" - ! grep -qE '^_pf_total_mem_kb\(\)' "$f" + ! grep -qE '^_pf_total_mem_kb\(\)' "$f" || return 1 # _pf_memory and the hardware summary must read the HOST reader, not a selector. grep -qE '_pf_host_mem_kb' "$f" } @@ -482,16 +486,16 @@ setup() { @test "_pf_ncpu: prefers runtime, falls back to host" { source "${BATS_TEST_DIRNAME}/../lib/preflight.sh" _pf_runtime_ncpu() { echo 2; }; _pf_host_ncpu() { echo 16; } - run _pf_ncpu; [ "$output" -eq 2 ] + run _pf_ncpu; [ "$output" -eq 2 ] || return 1 _pf_runtime_ncpu() { echo ""; } - run _pf_ncpu; [ "$output" -eq 16 ] + run _pf_ncpu; [ "$output" -eq 16 ] || return 1 } @test "_pf_runtime_mem_kb: junk/zero MemTotal -> empty (forces fallback)" { source "${BATS_TEST_DIRNAME}/../lib/preflight.sh" has() { return 0; } docker() { case "$*" in *MemTotal*) echo 0 ;; *) return 0 ;; esac; } - run _pf_runtime_mem_kb; [ -z "$output" ] + run _pf_runtime_mem_kb; [ -z "$output" ] || return 1 } # ── _pf_recheck_runtime_mem (post-Docker, warn-only) ───────────────────────── @@ -500,16 +504,16 @@ setup() { OS=Darwin; _pf_runtime_mem_kb() { echo $((4 * 1024 * 1024)); } # 4 GB VM < 5 GB floor error() { printf 'ERR: %s\n' "$*"; exit 1; } # real error() exits run _pf_recheck_runtime_mem - [ "$status" -ne 0 ] - [[ "$output" == *"below the ${PF_MIN_MEM_GB:-5} GB"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"below the ${PF_MIN_MEM_GB:-5} GB"* ]] || return 1 } @test "_pf_recheck_runtime_mem: between floor and warn -> warn, no hard fail (#428)" { source "${BATS_TEST_DIRNAME}/../lib/preflight.sh" OS=Linux; _pf_runtime_mem_kb() { echo $((6 * 1024 * 1024)); } # 6 GB: >=5 floor, <8 warn error() { printf 'ERR: %s\n' "$*"; exit 1; } run _pf_recheck_runtime_mem - [ "$status" -eq 0 ] - [[ "$output" == *"Docker's memory budget: 6 GB"* ]] # the ONE shared copy (#417) + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Docker's memory budget: 6 GB"* ]] || return 1 # the ONE shared copy (#417) } @test "_pf_recheck_runtime_mem: VM at the documented floor (guest a bit under) -> warn, NOT hard fail (#513 reviewer)" { source "${BATS_TEST_DIRNAME}/../lib/preflight.sh" @@ -520,9 +524,9 @@ setup() { _pf_host_mem_gb() { echo 16; } # ample host (not the host-too-small path) error() { printf 'ERR: %s\n' "$*"; exit 1; } run _pf_recheck_runtime_mem - [ "$status" -eq 0 ] # grace covers guest overhead -> no hard fail - [[ "$output" == *"Docker's memory budget"* ]] # warns instead, shared copy - [[ "$output" != *"below the"* ]] # not the hard-fail message + [ "$status" -eq 0 ] || return 1 # grace covers guest overhead -> no hard fail + [[ "$output" == *"Docker's memory budget"* ]] || return 1 # warns instead, shared copy + [[ "$output" != *"below the"* ]] || return 1 # not the hard-fail message } @test "_pf_recheck_runtime_mem: host too small -> 'use a larger machine', not a resize loop (#428 Bugbot)" { source "${BATS_TEST_DIRNAME}/../lib/preflight.sh" @@ -531,24 +535,24 @@ setup() { _pf_host_mem_gb() { echo 6; } # 6 GB Mac: 6 − 2 reserve = 4 < 5 floor error() { printf 'ERR: %s\n' "$*"; exit 1; } run _pf_recheck_runtime_mem - [ "$status" -ne 0 ] - [[ "$output" == *"too little for tracebloc"* ]] - [[ "$output" == *"larger machine"* ]] - [[ "$output" != *"colima start --memory"* ]] # no unachievable resize remedy + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"too little for tracebloc"* ]] || return 1 + [[ "$output" == *"larger machine"* ]] || return 1 + [[ "$output" != *"colima start --memory"* ]] || return 1 # no unachievable resize remedy } @test "_pf_recheck_runtime_mem: daemon not reporting -> silent no-op" { source "${BATS_TEST_DIRNAME}/../lib/preflight.sh" _pf_runtime_mem_kb() { echo ""; } - run _pf_recheck_runtime_mem; [ -z "$output" ] + run _pf_recheck_runtime_mem; [ -z "$output" ] || return 1 } # ── run_preflight orchestration ────────────────────────────────────────────── @test "run_preflight: TRACEBLOC_SKIP_PREFLIGHT -> skipped, exit 0" { export TRACEBLOC_SKIP_PREFLIGHT=1 run run_preflight - [ "$status" -eq 0 ] - [[ "$output" == *"skipped"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"skipped"* ]] || return 1 unset TRACEBLOC_SKIP_PREFLIGHT } @@ -556,26 +560,26 @@ setup() { ARCH=x86_64; OS=Linux _pf_probe_url() { case "$1" in *registry-1.docker*) echo blocked ;; *) echo ok ;; esac; } run run_preflight - [ "$status" -ne 0 ] - [[ "$output" == *"Preflight failed"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"Preflight failed"* ]] || return 1 } @test "run_preflight: healthy environment -> exit 0" { ARCH=x86_64; OS=Linux _pf_probe_url() { echo ok; } run run_preflight - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } # ── real _pf_probe_url + readers (setup() stubs them; re-source for the real ones) ── @test "_pf_probe_url: maps curl outcomes to tokens" { source "${BATS_TEST_DIRNAME}/../lib/preflight.sh" # restore the real function has() { return 0; } # 'has curl' true - curl() { return 6; }; run _pf_probe_url https://x; [ "$output" = "dns" ] - curl() { return 7; }; run _pf_probe_url https://x; [ "$output" = "refused" ] - curl() { return 28; }; run _pf_probe_url https://x; [ "$output" = "timeout" ] - curl() { return 60; }; run _pf_probe_url https://x; [ "$output" = "tls" ] - curl() { printf '200'; return 0;};run _pf_probe_url https://x; [ "$output" = "ok" ] + curl() { return 6; }; run _pf_probe_url https://x; [ "$output" = "dns" ] || return 1 + curl() { return 7; }; run _pf_probe_url https://x; [ "$output" = "refused" ] || return 1 + curl() { return 28; }; run _pf_probe_url https://x; [ "$output" = "timeout" ] || return 1 + curl() { return 60; }; run _pf_probe_url https://x; [ "$output" = "tls" ] || return 1 + curl() { printf '200'; return 0;};run _pf_probe_url https://x; [ "$output" = "ok" ] || return 1 } # strict mode (#385): content must exist — an HTTP error is a failure, not @@ -584,34 +588,34 @@ setup() { @test "_pf_probe_url: strict maps HTTP errors to 'http <code>', 2xx to ok (#385)" { source "${BATS_TEST_DIRNAME}/../lib/preflight.sh" has() { return 0; } - curl() { printf '404'; return 0; }; run _pf_probe_url https://x strict; [ "$output" = "http 404" ] - curl() { printf '200'; return 0; }; run _pf_probe_url https://x strict; [ "$output" = "ok" ] - curl() { printf '301'; return 0; }; run _pf_probe_url https://x strict; [ "$output" = "ok" ] - curl() { printf '404'; return 0; }; run _pf_probe_url https://x; [ "$output" = "ok" ] - curl() { return 6; }; run _pf_probe_url https://x strict; [ "$output" = "dns" ] + curl() { printf '404'; return 0; }; run _pf_probe_url https://x strict; [ "$output" = "http 404" ] || return 1 + curl() { printf '200'; return 0; }; run _pf_probe_url https://x strict; [ "$output" = "ok" ] || return 1 + curl() { printf '301'; return 0; }; run _pf_probe_url https://x strict; [ "$output" = "ok" ] || return 1 + curl() { printf '404'; return 0; }; run _pf_probe_url https://x; [ "$output" = "ok" ] || return 1 + curl() { return 6; }; run _pf_probe_url https://x strict; [ "$output" = "dns" ] || return 1 } @test "_pf_connectivity: chart-repo index probed strictly — 404 hard-fails preflight (#385)" { _pf_probe_url() { case "${1}|${2:-}" in *index.yaml*\|strict) echo "http 404" ;; *) echo ok ;; esac; } run _pf_connectivity - [[ "$output" == *"tracebloc Helm charts"* ]] - [[ "$output" == *"http 404"* ]] - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] + [[ "$output" == *"tracebloc Helm charts"* ]] || return 1 + [[ "$output" == *"http 404"* ]] || return 1 + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] || return 1 } @test "_pf_probe_url: missing curl -> nocurl" { source "${BATS_TEST_DIRNAME}/../lib/preflight.sh" has() { return 1; } run _pf_probe_url https://x - [ "$output" = "nocurl" ] + [ "$output" = "nocurl" ] || return 1 } @test "_pf readers return a number on this host" { source "${BATS_TEST_DIRNAME}/../lib/preflight.sh" OS="$(uname -s)" - run _pf_ncpu; [[ "$output" =~ ^[0-9]+$ ]] - run _pf_host_mem_kb; [[ "$output" =~ ^[0-9]+$ ]] - run _pf_free_kb /; [[ "$output" =~ ^[0-9]+$ ]] + run _pf_ncpu; [[ "$output" =~ ^[0-9]+$ ]] || return 1 + run _pf_host_mem_kb; [[ "$output" =~ ^[0-9]+$ ]] || return 1 + run _pf_free_kb /; [[ "$output" =~ ^[0-9]+$ ]] || return 1 } # Code review: curl absent must SKIP connectivity (curl is installed downstream), @@ -619,8 +623,8 @@ setup() { @test "_pf_connectivity: no curl -> warn + skip, not a hard fail" { has() { return 1; } run _pf_connectivity - [[ "$output" == *"Skipping connectivity"* ]] - PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] + [[ "$output" == *"Skipping connectivity"* ]] || return 1 + PF_HARD_FAIL=0; _pf_connectivity >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } # ── _pf_storage_type (network-FS guard for HOST_DATA_DIR) ──────────────────── @@ -630,66 +634,66 @@ setup() { _pf_fstype() { echo ext4; } # First-run copy: the visible line is the clean "Local storage (…)"; the fstype # detail (ext4) moved to the log, so assert the user-facing line, not the fstype. - run _pf_storage_type; [[ "$output" == *"Local storage"* ]] - PF_HARD_FAIL=0; _pf_storage_type >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] + run _pf_storage_type; [[ "$output" == *"Local storage"* ]] || return 1 + PF_HARD_FAIL=0; _pf_storage_type >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_storage_type: overlay (CI/containers) -> success, never blocked" { _pf_fstype() { echo overlay; } - PF_HARD_FAIL=0; _pf_storage_type >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] + PF_HARD_FAIL=0; _pf_storage_type >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } @test "_pf_storage_type: NFS -> hard fail with a FOLLOWABLE remedy, not the old ~/.tracebloc advice (#479)" { _pf_fstype() { echo nfs; } run _pf_storage_type - [[ "$output" == *"network filesystem (nfs)"* ]] + [[ "$output" == *"network filesystem (nfs)"* ]] || return 1 # the followable remedy (shared with early_data_dir_guard) - [[ "$output" == *"install as a user whose home is on a local disk"* ]] - [[ "$output" == *"TRACEBLOC_ALLOW_NETWORK_FS=1"* ]] + [[ "$output" == *"install as a user whose home is on a local disk"* ]] || return 1 + [[ "$output" == *"TRACEBLOC_ALLOW_NETWORK_FS=1"* ]] || return 1 # NOT the old un-followable advice: on a network home ~/.tracebloc is still NFS, # and validate_config rejects paths outside $HOME (#479). - [[ "$output" != *'HOST_DATA_DIR="$HOME/.tracebloc" ./install'* ]] - [[ "$output" != *"the default ~/.tracebloc is local"* ]] - PF_HARD_FAIL=0; _pf_storage_type >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] + [[ "$output" != *'HOST_DATA_DIR="$HOME/.tracebloc" ./install'* ]] || return 1 + [[ "$output" != *"the default ~/.tracebloc is local"* ]] || return 1 + PF_HARD_FAIL=0; _pf_storage_type >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] || return 1 } @test "_pf_storage_type and early_data_dir_guard share the same network-FS remedy (#479)" { # Both route through _pf_network_fs_remedy — capture it once and assert both callers' # remedy lines match it, so they can't drift. local remedy; remedy="$(_pf_network_fs_remedy)" - [[ "$remedy" == *"install as a user whose home is on a local disk"* ]] + [[ "$remedy" == *"install as a user whose home is on a local disk"* ]] || return 1 _pf_fstype() { echo nfs; } run _pf_storage_type - [[ "$output" == *"install as a user whose home is on a local disk"* ]] + [[ "$output" == *"install as a user whose home is on a local disk"* ]] || return 1 HOST_DATA_DIR="$BATS_TEST_TMPDIR/fresh479/.tracebloc" run early_data_dir_guard - [[ "$output" == *"install as a user whose home is on a local disk"* ]] + [[ "$output" == *"install as a user whose home is on a local disk"* ]] || return 1 } @test "_pf_storage_type: NFS4 -> hard fail" { _pf_fstype() { echo nfs4; } - PF_HARD_FAIL=0; _pf_storage_type >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] + PF_HARD_FAIL=0; _pf_storage_type >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] || return 1 } @test "_pf_storage_type: CIFS -> hard fail" { _pf_fstype() { echo cifs; } - PF_HARD_FAIL=0; _pf_storage_type >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] + PF_HARD_FAIL=0; _pf_storage_type >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] || return 1 } @test "_pf_storage_type: fuse.sshfs -> hard fail (covers fuse.* network mounts)" { _pf_fstype() { echo fuse.sshfs; } - PF_HARD_FAIL=0; _pf_storage_type >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] + PF_HARD_FAIL=0; _pf_storage_type >/dev/null 2>&1; [ "$PF_HARD_FAIL" -eq 1 ] || return 1 } @test "_pf_storage_type: NFS + TRACEBLOC_ALLOW_NETWORK_FS -> warn, no hard fail" { _pf_fstype() { echo nfs; }; export TRACEBLOC_ALLOW_NETWORK_FS=1 - run _pf_storage_type; [[ "$output" == *"proceeding"* ]] - PF_HARD_FAIL=0; _pf_storage_type >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] + run _pf_storage_type; [[ "$output" == *"proceeding"* ]] || return 1 + PF_HARD_FAIL=0; _pf_storage_type >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 unset TRACEBLOC_ALLOW_NETWORK_FS } @test "_pf_storage_type: undetermined fstype -> no hard fail (assume local)" { _pf_fstype() { echo ""; } - PF_HARD_FAIL=0; _pf_storage_type >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] + PF_HARD_FAIL=0; _pf_storage_type >/dev/null; [ "$PF_HARD_FAIL" -eq 0 ] || return 1 } # ── _pf_fstype reader (re-source for the real function) ────────────────────── @@ -698,15 +702,15 @@ setup() { has() { [[ "$1" == "findmnt" ]]; } # only findmnt 'present' findmnt() { echo NFS4; } # upper-case, ignores args run _pf_fstype "${BATS_TEST_TMPDIR}/does/not/exist/yet" - [ "$output" = "nfs4" ] + [ "$output" = "nfs4" ] || return 1 } @test "_pf_fstype: real reader on this host -> a token or empty, never crashes" { source "${BATS_TEST_DIRNAME}/../lib/preflight.sh" OS="$(uname -s)" run _pf_fstype / - [ "$status" -eq 0 ] - [[ -z "$output" || "$output" =~ ^[a-z0-9._/]+$ ]] + [ "$status" -eq 0 ] || return 1 + [[ -z "$output" || "$output" =~ ^[a-z0-9._/]+$ ]] || return 1 } # ── first-run step a: collapsed hardware summary + connectivity combined line ─ @@ -716,20 +720,20 @@ setup() { _pf_host_mem_kb() { echo $((11 * 1024 * 1024)); } _pf_free_kb() { echo $((419 * 1024 * 1024)); } run _pf_hw_summary_line - [ "$status" -eq 0 ] - [[ "$output" == *"arm64"* ]] - [[ "$output" == *"6 CPU cores"* ]] - [[ "$output" == *"11 GB memory"* ]] - [[ "$output" == *"419 GB free disk"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"arm64"* ]] || return 1 + [[ "$output" == *"6 CPU cores"* ]] || return 1 + [[ "$output" == *"11 GB memory"* ]] || return 1 + [[ "$output" == *"419 GB free disk"* ]] || return 1 } @test "_pf_connectivity: all reachable -> single combined 'Connected:' line" { _pf_probe_url() { echo ok; } run _pf_connectivity - [ "$status" -eq 0 ] - [[ "$output" == *"Connected: tracebloc.io"* ]] - [[ "$output" == *"Docker Hub (registry-1.docker.io)"* ]] - [[ "$output" == *"GitHub (ghcr.io)"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Connected: tracebloc.io"* ]] || return 1 + [[ "$output" == *"Docker Hub (registry-1.docker.io)"* ]] || return 1 + [[ "$output" == *"GitHub (ghcr.io)"* ]] || return 1 } @test "run_preflight: healthy -> collapsed step-a view, per-check ✔ lines folded away" { @@ -741,13 +745,13 @@ setup() { _pf_probe_url() { echo ok; } HOST_DATA_DIR="$HOME/.tracebloc" run run_preflight - [ "$status" -eq 0 ] - [[ "$output" == *"6 CPU cores"* ]] # collapsed hardware line - [[ "$output" == *"Connected:"* ]] # connectivity combined line - [[ "$output" == *"Local storage"* ]] # storage line + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"6 CPU cores"* ]] || return 1 # collapsed hardware line + [[ "$output" == *"Connected:"* ]] || return 1 # connectivity combined line + [[ "$output" == *"Local storage"* ]] || return 1 # storage line # the individual arch/memory ✔ lines are suppressed inside run_preflight - [[ "$output" != *"Architecture:"* ]] - [[ "$output" != *"Memory:"* ]] + [[ "$output" != *"Architecture:"* ]] || return 1 + [[ "$output" != *"Memory:"* ]] || return 1 } # ── early_data_dir_guard — pre-log network-FS refusal (#432) ───────────────── @@ -755,51 +759,51 @@ setup() { _pf_is_network_fstype nfs4 _pf_is_network_fstype cifs _pf_is_network_fstype fuse.sshfs - ! _pf_is_network_fstype ext4 - ! _pf_is_network_fstype apfs - ! _pf_is_network_fstype "" + ! _pf_is_network_fstype ext4 || return 1 + ! _pf_is_network_fstype apfs || return 1 + ! _pf_is_network_fstype "" || return 1 } @test "early_data_dir_guard: local filesystem -> silent pass" { _pf_fstype() { echo ext4; } run early_data_dir_guard - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "early_data_dir_guard: undetermined filesystem -> pass (assume local)" { _pf_fstype() { echo ""; } run early_data_dir_guard - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "early_data_dir_guard: NFS + existing data dir -> silent pass (healthy re-run reaches assess; Bugbot #441)" { _pf_fstype() { echo nfs4; } mkdir -p "$BATS_TEST_TMPDIR/existing/.tracebloc" HOST_DATA_DIR="$BATS_TEST_TMPDIR/existing/.tracebloc" run early_data_dir_guard - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "early_data_dir_guard: NFS -> refuses before any mkdir, names the fix" { _pf_fstype() { echo nfs4; } HOST_DATA_DIR="$BATS_TEST_TMPDIR/fresh/.tracebloc" run early_data_dir_guard - [ "$status" -eq 1 ] - [[ "$output" == *"network filesystem (nfs4)"* ]] - [[ "$output" == *"HOST_DATA_DIR"* ]] - [[ "$output" == *"Refusing to create the data directory"* ]] + [ "$status" -eq 1 ] || return 1 + [[ "$output" == *"network filesystem (nfs4)"* ]] || return 1 + [[ "$output" == *"HOST_DATA_DIR"* ]] || return 1 + [[ "$output" == *"Refusing to create the data directory"* ]] || return 1 # The remediation must be followable (Bugbot #441): validate_config rejects # paths outside $HOME, so the guard must not advise HOST_DATA_DIR=/local/path. - [[ "$output" != *"/local/path"* ]] - [[ "$output" == *"TRACEBLOC_ALLOW_NETWORK_FS=1"* ]] - [[ "$output" == *"local disk"* ]] + [[ "$output" != *"/local/path"* ]] || return 1 + [[ "$output" == *"TRACEBLOC_ALLOW_NETWORK_FS=1"* ]] || return 1 + [[ "$output" == *"local disk"* ]] || return 1 } @test "early_data_dir_guard: TRACEBLOC_ALLOW_NETWORK_FS defers to the full check" { _pf_fstype() { echo nfs4; } TRACEBLOC_ALLOW_NETWORK_FS=1 run early_data_dir_guard - [ "$status" -eq 0 ] - [ -z "$output" ] + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 } @test "install-k8s.sh runs the early guard before setup_log_file (#432 ordering)" { @@ -807,54 +811,54 @@ setup() { local guard_line setup_line guard_line=$(grep -n 'early_data_dir_guard' "$main_sh" | head -1 | cut -d: -f1) setup_line=$(grep -n '^ setup_log_file' "$main_sh" | head -1 | cut -d: -f1) - [ -n "$guard_line" ] - [ -n "$setup_line" ] - [ "$guard_line" -lt "$setup_line" ] + [ -n "$guard_line" ] || return 1 + [ -n "$setup_line" ] || return 1 + [ "$guard_line" -lt "$setup_line" ] || return 1 } # ── #428: memory recommendation clamp + macOS VM sizing ───────────────────── @test "_pf_clamp_mem_gb: clamps a recommendation to physical − reserve (#428)" { PF_OS_RESERVE_GB=2; PF_MIN_MEM_GB=5 - [ "$(_pf_clamp_mem_gb 16 16)" -eq 14 ] # 16 GB Mac: can't recommend 16 -> 14 - [ "$(_pf_clamp_mem_gb 16 8)" -eq 6 ] # 8 GB Mac -> 6 - [ "$(_pf_clamp_mem_gb 8 32)" -eq 8 ] # plenty of headroom -> desired unchanged + [ "$(_pf_clamp_mem_gb 16 16)" -eq 14 ] || return 1 # 16 GB Mac: can't recommend 16 -> 14 + [ "$(_pf_clamp_mem_gb 16 8)" -eq 6 ] || return 1 # 8 GB Mac -> 6 + [ "$(_pf_clamp_mem_gb 8 32)" -eq 8 ] || return 1 # plenty of headroom -> desired unchanged } @test "_pf_clamp_mem_gb: never undershoots the floor on a tiny host (#428 Bugbot)" { PF_OS_RESERVE_GB=2; PF_MIN_MEM_GB=5 # 6 GB host: physical − reserve = 4, but a hint must never say "raise to 4" (below # the 5 GB floor) — clamp up to the floor instead. - [ "$(_pf_clamp_mem_gb 8 6)" -eq 5 ] - [ "$(_pf_clamp_mem_gb 16 6)" -eq 5 ] + [ "$(_pf_clamp_mem_gb 8 6)" -eq 5 ] || return 1 + [ "$(_pf_clamp_mem_gb 16 6)" -eq 5 ] || return 1 } @test "_pf_clamp_mem_gb: non-numeric/unknown physical -> desired unchanged (can't clamp) (#428)" { # An explicit '' would hit the ${2:-host} default and read real host RAM — so test # the genuinely uncatchable cases: 0 and a non-numeric string. - [ "$(_pf_clamp_mem_gb 16 0)" -eq 16 ] - [ "$(_pf_clamp_mem_gb 16 abc)" -eq 16 ] + [ "$(_pf_clamp_mem_gb 16 0)" -eq 16 ] || return 1 + [ "$(_pf_clamp_mem_gb 16 abc)" -eq 16 ] || return 1 } @test "_macos_vm_mem_gb: derives min(half physical, clamped rec), with floor headroom (#428)" { PF_MIN_MEM_GB=5; PF_WARN_MEM_GB=8; PF_REC_MEM_GB=16; PF_OS_RESERVE_GB=2 # 8 GB: half=4 -> raised to floor+1=6 so the guest MemTotal clears the recheck floor # (sizing EXACTLY 5 would boot then hard-fail on its own choice, #428 Bugbot). - [ "$(_macos_vm_mem_gb 8)" -eq 6 ] - [ "$(_macos_vm_mem_gb 16)" -eq 8 ] # half=8, rec clamped 14 -> 8 - [ "$(_macos_vm_mem_gb 64)" -eq 16 ] # half=32, rec 16 -> 16 (capped at rec) + [ "$(_macos_vm_mem_gb 8)" -eq 6 ] || return 1 + [ "$(_macos_vm_mem_gb 16)" -eq 8 ] || return 1 # half=8, rec clamped 14 -> 8 + [ "$(_macos_vm_mem_gb 64)" -eq 16 ] || return 1 # half=32, rec 16 -> 16 (capped at rec) } @test "_macos_vm_mem_gb: too-small host -> capped at physical − reserve, not over-committed (#428 Bugbot)" { PF_MIN_MEM_GB=5; PF_WARN_MEM_GB=8; PF_REC_MEM_GB=16; PF_OS_RESERVE_GB=2 # 6 GB host: floor+1=6 would leave the OS nothing, so cap at physical − reserve = 4. # colima gets 4; the runtime recheck then stops it honestly as "host too small". - [ "$(_macos_vm_mem_gb 6)" -eq 4 ] + [ "$(_macos_vm_mem_gb 6)" -eq 4 ] || return 1 } @test "_macos_vm_mem_gb: unknown physical -> COLIMA_MEMORY default (#428)" { COLIMA_MEMORY=6 - [ "$(_macos_vm_mem_gb 0)" -eq 6 ] + [ "$(_macos_vm_mem_gb 0)" -eq 6 ] || return 1 } @test "setup-macos.sh colima memory is DERIVED via _macos_vm_mem_gb, not hard-coded 6 (#428)" { f="$BATS_TEST_DIRNAME/../lib/setup-macos.sh" grep -qE 'COLIMA_MEMORY:-\$\(_macos_vm_mem_gb\)' "$f" - ! grep -qE '\-\-memory "\$\{COLIMA_MEMORY:-6\}"' "$f" # the old hard-coded 6 is gone + ! grep -qE '\-\-memory "\$\{COLIMA_MEMORY:-6\}"' "$f" || return 1 # the old hard-coded 6 is gone } @test "_pf_recheck_runtime_mem: colima remedy uses a real resize command (#428 Bugbot)" { @@ -862,7 +866,7 @@ setup() { # sets VAR for `stop`. The correct resize is `colima stop && colima start --memory N`. f="$BATS_TEST_DIRNAME/../lib/preflight.sh" grep -qE 'colima stop && colima start --memory' "$f" - ! grep -qE 'COLIMA_MEMORY=[^ ]* colima stop' "$f" + ! grep -qE 'COLIMA_MEMORY=[^ ]* colima stop' "$f" || return 1 } @test "_pf_runtime_mem_status: sub-floor remedy quotes the SAME size the recheck hard-fails with (Bugbot #445 r3)" { @@ -944,8 +948,8 @@ setup() { _pf_host_mem_kb() { echo ""; } # unreadable host _pf_runtime_mem_kb() { echo $((6 * 1024 * 1024)); } # 6 GB VM budget run _pf_memory - [[ "$output" != *"larger machine"* ]] - [[ "$output" == *"Docker VM"* ]] + [[ "$output" != *"larger machine"* ]] || return 1 + [[ "$output" == *"Docker VM"* ]] || return 1 } @test "_pf_runtime_mem_status: a VM at exactly the warn target is not told to reach it (Bugbot #445 r4)" { @@ -956,8 +960,8 @@ setup() { local warn_eff warn_eff="$(_pf_clamp_mem_gb "$PF_WARN_MEM_GB")" run _pf_runtime_mem_status $(( warn_eff * 1024 - 124 )) - [[ "$output" != *"recommended ≥ ${warn_eff} GB"* ]] - [[ "$output" == *"budget: ${warn_eff} GB"* ]] + [[ "$output" != *"recommended ≥ ${warn_eff} GB"* ]] || return 1 + [[ "$output" == *"budget: ${warn_eff} GB"* ]] || return 1 } @test "_pf_memory: a floor-sized VM is never told it is below the floor it meets (Bugbot #445 r5)" { @@ -969,8 +973,8 @@ setup() { _pf_runtime_mem_kb() { echo ""; } _pf_avail_mem_kb() { echo $(( 8 * 1024 * 1024 )); } # plenty free: isolate the total line run _pf_memory - [[ "$output" == *"${PF_MIN_MEM_GB} GB (machine)"* ]] - [[ "$output" != *"below the ${PF_MIN_MEM_GB} GB"* ]] + [[ "$output" == *"${PF_MIN_MEM_GB} GB (machine)"* ]] || return 1 + [[ "$output" != *"below the ${PF_MIN_MEM_GB} GB"* ]] || return 1 } @test "_pf_memory + _pf_runtime_mem_status feed the predicate the same host figure (Bugbot #445 r5)" { @@ -978,7 +982,7 @@ setup() { # _pf_memory passed a grace-adjusted VM-or-host figure while the status path # passed _pf_host_mem_gb, so the two could still disagree. grep -qE '_pf_host_too_small_for_floor "\$\(_pf_host_mem_gb\)"' "$BATS_TEST_DIRNAME/../lib/preflight.sh" - ! grep -qE '_pf_host_too_small_for_floor "\$gb"' "$BATS_TEST_DIRNAME/../lib/preflight.sh" + ! grep -qE '_pf_host_too_small_for_floor "\$gb"' "$BATS_TEST_DIRNAME/../lib/preflight.sh" || return 1 } @test "memory GB is rendered through ONE converter, so no two lines can disagree (Bugbot #445 r6)" { @@ -990,10 +994,10 @@ setup() { f="$BATS_TEST_DIRNAME/../lib/preflight.sh" grep -qE '^_pf_display_gb_from_mib\(\)' "$f" for v in 'rt_gb' 'mem_gb'; do - ! grep -qE "^\s*.*${v}=\\\$\(\( .*1024 / 1024" "$f" + ! grep -qE "^\s*.*${v}=\\\$\(\( .*1024 / 1024" "$f" || return 1 done # _pf_hw_summary_line must not compute its own memory GB - ! grep -qE 'mem_gb=\$\(\( mem_kb / 1024 / 1024 \)\)' "$f" + ! grep -qE 'mem_gb=\$\(\( mem_kb / 1024 / 1024 \)\)' "$f" || return 1 } @test "_pf_hw_summary_line agrees with the memory line on the same host (Bugbot #445 r6)" { @@ -1005,8 +1009,8 @@ setup() { local mem_line="$output" run _pf_hw_summary_line # both must name the same figure - [[ "$mem_line" == *"${PF_MIN_MEM_GB} GB"* ]] - [[ "$output" == *"${PF_MIN_MEM_GB} GB memory"* ]] + [[ "$mem_line" == *"${PF_MIN_MEM_GB} GB"* ]] || return 1 + [[ "$output" == *"${PF_MIN_MEM_GB} GB memory"* ]] || return 1 } @test "_pf_recheck_runtime_mem: host-too-small applies on Linux too, matching preflight (Bugbot #445 r7)" { @@ -1019,13 +1023,162 @@ setup() { _pf_runtime_mem_kb() { echo $(( 3 * 1024 * 1024 )); } # sub-floor budget PF_RUNTIME_MEM_WARNED=1 run _pf_recheck_runtime_mem - [[ "$output" == *"larger machine"* ]] - [[ "$output" != *"Free memory (or raise the VM) to"* ]] # the impossible remedy + [[ "$output" == *"larger machine"* ]] || return 1 + [[ "$output" != *"Free memory (or raise the VM) to"* ]] || return 1 # the impossible remedy } @test "_pf_recheck_runtime_mem uses the shared too-small predicate, not inlined arithmetic (Bugbot #445 r7)" { f="$BATS_TEST_DIRNAME/../lib/preflight.sh" # the reserve arithmetic must exist in exactly one place: the predicate itself - [ "$(grep -cE 'PF_OS_RESERVE_GB \)\) -lt PF_MIN_MEM_GB|- PF_OS_RESERVE_GB < PF_MIN_MEM_GB' "$f")" -le 1 ] + [ "$(grep -cE 'PF_OS_RESERVE_GB \)\) -lt PF_MIN_MEM_GB|- PF_OS_RESERVE_GB < PF_MIN_MEM_GB' "$f")" -le 1 ] || return 1 grep -qE '_pf_host_too_small_for_floor "\$phys_gb"' "$f" } + +# ── network profile (#582) ─────────────────────────────────────────────────── +@test "_pf_proxy_hostport: strips scheme and user:pass credentials (PII)" { + run _pf_proxy_hostport "http://user:pass@proxy.corp:8080/path" + [ "$output" = "proxy.corp:8080" ] || return 1 + [[ "$output" != *"user"* ]] || return 1 +} + +@test "_pf_env_proxy: HTTPS_PROXY wins and credentials are stripped" { + HTTP_PROXY="http://h:1"; HTTPS_PROXY="http://user:secret@sproxy.corp:3128" + run _pf_env_proxy + [ "$output" = "sproxy.corp:3128" ] || return 1 + [[ "$output" != *"secret"* ]] || return 1 +} + +@test "_pf_env_proxy: empty when no proxy env is set" { + unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy + run _pf_env_proxy + [ -z "$output" ] || return 1 +} + +@test "_pf_env_ca_bundle: readable CA file returned, empty when unset" { + ca="$BATS_TEST_TMPDIR/ca.pem"; echo x > "$ca" + TRACEBLOC_CA_BUNDLE="$ca" + run _pf_env_ca_bundle + [ "$output" = "$ca" ] || return 1 + unset TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE + run _pf_env_ca_bundle + [ -z "$output" ] || return 1 +} + +@test "_pf_issuer_is_public: public CA yes, corporate re-signer no" { + run _pf_issuer_is_public "CN=DigiCert Global G2,O=DigiCert Inc" + [ "$status" -eq 0 ] || return 1 + run _pf_issuer_is_public "CN=Acme Corp Proxy CA,O=Acme Corp" + [ "$status" -ne 0 ] || return 1 +} + +@test "_pf_network_profile: direct (no proxy, no inspection) is silent" { + unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE + _pf_detect_tls_inspection() { echo "no"; } + run _pf_network_profile + [ -z "$output" ] || return 1 +} + +@test "_pf_network_profile: proxy + inspection -> one plain-language line, no creds" { + unset TRACEBLOC_CA_BUNDLE CURL_CA_BUNDLE + HTTPS_PROXY="http://u:p@proxy.corp:8080" + _pf_detect_tls_inspection() { echo "yes"; } + run _pf_network_profile + [[ "$output" == *"corporate proxy detected (proxy.corp:8080)"* ]] || return 1 + [[ "$output" == *"TLS inspection detected"* ]] || return 1 + [[ "$output" != *"u:p"* ]] || return 1 +} + +@test "_pf_network_profile: a configured CA bundle is announced" { + ca="$BATS_TEST_TMPDIR/ca.pem"; echo x > "$ca" + HTTPS_PROXY="http://proxy.corp:8080"; TRACEBLOC_CA_BUNDLE="$ca" + _pf_detect_tls_inspection() { echo "yes"; } + run _pf_network_profile + [[ "$output" == *"your company's certificate is configured"* ]] || return 1 +} + +@test "_pf_detect_tls_inspection: unknown when openssl is unavailable (never hangs)" { + # Source fresh so we exercise the REAL probe, not setup()'s hermetic stub. + run bash -c ' + source "'"$BATS_TEST_DIRNAME"'/../lib/common.sh" 2>/dev/null || true + source "'"$BATS_TEST_DIRNAME"'/../lib/preflight.sh" + has() { [[ "$1" != "openssl" ]]; } # openssl absent + _pf_detect_tls_inspection + ' + [ "$output" = "unknown" ] || return 1 +} + +@test "_pf_urldecode: percent-decodes (parity with PS UnescapeDataString)" { + run _pf_urldecode "p%40ss%3Aword" + [ "$output" = "p@ss:word" ] || return 1 +} + +@test "_pf_env_proxy_raw: preserves credentials (probe connection only, never displayed)" { + HTTPS_PROXY="http://user:secret@px.corp:3128" + run _pf_env_proxy_raw + [ "$output" = "http://user:secret@px.corp:3128" ] || return 1 + # display path still strips (the two must not be confused) + run _pf_env_proxy + [ "$output" = "px.corp:3128" ] || return 1 +} + +@test "_pf_detect_tls_inspection: auth proxy -> creds to openssl via env:, never argv (Bugbot)" { + # An authenticated proxy needs credentials on the CONNECT, else it 407s and + # inspection reads as a false 'unknown'. The password must travel via env: (openssl + # reads $_TB_PROXY_PASS), NEVER argv/ps. Sourced fresh to exercise the real probe. + cap="$BATS_TEST_TMPDIR/args" + run bash -c ' + source "'"$BATS_TEST_DIRNAME"'/../lib/common.sh" 2>/dev/null || true + source "'"$BATS_TEST_DIRNAME"'/../lib/preflight.sh" + export HTTPS_PROXY="http://user:secret@px.corp:3128" + has() { return 0; } + _bounded() { shift; "$@"; } + openssl() { + if [[ "$1" == "s_client" && "$*" == *"-help"* ]]; then echo " -proxy_user val"; return 0; fi + if [[ "$1" == "s_client" ]]; then printf "%s\n" "$@" > "'"$cap"'"; echo "-----BEGIN CERTIFICATE-----"; return 0; fi + if [[ "$1" == "x509" ]]; then echo "issuer=CN=Acme Corp Proxy CA"; return 0; fi + } + _pf_detect_tls_inspection + ' + [ "$output" = "yes" ] || return 1 # Acme = corporate re-signer + grep -q -- '-proxy_user' "$cap" # username passed to the connect + grep -q 'env:_TB_PROXY_PASS' "$cap" # password by env reference, not literal + ! grep -q 'secret' "$cap" || return 1 # password NEVER in openssl argv + [[ "$output" != *"secret"* ]] || return 1 # nor in the result +} + +@test "_pf_detect_tls_inspection: username-only proxy doesn't reuse the username as password (Bugbot)" { + cap="$BATS_TEST_TMPDIR/args-uo" + run bash -c ' + source "'"$BATS_TEST_DIRNAME"'/../lib/common.sh" 2>/dev/null || true + source "'"$BATS_TEST_DIRNAME"'/../lib/preflight.sh" + export HTTPS_PROXY="http://onlyuser@px.corp:3128" + has() { return 0; } + _bounded() { shift; "$@"; } + openssl() { + if [[ "$1" == "s_client" && "$*" == *"-help"* ]]; then echo " -proxy_user val"; return 0; fi + if [[ "$1" == "s_client" ]]; then printf "%s\n" "$@" > "'"$cap"'"; echo "-----BEGIN CERTIFICATE-----"; return 0; fi + if [[ "$1" == "x509" ]]; then echo "issuer=CN=Acme"; return 0; fi + } + _pf_detect_tls_inspection + ' + grep -q -- '-proxy_user' "$cap" + # username must appear exactly once (as -proxy_user), never reused as the password + [ "$(grep -c 'onlyuser' "$cap")" -eq 1 ] || return 1 +} + +# ── registry-block detection + guidance (#585) ─────────────────────────────── +@test "_pf_connectivity: blocked container registry -> mirror/offline guidance (#585)" { + _pf_probe_url() { case "$1" in *registry-1.docker*|*auth.docker*|*ghcr.io*) echo blocked ;; *) echo ok ;; esac; } + run _pf_connectivity + [[ "$output" == *"container registries"* ]] || return 1 + [[ "$output" == *"mirror"* ]] || return 1 + [[ "$output" == *"docs/INSTALL.md"* ]] || return 1 +} + +@test "_pf_connectivity: a non-registry host blocked does NOT trigger the registry guidance (#585)" { + # Only the backend API host fails; the registries are reachable -> generic egress + # hint only, no registry-mirror guidance. + _pf_probe_url() { case "$1" in *api.tracebloc.io*|*dev-api*|*stg-api*) echo blocked ;; *) echo ok ;; esac; } + run _pf_connectivity + [[ "$output" != *"container registries"* ]] || return 1 +} diff --git a/scripts/tests/probe.bats b/scripts/tests/probe.bats index df6c23d9..8df4fe09 100644 --- a/scripts/tests/probe.bats +++ b/scripts/tests/probe.bats @@ -44,49 +44,49 @@ setup() { @test "classify: usable runtime => Tier 0" { OS=Linux; PROBE_RUNTIME_USABLE=1; PROBE_CGROUP2=0; PROBE_USERNS=0 _classify_from_probes - [ "$INSTALL_TIER" = 0 ] - [ "$INSTALL_TIER_REASON" = runtime-usable ] + [ "$INSTALL_TIER" = 0 ] || return 1 + [ "$INSTALL_TIER_REASON" = runtime-usable ] || return 1 } @test "classify: runtime wins even on a non-rootless kernel" { OS=Linux; PROBE_RUNTIME_USABLE=1; PROBE_CGROUP2=0; PROBE_USERNS=0 _classify_from_probes - [ "$INSTALL_TIER" = 0 ] + [ "$INSTALL_TIER" = 0 ] || return 1 } @test "classify: Linux, no runtime, rootless-capable => Tier 1" { OS=Linux; PROBE_RUNTIME_USABLE=0; PROBE_CGROUP2=1; PROBE_USERNS=1 _classify_from_probes - [ "$INSTALL_TIER" = 1 ] - [ "$INSTALL_TIER_REASON" = rootless-capable ] + [ "$INSTALL_TIER" = 1 ] || return 1 + [ "$INSTALL_TIER_REASON" = rootless-capable ] || return 1 } @test "classify: Linux, userns disabled => Tier 2 (no-userns)" { OS=Linux; PROBE_RUNTIME_USABLE=0; PROBE_CGROUP2=1; PROBE_USERNS=0 _classify_from_probes - [ "$INSTALL_TIER" = 2 ] - [ "$INSTALL_TIER_REASON" = no-userns ] + [ "$INSTALL_TIER" = 2 ] || return 1 + [ "$INSTALL_TIER_REASON" = no-userns ] || return 1 } @test "classify: Linux, no cgroup v2 => Tier 2 (no-cgroup2)" { OS=Linux; PROBE_RUNTIME_USABLE=0; PROBE_CGROUP2=0; PROBE_USERNS=1 _classify_from_probes - [ "$INSTALL_TIER" = 2 ] - [ "$INSTALL_TIER_REASON" = no-cgroup2 ] + [ "$INSTALL_TIER" = 2 ] || return 1 + [ "$INSTALL_TIER_REASON" = no-cgroup2 ] || return 1 } @test "classify: macOS, no runtime => Tier 2 (needs-docker-desktop)" { OS=Darwin; PROBE_RUNTIME_USABLE=0 _classify_from_probes - [ "$INSTALL_TIER" = 2 ] - [ "$INSTALL_TIER_REASON" = needs-docker-desktop ] + [ "$INSTALL_TIER" = 2 ] || return 1 + [ "$INSTALL_TIER_REASON" = needs-docker-desktop ] || return 1 } @test "classify: other non-Linux (Git Bash/MINGW) => Tier 2 (unsupported-os), not Docker Desktop (#370)" { OS="MINGW64_NT-10.0"; PROBE_RUNTIME_USABLE=0 _classify_from_probes - [ "$INSTALL_TIER" = 2 ] - [ "$INSTALL_TIER_REASON" = unsupported-os ] + [ "$INSTALL_TIER" = 2 ] || return 1 + [ "$INSTALL_TIER_REASON" = unsupported-os ] || return 1 } # ── _probe_privilege: the four postures ────────────────────────────────────── @@ -94,7 +94,7 @@ setup() { @test "privilege: uid 0 => root" { id() { echo 0; } run _probe_privilege - [ "$output" = root ] + [ "$output" = root ] || return 1 } @test "privilege: not root, sudo absent => no_sudo" { @@ -102,7 +102,7 @@ setup() { # No real sudo binary — even if the A2 sudo() shadow is defined (Bugbot #372). _have_sudo_bin() { return 1; } run _probe_privilege - [ "$output" = no_sudo ] + [ "$output" = no_sudo ] || return 1 } @test "privilege: not root, passwordless sudo => sudo_nopw" { @@ -110,7 +110,7 @@ setup() { _have_sudo_bin() { return 0; } _real_sudo() { return 0; } run _probe_privilege - [ "$output" = sudo_nopw ] + [ "$output" = sudo_nopw ] || return 1 } @test "privilege: not root, sudo needs a password => sudo_pw" { @@ -118,7 +118,7 @@ setup() { _have_sudo_bin() { return 0; } _real_sudo() { return 1; } run _probe_privilege - [ "$output" = sudo_pw ] + [ "$output" = sudo_pw ] || return 1 } # ── _probe_subid_ranges / _probe_uidmap_helpers (rootless prereqs, #1220) ───── @@ -147,14 +147,14 @@ setup() { printf 'testuser:100000:65536\n' >"$TB_SUBUID_FILE" : >"$TB_SUBGID_FILE" run _probe_subid_ranges - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } @test "subid: both files empty => not present" { USER=testuser; id() { [ "$1" = "-un" ] && echo testuser || echo 1000; } TB_SUBUID_FILE="$(mktemp)"; TB_SUBGID_FILE="$(mktemp)" run _probe_subid_ranges - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } @test "subid: keyed off id -un not \$USER — su/cron divergence can't false-positive (#458)" { @@ -165,7 +165,7 @@ setup() { printf 'alice:100000:65536\n' >"$TB_SUBUID_FILE" printf 'alice:100000:65536\n' >"$TB_SUBGID_FILE" run _probe_subid_ranges - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } @test "subid: a name that is a substring of another entry does NOT false-positive" { @@ -174,7 +174,7 @@ setup() { printf 'testuser:100000:65536\n' >"$TB_SUBUID_FILE" printf 'testuser:100000:65536\n' >"$TB_SUBGID_FILE" run _probe_subid_ranges - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } # NOTE: these clobber PATH to $bin for a hermetic probe (hide any system newuidmap), @@ -204,7 +204,7 @@ setup() { chmod +x "$bin/newuidmap" "$bin/newgidmap" # Both report cap_setuid; newgidmap actually needs cap_setgid, so it must be rejected. getcap() { printf '%s cap_setuid=ep\n' "$1"; } - ! ( PATH="$bin"; _probe_uidmap_helpers ) + ! ( PATH="$bin"; _probe_uidmap_helpers ) || return 1 } @test "uidmap: present with neither setuid bit nor cap_setuid => not satisfied" { @@ -212,13 +212,13 @@ setup() { : >"$bin/newuidmap"; : >"$bin/newgidmap" chmod +x "$bin/newuidmap" "$bin/newgidmap" getcap() { printf '%s =\n' "$1"; } # getcap present, no caps - ! ( PATH="$bin"; _probe_uidmap_helpers ) + ! ( PATH="$bin"; _probe_uidmap_helpers ) || return 1 } @test "uidmap: one helper missing => not satisfied" { bin="$(mktemp -d)" : >"$bin/newuidmap"; chmod u+s "$bin/newuidmap" # newgidmap absent - ! ( PATH="$bin"; _probe_uidmap_helpers ) + ! ( PATH="$bin"; _probe_uidmap_helpers ) || return 1 } @test "subid probes: side-effect-free — fixtures unchanged after run_host_probes" { @@ -233,9 +233,9 @@ setup() { _probe_privilege() { echo no_sudo; } _probe_uidmap_helpers() { return 0; } run_host_probes - [ "$PROBE_SUBID" = 1 ] - [ "$(cat "$TB_SUBUID_FILE")" = "$before_u" ] - [ "$(cat "$TB_SUBGID_FILE")" = "$before_g" ] + [ "$PROBE_SUBID" = 1 ] || return 1 + [ "$(cat "$TB_SUBUID_FILE")" = "$before_u" ] || return 1 + [ "$(cat "$TB_SUBGID_FILE")" = "$before_g" ] || return 1 } # ── read-only guarantee ─────────────────────────────────────────────────────── @@ -248,7 +248,7 @@ setup() { run_host_probes refute_has "docker run" "$(mock_calls)" refute_has "docker pull" "$(mock_calls)" - [ "$INSTALL_TIER" = 0 ] # docker info OK => Tier 0 + [ "$INSTALL_TIER" = 0 ] || return 1 # docker info OK => Tier 0 } # The default-path daemon check must be bounded so a wedged Docker can't hang a @@ -273,7 +273,7 @@ setup() { has() { case "$1" in docker) return 0 ;; timeout|gtimeout) return 1 ;; *) return 1 ;; esac; } docker() { return 1; } # daemon unreachable, or timeout killed it (124) run _probe_runtime_usable - [ "$status" -ne 0 ] # "not usable" — no error thrown + [ "$status" -ne 0 ] || return 1 # "not usable" — no error thrown } @test "verify probe pulls only when --verify is set" { @@ -293,8 +293,8 @@ setup() { id() { echo 1000; } has() { case "$1" in docker) return 0 ;; sudo) return 1 ;; *) command -v "$1" >/dev/null 2>&1 ;; esac; } run_host_probes - [ "$PROBE_RUNTIME_USABLE" = 0 ] # docker info OK but `docker run` failed => not usable - [ "$INSTALL_TIER" != 0 ] # so NOT Tier 0 + [ "$PROBE_RUNTIME_USABLE" = 0 ] || return 1 # docker info OK but `docker run` failed => not usable + [ "$INSTALL_TIER" != 0 ] || return 1 # so NOT Tier 0 } # ── render_host_audit: the panel ────────────────────────────────────────────── @@ -361,7 +361,7 @@ setup() { TB_OSRELEASE_FILE="$(mktemp)"; printf '6.8.0-generic\n' > "$TB_OSRELEASE_FILE" TB_PROC_VERSION_FILE="$(mktemp)"; printf 'Linux version 6.8.0-generic (gcc 13)\n' > "$TB_PROC_VERSION_FILE" run _probe_wsl - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } @test "run_host_probes: sets PROBE_WSL=1 inside WSL2 (Linux)" { @@ -373,7 +373,7 @@ setup() { _probe_uidmap_helpers() { return 0; } _probe_privilege() { echo no_sudo; } run_host_probes - [ "$PROBE_WSL" = "1" ] + [ "$PROBE_WSL" = "1" ] || return 1 } # ── audit: WSL2-aware rows/messages (#1179) ─────────────────────────────────── diff --git a/scripts/tests/provision.bats b/scripts/tests/provision.bats index c5da4cb9..4f4f90ca 100644 --- a/scripts/tests/provision.bats +++ b/scripts/tests/provision.bats @@ -56,17 +56,17 @@ _stub_tracebloc() { export TRACEBLOC_CLIENT_ID=abc TRACEBLOC_CLIENT_PASSWORD=xyz tracebloc() { echo "TRACEBLOC $*"; } # must NOT be called for login/create run provision_client - [ "$status" -eq 0 ] - [[ "$output" == *"skipping browser sign-in"* ]] - [[ "$output" != *"TRACEBLOC login"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"skipping browser sign-in"* ]] || return 1 + [[ "$output" != *"TRACEBLOC login"* ]] || return 1 } @test "provision_client: dual-mode (values file) skips browser sign-in" { export TRACEBLOC_VALUES_FILE=/tmp/values.yaml tracebloc() { echo "TRACEBLOC $*"; } run provision_client - [ "$status" -eq 0 ] - [[ "$output" == *"skipping browser sign-in"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"skipping browser sign-in"* ]] || return 1 } @test "provision_client: a CLI too old to provision falls back to manual sign-in (not fatal)" { @@ -75,19 +75,19 @@ _stub_tracebloc() { # install_client_helm collect credentials, NOT hard-fail on `tracebloc login`. tracebloc() { case "$1" in login|client) return 1 ;; *) return 0 ;; esac; } run provision_client - [ "$status" -eq 0 ] - [[ "$output" == *"falling back to manual sign-in"* ]] - [[ "$output" != *"approve this machine in your browser"* ]] # never entered the login flow + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"falling back to manual sign-in"* ]] || return 1 + [[ "$output" != *"approve this machine in your browser"* ]] || return 1 # never entered the login flow } @test "provision_client: mint hands id+password+namespace to Helm" { _stub_tracebloc 'TRACEBLOC_CLIENT_ID=5\nTRACEBLOC_CLIENT_PASSWORD=pw9\nTB_NAMESPACE=my-ns\n' provision_client # called directly so exports persist - [ "$TRACEBLOC_CLIENT_ID" = "5" ] - [ "$TRACEBLOC_CLIENT_PASSWORD" = "pw9" ] - [ "$TB_NAMESPACE" = "my-ns" ] + [ "$TRACEBLOC_CLIENT_ID" = "5" ] || return 1 + [ "$TRACEBLOC_CLIENT_PASSWORD" = "pw9" ] || return 1 + [ "$TB_NAMESPACE" = "my-ns" ] || return 1 # the credential file is transient — removed after sourcing - [ ! -f "${HOST_DATA_DIR}/client-credential.env" ] + [ ! -f "${HOST_DATA_DIR}/client-credential.env" ] || return 1 } @test "provision_client: a stale TRACEBLOC_CLIENT_ADOPTED in the env does not misroute a mint" { @@ -95,26 +95,26 @@ _stub_tracebloc() { _stub_tracebloc 'TRACEBLOC_CLIENT_ID=7\nTRACEBLOC_CLIENT_PASSWORD=pw\nTB_NAMESPACE=mns\n' # mint: no ADOPTED line provision_client # mint path must win: the credential is handed to Helm, not dropped as if adopted - [ "$TRACEBLOC_CLIENT_ID" = "7" ] - [ "$TRACEBLOC_CLIENT_PASSWORD" = "pw" ] - [ "$TB_NAMESPACE" = "mns" ] + [ "$TRACEBLOC_CLIENT_ID" = "7" ] || return 1 + [ "$TRACEBLOC_CLIENT_PASSWORD" = "pw" ] || return 1 + [ "$TB_NAMESPACE" = "mns" ] || return 1 } @test "provision_client: adopt hands only the namespace (no password)" { _stub_tracebloc 'TRACEBLOC_CLIENT_ID=8\nTB_NAMESPACE=ex-ns\nTRACEBLOC_CLIENT_ADOPTED=1\n' provision_client - [ "$TB_NAMESPACE" = "ex-ns" ] - [ -z "${TRACEBLOC_CLIENT_PASSWORD:-}" ] # no fresh credential on adopt - [ "$TRACEBLOC_CLIENT_ID" = "8" ] # adopted id kept → Step 5 heals the release's clientId to it - [ "$TRACEBLOC_CLIENT_ADOPTED" = "1" ] # marker kept → Step 5 takes the reconcile branch + [ "$TB_NAMESPACE" = "ex-ns" ] || return 1 + [ -z "${TRACEBLOC_CLIENT_PASSWORD:-}" ] || return 1 # no fresh credential on adopt + [ "$TRACEBLOC_CLIENT_ID" = "8" ] || return 1 # adopted id kept → Step 5 heals the release's clientId to it + [ "$TRACEBLOC_CLIENT_ADOPTED" = "1" ] || return 1 # marker kept → Step 5 takes the reconcile branch } @test "provision_client: missing CLI after install is fatal" { has() { return 1; } # CLI not resolvable after install tracebloc() { return 0; } run provision_client - [ "$status" -ne 0 ] - [[ "$output" == *"tracebloc CLI is required"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"tracebloc CLI is required"* ]] || return 1 } @test "provision_client: failed sign-in is fatal" { @@ -122,15 +122,15 @@ _stub_tracebloc() { # sign-in fails — that must still be fatal, not a silent fall-through. tracebloc() { [[ "$*" == *--help ]] && return 0; [ "$1" = "login" ] && return 1; return 0; } run provision_client - [ "$status" -ne 0 ] - [[ "$output" == *"Sign-in didn't complete"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"Sign-in didn't complete"* ]] || return 1 } @test "provision_client: client create writing no credential file is fatal" { tracebloc() { return 0; } # login OK, create "succeeds" but writes nothing run provision_client - [ "$status" -ne 0 ] - [[ "$output" == *"did not write the credential file"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"did not write the credential file"* ]] || return 1 } @test "provision_client: a failed client create leaves no credential file behind" { @@ -144,8 +144,8 @@ _stub_tracebloc() { return 1 } run provision_client - [ "$status" -ne 0 ] - [ ! -f "${HOST_DATA_DIR}/client-credential.env" ] + [ "$status" -ne 0 ] || return 1 + [ ! -f "${HOST_DATA_DIR}/client-credential.env" ] || return 1 } @test "provision_client: mint passes --name (+ --location) through to client create" { @@ -156,8 +156,8 @@ _stub_tracebloc() { _stub_tracebloc 'TRACEBLOC_CLIENT_ID=1\nTRACEBLOC_CLIENT_PASSWORD=p\nTB_NAMESPACE=ns\n' provision_client run cat "$CREATE_ARGS_FILE" - [[ "$output" == *"--name lab box 3"* ]] - [[ "$output" == *"--location DE"* ]] + [[ "$output" == *"--name lab box 3"* ]] || return 1 + [[ "$output" == *"--location DE"* ]] || return 1 } @test "provision_client: refuses BEFORE minting when a foreign client already runs here (#303)" { @@ -177,10 +177,10 @@ _stub_tracebloc() { return 0 } run provision_client - [ "$status" -ne 0 ] - [[ "$output" == *"isn't in the account you just signed in as"* ]] - [[ "$output" == *"Refusing to provision a second client"* ]] - [ ! -s "$CREATE_ARGS_FILE" ] # create was never called — no orphan minted + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"isn't in the account you just signed in as"* ]] || return 1 + [[ "$output" == *"Refusing to provision a second client"* ]] || return 1 + [ ! -s "$CREATE_ARGS_FILE" ] || return 1 # create was never called — no orphan minted } @test "provision_client: unknown helm state (list failed) refuses BEFORE minting — no orphan (#303)" { @@ -196,9 +196,9 @@ _stub_tracebloc() { return 0 } run provision_client - [ "$status" -ne 0 ] - [[ "$output" == *"Couldn't determine whether a tracebloc client is already installed"* ]] - [ ! -s "$CREATE_ARGS_FILE" ] # create never ran — no orphan minted + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"Couldn't determine whether a tracebloc client is already installed"* ]] || return 1 + [ ! -s "$CREATE_ARGS_FILE" ] || return 1 # create never ran — no orphan minted } @test "provision_client: same-account re-run with a local client still provisions (adopt path intact) (#303)" { @@ -216,8 +216,8 @@ _stub_tracebloc() { return 0 } provision_client - [ "$TB_NAMESPACE" = "my-ns" ] - [ "$TRACEBLOC_CLIENT_ADOPTED" = "1" ] # adopt path reached, not refused + [ "$TB_NAMESPACE" = "my-ns" ] || return 1 + [ "$TRACEBLOC_CLIENT_ADOPTED" = "1" ] || return 1 # adopt path reached, not refused } @test "provision_client: an unreadable client list falls through to create, not a refusal (#303)" { @@ -233,8 +233,8 @@ _stub_tracebloc() { return 0 } provision_client - [ "$TRACEBLOC_CLIENT_ID" = "9" ] # create ran despite the inconclusive list - [ "$TB_NAMESPACE" = "fresh-ns" ] + [ "$TRACEBLOC_CLIENT_ID" = "9" ] || return 1 # create ran despite the inconclusive list + [ "$TB_NAMESPACE" = "fresh-ns" ] || return 1 } @test "provision_client: legacy 'tracebloc' namespace absent from the account is NOT refused — defers to create + guard (#306 Bugbot)" { @@ -255,7 +255,7 @@ _stub_tracebloc() { return 0 } provision_client # must NOT refuse (a direct call fails the test if error exits) - [ "$TRACEBLOC_CLIENT_ID" = "9" ] # deferred to create, which ran + [ "$TRACEBLOC_CLIENT_ID" = "9" ] || return 1 # deferred to create, which ran } @test "provision_client: no name and no TTY to prompt is fatal (can't provision blind)" { @@ -263,10 +263,10 @@ _stub_tracebloc() { _prompt_tty() { return 1; } # non-interactive: no terminal to prompt on _stub_tracebloc 'TRACEBLOC_CLIENT_ID=1\nTRACEBLOC_CLIENT_PASSWORD=p\nTB_NAMESPACE=ns\n' run provision_client - [ "$status" -ne 0 ] - [[ "$output" == *"name for this client is required"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"name for this client is required"* ]] || return 1 # and it must not have called client create (no argv recorded) - [ ! -s "$CREATE_ARGS_FILE" ] + [ ! -s "$CREATE_ARGS_FILE" ] || return 1 } @test "provision_client: type-ahead blank lines are re-prompted, not accepted as the name (2026-07-09)" { @@ -280,9 +280,9 @@ _stub_tracebloc() { TB_TTY=/dev/stdin _stub_tracebloc 'TRACEBLOC_CLIENT_ID=1\nTRACEBLOC_CLIENT_PASSWORD=p\nTB_NAMESPACE=ns\n' run provision_client <<< $'\n\nMyBox\n' # two type-ahead blanks, then the real name - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run cat "$CREATE_ARGS_FILE" - [[ "$output" == *"--name MyBox"* ]] # the real name survived the stray blanks + [[ "$output" == *"--name MyBox"* ]] || return 1 # the real name survived the stray blanks } @test "provision_client: a dead input tty (EOF, no keystrokes) fails fast with the actionable error" { @@ -295,9 +295,9 @@ _stub_tracebloc() { TB_TTY=/dev/stdin _stub_tracebloc 'TRACEBLOC_CLIENT_ID=1\nTRACEBLOC_CLIENT_PASSWORD=p\nTB_NAMESPACE=ns\n' run provision_client </dev/null # read returns EOF immediately - [ "$status" -ne 0 ] - [[ "$output" == *"name for this client is required"* ]] - [ ! -s "$CREATE_ARGS_FILE" ] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"name for this client is required"* ]] || return 1 + [ ! -s "$CREATE_ARGS_FILE" ] || return 1 } @test "provision_client: interactive install auto-derives location from the timezone — never prompts (#354)" { @@ -311,11 +311,11 @@ _stub_tracebloc() { _detect_location_zone() { printf 'FR Europe/Paris\n'; } # detection succeeds _stub_tracebloc 'TRACEBLOC_CLIENT_ID=1\nTRACEBLOC_CLIENT_PASSWORD=p\nTB_NAMESPACE=ns\n' run provision_client <<< $'MyBox\n' - [ "$status" -eq 0 ] - [[ "$output" != *"carbon reporting"* ]] # the location prompt is gone + [ "$status" -eq 0 ] || return 1 + [[ "$output" != *"carbon reporting"* ]] || return 1 # the location prompt is gone run cat "$CREATE_ARGS_FILE" - [[ "$output" == *"--name MyBox"* ]] - [[ "$output" == *"--location FR"* ]] # zone came from the timezone, silently + [[ "$output" == *"--name MyBox"* ]] || return 1 + [[ "$output" == *"--location FR"* ]] || return 1 # zone came from the timezone, silently } @test "provision_client: interactive install with no detectable zone provisions with NO location — never prompts (#354)" { @@ -328,12 +328,12 @@ _stub_tracebloc() { _detect_location_zone() { return 0; } # nothing detected _stub_tracebloc 'TRACEBLOC_CLIENT_ID=1\nTRACEBLOC_CLIENT_PASSWORD=p\nTB_NAMESPACE=ns\n' run provision_client <<< $'MyBox\n' - [ "$status" -eq 0 ] # no location is not fatal anymore - [[ "$output" != *"carbon reporting"* ]] # never prompted - [[ "$output" != *"location zone is required"* ]] + [ "$status" -eq 0 ] || return 1 # no location is not fatal anymore + [[ "$output" != *"carbon reporting"* ]] || return 1 # never prompted + [[ "$output" != *"location zone is required"* ]] || return 1 run cat "$CREATE_ARGS_FILE" - [[ "$output" == *"--name MyBox"* ]] - [[ "$output" != *"--location"* ]] # provisioned with no location + [[ "$output" == *"--name MyBox"* ]] || return 1 + [[ "$output" != *"--location"* ]] || return 1 # provisioned with no location } # ── cli#141: the #303 pre-flight's grep contract with `tracebloc client list` ── @@ -374,23 +374,23 @@ _stub_client_list_plain() { # Owned → 0 (provision proceeds to create, which adopts). run _account_owns_namespace "acme-prod-01" - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 # List read OK but the namespace is absent → 1 (the FOREIGN-client refuse signal). run _account_owns_namespace "acme-prod-99" - [ "$status" -eq 1 ] + [ "$status" -eq 1 ] || return 1 # Strict prefix must NOT match: after "acme-prod-0" comes "1", not whitespace/EOL, # so the account does not "own" acme-prod-0 just because it owns acme-prod-01. # This pins the ([[:space:]]|$) anchor the installer's grep depends on — without # it a prefix collision would silently mis-classify ownership. run _account_owns_namespace "acme-prod-0" - [ "$status" -eq 1 ] + [ "$status" -eq 1 ] || return 1 # The pre-flight MUST invoke the hidden list with --plain (the #141 output # contract). grep gives a real exit code, so this holds on bash 3.2 too. run grep -qF -- '--plain' "$LIST_ARGV_FILE" - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "cli#141: _account_owns_namespace reports 'couldn't read the list' as rc 2, distinct from 'absent' rc 1" { @@ -399,7 +399,7 @@ _stub_client_list_plain() { # transient blip — the distinction the #303 pre-flight branches on. tracebloc() { if [ "$1" = "client" ] && [ "$2" = "list" ]; then return 7; fi; return 0; } run _account_owns_namespace "any-ns" - [ "$status" -eq 2 ] + [ "$status" -eq 2 ] || return 1 } # ── _report_create_failure: rejected-zone hint names the real source (Bugbot #356) ── @@ -411,24 +411,24 @@ _stub_client_list_plain() { local out; out="$(mktemp)" printf '%s\n' "Error: location: 'ZZ' is not a valid choice." > "$out" run _report_create_failure "$out" "ZZ" "env" - [ "$status" -eq 0 ] - [[ "$output" == *"came from TRACEBLOC_CLIENT_LOCATION"* ]] - [[ "$output" != *"auto-derived from this machine's timezone"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"came from TRACEBLOC_CLIENT_LOCATION"* ]] || return 1 + [[ "$output" != *"auto-derived from this machine's timezone"* ]] || return 1 } @test "bugbot#356: rejected auto-derived zone still blames the timezone and offers the override" { local out; out="$(mktemp)" printf '%s\n' "Error: location: 'XX' is not a valid choice." > "$out" run _report_create_failure "$out" "XX" "auto" - [ "$status" -eq 0 ] - [[ "$output" == *"auto-derived from this machine's timezone"* ]] - [[ "$output" == *"TRACEBLOC_CLIENT_LOCATION"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"auto-derived from this machine's timezone"* ]] || return 1 + [[ "$output" == *"TRACEBLOC_CLIENT_LOCATION"* ]] || return 1 } @test "bugbot#356: source defaults to auto when the caller omits it" { local out; out="$(mktemp)" printf '%s\n' "Error: location: 'XX' is not a valid choice." > "$out" run _report_create_failure "$out" "XX" - [ "$status" -eq 0 ] - [[ "$output" == *"auto-derived from this machine's timezone"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"auto-derived from this machine's timezone"* ]] || return 1 } diff --git a/scripts/tests/setup-linux.bats b/scripts/tests/setup-linux.bats index 7b9df538..16cd6ef2 100644 --- a/scripts/tests/setup-linux.bats +++ b/scripts/tests/setup-linux.bats @@ -5,6 +5,9 @@ load test_helper setup() { load_lib setup-linux.sh + # Fetch-test curl mocks write tiny fixture files; relax the #607 size floor + # so _assert_download_size does not reject them (real floor applies in prod). + export TB_MIN_DOWNLOAD_BYTES=0 MOCK_CALLS="$(mktemp)" PRESENT_CMDS="curl conntrack" TEST_DISTRO=ubuntu @@ -13,6 +16,9 @@ setup() { has() { case " $PRESENT_CMDS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } spin_cmd() { record "$*"; return 0; } + # Same default as spin_cmd: record + succeed. Tests that need the deadline + # semantics (rc 124) override it locally. + spin_cmd_bounded() { record "spin_cmd_bounded $*"; return 0; } sudo() { record "sudo $*"; return 0; } systemctl() { return 0; } usermod() { return 0; } @@ -50,7 +56,7 @@ setup() { @test "setup_pm: apt-get detected" { PRESENT_CMDS="apt-get" setup_pm - [[ "$PM_INSTALL" == *"apt-get install"* ]] + [[ "$PM_INSTALL" == *"apt-get install"* ]] || return 1 } # Ubuntu 22.04+ needrestart opens a hidden "restart services?" prompt under # spin_cmd that `-y` doesn't suppress → the install hangs. apt must be fully @@ -58,28 +64,28 @@ setup() { @test "setup_pm: apt is non-interactive (needrestart/debconf guard)" { PRESENT_CMDS="apt-get" setup_pm - [[ "$PM_INSTALL" == *"DEBIAN_FRONTEND=noninteractive"* ]] - [[ "$PM_INSTALL" == *"NEEDRESTART_MODE=a"* ]] - [[ "$PM_INSTALL" == *"sudo env"* ]] + [[ "$PM_INSTALL" == *"DEBIAN_FRONTEND=noninteractive"* ]] || return 1 + [[ "$PM_INSTALL" == *"NEEDRESTART_MODE=a"* ]] || return 1 + [[ "$PM_INSTALL" == *"sudo env"* ]] || return 1 } # apt must WAIT (bounded) for the dpkg lock instead of hanging forever behind # apt-daily/unattended-upgrades on a freshly-booted host (#210). @test "setup_pm: apt waits for the dpkg lock with a bounded timeout (#210)" { PRESENT_CMDS="apt-get" setup_pm - [[ "$PM_INSTALL" == *"DPkg::Lock::Timeout="* ]] - [[ "$PM_UPDATE" == *"DPkg::Lock::Timeout="* ]] + [[ "$PM_INSTALL" == *"DPkg::Lock::Timeout="* ]] || return 1 + [[ "$PM_UPDATE" == *"DPkg::Lock::Timeout="* ]] || return 1 } @test "setup_pm: dnf detected" { PRESENT_CMDS="dnf" setup_pm - [[ "$PM_INSTALL" == *"dnf install"* ]] + [[ "$PM_INSTALL" == *"dnf install"* ]] || return 1 } @test "setup_pm: none -> error" { PRESENT_CMDS="" run setup_pm - [ "$status" -ne 0 ] - [[ "$output" == *"No supported package manager"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"No supported package manager"* ]] || return 1 } # ── install_system_deps: conntrack package name (#720) ───────────────────── @@ -87,20 +93,20 @@ setup() { PRESENT_CMDS="apt-get curl" # apt present, conntrack binary absent run install_system_deps run mock_calls - [[ "$output" == *"conntrack"* ]] - [[ "$output" != *"conntrack-tools"* ]] + [[ "$output" == *"conntrack"* ]] || return 1 + [[ "$output" != *"conntrack-tools"* ]] || return 1 } @test "install_system_deps: dnf uses 'conntrack-tools'" { PRESENT_CMDS="dnf curl" # no apt-get, conntrack binary absent run install_system_deps run mock_calls - [[ "$output" == *"conntrack-tools"* ]] + [[ "$output" == *"conntrack-tools"* ]] || return 1 } @test "install_system_deps: conntrack present -> not installed" { PRESENT_CMDS="apt-get curl conntrack" run install_system_deps run mock_calls - [[ "$output" != *"Installing conntrack"* ]] + [[ "$output" != *"Installing conntrack"* ]] || return 1 } # Caught by the cross-distro CI matrix on Amazon Linux 2023: the Helm tarball # needs tar + gzip to unpack, absent on minimal cloud images. openssl is no @@ -110,16 +116,16 @@ setup() { PRESENT_CMDS="dnf curl conntrack" # tar + gzip absent run install_system_deps run mock_calls - [[ "$output" == *"Installing tar"* ]] - [[ "$output" == *"Installing gzip"* ]] - [[ "$output" != *"Installing openssl"* ]] + [[ "$output" == *"Installing tar"* ]] || return 1 + [[ "$output" == *"Installing gzip"* ]] || return 1 + [[ "$output" != *"Installing openssl"* ]] || return 1 } @test "install_system_deps: tar + gzip already present -> not reinstalled" { PRESENT_CMDS="apt-get curl conntrack tar gzip" run install_system_deps run mock_calls - [[ "$output" != *"Installing tar"* ]] - [[ "$output" != *"Installing gzip"* ]] + [[ "$output" != *"Installing tar"* ]] || return 1 + [[ "$output" != *"Installing gzip"* ]] || return 1 } # _ensure_helm_prereqs was removed with get-helm-3 (Bugbot #396): Helm no longer @@ -131,42 +137,54 @@ setup() { PRESENT_CMDS="dnf"; TEST_DISTRO=amzn; write_os_release run install_docker_engine run mock_calls - [[ "$output" == *"dnf install -y docker"* ]] + [[ "$output" == *"dnf install -y docker"* ]] || return 1 } @test "install_docker_engine: Arch -> pacman docker" { PRESENT_CMDS="pacman"; TEST_DISTRO=ubuntu run install_docker_engine run mock_calls - [[ "$output" == *"pacman -S --noconfirm docker"* ]] + [[ "$output" == *"pacman -S --noconfirm docker"* ]] || return 1 } @test "install_docker_engine: SUSE -> zypper docker" { PRESENT_CMDS="zypper"; TEST_DISTRO=ubuntu run install_docker_engine run mock_calls - [[ "$output" == *"zypper install -y docker"* ]] + [[ "$output" == *"zypper install -y docker"* ]] || return 1 } @test "install_docker_engine: RHEL clone (#719) -> docker-ce dnf repo" { PRESENT_CMDS=""; TEST_DISTRO=alma; write_os_release run install_docker_engine run mock_calls - [[ "$output" == *"docker-ce.repo"* ]] - [[ "$output" == *"docker-ce docker-ce-cli containerd.io"* ]] + [[ "$output" == *"docker-ce.repo"* ]] || return 1 + [[ "$output" == *"docker-ce docker-ce-cli containerd.io"* ]] || return 1 } @test "install_docker_engine: Debian/Ubuntu -> get.docker.com" { PRESENT_CMDS="curl"; TEST_DISTRO=ubuntu run install_docker_engine run mock_calls - [[ "$output" == *"get.docker.com"* ]] + [[ "$output" == *"get.docker.com"* ]] || return 1 # the convenience script runs apt-get internally → must be non-interactive too - [[ "$output" == *"DEBIAN_FRONTEND=noninteractive"* ]] - [[ "$output" == *"NEEDRESTART_MODE=a"* ]] + [[ "$output" == *"DEBIAN_FRONTEND=noninteractive"* ]] || return 1 + [[ "$output" == *"NEEDRESTART_MODE=a"* ]] || return 1 +} +# The get.docker.com script's internal apt/download.docker.com fetches carry no +# timeout of their own, so a stalled connection hung "Installing Docker…" +# silently until something else killed the process — in CI the 20-minute job +# timeout, three times on 2026-08-04 (#525/#592). The run must be bounded so a +# stall fails in minutes with a clear message instead. +@test "install_docker_engine: get.docker.com run is bounded with timeout (CI hang 2026-08-04)" { + PRESENT_CMDS="curl"; TEST_DISTRO=ubuntu + run install_docker_engine + run mock_calls + [[ "$output" == *"spin_cmd_bounded 600 "* ]] || return 1 } + @test "install_docker_engine: docker already present -> no install" { PRESENT_CMDS="docker"; TEST_DISTRO=ubuntu run install_docker_engine run mock_calls - [[ "$output" != *"get.docker.com"* ]] - [[ "$output" != *"docker-ce.repo"* ]] + [[ "$output" != *"get.docker.com"* ]] || return 1 + [[ "$output" != *"docker-ce.repo"* ]] || return 1 } # ── install_docker_engine under prepare-host (#381 Bugbot) ─────────────────── @@ -183,11 +201,11 @@ setup() { sg() { record "sg $*"; exit 97; } # the escape this test forbids id() { echo "admin docker"; } # even WITH membership visible… run install_docker_engine - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"sudo docker info"* ]] - [[ "$output" == *"systemctl enable docker"* ]] # boot-enable survives a reboot (r5) - [[ "$output" != *"sg "* ]] # …no re-exec ever fires + [[ "$output" == *"sudo docker info"* ]] || return 1 + [[ "$output" == *"systemctl enable docker"* ]] || return 1 # boot-enable survives a reboot (r5) + [[ "$output" != *"sg "* ]] || return 1 # …no re-exec ever fires TB_PREPARE_HOST_MODE="" } @@ -204,9 +222,9 @@ setup() { } sg() { record "sg $*"; exit 97; } run install_docker_engine - [ "$status" -ne 0 ] - [[ "$output" == *"re-run prepare-host"* ]] - [[ "$output" != *"logging out and back in"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"re-run prepare-host"* ]] || return 1 + [[ "$output" != *"logging out and back in"* ]] || return 1 TB_PREPARE_HOST_MODE="" } @@ -225,9 +243,9 @@ setup() { } sg() { record "sg $*"; exit 97; } run install_docker_engine - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"enable --now docker"* ]] + [[ "$output" == *"enable --now docker"* ]] || return 1 TB_PREPARE_HOST_MODE="" } @@ -252,10 +270,10 @@ setup() { } sg() { record "sg $*"; exit 97; } run install_docker_engine - [ "$status" -ne 0 ] - [[ "$output" == *"re-run prepare-host"* ]] - [[ "$output" != *"re-run this installer"* ]] - [[ "$output" != *"logging out and back in"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"re-run prepare-host"* ]] || return 1 + [[ "$output" != *"re-run this installer"* ]] || return 1 + [[ "$output" != *"logging out and back in"* ]] || return 1 TB_PREPARE_HOST_MODE="" } @@ -266,9 +284,9 @@ setup() { sudo() { record "sudo $*"; return 0; } sg() { record "sg $*"; exit 97; } run install_docker_engine - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" != *"usermod -aG docker"* ]] # the ADMIN is never granted the socket + [[ "$output" != *"usermod -aG docker"* ]] || return 1 # the ADMIN is never granted the socket TB_PREPARE_HOST_MODE="" } @@ -288,11 +306,11 @@ setup() { } sha256sum() { cat >/dev/null; return 0; } run _fetch_kubectl v1.29.4 amd64 - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"--speed-limit 1024 --speed-time 60"* ]] - [[ "$output" != *"--max-time"* ]] - [[ "$output" == *"--tlsv1.2"* ]] + [[ "$output" == *"--speed-limit 1024 --speed-time 60"* ]] || return 1 + [[ "$output" != *"--max-time"* ]] || return 1 + [[ "$output" == *"--tlsv1.2"* ]] || return 1 } # retry emits its attempt notices on STDOUT, so a failed-then-successful @@ -307,8 +325,8 @@ setup() { # spin_cmd (default mock) records "_fetch_kubectl <ver> <arch>" — the version # must be the clean token, not the retry notice. run mock_calls - [[ "$output" == *"_fetch_kubectl v1.29.4 amd64"* ]] - [[ "$output" != *"Retrying"* ]] + [[ "$output" == *"_fetch_kubectl v1.29.4 amd64"* ]] || return 1 + [[ "$output" != *"Retrying"* ]] || return 1 } @test "install_kubectl: unresolvable version (only a retry notice) fails closed, no bad fetch" { @@ -317,9 +335,9 @@ setup() { retry() { shift 2; "$@"; } curl_secure() { printf '%s\n' "Command failed after 3 attempts: curl_secure"; } # no version line run install_kubectl - [ "$status" -ne 0 ] # regex rejects the notice -> error, not a broken URL + [ "$status" -ne 0 ] || return 1 # regex rejects the notice -> error, not a broken URL run mock_calls - [[ "$output" != *"_fetch_kubectl"* ]] + [[ "$output" != *"_fetch_kubectl"* ]] || return 1 } # ── install_k3d: pinned release, verified direct download (#382) ──────────── @@ -361,51 +379,51 @@ _k3d_dl_setup() { @test "install_k3d: default pin -> verified direct download, no upstream script" { _k3d_dl_setup run install_k3d - [ "$status" -eq 0 ] - [ -f "$TB_TOOLS_DIR/k3d" ] # installed where we said + [ "$status" -eq 0 ] || return 1 + [ -f "$TB_TOOLS_DIR/k3d" ] || return 1 # installed where we said run mock_calls - [[ "$output" == *"releases/download/${K3D_VERSION}/k3d-linux-amd64"* ]] - [[ "$output" == *"releases/download/${K3D_VERSION}/checksums.txt"* ]] - [[ "$output" == *"sha256sum --check"* ]] # verification ran - [[ "$output" != *"install.sh"* ]] # upstream script gone - [[ "$output" != *"releases/latest"* ]] # pinned path never resolves + [[ "$output" == *"releases/download/${K3D_VERSION}/k3d-linux-amd64"* ]] || return 1 + [[ "$output" == *"releases/download/${K3D_VERSION}/checksums.txt"* ]] || return 1 + [[ "$output" == *"sha256sum --check"* ]] || return 1 # verification ran + [[ "$output" != *"install.sh"* ]] || return 1 # upstream script gone + [[ "$output" != *"releases/latest"* ]] || return 1 # pinned path never resolves } @test "install_k3d: checksum mismatch fails closed, nothing installed (#382)" { _k3d_dl_setup SHA_RC=1 run install_k3d - [ "$status" -ne 0 ] - [[ "$output" == *"checksum verification failed"* ]] - [ ! -f "$TB_TOOLS_DIR/k3d" ] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"checksum verification failed"* ]] || return 1 + [ ! -f "$TB_TOOLS_DIR/k3d" ] || return 1 } @test "install_k3d: asset missing from checksums.txt fails closed (#382)" { _k3d_dl_setup ARCH_DL="arm64" # fixture checksums.txt only lists amd64 -> no matching line run install_k3d - [ "$status" -ne 0 ] - [[ "$output" == *"checksum verification failed"* ]] - [ ! -f "$TB_TOOLS_DIR/k3d" ] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"checksum verification failed"* ]] || return 1 + [ ! -f "$TB_TOOLS_DIR/k3d" ] || return 1 } @test "install_k3d: system path installs via sudo mv" { _k3d_dl_setup TB_TOOLS_SUDO="sudo" sudo() { record "sudo $*"; "$@"; } run install_k3d - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"sudo mv"* ]] - [[ "$output" == *"$TB_TOOLS_DIR/k3d"* ]] + [[ "$output" == *"sudo mv"* ]] || return 1 + [[ "$output" == *"$TB_TOOLS_DIR/k3d"* ]] || return 1 } @test "install_k3d: K3D_VERSION=latest resolves the tag, then the same verified path" { _k3d_dl_setup K3D_VERSION=latest run install_k3d - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"releases/latest"* ]] # resolve-at-install-time - [[ "$output" == *"releases/download/v9.9.9/k3d-linux-amd64"* ]] # resolved tag used - [[ "$output" == *"releases/download/v9.9.9/checksums.txt"* ]] # still verified - [[ "$output" != *"install.sh"* ]] + [[ "$output" == *"releases/latest"* ]] || return 1 # resolve-at-install-time + [[ "$output" == *"releases/download/v9.9.9/k3d-linux-amd64"* ]] || return 1 # resolved tag used + [[ "$output" == *"releases/download/v9.9.9/checksums.txt"* ]] || return 1 # still verified + [[ "$output" != *"install.sh"* ]] || return 1 } @test "install_k3d: malformed K3D_VERSION fails closed before any fetch (Bugbot r1)" { PRESENT_CMDS="curl" @@ -413,19 +431,19 @@ _k3d_dl_setup() { has() { case " $PRESENT_CMDS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } spin_cmd() { record "$*"; return 0; } run install_k3d - [ "$status" -ne 0 ] - [[ "$output" == *"K3D_VERSION must be a k3d release tag"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"K3D_VERSION must be a k3d release tag"* ]] || return 1 run mock_calls - [ -z "$output" ] # no curl, no spin_cmd — nothing ran + [ -z "$output" ] || return 1 # no curl, no spin_cmd — nothing ran } @test "install_k3d: already present -> skip" { has() { [ "$1" = k3d ]; } spin_cmd() { record "$*"; return 0; } run install_k3d - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [ -z "$output" ] + [ -z "$output" ] || return 1 } # ── install_helm: pinned release, verified direct download (#395) ──────────── @@ -471,43 +489,43 @@ _helm_dl_setup() { @test "install_helm: default pin -> verified direct download, no get-helm-3 (#395)" { _helm_dl_setup run install_helm - [ "$status" -eq 0 ] - [ -f "$TB_TOOLS_DIR/helm" ] # installed where we said + [ "$status" -eq 0 ] || return 1 + [ -f "$TB_TOOLS_DIR/helm" ] || return 1 # installed where we said run mock_calls - [[ "$output" == *"get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz"* ]] - [[ "$output" == *"get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz.sha256sum"* ]] - [[ "$output" == *"sha256sum --check"* ]] # verification ran - [[ "$output" != *"get-helm-3"* ]] # upstream script gone - [[ "$output" != *"raw.githubusercontent.com"* ]] - [[ "$output" != *"helm-latest-version"* ]] # pinned path never resolves + [[ "$output" == *"get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz"* ]] || return 1 + [[ "$output" == *"get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz.sha256sum"* ]] || return 1 + [[ "$output" == *"sha256sum --check"* ]] || return 1 # verification ran + [[ "$output" != *"get-helm-3"* ]] || return 1 # upstream script gone + [[ "$output" != *"raw.githubusercontent.com"* ]] || return 1 + [[ "$output" != *"helm-latest-version"* ]] || return 1 # pinned path never resolves } @test "install_helm: checksum mismatch fails closed, nothing installed (#395)" { _helm_dl_setup SHA_RC=1 run install_helm - [ "$status" -ne 0 ] - [[ "$output" == *"checksum verification failed"* ]] - [ ! -f "$TB_TOOLS_DIR/helm" ] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"checksum verification failed"* ]] || return 1 + [ ! -f "$TB_TOOLS_DIR/helm" ] || return 1 } @test "install_helm: system path installs via sudo mv" { _helm_dl_setup TB_TOOLS_SUDO="sudo" sudo() { record "sudo $*"; "$@"; } run install_helm - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"sudo mv"* ]] - [[ "$output" == *"$TB_TOOLS_DIR/helm"* ]] + [[ "$output" == *"sudo mv"* ]] || return 1 + [[ "$output" == *"$TB_TOOLS_DIR/helm"* ]] || return 1 } @test "install_helm: HELM_VERSION=latest resolves the tag, then the same verified path" { _helm_dl_setup HELM_VERSION=latest run install_helm - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"helm-latest-version"* ]] # resolve-at-install-time - [[ "$output" == *"get.helm.sh/helm-v9.9.9-linux-amd64.tar.gz"* ]] # resolved tag used - [[ "$output" == *"helm-v9.9.9-linux-amd64.tar.gz.sha256sum"* ]] # still verified + [[ "$output" == *"helm-latest-version"* ]] || return 1 # resolve-at-install-time + [[ "$output" == *"get.helm.sh/helm-v9.9.9-linux-amd64.tar.gz"* ]] || return 1 # resolved tag used + [[ "$output" == *"helm-v9.9.9-linux-amd64.tar.gz.sha256sum"* ]] || return 1 # still verified } @test "install_helm: malformed HELM_VERSION fails closed before any fetch (#395)" { PRESENT_CMDS="curl tar gzip" @@ -515,18 +533,18 @@ _helm_dl_setup() { has() { case " $PRESENT_CMDS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } spin_cmd() { record "$*"; return 0; } run install_helm - [ "$status" -ne 0 ] - [[ "$output" == *"HELM_VERSION must be a Helm release tag"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"HELM_VERSION must be a Helm release tag"* ]] || return 1 run mock_calls - [ -z "$output" ] # no curl, no spin_cmd — nothing ran + [ -z "$output" ] || return 1 # no curl, no spin_cmd — nothing ran } @test "install_helm: already present -> skip" { has() { [ "$1" = helm ]; } spin_cmd() { record "$*"; return 0; } run install_helm - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [ -z "$output" ] + [ -z "$output" ] || return 1 } # ── _ensure_unpack_tools: the installer installs tar/gzip itself (#395) ────── @@ -537,9 +555,9 @@ _helm_dl_setup() { @test "_ensure_unpack_tools: tar + gzip present -> silent no-op" { PRESENT_CMDS="curl apt-get tar gzip" run _ensure_unpack_tools - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [ -z "$output" ] + [ -z "$output" ] || return 1 } @test "_ensure_unpack_tools: missing + passwordless sudo -> ONE combined install via the package manager (#395)" { PRESENT_CMDS="curl apt-get" # tar + gzip absent @@ -548,11 +566,11 @@ _helm_dl_setup() { _have_sudo_bin() { return 0; } _real_sudo() { record "_real_sudo $*"; return 0; } # -n probe succeeds → quiet path run _ensure_unpack_tools - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls # One combined install call — a single sudo consumer (Bugbot r2), both pkgs on it. - [[ "$output" == *"Installing tar gzip"* ]] - [[ "$output" == *"apt-get install"*"tar gzip"* ]] + [[ "$output" == *"Installing tar gzip"* ]] || return 1 + [[ "$output" == *"apt-get install"*"tar gzip"* ]] || return 1 } @test "_ensure_unpack_tools: password path primes sudo, waits out the dpkg lock, then installs (Bugbot r2)" { PRESENT_CMDS="curl apt-get fuser" # tar + gzip absent; fuser present → lock wait live @@ -562,29 +580,29 @@ _helm_dl_setup() { _real_sudo() { record "_real_sudo $*"; case "$1" in -v) return 0 ;; *) return 1 ;; esac; } apt_wait_for_lock() { record "apt_wait_for_lock"; } run _ensure_unpack_tools - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"_real_sudo -v"* ]] # primed before any spinner - [[ "$output" == *"apt_wait_for_lock"* ]] # lock wait not skipped on Tier 0 - [[ "$output" == *"Installing tar gzip"* ]] + [[ "$output" == *"_real_sudo -v"* ]] || return 1 # primed before any spinner + [[ "$output" == *"apt_wait_for_lock"* ]] || return 1 # lock wait not skipped on Tier 0 + [[ "$output" == *"Installing tar gzip"* ]] || return 1 } @test "_ensure_unpack_tools: no sudo rights -> honest error, names the packages" { PRESENT_CMDS="curl apt-get" # tar + gzip absent _have_sudo_bin() { return 0; } _real_sudo() { record "_real_sudo $*"; return 1; } # -n probe AND -v both fail run _ensure_unpack_tools - [ "$status" -ne 0 ] - [[ "$output" == *"administrator"* ]] - [[ "$output" == *"tar"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"administrator"* ]] || return 1 + [[ "$output" == *"tar"* ]] || return 1 } @test "_ensure_unpack_tools: not root and no sudo binary -> honest error before any prompt" { PRESENT_CMDS="curl apt-get" # tar + gzip absent _have_sudo_bin() { return 1; } # no sudo on the machine at all _real_sudo() { record "_real_sudo $*"; return 127; } run _ensure_unpack_tools - [ "$status" -ne 0 ] - [[ "$output" == *"no sudo"* ]] - [[ "$output" == *"tar"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"no sudo"* ]] || return 1 + [[ "$output" == *"tar"* ]] || return 1 } @test "_ensure_unpack_tools: never kills a preflight keepalive (Bugbot r3)" { PRESENT_CMDS="curl apt-get" # tar + gzip absent (Tier 1/2 recovery case) @@ -593,9 +611,9 @@ _helm_dl_setup() { _real_sudo() { record "_real_sudo $*"; return 0; } # ticket cached → quiet path kill() { record "kill $*"; } run _ensure_unpack_tools - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" != *"kill 99999"* ]] # preflight's warm ticket left alone + [[ "$output" != *"kill 99999"* ]] || return 1 # preflight's warm ticket left alone } @test "install_helm: HELM_VERSION=latest survives retry notices on stdout (Bugbot r3)" { _helm_dl_setup @@ -631,9 +649,9 @@ _helm_dl_setup() { done } run install_helm - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"get.helm.sh/helm-v9.9.9-linux-amd64.tar.gz"* ]] # clean tag despite the notice + [[ "$output" == *"get.helm.sh/helm-v9.9.9-linux-amd64.tar.gz"* ]] || return 1 # clean tag despite the notice } @test "_ensure_unpack_tools: package install fails -> fatal (helm can't unpack without it)" { @@ -642,8 +660,8 @@ _helm_dl_setup() { _real_sudo() { record "_real_sudo $*"; return 0; } spin_cmd() { record "$*"; case "$*" in *"apt-get install"*) return 1 ;; *) return 0 ;; esac; } run _ensure_unpack_tools - [ "$status" -ne 0 ] - [[ "$output" == *"Couldn't install tar"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"Couldn't install tar"* ]] || return 1 } # ── install_docker_engine: dead daemon vs group-not-active (Asad's Alma9 case) ── @@ -656,9 +674,9 @@ _helm_dl_setup() { record "sudo $*"; return 0 } run install_docker_engine - [ "$status" -ne 0 ] - [[ "$output" == *"daemon won't start"* ]] - [[ "$output" != *"logging out"* ]] # the misleading group hint is NOT used + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"daemon won't start"* ]] || return 1 + [[ "$output" != *"logging out"* ]] || return 1 # the misleading group hint is NOT used } # Asad's root cause: minimal AlmaLinux lacks xt_addrtype -> dockerd bridge init fails. @@ -668,9 +686,9 @@ _helm_dl_setup() { spin_cmd() { record "$*"; return 0; } run _ensure_kernel_modules run mock_calls - [[ "$output" == *"modprobe overlay"* ]] - [[ "$output" == *"modprobe xt_addrtype"* ]] - [[ "$output" == *"kernel-modules-"* ]] # RHEL fallback install fired + [[ "$output" == *"modprobe overlay"* ]] || return 1 + [[ "$output" == *"modprobe xt_addrtype"* ]] || return 1 + [[ "$output" == *"kernel-modules-"* ]] || return 1 # RHEL fallback install fired } # ── _configure_docker_proxy (#244: host proxy -> dockerd systemd drop-in) ──── @@ -682,8 +700,8 @@ _helm_dl_setup() { TB_DOCKER_DROPIN_DIR="$BATS_TEST_TMPDIR/dropin" sudo() { "$@"; } run _configure_docker_proxy - [ "$status" -eq 0 ] - [ ! -e "$TB_DOCKER_DROPIN_DIR/http-proxy.conf" ] + [ "$status" -eq 0 ] || return 1 + [ ! -e "$TB_DOCKER_DROPIN_DIR/http-proxy.conf" ] || return 1 } @test "_configure_docker_proxy: not systemd-managed -> no-op" { @@ -692,8 +710,8 @@ _helm_dl_setup() { TB_DOCKER_DROPIN_DIR="$BATS_TEST_TMPDIR/dropin" sudo() { "$@"; } run _configure_docker_proxy - [ "$status" -eq 0 ] - [ ! -e "$TB_DOCKER_DROPIN_DIR/http-proxy.conf" ] + [ "$status" -eq 0 ] || return 1 + [ ! -e "$TB_DOCKER_DROPIN_DIR/http-proxy.conf" ] || return 1 } @test "_configure_docker_proxy: host proxy -> writes dockerd drop-in (HTTP/HTTPS/NO_PROXY)" { @@ -704,9 +722,9 @@ _helm_dl_setup() { sudo() { "$@"; } systemctl() { return 1; } # is-active false (fresh) -> no restart run _configure_docker_proxy - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 f="$TB_DOCKER_DROPIN_DIR/http-proxy.conf" - [ -f "$f" ] + [ -f "$f" ] || return 1 grep -q 'Environment="HTTP_PROXY=http://proxy.corp:3128"' "$f" grep -q 'Environment="HTTPS_PROXY=http://proxy.corp:3128"' "$f" grep -q 'Environment="NO_PROXY=localhost,.corp"' "$f" @@ -735,7 +753,7 @@ _helm_dl_setup() { : > "$MOCK_CALLS" # reset records run _configure_docker_proxy # 2nd: unchanged -> early return run mock_calls - [[ "$output" != *"restart docker"* ]] + [[ "$output" != *"restart docker"* ]] || return 1 } # Bugbot #245: proxy removed since last run -> the stale drop-in we wrote must @@ -750,8 +768,8 @@ _helm_dl_setup() { sudo() { "$@"; } systemctl() { return 1; } # not active -> no restart run _configure_docker_proxy - [ "$status" -eq 0 ] - [ ! -e "$TB_DOCKER_DROPIN_DIR/http-proxy.conf" ] # ours -> removed + [ "$status" -eq 0 ] || return 1 + [ ! -e "$TB_DOCKER_DROPIN_DIR/http-proxy.conf" ] || return 1 # ours -> removed } @test "_configure_docker_proxy: host proxy removed -> leaves a foreign drop-in untouched" { @@ -763,8 +781,8 @@ _helm_dl_setup() { > "$TB_DOCKER_DROPIN_DIR/http-proxy.conf" # no tracebloc marker sudo() { "$@"; } run _configure_docker_proxy - [ "$status" -eq 0 ] - [ -f "$TB_DOCKER_DROPIN_DIR/http-proxy.conf" ] # NOT ours -> left alone + [ "$status" -eq 0 ] || return 1 + [ -f "$TB_DOCKER_DROPIN_DIR/http-proxy.conf" ] || return 1 # NOT ours -> left alone grep -q 'it-managed' "$TB_DOCKER_DROPIN_DIR/http-proxy.conf" } @@ -772,7 +790,7 @@ _helm_dl_setup() { @test "_route_install_tier: Tier 2 + no sudo => actionable fail-fast" { INSTALL_TIER=2; PROBE_PRIVILEGE=no_sudo run _route_install_tier - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 printf '%s\n' "$output" | grep -qF "administrator rights" printf '%s\n' "$output" | grep -qF "prepare this host" } @@ -780,25 +798,25 @@ _helm_dl_setup() { @test "_route_install_tier: Tier 2 + root => proceeds (root can install a runtime)" { INSTALL_TIER=2; PROBE_PRIVILEGE=root run _route_install_tier - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "_route_install_tier: Tier 0 + no sudo => proceeds (runtime already usable)" { INSTALL_TIER=0; PROBE_PRIVILEGE=no_sudo run _route_install_tier - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "_route_install_tier: unset tier (stale bootstrap) => proceeds as before" { unset INSTALL_TIER PROBE_PRIVILEGE run _route_install_tier - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "_route_install_tier: TB_FORCE_TIER overrides the detected tier" { INSTALL_TIER=0; PROBE_PRIVILEGE=no_sudo; TB_FORCE_TIER=2 run _route_install_tier - [ "$status" -ne 0 ] # forced to Tier 2 + no_sudo => fail-fast + [ "$status" -ne 0 ] || return 1 # forced to Tier 2 + no_sudo => fail-fast printf '%s\n' "$output" | grep -qF "administrator rights" } @@ -826,14 +844,14 @@ _stub_install_steps() { HOME="$BATS_TEST_TMPDIR" _stub_install_steps run install_linux - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q install_kubectl mock_calls | grep -q install_k3d mock_calls | grep -q install_helm - ! mock_calls | grep -q preflight_sudo - ! mock_calls | grep -q install_docker_engine - ! mock_calls | grep -q install_system_deps - ! mock_calls | grep -q dispatch_gpu_setup + ! mock_calls | grep -q preflight_sudo || return 1 + ! mock_calls | grep -q install_docker_engine || return 1 + ! mock_calls | grep -q install_system_deps || return 1 + ! mock_calls | grep -q dispatch_gpu_setup || return 1 } @test "install_linux: Tier 1 runs the full privileged flow" { @@ -841,7 +859,7 @@ _stub_install_steps() { INSTALL_TIER=1; PROBE_PRIVILEGE=sudo_nopw _stub_install_steps run install_linux - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q preflight_sudo mock_calls | grep -q install_docker_engine mock_calls | grep -q install_kubectl @@ -853,7 +871,7 @@ _stub_install_steps() { unset INSTALL_TIER PROBE_PRIVILEGE _stub_install_steps run install_linux - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q install_docker_engine } @@ -869,14 +887,14 @@ _stub_install_steps() { install_rootless_docker() { record "install_rootless_docker"; } _stub_install_steps run install_linux - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q _ensure_subid_ranges # gate runs before daemon setup mock_calls | grep -q install_rootless_docker mock_calls | grep -q install_kubectl # _install_userspace_tools ran - ! mock_calls | grep -q preflight_sudo - ! mock_calls | grep -q install_docker_engine - ! mock_calls | grep -q install_system_deps - ! mock_calls | grep -q dispatch_gpu_setup + ! mock_calls | grep -q preflight_sudo || return 1 + ! mock_calls | grep -q install_docker_engine || return 1 + ! mock_calls | grep -q install_system_deps || return 1 + ! mock_calls | grep -q dispatch_gpu_setup || return 1 } @test "install_linux: Tier 1 WITHOUT the opt-in falls through to the legacy privileged flow (safe default)" { @@ -885,8 +903,8 @@ _stub_install_steps() { install_rootless_docker() { record "install_rootless_docker"; } _stub_install_steps run install_linux - [ "$status" -eq 0 ] - ! mock_calls | grep -q install_rootless_docker # opt-in off → rootless never runs + [ "$status" -eq 0 ] || return 1 + ! mock_calls | grep -q install_rootless_docker || return 1 # opt-in off → rootless never runs mock_calls | grep -q preflight_sudo mock_calls | grep -q install_docker_engine } @@ -904,8 +922,8 @@ _stub_install_steps() { mock_calls | grep -q "dockerd-rootless-setuptool.sh install" mock_calls | grep -q "systemctl --user enable --now docker" mock_calls | grep -q "loginctl enable-linger testuser" - [ "$DOCKER_HOST" = "unix://${XDG_RUNTIME_DIR}/docker.sock" ] - ! mock_calls | grep -q sudo # no blanket sudo anywhere on the rootless path + [ "$DOCKER_HOST" = "unix://${XDG_RUNTIME_DIR}/docker.sock" ] || return 1 + ! mock_calls | grep -q sudo || return 1 # no blanket sudo anywhere on the rootless path } @test "install_rootless_docker: DOCKER_HOST targets the XDG runtime-dir socket (systemd path)" { @@ -916,7 +934,7 @@ _stub_install_steps() { systemctl() { case "$*" in *is-system-running*) echo running ;; esac; } loginctl() { :; } install_rootless_docker - [ "$DOCKER_HOST" = "unix:///run/user/1000/docker.sock" ] + [ "$DOCKER_HOST" = "unix:///run/user/1000/docker.sock" ] || return 1 } @test "install_rootless_docker: prepends ~/bin so the rootless CLI resolves (get.docker.com fallback) (Bugbot)" { @@ -940,7 +958,7 @@ _stub_install_steps() { loginctl() { return 1; } # linger blocked (polkit-locked) docker() { return 0; } # ...but the daemon is actually up install_rootless_docker # must reach the export + verify, not set -e abort - [ "$DOCKER_HOST" = "unix:///run/user/1000/docker.sock" ] + [ "$DOCKER_HOST" = "unix:///run/user/1000/docker.sock" ] || return 1 } @test "install_rootless_docker: success line is honest about the admin touch (#458)" { @@ -950,12 +968,12 @@ _stub_install_steps() { # Zero-root path (gate didn't touch sudo): claims no admin rights. MOCK_CALLS="$(mktemp)"; unset TB_ROOTLESS_ADMIN_TOUCH run install_rootless_docker - [[ "$output" == *"no administrator rights were used"* ]] + [[ "$output" == *"no administrator rights were used"* ]] || return 1 # Sudo-touch path (gate provisioned the range with sudo): must NOT claim zero-root. MOCK_CALLS="$(mktemp)"; TB_ROOTLESS_ADMIN_TOUCH=1 run install_rootless_docker - [[ "$output" != *"no administrator rights were used"* ]] - [[ "$output" == *"one-time admin step"* ]] + [[ "$output" != *"no administrator rights were used"* ]] || return 1 + [[ "$output" == *"one-time admin step"* ]] || return 1 } # ── no-systemd fallback + Tier-2 fall-through (RFC 0001 #1222) ──────────────── @@ -967,11 +985,11 @@ _stub_install_steps() { systemctl() { record "systemctl $*"; } # is-system-running → empty ⇒ no user manager loginctl() { record "loginctl $*"; } run install_rootless_docker - [ "$status" -ne 0 ] # routes to Tier-2 and exits (no blind nohup bring-up) - [[ "$output" == *"prepare-host"* ]] # the Tier-2 remedy - [[ "$output" == *"no per-user systemd"* ]] # accurate reason (not a vague setuptool failure) - ! mock_calls | grep -q "systemctl --user enable" # never attempted the user-systemd bring-up - ! mock_calls | grep -q "dockerd-rootless-setuptool.sh install" # gate is UPFRONT → no partial ~/bin install (Bugbot #485) + [ "$status" -ne 0 ] || return 1 # routes to Tier-2 and exits (no blind nohup bring-up) + [[ "$output" == *"prepare-host"* ]] || return 1 # the Tier-2 remedy + [[ "$output" == *"no per-user systemd"* ]] || return 1 # accurate reason (not a vague setuptool failure) + ! mock_calls | grep -q "systemctl --user enable" || return 1 # never attempted the user-systemd bring-up + ! mock_calls | grep -q "dockerd-rootless-setuptool.sh install" || return 1 # gate is UPFRONT → no partial ~/bin install (Bugbot #485) } @test "install_rootless_docker: daemon never Ready -> Tier-2 prepare-host fall-through, not a silent proceed (#1222)" { @@ -982,8 +1000,8 @@ _stub_install_steps() { loginctl() { :; } docker() { return 1; } # daemon never answers on the socket run install_rootless_docker - [ "$status" -ne 0 ] # exits via fall-through, not onward - [[ "$output" == *"prepare-host"* ]] # routes to the Tier-2 remedy + [ "$status" -ne 0 ] || return 1 # exits via fall-through, not onward + [[ "$output" == *"prepare-host"* ]] || return 1 # routes to the Tier-2 remedy } @test "install_rootless_docker: setuptool install failure -> Tier-2 fall-through, not a bare set -e abort (#485 r2)" { @@ -994,18 +1012,18 @@ _stub_install_steps() { loginctl() { :; } spin_cmd() { record "$*"; case "$*" in *"dockerd-rootless-setuptool.sh install"*) return 1 ;; *) return 0 ;; esac; } run install_rootless_docker - [ "$status" -ne 0 ] - [[ "$output" == *"prepare-host"* ]] # routed to the Tier-2 remedy… - [[ "$output" == *"setup tool"* ]] # …naming the setuptool failure, not a spinner tail + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"prepare-host"* ]] || return 1 # routed to the Tier-2 remedy… + [[ "$output" == *"setup tool"* ]] || return 1 # …naming the setuptool failure, not a spinner tail } @test "_tier2_fallthrough: names the researcher in the prepare-host remedy so prepare-host actually provisions them (Bugbot #485)" { id() { [ "${1:-}" = "-un" ] && echo researcher || echo "researcher"; } run _tier2_fallthrough "some reason" - [ "$status" -ne 0 ] # exits - [[ "$output" == *"export TB_PREPARE_USER=researcher"* ]] # names the researcher (run_prepare_host keys off this) - [[ "$output" == *"tracebloc prepare-host researcher"* ]] # CLI form names them too - [[ "$output" == *"prepare it for 'researcher'"* ]] # final error names them + [ "$status" -ne 0 ] || return 1 # exits + [[ "$output" == *"export TB_PREPARE_USER=researcher"* ]] || return 1 # names the researcher (run_prepare_host keys off this) + [[ "$output" == *"tracebloc prepare-host researcher"* ]] || return 1 # CLI form names them too + [[ "$output" == *"prepare it for 'researcher'"* ]] || return 1 # final error names them } # ── _ensure_subid_ranges: the Tier-1 subuid/subgid gate (RFC 0001 #1220) ───── @@ -1014,9 +1032,9 @@ _stub_install_steps() { PROBE_SUBID=1; PROBE_UIDMAP=1 _provision_subid_ranges() { record "_provision_subid_ranges $*"; } run _ensure_subid_ranges - [ "$status" -eq 0 ] - ! mock_calls | grep -q _provision_subid_ranges - ! mock_calls | grep -q sudo + [ "$status" -eq 0 ] || return 1 + ! mock_calls | grep -q _provision_subid_ranges || return 1 + ! mock_calls | grep -q sudo || return 1 } @test "_ensure_subid_ranges: missing + unprivileged => hand off to prepare-host (naming the user), fail-fast, no sudo" { @@ -1026,14 +1044,14 @@ _stub_install_steps() { TB_SUBUID_FILE="$(mktemp)"; TB_SUBGID_FILE="$(mktemp)" # empty -> next start 100000 _provision_subid_ranges() { record "_provision_subid_ranges $*"; } run _ensure_subid_ranges - [ "$status" -ne 0 ] # honest fail-fast - [[ "$output" == *prepare-host* ]] - [[ "$output" == *"TB_PREPARE_USER=researcher"* ]] # command names the researcher (#458) - [[ "$output" == *"/etc/subuid"* ]] # the two literal remedy lines - [[ "$output" == *"/etc/subgid"* ]] - [[ "$output" == *"researcher:100000:65536"* ]] # computed (non-hardcoded) start for this host - ! mock_calls | grep -q _provision_subid_ranges # never self-provisions unprivileged - ! mock_calls | grep -q sudo + [ "$status" -ne 0 ] || return 1 # honest fail-fast + [[ "$output" == *prepare-host* ]] || return 1 + [[ "$output" == *"TB_PREPARE_USER=researcher"* ]] || return 1 # command names the researcher (#458) + [[ "$output" == *"/etc/subuid"* ]] || return 1 # the two literal remedy lines + [[ "$output" == *"/etc/subgid"* ]] || return 1 + [[ "$output" == *"researcher:100000:65536"* ]] || return 1 # computed (non-hardcoded) start for this host + ! mock_calls | grep -q _provision_subid_ranges || return 1 # never self-provisions unprivileged + ! mock_calls | grep -q sudo || return 1 } @test "_ensure_subid_ranges: missing + sudo available => exactly one announced provision (for id -un)" { @@ -1042,18 +1060,18 @@ _stub_install_steps() { id() { [ "$1" = "-un" ] && echo researcher || echo 1000; } _provision_subid_ranges() { record "_provision_subid_ranges $*"; } run _ensure_subid_ranges - [ "$status" -eq 0 ] - [ "$(mock_calls | grep -c _provision_subid_ranges)" -eq 1 ] + [ "$status" -eq 0 ] || return 1 + [ "$(mock_calls | grep -c _provision_subid_ranges)" -eq 1 ] || return 1 mock_calls | grep -q "_provision_subid_ranges researcher" # id -un, not $USER - [[ "$output" == *one-time* ]] # announced (A2 honest messaging) + [[ "$output" == *one-time* ]] || return 1 # announced (A2 honest messaging) } @test "_ensure_subid_ranges: uidmap helpers absent => message includes the package-install hint" { MOCK_CALLS="$(mktemp)" PROBE_SUBID=1; PROBE_UIDMAP=0; PROBE_PRIVILEGE=no_sudo run _ensure_subid_ranges - [ "$status" -ne 0 ] - [[ "$output" == *uidmap* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *uidmap* ]] || return 1 } # ── _provision_subid_ranges: shared remediation body ───────────────────────── @@ -1081,8 +1099,8 @@ _stub_install_steps() { printf 'testuser:100000:65536\n' >"$TB_SUBUID_FILE" printf 'testuser:100000:65536\n' >"$TB_SUBGID_FILE" _provision_subid_ranges testuser - [ "$(grep -c '^testuser:' "$TB_SUBUID_FILE")" -eq 1 ] # not appended again - [ "$(grep -c '^testuser:' "$TB_SUBGID_FILE")" -eq 1 ] + [ "$(grep -c '^testuser:' "$TB_SUBUID_FILE")" -eq 1 ] || return 1 # not appended again + [ "$(grep -c '^testuser:' "$TB_SUBGID_FILE")" -eq 1 ] || return 1 } @test "_provision_subid_ranges: allocates a NON-overlapping block past an existing range" { @@ -1120,9 +1138,9 @@ _stub_install_steps() { id() { echo 1000; } TB_SUBUID_FILE="$(mktemp)"; TB_SUBGID_FILE="$(mktemp)" run _provision_subid_ranges testuser - [ "$status" -ne 0 ] # returns non-zero (NOT exit) so callers decide - [[ "$output" == *uidmap* ]] - ! mock_calls | grep -q "tee -a" # never wrote a range on a broken host + [ "$status" -ne 0 ] || return 1 # returns non-zero (NOT exit) so callers decide + [[ "$output" == *uidmap* ]] || return 1 + ! mock_calls | grep -q "tee -a" || return 1 # never wrote a range on a broken host } @test "_provision_subid_ranges: a failed range write returns non-zero, not false success (#458)" { @@ -1133,7 +1151,7 @@ _stub_install_steps() { id() { echo 1000; } TB_SUBUID_FILE="$(mktemp)"; TB_SUBGID_FILE="$(mktemp)" run _provision_subid_ranges testuser - [ "$status" -ne 0 ] # must surface the write failure, not print success + [ "$status" -ne 0 ] || return 1 # must surface the write failure, not print success } @test "_provision_subid_ranges: usermod --help nonzero exit still takes the usermod path (pipefail-safe, #458)" { @@ -1149,7 +1167,7 @@ _stub_install_steps() { # own harness pipelines and fail the run even when every test passes (bats footgun). ( set -o pipefail; _provision_subid_ranges testuser ) mock_calls | grep -q "usermod --add-subuids" # usermod path, not the append fallback - ! mock_calls | grep -q "tee -a" + ! mock_calls | grep -q "tee -a" || return 1 } @test "_install_uidmap_pkg: apt-get distro installs 'uidmap' via the hardened PM_INSTALL (no bare apt hang, #458)" { @@ -1158,37 +1176,37 @@ _stub_install_steps() { unset PM_INSTALL PM_UPDATE # Tier-1 skips setup_pm; force the real populate-then-install path _install_uidmap_pkg run mock_calls - [[ "$output" == *"apt-get update"* ]] # refreshes the index first (#458) - [[ "$output" == *"apt-get install"* ]] - [[ "$output" == *"uidmap"* ]] - [[ "$output" == *"NEEDRESTART_MODE=a"* ]] # needrestart guard (no spinner hang) - [[ "$output" == *"DPkg::Lock::Timeout="* ]] # bounded dpkg-lock wait (#210) + [[ "$output" == *"apt-get update"* ]] || return 1 # refreshes the index first (#458) + [[ "$output" == *"apt-get install"* ]] || return 1 + [[ "$output" == *"uidmap"* ]] || return 1 + [[ "$output" == *"NEEDRESTART_MODE=a"* ]] || return 1 # needrestart guard (no spinner hang) + [[ "$output" == *"DPkg::Lock::Timeout="* ]] || return 1 # bounded dpkg-lock wait (#210) } # ── _set_tools_target: Tier 0 tools must NOT sudo (Bugbot #1175) ───────────── @test "_set_tools_target: Tier 0 => ~/.local/bin, no sudo, on PATH" { INSTALL_TIER=0; HOME="$BATS_TEST_TMPDIR" _set_tools_target - [ "$TB_TOOLS_DIR" = "$HOME/.local/bin" ] - [ -z "$TB_TOOLS_SUDO" ] # zero-root: no sudo for the tools - [ -d "$TB_TOOLS_DIR" ] # created + [ "$TB_TOOLS_DIR" = "$HOME/.local/bin" ] || return 1 + [ -z "$TB_TOOLS_SUDO" ] || return 1 # zero-root: no sudo for the tools + [ -d "$TB_TOOLS_DIR" ] || return 1 # created case ":$PATH:" in *":$TB_TOOLS_DIR:"*) : ;; *) return 1 ;; esac # on PATH now } @test "_set_tools_target: full flow => /usr/local/bin with sudo" { INSTALL_TIER=1 _set_tools_target - [ "$TB_TOOLS_DIR" = "/usr/local/bin" ] - [ "$TB_TOOLS_SUDO" = "sudo" ] + [ "$TB_TOOLS_DIR" = "/usr/local/bin" ] || return 1 + [ "$TB_TOOLS_SUDO" = "sudo" ] || return 1 } # ── _tools_rc_for_shell + _persist_tools_on_path: keep Tier-0 tools on PATH (#375) ─ @test "_tools_rc_for_shell: zsh/bash-linux/bash-mac/other" { HOME=/h - SHELL=/bin/zsh; [ "$(_tools_rc_for_shell)" = "/h/.zshrc" ] - SHELL=/bin/bash; OS=Linux; [ "$(_tools_rc_for_shell)" = "/h/.bashrc" ] - SHELL=/bin/bash; OS=Darwin; [ "$(_tools_rc_for_shell)" = "/h/.bash_profile" ] - SHELL=/bin/dash; OS=Linux; [ "$(_tools_rc_for_shell)" = "/h/.profile" ] + SHELL=/bin/zsh; [ "$(_tools_rc_for_shell)" = "/h/.zshrc" ] || return 1 + SHELL=/bin/bash; OS=Linux; [ "$(_tools_rc_for_shell)" = "/h/.bashrc" ] || return 1 + SHELL=/bin/bash; OS=Darwin; [ "$(_tools_rc_for_shell)" = "/h/.bash_profile" ] || return 1 + SHELL=/bin/dash; OS=Linux; [ "$(_tools_rc_for_shell)" = "/h/.profile" ] || return 1 } @test "_persist_tools_on_path: Tier 0 appends ~/.local/bin to the shell rc (#375)" { @@ -1205,7 +1223,7 @@ _stub_install_steps() { hint() { :; } _persist_tools_on_path _persist_tools_on_path - [ "$(grep -cF '.local/bin' "$HOME/.bashrc")" -eq 1 ] + [ "$(grep -cF '.local/bin' "$HOME/.bashrc")" -eq 1 ] || return 1 } @test "_persist_tools_on_path: no-op for the full flow (/usr/local/bin) (#375)" { @@ -1213,9 +1231,9 @@ _stub_install_steps() { TB_TOOLS_DIR="/usr/local/bin" hint() { echo "must-not-run"; } run _persist_tools_on_path - [ "$status" -eq 0 ] - [ ! -f "$HOME/.bashrc" ] # nothing written - [[ "$output" != *"must-not-run"* ]] # no PATH hint emitted + [ "$status" -eq 0 ] || return 1 + [ ! -f "$HOME/.bashrc" ] || return 1 # nothing written + [[ "$output" != *"must-not-run"* ]] || return 1 # no PATH hint emitted } @test "_persist_tools_on_path: fish gets fish_add_path, no dead export in ~/.profile (#375)" { @@ -1223,9 +1241,9 @@ _stub_install_steps() { TB_TOOLS_DIR="$HOME/.local/bin" hint() { echo "$*"; } run _persist_tools_on_path - [ "$status" -eq 0 ] - [[ "$output" == *"fish_add_path"* ]] # fish-correct guidance - [ ! -f "$HOME/.profile" ] # did NOT write a bash export fish can't read + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"fish_add_path"* ]] || return 1 # fish-correct guidance + [ ! -f "$HOME/.profile" ] || return 1 # did NOT write a bash export fish can't read } # ── _tier0_gpu_flags: NVIDIA k3d flag reused only when the runtime exists (#375) ─ @@ -1234,7 +1252,7 @@ _stub_install_steps() { success() { :; } docker() { case "$*" in *Runtimes*) echo '{"nvidia":{"path":"nvidia-container-runtime"},"runc":{}}' ;; *) return 0 ;; esac; } _tier0_gpu_flags - [ "${K3D_GPU_FLAGS[*]}" = "--gpus=all" ] + [ "${K3D_GPU_FLAGS[*]}" = "--gpus=all" ] || return 1 } @test "_tier0_gpu_flags: nvidia + NO configured runtime => stays CPU-only (empty flags)" { @@ -1242,13 +1260,13 @@ _stub_install_steps() { warn() { :; }; hint() { :; } docker() { case "$*" in *Runtimes*) echo '{"runc":{}}' ;; *) return 0 ;; esac; } _tier0_gpu_flags - [ "${#K3D_GPU_FLAGS[@]}" -eq 0 ] # no --gpus flag → CPU-only cluster (safe, not a broken create) + [ "${#K3D_GPU_FLAGS[@]}" -eq 0 ] || return 1 # no --gpus flag → CPU-only cluster (safe, not a broken create) } @test "_tier0_gpu_flags: non-nvidia GPU => no-op" { GPU_VENDOR=none; K3D_GPU_FLAGS=() _tier0_gpu_flags - [ "${#K3D_GPU_FLAGS[@]}" -eq 0 ] + [ "${#K3D_GPU_FLAGS[@]}" -eq 0 ] || return 1 } # ── run_prepare_host (RFC 0001 #1178) ──────────────────────────────────────── @@ -1263,11 +1281,11 @@ _stub_install_steps() { install_system_deps() { record install_system_deps; } sudo() { record "sudo $*"; return 0; } run run_prepare_host - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q install_docker_engine mock_calls | grep -q "sudo usermod -aG docker researcher" - ! mock_calls | grep -qi "create_cluster" - ! mock_calls | grep -qi "install_tracebloc_cli" + ! mock_calls | grep -qi "create_cluster" || return 1 + ! mock_calls | grep -qi "install_tracebloc_cli" || return 1 # Grant succeeded => the no-admin promise is honest and shown (#377). printf '%s\n' "$output" | grep -qi "no administrator rights" } @@ -1283,11 +1301,11 @@ _stub_install_steps() { install_system_deps() { :; } sudo() { record "sudo $*"; return 0; } run run_prepare_host - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 # Untrimmed, the record would be "docker researcher " — single-space match # proves the value was trimmed before the gate and the grant. mock_calls | grep -q "sudo usermod -aG docker researcher" - [[ "$output" == *"Added researcher to the docker group"* ]] + [[ "$output" == *"Added researcher to the docker group"* ]] || return 1 } @test "run_prepare_host: no target user => best-effort, still prepares the host" { @@ -1301,11 +1319,11 @@ _stub_install_steps() { install_system_deps() { :; } sudo() { record "sudo $*"; return 0; } run run_prepare_host - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q install_docker_engine - ! mock_calls | grep -q "usermod" # nobody to add + ! mock_calls | grep -q "usermod" || return 1 # nobody to add # No grant happened => must NOT falsely promise a no-admin install (#377). - ! printf '%s\n' "$output" | grep -qi "can now install tracebloc with no administrator rights" + ! printf '%s\n' "$output" | grep -qi "can now install tracebloc with no administrator rights" || return 1 } @test "run_prepare_host: subid provisioning failure is best-effort — warns, still prepares the host (#458)" { @@ -1320,8 +1338,8 @@ _stub_install_steps() { sudo() { record "sudo $*"; return 0; } _provision_subid_ranges() { return 1; } # can't provision (e.g. unknown distro / helpers unfixable) run run_prepare_host - [ "$status" -eq 0 ] # best-effort: the whole prep must NOT abort (#458) - [[ "$output" == *"Couldn't provision subuid/subgid"* ]] # honest warning, not a hard exit + [ "$status" -eq 0 ] || return 1 # best-effort: the whole prep must NOT abort (#458) + [[ "$output" == *"Couldn't provision subuid/subgid"* ]] || return 1 # honest warning, not a hard exit mock_calls | grep -q install_docker_engine # host still prepared } @@ -1337,9 +1355,9 @@ _stub_install_steps() { # sudo succeeds for everything EXCEPT the usermod grant. sudo() { case "$*" in usermod*) return 1 ;; *) return 0 ;; esac; } run run_prepare_host - [ "$status" -eq 0 ] # best-effort: prep still succeeds + [ "$status" -eq 0 ] || return 1 # best-effort: prep still succeeds printf '%s\n' "$output" | grep -qi "Couldn't add" # honest warning - ! printf '%s\n' "$output" | grep -qi "can now install tracebloc with no administrator rights" + ! printf '%s\n' "$output" | grep -qi "can now install tracebloc with no administrator rights" || return 1 } @test "run_prepare_host: does NOT grant docker-group to SUDO_USER (the admin), only TB_PREPARE_USER (#377)" { @@ -1353,15 +1371,15 @@ _stub_install_steps() { install_system_deps() { :; } sudo() { record "sudo $*"; return 0; } run run_prepare_host - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q install_docker_engine # host still prepared - ! mock_calls | grep -q "usermod" # the ADMIN (SUDO_USER) is NOT added + ! mock_calls | grep -q "usermod" || return 1 # the ADMIN (SUDO_USER) is NOT added } @test "run_prepare_host: non-Linux errors with a Docker Desktop / WSL2 pointer" { OS=Darwin run run_prepare_host - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 printf '%s\n' "$output" | grep -qiE "Docker Desktop|WSL2" } @@ -1373,10 +1391,10 @@ _stub_install_steps() { systemctl() { record "systemctl $*"; } _write_cgroup_delegation run cat "$TB_USER_UNIT_DROPIN_DIR/delegate.conf" - [[ "$output" == *"[Service]"* ]] - [[ "$output" == *"Delegate=cpu cpuset io memory pids"* ]] + [[ "$output" == *"[Service]"* ]] || return 1 + [[ "$output" == *"Delegate=cpu cpuset io memory pids"* ]] || return 1 run mock_calls - [[ "$output" == *"systemctl daemon-reload"* ]] + [[ "$output" == *"systemctl daemon-reload"* ]] || return 1 } @test "_write_cgroup_delegation: idempotent when content already matches (no daemon-reload)" { @@ -1387,7 +1405,7 @@ _stub_install_steps() { systemctl() { record "systemctl $*"; } _write_cgroup_delegation run mock_calls - [[ "$output" != *"daemon-reload"* ]] # unchanged -> no user-manager churn + [[ "$output" != *"daemon-reload"* ]] || return 1 # unchanged -> no user-manager churn } @test "_ensure_cgroup_delegation: no_sudo -> hands off with the exact path + content, writes nothing" { @@ -1395,10 +1413,10 @@ _stub_install_steps() { TB_USER_UNIT_DROPIN_DIR="$d"; PROBE_PRIVILEGE=no_sudo sudo() { record "sudo $*"; } # must NOT be used to write run _ensure_cgroup_delegation - [ "$status" -ne 0 ] # non-fatal signal to the caller - [[ "$output" == *"Delegate=cpu cpuset io memory pids"* ]] - [[ "$output" == *"$d/delegate.conf"* ]] - [ ! -e "$d/delegate.conf" ] + [ "$status" -ne 0 ] || return 1 # non-fatal signal to the caller + [[ "$output" == *"Delegate=cpu cpuset io memory pids"* ]] || return 1 + [[ "$output" == *"$d/delegate.conf"* ]] || return 1 + [ ! -e "$d/delegate.conf" ] || return 1 } @test "_ensure_cgroup_delegation: root -> writes the drop-in + records the one admin touch" { @@ -1408,7 +1426,7 @@ _stub_install_steps() { TB_ROOTLESS_ADMIN_TOUCH=0 _ensure_cgroup_delegation grep -qF 'Delegate=cpu cpuset io memory pids' "$TB_USER_UNIT_DROPIN_DIR/delegate.conf" - [ "$TB_ROOTLESS_ADMIN_TOUCH" = "1" ] + [ "$TB_ROOTLESS_ADMIN_TOUCH" = "1" ] || return 1 } @test "_ensure_cgroup_delegation: already delegated -> no privileged call (fast path)" { @@ -1418,7 +1436,7 @@ _stub_install_steps() { sudo() { record "sudo $*"; } _ensure_cgroup_delegation run mock_calls - [ -z "$output" ] # nothing invoked at all + [ -z "$output" ] || return 1 # nothing invoked at all } # ── rootless-daemon corporate proxy: user-scoped, no sudo (carry-in, #452) ──── @@ -1433,10 +1451,10 @@ _stub_install_steps() { systemctl() { record "systemctl $*"; return 1; } # is-active: fresh daemon, not up _configure_docker_proxy user run cat "$d/http-proxy.conf" - [[ "$output" == *'HTTP_PROXY=http://proxy.example:3128'* ]] + [[ "$output" == *'HTTP_PROXY=http://proxy.example:3128'* ]] || return 1 run mock_calls - [[ "$output" == *"systemctl --user daemon-reload"* ]] - [[ "$output" != *"sudo "* ]] # user scope never elevates + [[ "$output" == *"systemctl --user daemon-reload"* ]] || return 1 + [[ "$output" != *"sudo "* ]] || return 1 # user scope never elevates } # ── carry-in: tools install user-space on rootless Tier 1 (no sudo mv crash) ── @@ -1445,8 +1463,8 @@ _stub_install_steps() { INSTALL_TIER=1; TB_TIER1_ROOTLESS=1 HOME="$(mktemp -d)" _set_tools_target - [ "$TB_TOOLS_DIR" = "$HOME/.local/bin" ] - [ -z "$TB_TOOLS_SUDO" ] + [ "$TB_TOOLS_DIR" = "$HOME/.local/bin" ] || return 1 + [ -z "$TB_TOOLS_SUDO" ] || return 1 case ":$PATH:" in *":$HOME/.local/bin:"*) : ;; *) return 1 ;; esac } @@ -1457,17 +1475,17 @@ _stub_install_steps() { HOME="$(mktemp -d)"; SHELL=/bin/bash; OS=Linux # _tools_rc_for_shell -> ~/.bashrc _persist_docker_host run cat "$HOME/.bashrc" - [[ "$output" == *'export DOCKER_HOST="unix://${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock"'* ]] + [[ "$output" == *'export DOCKER_HOST="unix://${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/docker.sock"'* ]] || return 1 _persist_docker_host # second run must not double-append run bash -c "grep -c 'DOCKER_HOST=' '$HOME/.bashrc'" - [ "$output" = "1" ] + [ "$output" = "1" ] || return 1 } @test "_persist_docker_host: flag OFF -> no-op (no rc write)" { INSTALL_TIER=1; unset TB_TIER1_ROOTLESS HOME="$(mktemp -d)"; SHELL=/bin/bash; OS=Linux _persist_docker_host - [ ! -e "$HOME/.bashrc" ] + [ ! -e "$HOME/.bashrc" ] || return 1 } @test "_persist_docker_host: foreign DOCKER_HOST present -> warns, does NOT clobber or double-write (Asad/Bugbot #478)" { @@ -1475,10 +1493,10 @@ _stub_install_steps() { HOME="$(mktemp -d)"; SHELL=/bin/bash; OS=Linux printf 'export DOCKER_HOST="tcp://10.0.0.5:2375"\n' > "$HOME/.bashrc" # user's own remote daemon run _persist_docker_host - [ "$status" -eq 0 ] - [[ "$output" == *"already sets DOCKER_HOST"* ]] # warned, not silent + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"already sets DOCKER_HOST"* ]] || return 1 # warned, not silent grep -q 'tcp://10.0.0.5:2375' "$HOME/.bashrc" # their line left untouched - [ "$(grep -c 'DOCKER_HOST=' "$HOME/.bashrc")" -eq 1 ] # we did NOT append the rootless line + [ "$(grep -c 'DOCKER_HOST=' "$HOME/.bashrc")" -eq 1 ] || return 1 # we did NOT append the rootless line } @@ -1487,21 +1505,21 @@ _stub_install_steps() { PRESENT_CMDS="docker curl conntrack"; TEST_DISTRO=ubuntu id() { echo "testuser"; } # NOT yet in the docker group run install_docker_engine - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q "sudo usermod -aG docker testuser" } @test "install_docker_engine: pre-installed Docker + user already in group -> no redundant grant (#427)" { PRESENT_CMDS="docker curl conntrack"; TEST_DISTRO=ubuntu id() { echo "testuser docker"; } # already a member run install_docker_engine - [ "$status" -eq 0 ] - ! mock_calls | grep -q "usermod -aG docker" + [ "$status" -eq 0 ] || return 1 + ! mock_calls | grep -q "usermod -aG docker" || return 1 } @test "install_docker_engine: fresh install still grants the invoking user (#427 regression)" { PRESENT_CMDS="curl conntrack"; TEST_DISTRO=ubuntu # docker ABSENT -> fresh install id() { echo "testuser"; } run install_docker_engine - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q "sudo usermod -aG docker testuser" } @test "install_docker_engine: prepare-host mode never grants the invoking admin (#427/#381)" { @@ -1509,16 +1527,16 @@ _stub_install_steps() { TB_PREPARE_HOST_MODE=1 id() { echo "admin"; } run install_docker_engine - ! mock_calls | grep -q "usermod -aG docker admin" + ! mock_calls | grep -q "usermod -aG docker admin" || return 1 } @test "install_docker_engine: grants the INVOKING user, not TB_PREPARE_USER (#427 Bugbot)" { PRESENT_CMDS="docker curl conntrack"; TEST_DISTRO=ubuntu TB_PREPARE_USER=researcher # a leftover export must NOT redirect the grant id() { echo "testuser"; } # invoker ($USER) not in group run install_docker_engine - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 mock_calls | grep -q "sudo usermod -aG docker testuser" # $USER, matches the sg re-exec + socket owner - ! mock_calls | grep -q "usermod -aG docker researcher" + ! mock_calls | grep -q "usermod -aG docker researcher" || return 1 } # ── #427: refuse a sudo-wrapped full install ──────────────────────────────── @@ -1526,32 +1544,32 @@ _stub_install_steps() { error() { printf 'ERR: %s\n' "$*"; return 1; } id() { echo 0; } SUDO_USER=alice run refuse_sudo_wrapped_install - [ "$status" -ne 0 ] - [[ "$output" == *"Don't run the installer with sudo"* ]] - [[ "$output" == *"alice"* ]] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"Don't run the installer with sudo"* ]] || return 1 + [[ "$output" == *"alice"* ]] || return 1 # the prepare-host remedy must name TB_PREPARE_USER (bare prepare-host grants nobody), # but with a RESEARCHER placeholder — never the admin's $SUDO_USER, which would grant # the admin and recreate the #377 footgun (#427 Bugbot r2). - [[ "$output" == *"TB_PREPARE_USER=<researcher-username>"* ]] - [[ "$output" != *"TB_PREPARE_USER=alice"* ]] + [[ "$output" == *"TB_PREPARE_USER=<researcher-username>"* ]] || return 1 + [[ "$output" != *"TB_PREPARE_USER=alice"* ]] || return 1 } @test "refuse_sudo_wrapped_install: genuine root login (no SUDO_USER) is allowed (#427)" { error() { printf 'ERR: %s\n' "$*"; return 1; } id() { echo 0; } SUDO_USER="" run refuse_sudo_wrapped_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "refuse_sudo_wrapped_install: SUDO_USER=root (sudo -i) is allowed (#427)" { error() { printf 'ERR: %s\n' "$*"; return 1; } id() { echo 0; } SUDO_USER=root run refuse_sudo_wrapped_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "refuse_sudo_wrapped_install: non-root run is allowed (#427)" { error() { printf 'ERR: %s\n' "$*"; return 1; } id() { echo 1000; } SUDO_USER=alice run refuse_sudo_wrapped_install - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } @test "install_docker_engine: sg-docker re-exec guard keys off _grant_user, not bare \$USER (#427 reviewer)" { @@ -1559,7 +1577,7 @@ _stub_install_steps() { # edge grants but never re-execs -> the dead-end loop returns. f="$BATS_TEST_DIRNAME/../lib/setup-linux.sh" grep -qE 'id -nG "\$_grant_user"[^|]*\| grep -qw docker' "$f" - ! grep -qE 'id -nG "\$USER"[^|]*\| grep -qw docker' "$f" + ! grep -qE 'id -nG "\$USER"[^|]*\| grep -qw docker' "$f" || return 1 } # ── #496: cgroup delegation is VERIFIED, not assumed ──────────────────────── @@ -1568,37 +1586,37 @@ _stub_install_steps() { # read "active" before the drop-in takes effect (#514 Bugbot, High). @test "_cgroup_controllers_path: points at user@\$UID.service (not the bare slice) (#514)" { run _cgroup_controllers_path - [[ "$output" == *"/user@$(id -u).service/cgroup.controllers" ]] - [[ "$output" != *".slice/cgroup.controllers" ]] # NOT the slice-level node + [[ "$output" == *"/user@$(id -u).service/cgroup.controllers" ]] || return 1 + [[ "$output" != *".slice/cgroup.controllers" ]] || return 1 # NOT the slice-level node } @test "_cgroup_controllers_active: true only when cpu+cpuset+io are all present (#496)" { cf="$(mktemp)"; TB_USER_CGROUP_CONTROLLERS="$cf" echo "cpuset cpu io memory pids" > "$cf" - run _cgroup_controllers_active; [ "$status" -eq 0 ] + run _cgroup_controllers_active; [ "$status" -eq 0 ] || return 1 echo "memory pids" > "$cf" # cpu/cpuset/io absent (systemd default) - run _cgroup_controllers_active; [ "$status" -ne 0 ] + run _cgroup_controllers_active; [ "$status" -ne 0 ] || return 1 } @test "_cgroup_controllers_active: unreadable controllers file -> not active (#496)" { TB_USER_CGROUP_CONTROLLERS="/no/such/cgroup/controllers" - run _cgroup_controllers_active; [ "$status" -ne 0 ] + run _cgroup_controllers_active; [ "$status" -ne 0 ] || return 1 } @test "_write_cgroup_delegation: controllers NOT active -> warns limits unenforced + recreate (#496)" { TB_USER_UNIT_DROPIN_DIR="$(mktemp -d)/user@.service.d" cf="$(mktemp)"; echo "memory pids" > "$cf"; TB_USER_CGROUP_CONTROLLERS="$cf" # not delegated yet sudo() { "$@"; }; systemctl() { :; } run _write_cgroup_delegation - [[ "$output" == *"NOT active in this session"* ]] - [[ "$output" == *"recreate the cluster"* ]] - [[ "$output" == *"k3d cluster delete"* ]] - [[ "$output" != *"active in this session."* ]] # never the plain-success wording + [[ "$output" == *"NOT active in this session"* ]] || return 1 + [[ "$output" == *"recreate the cluster"* ]] || return 1 + [[ "$output" == *"k3d cluster delete"* ]] || return 1 + [[ "$output" != *"active in this session."* ]] || return 1 # never the plain-success wording } @test "_write_cgroup_delegation: controllers active -> success, no scary warn (#496)" { TB_USER_UNIT_DROPIN_DIR="$(mktemp -d)/user@.service.d" cf="$(mktemp)"; echo "cpuset cpu io memory pids" > "$cf"; TB_USER_CGROUP_CONTROLLERS="$cf" sudo() { "$@"; }; systemctl() { :; } run _write_cgroup_delegation - [[ "$output" == *"active in this session"* ]] - [[ "$output" != *"NOT active"* ]] + [[ "$output" == *"active in this session"* ]] || return 1 + [[ "$output" != *"NOT active"* ]] || return 1 } @test "_write_cgroup_delegation: re-run over an existing drop-in still verifies (no silent fast path) (#496 Bugbot)" { @@ -1608,9 +1626,9 @@ _stub_install_steps() { cf="$(mktemp)"; echo "memory pids" > "$cf"; TB_USER_CGROUP_CONTROLLERS="$cf" # not delegated sudo() { "$@"; }; systemctl() { record "systemctl $*"; } run _write_cgroup_delegation - [[ "$output" == *"NOT active in this session"* ]] # report ran even on the idempotent path + [[ "$output" == *"NOT active in this session"* ]] || return 1 # report ran even on the idempotent path run mock_calls - [[ "$output" != *"daemon-reload"* ]] # …and it was the no-reload idempotent path + [[ "$output" != *"daemon-reload"* ]] || return 1 # …and it was the no-reload idempotent path } @test "_write_cgroup_delegation: prepare-host mode -> researcher-login wording, no cluster-delete (#496 Bugbot)" { TB_USER_UNIT_DROPIN_DIR="$(mktemp -d)/user@.service.d" @@ -1618,9 +1636,9 @@ _stub_install_steps() { cf="$(mktemp)"; echo "memory pids" > "$cf"; TB_USER_CGROUP_CONTROLLERS="$cf" # admin's slice is irrelevant here sudo() { "$@"; }; systemctl() { :; } run _write_cgroup_delegation - [[ "$output" == *"researcher's next login"* ]] - [[ "$output" != *"k3d cluster delete"* ]] # prepare-host creates no cluster - [[ "$output" != *"NOT active in this session"* ]] # doesn't judge on the admin's own slice + [[ "$output" == *"researcher's next login"* ]] || return 1 + [[ "$output" != *"k3d cluster delete"* ]] || return 1 # prepare-host creates no cluster + [[ "$output" != *"NOT active in this session"* ]] || return 1 # doesn't judge on the admin's own slice } # _ensure_cgroup_delegation is the ONLY full-install caller, and it short-circuits at @@ -1634,10 +1652,10 @@ _stub_install_steps() { PROBE_PRIVILEGE=no_sudo sudo() { record "sudo $*"; } run _ensure_cgroup_delegation - [[ "$output" == *"NOT active in this session"* ]] # NOT a silent "already present" log - [[ "$output" == *"k3d cluster delete"* ]] # full-install remedy (not prepare-host mode here) + [[ "$output" == *"NOT active in this session"* ]] || return 1 # NOT a silent "already present" log + [[ "$output" == *"k3d cluster delete"* ]] || return 1 # full-install remedy (not prepare-host mode here) run mock_calls - [ -z "$output" ] # …and still no sudo/systemctl (unprivileged read only) + [ -z "$output" ] || return 1 # …and still no sudo/systemctl (unprivileged read only) } @test "_ensure_cgroup_delegation: drop-in present AND active -> fast path confirms active, no privileged call (#514)" { @@ -1647,10 +1665,10 @@ _stub_install_steps() { PROBE_PRIVILEGE=no_sudo sudo() { record "sudo $*"; } run _ensure_cgroup_delegation - [[ "$output" == *"active in this session"* ]] - [[ "$output" != *"NOT active"* ]] + [[ "$output" == *"active in this session"* ]] || return 1 + [[ "$output" != *"NOT active"* ]] || return 1 run mock_calls - [ -z "$output" ] + [ -z "$output" ] || return 1 } # The prepare-host caller resets TB_PREPARE_HOST_MODE right after install_docker_engine, @@ -1671,8 +1689,8 @@ _stub_install_steps() { systemctl() { :; } sudo() { record "sudo $*"; return 0; } # fakes the drop-in write (idempotent path) run run_prepare_host - [ "$status" -eq 0 ] - [[ "$output" == *"researcher's next login"* ]] # mode-aware wording, not judged on the admin - [[ "$output" != *"k3d cluster delete"* ]] # prepare-host creates no cluster to recreate - [[ "$output" != *"NOT active in this session"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"researcher's next login"* ]] || return 1 # mode-aware wording, not judged on the admin + [[ "$output" != *"k3d cluster delete"* ]] || return 1 # prepare-host creates no cluster to recreate + [[ "$output" != *"NOT active in this session"* ]] || return 1 } diff --git a/scripts/tests/setup-macos-arch.bats b/scripts/tests/setup-macos-arch.bats index 6acbc8ad..e756792e 100644 --- a/scripts/tests/setup-macos-arch.bats +++ b/scripts/tests/setup-macos-arch.bats @@ -26,17 +26,17 @@ setup() { # ── _macos_supports_vz ─────────────────────────────────────────────────────── @test "_macos_supports_vz: macOS 13+ -> yes; 12 -> no; junk -> no (#433)" { - TB_MACOS_VER=14.5; run _macos_supports_vz; [ "$status" -eq 0 ] - TB_MACOS_VER=13.0; run _macos_supports_vz; [ "$status" -eq 0 ] - TB_MACOS_VER=12.7; run _macos_supports_vz; [ "$status" -ne 0 ] - TB_MACOS_VER=abc; run _macos_supports_vz; [ "$status" -ne 0 ] + TB_MACOS_VER=14.5; run _macos_supports_vz; [ "$status" -eq 0 ] || return 1 + TB_MACOS_VER=13.0; run _macos_supports_vz; [ "$status" -eq 0 ] || return 1 + TB_MACOS_VER=12.7; run _macos_supports_vz; [ "$status" -ne 0 ] || return 1 + TB_MACOS_VER=abc; run _macos_supports_vz; [ "$status" -ne 0 ] || return 1 } @test "_macos_supports_vz: undeterminable version (sw_vers empty) -> no (fail closed to QEMU) (#433)" { unset TB_MACOS_VER sw_vers() { echo ""; } # can't read the version → treat as unsupported run _macos_supports_vz - [ "$status" -ne 0 ] + [ "$status" -ne 0 ] || return 1 } # ── colima VZ/Rosetta flags ────────────────────────────────────────────────── @@ -57,39 +57,39 @@ _colima_env() { @test "_install_docker_colima: Apple Silicon + macOS 13+ -> colima start with VZ + Rosetta (#433)" { _colima_env; ARCH=arm64; TB_MACOS_VER=14.0 run _install_docker_colima - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"colima start"* ]] - [[ "$output" == *"--vm-type vz --vz-rosetta"* ]] + [[ "$output" == *"colima start"* ]] || return 1 + [[ "$output" == *"--vm-type vz --vz-rosetta"* ]] || return 1 } @test "_install_docker_colima: Apple Silicon + macOS 13+ but an EXISTING VM -> no VZ flags (colima rejects vmType change) (#433 Bugbot)" { _colima_env; ARCH=arm64; TB_MACOS_VER=14.0; COLIMA_HAS_INSTANCE=1 run _install_docker_colima - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"colima start"* ]] - [[ "$output" != *"--vm-type vz"* ]] # don't force a vmType change on the existing VM - [[ "$output" != *"--vz-rosetta"* ]] + [[ "$output" == *"colima start"* ]] || return 1 + [[ "$output" != *"--vm-type vz"* ]] || return 1 # don't force a vmType change on the existing VM + [[ "$output" != *"--vz-rosetta"* ]] || return 1 } @test "_install_docker_colima: Apple Silicon + macOS 12 -> QEMU default, no VZ flags (#433)" { _colima_env; ARCH=arm64; TB_MACOS_VER=12.7 run _install_docker_colima - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"colima start"* ]] - [[ "$output" != *"--vm-type vz"* ]] - [[ "$output" != *"--vz-rosetta"* ]] + [[ "$output" == *"colima start"* ]] || return 1 + [[ "$output" != *"--vm-type vz"* ]] || return 1 + [[ "$output" != *"--vz-rosetta"* ]] || return 1 } @test "_install_docker_colima: Intel Mac -> no VZ/Rosetta flags (amd64 native) (#433)" { _colima_env; ARCH=x86_64; TB_MACOS_VER=14.0 run _install_docker_colima - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"colima start"* ]] - [[ "$output" != *"--vm-type vz"* ]] + [[ "$output" == *"colima start"* ]] || return 1 + [[ "$output" != *"--vm-type vz"* ]] || return 1 } # ── assert_amd64_emulation (post-Docker smoke) ─────────────────────────────── @@ -97,41 +97,41 @@ _colima_env() { ARCH=x86_64 docker() { record "docker $*"; return 0; } run assert_amd64_emulation - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" != *"docker run"* ]] # native amd64 — nothing to probe + [[ "$output" != *"docker run"* ]] || return 1 # native amd64 — nothing to probe } @test "assert_amd64_emulation: Apple Silicon + working emulation -> forces linux/amd64, time-bounded, succeeds (#433)" { ARCH=arm64 docker() { record "docker $*"; return 0; } run assert_amd64_emulation - [ "$status" -eq 0 ] - [[ "$output" == *"amd64 emulation verified"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"amd64 emulation verified"* ]] || return 1 run mock_calls - [[ "$output" == *"docker run --rm --platform linux/amd64"* ]] - [[ "$output" == *"busybox:1.36 true"* ]] - [[ "$output" == *"spin_cmd_bounded 120 docker run"* ]] # bounded, not an unbounded spin_cmd (#433 Bugbot) + [[ "$output" == *"docker run --rm --platform linux/amd64"* ]] || return 1 + [[ "$output" == *"busybox:1.36 true"* ]] || return 1 + [[ "$output" == *"spin_cmd_bounded 120 docker run"* ]] || return 1 # bounded, not an unbounded spin_cmd (#433 Bugbot) } @test "assert_amd64_emulation: Apple Silicon + broken emulation -> hard fail naming the Rosetta setting (#433)" { ARCH=arm64 docker() { record "docker $*"; return 1; } # exec-format error / no emulation run assert_amd64_emulation - [ "$status" -ne 0 ] # error() exits — caught in the field before a crash-looping pod - [[ "$output" == *"Use Rosetta for x86_64/amd64 emulation"* ]] # names the exact setting - [[ "$output" == *"colima start --vm-type vz --vz-rosetta"* ]] # and the colima remedy - [[ "$output" == *"TRACEBLOC_ALLOW_ARM64=1"* ]] # and the escape hatch + [ "$status" -ne 0 ] || return 1 # error() exits — caught in the field before a crash-looping pod + [[ "$output" == *"Use Rosetta for x86_64/amd64 emulation"* ]] || return 1 # names the exact setting + [[ "$output" == *"colima start --vm-type vz --vz-rosetta"* ]] || return 1 # and the colima remedy + [[ "$output" == *"TRACEBLOC_ALLOW_ARM64=1"* ]] || return 1 # and the escape hatch } @test "assert_amd64_emulation: TRACEBLOC_ALLOW_ARM64 set -> skipped with a warning, no docker run (#433)" { ARCH=arm64; export TRACEBLOC_ALLOW_ARM64=1 docker() { record "docker $*"; return 1; } run assert_amd64_emulation - [ "$status" -eq 0 ] - [[ "$output" == *"Skipping the amd64 emulation smoke test"* ]] + [ "$status" -eq 0 ] || return 1 + [[ "$output" == *"Skipping the amd64 emulation smoke test"* ]] || return 1 run mock_calls - [[ "$output" != *"docker run"* ]] + [[ "$output" != *"docker run"* ]] || return 1 unset TRACEBLOC_ALLOW_ARM64 } @@ -139,7 +139,7 @@ _colima_env() { ARCH=arm64; TB_AMD64_SMOKE_IMAGE="alpine:3.20" docker() { record "docker $*"; return 0; } run assert_amd64_emulation - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"--platform linux/amd64 alpine:3.20 true"* ]] + [[ "$output" == *"--platform linux/amd64 alpine:3.20 true"* ]] || return 1 } diff --git a/scripts/tests/setup-macos-lifecycle.bats b/scripts/tests/setup-macos-lifecycle.bats index 0034e540..20e009ec 100644 --- a/scripts/tests/setup-macos-lifecycle.bats +++ b/scripts/tests/setup-macos-lifecycle.bats @@ -21,34 +21,34 @@ setup() { # ── _macos_user_is_admin ───────────────────────────────────────────────────── @test "_macos_user_is_admin: admin group -> yes; standard account -> no (#430)" { - TB_MACOS_ADMIN_GROUPS="staff admin everyone"; run _macos_user_is_admin; [ "$status" -eq 0 ] - TB_MACOS_ADMIN_GROUPS="staff everyone"; run _macos_user_is_admin; [ "$status" -ne 0 ] + TB_MACOS_ADMIN_GROUPS="staff admin everyone"; run _macos_user_is_admin; [ "$status" -eq 0 ] || return 1 + TB_MACOS_ADMIN_GROUPS="staff everyone"; run _macos_user_is_admin; [ "$status" -ne 0 ] || return 1 } @test "_macos_user_is_admin: root -> yes (#430)" { id() { [ "$1" = "-u" ] && echo 0 || echo "root wheel"; } run _macos_user_is_admin - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 } # ── _macos_require_admin (no-admin named remedy) ───────────────────────────── @test "_macos_require_admin: admin passes through silently (#430)" { TB_MACOS_ADMIN_GROUPS="staff admin" run _macos_require_admin - [ "$status" -eq 0 ] - [ -z "$output" ] # no scary output for a normal admin + [ "$status" -eq 0 ] || return 1 + [ -z "$output" ] || return 1 # no scary output for a normal admin } @test "_macos_require_admin: no-admin Mac -> hard fail with an ACCURATE remedy, not a generic sudo error (#430)" { TB_MACOS_ADMIN_GROUPS="staff everyone" # not an admin run _macos_require_admin - [ "$status" -ne 0 ] # error() exits — fails fast up front - [[ "$output" == *"isn't an administrator"* ]] - [[ "$output" == *"administrator rights"* ]] # the remedy that actually unblocks it - [[ "$output" == *"admin account"* ]] # …or install from an admin account - [[ "$output" != *"prepare-host"* ]] # NO macOS prepare-host exists (it errors on Darwin) (#430 Bugbot) - [[ "$output" != *"grant this account access"* ]] # not the re-run-as-non-admin loop (#430 Bugbot) - [[ "$output" != *"sudo authentication failed"* ]] # NOT the old generic message + [ "$status" -ne 0 ] || return 1 # error() exits — fails fast up front + [[ "$output" == *"isn't an administrator"* ]] || return 1 + [[ "$output" == *"administrator rights"* ]] || return 1 # the remedy that actually unblocks it + [[ "$output" == *"admin account"* ]] || return 1 # …or install from an admin account + [[ "$output" != *"prepare-host"* ]] || return 1 # NO macOS prepare-host exists (it errors on Darwin) (#430 Bugbot) + [[ "$output" != *"grant this account access"* ]] || return 1 # not the re-run-as-non-admin loop (#430 Bugbot) + [[ "$output" != *"sudo authentication failed"* ]] || return 1 # NOT the old generic message } # ── _install_macos_autostart (LaunchAgent) ─────────────────────────────────── @@ -57,17 +57,17 @@ setup() { _has_gui_session() { return 0; } TB_MACOS_AUTOSTART=0 _install_macos_autostart - [ "$TB_MACOS_AUTOSTART" = "1" ] + [ "$TB_MACOS_AUTOSTART" = "1" ] || return 1 local plist="$TB_LAUNCHAGENTS_DIR/io.tracebloc.runtime.plist" - [ -f "$plist" ] + [ -f "$plist" ] || return 1 grep -q '<key>RunAtLoad</key><true/>' "$plist" grep -q '<string>/usr/bin/open</string>' "$plist" grep -q '<string>-a</string>' "$plist" grep -q '<string>Docker</string>' "$plist" grep -q 'Library/Logs/tracebloc-autostart.log' "$plist" # per-user log, not shared /tmp (#430 Bugbot) - ! grep -q '/tmp/tracebloc-autostart.log' "$plist" + ! grep -q '/tmp/tracebloc-autostart.log' "$plist" || return 1 run mock_calls - [[ "$output" == *"launchctl"* ]] # registered for this session too + [[ "$output" == *"launchctl"* ]] || return 1 # registered for this session too } @test "_install_macos_autostart: headless Mac -> resilient LaunchDAEMON runs 'colima start' at BOOT (not a login-only agent) (#430 Bugbot)" { @@ -81,8 +81,8 @@ setup() { sudo() { record "sudo $*"; "$@"; } # passthrough so tee/mkdir really write _install_macos_autostart local plist="$TB_LAUNCHDAEMONS_DIR/io.tracebloc.runtime.plist" - [ -f "$plist" ] - [ -d "$HOME/Library/Logs" ] # log dir created, else launchd EX_CONFIG (#430 Bugbot) + [ -f "$plist" ] || return 1 + [ -d "$HOME/Library/Logs" ] || return 1 # log dir created, else launchd EX_CONFIG (#430 Bugbot) grep -q '<string>/bin/bash</string>' "$plist" # resilient wrapper, not a bare oneshot (#430 Bugbot) grep -q 'colima start' "$plist" grep -q 'colima stop --force' "$plist" # retry FORCE-stops to clear stale VZ state (#430 Bugbot) @@ -90,9 +90,9 @@ setup() { grep -q '<key>UserName</key>' "$plist" # runs as the install user, at boot grep -q '<key>EnvironmentVariables</key>' "$plist" # HOME/PATH for colima under launchd grep -q 'Library/Logs/tracebloc-autostart.log' "$plist" - ! grep -q '/tmp/tracebloc-autostart.log' "$plist" + ! grep -q '/tmp/tracebloc-autostart.log' "$plist" || return 1 run mock_calls - [[ "$output" == *"sudo launchctl"* ]] # registered in the SYSTEM domain + [[ "$output" == *"sudo launchctl"* ]] || return 1 # registered in the SYSTEM domain } @test "_install_macos_autostart: headless but colima NOT installed -> skip honestly, no bogus daemon, flag unset (#430 Bugbot)" { @@ -105,13 +105,13 @@ setup() { sudo() { record "sudo $*"; "$@"; } TB_MACOS_AUTOSTART=0 run _install_macos_autostart - [ "$status" -ne 0 ] # best-effort skip (caller's || true) - [[ "$output" == *"isn't installed"* ]] - [ ! -e "$TB_LAUNCHDAEMONS_DIR/io.tracebloc.runtime.plist" ] # no bogus daemon + [ "$status" -ne 0 ] || return 1 # best-effort skip (caller's || true) + [[ "$output" == *"isn't installed"* ]] || return 1 + [ ! -e "$TB_LAUNCHDAEMONS_DIR/io.tracebloc.runtime.plist" ] || return 1 # no bogus daemon command() { if [ "$2" = colima ]; then return 1; fi; builtin command "$@"; } _has_gui_session() { return 1; }; TB_MACOS_AUTOSTART=0 _install_macos_autostart || true - [ "${TB_MACOS_AUTOSTART:-0}" = "0" ] # summary won't falsely promise restart + [ "${TB_MACOS_AUTOSTART:-0}" = "0" ] || return 1 # summary won't falsely promise restart } @test "_install_macos_autostart: TRACEBLOC_NO_AUTOSTART -> skipped, nothing written, flag unset (#430 Bugbot)" { @@ -121,12 +121,12 @@ setup() { _has_gui_session() { return 0; } TB_MACOS_AUTOSTART=0 run _install_macos_autostart - [ "$status" -eq 0 ] # honored, no error - [ ! -e "$TB_LAUNCHAGENTS_DIR/io.tracebloc.runtime.plist" ] + [ "$status" -eq 0 ] || return 1 # honored, no error + [ ! -e "$TB_LAUNCHAGENTS_DIR/io.tracebloc.runtime.plist" ] || return 1 # flag stays unset -> the summary won't falsely promise auto-restart _has_gui_session() { return 0; }; TB_MACOS_AUTOSTART=0 _install_macos_autostart - [ "${TB_MACOS_AUTOSTART:-0}" = "0" ] + [ "${TB_MACOS_AUTOSTART:-0}" = "0" ] || return 1 unset TRACEBLOC_NO_AUTOSTART } @@ -135,12 +135,12 @@ setup() { _has_gui_session() { return 0; } TB_MACOS_AUTOSTART=0 run _install_macos_autostart - [ "$status" -ne 0 ] # returns 1 (best-effort), never aborts the caller - [[ "$output" == *"skipping login autostart"* ]] + [ "$status" -ne 0 ] || return 1 # returns 1 (best-effort), never aborts the caller + [[ "$output" == *"skipping login autostart"* ]] || return 1 # the flag stays unset so the summary is honest about the reboot story _has_gui_session() { return 0; }; TB_MACOS_AUTOSTART=0 _install_macos_autostart || true - [ "${TB_MACOS_AUTOSTART:-0}" = "0" ] + [ "${TB_MACOS_AUTOSTART:-0}" = "0" ] || return 1 } # ── _reboot_note reflects the configured autostart ─────────────────────────── @@ -160,18 +160,18 @@ setup() { install_docker_desktop(){ :; }; assert_amd64_emulation(){ :; }; install_macos_cli_tools(){ :; } _install_macos_autostart(){ return 1; } install_macos' - [ "$status" -eq 0 ] # install completes despite the autostart failure + [ "$status" -eq 0 ] || return 1 # install completes despite the autostart failure } @test "_reboot_note: macOS + autostart configured -> promises automatic restart (#430)" { OS=Darwin; TB_MACOS_AUTOSTART=1 run _reboot_note - [[ "$output" == *"restarts automatically"* ]] - [[ "$output" != *"open Docker Desktop"* ]] + [[ "$output" == *"restarts automatically"* ]] || return 1 + [[ "$output" != *"open Docker Desktop"* ]] || return 1 } @test "_reboot_note: macOS without autostart -> unchanged 'open Docker Desktop' line (golden-safe) (#430)" { OS=Darwin; TB_MACOS_AUTOSTART=0 run _reboot_note - [[ "$output" == *"open Docker Desktop to bring tracebloc back"* ]] + [[ "$output" == *"open Docker Desktop to bring tracebloc back"* ]] || return 1 } diff --git a/scripts/tests/setup-macos.bats b/scripts/tests/setup-macos.bats index 50ed2897..8844d913 100644 --- a/scripts/tests/setup-macos.bats +++ b/scripts/tests/setup-macos.bats @@ -18,6 +18,9 @@ setup() { # shellcheck source=/dev/null source "${LIB_DIR}/setup-macos.sh" LOG_FILE=/dev/null + # Fetch-test curl mocks write tiny fixture files; relax the #607 size floor so + # _assert_download_size does not reject them (the real floor applies in prod). + export TB_MIN_DOWNLOAD_BYTES=0 MOCK_CALLS="$(mktemp)" PRESENT_CMDS="curl tar gzip" ARCH_DL="amd64" @@ -32,9 +35,9 @@ setup() { @test "_verify_sha256: correct hash passes, wrong fails, empty expected fails closed (#429)" { local f="$BATS_TEST_TMPDIR/empty"; : > "$f" # 0-byte file → the well-known empty sha256 local empty_sha=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 - run _verify_sha256 "$empty_sha" "$f"; [ "$status" -eq 0 ] # matches (real sha256sum/shasum) - run _verify_sha256 deadbeefdeadbeef "$f"; [ "$status" -ne 0 ] # mismatch - run _verify_sha256 "" "$f"; [ "$status" -ne 0 ] # empty expected → fail closed + run _verify_sha256 "$empty_sha" "$f"; [ "$status" -eq 0 ] || return 1 # matches (real sha256sum/shasum) + run _verify_sha256 deadbeefdeadbeef "$f"; [ "$status" -ne 0 ] || return 1 # mismatch + run _verify_sha256 "" "$f"; [ "$status" -ne 0 ] || return 1 # empty expected → fail closed } @test "_verify_sha256: falls back to shasum when sha256sum is unavailable (macOS ships no sha256sum) (#429)" { @@ -43,9 +46,9 @@ setup() { command() { case "$2" in sha256sum) return 1 ;; *) builtin command "$@" ;; esac; } shasum() { record "shasum $*"; cat >/dev/null; return 0; } run _verify_sha256 abc123 "$f" - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"shasum -a 256 --check"* ]] # the macOS fallback tool was used + [[ "$output" == *"shasum -a 256 --check"* ]] || return 1 # the macOS fallback tool was used } # ── OS_DL platform selection in the shared fetchers ────────────────────────── @@ -71,21 +74,21 @@ _k3d_dl_setup_darwin() { @test "_fetch_k3d_release: OS_DL=darwin fetches + verifies the darwin asset, never the linux one (#429)" { _k3d_dl_setup_darwin run _fetch_k3d_release v5.9.0 amd64 - [ "$status" -eq 0 ] - [ -f "$TB_TOOLS_DIR/k3d" ] + [ "$status" -eq 0 ] || return 1 + [ -f "$TB_TOOLS_DIR/k3d" ] || return 1 run mock_calls - [[ "$output" == *"releases/download/v5.9.0/k3d-darwin-amd64"* ]] # darwin asset - [[ "$output" == *"sha256sum --check"* ]] # still verified - [[ "$output" != *"k3d-linux-"* ]] # not the linux asset + [[ "$output" == *"releases/download/v5.9.0/k3d-darwin-amd64"* ]] || return 1 # darwin asset + [[ "$output" == *"sha256sum --check"* ]] || return 1 # still verified + [[ "$output" != *"k3d-linux-"* ]] || return 1 # not the linux asset } @test "_fetch_k3d_release: OS_DL=darwin + checksum mismatch fails closed, nothing installed (#429)" { _k3d_dl_setup_darwin SHA_RC=1 run _fetch_k3d_release v5.9.0 amd64 - [ "$status" -ne 0 ] - [[ "$output" == *"checksum verification failed"* ]] - [ ! -f "$TB_TOOLS_DIR/k3d" ] + [ "$status" -ne 0 ] || return 1 + [[ "$output" == *"checksum verification failed"* ]] || return 1 + [ ! -f "$TB_TOOLS_DIR/k3d" ] || return 1 } @test "_fetch_kubectl: OS_DL=darwin uses the darwin download path (#429)" { @@ -98,10 +101,10 @@ _k3d_dl_setup_darwin() { return 0 } run _fetch_kubectl v1.29.4 amd64 - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"/bin/darwin/amd64/kubectl"* ]] - [[ "$output" != *"/bin/linux/"* ]] + [[ "$output" == *"/bin/darwin/amd64/kubectl"* ]] || return 1 + [[ "$output" != *"/bin/linux/"* ]] || return 1 } _helm_dl_setup_darwin() { @@ -132,13 +135,13 @@ _helm_dl_setup_darwin() { @test "_fetch_helm_release: OS_DL=darwin fetches + unpacks the darwin tarball (#429)" { _helm_dl_setup_darwin run _fetch_helm_release v4.2.3 amd64 - [ "$status" -eq 0 ] - [ -f "$TB_TOOLS_DIR/helm" ] + [ "$status" -eq 0 ] || return 1 + [ -f "$TB_TOOLS_DIR/helm" ] || return 1 run mock_calls - [[ "$output" == *"get.helm.sh/helm-v4.2.3-darwin-amd64.tar.gz"* ]] - [[ "$output" == *"helm-v4.2.3-darwin-amd64.tar.gz.sha256sum"* ]] - [[ "$output" == *"sha256sum --check"* ]] - [[ "$output" != *"-linux-"* ]] + [[ "$output" == *"get.helm.sh/helm-v4.2.3-darwin-amd64.tar.gz"* ]] || return 1 + [[ "$output" == *"helm-v4.2.3-darwin-amd64.tar.gz.sha256sum"* ]] || return 1 + [[ "$output" == *"sha256sum --check"* ]] || return 1 + [[ "$output" != *"-linux-"* ]] || return 1 } # ── install_macos_cli_tools: routes through the shared pinned installers ────── @@ -149,13 +152,13 @@ _helm_dl_setup_darwin() { install_helm() { record "install_helm OS_DL=$OS_DL"; success "System tools"; } brew() { record "brew $*"; } # must NOT be used for the CLI tools run install_macos_cli_tools - [ "$status" -eq 0 ] + [ "$status" -eq 0 ] || return 1 run mock_calls - [[ "$output" == *"install_kubectl OS_DL=darwin DIR=/usr/local/bin SUDO=sudo"* ]] - [[ "$output" == *"install_k3d OS_DL=darwin"* ]] - [[ "$output" == *"install_helm OS_DL=darwin"* ]] - [[ "$output" == *"sudo mkdir -p /usr/local/bin"* ]] - [[ "$output" != *"brew install kubectl"* ]] # pins no longer floated by brew - [[ "$output" != *"brew install k3d"* ]] - [[ "$output" != *"brew install helm"* ]] + [[ "$output" == *"install_kubectl OS_DL=darwin DIR=/usr/local/bin SUDO=sudo"* ]] || return 1 + [[ "$output" == *"install_k3d OS_DL=darwin"* ]] || return 1 + [[ "$output" == *"install_helm OS_DL=darwin"* ]] || return 1 + [[ "$output" == *"sudo mkdir -p /usr/local/bin"* ]] || return 1 + [[ "$output" != *"brew install kubectl"* ]] || return 1 # pins no longer floated by brew + [[ "$output" != *"brew install k3d"* ]] || return 1 + [[ "$output" != *"brew install helm"* ]] || return 1 } diff --git a/scripts/tests/summary.bats b/scripts/tests/summary.bats index e545481a..e1ac82ca 100644 --- a/scripts/tests/summary.bats +++ b/scripts/tests/summary.bats @@ -12,25 +12,25 @@ setup() { @test "_diagnose_not_ready: jobs-manager auth error -> bad_creds" { kubectl() { case "$*" in *logs*) echo "Exception: Authentication failed: Unable to log in with provided credentials";; *) echo "x 0/2 CrashLoopBackOff";; esac; } run _diagnose_not_ready testns - [ "$output" = "bad_creds" ] + [ "$output" = "bad_creds" ] || return 1 } @test "_diagnose_not_ready: ImagePullBackOff -> image_pull" { kubectl() { case "$*" in *logs*) echo "booting";; *) echo "x 0/1 ImagePullBackOff";; esac; } run _diagnose_not_ready testns - [ "$output" = "image_pull" ] + [ "$output" = "image_pull" ] || return 1 } @test "_diagnose_not_ready: CrashLoopBackOff (no auth err) -> crash" { kubectl() { case "$*" in *logs*) echo "booting";; *) echo "x 0/1 CrashLoopBackOff";; esac; } run _diagnose_not_ready testns - [ "$output" = "crash" ] + [ "$output" = "crash" ] || return 1 } @test "_diagnose_not_ready: still creating -> starting" { kubectl() { case "$*" in *logs*) echo "booting";; *) echo "x 0/1 ContainerCreating";; esac; } run _diagnose_not_ready testns - [ "$output" = "starting" ] + [ "$output" = "starting" ] || return 1 } # ── wait_for_client_ready ────────────────────────────────────────────────── @@ -39,7 +39,7 @@ setup() { READY_TIMEOUT=20 CLIENT_STATE="" wait_for_client_ready - [ "$CLIENT_STATE" = "connected" ] + [ "$CLIENT_STATE" = "connected" ] || return 1 } @test "wait_for_client_ready: a rollout fails -> diagnosed (bad_creds)" { @@ -53,7 +53,7 @@ setup() { READY_TIMEOUT=20 CLIENT_STATE="" wait_for_client_ready - [ "$CLIENT_STATE" = "bad_creds" ] + [ "$CLIENT_STATE" = "bad_creds" ] || return 1 } # ── print_summary: the trust claim must appear ONLY when connected ───────── @@ -61,60 +61,60 @@ setup() { CLIENT_STATE=connected TB_CLI_USABLE_NOW=1 # pin CLI-usable so the CTA is the deterministic "Run …" variant (B2) run print_summary - [[ "$output" == *"Connected to tracebloc"* ]] - [[ "$output" == *"never leaves this machine"* ]] # trust claim (was "data never leaves") + [[ "$output" == *"Connected to tracebloc"* ]] || return 1 + [[ "$output" == *"never leaves this machine"* ]] || return 1 # trust claim (was "data never leaves") # rich summary from the run-through - [[ "$output" == *"Environment"* ]] - [[ "$output" == *"Mode"* ]] - [[ "$output" == *"Your secure environment is live"* ]] # live-status heading (lime ● replaced the 🟢 emoji) - [[ "$output" == *"What's next"* ]] - [[ "$output" == *"tracebloc data ingest"* ]] - [[ "$output" == *"my-use-cases"* ]] - [[ "$output" == *"Run"* && "$output" == *"to get started"* ]] + [[ "$output" == *"Environment"* ]] || return 1 + [[ "$output" == *"Mode"* ]] || return 1 + [[ "$output" == *"Your secure environment is live"* ]] || return 1 # live-status heading (lime ● replaced the 🟢 emoji) + [[ "$output" == *"What's next"* ]] || return 1 + [[ "$output" == *"tracebloc data ingest"* ]] || return 1 + [[ "$output" == *"my-use-cases"* ]] || return 1 + [[ "$output" == *"Run"* && "$output" == *"to get started"* ]] || return 1 } @test "print_summary connected: shows the client version" { CLIENT_STATE=connected helm() { echo "tracebloc tracebloc 1 now deployed client-1.4.4 1.4.4"; } run print_summary - [[ "$output" == *"Version"* ]] - [[ "$output" == *"1.4.4"* ]] + [[ "$output" == *"Version"* ]] || return 1 + [[ "$output" == *"1.4.4"* ]] || return 1 } @test "print_summary starting: 'still starting', no trust claim" { CLIENT_STATE=starting run print_summary - [[ "$output" == *"still starting"* ]] - [[ "$output" != *"never leaves this machine"* ]] + [[ "$output" == *"still starting"* ]] || return 1 + [[ "$output" != *"never leaves this machine"* ]] || return 1 } @test "print_summary bad_creds: 'rejected', no trust claim" { CLIENT_STATE=bad_creds run print_summary - [[ "$output" == *"rejected"* ]] - [[ "$output" != *"never leaves this machine"* ]] + [[ "$output" == *"rejected"* ]] || return 1 + [[ "$output" != *"never leaves this machine"* ]] || return 1 } @test "print_summary image_pull: image message, no trust claim" { CLIENT_STATE=image_pull run print_summary - [[ "$output" == *"image couldn't be pulled"* ]] - [[ "$output" != *"never leaves this machine"* ]] + [[ "$output" == *"image couldn't be pulled"* ]] || return 1 + [[ "$output" != *"never leaves this machine"* ]] || return 1 } @test "print_summary crash: crash-loop message" { CLIENT_STATE=crash run print_summary - [[ "$output" == *"crash loop"* ]] - [[ "$output" != *"never leaves this machine"* ]] + [[ "$output" == *"crash loop"* ]] || return 1 + [[ "$output" != *"never leaves this machine"* ]] || return 1 } # ── _reboot_note (reboot persistence) ─────────────────────────────────────── @test "_reboot_note: Linux with docker autostart -> survives-reboot line" { OS=Linux; TB_DOCKER_AUTOSTART=1 run _reboot_note - [[ "$output" == *"restarts automatically"* ]] - [[ "$output" != *"Docker Desktop"* ]] + [[ "$output" == *"restarts automatically"* ]] || return 1 + [[ "$output" != *"Docker Desktop"* ]] || return 1 } @test "_reboot_note: Linux without docker autostart -> honest 'start Docker' line" { @@ -122,21 +122,21 @@ setup() { # promise an automatic restart (Bugbot r3645585369). OS=Linux; TB_DOCKER_AUTOSTART=0 run _reboot_note - [[ "$output" == *"start Docker"* ]] - [[ "$output" != *"restarts automatically"* ]] + [[ "$output" == *"start Docker"* ]] || return 1 + [[ "$output" != *"restarts automatically"* ]] || return 1 } @test "_reboot_note: macOS -> Docker Desktop start-on-login instruction" { OS=Darwin run _reboot_note - [[ "$output" == *"Docker Desktop"* ]] - [[ "$output" == *"open Docker Desktop"* ]] + [[ "$output" == *"Docker Desktop"* ]] || return 1 + [[ "$output" == *"open Docker Desktop"* ]] || return 1 } @test "print_summary connected: includes the reboot note" { CLIENT_STATE=connected; OS=Linux; TB_DOCKER_AUTOSTART=1 run print_summary - [[ "$output" == *"restarts automatically"* ]] + [[ "$output" == *"restarts automatically"* ]] || return 1 } @test "print_summary connected: node-local storage -> in-node data path, no host /tracebloc" { @@ -144,14 +144,14 @@ setup() { # (RFC-0003 Option C) — the summary must not point at one (Bugbot r3645585376). CLIENT_STATE=connected; OS=Linux; TB_DOCKER_AUTOSTART=1; TB_STORAGE_MODE=node-local run print_summary - [[ "$output" == *"in-node (k3s local-path)"* ]] - [[ "$output" != *"Data /tracebloc/"* ]] + [[ "$output" == *"in-node (k3s local-path)"* ]] || return 1 + [[ "$output" != *"Data /tracebloc/"* ]] || return 1 } @test "print_summary connected: hostpath storage -> host /tracebloc data path" { CLIENT_STATE=connected; OS=Linux; TB_DOCKER_AUTOSTART=1; TB_STORAGE_MODE=hostpath run print_summary - [[ "$output" == *"Data /tracebloc/testns"* ]] + [[ "$output" == *"Data /tracebloc/testns"* ]] || return 1 } # ── B2: PATH-aware CTA (grep-based so a false check fails loudly on bash 3.2) ── @@ -161,7 +161,7 @@ setup() { TB_CLI_USABLE_NOW=1 run print_summary printf '%s\n' "$output" | grep -qE "Run[[:space:]]+tracebloc" # the "Run …" branch specifically - ! printf '%s\n' "$output" | grep -qF "Open a new terminal" + ! printf '%s\n' "$output" | grep -qF "Open a new terminal" || return 1 } @test "print_summary connected: CTA says 'open a new terminal' when persisted but this shell can't see it yet (case A, B2)" { @@ -180,7 +180,7 @@ setup() { has() { [ "$1" = tracebloc ] && return 1; command -v "$1" >/dev/null 2>&1; } run print_summary printf '%s\n' "$output" | grep -qF "Add tracebloc to your PATH" # matches install-cli.sh's PATH-fix step - ! printf '%s\n' "$output" | grep -qF "Open a new terminal" # never the useless new-terminal advice + ! printf '%s\n' "$output" | grep -qF "Open a new terminal" || return 1 # never the useless new-terminal advice } @test "print_summary connected: UNSET fresh-path flag falls back to 'open a new terminal', not the 'see above' PATH fix (#371)" { @@ -193,7 +193,7 @@ setup() { has() { [ "$1" = tracebloc ] && return 1; command -v "$1" >/dev/null 2>&1; } run print_summary printf '%s\n' "$output" | grep -qF "Open a new terminal" - ! printf '%s\n' "$output" | grep -qF "Add tracebloc to your PATH" + ! printf '%s\n' "$output" | grep -qF "Add tracebloc to your PATH" || return 1 } # ── CA-trust diagnosis (#424) ──────────────────────────────────────────────── @@ -206,7 +206,7 @@ setup() { esac } run _diagnose_not_ready testns - [ "$output" = "image_pull_ca" ] + [ "$output" = "image_pull_ca" ] || return 1 } @test "_diagnose_not_ready: ImagePullBackOff without x509 stays image_pull (#424)" { @@ -218,7 +218,7 @@ setup() { esac } run _diagnose_not_ready testns - [ "$output" = "image_pull" ] + [ "$output" = "image_pull" ] || return 1 } @test "_diagnose_not_ready: x509 on an unrelated event (not the pull) stays image_pull (Bugbot #424)" { @@ -235,15 +235,15 @@ setup() { esac } run _diagnose_not_ready testns - [ "$output" = "image_pull" ] + [ "$output" = "image_pull" ] || return 1 } @test "print_summary image_pull_ca: names the CA problem + env var, not a generic pull error (#424)" { CLIENT_STATE=image_pull_ca TB_NAMESPACE=testns run print_summary - [[ "$output" == *"TLS-inspection CA"* ]] - [[ "$output" == *"TRACEBLOC_CA_BUNDLE"* ]] - [[ "$output" == *"x509"* ]] - [[ "$output" != *"an image couldn't be pulled"* ]] # not the generic message + [[ "$output" == *"TLS-inspection CA"* ]] || return 1 + [[ "$output" == *"TRACEBLOC_CA_BUNDLE"* ]] || return 1 + [[ "$output" == *"x509"* ]] || return 1 + [[ "$output" != *"an image couldn't be pulled"* ]] || return 1 # not the generic message } diff --git a/scripts/tests/unenforced-assertions.awk b/scripts/tests/unenforced-assertions.awk new file mode 100644 index 00000000..cd5f0322 --- /dev/null +++ b/scripts/tests/unenforced-assertions.awk @@ -0,0 +1,393 @@ +# unenforced-assertions.awk — list assertions that cannot fail their bats test. +# +# Bats runs a test body under errexit, but two classes of assertion escape it, so +# a failing one that is not the LAST command in the body is silently ignored: +# +# [[ ... ]] on bash 3.2 — the system bash on macOS — errexit does not fire +# for a failing conditional expression +# ! cmd POSIX: errexit never propagates a status that was inverted with +# '!', on every bash +# +# The suite's convention is that every standalone assertion inside an @test body +# ends in `|| return 1`; this reports the ones that don't. `[ ... ]` is held to +# the same convention: it does trip errexit today, but the reader cannot tell +# `[` from `[[` at a glance, so both carry the marker. +# +# Usage: awk -f unenforced-assertions.awk scripts/tests/*.bats +# Output: file:line: text (empty output = clean) +# +# Reported: +# - standalone `[[ ... ]]` / `[ ... ]`, negated or not, whether written on one +# line or continued over several (trailing backslash, or a newline after an +# `||`/`&&` inside the brackets). An `||` or `&&` INSIDE the brackets does +# not make the assertion enforcing, so it does not buy an exemption; only a +# top-level chain does. Multi-line assertions are reported, and printed +# joined, at their FIRST line. +# - standalone `! cmd ...` — a negated bare command. +# +# Deliberately NOT reported: +# - plain bare commands (`grep -q ...`): errexit does fire for those +# - control flow (if/elif/while/until/case) — a condition, not an assertion +# - lines already chained at the top level with && or || (`[[ x ]] || fail`) +# - anything outside an @test body: helpers and setup/teardown, where a bare +# `return` means something different +# - heredoc bodies, so a test that embeds example bats source (fixtures) is not +# mistaken for real assertions + +BEGIN { NOCLOSE = "\001" } # sentinel: not a bracket, or bracket still open + +function trim(s) { sub(/^[[:space:]]+/, "", s); sub(/[[:space:]]+$/, "", s); return s } + +# Does s start with the bracket opener `op` followed by whitespace? +function opens(s, op, nxt) { + if (substr(s, 1, length(op)) != op) return 0 + nxt = substr(s, length(op) + 1, 1) + return (nxt == " " || nxt == "\t") +} + +# Text following the closer that matches `op`, or NOCLOSE when s does not open +# that bracket or the closer has not appeared yet. The closer only counts when it +# is UNQUOTED and a word of its own (blank before, blank or end-of-line after), so +# a blank-delimited `]]` inside a quoted pattern (`[[ "$x" == "a ]] b" ]]`) is not +# mistaken for the end of the assertion — matching the quote-awareness of the other +# structural walkers (Bugbot). +function after_close(s, op, cl, rest, p, hit, cp, before, after) { + if (!opens(s, op)) return NOCLOSE + rest = substr(s, length(op) + 1) + p = 1 + while (p <= length(rest)) { + hit = index(substr(rest, p), cl) + if (hit == 0) return NOCLOSE + cp = p + hit - 1 + before = (cp > 1) ? substr(rest, cp - 1, 1) : "" + after = substr(rest, cp + length(cl)) + if (!quoted_at(rest, cp) && (before == " " || before == "\t") && (after == "" || after ~ /^[[:space:]]/)) + return after + p = cp + 1 + } + return NOCLOSE +} + +# As after_close, for whichever bracket form the (trimmed, optionally negated) +# line uses. NOCLOSE when it is not a bracket assertion or is still open. +function bracket_tail(line, s, r) { + s = trim(line) + sub(/^![[:space:]]*/, "", s) + r = after_close(s, "[[", "]]") + if (r == NOCLOSE) r = after_close(s, "[", "]") + return r +} + +# Is this a bracket assertion whose closer has not been reached yet? True whether +# the `[[`/`[` opens the whole line OR opens the LAST statement of a `;`-compound +# (`run x; [[ a ||` continued onto the next line) — the latter would otherwise never +# be joined, so a multi-line compound bracket stayed invisible (Bugbot). +function bracket_open(line, s, seg_arr, n) { + s = trim(line) + sub(/^![[:space:]]*/, "", s) + if (opens(s, "[[") || opens(s, "[")) + return (bracket_tail(line) == NOCLOSE) + + n = split_segments(strip_comment(line), seg_arr) + if (n > 1) { + s = trim(seg_arr[n]) + sub(/^![[:space:]]*/, "", s) + if (opens(s, "[[") || opens(s, "[")) + return (bracket_tail(seg_arr[n]) == NOCLOSE) + } + return 0 +} + +# Report `logical` once if ANY of its top-level (`;`-separated) statements is an +# unhardened assertion. Splitting on unquoted `;` lets us see an assertion that is +# not the LAST command of a compound or one-line body — `run x; [[ y ]]`, or +# `@test "…" { run x; [ y ]; }` — which a line-start-only match would miss (Bugbot). +function classify(logical, fnr, code, seg_arr, n, i) { + # Strip any trailing/whole-line comment BEFORE splitting: a `;` inside a comment + # (`# … run foo; [ x ]`) must not be treated as a statement separator, or the + # comment's text is mis-read as a bare assertion. + code = strip_comment(logical) + n = split_segments(code, seg_arr) + for (i = 1; i <= n; i++) { + if (stmt_is_unhardened_assertion(trim(seg_arr[i]))) { + printf "%s:%d: %s\n", FILENAME, fnr, logical + return + } + } +} + +# Is one statement a standalone `[[ … ]]` / `[ … ]` / `! cmd` that lacks a +# top-level `|| return 1`? An `||`/`&&` INSIDE the brackets does not exempt it +# (bracket_tail looks only AFTER the closer); a real top-level chain does. +function stmt_is_unhardened_assertion(seg, word, tail) { + if (seg == "") return 0 + if (is_enforcing(seg)) return 0 # a REAL, unquoted, uncommented `|| return 1` + if (seg ~ /^#/) return 0 # comment + + # control flow — a condition, not an assertion. Compared as a word rather than + # with \b, which is not portable across awk implementations. + word = seg + sub(/[[:space:]].*$/, "", word) + if (word == "if" || word == "elif" || word == "while" || word == "until" || word == "case") + return 0 + + # standalone bracket assertion — internal ||/&& is not a top-level chain + tail = bracket_tail(seg) + if (tail != NOCLOSE && (trim(tail) == "" || trim(tail) ~ /^#/)) + return 1 + + if (has_toplevel_chain(seg)) return 0 # already chained at top level + + # standalone negated bare command: `! cmd ...` + if (seg ~ /^![[:space:]]*[^[:space:]]/) + return 1 + return 0 +} + +# Is position `pos` of s inside a quoted string? Walks shell quoting state from the +# start of the line: '...' suppresses ", "..." suppresses ', and a backslash escapes +# the next character everywhere except inside single quotes. +function quoted_at(s, pos, i, c, sq, dq) { + sq = 0; dq = 0 + for (i = 1; i < pos; i++) { + c = substr(s, i, 1) + if (c == "\\" && !sq) { i++ } + else if (c == "'" && !dq) { sq = !sq } + else if (c == "\"" && !sq) { dq = !dq } + } + return (sq || dq) +} + +# The line with any UNQUOTED trailing comment removed: everything up to the first +# `#` that starts a word (line start or after whitespace) and sits outside quotes. +# A `#` inside a quoted pattern, or mid-word, is not a comment. +function strip_comment(s, i, c, sq, dq) { + sq = 0; dq = 0; i = 1 + while (i <= length(s)) { + c = substr(s, i, 1) + if (c == "\\" && !sq) { i += 2; continue } + else if (c == "'" && !dq) { sq = !sq } + else if (c == "\"" && !sq) { dq = !dq } + else if (c == "#" && !sq && !dq && (i == 1 || substr(s, i - 1, 1) ~ /[[:space:]]/)) + return substr(s, 1, i - 1) + i++ + } + return s +} + +# Is the line actually hardened — a REAL `|| return 1` that is CODE, not text? A +# line-wide substring match spared an unhardened `[[ … *"|| return 1"* ]]` (the +# marker inside a quoted pattern) or `[[ … ]] # … || return 1` (only in a trailing +# comment), since neither actually enforces (Bugbot). Require the `||` to be outside +# quotes and outside the comment. +function is_enforcing(logical, code, i, c, sq, dq, pd) { + # ...and outside PARENS: a `|| return 1` inside `( )`/`$( )` is not + # hardening -- in `! ( cmd || return 1 )` the return only exits the + # subshell while the `!` still escapes errexit, so the statement stays + # advisory, yet the old quote-only scan spared it (Bugbot). Same + # quote+paren walker as has_toplevel_chain. + code = strip_comment(logical) + sq = 0; dq = 0; pd = 0 + for (i = 1; i <= length(code); i++) { + c = substr(code, i, 1) + if (c == "\\" && !sq) { i++ } + else if (c == "'" && !dq) { sq = !sq } + else if (c == "\"" && !sq) { dq = !dq } + else if (sq || dq) { continue } + else if (c == "(") { pd++ } + else if (c == ")") { if (pd > 0) pd-- } + else if (pd > 0) { continue } + else if (substr(code, i, 11) == "|| return 1") return 1 + } + return 0 +} + +# The heredoc tag this line opens, or "" if it opens none. A `<<TAG` INSIDE a quoted +# string is text, not a redirection: `printf "cat <<'EOF'"` was putting the scanner +# into heredoc-skip mode with no bare terminator to leave it again, so every later +# line in that file was silently ignored (Bugbot). Same for a `<<TAG` in a trailing +# `#` comment — a COMMENT documenting heredocs opened skip mode too, swallowing the +# rest of the @test body, so the scan runs on the comment-stripped line (Bugbot). +# strip_comment returns a prefix, so every position in `code` matches `line`. +function heredoc_tag_of(line, code, s, off, pos, tag) { + code = strip_comment(line) + s = code; off = 0 + while (match(s, /<<-?[[:space:]]*['"]?[A-Za-z_][A-Za-z0-9_]*['"]?/)) { + pos = off + RSTART + # `<<<` is a herestring, not a heredoc: `run cmd <<< "r"` matched here from + # the second `<` and opened a body that never closed (leftover-guard.bats). + if (pos > 1 && substr(code, pos - 1, 1) == "<") { } + else if (!quoted_at(code, pos)) { + tag = substr(s, RSTART, RLENGTH) + sub(/^<<-?[[:space:]]*/, "", tag) + gsub(/['"]/, "", tag) + sub(/[^A-Za-z0-9_].*$/, "", tag) + return tag + } + off = off + RSTART + RLENGTH - 1 + s = substr(s, RSTART + RLENGTH) + } + return "" +} + +# Split a line into its top-level statements on UNQUOTED `;`, filling arr[1..n] +# and returning n. A `;` inside a quoted pattern (`[[ "$x" == *";"* ]]`) or inside +# a `( … )` subshell / `$( … )` substitution (`! ( a; b ) || return 1`) does not +# split. This is how a compound / one-line body is broken into individually +# checkable assertions. +function split_segments(line, arr, i, c, sq, dq, pd, start, n) { + sq = 0; dq = 0; pd = 0; start = 1; n = 0 + for (i = 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (c == "\\" && !sq) { i++ } + else if (c == "'" && !dq) { sq = !sq } + else if (c == "\"" && !sq) { dq = !dq } + else if (sq || dq) { continue } + else if (c == "(") { pd++ } + else if (c == ")") { if (pd > 0) pd-- } + else if (c == ";" && pd == 0) { n++; arr[n] = substr(line, start, i - start); start = i + 1 } + } + n++; arr[n] = substr(line, start) + return n +} + +# Net UNQUOTED brace balance of a line (`{` = +1, `}` = -1). Used to follow the +# test body across a nested `name() { … }` stub so its closing `}` is not mistaken +# for the end of the @test (Bugbot). `${var}` balances to 0; a brace in a quoted +# string is ignored, and callers pass strip_comment(line) so a brace in a trailing +# comment is ignored too. +function brace_delta(s, i, c, sq, dq, d) { + sq = 0; dq = 0; d = 0 + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (c == "\\" && !sq) { i++ } + else if (c == "'" && !dq) { sq = !sq } + else if (c == "\"" && !sq) { dq = !dq } + else if (c == "{" && !sq && !dq) { d++ } + else if (c == "}" && !sq && !dq) { d-- } + } + return d +} + +# The body of a `@test "name" { … }` line: everything after the first UNQUOTED `{`. +# "" when the line opens no brace. +function after_first_brace(s, i, c, sq, dq) { + sq = 0; dq = 0 + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (c == "\\" && !sq) { i++ } + else if (c == "'" && !dq) { sq = !sq } + else if (c == "\"" && !sq) { dq = !dq } + else if (c == "{" && !sq && !dq) return substr(s, i + 1) + } + return "" +} + +# Drop a one-line @test's group-closing `}` — the LAST unquoted `}` — and anything +# after it. A bracket assertion sitting directly before it (`{ … [ a ] }`, or the +# no-space `[ a ]}` where the closer is not even recognised) would otherwise keep a +# `}` in its post-closer tail and read as non-standalone, so the one-liner stays +# invisible. (Valid bats needs a `;` before `}`, which already splits it off — this +# is belt-and-suspenders for the degenerate shapes, Bugbot.) +function strip_group_close(s, i, c, sq, dq, last) { + sq = 0; dq = 0; last = 0 + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (c == "\\" && !sq) { i++ } + else if (c == "'" && !dq) { sq = !sq } + else if (c == "\"" && !sq) { dq = !dq } + else if (c == "}" && !sq && !dq) last = i + } + return (last > 0) ? substr(s, 1, last - 1) : s +} + +# Is there a `||` or `&&` at the TOP level — outside quotes AND outside a `( )` / +# `$( )`? A `||`/`&&` that appears only inside a pattern (`! grep -q "a||b"`) or a +# subshell is not a chain that makes the statement enforcing, so it must not buy an +# exemption (Bugbot). +function has_toplevel_chain(s, i, c, sq, dq, pd) { + sq = 0; dq = 0; pd = 0 + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (c == "\\" && !sq) { i++ } + else if (c == "'" && !dq) { sq = !sq } + else if (c == "\"" && !sq) { dq = !dq } + else if (sq || dq) { continue } + else if (c == "(") { pd++ } + else if (c == ")") { if (pd > 0) pd-- } + else if (pd > 0) { continue } + else if (substr(s, i, 2) == "||" || substr(s, i, 2) == "&&") return 1 + } + return 0 +} + +FILENAME != prev { prev = FILENAME; depth = 0; in_heredoc = 0; heredoc_tag = ""; pending = ""; parts = 0 } + +# ── heredoc tracking: skip the body so embedded fixture code isn't scanned ── +in_heredoc { + # Safety valve: a heredoc body cannot span an @test at column 0, so even a + # mis-detected opener can never swallow more than one test's worth of lines. + if ($0 ~ /^@test/) { in_heredoc = 0; heredoc_tag = "" } + else { + line = trim($0) + if (line == heredoc_tag) { in_heredoc = 0; heredoc_tag = "" } + next + } +} +{ + tag = heredoc_tag_of($0) + if (tag != "") { + # Count this opener's own braces before skipping the body: the `{` of + # `helm() { cat <<'EOF'` belongs to the test-body brace depth, but the + # heredoc body we skip must not be counted. + if (depth > 0) { depth += brace_delta(strip_comment($0)); if (depth < 0) depth = 0 } + in_heredoc = 1; heredoc_tag = tag; pending = ""; parts = 0; next + } +} + +# ── @test opener: a one-line body, or the start of a multi-line one. Track the +# body by brace depth (not the first column-0 `}`) so a nested `name() { … }` +# closer does not end the scan early (Bugbot), and scan any inline body so +# one-line tests are not skipped (Bugbot). ── +/^@test/ { + body = strip_group_close(after_first_brace(strip_comment($0))) + if (trim(body) != "") classify(body, FNR) + depth = brace_delta(strip_comment($0)) + if (depth < 0) depth = 0 + pending = ""; parts = 0; next +} + +depth <= 0 { pending = ""; parts = 0; next } # outside any @test body + +# ── inside a test body ── +{ + d = brace_delta(strip_comment($0)) + + cur = $0 + if (pending != "") { + sub(/^[[:space:]]+/, "", cur) + logical = pending " " cur + at = pending_fnr + } else { + logical = cur + at = FNR + } + + # keep joining while the logical line is unfinished: trailing backslash, or a + # bracket assertion whose closer is on a later line. Bounded so an unbalanced + # line cannot swallow the rest of the body and hide real offenders. + if (logical ~ /\\[[:space:]]*$/ || bracket_open(logical)) { + if (parts < 8) { + sub(/[[:space:]]*\\[[:space:]]*$/, "", logical) + pending = logical; pending_fnr = at; parts++ + depth += d; if (depth <= 0) { depth = 0; pending = ""; parts = 0 } + next + } + } + pending = ""; parts = 0 + + classify(logical, at) + + depth += d + if (depth <= 0) depth = 0 +}