/** * Prove the CLI install.sh actually installed is the CLI this bake packed. * * `image-freshness` stamps the fingerprint of the STAGED tarballs * (pack-manifest.json). That is an attestation of intent: it says what the bake * meant to install, never what it installed. The two diverge whenever the * tarballs reach the container by a path that can go stale — the npm-registry * sim COPYs `.npm-registry-cache/` at docker-BUILD time, so a sim image that * failed to rebuild keeps serving last week's tarball while the manifest beside * it describes today's tree (celilo#1423, and the staging half in celilo#1299). * * Measured 2026-09-25: doctor reported `baked from this tree (2496fd8eaf07f66f)` * over an image whose installed `@celilo/cli` was the pre-fix revision. A * 16-minute run on a P1 was then read as falsifying a correct hypothesis — a fix * that did not ship looks exactly like a fix that did not work, and this gate is * the one instrument whose job is telling those apart. * * So: compare bytes. Copy the staged tarball into the container, digest the * files it contains from BOTH the extracted tarball and the installed package, * and fail the bake when they differ — before `docker commit` can stamp a label * that would outlive the mistake. Fail-closed at bake time beats a warning at * doctor time: the wrong image never gets committed. * * Only paths present in the TARBALL are digested. An installer is free to add * files (bun does not, today) and a test that forbade it would fail for a reason * nobody cares about; a tarball file that is MISSING from the install still * fails, because sha256sum errors on it. */ /** Where install.sh leaves the global @celilo/cli inside the management image. */ export const INSTALLED_CLI_DIR = '/root/.bun/install/global/node_modules/@celilo/cli'; /** Container paths the probe uses for its own scratch. */ const STAGED_TARBALL = '/tmp/cli-staged.tgz'; const STAGED_DIR = '/tmp/cli-staged'; /** * The shell script that prints both digests. * * Returned as text for the caller to base64 into the container. It must NOT be * interpolated into a dockerExec command directly: dockerExec wraps the command * as `bash -c ""` and hands it to a HOST shell, which expands `$(...)` * and `$VAR` itself before the container ever sees them (celilo#1365 lost days * to exactly that, via `$?`). base64 has no metacharacters. */ export function digestScript(installedDir: string = INSTALLED_CLI_DIR): string { return [ '#!/bin/bash', 'set -euo pipefail', `rm -rf ${STAGED_DIR}`, `mkdir -p ${STAGED_DIR}`, `tar -xzf ${STAGED_TARBALL} -C ${STAGED_DIR}`, // npm/bun tarballs root everything under `package/`. `S=${STAGED_DIR}/package`, `I=${installedDir}`, // One list, taken from the tarball, digested against both trees — so the // comparison is over the same paths in the same order on both sides. // sha256sum on the container, shasum on a macOS dev box. Resolved once so // the gate below is runnable everywhere — a test that only runs in CI is a // test nobody has watched fail. 'if command -v sha256sum >/dev/null; then SUM="sha256sum"; else SUM="shasum -a 256"; fi', 'cd "$S" && find . -type f -print | LC_ALL=C sort > /tmp/cli-files', // `while read` rather than xargs: portable, and it keeps one file per line // so a missing file still errors the pipeline under `set -o pipefail`. 'digest() { (cd "$1" && while IFS= read -r f; do $SUM "$f"; done < /tmp/cli-files) | $SUM | cut -d" " -f1; }', 'echo "staged=$(digest "$S")"', 'echo "installed=$(digest "$I")"', ].join('\n'); } export interface DigestPair { staged: string; installed: string; } /** * Pull the two digests out of the probe's output. * * Throws rather than returning a partial: an unreadable probe is the * can't-fail-shaped failure this whole file exists to prevent, so it must not * degrade into a pass. */ export function parseDigests(output: string): DigestPair { const read = (key: string): string => { const match = output.match(new RegExp(`^${key}=([0-9a-f]{64})$`, 'm')); if (!match) { throw new Error( `installed-CLI probe printed no ${key} digest — it did not run to completion.\n` + `--- probe output ---\n${output.trim() || '(empty)'}`, ); } return match[1] as string; }; return { staged: read('staged'), installed: read('installed') }; } /** The bake-stopping message for a mismatch, naming the cause worth checking first. */ export function mismatchMessage({ staged, installed }: DigestPair): string { return `The installed @celilo/cli is NOT the tarball this bake staged.\n staged tarball: ${staged}\n installed CLI: ${installed}\ninstall.sh fetched a DIFFERENT build from the npm-registry sim. The sim image COPYs .npm-registry-cache/ at docker-build time, so re-staging tarballs does nothing until that image is rebuilt — and build.ts scans image directories in sorted order and exits on the first failure, so an image failing earlier in the alphabet (minio, say) strands the registry sim while a usable-looking :latest remains. The other way to get here is a registry CONTAINER older than its image: the shared stack was already up and kept serving from the image it was created with. The bake recreates that container when it can read both image ids (celilo#1502), so reaching this line means it could not. Rebuild the sims: \`cele2e down && cele2e build-infra\`.\nRefusing to commit — a stamped label over the wrong CLI is celilo#1423.`; } export { STAGED_TARBALL, STAGED_DIR };