foundation/bootstrap/components/runner.ts

123 lines
4.7 KiB
TypeScript
Raw Permalink Normal View History

// 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
);
// act_runner config (T14): job containers must join foundation-net to reach
// foundation-forgejo:3000 (checkout) + the data plane, and must NOT force-pull —
// the CI toolchain image (foundation-ci, VERSIONS IMAGE_CI) is built locally on the
// VM, not in a registry. valid_volumes allows jobs to mount the host docker socket
// (docker-label builds). Re-written on every up so config drift self-heals.
const RUNNER_CONFIG = `log:
level: info
runner:
capacity: 2
timeout: 30m
fetch_interval: 2s
container:
network: foundation-net
force_pull: false
valid_volumes:
- /var/run/docker.sock
`;
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
printf '%s' '${RUNNER_CONFIG}' | docker run --rm -i --entrypoint sh -v "$VOL":/data "$IMG" -c 'cat > /data/config.yaml'
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, RUNNER_CONFIG],
},
{ 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", "-c", "/data/config.yaml"], // T14 runner config (network/force_pull)
// 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 };
}