feat(image): add scratch container image

Two-stage build producing a static /app on scratch; e2e tests for image contents, server and CLI through docker exec, socket handling, lifecycle and host/image parity.
This commit is contained in:
bryanpedini committed 2026-10-06 10:52:25 +02:00
1 parent 0a6023e942
commit 581a672fcf
9 files changed
+1858

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* Helpers shared by the container-image specs (not a spec file itself: Playwright only collects
* `*.spec.ts`). Kept next to the specs instead of in harness/ so the shared harness stays
* untouched; everything here only creates resources named/labelled like the harness does.
*
* What the harness lacked for these tests:
* - running a throwaway, labelled helper container (chown/stat of a data dir for the
* `--user 65534:65534` case) — image.ts only has snapshot/remove helpers;
* - creating a never-started container for `docker export`, and `docker save` of the image;
* - parsing `tar -tv` listings and ELF program headers.
*/
import fs from 'node:fs';
import path from 'node:path';
import {
ALPINE_IMAGE,
HarnessError,
LABEL_RUN,
LABEL_TEST,
RESOURCE_PREFIX,
dockerCli,
expect,
runId,
runProcess,
toolEnv,
type CliResult,
type ImageContainer,
type ImageExecOptions,
type ProcResult,
type Workspace,
} from '../../harness';
let helperSeq = 0;
/** Labels every suite-owned container must carry (so teardown removes and leak-checks it). */
export function ownLabels(testKey: string): string[] {
return ['--label', `${LABEL_RUN}=${runId()}`, '--label', `${LABEL_TEST}=${testKey}`];
}
/** Name of a suite-owned helper container. */
export function helperName(testKey: string, what: string): string {
return `${RESOURCE_PREFIX}${runId()}-${testKey}-${what}${++helperSeq}`;
}
/**
* Runs a throwaway alpine container (no network, labelled) with `dir` mounted at /d and returns
* its result; a non-zero exit is a HarnessError (helper failures are never app outcomes).
*/
export async function alpineOnDir(testKey: string, dir: string, script: string): Promise<ProcResult> {
if (!fs.existsSync(dir)) throw new Error(`helper mount source ${dir} does not exist (dockerd would create a root-owned dir)`);
return dockerCli(['run', '--rm', '--name', helperName(testKey, 'helper'), ...ownLabels(testKey), '--network', 'none', '-v', `${dir}:/d`, ALPINE_IMAGE, 'sh', '-c', script]);
}
/** chown/chmod a host directory through a helper container (e.g. hand it to uid 65534). */
export async function chownDir(testKey: string, dir: string, owner: string, mode: string): Promise<void> {
await alpineOnDir(testKey, dir, `chown ${owner} /d && chmod ${mode} /d`);
}
/** `uid:gid` of a file inside a directory the host user may not be able to traverse. */
export async function ownerOf(testKey: string, dir: string, file: string): Promise<string> {
return (await alpineOnDir(testKey, dir, `stat -c %u:%g /d/${file}`)).stdout.trim();
}
export interface TarEntry {
/** '-' regular, 'd' directory, 'l' symlink, ... */
type: string;
perms: string;
owner: string;
size: number;
/** Path as stored (directories keep their trailing slash). */
name: string;
linkTarget?: string;
}
/** `tar -tvf <file>` parsed (GNU tar format). */
export async function listTar(file: string): Promise<TarEntry[]> {
const r = await runProcess('tar', ['-tvf', file], { env: toolEnv(), timeoutMs: 60_000, label: 'tar -tv' });
expect(r.exitCode, `tar -tvf ${file}: ${r.stderr}`).toBe(0);
return r.stdout
.split('\n')
.filter((l) => l.trim())
.map((l) => {
const m = /^(\S)(\S{9})\s+(\S+)\s+(\d+)\s+\S+\s+\S+\s+(.*)$/.exec(l);
if (!m) throw new Error(`unparseable tar listing line ${JSON.stringify(l)}`);
const [name, linkTarget] = m[1] === 'l' ? m[5].split(' -> ') : [m[5], undefined];
return { type: m[1], perms: m[2], owner: m[3], size: Number(m[4]), name, linkTarget };
});
}
export interface SavedImage {
/** Directory the `docker save` archive was unpacked into. */
dir: string;
manifest: Array<{ Config: string; RepoTags: string[] | null; Layers: string[] }>;
config: {
architecture: string;
os: string;
config: Record<string, unknown>;
history: Array<{ created_by?: string; empty_layer?: boolean }>;
rootfs: { type: string; diff_ids: string[] };
};
/** Entries of every layer tarball, in layer order. */
layers: TarEntry[][];
}
/** `docker save` + unpack: manifest, image config and the listing of every layer (no container is created). */
export async function saveImage(ws: Workspace, image: string): Promise<SavedImage> {
const tar = ws.path('image-save.tar');
const dir = ws.mkdir('image-save');
await dockerCli(['save', '-o', tar, image], { timeoutMs: 120_000 });
const x = await runProcess('tar', ['-xf', tar, '-C', dir], { env: toolEnv(), timeoutMs: 60_000, label: 'tar -x' });
expect(x.exitCode, x.stderr).toBe(0);
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8')) as SavedImage['manifest'];
expect(manifest, 'docker save archive holds exactly one image').toHaveLength(1);
const config = JSON.parse(fs.readFileSync(path.join(dir, manifest[0].Config), 'utf8')) as SavedImage['config'];
const layers: TarEntry[][] = [];
for (const l of manifest[0].Layers) layers.push(await listTar(path.join(dir, l)));
return { dir, manifest, config, layers };
}
/**
* `docker create` (never started) + `docker export`: the container's merged filesystem as Docker
* materializes it, including the entries Docker injects at container creation.
*/
export async function exportCreatedContainer(ws: Workspace, testKey: string, image: string): Promise<TarEntry[]> {
const name = helperName(testKey, 'export');
const out = ws.path('container-export.tar');
await dockerCli(['create', '--name', name, ...ownLabels(testKey), '--network', 'none', image]);
try {
await dockerCli(['export', '-o', out, name], { timeoutMs: 120_000 });
} finally {
await dockerCli(['rm', '-f', name], { allowFailure: true });
}
return listTar(out);
}
export interface ElfInfo {
elfClass: 32 | 64;
littleEndian: boolean;
/** e_type: 2 ET_EXEC, 3 ET_DYN. */
type: number;
machine: number;
/** p_type of every program header. */
programHeaderTypes: number[];
}
export const PT_DYNAMIC = 2;
export const PT_INTERP = 3;
export const EM_BY_ARCH: Record<string, number> = { amd64: 62, arm64: 183, '386': 3, arm: 40 };
/** Minimal ELF header + program header table parser. */
export function readElf(file: string): ElfInfo {
const b = fs.readFileSync(file);
expect(b.subarray(0, 4).toString('latin1'), 'ELF magic').toBe('\x7fELF');
const elfClass = b[4] === 2 ? 64 : 32;
const le = b[5] === 1;
const u16 = (o: number) => (le ? b.readUInt16LE(o) : b.readUInt16BE(o));
const u32 = (o: number) => (le ? b.readUInt32LE(o) : b.readUInt32BE(o));
const u64 = (o: number) => Number(le ? b.readBigUInt64LE(o) : b.readBigUInt64BE(o));
const type = u16(16);
const machine = u16(18);
const phoff = elfClass === 64 ? u64(32) : u32(28);
const phentsize = elfClass === 64 ? u16(54) : u16(42);
const phnum = elfClass === 64 ? u16(56) : u16(44);
const programHeaderTypes: number[] = [];
for (let i = 0; i < phnum; i++) programHeaderTypes.push(u32(phoff + i * phentsize));
return { elfClass, littleEndian: le, type, machine, programHeaderTypes };
}
/** Tag of an image reference (`repo/name:tag` → `tag`), undefined for digests/untagged refs. */
export function imageTag(ref: string): string | undefined {
if (ref.includes('@')) return undefined;
const last = ref.slice(ref.lastIndexOf('/') + 1);
const i = last.indexOf(':');
return i < 0 ? undefined : last.slice(i + 1);
}
/** Gateway of the container's (single) network: the source address docker-proxy uses toward it. Call while the container runs. */
export async function gatewayOf(c: ImageContainer): Promise<string> {
const info = await c.inspect();
const nets = (info.NetworkSettings as { Networks: Record<string, { Gateway: string }> }).Networks;
const gws = Object.values(nets).map((n) => n.Gateway).filter(Boolean);
expect(gws, 'container has exactly one network with a gateway').toHaveLength(1);
return gws[0];
}
/**
* Origins a request from the host to a 127.0.0.1-published port may legitimately carry inside the
* container: the network gateway (docker-proxy / masquerade — what this host shows) or loopback
* (some userland-proxy-less setups). PLAN only requires "the request's network source address";
* the exact value through Docker's port publishing is environment-defined, asserted as observed.
*/
export async function expectedOrigins(c: ImageContainer): Promise<string[]> {
return [await gatewayOf(c), '127.0.0.1'];
}
/**
* `docker exec … /app` that also treats daemon-side exec failures as infrastructure errors.
* ImageContainer.exec only raises for exit codes >= 125, but the docker CLI exits 1 when the
* daemon refuses the exec before starting the process (e.g. unknown --user), which would otherwise
* look like the application's exit code 1.
*/
export async function execApp(c: ImageContainer, args: string[], opts: ImageExecOptions = {}): Promise<CliResult> {
const r = await c.exec(args, opts);
if (r.exitCode !== 0 && /^(Error response from daemon:|OCI runtime exec failed)/.test(r.stderr + r.stdout)) {
throw new HarnessError('infrastructure', `docker exec ${c.name} /app ${args.slice(0, 2).join(' ')} failed at the docker level (exit ${r.exitCode}): ${(r.stderr + r.stdout).trim()}`, r);
}
return r;
}
/** Hex of a string's UTF-8 bytes, upper case (as SQLite hex()). */
export const hex = (s: string): string => Buffer.from(s, 'utf8').toString('hex').toUpperCase();