foundation/bootstrap/config.ts

264 lines
10 KiB
TypeScript
Raw Normal View History

// 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<unknown>(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!,
},
};
}