feat(testbed): 51-04 Dockerfile + entrypoint with plan-§1 fences guarded

The openrig-testbed Dockerfile (docker/testbed/) as the deliverable: digest-pinned
base parameterized via ARG BASE_IMAGE (the verb refuses a tag-floating ref), tmux+git
PTY substrate, pinned in-range node, OpenRig from the LOCAL pack tarball never the npm
registry, staged stub assets, a non-root openrig user, tini PID1 entrypoint that seeds
the 51-09 self-host-id adopt path. docker build runs host-side (locus ruling); the
plan-§1 fences are pinned as an executable contract (scripts/testbed-dockerfile.test.mjs
8/8, negative-asserts the registry-install fence), RED-first.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-06 05:23:49 +00:00
1 parent 10e67c94aa
commit 026a7eac50
3 files changed
+168

No files matched your search

+56
View File
@@ -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"]
+13
View File
@@ -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 "$@"
+99
View File
@@ -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=<x.y.z> 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");
});