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

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