// 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 }; }