feat(bootstrap): forgejo admin + org + repo + operator key (T09)
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) <noreply@anthropic.com>
This commit is contained in:
parent
f1e1d6facd
commit
3a297d021e
3 changed files with 134 additions and 6 deletions
|
|
@ -35,10 +35,22 @@ export interface RustfsCredentials {
|
||||||
serviceKeySecret: pulumi.Output<string>;
|
serviceKeySecret: pulumi.Output<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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<string>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Everything generateCredentials() produces; grows as Wave-2 tasks land. */
|
/** Everything generateCredentials() produces; grows as Wave-2 tasks land. */
|
||||||
export interface FoundationCredentials {
|
export interface FoundationCredentials {
|
||||||
postgres: PostgresCredentials;
|
postgres: PostgresCredentials;
|
||||||
rustfs: RustfsCredentials;
|
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
|
serviceKeyId: secret("rustfs-service-key-id", 20), // S3 access-key id
|
||||||
serviceKeySecret: secret("rustfs-service-key-secret", 40), // S3 secret
|
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_ADMIN_PW
|
||||||
IFS= read -r RUSTFS_SVC_ID
|
IFS= read -r RUSTFS_SVC_ID
|
||||||
IFS= read -r RUSTFS_SVC_SECRET
|
IFS= read -r RUSTFS_SVC_SECRET
|
||||||
|
IFS= read -r FORGEJO_ADMIN_PW
|
||||||
C=foundation-vault
|
C=foundation-vault
|
||||||
VE="-e VAULT_ADDR=http://127.0.0.1:8200 -e VAULT_TOKEN=$ROOT_TOKEN"
|
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}' \
|
'{rustfsAdminUser:$u,rustfsAdminPassword:$p,rustfsServiceKeyId:$ki,rustfsServiceKeySecret:$ks}' \
|
||||||
| put rustfs/service-credentials
|
| 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).
|
* 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}'
|
const create = pulumi.interpolate`PG_SUPER_USER='${creds.postgres.superUser}'
|
||||||
PG_FORGEJO_USER='${creds.postgres.forgejoDbUser}'
|
PG_FORGEJO_USER='${creds.postgres.forgejoDbUser}'
|
||||||
RUSTFS_ADMIN_USER='${creds.rustfs.adminUser}'
|
RUSTFS_ADMIN_USER='${creds.rustfs.adminUser}'
|
||||||
|
FORGEJO_ADMIN_USER='${creds.forgejo.adminUser}'
|
||||||
${WRITE_CREDS}`;
|
${WRITE_CREDS}`;
|
||||||
|
|
||||||
return new command.remote.Command(
|
return new command.remote.Command(
|
||||||
|
|
@ -129,6 +151,7 @@ ${creds.postgres.forgejoDbPassword}
|
||||||
${creds.rustfs.adminPassword}
|
${creds.rustfs.adminPassword}
|
||||||
${creds.rustfs.serviceKeyId}
|
${creds.rustfs.serviceKeyId}
|
||||||
${creds.rustfs.serviceKeySecret}
|
${creds.rustfs.serviceKeySecret}
|
||||||
|
${creds.forgejo.adminPassword}
|
||||||
`,
|
`,
|
||||||
addPreviousOutputInEnv: false,
|
addPreviousOutputInEnv: false,
|
||||||
triggers: [
|
triggers: [
|
||||||
|
|
@ -138,6 +161,7 @@ ${creds.rustfs.serviceKeySecret}
|
||||||
creds.rustfs.adminPassword,
|
creds.rustfs.adminPassword,
|
||||||
creds.rustfs.serviceKeyId,
|
creds.rustfs.serviceKeyId,
|
||||||
creds.rustfs.serviceKeySecret,
|
creds.rustfs.serviceKeySecret,
|
||||||
|
creds.forgejo.adminPassword,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ dependsOn: [vault.init] },
|
{ dependsOn: [vault.init] },
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,11 @@ import * as docker from "@pulumi/docker";
|
||||||
import * as command from "@pulumi/command";
|
import * as command from "@pulumi/command";
|
||||||
import { DeployCtx } from "../lib/context";
|
import { DeployCtx } from "../lib/context";
|
||||||
import { vmConnection } from "../lib/remote";
|
import { vmConnection } from "../lib/remote";
|
||||||
import { PostgresCredentials, RustfsCredentials } from "./credentials";
|
import {
|
||||||
|
ForgejoCredentials,
|
||||||
|
PostgresCredentials,
|
||||||
|
RustfsCredentials,
|
||||||
|
} from "./credentials";
|
||||||
import { PostgresOutputs } from "./postgres";
|
import { PostgresOutputs } from "./postgres";
|
||||||
import { RustfsOutputs } from "./rustfs";
|
import { RustfsOutputs } from "./rustfs";
|
||||||
|
|
||||||
|
|
@ -160,3 +164,88 @@ exit 1`,
|
||||||
|
|
||||||
return { container, ready, endpoint: "http://foundation-forgejo:3000" };
|
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] },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
// edges, NOT imperative sequencing, so `pulumi up` derives the order. Wave-2+ tasks
|
// 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
|
// fill the marked slots — this file is the single composition point; components stay
|
||||||
// pure factories in components/*.
|
// pure factories in components/*.
|
||||||
|
import * as pulumi from "@pulumi/pulumi";
|
||||||
import { loadConfig } from "./config";
|
import { loadConfig } from "./config";
|
||||||
import { buildBaseContext, DeployCtx } from "./lib/context";
|
import { buildBaseContext, DeployCtx } from "./lib/context";
|
||||||
import { deployNetwork } from "./components/network";
|
import { deployNetwork } from "./components/network";
|
||||||
|
|
@ -17,7 +18,8 @@ import { deployPostgres } from "./components/postgres";
|
||||||
import { deployRustfs } from "./components/rustfs";
|
import { deployRustfs } from "./components/rustfs";
|
||||||
import { deployVault } from "./components/vault";
|
import { deployVault } from "./components/vault";
|
||||||
import { deployProxy } from "./components/proxy";
|
import { deployProxy } from "./components/proxy";
|
||||||
import { deployForgejo } from "./components/forgejo";
|
import { deployForgejo, bootstrapForgejo } from "./components/forgejo";
|
||||||
|
import * as fs from "fs";
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
|
|
||||||
|
|
@ -55,7 +57,19 @@ const forgejo = deployForgejo(ctx, {
|
||||||
pgCreds: credentials.postgres,
|
pgCreds: credentials.postgres,
|
||||||
rustfsCreds: credentials.rustfs,
|
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 });
|
// 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.
|
// vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet.
|
||||||
void vaultCreds;
|
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 caddyImageId = proxy.imageId;
|
||||||
export const forgejoEndpoint = forgejo.endpoint;
|
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 networkName = network.name;
|
||||||
export const vmTarget = `${cfg.vm.user}@${cfg.vm.host}`;
|
export const vmTarget = `${cfg.vm.user}@${cfg.vm.host}`;
|
||||||
export const postgresEndpoint = postgres.endpoint;
|
export const postgresEndpoint = postgres.endpoint;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue