2026-06-30 22:23:39 +02:00
|
|
|
// components/forgejo.ts (T08)
|
|
|
|
|
//
|
|
|
|
|
// foundation-forgejo — the forge (CONTRACT_003 §3.2): git repos on the /data volume
|
|
|
|
|
// (the irreducible state), metadata in external Postgres, blobs in RustFS S3.
|
|
|
|
|
// Config is seeded via FORGEJO__section__KEY env (the image's environment-to-ini
|
|
|
|
|
// writes app.ini on start). INSTALL_LOCK skips the web installer; crypto secrets
|
|
|
|
|
// (SECRET_KEY/INTERNAL_TOKEN/JWT) are auto-generated by Forgejo on first start and
|
|
|
|
|
// persist in the volume. HTTP 3000 is internal (Caddy fronts forge.olsitec.net);
|
|
|
|
|
// the built-in SSH server (container :22) is published on BOTH host :22 (so the
|
|
|
|
|
// literal goal `git@git.olsitec.net:olsitec/...` works) and :2222 (CONTRACT_003).
|
|
|
|
|
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";
|
2026-06-30 22:31:13 +02:00
|
|
|
import {
|
|
|
|
|
ForgejoCredentials,
|
|
|
|
|
PostgresCredentials,
|
|
|
|
|
RustfsCredentials,
|
|
|
|
|
} from "./credentials";
|
2026-06-30 22:23:39 +02:00
|
|
|
import { PostgresOutputs } from "./postgres";
|
|
|
|
|
import { RustfsOutputs } from "./rustfs";
|
|
|
|
|
|
|
|
|
|
export interface ForgejoDeps {
|
|
|
|
|
postgres: PostgresOutputs;
|
|
|
|
|
rustfs: RustfsOutputs;
|
|
|
|
|
pgCreds: PostgresCredentials;
|
|
|
|
|
rustfsCreds: RustfsCredentials;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ForgejoOutputs {
|
|
|
|
|
container: docker.Container;
|
|
|
|
|
/** Forgejo /api/healthz passes once this resolves — GATE B (T09/T10). */
|
|
|
|
|
ready: command.remote.Command;
|
|
|
|
|
endpoint: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function deployForgejo(
|
|
|
|
|
ctx: DeployCtx,
|
|
|
|
|
deps: ForgejoDeps,
|
|
|
|
|
): ForgejoOutputs {
|
|
|
|
|
const { cfg, provider, network } = ctx;
|
|
|
|
|
|
|
|
|
|
const image = new docker.RemoteImage(
|
|
|
|
|
"foundation-forgejo-image",
|
|
|
|
|
{ name: ctx.image("FORGEJO"), keepLocally: true },
|
|
|
|
|
{ provider },
|
|
|
|
|
);
|
|
|
|
|
const volume = new docker.Volume(
|
|
|
|
|
"foundation-forgejo-data",
|
|
|
|
|
{ name: "foundation-forgejo-data" },
|
|
|
|
|
{ provider, retainOnDelete: true }, // git repos — the irreplaceable core
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// A RustFS (minio-compatible) storage block for a given Forgejo config section.
|
|
|
|
|
const minio = (
|
|
|
|
|
section: string,
|
|
|
|
|
bucket: string,
|
|
|
|
|
): pulumi.Input<string>[] => [
|
|
|
|
|
`FORGEJO__${section}__STORAGE_TYPE=minio`,
|
|
|
|
|
`FORGEJO__${section}__MINIO_ENDPOINT=foundation-rustfs:9000`,
|
|
|
|
|
pulumi.interpolate`FORGEJO__${section}__MINIO_ACCESS_KEY_ID=${deps.rustfsCreds.serviceKeyId}`,
|
|
|
|
|
pulumi.interpolate`FORGEJO__${section}__MINIO_SECRET_ACCESS_KEY=${deps.rustfsCreds.serviceKeySecret}`,
|
|
|
|
|
`FORGEJO__${section}__MINIO_BUCKET=${bucket}`,
|
|
|
|
|
`FORGEJO__${section}__MINIO_USE_SSL=false`,
|
|
|
|
|
`FORGEJO__${section}__MINIO_LOCATION=us-east-1`,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const envs: pulumi.Input<string>[] = [
|
|
|
|
|
"USER_UID=1000",
|
|
|
|
|
"USER_GID=1000",
|
|
|
|
|
// --- database: external Postgres (CONTRACT_003 §3.3) ---
|
|
|
|
|
"FORGEJO__database__DB_TYPE=postgres",
|
|
|
|
|
"FORGEJO__database__HOST=foundation-postgres:5432",
|
|
|
|
|
`FORGEJO__database__NAME=${cfg.postgres.forgejoDb}`,
|
|
|
|
|
pulumi.interpolate`FORGEJO__database__USER=${deps.pgCreds.forgejoDbUser}`,
|
|
|
|
|
pulumi.interpolate`FORGEJO__database__PASSWD=${deps.pgCreds.forgejoDbPassword}`,
|
|
|
|
|
"FORGEJO__database__SSL_MODE=disable",
|
|
|
|
|
// --- server / web / ssh ---
|
|
|
|
|
"FORGEJO__server__PROTOCOL=http",
|
|
|
|
|
`FORGEJO__server__DOMAIN=${cfg.hosts.forge}`,
|
|
|
|
|
`FORGEJO__server__ROOT_URL=https://${cfg.hosts.forge}/`,
|
|
|
|
|
"FORGEJO__server__HTTP_ADDR=0.0.0.0",
|
|
|
|
|
"FORGEJO__server__HTTP_PORT=3000",
|
|
|
|
|
// Use the IMAGE's openssh sshd on container :22. START_SSH_SERVER MUST be set
|
|
|
|
|
// false explicitly (not just omitted): env-to-ini only overwrites keys it is
|
|
|
|
|
// given, so a stale true in the persisted app.ini would keep Forgejo's built-in
|
|
|
|
|
// Go SSH server colliding on :22. SSH_PORT is the clone-URL port; the sshd is
|
|
|
|
|
// published on host :22 (scp-form goal) + :2222 (CONTRACT_003).
|
|
|
|
|
"FORGEJO__server__START_SSH_SERVER=false",
|
|
|
|
|
"FORGEJO__server__SSH_LISTEN_PORT=22",
|
|
|
|
|
`FORGEJO__server__SSH_PORT=${cfg.forgeSshPort}`,
|
|
|
|
|
`FORGEJO__server__SSH_DOMAIN=${cfg.hosts.git}`,
|
|
|
|
|
"FORGEJO__server__LFS_START_SERVER=true",
|
|
|
|
|
// --- blobs on RustFS: default storage + LFS (CONTRACT_003 §3.4) ---
|
|
|
|
|
...minio("storage", "forgejo-packages"),
|
|
|
|
|
...minio("lfs", "forgejo-lfs"),
|
|
|
|
|
// --- lock the installer; no open registration (admin creates users, T09) ---
|
|
|
|
|
"FORGEJO__security__INSTALL_LOCK=true",
|
|
|
|
|
"FORGEJO__service__DISABLE_REGISTRATION=true",
|
|
|
|
|
"FORGEJO__service__REQUIRE_SIGNIN_VIEW=false",
|
|
|
|
|
"FORGEJO__log__LEVEL=info",
|
|
|
|
|
"FORGEJO__mailer__ENABLED=false",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const container = new docker.Container(
|
|
|
|
|
"foundation-forgejo",
|
|
|
|
|
{
|
|
|
|
|
name: "foundation-forgejo",
|
|
|
|
|
image: image.imageId,
|
|
|
|
|
hostname: "foundation-forgejo",
|
|
|
|
|
restart: "unless-stopped",
|
|
|
|
|
envs,
|
|
|
|
|
volumes: [{ volumeName: volume.name, containerPath: "/data" }],
|
|
|
|
|
networksAdvanced: [
|
|
|
|
|
{ name: network.name, aliases: ["foundation-forgejo"] },
|
|
|
|
|
],
|
|
|
|
|
ports: [
|
|
|
|
|
{ internal: 22, external: 22 }, // scp-form git@git.olsitec.net (the goal)
|
|
|
|
|
{ internal: 22, external: cfg.forgeSshPort }, // 2222 (CONTRACT_003)
|
|
|
|
|
],
|
|
|
|
|
healthcheck: {
|
|
|
|
|
tests: [
|
|
|
|
|
"CMD-SHELL",
|
|
|
|
|
// healthz pretty-prints `"status": "pass"` (note the space); head -3 limits
|
|
|
|
|
// the match to the top-level status, not a sub-check.
|
|
|
|
|
"wget -qO- http://127.0.0.1:3000/api/healthz | head -3 | grep -q '\"status\": \"pass\"' || exit 1",
|
|
|
|
|
],
|
|
|
|
|
interval: "15s",
|
|
|
|
|
timeout: "5s",
|
|
|
|
|
retries: 6,
|
|
|
|
|
startPeriod: "40s",
|
|
|
|
|
},
|
|
|
|
|
logDriver: "json-file",
|
|
|
|
|
logOpts: { "max-size": "10m", "max-file": "3" },
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
provider,
|
|
|
|
|
dependsOn: [network, deps.postgres.ready, deps.rustfs.ready],
|
|
|
|
|
deleteBeforeReplace: true,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// GATE B: block downstream (T09 admin/org/repo, T10 runner) until healthz passes.
|
|
|
|
|
const ready = new command.remote.Command(
|
|
|
|
|
"foundation-forgejo-ready",
|
|
|
|
|
{
|
|
|
|
|
connection: vmConnection(ctx),
|
|
|
|
|
create: `set -eu
|
|
|
|
|
for _ in $(seq 1 60); do
|
|
|
|
|
if docker exec foundation-forgejo wget -qO- http://127.0.0.1:3000/api/healthz 2>/dev/null | head -3 | grep -q '"status": "pass"'; then
|
|
|
|
|
echo "foundation-forgejo healthy"; exit 0
|
|
|
|
|
fi
|
|
|
|
|
sleep 4
|
|
|
|
|
done
|
|
|
|
|
echo "foundation-forgejo did not become healthy in 240s" >&2
|
|
|
|
|
docker logs --tail 30 foundation-forgejo >&2 || true
|
|
|
|
|
exit 1`,
|
|
|
|
|
addPreviousOutputInEnv: false,
|
|
|
|
|
triggers: [container.id],
|
|
|
|
|
},
|
|
|
|
|
{ dependsOn: [container] },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { container, ready, endpoint: "http://foundation-forgejo:3000" };
|
|
|
|
|
}
|
2026-06-30 22:31:13 +02:00
|
|
|
|
|
|
|
|
// ─── 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] },
|
|
|
|
|
);
|
|
|
|
|
}
|