feat(bootstrap): forgejo forge — postgres + rustfs + ssh (T08)

foundation-forgejo (forgejo:11, digest-pinned) on foundation-net: git repos on the
foundation-forgejo-data volume (the irreducible state), metadata in external
Postgres, blobs in RustFS (default storage + LFS over the minio API). Config is
seeded via FORGEJO__section__KEY env -> app.ini; INSTALL_LOCK skips the web
installer and the crypto secrets (SECRET_KEY/INTERNAL_TOKEN/JWT) auto-generate and
persist in the volume. HTTP 3000 is internal (Caddy fronts forge.olsitec.net); the
image's openssh sshd owns container :22 (START_SSH_SERVER=false — explicitly, so a
stale app.ini value can't keep Forgejo's built-in Go SSH server colliding on :22),
published on host :22 (scp-form goal) and :2222 (CONTRACT_003). A healthz-gated
ready command is GATE B for T09/T10.

Live on cx33 Helsinki: container healthy, https://forge.olsitec.net = 200 over a
valid Let's Encrypt cert, API 11.0.15, sshd reachable on :22 and :2222.
Acceptance T08 met.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Niemann 2026-06-30 22:23:39 +02:00
parent 6a7c28b54c
commit f1e1d6facd
3 changed files with 173 additions and 3 deletions

View file

@ -60,7 +60,7 @@
# caddy:2 lacks it) — recipe + pinned base digests + module version live in
# containers/caddy-cloudflare/Dockerfile. This is the pinned FINAL base it derives.
IMAGE_CADDY=caddy:2.10@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb
IMAGE_FORGEJO=codeberg.org/forgejo/forgejo:11@sha256:PIN_DIGEST
IMAGE_FORGEJO=codeberg.org/forgejo/forgejo:11@sha256:d98d860ea64fd36cb0aabf0b46bbe1a37566b498eee4af0a6b246d5a45759d6d
IMAGE_POSTGRES=postgres:17@sha256:5c855ad7b85e68e48a62f34662853f38b57c1c1d80f3a927ab58034fd6d31c5e
IMAGE_VAULT=hashicorp/vault:1.18@sha256:750bb37c1638fa194ab37053a81618c61bb0491ddec6fccac87c07a8e6cd8166
IMAGE_RUSTFS=rustfs/rustfs:latest@sha256:fa19210ac4697c79d7ccca1ec9b0eb91aebacc6691991ffb14014bb3c67e6cc3

View file

@ -0,0 +1,162 @@
// 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";
import { PostgresCredentials, RustfsCredentials } from "./credentials";
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" };
}

View file

@ -17,6 +17,7 @@ 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";
const cfg = loadConfig();
@ -48,7 +49,12 @@ const vaultCreds = writeCredentialsToVault(ctx, credentials, vault);
// T07 caddy ✓ · T08 forgejo · T10 runner
// -----------------------------------------------------------------------------
const proxy = deployProxy(ctx);
// const forgejo = deployForgejo(ctx, { postgres, rustfs, credentials, proxy });
const forgejo = deployForgejo(ctx, {
postgres,
rustfs,
pgCreds: credentials.postgres,
rustfsCreds: credentials.rustfs,
});
// --- GATE B: Forgejo healthy → handover (T11) + runner registration (T10).
// const runner = deployRunner(ctx, { forgejo, credentials });
// =============================================================================
@ -57,8 +63,10 @@ const proxy = deployProxy(ctx);
// vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet.
void vaultCreds;
export const phase = "T07-caddy"; // data-plane + creds + public ingress (Caddy)
export const phase = "T08-forgejo"; // forge live (postgres + rustfs + caddy + ssh)
export const caddyImageId = proxy.imageId;
export const forgejoEndpoint = forgejo.endpoint;
void forgejo.ready; // GATE B for T09/T10
export const networkName = network.name;
export const vmTarget = `${cfg.vm.user}@${cfg.vm.host}`;
export const postgresEndpoint = postgres.endpoint;