feat: Ekklesia Module Package v1, the canonical packager and verifier (ADR 0049) #66

Merged
Zayin_Admin merged 2 commits from feat/module-package-v1 into main 2026-10-07 18:33:28 +00:00
4 changed files with 213 additions and 6 deletions
Showing only changes of commit 27d85e0162 - Show all commits
+53
View File
@@ -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 <build output directory> <new release directory>
#
# 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"
}
+11 -6
View File
@@ -54,6 +54,8 @@ cd "$repository_root"
source "$repository_root/scripts/lib/compose-diagnostics.sh" source "$repository_root/scripts/lib/compose-diagnostics.sh"
# shellcheck source=scripts/lib/ci-project.sh # shellcheck source=scripts/lib/ci-project.sh
source "$repository_root/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 for command in docker openssl awk; do
command -v "$command" >/dev/null || { command -v "$command" >/dev/null || {
@@ -224,11 +226,14 @@ if [[ "$topology" == split ]]; then
--label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \ --label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \
--output "type=local,dest=${work}/module-package" . >/dev/null || --output "type=local,dest=${work}/module-package" . >/dev/null ||
fail "the module package did not build." fail "the module package did not build."
package_archive="$(cd "$work/module-package" && ls -- *-any.tar.gz)" # The archive and its digest are one release: copied together, from this run's own build output,
[[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ && # into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is
-f "$work/module-package/${package_archive}.sha256" ]] || # 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." 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 fi
# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here. # 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" mkdir -m 755 "$work/modules"
stage modules stage modules
if [[ "$topology" == split ]]; then 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 # 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. # module storage: the install step below moves a verified tree into module storage as root.
mkdir -m 777 "$work/staging" mkdir -m 777 "$work/staging"
@@ -443,7 +448,7 @@ install_test_module() {
verified="$( verified="$(
docker run --rm --network none --read-only --tmpfs /tmp --cap-drop ALL \ docker run --rm --network none --read-only --tmpfs /tmp --cap-drop ALL \
--security-opt no-new-privileges \ --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}" \ --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 --sha256-file "/package/${package_archive}.sha256" --extract-to /staging/example 2>&1
)" || fail "the module package did not verify: ${verified}" )" || fail "the module package did not verify: ${verified}"
+22
View File
@@ -164,6 +164,28 @@ describe("CI scripts on a shared Docker daemon", () => {
expect(deploy).toContain("cp -R /staging/example /modules/.incoming-example"); 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 () => { 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. // 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, " "); const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");
+127
View File
@@ -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<Record<string, string>> = {
[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`]);
});
});