mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
The internal testbed image moves from Node 22.22.1 to 22.23.4 (CVE-2026-111497). Its download is checked against Node's official SHASUMS256, and the check binds the extracted archive, so a mismatch stops the build. The scenario runner now settles its containers by recorded id instead of `rm -f`. It removes the cidfile only after a confirmed removal, so a re-run into the same folder isn't refused, and it prints a retained container's name. This image isn't shipped. Its rebuild hasn't been qualified on its own: the first ordinary run of the route that consumes it is the proof.
182 lines
11 KiB
JavaScript
182 lines
11 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join } from "node:path";
|
|
|
|
// 51-04 testbed image — the build verb (scripts/build-testbed-image.sh) is HOST-executed (it needs
|
|
// docker; the locus ruling puts the container runtime host-side). Its real logic lives in the
|
|
// tested node helpers (testbed-emit-manifest / build-inputs / manifest); this guard is the
|
|
// VM-authorable proof that the shell wrapper honors the plan §1 + FENCES contract, so a later edit
|
|
// that pushes the image, floats the build, or pulls openrig from the registry breaks here.
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const SCRIPT = join(HERE, "build-testbed-image.sh");
|
|
const DOCKERFILE = join(HERE, "..", "docker", "testbed", "Dockerfile");
|
|
|
|
function readScript() {
|
|
return readFileSync(SCRIPT, "utf8");
|
|
}
|
|
|
|
function readDockerfile() {
|
|
return readFileSync(DOCKERFILE, "utf8");
|
|
}
|
|
|
|
test("is a strict bash script (shebang + set -euo pipefail)", () => {
|
|
const text = readScript();
|
|
assert.match(text, /^#!.*\b(bash|sh)\b/, "must have a shell shebang");
|
|
assert.match(text, /set -euo pipefail/, "must fail-fast (set -euo pipefail)");
|
|
});
|
|
|
|
test("derives the image tag from the git sha (openrig-testbed:<git-sha>)", () => {
|
|
const text = readScript();
|
|
assert.match(text, /git\s+(?:-C\s+\S+\s+)?rev-parse/, "must resolve the git sha via git rev-parse");
|
|
assert.match(text, /openrig-testbed:/, "must tag openrig-testbed:<git-sha>");
|
|
});
|
|
|
|
test("builds OpenRig from the tree via npm pack — never npm publish, never the registry", () => {
|
|
const text = readScript();
|
|
assert.match(text, /npm pack/, "must build the tarball from the tree via npm pack");
|
|
assert.doesNotMatch(text, /npm\s+publish/, "must NOT npm publish");
|
|
assert.doesNotMatch(
|
|
text,
|
|
/npm\s+(?:install|i|add)\s+(?:-g\s+)?openrig(?:@|\s|$)/m,
|
|
"must NOT install openrig from the npm registry",
|
|
);
|
|
});
|
|
|
|
test("runs docker build with the digest-pinned base + tarball build-args", () => {
|
|
const text = readScript();
|
|
assert.match(text, /docker build/, "must docker build");
|
|
assert.match(text, /--build-arg\s+BASE_IMAGE=/, "must pass BASE_IMAGE (the digest-pinned base)");
|
|
assert.match(text, /--build-arg\s+OPENRIG_TARBALL=/, "must pass OPENRIG_TARBALL (the local pack)");
|
|
});
|
|
|
|
test("Q2 fix A: packs the ASSEMBLED @openrig/cli (has the `rig` bin), NEVER the private monorepo root", () => {
|
|
const text = readScript();
|
|
// must assemble the publishable CLI first (bundles daemon/ui/tui + the bin) ...
|
|
assert.match(text, /build-package\.sh/, "must run scripts/build-package.sh to assemble @openrig/cli");
|
|
// ... and pack packages/cli, not the repo root (root = [email protected], no bin -> rig --version exit 127)
|
|
assert.match(text, /packages\/cli["'}\s]*&&\s*npm pack|cd\s+"?\$\{REPO_ROOT\}\/packages\/cli/, "npm pack must run with cwd packages/cli");
|
|
assert.doesNotMatch(text, /cd\s+"?\$\{REPO_ROOT\}"?\s*&&\s*npm pack/, "must NOT pack the monorepo root");
|
|
});
|
|
|
|
test("resolves the target from the Docker server and passes both platform and arch", () => {
|
|
const text = readScript();
|
|
assert.match(text, /scenario-executor\.mjs.*platform/);
|
|
assert.match(text, /--build-arg\s+TARGETARCH=/);
|
|
assert.match(text, /docker build --platform/);
|
|
assert.match(text, /docker run --name/);
|
|
assert.doesNotMatch(text, /case.*uname -m/);
|
|
});
|
|
|
|
test("Q2 fix B: the Dockerfile fails CLOSED on an empty TARGETARCH (no silent amd64 default)", () => {
|
|
const dockerfile = readFileSync(join(HERE, "..", "docker", "testbed", "Dockerfile"), "utf8");
|
|
assert.doesNotMatch(dockerfile, /TARGETARCH:-amd64/, "must NOT default TARGETARCH to amd64 (that installs wrong-arch Node)");
|
|
assert.match(dockerfile, /"".*exit\s+[1-9]/, "an empty TARGETARCH must exit non-zero (fail closed)");
|
|
});
|
|
|
|
test("NEVER pushes the image (the never-push fence)", () => {
|
|
const text = readScript();
|
|
assert.doesNotMatch(text, /docker\s+push/, "must NOT docker push");
|
|
});
|
|
|
|
test("consumes the committed base-image slot + the stub-assets list (census scope)", () => {
|
|
const text = readScript();
|
|
assert.match(text, /base-image/, "must read the docker/testbed/base-image pin slot");
|
|
assert.match(text, /stub-assets\.list/, "must read the explicit stub-assets census list");
|
|
});
|
|
|
|
test("emits the manifest via the tested node orchestrator", () => {
|
|
const text = readScript();
|
|
assert.match(text, /testbed-emit-manifest\.mjs/, "must emit the manifest via testbed-emit-manifest.mjs");
|
|
});
|
|
|
|
test("Q2 fix (break #4): the image installs the better-sqlite3 native-build toolchain (builds fresh on target)", () => {
|
|
// The sealed Q2 packaging ruling builds better-sqlite3 FROM SOURCE on target (never a prebuilt/nested
|
|
// binary). Its install is `prebuild-install || node-gyp rebuild`; node-gyp needs python3+make+g++.
|
|
// Without them layer 3's `npm install -g` dies ('prebuild-install: not found' → no Python). This
|
|
// static fence is the VM-authorable half; the behavioral RED→GREEN docker build runs host-side.
|
|
const df = readDockerfile();
|
|
assert.match(df, /python3 make g\+\+/, "layer 1 must install python3 make g++ (node-gyp toolchain)");
|
|
});
|
|
|
|
test("Q2 rider (effect proof): the build verb LOADS the daemon inside the container, not just `rig --version`", () => {
|
|
// assert-the-EFFECT-not-the-command: a green build over a broken native install is the break-#4 CLASS.
|
|
// Only a CONTAINER load catches it (the host has the toolchain, the image must not need it). The verb
|
|
// must run the freshly-built image and START the daemon (opens the DB → better-sqlite3 must have
|
|
// bound), failing the build if it can't. `rig --version` alone never opens the DB.
|
|
const text = readScript();
|
|
assert.match(text, /docker run\b[\s\S]*\$\{IMAGE_TAG\}/, "must run the freshly-built image (effect proof)");
|
|
assert.match(text, /rig daemon start --no-kernel/, "must LOAD the daemon (better-sqlite3 binds) via the operator-corrected start, not merely check rig exists");
|
|
assert.match(text, /\/healthz/, "must confirm readiness deterministically via /healthz (operator correction — no fixed sleep)");
|
|
});
|
|
|
|
test("image-load success, failure and deadline all retain status and remove only the named container", () => {
|
|
const root = mkdtempSync(join(process.cwd(), ".testbed-load-"));
|
|
try {
|
|
for (const p of ["scripts", "docker/testbed", "packages/cli", "bin"]) mkdirSync(join(root, p), { recursive: true });
|
|
copyFileSync(SCRIPT, join(root, "scripts/build-testbed-image.sh"));
|
|
copyFileSync(join(HERE, "scenario-executor.mjs"), join(root, "scripts/scenario-executor.mjs"));
|
|
writeFileSync(join(root, "scripts/build-package.sh"), "#!/bin/sh\nexit 0\n");
|
|
writeFileSync(join(root, "scripts/testbed-build-inputs.mjs"), "export const readBaseImage = () => ({ref:'fixture@sha256:abc'});\n");
|
|
writeFileSync(join(root, "scripts/testbed-emit-manifest.mjs"), "import fs from 'node:fs';fs.writeFileSync(process.argv[3]+'/manifest.json','{}');\n");
|
|
writeFileSync(join(root, "docker/testbed/Dockerfile"), "ARG NODE_VERSION=22.22.1\n");
|
|
for (const p of ["docker/testbed/entrypoint.sh", "docker/testbed/base-image", "docker/testbed/stub-assets.list"]) writeFileSync(join(root, p), "");
|
|
writeFileSync(join(root, "bin/git"), "#!/bin/sh\nprintf 'fake-source\\n'\n", { mode: 0o755 });
|
|
writeFileSync(join(root, "bin/npm"), "#!/bin/sh\ntouch fixture.tgz\nprintf 'fixture.tgz\\n'\n", { mode: 0o755 });
|
|
writeFileSync(join(root, "bin/mktemp"), '#!/bin/sh\nexec /usr/bin/mktemp "$@" "$HOME/tmp.XXXXXXXX"\n', { mode: 0o755 });
|
|
// Use the real deadline helper with a short test deadline. No Docker daemon,
|
|
// build, package install or network is involved; the marker models a remote
|
|
// container surviving the client process until an explicit named removal.
|
|
writeFileSync(join(root, "bin/node"), `#!${process.execPath}
|
|
import {spawnSync} from 'node:child_process';const args=process.argv.slice(2);
|
|
if(args[1]==='timeout')args[2]='0.4';
|
|
const p=spawnSync(${JSON.stringify(process.execPath)},args,{stdio:'inherit'});process.exit(p.status??1);
|
|
`, { mode: 0o755 });
|
|
writeFileSync(join(root, "bin/docker"), `#!${process.execPath}
|
|
import fs from 'node:fs';const args=process.argv.slice(2);const marker=process.env.FAKE_CONTAINER;
|
|
const id='a'.repeat(64),image='sha256:'+'b'.repeat(64);
|
|
if(args[0]==='version')console.log(JSON.stringify({Os:'linux',Arch:'amd64'}));
|
|
else if(args[0]==='image')console.log(image);
|
|
else if(args[0]==='build'){}
|
|
else if(args[0]==='run'){
|
|
const at=args.indexOf('--name'),cid=args.indexOf('--cidfile');if(at<0||cid<0)process.exit(9);
|
|
fs.writeFileSync(args[cid+1],id);fs.writeFileSync(marker,JSON.stringify({Id:id,Name:'/'+args[at+1],Image:image,State:{Running:process.env.LOAD_CASE==='timeout',Status:process.env.LOAD_CASE==='timeout'?'running':'exited'}}));
|
|
if(process.env.LOAD_CASE==='timeout')setInterval(()=>{},1000);
|
|
else process.exit(process.env.LOAD_CASE==='failure'?7:0);
|
|
}else if(args[0]==='inspect')console.log(JSON.stringify([JSON.parse(fs.readFileSync(marker))]));
|
|
else if(args[0]==='kill'){
|
|
if(args.join(' ')!=='kill --signal TERM '+id)process.exit(8);
|
|
const state=JSON.parse(fs.readFileSync(marker));state.State={Running:false,Status:'exited'};fs.writeFileSync(marker,JSON.stringify(state));
|
|
}else if(args[0]==='wait'){if(JSON.parse(fs.readFileSync(marker)).State.Running)process.exit(8);console.log('0');}
|
|
else if(args[0]==='rm'){
|
|
if(process.env.LOAD_CASE==='retain')process.exit(8);
|
|
if(args.length!==2||args[1]!==id||JSON.parse(fs.readFileSync(marker)).State.Running)process.exit(8);
|
|
fs.unlinkSync(marker);
|
|
}else process.exit(8);
|
|
`, { mode: 0o755 });
|
|
for (const [mode, status] of [["success", 0], ["success", 0], ["failure", 7], ["timeout", 124], ["retain", 2]]) {
|
|
const out = join(root, mode), marker = join(root, "container");
|
|
const run = spawnSync("/bin/bash", [join(root, "scripts/build-testbed-image.sh"), out], {
|
|
env: { PATH: `${join(root, "bin")}:/usr/bin:/bin`, HOME: root, TMPDIR: root, LOAD_CASE: mode, FAKE_CONTAINER: marker },
|
|
encoding: "utf8", timeout: 8000,
|
|
});
|
|
assert.equal(run.status, status, `${mode}: ${run.stderr}`);
|
|
assert.equal(Number(readFileSync(join(out, "image-load.exit-code.txt"), "utf8")), mode === "retain" ? 0 : status);
|
|
assert.match(readFileSync(join(out, "image-load.container-name.txt"), "utf8"), /^openrig-testbed-load-/);
|
|
assert.equal(existsSync(marker), mode === "retain", `${mode}: only confirmed removal clears the container`);
|
|
assert.equal(existsSync(join(out, "image-load.cid")), mode === "retain", `${mode}: cidfile follows confirmed removal`);
|
|
assert.equal(existsSync(join(out, "manifest.json")), status === 0);
|
|
if (mode === "retain") {
|
|
const retry = spawnSync("/bin/bash", [join(root, "scripts/build-testbed-image.sh"), out], {
|
|
env: { PATH: `${join(root, "bin")}:/usr/bin:/bin`, HOME: root, TMPDIR: root, LOAD_CASE: mode, FAKE_CONTAINER: marker },
|
|
encoding: "utf8", timeout: 3000,
|
|
});
|
|
assert.equal(retry.status, 2); assert.match(retry.stderr, /Retained cidfile/);
|
|
}
|
|
}
|
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
});
|