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:
parent
2e11fd2448
commit
6edba60612
8 changed files with 252 additions and 13 deletions
50
bootstrap/components/credentials.ts
Normal file
50
bootstrap/components/credentials.ts
Normal 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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
138
bootstrap/components/postgres.ts
Normal file
138
bootstrap/components/postgres.ts
Normal 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" };
|
||||
}
|
||||
|
|
@ -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
39
bootstrap/lib/remote.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@
|
|||
"noImplicitReturns": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"files": [
|
||||
"config.ts",
|
||||
"index.ts"
|
||||
"include": [
|
||||
"*.ts",
|
||||
"lib/**/*.ts",
|
||||
"components/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue