diff --git a/bootstrap/components/credentials.ts b/bootstrap/components/credentials.ts index e74b3f6..8cd1d9d 100644 --- a/bootstrap/components/credentials.ts +++ b/bootstrap/components/credentials.ts @@ -15,6 +15,7 @@ import * as pulumi from "@pulumi/pulumi"; import * as command from "@pulumi/command"; import { RandomPassword } from "@pulumi/random"; +import { BackupSecrets } from "../config"; import { DeployCtx } from "../lib/context"; import { vmConnection } from "../lib/remote"; import { VaultOutputs } from "./vault"; @@ -167,3 +168,65 @@ ${creds.forgejo.adminPassword} { dependsOn: [vault.init] }, ); } + +// Mirrors the config-seeded backup credentials (offsite S3 creds + the age key) +// into Vault at foundation/backup/backup-credentials (CONTRACT_002 §2.3). Unlike +// the generated data-plane creds these are seeded once into passphrase-encrypted +// config (the age IDENTITY MUST also live there so {repo + passphrase} can decrypt +// a bundle after total Vault loss — CONTRACT_004 §4.3); this writer makes them +// available to in-Vault consumers (Layer-1 ESO, the backup-verify job). Secret +// values on stdin (ADR-007 D2); non-secrets (endpoint, recipient) as shell vars. +const WRITE_BACKUP_CREDS = `set -eu +IFS= read -r ROOT_TOKEN +IFS= read -r OFF_AK +IFS= read -r OFF_SK +IFS= read -r AGE_IDENTITY +C=foundation-vault +VE="-e VAULT_ADDR=http://127.0.0.1:8200 -e VAULT_TOKEN=$ROOT_TOKEN" + +if ! docker exec $VE "$C" vault secrets list -format=json 2>/dev/null | jq -e 'has("foundation/")' >/dev/null; then + docker exec $VE "$C" vault secrets enable -path=foundation kv-v2 >/dev/null +fi + +jq -n --arg ep "$OFF_EP" --arg ak "$OFF_AK" --arg sk "$OFF_SK" --arg ar "$AGE_RECIPIENT" --arg ai "$AGE_IDENTITY" \ + '{offsiteEndpoint:$ep,offsiteAccessKey:$ak,offsiteSecretKey:$sk,backupAgeRecipient:$ar,backupAgeIdentity:$ai}' \ + | docker exec -i $VE "$C" vault kv put foundation/backup/backup-credentials - >/dev/null + +echo "vault: wrote backup/backup-credentials (offsite + age key)"`; + +/** + * Mirror the backup credentials (incl. the age key) into Vault (CONTRACT_002 §2.3). + * Depends on Vault being unsealed (GATE A) via vault.init — same pattern as the + * data-plane creds writer above. + */ +export function writeBackupCredentialsToVault( + ctx: DeployCtx, + vault: VaultOutputs, + backup: BackupSecrets, +): command.remote.Command { + const create = pulumi.interpolate`OFF_EP='${backup.offsiteEndpoint}' +AGE_RECIPIENT='${backup.ageRecipient}' +${WRITE_BACKUP_CREDS}`; + + return new command.remote.Command( + "foundation-backup-credentials", + { + connection: vmConnection(ctx), + create, + update: create, + stdin: pulumi.interpolate`${vault.rootToken} +${backup.offsiteAccessKey} +${backup.offsiteSecretKey} +${backup.ageIdentity} +`, + addPreviousOutputInEnv: false, + triggers: [ + vault.init.id, + backup.offsiteAccessKey, + backup.offsiteSecretKey, + backup.ageIdentity, + ], + }, + { dependsOn: [vault.init] }, + ); +} diff --git a/bootstrap/config.ts b/bootstrap/config.ts index 762c19d..d737a00 100644 --- a/bootstrap/config.ts +++ b/bootstrap/config.ts @@ -88,6 +88,33 @@ export interface FoundationConfig { }; } +/** + * The backup secret slice (CONTRACT_001 §1.3 + CONTRACT_002 `foundation/backup/ + * backup-credentials`). Kept OUT of the non-secret FoundationConfig surface: these + * are `secure:`-encrypted (offsite creds, age identity) or a public key that only + * the Vault mirror + backup scripts consume. `loadBackupSecrets()` is the single + * reader (composition point), so components still never touch raw pulumi.Config. + */ +export interface BackupSecrets { + offsiteEndpoint: string; // non-secret (also in FoundationConfig.backup) + offsiteAccessKey: pulumi.Output; + offsiteSecretKey: pulumi.Output; + ageRecipient: string; // age1… public key (non-secret) + ageIdentity: pulumi.Output; // AGE-SECRET-KEY-… (secret; survives Vault loss via config) +} + +/** Reads the backup secret slice for the Vault mirror (CONTRACT_002 §2.3). */ +export function loadBackupSecrets(): BackupSecrets { + const c = new pulumi.Config("foundation"); + return { + offsiteEndpoint: c.require("backup.offsiteEndpoint"), + offsiteAccessKey: c.requireSecret("backup.offsiteAccessKey"), + offsiteSecretKey: c.requireSecret("backup.offsiteSecretKey"), + ageRecipient: c.require("backup.ageRecipient"), + ageIdentity: c.requireSecret("backup.ageIdentity"), + }; +} + /** * 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 diff --git a/bootstrap/index.ts b/bootstrap/index.ts index 1c1ed20..6d925fb 100644 --- a/bootstrap/index.ts +++ b/bootstrap/index.ts @@ -6,13 +6,14 @@ // fill the marked slots — this file is the single composition point; components stay // pure factories in components/*. import * as pulumi from "@pulumi/pulumi"; -import { loadConfig } from "./config"; +import { loadConfig, loadBackupSecrets } from "./config"; import { buildBaseContext, DeployCtx } from "./lib/context"; import { deployNetwork } from "./components/network"; import { deployDns } from "./components/dns"; import { generateCredentials, writeCredentialsToVault, + writeBackupCredentialsToVault, } from "./components/credentials"; import { deployPostgres } from "./components/postgres"; import { deployRustfs } from "./components/rustfs"; @@ -47,6 +48,8 @@ const vault = deployVault(ctx); // --- GATE A: Vault init + unseal (T05). T06 writes the generated data-plane creds // into Vault (CONTRACT_002), dependsOn vault.init so it runs only once unsealed. const vaultCreds = writeCredentialsToVault(ctx, credentials, vault); +// Mirror the config-seeded backup creds + age key into Vault (CONTRACT_002 §2.3). +const backupCreds = writeBackupCredentialsToVault(ctx, vault, loadBackupSecrets()); // ============================================================================= // PHASE 6 — FORGE (depends on: credentials, GATE A) // T07 caddy ✓ · T08 forgejo · T10 runner @@ -77,6 +80,7 @@ const runner = cfg.features.runner ? deployRunner(ctx, forgejo) : undefined; // Stack outputs (extended as phases land). // vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet. void vaultCreds; +void backupCreds; // CONTRACT_002 backup/backup-credentials mirror; no secret output export const phase = "T10-runner"; // forge + CI runner live export const caddyImageId = proxy.imageId;