feat(bootstrap): shared docker provider + foundation-net precursor (ADR-006)

Composition substrate for Wave 2 (T03+):
- lib/context.ts: one Docker-over-SSH provider + DeployCtx threaded to component
  factories; FOUNDATION_DOCKER_HOST override for ephemeral validation.
- lib/versions.ts: resolve pinned images from VERSIONS; FOUNDATION_ALLOW_UNPINNED
  for local validation when digests are still PIN_DIGEST.
- components/network.ts: foundation-net (CONTRACT_003 §3.1).
- index.ts: phase-orchestration entrypoint with dependsOn gates; Wave-2 slots.
- ADR-006: shared-provider + per-component-factory model (egg does not route its
  phased bootstrap through the monolithic vendored DockerDeployments).

Validated: pulumi up over Docker-over-SSH created+verified+destroyed foundation-net
on crunchy01 (x86_64); ephemeral, nothing persisted. tsc + preview clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Niemann 2026-06-30 18:18:40 +02:00
parent 57c4eadea7
commit 6a29db386f
8 changed files with 287 additions and 32 deletions

59
bootstrap/lib/versions.ts Normal file
View file

@ -0,0 +1,59 @@
// lib/versions.ts
//
// Reads foundation/VERSIONS (the determinism pin-file, T01 / baseline D5) and
// resolves an image reference by key. Image refs live in VERSIONS, NOT in Pulumi
// config (config.ts comment; CONTRACT_001 §1.1). Components ask for an image by
// its logical name; this module returns the pinned `repo:tag@sha256:<digest>`.
//
// PIN_DIGEST handling (D5 "no floating tags"):
// VERSIONS may carry `@sha256:PIN_DIGEST` placeholders until digests are pinned
// online (T01 documented the `pin-digests` procedure). For a real deploy that is
// a hard error. For local/ephemeral VALIDATION against a dev Docker host, export
// FOUNDATION_ALLOW_UNPINNED=1 to fall back to the bare tag (the digest is dropped).
import * as fs from "fs";
import * as path from "path";
const VERSIONS_PATH = path.resolve(__dirname, "..", "..", "VERSIONS");
let cache: Record<string, string> | undefined;
function load(): Record<string, string> {
if (cache) return cache;
const text = fs.readFileSync(VERSIONS_PATH, "utf8");
const out: Record<string, string> = {};
for (const raw of text.split("\n")) {
const line = raw.trim();
if (line === "" || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq < 0) continue;
out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
cache = out;
return out;
}
/** Resolve a container image by its VERSIONS key suffix, e.g. image("FORGEJO"). */
export function image(name: string): string {
const key = `IMAGE_${name.toUpperCase()}`;
const v = load()[key];
if (!v) {
throw new Error(`VERSIONS: ${key} is not defined (foundation/VERSIONS)`);
}
if (v.includes("PIN_DIGEST")) {
if (process.env.FOUNDATION_ALLOW_UNPINNED === "1") {
// validation only: drop the unresolved @sha256:PIN_DIGEST, use the tag
return v.replace(/@sha256:PIN_DIGEST$/, "");
}
throw new Error(
`VERSIONS: ${key} is not digest-pinned ('${v}'). Run the pin-digests ` +
`procedure (see VERSIONS header) before a real deploy, or set ` +
`FOUNDATION_ALLOW_UNPINNED=1 for local validation.`,
);
}
return v;
}
/** Minimum version string for a host tool, e.g. toolMin("VAULT"). */
export function toolMin(name: string): string | undefined {
return load()[`TOOL_${name.toUpperCase()}_MIN`];
}