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:
1 parent
0a6023e942
commit
581a672fcf
9 files changed
+1858
No files matched your search
@@ -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();
|
||||
Reference in new issue
Block a user