Unify trunk on main: initialization → main #3

Merged
jschoubben merged 95 commits from initialization into main 2026-09-05 01:13:46 +00:00
3 changed files with 278 additions and 0 deletions
Showing only changes of commit 37c6a0ba21 - Show all commits
+13
View File
@@ -54,6 +54,19 @@ export async function buildBaseImage(
log(" installing a container runtime");
await incus(["exec", BUILDER, "--", "pacman", "-Sy", "--noconfirm", "docker"], 600_000);
// Trust the documentation ranges as plain-HTTP registries.
//
// A scenario's registry is scenery inside the scenario, serving over HTTP, and a runtime
// will not pull from one without being told. Scoped to RFC 5737 and RFC 3849 ranges rather
// than a specific address, because those never route on the real internet — so this cannot
// make a real machine trust a real registry, whatever it is copied onto.
await incus([
"exec", BUILDER, "--", "sh", "-c",
`mkdir -p /etc/docker && printf '%s' '${JSON.stringify({
"insecure-registries": ["192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"],
})}' > /etc/docker/daemon.json`,
], 60_000);
await incus(["exec", BUILDER, "--", "systemctl", "enable", "docker"], 60_000);
await incus(["exec", BUILDER, "--", "systemctl", "start", "docker"], 120_000);
+199
View File
@@ -0,0 +1,199 @@
/**
* A registry inside the scenario.
*
* A sealed machine cannot reach a registry, and an image placed from an archive cannot keep its
* digest — `docker save` of a digest reference produces an archive with no repo tag, because a
* repo digest only exists for an image a registry served (novox/hq 04-ISSUES/009). So an image
* pinned by digest, which is the only kind the host accepts
* ([ADR 0046](../../02-DECISIONS/0046-the-installer-fetches-what-it-pins.md)), could not be
* placed at all.
*
* The answer is a registry, and it is not a workaround for the lab: ADR 0048 names an OCI
* registry as substrate, and ADR 0046 says a first node fetches "upstream, wherever the image
* ordinarily lives". **This is that upstream** — scenery, like the transit router is the
* internet ([ADR 0033](../../02-DECISIONS/0033-a-router-is-scenery-not-a-node.md)).
*
* The digests it serves are its own, not Docker Hub's, and that is correct rather than a
* compromise. What ADR 0046 requires is a reference that is exact and cannot move. A digest
* assigned by this registry is both.
*/
import { spawn } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
/** The image the registry itself runs from. Placed by tag, which archives keep. */
export const REGISTRY_IMAGE = "registry:2";
/** Where the registry serves, inside its machine. */
export const REGISTRY_PORT = 5000;
export class RegistryError extends Error {
constructor(message: string) {
super(message);
this.name = "RegistryError";
}
}
export interface StockedImage {
/** What the scenario asked for, as written. */
requested: string;
/** The repository path the registry serves it under. */
repository: string;
/** The digest THIS registry assigned. What a declaration pins. */
digest: string;
}
export interface Stock {
/** A directory holding the registry's data, ready to be placed in a machine. */
dataDir: string;
images: StockedImage[];
}
/**
* Build a registry's data directory on this workstation, with the given images in it.
*
* Runs a throwaway registry here — where there IS a network — pushes into it, and keeps what
* it wrote. Research 012's reframing again: fetch at build time on a machine that has a
* network, apply on a target that needs nothing.
*
* The caller owns the returned directory and must remove it.
*/
export async function stockRegistry(
references: string[],
log: (message: string) => void = () => {},
): Promise<Stock> {
if (references.length === 0) return { dataDir: "", images: [] };
const dataDir = await mkdtemp(join(tmpdir(), "mesh-lab-registry-"));
const container = `mesh-lab-stock-${process.pid}`;
const port = 5000 + (process.pid % 1000);
await docker(["rm", "-f", container], 60_000);
const started = await docker(
["run", "-d", "--name", container, "-p", `${port}:5000`, "-v", `${dataDir}:/var/lib/registry`,
REGISTRY_IMAGE],
300_000,
);
if (!started.ok) {
await rm(dataDir, { recursive: true, force: true });
throw new RegistryError(
`cannot run ${REGISTRY_IMAGE} on this workstation to stock a registry: ${started.stderr.trim()}`,
);
}
try {
await waitForRegistry(port);
const images: StockedImage[] = [];
for (const reference of references) {
// The repository path a machine will pull from. A tag is dropped: what a declaration
// pins is the digest, and carrying the tag as well would invite pinning the wrong one.
const repository = repositoryFor(reference);
const target = `localhost:${port}/${repository}`;
const tagged = await docker(["tag", reference, target], 60_000);
if (!tagged.ok) {
throw new RegistryError(
`${reference} is not on this workstation, and the lab does not fetch on a scenario's ` +
`behalf. Pull it here first.\n ${tagged.stderr.trim()}`,
);
}
const pushed = await docker(["push", target], 900_000);
if (!pushed.ok) throw new RegistryError(`cannot push ${reference}: ${pushed.stderr.trim()}`);
const digest = digestFrom(pushed.stdout + pushed.stderr);
if (!digest) {
throw new RegistryError(
`${reference} was pushed and the registry did not report a digest. Without one there ` +
`is nothing for a declaration to pin.`,
);
}
images.push({ requested: reference, repository, digest });
log(` stocked ${repository}@${digest}`);
}
return { dataDir, images };
} catch (err) {
await rm(dataDir, { recursive: true, force: true });
throw err;
} finally {
await docker(["rm", "-f", container], 60_000);
}
}
/** `alpine:3.20` and `alpine` both serve from `alpine`; `foo/bar:1` from `foo/bar`. */
export function repositoryFor(reference: string): string {
const withoutDigest = reference.split("@")[0] ?? reference;
const lastColon = withoutDigest.lastIndexOf(":");
const lastSlash = withoutDigest.lastIndexOf("/");
return lastColon > lastSlash ? withoutDigest.slice(0, lastColon) : withoutDigest;
}
/** `docker push` prints `<tag>: digest: sha256:… size: …` on its last useful line. */
export function digestFrom(output: string): string | null {
const match = output.match(/digest:\s*(sha256:[a-f0-9]{64})/);
return match?.[1] ?? null;
}
async function waitForRegistry(port: number): Promise<void> {
for (let i = 0; i < 30; i++) {
const probe = await docker(["run", "--rm", "--network", "host", REGISTRY_IMAGE,
"sh", "-c", `wget -q -O- http://localhost:${port}/v2/ >/dev/null 2>&1`], 30_000);
if (probe.ok) return;
await new Promise((r) => setTimeout(r, 1_000));
}
throw new RegistryError("a registry was started on this workstation and never answered");
}
function docker(
args: string[],
timeoutMs: number,
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
child.stdout.on("data", (d) => (stdout += d));
child.stderr.on("data", (d) => (stderr += d));
child.on("error", (err) => {
clearTimeout(timer);
resolve({ ok: false, stdout, stderr: err.message });
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ ok: code === 0, stdout, stderr });
});
});
}
// --- the registry inside a scenario ------------------------------------------------------------
/**
* Where the registry sits on its segment.
*
* A convention rather than a declaration, like the router's. `.250` is chosen to sit well away
* from the low addresses scenarios give their machines, so a scenario can be written without
* thinking about it and a collision is obvious when it happens.
*/
export const REGISTRY_HOST_OCTET = 250;
/** The address the registry answers on, given the segment it is attached to. */
export function registryAddress(cidr: string): string {
const [network] = cidr.split("/");
const parts = (network ?? "").split(".");
if (parts.length !== 4) {
throw new RegistryError(
`cannot place a registry on '${cidr}': it is not an IPv4 network, and the registry needs ` +
`an address a machine can be pointed at.`,
);
}
return `${parts[0]}.${parts[1]}.${parts[2]}.${REGISTRY_HOST_OCTET}`;
}
/** What a declaration should pin, once a scenario is raised. */
export function pinnedReference(address: string, image: StockedImage): string {
return `${address}:${REGISTRY_PORT}/${image.repository}@${image.digest}`;
}
+66
View File
@@ -0,0 +1,66 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { digestFrom, pinnedReference, registryAddress, repositoryFor } from "../src/lifecycle/registry.ts";
/**
* The registry inside a scenario (novox/hq 04-ISSUES/009).
*
* These test the pure parts. The parts that need a registry are exercised by raising a
* scenario, because a fake registry would assert that the fake behaves as expected
* (novox/hq ADR 0034).
*/
test("a digest is read from what the registry actually said", () => {
// The real shape of `docker push` output. The digest here is the REGISTRY's, not Docker
// Hub's, and that is the point: a declaration pins what this registry serves.
const output =
"The push refers to repository [localhost:5000/alpine]\n" +
"63f227048c13: Pushed\n" +
"3.20: digest: sha256:6c2a9711b0a9f32b0239d9222eb1072309cf46c6431d319ae249186d811a987c size: 528\n";
assert.equal(
digestFrom(output),
"sha256:6c2a9711b0a9f32b0239d9222eb1072309cf46c6431d319ae249186d811a987c",
);
});
test("no digest is not an empty digest", () => {
// A push that reported no digest leaves nothing for a declaration to pin, and inventing one
// would be worse than failing — the host would refuse it later, further from the cause.
assert.equal(digestFrom("The push refers to repository [localhost:5000/alpine]\n"), null);
assert.equal(digestFrom(""), null);
// Hex, but the wrong LENGTH. An earlier version used "tooshort", whose letters fall outside
// a-f — so it failed the character class and proved nothing about the length check.
assert.equal(digestFrom("digest: sha256:abc123"), null);
assert.equal(digestFrom("digest: sha256:" + "a".repeat(63)), null, "63 is not 64");
});
test("the repository is the reference without its tag", () => {
assert.equal(repositoryFor("alpine:3.20"), "alpine");
assert.equal(repositoryFor("alpine"), "alpine");
assert.equal(repositoryFor("library/postgres:17"), "library/postgres");
// A port in a hostname is a colon that is NOT a tag, and treating it as one would serve the
// image from a truncated path.
assert.equal(repositoryFor("localhost:5000/alpine:3.20"), "localhost:5000/alpine");
assert.equal(repositoryFor("localhost:5000/alpine"), "localhost:5000/alpine");
});
test("the registry's address is derived from its segment", () => {
assert.equal(registryAddress("192.0.2.0/24"), "192.0.2.250");
assert.equal(registryAddress("198.51.100.0/24"), "198.51.100.250");
// An IPv6-only segment cannot host it, and saying so beats producing an address nothing
// can be pointed at.
assert.throws(() => registryAddress("2001:db8:a::/48"), /not an IPv4 network/);
});
test("what a declaration pins is the registry's own digest", () => {
// Not Docker Hub's. ADR 0046 requires a reference that is exact and cannot move, and a
// digest this registry assigned is both.
const pinned = pinnedReference("192.0.2.250", {
requested: "alpine:3.20",
repository: "alpine",
digest: "sha256:" + "6".repeat(64),
});
assert.equal(pinned, `192.0.2.250:5000/alpine@sha256:${"6".repeat(64)}`);
assert.ok(pinned.includes("@sha256:"), "the host refuses anything not pinned by digest");
assert.ok(!pinned.includes(":3.20"), "a tag would move; the digest is what is pinned");
});