From 3a297d021ee954c29d98bf6e5b0ff5cb55758bc7 Mon Sep 17 00:00:00 2001 From: Andreas Niemann Date: Tue, 30 Jun 2026 22:31:13 +0200 Subject: [PATCH] feat(bootstrap): forgejo admin + org + repo + operator key (T09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bootstrapForgejo (idempotent, docker-exec — ADR-007) creates the headless admin via `forgejo admin user create` (run as the git user; no web installer, no default credentials — PLAN-002 §9.3), then via the image's own curl against the API: the olsitec org, an auto-init'd olsitec/foundation repo, and the operator's SSH public key. credentials.ts gains the forgejo admin slice (@pulumi/random) and writeCredentialsToVault now also writes foundation/forgejo/service-credentials. Live on cx33 Helsinki: admin + org + repo + key created. GOAL MET — `git clone git@git.olsitec.net:olsitec/foundation.git` (scp-form, :22) and `ssh://git@git.olsitec.net:2222/olsitec/foundation.git` both clone the repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- bootstrap/components/credentials.ts | 26 ++++++++- bootstrap/components/forgejo.ts | 91 ++++++++++++++++++++++++++++- bootstrap/index.ts | 23 ++++++-- 3 files changed, 134 insertions(+), 6 deletions(-) diff --git a/bootstrap/components/credentials.ts b/bootstrap/components/credentials.ts index bbcc857..e74b3f6 100644 --- a/bootstrap/components/credentials.ts +++ b/bootstrap/components/credentials.ts @@ -35,10 +35,22 @@ export interface RustfsCredentials { serviceKeySecret: pulumi.Output; } +/** + * `foundation/forgejo/service-credentials` — the admin slice (CONTRACT_002 §2.3). + * The crypto secrets (forgejoSecretKey/InternalToken/Jwt*) are auto-generated by + * Forgejo into its app.ini (format-constrained — JWTs, not free random), so they + * are not generated here; capturing them into Vault is a later refinement. + */ +export interface ForgejoCredentials { + adminUser: string; // cfg.forgejo.adminUser (deterministic) + adminPassword: pulumi.Output; +} + /** Everything generateCredentials() produces; grows as Wave-2 tasks land. */ export interface FoundationCredentials { postgres: PostgresCredentials; rustfs: RustfsCredentials; + forgejo: ForgejoCredentials; } /** @@ -64,6 +76,10 @@ export function generateCredentials(ctx: DeployCtx): FoundationCredentials { serviceKeyId: secret("rustfs-service-key-id", 20), // S3 access-key id serviceKeySecret: secret("rustfs-service-key-secret", 40), // S3 secret }, + forgejo: { + adminUser: ctx.cfg.forgejo.adminUser, // "platform-admin" + adminPassword: secret("forgejo-admin-password"), + }, }; } @@ -82,6 +98,7 @@ IFS= read -r PG_FORGEJO_PW IFS= read -r RUSTFS_ADMIN_PW IFS= read -r RUSTFS_SVC_ID IFS= read -r RUSTFS_SVC_SECRET +IFS= read -r FORGEJO_ADMIN_PW C=foundation-vault VE="-e VAULT_ADDR=http://127.0.0.1:8200 -e VAULT_TOKEN=$ROOT_TOKEN" @@ -99,7 +116,11 @@ jq -n --arg u "$RUSTFS_ADMIN_USER" --arg p "$RUSTFS_ADMIN_PW" --arg ki "$RUSTFS_ '{rustfsAdminUser:$u,rustfsAdminPassword:$p,rustfsServiceKeyId:$ki,rustfsServiceKeySecret:$ks}' \ | put rustfs/service-credentials -echo "vault: wrote postgres + rustfs service-credentials"`; +jq -n --arg u "$FORGEJO_ADMIN_USER" --arg p "$FORGEJO_ADMIN_PW" \ + '{forgejoAdminUser:$u,forgejoAdminPassword:$p}' \ + | put forgejo/service-credentials + +echo "vault: wrote postgres + rustfs + forgejo service-credentials"`; /** * T06 — distribute the generated data-plane credentials into Vault (CONTRACT_002). @@ -115,6 +136,7 @@ export function writeCredentialsToVault( const create = pulumi.interpolate`PG_SUPER_USER='${creds.postgres.superUser}' PG_FORGEJO_USER='${creds.postgres.forgejoDbUser}' RUSTFS_ADMIN_USER='${creds.rustfs.adminUser}' +FORGEJO_ADMIN_USER='${creds.forgejo.adminUser}' ${WRITE_CREDS}`; return new command.remote.Command( @@ -129,6 +151,7 @@ ${creds.postgres.forgejoDbPassword} ${creds.rustfs.adminPassword} ${creds.rustfs.serviceKeyId} ${creds.rustfs.serviceKeySecret} +${creds.forgejo.adminPassword} `, addPreviousOutputInEnv: false, triggers: [ @@ -138,6 +161,7 @@ ${creds.rustfs.serviceKeySecret} creds.rustfs.adminPassword, creds.rustfs.serviceKeyId, creds.rustfs.serviceKeySecret, + creds.forgejo.adminPassword, ], }, { dependsOn: [vault.init] }, diff --git a/bootstrap/components/forgejo.ts b/bootstrap/components/forgejo.ts index b0ca439..9711fcb 100644 --- a/bootstrap/components/forgejo.ts +++ b/bootstrap/components/forgejo.ts @@ -13,7 +13,11 @@ import * as docker from "@pulumi/docker"; import * as command from "@pulumi/command"; import { DeployCtx } from "../lib/context"; import { vmConnection } from "../lib/remote"; -import { PostgresCredentials, RustfsCredentials } from "./credentials"; +import { + ForgejoCredentials, + PostgresCredentials, + RustfsCredentials, +} from "./credentials"; import { PostgresOutputs } from "./postgres"; import { RustfsOutputs } from "./rustfs"; @@ -160,3 +164,88 @@ exit 1`, return { container, ready, endpoint: "http://foundation-forgejo:3000" }; } + +// ─── T09: headless first-admin + org + repo + operator SSH key ─────────────── +// All idempotent, over docker-exec (ADR-007): `forgejo admin` CLI for the admin +// (run as the git user so /data stays git-owned), then the Forgejo API via the +// image's own curl against localhost:3000. The admin password arrives on stdin +// (secret); the operator SSH public key on stdin (non-secret, but has spaces). +// No web installer, no default credentials (PLAN-002 §9.3). +const BOOTSTRAP = `set -eu +IFS= read -r ADMIN_PW +IFS= read -r SSH_PUBKEY +C=foundation-forgejo +BASE=http://127.0.0.1:3000/api/v1 + +if docker exec -u git "$C" forgejo admin user list 2>/dev/null | grep -qw "$ADMIN"; then + echo "admin exists" +else + docker exec -u git "$C" forgejo admin user create --admin --username "$ADMIN" --password "$ADMIN_PW" --email "$EMAIL" --must-change-password=false >/dev/null + echo "admin created" +fi + +au() { docker exec -i "$C" curl -fsS -u "$ADMIN:$ADMIN_PW" -H 'Content-Type: application/json' "$@"; } +code() { docker exec "$C" curl -s -o /dev/null -w '%{http_code}' -u "$ADMIN:$ADMIN_PW" "$@"; } + +if [ "$(code "$BASE/orgs/$ORG")" = 200 ]; then + echo "org exists" +else + au -X POST "$BASE/orgs" -d "{\\"username\\":\\"$ORG\\"}" >/dev/null + echo "org created" +fi + +if [ "$(code "$BASE/repos/$ORG/$REPO")" = 200 ]; then + echo "repo exists" +else + au -X POST "$BASE/orgs/$ORG/repos" -d "{\\"name\\":\\"$REPO\\",\\"auto_init\\":true,\\"private\\":false,\\"default_branch\\":\\"main\\",\\"description\\":\\"olsitec-foundation platform repo\\"}" >/dev/null + echo "repo created" +fi + +if au "$BASE/user/keys" | grep -q "\\"title\\":\\"$KEYTITLE\\""; then + echo "ssh key exists" +else + au -X POST "$BASE/user/keys" -d "{\\"title\\":\\"$KEYTITLE\\",\\"key\\":\\"$SSH_PUBKEY\\"}" >/dev/null + echo "ssh key added" +fi +echo "forgejo bootstrap complete: $ORG/$REPO"`; + +export interface ForgejoBootstrapArgs { + forgejo: ForgejoOutputs; + adminCreds: ForgejoCredentials; + acmeEmail: string; + orgName: string; + repoName: string; + sshPublicKey: string; +} + +/** Create the admin, the org, a seeded repo, and register the operator SSH key. */ +export function bootstrapForgejo( + ctx: DeployCtx, + args: ForgejoBootstrapArgs, +): command.remote.Command { + const create = pulumi.interpolate`ADMIN='${args.adminCreds.adminUser}' +EMAIL='${args.acmeEmail}' +ORG='${args.orgName}' +REPO='${args.repoName}' +KEYTITLE='operator-foundation' +${BOOTSTRAP}`; + + return new command.remote.Command( + "foundation-forgejo-bootstrap", + { + connection: vmConnection(ctx), + create, + update: create, + stdin: pulumi.interpolate`${args.adminCreds.adminPassword} +${args.sshPublicKey} +`, + addPreviousOutputInEnv: false, + triggers: [ + args.forgejo.ready.id, + args.adminCreds.adminPassword, + args.sshPublicKey, + ], + }, + { dependsOn: [args.forgejo.ready] }, + ); +} diff --git a/bootstrap/index.ts b/bootstrap/index.ts index 98e8733..5cf7982 100644 --- a/bootstrap/index.ts +++ b/bootstrap/index.ts @@ -5,6 +5,7 @@ // 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 } from "./config"; import { buildBaseContext, DeployCtx } from "./lib/context"; import { deployNetwork } from "./components/network"; @@ -17,7 +18,8 @@ import { deployPostgres } from "./components/postgres"; import { deployRustfs } from "./components/rustfs"; import { deployVault } from "./components/vault"; import { deployProxy } from "./components/proxy"; -import { deployForgejo } from "./components/forgejo"; +import { deployForgejo, bootstrapForgejo } from "./components/forgejo"; +import * as fs from "fs"; const cfg = loadConfig(); @@ -55,7 +57,19 @@ const forgejo = deployForgejo(ctx, { pgCreds: credentials.postgres, rustfsCreds: credentials.rustfs, }); -// --- GATE B: Forgejo healthy → handover (T11) + runner registration (T10). + +// --- 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 = deployRunner(ctx, { forgejo, credentials }); // ============================================================================= @@ -63,10 +77,11 @@ const forgejo = deployForgejo(ctx, { // vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet. void vaultCreds; -export const phase = "T08-forgejo"; // forge live (postgres + rustfs + caddy + ssh) +export const phase = "T09-forge-bootstrap"; // admin + org + repo + operator key export const caddyImageId = proxy.imageId; export const forgejoEndpoint = forgejo.endpoint; -void forgejo.ready; // GATE B for T09/T10 +export const cloneUrl = pulumi.interpolate`git@${cfg.hosts.git}:${cfg.forgejo.orgName}/foundation.git`; +void forgejoBootstrap; // GATE B consumer; no secret output to export export const networkName = network.name; export const vmTarget = `${cfg.vm.user}@${cfg.vm.host}`; export const postgresEndpoint = postgres.endpoint;