foundation/bootstrap/index.ts
Andreas Niemann 522c5d7a54 fix(forgejo): generate + set SECRET_KEY (was empty under INSTALL_LOCK)
Follow-up to the crypto-secret mirror: Forgejo's [security] SECRET_KEY was
EMPTY because the bootstrap skips the web installer (INSTALL_LOCK), which is
what normally generates it. An empty SECRET_KEY weakens at-rest encryption of
2FA secrets, push-mirror/migration passwords, and OAuth app secrets.

Generate it with @pulumi/random (it is a plain high-entropy string, not a
format-constrained JWT — so unlike INTERNAL_TOKEN/JWT_SECRET it CAN be
random-generated, matching CONTRACT_002 §2.3) and inject via
FORGEJO__security__SECRET_KEY; env-to-ini overwrites it in the volume's
app.ini while leaving Forgejo's own INTERNAL_TOKEN + JWT secrets untouched.
The GATE-B mirror then captures the real value into Vault.

Done now while the egg is fresh (no encrypted data yet) → no re-encryption.

Validated live: app.ini + Vault forgejoSecretKey = 40 chars; forge healthz
pass + https 200; scp-form clone works; idempotent at 44 unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:30:35 +02:00

113 lines
5.2 KiB
TypeScript

// index.ts — foundation bootstrap entrypoint (the egg, PLAN-002 §0, Layer 0).
//
// Phase orchestration (PLAN-002 §2, §5). Components are created against the shared
// provider + foundation-net (ADR-006); GATES between phases are Pulumi dependsOn
// edges, NOT imperative sequencing, so `pulumi up` derives the order. Wave-2+ tasks
// 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, loadBackupSecrets } from "./config";
import { buildBaseContext, DeployCtx } from "./lib/context";
import { deployNetwork } from "./components/network";
import { deployDns } from "./components/dns";
import {
generateCredentials,
writeCredentialsToVault,
writeBackupCredentialsToVault,
writeForgejoCredentialsToVault,
} from "./components/credentials";
import { deployPostgres } from "./components/postgres";
import { deployRustfs } from "./components/rustfs";
import { deployVault } from "./components/vault";
import { deployProxy } from "./components/proxy";
import { deployForgejo, bootstrapForgejo } from "./components/forgejo";
import { deployRunner } from "./components/runner";
import * as fs from "fs";
const cfg = loadConfig();
// --- shared substrate: provider + network (always first) ---
const base = buildBaseContext(cfg);
const network = deployNetwork(base);
const ctx: DeployCtx = { ...base, network };
// --- public DNS records → the VM (independent of the container plane) ---
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 ✓
// -----------------------------------------------------------------------------
const postgres = deployPostgres(ctx, credentials.postgres);
const rustfs = deployRustfs(ctx, credentials.rustfs);
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
// -----------------------------------------------------------------------------
const proxy = deployProxy(ctx);
const forgejo = deployForgejo(ctx, {
postgres,
rustfs,
pgCreds: credentials.postgres,
rustfsCreds: credentials.rustfs,
forgejoCreds: credentials.forgejo,
});
// --- GATE B: Forgejo healthy → headless admin + org + repo + operator SSH key
// (T09) so `git clone git@git.olsitec.net:olsitec/...` works. The operator
// public key is the .pub beside SSH_PRIVATE_KEY_PATH (CONTRACT_001 §1).
const sshPublicKey = fs.readFileSync(`${base.sshKeyPath}.pub`, "utf8").trim();
const forgejoBootstrap = bootstrapForgejo(ctx, {
forgejo,
adminCreds: credentials.forgejo,
acmeEmail: cfg.tls.acmeEmail,
orgName: cfg.forgejo.orgName,
repoName: "foundation",
sshPublicKey,
});
const runner = cfg.features.runner ? deployRunner(ctx, forgejo) : undefined;
// Mirror Forgejo's admin + app.ini crypto secrets into Vault (CONTRACT_002 §2.3);
// GATE B — needs app.ini, which exists only once Forgejo has started.
const forgejoCreds = writeForgejoCredentialsToVault(
ctx,
vault,
credentials.forgejo,
forgejo.ready,
);
// =============================================================================
// 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
void forgejoCreds; // CONTRACT_002 forgejo/service-credentials mirror; no secret output
export const phase = "T10-runner"; // forge + CI runner live
export const caddyImageId = proxy.imageId;
export const forgejoEndpoint = forgejo.endpoint;
export const cloneUrl = pulumi.interpolate`git@${cfg.hosts.git}:${cfg.forgejo.orgName}/foundation.git`;
void forgejoBootstrap; // GATE B consumer; no secret output to export
void runner; // CI runner (feature-flagged)
export const networkName = network.name;
export const vmTarget = `${cfg.vm.user}@${cfg.vm.host}`;
export const postgresEndpoint = postgres.endpoint;
export const rustfsEndpoint = rustfs.endpoint;
export const vaultEndpoint = vault.endpoint;
// Captured by run.sh into vaultCredentials:* (passphrase-encrypted config) after
// `up` — the one bootstrap secret that cannot live in Vault (CONTRACT_002 §2.4).
export const vaultUnsealKeys = vault.unsealKeys;
export const vaultRootToken = vault.rootToken;
export const enabledFeatures = Object.entries(cfg.features)
.filter(([, on]) => on)
.map(([name]) => name);