From 27d85e01622a8d31731a4b354d638b0d976a98f3 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Wed, 7 Oct 2026 13:10:58 -0500 Subject: [PATCH] fix(smoke): hand the verifier the package's archive and digest as one readable release pair The split deploy smoke failed in CI with "The SHA-256 file could not be read." BuildKit's local exporter creates its output directory (`--output type=local,dest=$work/module-package`) with mode 0700, owned by whoever ran the build. That directory was mounted into the verifier, which runs as the image's unprivileged `node` user (uid 1000). In CI the build runs as root, so the directory was root:root 0700 and the verifier could read neither file; the digest file is simply read first. On a workstation the build runs as uid 1000, the same uid as `node`, so it was readable by coincidence. scripts/lib/module-package-release.sh turns one build's output into the release pair: it requires exactly one archive and its .sha256 and nothing else, checks that the digest line names that archive, then creates a fresh directory that must not exist yet (0755) and copies both files into it (0644). The smoke stages and mounts only that directory; BuildKit's output directory is never mounted. Archive-digest verification stays mandatory: the verifier still reads the .sha256 file. Nothing in the verifier changed. Tests: the helper is driven in bash against a 0700 build output, and refuses a missing, malformed, or mismatched digest, stray output, a second archive, and an existing release directory, creating nothing. A static test pins the smoke to the release pair and the verifier's single mount with --sha256-file. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/module-package-release.sh | 53 +++++++++++ scripts/smoke-deploy.sh | 17 ++-- tests/ci-scripts.test.ts | 22 +++++ tests/module-package-release.test.ts | 127 ++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 scripts/lib/module-package-release.sh create mode 100644 tests/module-package-release.test.ts diff --git a/scripts/lib/module-package-release.sh b/scripts/lib/module-package-release.sh new file mode 100644 index 0000000..ae94bc1 --- /dev/null +++ b/scripts/lib/module-package-release.sh @@ -0,0 +1,53 @@ +# shellcheck shell=bash +# The deploy smoke test's module package release pair (ADR 0049). +# +# A package archive and its `.sha256` are one logical release: they travel together, and the +# verifier reads both. This turns one build's output into exactly that pair, in a directory any user +# can traverse, because the verifier runs as the image's unprivileged user. +# +# BuildKit's local exporter creates its output directory with mode 0700, owned by whoever ran the +# build. On a workstation that is usually uid 1000, which is also the image's `node` user, so the +# verifier happened to read it; in CI the build runs as root, and the verifier could read neither file. +# Nothing here depends on who built the package. + +# module_package_release +# +# Checks the build output holds exactly one archive and its digest and nothing else, and that the +# digest line names that archive. Then creates the release directory, which must not exist yet (so an +# earlier run's files can never be mixed in), mode 0755, and copies both files into it, mode 0644. +# Prints the archive's file name. Fails, creating nothing, on any deviation. +module_package_release() { + local output="$1" release="$2" archives=() archive digest_line digest named entries + + shopt -s nullglob + archives=("$output"/*.tar.gz) + shopt -u nullglob + if ((${#archives[@]} != 1)); then + echo "module_package_release: the build output must hold exactly one package archive." >&2 + return 1 + fi + archive="$(basename -- "${archives[0]}")" + if [[ ! -f "$output/$archive.sha256" ]]; then + echo "module_package_release: the package's .sha256 is missing." >&2 + return 1 + fi + entries="$(find "$output" -mindepth 1 -maxdepth 1 | wc -l)" + if ((entries != 2)); then + echo "module_package_release: the build output holds more than the archive and its digest." >&2 + return 1 + fi + digest_line="$(<"$output/$archive.sha256")" + digest="${digest_line%% *}" + named="${digest_line#* }" + if [[ ! "$digest" =~ ^[0-9a-f]{64}$ || "$named" != "$archive" ]]; then + echo "module_package_release: the .sha256 line does not name this archive." >&2 + return 1 + fi + + mkdir -m 755 -- "$release" || return 1 + if ! install -m 0644 -- "$output/$archive" "$output/$archive.sha256" "$release/"; then + rm -rf -- "$release" + return 1 + fi + printf '%s\n' "$archive" +} diff --git a/scripts/smoke-deploy.sh b/scripts/smoke-deploy.sh index d5496a9..73b3eb0 100755 --- a/scripts/smoke-deploy.sh +++ b/scripts/smoke-deploy.sh @@ -54,6 +54,8 @@ cd "$repository_root" source "$repository_root/scripts/lib/compose-diagnostics.sh" # shellcheck source=scripts/lib/ci-project.sh source "$repository_root/scripts/lib/ci-project.sh" +# shellcheck source=scripts/lib/module-package-release.sh +source "$repository_root/scripts/lib/module-package-release.sh" for command in docker openssl awk; do command -v "$command" >/dev/null || { @@ -224,11 +226,14 @@ if [[ "$topology" == split ]]; then --label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \ --output "type=local,dest=${work}/module-package" . >/dev/null || fail "the module package did not build." - package_archive="$(cd "$work/module-package" && ls -- *-any.tar.gz)" - [[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ && - -f "$work/module-package/${package_archive}.sha256" ]] || + # The archive and its digest are one release: copied together, from this run's own build output, + # into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is + # mode 0700 and owned by whoever built it, so it is never handed to the verifier itself. + package_archive="$(module_package_release "$work/module-package" "$work/package")" || fail "the packager did not produce exactly one archive and its digest." - echo "ok packaged ${package_archive} ($(wc -c <"$work/module-package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/module-package/${package_archive}.sha256"))" + [[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] || + fail "the packager produced an unexpected archive name." + echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))" fi # The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here. @@ -240,7 +245,7 @@ stage deploy mkdir -m 755 "$work/modules" stage modules if [[ "$topology" == split ]]; then - stage module-package + stage package # TEST-ONLY staging the verifier extracts into, as the image's unprivileged user. Nothing here is # module storage: the install step below moves a verified tree into module storage as root. mkdir -m 777 "$work/staging" @@ -443,7 +448,7 @@ install_test_module() { verified="$( docker run --rm --network none --read-only --tmpfs /tmp --cap-drop ALL \ --security-opt no-new-privileges \ - -v "$daemon_work/module-package:/package:ro" -v "$daemon_work/staging:/staging" \ + -v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \ --entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \ --sha256-file "/package/${package_archive}.sha256" --extract-to /staging/example 2>&1 )" || fail "the module package did not verify: ${verified}" diff --git a/tests/ci-scripts.test.ts b/tests/ci-scripts.test.ts index 3df4498..e11c287 100644 --- a/tests/ci-scripts.test.ts +++ b/tests/ci-scripts.test.ts @@ -164,6 +164,28 @@ describe("CI scripts on a shared Docker daemon", () => { expect(deploy).toContain("cp -R /staging/example /modules/.incoming-example"); }); + it("hand the verifier the package's archive and digest together, from a fresh release pair, never from build output", async () => { + const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " "); + expect(deploy).toContain('source "$repository_root/scripts/lib/module-package-release.sh"'); + // The pair is made from this run's own build output, into a directory that must not exist yet. + expect(deploy).toContain('--output "type=local,dest=${work}/module-package"'); + expect(deploy).toContain( + 'package_archive="$(module_package_release "$work/module-package" "$work/package")"', + ); + expect(deploy).toMatch(/^\s*stage package$/mu); + // BuildKit's output directory is 0700 and owned by whoever built it: it is never mounted. + expect(deploy).not.toContain('"$daemon_work/module-package:'); + expect(deploy).not.toMatch(/^\s*stage module-package$/mu); + // The verifier reads both files from the one mount, and always checks the digest file. + const verifier = deploy + .split("\n") + .find((line) => line.includes("core/dist/cli/verify-module-package.js")); + expect(verifier).toContain('-v "$daemon_work/package:/package:ro"'); + expect(verifier).toContain('"/package/${package_archive}"'); + expect(verifier).toContain('--sha256-file "/package/${package_archive}.sha256"'); + expect(verifier).not.toMatch(/--sha256\s/u); + }); + it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => { // ADR 0048: the split topology installs its test module into module storage at run time. const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " "); diff --git a/tests/module-package-release.test.ts b/tests/module-package-release.test.ts new file mode 100644 index 0000000..232613d --- /dev/null +++ b/tests/module-package-release.test.ts @@ -0,0 +1,127 @@ +import { execFile } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; + +/** + * The deploy smoke test's release pair (ADR 0049): a package archive and its `.sha256` are one + * logical release and reach the verifier together, from a directory every user can traverse. + * + * The regression this exists for: BuildKit's local exporter creates its output directory with mode + * 0700, owned by whoever ran the build. On a workstation that is uid 1000, which is also the image's + * unprivileged `node` user, so the verifier could read it; in CI the build runs as root, and the + * verifier could read neither file ("The SHA-256 file could not be read"). + */ + +const execFileAsync = promisify(execFile); +const helper = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "scripts", + "lib", + "module-package-release.sh", +); +const archive = "example-0.1.0-any.tar.gz"; +const digest = "91e5dfee80880027bd1c2e1796513f07023c03847e9ca3870b51f8272c3accf7"; +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +/** A build output exactly as BuildKit leaves it: mode 0700, holding the archive and its digest. */ +async function buildOutput( + files: Readonly> = { + [archive]: "archive bytes", + [`${archive}.sha256`]: `${digest} ${archive}\n`, + }, +): Promise<{ readonly root: string; readonly output: string }> { + const root = await mkdtemp(path.join(tmpdir(), "ekklesia-release-pair-")); + temporaryDirectories.push(root); + const output = path.join(root, "module-package"); + await mkdir(output); + for (const [name, contents] of Object.entries(files)) { + await writeFile(path.join(output, name), contents, { mode: 0o600 }); + } + await chmod(output, 0o700); + return { output, root }; +} + +async function release(output: string, target: string) { + return execFileAsync("bash", [ + "-c", + 'source "$1"; module_package_release "$2" "$3"', + "release", + helper, + output, + target, + ]); +} + +describe("the smoke test's package release pair", () => { + it("copies the archive and its digest, together, into a fresh world-traversable directory", async () => { + const { output, root } = await buildOutput(); + const target = path.join(root, "package"); + + const { stdout } = await release(output, target); + + expect(stdout.trim()).toBe(archive); + expect((await readdir(target)).sort()).toEqual([archive, `${archive}.sha256`]); + expect((await stat(target)).mode & 0o777).toBe(0o755); + for (const name of [archive, `${archive}.sha256`]) { + expect((await stat(path.join(target, name))).mode & 0o777, name).toBe(0o644); + expect(await readFile(path.join(target, name))).toEqual( + await readFile(path.join(output, name)), + ); + } + }); + + it.each([ + ["the digest is missing", { [archive]: "archive bytes" }], + [ + "the digest names another file", + { [archive]: "a", [`${archive}.sha256`]: `${digest} other.tar.gz\n` }, + ], + ["the digest is malformed", { [archive]: "a", [`${archive}.sha256`]: "not-a-digest x\n" }], + [ + "the build left something else beside the pair", + { + [archive]: "a", + [`${archive}.sha256`]: `${digest} ${archive}\n`, + "stale.tar.gz.sha256": "x", + }, + ], + [ + "there is more than one archive", + { + [archive]: "a", + [`${archive}.sha256`]: `${digest} ${archive}\n`, + "example-0.0.9-any.tar.gz": "old", + }, + ], + ["there is no archive at all", {}], + ])("refuses when %s", async (_label, files) => { + const { output, root } = await buildOutput(files); + const target = path.join(root, "package"); + await expect(release(output, target)).rejects.toThrow(); + await expect(stat(target)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("never reuses a release directory that already exists", async () => { + const { output, root } = await buildOutput(); + const target = path.join(root, "package"); + await mkdir(target); + await writeFile(path.join(target, `${archive}.sha256`), `${"0".repeat(64)} ${archive}\n`); + + await expect(release(output, target)).rejects.toThrow(); + // The stale digest is left exactly as it was; nothing was mixed into it. + expect(await readdir(target)).toEqual([`${archive}.sha256`]); + }); +});