diff --git a/VERSIONS b/VERSIONS index db17172..d864569 100644 --- a/VERSIONS +++ b/VERSIONS @@ -58,7 +58,7 @@ # ----------------------------------------------------------------------------- IMAGE_CADDY=caddy:2.10@sha256:PIN_DIGEST IMAGE_FORGEJO=codeberg.org/forgejo/forgejo:11@sha256:PIN_DIGEST -IMAGE_POSTGRES=postgres:17@sha256:PIN_DIGEST +IMAGE_POSTGRES=postgres:17@sha256:5c855ad7b85e68e48a62f34662853f38b57c1c1d80f3a927ab58034fd6d31c5e IMAGE_VAULT=hashicorp/vault:1.18@sha256:PIN_DIGEST IMAGE_RUSTFS=rustfs/rustfs:latest@sha256:PIN_DIGEST IMAGE_ACT_RUNNER=code.forgejo.org/forgejo/runner:6@sha256:PIN_DIGEST diff --git a/bootstrap/components/credentials.ts b/bootstrap/components/credentials.ts new file mode 100644 index 0000000..52e5131 --- /dev/null +++ b/bootstrap/components/credentials.ts @@ -0,0 +1,50 @@ +// components/credentials.ts +// +// CONTRACT_002 single owner of credential GENERATION + Vault distribution. +// +// Generation (`generateCredentials`) is pure @pulumi/random — no Vault, no +// network — so it can be created in Phase 3 and consumed by the data-plane +// components (postgres.ts needs its password at container boot). Distribution +// (`writeCredentialsToVault`, T06) is the half that depends on Vault being +// unsealed (Gate A); it writes every value to the KV paths in CONTRACT_002 §2.3. +// Splitting the two halves resolves the ordering tension between "Postgres up in +// Phase 3" and "secrets in Vault in Phase 5" without giving up single ownership. +// +// camelCase keys, no exceptions (CONTRACT_002 §2.2): the Vault write JSON-encodes +// these objects, so the key names ARE the camelCase Vault keys downstream reads. +import * as pulumi from "@pulumi/pulumi"; +import { RandomPassword } from "@pulumi/random"; +import { DeployCtx } from "../lib/context"; + +/** `foundation/postgres/service-credentials` (CONTRACT_002 §2.3). */ +export interface PostgresCredentials { + superUser: string; // "postgres" — image default superuser (deterministic) + superPassword: pulumi.Output; + forgejoDbUser: string; // "forgejo" (deterministic) + forgejoDbPassword: pulumi.Output; +} + +/** Everything generateCredentials() produces; grows as Wave-2 tasks land. */ +export interface FoundationCredentials { + postgres: PostgresCredentials; +} + +/** + * High-entropy alphanumeric secret. special:false keeps values safe to drop into + * connection strings / app.ini / psql literals without escaping (len 28 ≈ 166 bits). + */ +function secret(name: string, length = 28): pulumi.Output { + return new RandomPassword(name, { length, special: false }).result; +} + +/** Generate all egg credentials (pure; no dependencies). CONTRACT_002 writer. */ +export function generateCredentials(ctx: DeployCtx): FoundationCredentials { + return { + postgres: { + superUser: "postgres", + superPassword: secret("postgres-super-password"), + forgejoDbUser: "forgejo", + forgejoDbPassword: secret("forgejo-db-password"), + }, + }; +} diff --git a/bootstrap/components/postgres.ts b/bootstrap/components/postgres.ts new file mode 100644 index 0000000..c1a17f2 --- /dev/null +++ b/bootstrap/components/postgres.ts @@ -0,0 +1,138 @@ +// components/postgres.ts (T03) +// +// foundation-postgres — the relational core (CONTRACT_003 §3.2): users, orgs, CI, +// package metadata for Forgejo. Internal-only (5432 NOT published); reached by +// Forgejo over foundation-net by container name. The named volume +// foundation-postgres-data holds the cluster (CONTRACT_003 §3.4 — backed up via +// pg_dump, CONTRACT_004). +// +// The superuser password comes from generateCredentials() (CONTRACT_002). The +// forgejo login role + database are created post-boot by an idempotent, +// readiness-gated remote.Command (docker exec over SSH — ADR-007), since 5432 is +// not reachable from the operator running Pulumi. +import * as pulumi from "@pulumi/pulumi"; +import * as docker from "@pulumi/docker"; +import * as command from "@pulumi/command"; +import { DeployCtx } from "../lib/context"; +import { vmConnection } from "../lib/remote"; +import { PostgresCredentials } from "./credentials"; + +export interface PostgresOutputs { + container: docker.Container; + /** The forgejo role+DB exist once this resolves — gates Forgejo (T08). */ + ready: command.remote.Command; + /** Internal endpoint for app.ini / consumers (CONTRACT_003 §3.3). */ + endpoint: string; +} + +// Idempotent, readiness-gated role+DB setup (ADR-007). The two SECRET passwords +// arrive on stdin (NOT inlined into the command — the command provider echoes the +// command string on error, which would leak them; stdin is never echoed — D2). +// Non-secret identifiers ($SUPER_USER/$SUPER_DB/$DB_USER/$DB_NAME) are prepended +// by the caller. They are fixed, vetted config (lowercase, no specials) so they +// expand straight into the SQL; the password is a single-quoted SQL literal, +// alphanumeric (special:false) ⇒ injection-safe. (psql does not interpolate +// :'var' in -c, so the statement is assembled in the shell.) +const ROLE_DB_SETUP = `set -eu +IFS= read -r SUPER_PW +IFS= read -r DB_PW +C=foundation-postgres +ready= +for _ in $(seq 1 30); do + if docker exec -e PGPASSWORD="$SUPER_PW" "$C" pg_isready -U "$SUPER_USER" -d "$SUPER_DB" >/dev/null 2>&1; then + ready=1; break + fi + sleep 2 +done +[ "$ready" = 1 ] || { echo "foundation-postgres not ready after 60s" >&2; exit 1; } + +px() { docker exec -e PGPASSWORD="$SUPER_PW" "$C" psql -qXtA -U "$SUPER_USER" -d "$SUPER_DB" "$@"; } + +if [ "$(px -c "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'")" = 1 ]; then + px -c "ALTER ROLE $DB_USER WITH LOGIN PASSWORD '$DB_PW'" +else + px -c "CREATE ROLE $DB_USER WITH LOGIN PASSWORD '$DB_PW'" +fi + +if [ "$(px -c "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'")" != 1 ]; then + px -c "CREATE DATABASE $DB_NAME OWNER $DB_USER" +fi +echo "foundation-postgres: role $DB_USER + database $DB_NAME ready"`; + +export function deployPostgres( + ctx: DeployCtx, + creds: PostgresCredentials, +): PostgresOutputs { + const { cfg, provider, network } = ctx; + + const image = new docker.RemoteImage( + "foundation-postgres-image", + { name: ctx.image("POSTGRES"), keepLocally: true }, + { provider }, + ); + + const volume = new docker.Volume( + "foundation-postgres-data", + { name: "foundation-postgres-data" }, + { provider, retainOnDelete: true }, // never silently drop the cluster + ); + + const container = new docker.Container( + "foundation-postgres", + { + name: "foundation-postgres", + image: image.imageId, + hostname: "foundation-postgres", + restart: "unless-stopped", + envs: [ + pulumi.interpolate`POSTGRES_USER=${creds.superUser}`, + pulumi.interpolate`POSTGRES_PASSWORD=${creds.superPassword}`, + `POSTGRES_DB=${cfg.postgres.db}`, + ], + volumes: [ + { volumeName: volume.name, containerPath: "/var/lib/postgresql/data" }, + ], + networksAdvanced: [ + { name: network.name, aliases: ["foundation-postgres"] }, + ], + healthcheck: { + tests: [ + "CMD-SHELL", + `pg_isready -U ${creds.superUser} -d ${cfg.postgres.db} || exit 1`, + ], + interval: "10s", + timeout: "5s", + retries: 5, + startPeriod: "10s", + }, + logDriver: "json-file", + logOpts: { "max-size": "10m", "max-file": "3" }, + }, + { provider, dependsOn: [network], deleteBeforeReplace: true }, + ); + + // Non-secret identifiers prepended to the static script; the two passwords go + // on stdin (trailing newline so the final `read` sees EOL, not EOF, under -e). + const create = pulumi.interpolate`SUPER_USER='${creds.superUser}' +SUPER_DB='${cfg.postgres.db}' +DB_USER='${creds.forgejoDbUser}' +DB_NAME='${cfg.postgres.forgejoDb}' +${ROLE_DB_SETUP}`; + + const ready = new command.remote.Command( + "foundation-postgres-roledb", + { + connection: vmConnection(ctx), + create, + stdin: pulumi.interpolate`${creds.superPassword}\n${creds.forgejoDbPassword}\n`, + // The VM's sshd rejects setenv (no AcceptEnv); don't try to export the prior + // output into the remote env (avoids a noisy warning on every run). + addPreviousOutputInEnv: false, + // Re-run when the container is replaced or the forgejo password rotates. + triggers: [container.id, creds.forgejoDbPassword], + }, + { dependsOn: [container] }, + ); + + return { container, ready, endpoint: "foundation-postgres:5432" }; +} diff --git a/bootstrap/index.ts b/bootstrap/index.ts index 2b4e961..8460f22 100644 --- a/bootstrap/index.ts +++ b/bootstrap/index.ts @@ -9,6 +9,8 @@ import { loadConfig } from "./config"; import { buildBaseContext, DeployCtx } from "./lib/context"; import { deployNetwork } from "./components/network"; import { deployDns } from "./components/dns"; +import { generateCredentials } from "./components/credentials"; +import { deployPostgres } from "./components/postgres"; const cfg = loadConfig(); @@ -21,17 +23,20 @@ const ctx: DeployCtx = { ...base, network }; const dnsRecords = deployDns(ctx); export const dnsHosts = dnsRecords.map((r) => r.name); +// --- credentials: generation is pure (no deps); Vault distribution is T06 --- +const credentials = generateCredentials(ctx); + // ============================================================================= // PHASE 3 — DATA PLANE (depends on: network) -// T03 postgres · T04 rustfs · T05 vault (sealed) +// T03 postgres ✓ · T04 rustfs · T05 vault (sealed) // ----------------------------------------------------------------------------- -// const postgres = deployPostgres(ctx); -// const rustfs = deployRustfs(ctx); +const postgres = deployPostgres(ctx, credentials.postgres); +// const rustfs = deployRustfs(ctx, credentials.rustfs); // const vault = deployVault(ctx); // // --- GATE A: Vault init + unseal (T05) → writes unseal keys to encrypted config; // credentials.ts (T06) dependsOn the init resource. -// const credentials = deployCredentials(ctx, { postgres, rustfs, vault }); +// writeCredentialsToVault(ctx, credentials, { vault }); // // ============================================================================= // PHASE 6 — FORGE (depends on: credentials, GATE A) @@ -44,12 +49,10 @@ export const dnsHosts = dnsRecords.map((r) => r.name); // ============================================================================= // Stack outputs (extended as phases land). -export const phase = "T03-precursor"; // network + shared provider only +export const phase = "T03-postgres"; // network + DNS + data-plane: postgres export const networkName = network.name; export const vmTarget = `${cfg.vm.user}@${cfg.vm.host}`; +export const postgresEndpoint = postgres.endpoint; export const enabledFeatures = Object.entries(cfg.features) .filter(([, on]) => on) .map(([name]) => name); - -// ctx is consumed by the Wave-2 slots above once uncommented. -void ctx; diff --git a/bootstrap/lib/remote.ts b/bootstrap/lib/remote.ts new file mode 100644 index 0000000..44216e2 --- /dev/null +++ b/bootstrap/lib/remote.ts @@ -0,0 +1,39 @@ +// lib/remote.ts +// +// Builds the SSH connection that @pulumi/command's remote.Command uses to run +// in-VM control-plane operations (docker exec over SSH) — ADR-007. It targets +// the SAME host the Docker provider does: the config VM coordinates, overridable +// by FOUNDATION_DOCKER_HOST (ssh://user@host[:port]) for local validation, with +// the private key read from SSH_PRIVATE_KEY_PATH (CONTRACT_001 §1) — never config. +import * as fs from "fs"; +import * as pulumi from "@pulumi/pulumi"; +import * as command from "@pulumi/command"; +import { BaseCtx } from "./context"; + +/** Parsed `ssh://user@host:port` override, if FOUNDATION_DOCKER_HOST is set. */ +function parseOverride(): + | { host: string; user?: string; port?: number } + | undefined { + const o = process.env.FOUNDATION_DOCKER_HOST; + if (!o) return undefined; + const m = o.match(/^ssh:\/\/(?:([^@]+)@)?([^:/]+)(?::(\d+))?/); + if (!m) return undefined; + return { user: m[1], host: m[2], port: m[3] ? Number(m[3]) : undefined }; +} + +/** + * The remote.Command connection to the foundation VM. The private key is read + * from disk and marked secret so it is redacted in logs and encrypted in state. + */ +export function vmConnection( + ctx: BaseCtx, +): command.types.input.remote.ConnectionArgs { + const ov = parseOverride(); + const privateKey = pulumi.secret(fs.readFileSync(ctx.sshKeyPath, "utf8")); + return { + host: ov?.host ?? ctx.cfg.vm.host, + port: ov?.port ?? ctx.cfg.vm.sshPort, + user: ov?.user ?? ctx.cfg.vm.user, + privateKey, + }; +} diff --git a/bootstrap/package.json b/bootstrap/package.json index 020bf16..0d23fb4 100644 --- a/bootstrap/package.json +++ b/bootstrap/package.json @@ -9,7 +9,10 @@ "@olsitec/pulumi-vault": "workspace:*", "@pulumi/docker": "^4.5.8", "@pulumi/pulumi": "^3.138.0", - "@pulumi/cloudflare": "^5.45.0" + "@pulumi/cloudflare": "^5.45.0", + "@pulumi/random": "^4.16.8", + "@pulumi/vault": "^4.5.8", + "@pulumi/command": "^1.1.3" }, "devDependencies": { "@types/node": "^18", diff --git a/bootstrap/tsconfig.json b/bootstrap/tsconfig.json index a41ee22..e392c29 100644 --- a/bootstrap/tsconfig.json +++ b/bootstrap/tsconfig.json @@ -12,8 +12,9 @@ "noImplicitReturns": true, "forceConsistentCasingInFileNames": true }, - "files": [ - "config.ts", - "index.ts" + "include": [ + "*.ts", + "lib/**/*.ts", + "components/**/*.ts" ] } diff --git a/bun.lock b/bun.lock index 71233cf..f31b3f2 100644 --- a/bun.lock +++ b/bun.lock @@ -15,8 +15,11 @@ "@olsitec/pulumi-docker": "workspace:*", "@olsitec/pulumi-vault": "workspace:*", "@pulumi/cloudflare": "^5.45.0", + "@pulumi/command": "^1.1.3", "@pulumi/docker": "^4.5.8", "@pulumi/pulumi": "^3.138.0", + "@pulumi/random": "^4.16.8", + "@pulumi/vault": "^4.5.8", }, "devDependencies": { "@types/node": "^18", @@ -241,6 +244,8 @@ "@pulumi/cloudflare": ["@pulumi/cloudflare@5.49.1", "", { "dependencies": { "@pulumi/pulumi": "^3.142.0" } }, "sha512-sc4j3XgKId9g9hIB5ZS4QXCLStZzYwzIAgbeAfW4+O78Nd3/tkNsuEWmUnPTpsw5Ezpc5zIwZxBCwhPX5qg+sA=="], + "@pulumi/command": ["@pulumi/command@1.2.1", "", { "dependencies": { "@pulumi/pulumi": "^3.142.0" } }, "sha512-mutNDIUYP67yCBYOVIidQyxuTwZDY9v/sx9EGbgIv4PXfyfolOKGgGLeoHEbI1lxRwaw2wbTZ3VNIynDnA5VKA=="], + "@pulumi/docker": ["@pulumi/docker@4.11.2", "", { "dependencies": { "@pulumi/pulumi": "^3.142.0", "semver": "^5.4.0" } }, "sha512-mm8Uscb/3S7OieYyg1E/vvFx3OS4bAkZvtFvi1yTqYda9NbnYOMbJi7a5fU5xB0N0Kd/uliS8olJ/e6nnvVVPg=="], "@pulumi/eslint-plugin": ["@pulumi/eslint-plugin@0.2.0", "", { "dependencies": { "@typescript-eslint/type-utils": "^5.33.1", "@typescript-eslint/typescript-estree": "^5.33.1", "@typescript-eslint/utils": "^5.33.1", "tsutils": "^3.21.0", "typescript": "^4.7.4" } }, "sha512-tb2Wo1pO8kmNIt+ECkVd7ykRHgadFJfddjLG8Of002X+qbRkNZNttdt55o7EdCDHGB6Dn1RFo/MJYNuHjYn/Dg=="],