From 9618da1421f871ffb17fcf2668bd291bfecb28e0 Mon Sep 17 00:00:00 2001 From: Andreas Niemann Date: Tue, 30 Jun 2026 22:38:37 +0200 Subject: [PATCH] feat(bootstrap): forgejo actions runner (T10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit foundation-runner (forgejo/runner:6, digest-pinned). Registration is idempotent (ADR-007): it reuses /data/.runner if present, else mints a token via `forgejo actions generate-runner-token` and consumes it with `forgejo-runner register` (the token never leaves the VM). The daemon runs as uid 1000 with the host docker group (gid 996) added for socket access — root-equivalent and co-located, the documented day-zero compromise (PLAN-002 R5 / PLAN-001 §4a); a fenced or separate runner VM is the steady state. Live on cx33 Helsinki: runner declared (labels docker,dind) and polling; a hello-world `runs-on: docker` workflow pushed to olsitec/foundation ran to success (workflow run #1). Acceptance T10 met. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSIONS | 2 +- bootstrap/components/runner.ts | 103 +++++++++++++++++++++++++++++++++ bootstrap/index.ts | 6 +- 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 bootstrap/components/runner.ts diff --git a/VERSIONS b/VERSIONS index 6befa05..cd2c4f0 100644 --- a/VERSIONS +++ b/VERSIONS @@ -64,7 +64,7 @@ IMAGE_FORGEJO=codeberg.org/forgejo/forgejo:11@sha256:d98d860ea64fd36cb0aabf0b46b IMAGE_POSTGRES=postgres:17@sha256:5c855ad7b85e68e48a62f34662853f38b57c1c1d80f3a927ab58034fd6d31c5e IMAGE_VAULT=hashicorp/vault:1.18@sha256:750bb37c1638fa194ab37053a81618c61bb0491ddec6fccac87c07a8e6cd8166 IMAGE_RUSTFS=rustfs/rustfs:latest@sha256:fa19210ac4697c79d7ccca1ec9b0eb91aebacc6691991ffb14014bb3c67e6cc3 -IMAGE_ACT_RUNNER=code.forgejo.org/forgejo/runner:6@sha256:PIN_DIGEST +IMAGE_ACT_RUNNER=code.forgejo.org/forgejo/runner:6@sha256:e8dd2880f2fc81984d2308b93f1bc064dfb41187942300676536c09a3b30043d IMAGE_REGISTRY=registry:2@sha256:PIN_DIGEST # Tool image: MinIO client `mc` — used transiently (never a long-running service) diff --git a/bootstrap/components/runner.ts b/bootstrap/components/runner.ts new file mode 100644 index 0000000..f4a076d --- /dev/null +++ b/bootstrap/components/runner.ts @@ -0,0 +1,103 @@ +// components/runner.ts (T10) +// +// foundation-runner — a Forgejo Actions runner (CONTRACT_003 §3.2). Registration +// is idempotent (ADR-007): if the persistent /data/.runner config already exists it +// is reused; otherwise a fresh registration token is minted with +// `forgejo actions generate-runner-token` and consumed by `forgejo-runner register` +// (the token never leaves the VM). The daemon then polls Forgejo for jobs. +// +// SECURITY (PLAN-002 R5 / PLAN-001 §4a): this runner shares the VM and is given the +// host Docker socket so `docker`-label jobs can spawn containers — i.e. it is +// root-equivalent on the host and is NOT fenced from the forge's trust boundary. +// That is the documented day-zero compromise; the steady-state is a throwaway, +// separate privileged runner VM. Do not run untrusted workflows here until fenced. +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 { ForgejoOutputs } from "./forgejo"; + +export interface RunnerOutputs { + container: docker.Container; + registered: command.remote.Command; +} + +export function deployRunner( + ctx: DeployCtx, + forgejo: ForgejoOutputs, +): RunnerOutputs { + const { cfg, provider, network } = ctx; + const img = ctx.image("ACT_RUNNER"); + const labels = cfg.runner.labels.join(","); + + const image = new docker.RemoteImage( + "foundation-runner-image", + { name: img, keepLocally: true }, + { provider }, + ); + const volume = new docker.Volume( + "foundation-runner-data", + { name: "foundation-runner-data" }, + { provider, retainOnDelete: true }, // holds .runner registration secret + ); + + const register = new command.remote.Command( + "foundation-runner-register", + { + connection: vmConnection(ctx), + create: pulumi.interpolate`set -eu +VOL=foundation-runner-data +IMG='${img}' +LABELS='${labels}' +docker volume inspect "$VOL" >/dev/null 2>&1 || docker volume create "$VOL" >/dev/null +if docker run --rm --entrypoint sh -v "$VOL":/data "$IMG" -c '[ -s /data/.runner ]'; then + echo "runner already registered" +else + TOKEN=$(docker exec -u git foundation-forgejo forgejo actions generate-runner-token) + docker run --rm --network foundation-net --entrypoint /bin/forgejo-runner -v "$VOL":/data "$IMG" \\ + register --no-interactive --instance http://foundation-forgejo:3000 --token "$TOKEN" --name foundation-runner --labels "$LABELS" >/dev/null + echo "runner registered" +fi`, + addPreviousOutputInEnv: false, + triggers: [forgejo.ready.id, labels], + }, + { dependsOn: [forgejo.ready] }, + ); + + const container = new docker.Container( + "foundation-runner", + { + name: "foundation-runner", + image: image.imageId, + hostname: "foundation-runner", + restart: "unless-stopped", + entrypoints: ["/bin/forgejo-runner"], + command: ["daemon"], + // The image runs as uid 1000; add the host docker group (gid of + // /var/run/docker.sock) so the daemon can reach the socket without running + // as root. NOTE: 996 is THIS host's docker gid — re-check on DR to a new VM + // (`stat -c %g /var/run/docker.sock`). Socket access is root-equivalent + // regardless (see the security note above). + groupAdds: ["996"], + envs: ["DOCKER_HOST=unix:///var/run/docker.sock"], + volumes: [ + { volumeName: volume.name, containerPath: "/data" }, + { + hostPath: "/var/run/docker.sock", + containerPath: "/var/run/docker.sock", + }, + ], + networksAdvanced: [{ name: network.name, aliases: ["foundation-runner"] }], + logDriver: "json-file", + logOpts: { "max-size": "10m", "max-file": "3" }, + }, + { + provider, + dependsOn: [network, register], + deleteBeforeReplace: true, + }, + ); + + return { container, registered: register }; +} diff --git a/bootstrap/index.ts b/bootstrap/index.ts index 5cf7982..1c1ed20 100644 --- a/bootstrap/index.ts +++ b/bootstrap/index.ts @@ -19,6 +19,7 @@ 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(); @@ -70,18 +71,19 @@ const forgejoBootstrap = bootstrapForgejo(ctx, { repoName: "foundation", sshPublicKey, }); -// const runner = deployRunner(ctx, { forgejo, credentials }); +const runner = cfg.features.runner ? deployRunner(ctx, forgejo) : undefined; // ============================================================================= // Stack outputs (extended as phases land). // vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet. void vaultCreds; -export const phase = "T09-forge-bootstrap"; // admin + org + repo + operator key +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;