feat(bootstrap): postgres data-plane + remote helper (T03)

foundation-postgres (postgres:17, digest-pinned in VERSIONS) on foundation-net,
internal only (5432 unpublished); named volume foundation-postgres-data with
retainOnDelete. The forgejo login role + database are created post-boot by an
idempotent, readiness-gated remote.Command (ADR-007), since 5432 isn't reachable
from the operator. Adds the generator half of credentials.ts (@pulumi/random →
CONTRACT_002 postgres keys) and lib/remote.ts (vmConnection over the VM SSH path).

Live on cx33 Helsinki: container healthy, role 'forgejo' + db 'forgejo' present,
no published ports. Acceptance T03 met.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Niemann 2026-06-30 21:10:34 +02:00
parent 2e11fd2448
commit 6edba60612
8 changed files with 252 additions and 13 deletions

View file

@ -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

View file

@ -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<string>;
forgejoDbUser: string; // "forgejo" (deterministic)
forgejoDbPassword: pulumi.Output<string>;
}
/** 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<string> {
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"),
},
};
}

View file

@ -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" };
}

View file

@ -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;

39
bootstrap/lib/remote.ts Normal file
View file

@ -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,
};
}

View file

@ -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",

View file

@ -12,8 +12,9 @@
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"files": [
"config.ts",
"index.ts"
"include": [
"*.ts",
"lib/**/*.ts",
"components/**/*.ts"
]
}

View file

@ -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=="],