diff --git a/docker/testbed/Dockerfile b/docker/testbed/Dockerfile new file mode 100644 index 000000000..7c3fb36c4 --- /dev/null +++ b/docker/testbed/Dockerfile @@ -0,0 +1,56 @@ +# 51-04 testbed image (openrig-testbed) — see IMPL-PLAN §1. Built HOST-side by +# scripts/build-testbed-image.sh, which resolves + records the base digest, pins node, stages the +# stub assets, and COPYs the local openrig pack tarball. Byte-reproducible BY CONTRACT: the base is +# digest-pinned (the verb refuses a tag-floating reference), node is pinned, and openrig comes from +# the tree — never the npm registry (0.5.1 is unreleased). Layers are ordered stable -> volatile. + +# --- base: a digest-pinned LTS-slim Linux, supplied by the build verb (docker/testbed/base-image, +# which REFUSES a non-@sha256 reference). No floating tag is baked into this file. --- +ARG BASE_IMAGE +FROM ${BASE_IMAGE} + +# --- layer 1: userland + tmux + git + PTY/process essentials (most stable) --- +RUN set -eux; \ + apt-get update; \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl xz-utils git tmux tini procps; \ + rm -rf /var/lib/apt/lists/* + +# --- layer 2: pinned node (an in-range engines version — see scripts/check-engines) --- +ARG NODE_VERSION=22.22.1 +ARG TARGETARCH +RUN set -eux; \ + case "${TARGETARCH:-amd64}" in \ + amd64) NODE_ARCH=x64 ;; \ + arm64) NODE_ARCH=arm64 ;; \ + *) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + curl -fsSLo /tmp/node.tar.xz \ + "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"; \ + tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 --no-same-owner; \ + rm -f /tmp/node.tar.xz; \ + node --version; npm --version + +# --- layer 3: the OpenRig package from the LOCAL pack tarball, never the npm registry --- +ARG OPENRIG_TARBALL +COPY ${OPENRIG_TARBALL} /tmp/openrig.tgz +RUN set -eux; \ + npm install -g /tmp/openrig.tgz; \ + rm -f /tmp/openrig.tgz; \ + rig --version + +# --- layer 4: stub runtime assets (zero-token by construction; staged by the build verb into the +# build context as ./stub-assets/, hashed into the manifest via deriveStubAssetsHash) --- +COPY stub-assets/ /opt/openrig-testbed/stub-assets/ + +# --- layer 5: a non-root openrig user + a tini entrypoint into tmux-server-friendly init --- +RUN set -eux; \ + useradd --create-home --shell /bin/bash openrig; \ + mkdir -p /opt/openrig-testbed; \ + chown -R openrig:openrig /opt/openrig-testbed +COPY entrypoint.sh /opt/openrig-testbed/entrypoint.sh +RUN chmod 0755 /opt/openrig-testbed/entrypoint.sh +USER openrig +WORKDIR /home/openrig +ENTRYPOINT ["/usr/bin/tini", "--", "/opt/openrig-testbed/entrypoint.sh"] +CMD ["sleep", "infinity"] diff --git a/docker/testbed/entrypoint.sh b/docker/testbed/entrypoint.sh new file mode 100755 index 000000000..0623517ad --- /dev/null +++ b/docker/testbed/entrypoint.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# 51-04 testbed entrypoint — runs under tini (PID1 reaping, layer 5). Keeps the container init +# tmux-server-friendly (tini reaps the tmux server's children) and seeds the per-container +# self-host identity when the operator supplies one (plan §3 — the 51-09 OPENRIG_SELF_HOST_ID +# adopt path; N containers boot as N distinct named hosts). Then execs the container command. +set -euo pipefail + +if [ -n "${OPENRIG_SELF_HOST_ID:-}" ]; then + # Announce the adopted self-host id so `docker logs` shows which simulated host this is. + echo "[testbed] self-host-id=${OPENRIG_SELF_HOST_ID}" >&2 +fi + +exec "$@" diff --git a/scripts/testbed-dockerfile.test.mjs b/scripts/testbed-dockerfile.test.mjs new file mode 100644 index 000000000..25a58fd0f --- /dev/null +++ b/scripts/testbed-dockerfile.test.mjs @@ -0,0 +1,99 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// 51-04 testbed image — the Dockerfile is the deliverable (plan §1). `docker build` runs +// HOST-side (the locus ruling: the VM seat has no container runtime); this contract test is the +// VM-authorable proof that the Dockerfile encodes the plan §1 fences as executable guards, so a +// later edit that floats the base tag, pulls openrig from the npm registry, or drops the non-root +// user breaks the build here — not silently in a host-side image nobody re-audits. +// +// The fences it pins (plan §1 + the FENCES line): base parameterized for a digest-pin (never a +// floating tag baked in); tmux + git present (the PTY/tmux substrate); node pinned to an in-range +// engines version; OpenRig installed from a COPY'd local tarball, NEVER the npm registry (0.5.1 is +// unreleased — build from the tree); a non-root `openrig` user; a tini/dumb-init entrypoint. + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, ".."); +const DOCKERFILE = join(REPO_ROOT, "docker", "testbed", "Dockerfile"); + +const ENGINES_LTS_MAJORS = new Set([20, 22, 24]); // mirrors scripts/check-engines LTS constraint + +function readDockerfile() { + return readFileSync(DOCKERFILE, "utf8"); +} + +/** Non-comment, non-blank instruction lines. */ +function instructions(text) { + return text + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("#")); +} + +test("Dockerfile exists at docker/testbed/Dockerfile", () => { + assert.match(readDockerfile(), /\S/); +}); + +test("base is parameterized via ARG BASE_IMAGE + FROM ${BASE_IMAGE} — no floating tag baked in", () => { + const text = readDockerfile(); + assert.match(text, /^ARG\s+BASE_IMAGE\b/m, "must declare ARG BASE_IMAGE (digest supplied by the build verb)"); + assert.match(text, /^FROM\s+\$\{BASE_IMAGE\}/m, "FROM must consume ${BASE_IMAGE}, not a hardcoded image"); + // No FROM with a concrete floating tag baked in (e.g. FROM node:22-slim / debian:bookworm-slim). + for (const line of instructions(text)) { + if (/^FROM\s/i.test(line)) { + assert.match(line, /\$\{BASE_IMAGE\}/, `FROM must be parameterized, got: ${line}`); + } + } +}); + +test("installs the PTY/tmux substrate: tmux AND git", () => { + const text = readDockerfile(); + assert.match(text, /\btmux\b/, "must install tmux"); + assert.match(text, /\bgit\b/, "must install git"); +}); + +test("node is pinned via ARG NODE_VERSION to an in-range (even LTS) engines version", () => { + const text = readDockerfile(); + const m = text.match(/^ARG\s+NODE_VERSION=(\d+)\.(\d+)\.(\d+)\b/m); + assert.ok(m, "must declare ARG NODE_VERSION= with a concrete default"); + const major = Number(m[1]); + assert.ok(ENGINES_LTS_MAJORS.has(major), `NODE_VERSION major ${major} must be in {20,22,24} (engines LTS)`); +}); + +test("OpenRig is installed from a COPY'd local tarball, NEVER the npm registry", () => { + const text = readDockerfile(); + // Positive: there is a build ARG for the tarball, it is COPY'd in, and installed from that path. + assert.match(text, /^ARG\s+OPENRIG_TARBALL\b/m, "must declare ARG OPENRIG_TARBALL"); + assert.match(text, /^COPY\s+.*\$\{OPENRIG_TARBALL\}/m, "must COPY the tarball into the image"); + assert.match(text, /npm\s+install[^\n]*\.tgz/, "must install openrig from the local .tgz"); + // Negative fence: no registry install of the openrig package by bare name. + assert.doesNotMatch( + text, + /npm\s+(?:install|i|add)\s+(?:-g\s+)?openrig(?:@|\s|$)/m, + "must NOT install openrig from the npm registry (build from the tree)", + ); +}); + +test("runs as a non-root `openrig` user (creates it + a trailing USER openrig)", () => { + const text = readDockerfile(); + assert.match(text, /\bopenrig\b/, "must reference an openrig user"); + assert.match(text, /(useradd|adduser)[^\n]*openrig/, "must create the openrig user"); + const userLines = instructions(text).filter((l) => /^USER\s/i.test(l)); + assert.ok(userLines.length > 0, "must set a USER"); + assert.match(userLines[userLines.length - 1], /^USER\s+openrig\b/i, "the final USER must be openrig (non-root)"); +}); + +test("entrypoint execs under tini/dumb-init (PID1 reaping)", () => { + const text = readDockerfile(); + const entry = instructions(text).find((l) => /^ENTRYPOINT\s/i.test(l)); + assert.ok(entry, "must declare an ENTRYPOINT"); + assert.match(entry, /tini|dumb-init/, "ENTRYPOINT must exec under tini or dumb-init"); +}); + +test("stages the stub runtime assets into the image (layer 4)", () => { + const text = readDockerfile(); + assert.match(text, /^COPY\s+.*stub-assets/m, "must COPY the staged stub-assets set"); +});