// config.ts // // Producer of the typed foundation config (CONTRACT_001 §1.1). Every component // in bootstrap/components/* consumes THIS — components MUST NOT read raw // pulumi.Config ad hoc (CONTRACT_001 "Ownership"). // // Three (and only three) configuration channels exist (CONTRACT_001 §Interface): // 1. ENV PULUMI_CONFIG_PASSPHRASE (master passphrase — the only external secret) // SSH_PRIVATE_KEY_PATH (key reaching the VM; default ~/.ssh/id_rsa) // 2. VERSIONS foundation/VERSIONS (image DIGESTS + tool versions — NOT in Pulumi config) // 3. Pulumi config Pulumi.foundation.yaml (typed non-secret + passphrase-encrypted secrets) // // IMPORTANT: image digests live in VERSIONS (determinism, baseline D5 / PLAN-002 §7.1), // NOT in this config. `loadConfig()` deliberately does not read or validate any image // reference — that is the preflight/VERSIONS concern, resolved at container-build time // in later component tasks (T03+). // // CONFIG KEY SHAPE: the non-secret keys are stored FLAT with dotted names // (CONTRACT_001 §1.2: `foundation:hosts.forge`, `foundation:features.forgejo`, …). // Pulumi does NOT auto-assemble `hosts.forge` + `hosts.vault` into a `hosts` object, // so each leaf is read by its full dotted key via the typed accessor for its kind // (`get` for strings, `getNumber`, `getBoolean`, `getObject` for arrays). This also // makes the loader robust to Pulumi storing scalars as quoted strings. import * as pulumi from "@pulumi/pulumi"; /** * The single typed configuration surface for the `foundation` stack (CONTRACT_001 §1.1). * Realises PLAN-002 §3, §4.2. See CONTRACT_003 for the fixed container names/ports/network. */ export interface FoundationConfig { // --- identity / networking --- baseDomain: string; // "olsitec.de" hosts: { // public FQDNs terminated by Caddy forge: string; // "forge.olsitec.net" (Forgejo web/API/registry) vault: string; // "vault.olsitec.net" (Vault UI/API — internal-restricted) s3: string; // "s3.olsitec.net" (RustFS API, optional public) git: string; // "git.olsitec.net" (Git-over-SSH host; CONTRACT_001 amendment 2026-06-30) }; forgeSshPort: number; // 2222 (git-over-ssh, published directly, not via Caddy) // --- deployment target (Docker-over-SSH provider) --- vm: { host: string; // IP or DNS of the foundation VM user: string; // ssh user (e.g. "root" or "deploy") sshPort: number; // SSH port for the Docker-over-SSH provider (22 default; test VM uses 222) // private key path comes from ENV SSH_PRIVATE_KEY_PATH, never config }; // --- Cloudflare (DNS records + ACME DNS-01); token is a SECRET (§1.3) --- cloudflare: { zoneId: string; // olsitec.net zone id (non-secret) }; // --- container plane (see CONTRACT_003 for names/ports) --- network: { name: string; subnet: string }; // "foundation-net", "172.30.0.0/24" dataRoot: string; // host path for bind mounts / named-volume root (e.g. "/srv/foundation") // --- TLS strategy --- tls: { mode: "letsencrypt-dns01" | "internal-ca"; // day-zero may start internal-ca, switch later acmeEmail: string; // cloudflareApiToken is a SECRET (CONTRACT_001 §1.3) — not in this typed surface }; // --- service sizing / fixed names (derived, non-secret) --- postgres: { db: string; forgejoDb: string }; // names only; creds are generated → Vault rustfs: { buckets: string[] }; // ["forgejo-packages","forgejo-artifacts","forgejo-lfs","foundation-backups"] forgejo: { adminUser: string; orgName: string }; // "platform-admin", "olsitec" runner: { labels: string[] }; // ["docker:docker://…","dind:docker://-"] (PLAN-001 §4a) // --- credential feature flags (ADR-002 style; selects what @pulumi/random generates) --- features: { postgres: boolean; rustfs: boolean; forgejo: boolean; runner: boolean; backup: boolean; registry: boolean; }; // --- backup --- backup: { bucket: string; // "foundation-backups" (in RustFS) offsiteEndpoint: string; // self-hosted second location (CONTRACT_004); creds are SECRET retentionDaily: number; retentionWeekly: number; }; } /** * The SSH private key path is supplied by ENV (CONTRACT_001 §1: `SSH_PRIVATE_KEY_PATH`, * default ~/.ssh/id_rsa) — NEVER by Pulumi config. Exposed separately from * FoundationConfig because it is an operator/host concern, not stack state. Mirrors the * proven olsitec-core/config.ts pattern. */ export function sshPrivateKeyPath(): string { const home = process.env.HOME ?? ""; return process.env.SSH_PRIVATE_KEY_PATH || `${home}/.ssh/id_rsa`; } /** * Reads + validates the Pulumi config and returns the typed FoundationConfig. * * Fails CLOSED (CONTRACT_001 "Ownership", "Validation"): every required key is read * defensively; any that is missing OR malformed is collected, and a single error * listing ALL of them is thrown — so `pulumi preview` on an under-populated stack * reports the whole gap at once (acceptance T02), not one key at a time. * * Secrets (CONTRACT_001 §1.3: vaultCredentials:*, foundation:cloudflareApiToken, * foundation:backup.offsite*Key) are NOT required here — they are seeded by later * tasks. This loader validates only the non-secret typed surface (§1.2). */ export function loadConfig(): FoundationConfig { const c = new pulumi.Config("foundation"); const missing: string[] = []; const malformed: string[] = []; // --- typed leaf readers: record a missing/malformed key, return undefined on failure --- const reqStr = (key: string): string | undefined => { const v = c.get(key); if (v === undefined || v === "") { missing.push(`foundation:${key}`); return undefined; } return v; }; const reqNum = (key: string): number | undefined => { if (c.get(key) === undefined) { missing.push(`foundation:${key}`); return undefined; } try { return c.requireNumber(key); } catch { malformed.push(`foundation:${key} must be a number`); return undefined; } }; const reqBool = (key: string): boolean | undefined => { if (c.get(key) === undefined) { missing.push(`foundation:${key}`); return undefined; } try { return c.requireBoolean(key); } catch { malformed.push(`foundation:${key} must be a boolean`); return undefined; } }; const reqStrArr = (key: string): string[] | undefined => { if (c.get(key) === undefined) { missing.push(`foundation:${key}`); return undefined; } const v = c.getObject(key); if ( !Array.isArray(v) || v.length === 0 || !v.every((x) => typeof x === "string") ) { malformed.push(`foundation:${key} must be a non-empty string[]`); return undefined; } return v as string[]; }; // --- read every required non-secret key (CONTRACT_001 §1.1 / §1.2) --- const baseDomain = reqStr("baseDomain"); const hostsForge = reqStr("hosts.forge"); const hostsVault = reqStr("hosts.vault"); const hostsS3 = reqStr("hosts.s3"); const hostsGit = reqStr("hosts.git"); const forgeSshPort = reqNum("forgeSshPort"); const vmHost = reqStr("vm.host"); const vmUser = reqStr("vm.user"); const vmSshPort = c.getNumber("vm.sshPort") ?? 22; // optional; defaults to standard SSH const cloudflareZoneId = reqStr("cloudflare.zoneId"); const networkName = reqStr("network.name"); const networkSubnet = reqStr("network.subnet"); const dataRoot = reqStr("dataRoot"); const tlsModeRaw = reqStr("tls.mode"); const tlsAcmeEmail = reqStr("tls.acmeEmail"); const postgresDb = reqStr("postgres.db"); const postgresForgejoDb = reqStr("postgres.forgejoDb"); const rustfsBuckets = reqStrArr("rustfs.buckets"); const forgejoAdminUser = reqStr("forgejo.adminUser"); const forgejoOrgName = reqStr("forgejo.orgName"); const runnerLabels = reqStrArr("runner.labels"); const fPostgres = reqBool("features.postgres"); const fRustfs = reqBool("features.rustfs"); const fForgejo = reqBool("features.forgejo"); const fRunner = reqBool("features.runner"); const fBackup = reqBool("features.backup"); const fRegistry = reqBool("features.registry"); const backupBucket = reqStr("backup.bucket"); const backupOffsiteEndpoint = reqStr("backup.offsiteEndpoint"); const backupRetentionDaily = reqNum("backup.retentionDaily"); const backupRetentionWeekly = reqNum("backup.retentionWeekly"); // tls.mode is an enum — validate only if it was present. if ( tlsModeRaw !== undefined && tlsModeRaw !== "letsencrypt-dns01" && tlsModeRaw !== "internal-ca" ) { malformed.push( `foundation:tls.mode must be "letsencrypt-dns01" | "internal-ca" (got ${JSON.stringify(tlsModeRaw)})`, ); } // --- fail closed: report ALL missing, then ALL malformed, in one error --- if (missing.length > 0) { throw new Error( "Foundation config validation FAILED (CONTRACT_001). " + `${missing.length} required key(s) missing from the 'foundation' stack:\n` + missing.map((k) => ` - ${k}`).join("\n") + "\nSet them in Pulumi.foundation.yaml (non-secret) and re-run. " + "Image digests are NOT config — they live in foundation/VERSIONS.", ); } if (malformed.length > 0) { throw new Error( "Foundation config validation FAILED (CONTRACT_001) — malformed value(s):\n" + malformed.map((m) => ` - ${m}`).join("\n"), ); } // All keys present + well-typed past this point (the `!` are now sound). return { baseDomain: baseDomain!, hosts: { forge: hostsForge!, vault: hostsVault!, s3: hostsS3!, git: hostsGit! }, forgeSshPort: forgeSshPort!, vm: { host: vmHost!, user: vmUser!, sshPort: vmSshPort }, cloudflare: { zoneId: cloudflareZoneId! }, network: { name: networkName!, subnet: networkSubnet! }, dataRoot: dataRoot!, tls: { mode: tlsModeRaw as FoundationConfig["tls"]["mode"], acmeEmail: tlsAcmeEmail!, }, postgres: { db: postgresDb!, forgejoDb: postgresForgejoDb! }, rustfs: { buckets: rustfsBuckets! }, forgejo: { adminUser: forgejoAdminUser!, orgName: forgejoOrgName! }, runner: { labels: runnerLabels! }, features: { postgres: fPostgres!, rustfs: fRustfs!, forgejo: fForgejo!, runner: fRunner!, backup: fBackup!, registry: fRegistry!, }, backup: { bucket: backupBucket!, offsiteEndpoint: backupOffsiteEndpoint!, retentionDaily: backupRetentionDaily!, retentionWeekly: backupRetentionWeekly!, }, }; }