mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
1 parent
10e67c94aa
commit
026a7eac50
3 files changed
+168
No files matched your search
@@ -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"]
|
||||
Executable
+13
@@ -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 "$@"
|
||||
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user