363 lines
15 KiB
JavaScript
363 lines
15 KiB
JavaScript
/**
|
|
* Multi-arch build targets test — locks in the [E00-S02-T07] amd64/arm64
|
|
* build targets for the workspace server application image.
|
|
*
|
|
* Acceptance criteria covered (each test fails without the committed state):
|
|
* - "amd64 is a build target" → the committed `compose.yaml` `app` service's
|
|
* `build.platforms` list (Compose Build spec `platforms`) declares
|
|
* `linux/amd64`, so `docker compose build` targets amd64. The mutation
|
|
* probes below prove the assertion is non-vacuous (removing the amd64
|
|
* entry, or dropping/emptying the whole list, breaks the criterion).
|
|
* - "arm64 is a build target" → the same `build.platforms` list declares
|
|
* `linux/arm64`, so `docker compose build` targets arm64. Removing the
|
|
* arm64 entry (or replacing it with a non-arm64 platform) breaks the
|
|
* criterion.
|
|
* - buildability: every stage of the committed `apps/server/Dockerfile`
|
|
* builds on the official multi-arch `node:24.19.0-bookworm-slim` base
|
|
* (which publishes linux/amd64 and linux/arm64 manifests), so the declared
|
|
* targets are actually realizable; and, on machines with Docker, two
|
|
* gated probes confirm it end to end:
|
|
* - `docker compose build --print` emits the equivalent bake (buildx)
|
|
* config whose app target lists both platforms — a real
|
|
* `docker compose build` produces a multi-arch image;
|
|
* - `docker buildx imagetools inspect node:24.19.0-bookworm-slim`
|
|
* reports both `linux/amd64` and `linux/arm64` in the base image's
|
|
* manifest list.
|
|
*
|
|
* Run: `node --test tests/build-targets.test.mjs`
|
|
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
|
*/
|
|
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { spawnSync } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8');
|
|
|
|
/** The committed Compose file and app image definition under test. */
|
|
const COMPOSE_PATH = 'compose.yaml';
|
|
const DOCKERFILE_PATH = 'apps/server/Dockerfile';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Minimal block-YAML parser (the same subset the committed compose.yaml uses;
|
|
// this is the repo's own parser from tests/compose-config.test.mjs)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Drops a `#` comment that is not inside a quoted scalar. */
|
|
function stripComment(line) {
|
|
let quote = null;
|
|
for (let i = 0; i < line.length; i += 1) {
|
|
const ch = line[i];
|
|
if (quote) {
|
|
if (ch === quote) quote = null;
|
|
} else if (ch === "'" || ch === '"') {
|
|
quote = ch;
|
|
} else if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
|
|
return line.slice(0, i).trimEnd();
|
|
}
|
|
}
|
|
return line.trimEnd();
|
|
}
|
|
|
|
/** Unquotes a plain / single-quoted / double-quoted scalar. */
|
|
function scalarValue(raw) {
|
|
if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
|
|
if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
|
|
return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
/**
|
|
* Parses the supported block-YAML subset into plain JS objects/arrays: nested
|
|
* block mappings, scalar values and sequences of scalars. Throws on any
|
|
* construct the committed file does not use.
|
|
*/
|
|
function parseYaml(text) {
|
|
const lines = [];
|
|
for (const raw of text.split(/\r?\n/)) {
|
|
const expanded = raw.replace(/\t/g, ' ');
|
|
if (!expanded.trim() || expanded.trim().startsWith('#')) continue;
|
|
const indent = expanded.length - expanded.trimStart().length;
|
|
const content = stripComment(expanded.trimStart()).trim();
|
|
if (!content) continue;
|
|
lines.push({ indent, content });
|
|
}
|
|
|
|
let pos = 0;
|
|
|
|
const parseBlock = (indent) => {
|
|
const node = {};
|
|
while (pos < lines.length && lines[pos].indent >= indent) {
|
|
if (lines[pos].indent > indent) {
|
|
throw new Error(`unexpected indentation at "${lines[pos].content}"`);
|
|
}
|
|
const { content } = lines[pos];
|
|
const match = /^([^:#][^:]*):(?:\s+(.*))?$/.exec(content);
|
|
if (!match) throw new Error(`expected "key: value", got "${content}"`);
|
|
const key = scalarValue(match[1].trim());
|
|
const rest = match[2] === undefined ? undefined : match[2].trim();
|
|
pos += 1;
|
|
if (rest === undefined || rest === '') {
|
|
if (pos < lines.length && lines[pos].indent > indent) {
|
|
if (lines[pos].content.startsWith('-')) {
|
|
node[key] = parseSequence(lines[pos].indent);
|
|
} else {
|
|
node[key] = parseBlock(lines[pos].indent);
|
|
}
|
|
} else {
|
|
node[key] = null;
|
|
}
|
|
} else {
|
|
node[key] = scalarValue(rest);
|
|
}
|
|
}
|
|
return node;
|
|
};
|
|
|
|
const parseSequence = (indent) => {
|
|
const items = [];
|
|
while (pos < lines.length && lines[pos].indent >= indent) {
|
|
if (lines[pos].indent > indent) {
|
|
throw new Error(`unexpected indentation in sequence at "${lines[pos].content}"`);
|
|
}
|
|
const { content } = lines[pos];
|
|
if (!content.startsWith('-')) break;
|
|
const rest = content.slice(1).trim();
|
|
if (!rest) throw new Error('empty sequence item at "-"');
|
|
items.push(scalarValue(rest));
|
|
pos += 1;
|
|
}
|
|
return items;
|
|
};
|
|
|
|
if (lines.length === 0) return {};
|
|
return parseBlock(0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Criterion assertions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Asserts the committed compose.yaml declares both acceptance criteria: the
|
|
* `app` service's build config lists `linux/amd64` and `linux/arm64` in
|
|
* `build.platforms`. Fails fast on a missing app service / build config or on
|
|
* a missing, empty or partial platforms list; the mutation probes below prove
|
|
* the assertions are non-vacuous.
|
|
*/
|
|
function assertBuildTargets(compose) {
|
|
const services = compose.services;
|
|
assert.ok(services, 'compose.yaml must declare a top-level "services" map');
|
|
const app = services.app;
|
|
assert.ok(app, 'compose.yaml must declare an "app" service (the image `docker compose build` produces)');
|
|
const build = app.build;
|
|
assert.ok(build, 'the "app" service must declare a build config (build.context / build.dockerfile)');
|
|
const platforms = build.platforms;
|
|
assert.ok(
|
|
Array.isArray(platforms) && platforms.length > 0,
|
|
'the "app" build config must declare a non-empty "platforms" list (Compose Build spec `build.platforms`)',
|
|
);
|
|
assert.ok(
|
|
platforms.includes('linux/amd64'),
|
|
'amd64 is a build target: "build.platforms" must include "linux/amd64"',
|
|
);
|
|
assert.ok(
|
|
platforms.includes('linux/arm64'),
|
|
'arm64 is a build target: "build.platforms" must include "linux/arm64"',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Asserts every stage of the committed Dockerfile builds on the official
|
|
* multi-arch `node:24.19.0-bookworm-slim` base, which publishes linux/amd64
|
|
* and linux/arm64 manifests — so the platforms declared in compose.yaml are
|
|
* actually buildable. A single-arch or private base image cannot satisfy the
|
|
* criteria.
|
|
*/
|
|
function assertMultiArchBase(dockerfile) {
|
|
const froms = [...dockerfile.matchAll(/^FROM\s+(\S+)(?:\s+AS\s+\S+)?\s*$/gm)].map((m) => m[1]);
|
|
assert.ok(
|
|
froms.length >= 2,
|
|
'the Dockerfile must declare at least a build and a runtime stage (two FROM lines)',
|
|
);
|
|
for (const image of froms) {
|
|
assert.equal(
|
|
image,
|
|
'node:24.19.0-bookworm-slim',
|
|
`every Dockerfile stage must use the official multi-arch "node:24.19.0-bookworm-slim" base ` +
|
|
`(amd64/arm64 build targets need a base image that publishes both manifests; got "${image}")`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Docker probe helpers (integration tests skip cleanly without Docker)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function run(cmd, args, opts = {}) {
|
|
return spawnSync(cmd, args, {
|
|
encoding: 'utf8',
|
|
timeout: 600_000,
|
|
...opts,
|
|
});
|
|
}
|
|
|
|
/** True when the `docker` CLI with the Compose plugin is on PATH. */
|
|
function dockerComposeAvailable() {
|
|
try {
|
|
return run('docker', ['compose', 'version'], { timeout: 15_000 }).status === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** True when a reachable Docker daemon exists. */
|
|
function dockerDaemonAvailable() {
|
|
try {
|
|
return run('docker', ['info'], { timeout: 15_000 }).status === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** True when the buildx plugin (multi-platform builds) is available. */
|
|
function dockerBuildxAvailable() {
|
|
try {
|
|
return run('docker', ['buildx', 'version'], { timeout: 15_000 }).status === 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const DOCKER_COMPOSE = dockerComposeAvailable();
|
|
const DOCKER_DAEMON = dockerDaemonAvailable();
|
|
const DOCKER_BUILDX = dockerBuildxAvailable();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Criterion tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('compose.yaml declares amd64 and arm64 as build targets (app service build.platforms)', () => {
|
|
assert.ok(existsSync(path.join(REPO_ROOT, COMPOSE_PATH)), `committed ${COMPOSE_PATH} must exist`);
|
|
assertBuildTargets(parseYaml(read(COMPOSE_PATH)));
|
|
});
|
|
|
|
test('the app image is built from a multi-arch base image (every stage FROM node:24.19.0-bookworm-slim)', () => {
|
|
assert.ok(existsSync(path.join(REPO_ROOT, DOCKERFILE_PATH)), `committed ${DOCKERFILE_PATH} must exist`);
|
|
assertMultiArchBase(read(DOCKERFILE_PATH));
|
|
});
|
|
|
|
test('docker compose build targets both platforms (bake config probe)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON }, () => {
|
|
// `docker compose build --print` emits the equivalent bake (buildx) config
|
|
// without building: its app target must list both platforms, i.e. a real
|
|
// `docker compose build` produces a multi-arch image. Requires the Compose
|
|
// plugin and a daemon (project resolution talks to the daemon).
|
|
const printed = run('docker', ['compose', 'build', '--print'], { cwd: REPO_ROOT, timeout: 60_000 });
|
|
assert.equal(
|
|
printed.status,
|
|
0,
|
|
`"docker compose build --print" must exit 0:\n${(printed.stdout || '')}\n${(printed.stderr || '')}`.trim(),
|
|
);
|
|
const out = String(printed.stdout || '');
|
|
const jsonStart = out.indexOf('{');
|
|
const jsonEnd = out.lastIndexOf('}');
|
|
assert.ok(
|
|
jsonStart !== -1 && jsonEnd > jsonStart,
|
|
`"docker compose build --print" must emit a JSON bake config (got: "${out.slice(0, 200)}")`,
|
|
);
|
|
const bake = JSON.parse(out.slice(jsonStart, jsonEnd + 1));
|
|
const targets = Object.values(bake.target || {});
|
|
const appTarget = targets.find((t) => Array.isArray(t.platforms));
|
|
assert.ok(appTarget, 'the bake config must contain a target with a platforms list');
|
|
assert.ok(
|
|
appTarget.platforms.includes('linux/amd64'),
|
|
`the bake target must list linux/amd64 (got: ${JSON.stringify(appTarget.platforms)})`,
|
|
);
|
|
assert.ok(
|
|
appTarget.platforms.includes('linux/arm64'),
|
|
`the bake target must list linux/arm64 (got: ${JSON.stringify(appTarget.platforms)})`,
|
|
);
|
|
});
|
|
|
|
test('the base image publishes amd64 and arm64 manifests (buildx imagetools probe)', { skip: !DOCKER_BUILDX }, () => {
|
|
// The declared build targets are only realizable if the base image ships
|
|
// both architectures. Requires the buildx plugin and registry access.
|
|
const inspect = run('docker', ['buildx', 'imagetools', 'inspect', 'node:24.19.0-bookworm-slim'], {
|
|
timeout: 120_000,
|
|
});
|
|
assert.equal(
|
|
inspect.status,
|
|
0,
|
|
`"docker buildx imagetools inspect" must exit 0:\n${(inspect.stdout || '')}\n${(inspect.stderr || '')}`.trim(),
|
|
);
|
|
const out = String(inspect.stdout || '');
|
|
assert.match(out, /linux\/amd64/, 'the base image manifest list must include linux/amd64');
|
|
assert.match(out, /linux\/arm64/, 'the base image manifest list must include linux/arm64');
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Non-vacuous probes — the assertions above really do fail on violations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test('removing the amd64 target makes the amd64 criterion fail (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const withoutAmd64 = text.replace(/\n\s*- linux\/amd64\n/, '\n');
|
|
assert.notEqual(withoutAmd64, text, 'the mutation must actually remove the linux/amd64 entry');
|
|
assert.throws(() => assertBuildTargets(parseYaml(withoutAmd64)), /linux\/amd64/);
|
|
});
|
|
|
|
test('removing the arm64 target makes the arm64 criterion fail (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const withoutArm64 = text.replace(/\n\s*- linux\/arm64\n/, '\n');
|
|
assert.notEqual(withoutArm64, text, 'the mutation must actually remove the linux/arm64 entry');
|
|
assert.throws(() => assertBuildTargets(parseYaml(withoutArm64)), /linux\/arm64/);
|
|
});
|
|
|
|
test('replacing the arm64 target with a non-arm64 platform fails (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const wrongArch = text.replace(/\n(\s*)- linux\/arm64\n/, '\n$1- linux/386\n');
|
|
assert.notEqual(wrongArch, text, 'the mutation must actually replace the linux/arm64 entry');
|
|
assert.throws(() => assertBuildTargets(parseYaml(wrongArch)), /linux\/arm64/);
|
|
});
|
|
|
|
test('dropping the platforms list entirely fails the criteria (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const withoutPlatforms = text.replace(/\n\s*platforms:\n(?:\s*- [^\n]+\n)+/, '\n');
|
|
assert.notEqual(withoutPlatforms, text, 'the mutation must actually remove the platforms list');
|
|
assert.throws(() => assertBuildTargets(parseYaml(withoutPlatforms)), /platforms/);
|
|
});
|
|
|
|
test('an empty platforms list fails the criteria (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const empty = text.replace(/\n\s*platforms:\n(?:\s*- [^\n]+\n)+/, '\n platforms:\n');
|
|
assert.notEqual(empty, text, 'the mutation must actually empty the platforms list');
|
|
assert.throws(() => assertBuildTargets(parseYaml(empty)), /platforms/);
|
|
});
|
|
|
|
test('a platforms list on the db service (which has no build config) cannot satisfy the criteria (mutation probe)', () => {
|
|
const text = read(COMPOSE_PATH);
|
|
const dbOnly = text
|
|
.replace(/\n\s*platforms:\n(?:\s*- [^\n]+\n)+/, '\n')
|
|
.replace(
|
|
' image: postgres:18-bookworm\n',
|
|
' image: postgres:18-bookworm\n platforms:\n - linux/amd64\n - linux/arm64\n',
|
|
);
|
|
assert.notEqual(dbOnly, text, 'the mutation must actually move the platforms list onto db');
|
|
assert.throws(() => assertBuildTargets(parseYaml(dbOnly)), /platforms/);
|
|
});
|
|
|
|
test('the YAML parser reads the committed build.platforms structure (non-vacuous parser probe)', () => {
|
|
const compose = parseYaml(read(COMPOSE_PATH));
|
|
assert.deepEqual(
|
|
compose.services.app.build.platforms,
|
|
['linux/amd64', 'linux/arm64'],
|
|
'the committed compose.yaml must declare exactly the two build targets',
|
|
);
|
|
});
|