// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini /** * In-process fake Docker Engine API on a unix socket. * * Answers `HEAD/GET /_ping` and `GET [/vX.Y]/containers/json`; anything else gets a 404 Engine * error. The behaviour of container listing is switchable live (the socket file is never * recreated, so bind mounts that pinned its inode keep working). */ import fs from 'node:fs'; import http from 'node:http'; import type { Socket } from 'node:net'; import { HarnessError } from './errors'; import type { Rng } from './rng'; /** Subset of the Engine API container summary (GET /containers/json) used by the fake. */ export interface ContainerSummary { Id: string; Names: string[]; Image: string; ImageID: string; Command: string; Created: number; Ports: Array<{ IP?: string; PrivatePort: number; PublicPort?: number; Type: string }>; Labels: Record; State: string; Status: string; HostConfig: { NetworkMode: string }; NetworkSettings: { Networks: Record }; Mounts: unknown[]; [extra: string]: unknown; } /** Plausible running-container summary number i (deterministic unless an rng is given). */ export function makeContainerSummary(i: number, rng?: Rng, overrides: Partial = {}): ContainerSummary { const id = rng ? rng.bytes(32).toString('hex') : (i + 1).toString(16).padStart(64, '0'); const images = ['alpine:3.22', 'nginx:1.29', 'postgres:18', 'redis:8', 'busybox:1.37', 'example.org/team/app:v' + (i % 7)]; const image = rng ? rng.pick(images) : images[i % images.length]; return { Id: id, Names: [`/fake-${i}`], Image: image, ImageID: 'sha256:' + id, Command: i % 3 === 0 ? 'sleep infinity' : i % 3 === 1 ? '/docker-entrypoint.sh nginx -g \'daemon off;\'' : 'sh -c "while :; do sleep 1; done"', Created: 1_760_000_000 + i, Ports: i % 4 === 0 ? [{ IP: '127.0.0.1', PrivatePort: 80, PublicPort: 30000 + (i % 1000), Type: 'tcp' }] : [], Labels: { 'fake.index': String(i), 'com.example.tier': i % 2 ? 'web' : 'db' }, State: 'running', Status: `Up ${1 + (i % 59)} minutes`, HostConfig: { NetworkMode: 'bridge' }, NetworkSettings: { Networks: {} }, Mounts: [], ...overrides, }; } /** n plausible summaries: makeContainerSummary(0..n-1). */ export function makeContainerSummaries(n: number, rng?: Rng): ContainerSummary[] { return Array.from({ length: n }, (_, i) => makeContainerSummary(i, rng)); } /** Behaviour of `GET /containers/json`. */ export type FakeDockerMode = | { kind: 'containers' } // 200 with `fake.containers` | { kind: 'error'; status: number; body: string } // Engine error response | { kind: 'destroy' } // drop the connection without a response | { kind: 'hang' } // never answer (connection held until the fake closes) | { kind: 'badjson'; body: string } // 200 with an unparseable body | { kind: 'passthrough'; upstream: string }; // proxy everything to a real Engine socket export interface FakeDockerRequest { method: string; url: string; /** Path without the /vX.Y prefix and query. */ path: string; query: string; at: number; } export interface FakeDockerStats { ping: number; list: number; other: number; total: number; } export class FakeDocker { /** Containers returned in 'containers' mode (and after flaky failures). */ containers: ContainerSummary[] = []; mode: FakeDockerMode = { kind: 'containers' }; /** The next N list calls fail with 500 before `mode` applies ("temporarily failing"). */ failNext = 0; /** Delay (ms) before answering list calls. */ delayMs = 0; /** API version advertised by /_ping. */ apiVersion = '1.56'; readonly requests: FakeDockerRequest[] = []; readonly stats: FakeDockerStats = { ping: 0, list: 0, other: 0, total: 0 }; private readonly server: http.Server; private readonly sockets = new Set(); private closed = false; private constructor(readonly socketPath: string) { this.server = http.createServer((req, res) => this.handle(req, res)); this.server.on('connection', (s) => { this.sockets.add(s); s.on('close', () => this.sockets.delete(s)); }); } /** Starts a fake listening on socketPath (any stale file there is removed first). Starts in empty mode. */ static async start(socketPath: string): Promise { if (Buffer.byteLength(socketPath) > 100) throw new HarnessError('infrastructure', `unix socket path too long: ${socketPath}`); fs.rmSync(socketPath, { force: true }); const f = new FakeDocker(socketPath); await new Promise((resolve, reject) => { f.server.once('error', reject); f.server.listen(socketPath, () => resolve()); }); return f; } // ---- mode switches (live) ---- /** 200 `[]`. */ empty(): this { return this.setContainers([]); } /** 200 with n generated summaries. */ many(n: number, rng?: Rng): this { return this.setContainers(makeContainerSummaries(n, rng)); } /** 200 with the given summaries. */ setContainers(list: ContainerSummary[]): this { this.containers = list; this.mode = { kind: 'containers' }; return this; } /** Engine error (default 500 `{"message":"fake engine failure"}`). */ error(status = 500, message = 'fake engine failure: /var/lib/docker internal detail'): this { this.mode = { kind: 'error', status, body: JSON.stringify({ message }) }; return this; } /** Alias of error(500). */ error500(message?: string): this { return this.error(500, message); } /** The next k list calls fail with 500, then the current mode applies again. */ flaky(k: number): this { this.failNext = k; return this; } destroy(): this { this.mode = { kind: 'destroy' }; return this; } hang(): this { this.mode = { kind: 'hang' }; return this; } badJson(body = '{"not":"an array"'): this { this.mode = { kind: 'badjson', body }; return this; } passthrough(upstream = '/var/run/docker.sock'): this { this.mode = { kind: 'passthrough', upstream }; return this; } delay(ms: number): this { this.delayMs = ms; return this; } /** Number of container-list calls received so far. */ listCalls(): number { return this.stats.list; } resetStats(): void { this.requests.length = 0; this.stats.ping = this.stats.list = this.stats.other = this.stats.total = 0; } /** Closes the listener and drops every open connection (hung requests included). */ async close(): Promise { if (this.closed) return; this.closed = true; for (const s of this.sockets) s.destroy(); await new Promise((r) => this.server.close(() => r())); fs.rmSync(this.socketPath, { force: true }); } private handle(req: http.IncomingMessage, res: http.ServerResponse): void { const url = new URL(req.url ?? '/', 'http://fake'); const path = url.pathname.replace(/^\/v[0-9.]+(?=\/)/, ''); this.requests.push({ method: req.method ?? '', url: req.url ?? '', path, query: url.search, at: Date.now() }); this.stats.total++; if (path === '/_ping') this.stats.ping++; else if (path === '/containers/json') this.stats.list++; else this.stats.other++; const mode = this.mode; if (mode.kind === 'passthrough') return this.proxy(req, res, mode.upstream); if (path === '/_ping' && (req.method === 'GET' || req.method === 'HEAD')) { res.writeHead(200, { 'Api-Version': this.apiVersion, 'Content-Type': 'text/plain; charset=utf-8', 'Docker-Experimental': 'false', Ostype: 'linux', Server: 'Docker/fake (linux)', }); res.end(req.method === 'HEAD' ? undefined : 'OK'); return; } if (path === '/containers/json' && req.method === 'GET') { const answer = () => this.answerList(res, req); if (this.delayMs > 0 && mode.kind !== 'hang') setTimeout(answer, this.delayMs); else answer(); return; } this.json(res, 404, { message: `page not found: ${req.method} ${path}` }); } private answerList(res: http.ServerResponse, req: http.IncomingMessage): void { if (this.closed) return; if (this.failNext > 0) { this.failNext--; this.json(res, 500, { message: 'fake engine temporarily failing' }); return; } const mode = this.mode; switch (mode.kind) { case 'containers': this.json(res, 200, this.containers); return; case 'error': res.writeHead(mode.status, { 'Content-Type': 'application/json', 'Api-Version': this.apiVersion }); res.end(mode.body); return; case 'destroy': req.socket.destroy(); return; case 'hang': return; // held until close() case 'badjson': res.writeHead(200, { 'Content-Type': 'application/json', 'Api-Version': this.apiVersion }); res.end(mode.body); return; case 'passthrough': return; // handled earlier } } private json(res: http.ServerResponse, status: number, body: unknown): void { const data = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), 'Api-Version': this.apiVersion }); res.end(data); } private proxy(req: http.IncomingMessage, res: http.ServerResponse, upstream: string): void { const up = http.request({ socketPath: upstream, method: req.method, path: req.url, headers: req.headers }, (ur) => { res.writeHead(ur.statusCode ?? 502, ur.headers); ur.pipe(res); }); up.on('error', () => req.socket.destroy()); req.pipe(up); } }