fix(smoke): hand the verifier the package's archive and digest as one readable release pair
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
CI / verify (pull_request) Successful in 58s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m51s
CI / image-build (pull_request) Successful in 22s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / deploy-smoke (split) (pull_request) Successful in 1m31s
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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
35ef31df9f
commit
27d85e0162
@@ -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, " ");
|
||||
|
||||
@@ -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`]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user