veritas-kanban/scripts/check-docker-image.mjs
Brad Groux c9db917422
build: enforce Docker image contract (#1222)
* build: enforce Docker image contract

* fix: run Docker contract on durable storage

* build: restore 200 MB image contract

* build: complete container runtime contract

* chore: refresh reviewed secret fingerprint

* fix: unwrap Docker backup response

* build: exclude generated Docker context

* build: enforce platform image budgets

* chore: align Docker docs leak baseline
2026-08-23 23:49:13 -05:00

214 lines
7.7 KiB
JavaScript

#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
const image = process.env.VERITAS_DOCKER_IMAGE || process.argv[2] || 'veritas-kanban:contract';
const configuredMaxBytes = process.env.VERITAS_DOCKER_MAX_BYTES;
const defaultMaxBytesByArchitecture = {
arm64: 200_000_000,
amd64: 600_000_000,
};
const containerName = `veritas-kanban-contract-${process.pid}`;
const volumeName = `${containerName}-data`;
const adminKey = randomBytes(24).toString('hex');
const expectedVersion = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf8')
).version;
function run(args) {
const result = spawnSync('docker', args, { encoding: 'utf8', stdio: 'pipe' });
if (result.status !== 0) {
const detail = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
throw new Error(detail || `Docker command failed with status ${result.status}`);
}
return result.stdout?.trim() ?? '';
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function containerLogs() {
const result = spawnSync('docker', ['logs', containerName], {
encoding: 'utf8',
stdio: 'pipe',
});
return [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
}
async function waitForHealthyContainer() {
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
const state = JSON.parse(
run(['inspect', '--format', '{{json .State}}', containerName])
);
if (state.Health?.Status === 'healthy') return;
if (state.Status === 'exited' || state.Status === 'dead') {
throw new Error(
`Container stopped before becoming healthy (${state.Status})\n${containerLogs()}`
);
}
await new Promise((resolve) => globalThis.setTimeout(resolve, 1_000));
}
const health = run(['inspect', '--format', '{{json .State.Health}}', containerName]);
throw new Error(
`Container did not become healthy within 90 seconds\nHealth: ${health}\n${containerLogs()}`
);
}
const runtimeProbe = String.raw`
import bcrypt from 'bcrypt';
import { access } from 'node:fs/promises';
const assert = (condition, message) => {
if (!condition) throw new Error(message);
};
const response = async (path, init) => fetch('http://127.0.0.1:3001' + path, init);
const health = await response('/health');
assert(health.status === 200, 'GET /health did not return 200');
const readiness = await response('/health/ready');
const readinessBody = await readiness.json();
assert(readiness.status === 200, 'GET /health/ready did not return 200');
assert(readinessBody.checks?.sqlite === 'ok', 'SQLite readiness was not healthy');
const index = await response('/');
const html = await index.text();
assert(index.status === 200 && html.includes('id="root"'), 'Built web app was not served');
const unauthenticated = await response('/api/tasks');
assert(unauthenticated.status === 401, 'Protected API did not reject an unauthenticated request');
const authenticated = await response('/api/tasks', {
headers: { 'X-API-Key': process.env.VERITAS_ADMIN_KEY },
});
assert(authenticated.status === 200, 'Admin API key did not authenticate');
const deepHealth = await response('/api/health/deep', {
headers: { 'X-API-Key': process.env.VERITAS_ADMIN_KEY },
});
const deepHealthBody = await deepHealth.json();
assert(deepHealth.status === 200, 'Deep health endpoint did not return 200');
assert(deepHealthBody.status === 'ok', 'Deep health reported a degraded runtime');
assert(
deepHealthBody.version === ${JSON.stringify(expectedVersion)},
'Deep health did not report release version ${expectedVersion}'
);
assert(deepHealthBody.checks?.storage === 'ok', 'Storage integrity check was not healthy');
assert(deepHealthBody.sqlite?.healthPosture === 'healthy', 'SQLite startup was not healthy');
assert(
deepHealthBody.dataDirectory?.path === '/app/data/.veritas-kanban',
'Runtime state did not resolve beneath the mounted DATA_DIR'
);
const backup = await response('/api/v1/sqlite/export', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.VERITAS_ADMIN_KEY,
},
body: JSON.stringify({
sqlitePath: '/app/data/.veritas-kanban/veritas.db',
outputDir: '/app/data/backups/docker-contract',
}),
});
const backupBody = await backup.json();
assert(backup.status === 200, 'SQLite backup export did not return 200');
assert(
backupBody.success === true &&
backupBody.data?.bundlePath === '/app/data/backups/docker-contract',
'SQLite backup export escaped the mounted DATA_DIR'
);
await access('/app/data/backups/docker-contract/manifest.json');
const hash = await bcrypt.hash('native-module-probe', 4);
assert(await bcrypt.compare('native-module-probe', hash), 'bcrypt native module failed');
`;
let started = false;
let volumeCreated = false;
try {
const architecture = run(['image', 'inspect', image, '--format', '{{.Architecture}}']);
const maxBytes = configuredMaxBytes
? Number(configuredMaxBytes)
: defaultMaxBytesByArchitecture[architecture];
assert(
maxBytes !== undefined,
`No Docker image size budget is defined for architecture ${architecture}; set VERITAS_DOCKER_MAX_BYTES explicitly`
);
assert(Number.isFinite(maxBytes) && maxBytes > 0, 'VERITAS_DOCKER_MAX_BYTES must be positive');
const imageBytes = Number(run(['image', 'inspect', image, '--format', '{{.Size}}']));
assert(Number.isFinite(imageBytes), `Could not read image size for ${image}`);
if (imageBytes >= maxBytes) {
const diagnostics = run([
'run',
'--rm',
'--entrypoint',
'sh',
image,
'-c',
'du -ak /app /usr/local 2>/dev/null | sort -nr | head -25',
]);
throw new Error(
`Docker image is ${imageBytes.toLocaleString()} bytes; budget is below ${maxBytes.toLocaleString()} bytes\nLargest runtime paths (KiB):\n${diagnostics}`
);
}
const configuredUser = run(['image', 'inspect', image, '--format', '{{.Config.User}}']);
assert(configuredUser === 'veritas', `Expected image user veritas, found ${configuredUser || 'root'}`);
run(['volume', 'create', volumeName]);
volumeCreated = true;
run([
'run',
'--detach',
'--name',
containerName,
'--mount',
`type=volume,source=${volumeName},target=/app/data`,
'--env',
`VERITAS_ADMIN_KEY=${adminKey}`,
'--env',
'VERITAS_STORAGE=sqlite',
image,
]);
started = true;
await waitForHealthyContainer();
run([
'exec',
containerName,
'sh',
'-c',
'test "$(id -u)" = 1001 && test ! -e /app/cli && test ! -e /app/mcp && test ! -e /app/pnpm-lock.yaml && test -f /app/data/.veritas-kanban/veritas.db',
]);
run(['exec', containerName, 'node', '--input-type=module', '--eval', runtimeProbe]);
run(['stop', '--time', '15', containerName]);
const stoppedState = JSON.parse(
run(['inspect', '--format', '{{json .State}}', containerName])
);
assert(stoppedState.Status === 'exited', 'Container did not stop cleanly');
assert(stoppedState.ExitCode === 0, `Container exited with code ${stoppedState.ExitCode}`);
run(['rm', containerName]);
started = false;
console.log(
`Docker image contract passed on ${architecture}: ${imageBytes.toLocaleString()} bytes (< ${maxBytes.toLocaleString()})`
);
console.log(
'Runtime smoke passed: non-root user, version, mounted paths, SQLite, backup, auth, web assets, health, bcrypt, and clean shutdown'
);
} finally {
if (started) {
spawnSync('docker', ['rm', '--force', containerName], { stdio: 'ignore' });
}
if (volumeCreated) {
spawnSync('docker', ['volume', 'rm', volumeName], { stdio: 'ignore' });
}
}