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.
214 lines
9.3 KiB
TypeScript
214 lines
9.3 KiB
TypeScript
// 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();
|