From 644d3603d20a7be85e643e3b881ca384a816c7ef Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Fri, 1 May 2026 00:00:28 +0200 Subject: [PATCH] chore: add docker compose for self-hosted Gitea act_runners Set up the infrastructure-as-code recipe to bring up the three self-hosted Gitea Actions runners required by ADR-0015. The compose file launches three act_runner instances pinned to 0.2.13, registered with the project's Gitea organisation, labelled self-hosted + on-prem to match the runs-on selector in every job under .gitea/workflows/*. Layout: - infra/ new top-level folder for IaC - infra/README.md explains the folder, registration flow, security implications, future placeholders (local/, prod/, runbooks/) - infra/ci-runners.compose.yml three act_runner services, networked together, persisting credentials to ./data/runner-N - infra/.env.example GITEA_INSTANCE_URL + GITEA_RUNNER_REGISTRATION_TOKEN; .env itself stays git-ignored (root rule) - infra/data/.gitignore tracks the dir, ignores runtime state Security posture (documented in infra/README.md): mounting /var/run/docker.sock gives the runner root-equivalent access to the host Docker daemon. Mitigations rely on (a) repo-scope of the runner in Gitea, (b) running the runner host outside the production trust boundary, (c) no extra host filesystem mounts. Future hardening (rootless Docker, DinD sidecar) is flagged as deferred. The compose pins the runner image (0.2.13). Bumps go through a dedicated chore(deps) PR per the convention; image upgrades roll one runner at a time so CI is never starved (procedure documented in the README). Doc indexing (docs/README.md) deliberately not touched here to avoid a conflict with the pending docs/architecture-diagrams branch which also modifies that file. A small follow-up PR will add an index entry once that branch is merged. --- infra/.env.example | 17 +++++++ infra/README.md | 87 ++++++++++++++++++++++++++++++++++++ infra/ci-runners.compose.yml | 62 +++++++++++++++++++++++++ infra/data/.gitignore | 5 +++ 4 files changed, 171 insertions(+) create mode 100644 infra/.env.example create mode 100644 infra/README.md create mode 100644 infra/ci-runners.compose.yml create mode 100644 infra/data/.gitignore diff --git a/infra/.env.example b/infra/.env.example new file mode 100644 index 0000000..5567dd0 --- /dev/null +++ b/infra/.env.example @@ -0,0 +1,17 @@ +# infra/.env.example +# +# Copy to infra/.env (which is git-ignored at the workspace level) and fill in. +# Used by infra/ci-runners.compose.yml. See infra/README.md for the full +# registration workflow. + +# Gitea base URL — https, no trailing slash. +GITEA_INSTANCE_URL=https://git.unespace.com + +# One-time registration token, generated in Gitea: +# Site Administration → Actions → Runners → "Create new Runner" +# (or Organisation Settings → Actions → Runners for an org-scoped runner). +# +# Required only on first boot. After the runners successfully register +# their credentials persist in infra/data/runner-N/.runner; remove this +# value from .env afterwards. +GITEA_RUNNER_REGISTRATION_TOKEN= diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..c0c3357 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,87 @@ +# `infra/` + +Infrastructure-as-code artefacts for the project. Separate from application code and from documentation: this folder contains the recipes and configs that the team and ops use to stand up running infrastructure (CI runners, future local-dev databases, future on-prem deploy assets). + +| Subject | File / Folder | ADR / Reference | +| -------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------- | +| Self-hosted CI runners (Gitea Actions) | [`ci-runners.compose.yml`](ci-runners.compose.yml) | [ADR-0015 §"Runners"](../docs/decisions/0015-cicd-gitea-actions.md) | +| Runtime state of the runners | `data/` (git-ignored after `.gitignore`) | — | +| Env-vars template for the runners | `.env.example` (`.env` is git-ignored) | — | + +Future folders / files that will land here as the corresponding ADRs ship: + +- **`local/`** — Docker Compose for the developer's machine (Postgres + Redis + OTel collector). Triggered by the first feature that needs Redis (sessions, ADR-0010). +- **`prod/`** — On-prem deploy manifests (HA Postgres, Redis Sentinel, OTel collector + backend, secret manager). Triggered by the on-prem infrastructure ADR (phase 3b). + +--- + +## CI runners — `ci-runners.compose.yml` + +Three self-hosted [`act_runner`](https://gitea.com/gitea/act_runner) instances, registered with the project's Gitea organisation, labelled `self-hosted` + `on-prem` (the labels referenced by every job in `.gitea/workflows/*`). Three matches the floor recommended by [ADR-0015 §"Runners"](../docs/decisions/0015-cicd-gitea-actions.md) — one runner is enough to validate the pipeline; two leave no slack; three keep CI flowing if one runner is down for upgrade or maintenance. + +### First-time registration + +```bash +cd infra/ + +# 1. Generate a registration token in Gitea. +# Site Administration → Actions → Runners → "Create new Runner" +# (or, for org-scoped runners: Organisation Settings → Actions → Runners). +# The token is one-time and short-lived; don't lose it. + +# 2. Configure .env (which is git-ignored). +cp .env.example .env +$EDITOR .env +# Set GITEA_INSTANCE_URL (https, no trailing slash) and +# GITEA_RUNNER_REGISTRATION_TOKEN. + +# 3. Bring the runners up. +docker compose -f ci-runners.compose.yml up -d + +# 4. Verify in Gitea: the three runners appear as online with the +# self-hosted, on-prem labels. If a runner doesn't come online, +# inspect its logs: +docker compose -f ci-runners.compose.yml logs runner-1 +``` + +After the first successful boot, each runner stores its credentials under `data/runner-N/.runner`. The registration token is no longer needed and **should be removed** from `.env`. Subsequent restarts (`docker compose restart`) authenticate from the persisted credential. + +### Operational tips + +- **Rotation of one runner at a time** — to upgrade the image or change config, restart runners one by one (`docker compose restart runner-1`, wait, runner-2, …) so the CI pipeline is never starved. +- **Logs** — `docker compose logs -f --tail=100 runner-N` for a single runner; jobs being executed appear here. +- **Disk pressure** — the runner caches each job's container image in `/var/lib/docker` on the host. On a small host, prune periodically (`docker system prune -af` while no job is running). +- **Adding a fourth runner** — copy any `runner-N` block in the compose file, increment the suffix in `container_name`, `GITEA_RUNNER_NAME`, and the `data/` mount path. Then `docker compose up -d`. The runner registers using the same `GITEA_RUNNER_REGISTRATION_TOKEN` (which must be regenerated if it has expired). + +### Security — Docker socket exposure + +The compose mounts `/var/run/docker.sock` into each runner so jobs can spawn containers. **This grants the runner root-equivalent access to the host's Docker daemon.** A malicious workflow could spawn arbitrary containers, mount host paths, escalate privileges. Mitigations: + +- **Trust boundary:** only register the runners against repositories controlled by the org. Gitea's runner-registration UI lets you scope a runner to an organisation, a single repository, or instance-wide. Prefer the narrowest scope. +- **Dedicated host:** run these containers on a host that does not also run production services or hold sensitive data. The runner host is in the trust boundary of any developer who can push to a repo it serves. +- **No host filesystem mounts beyond the docker socket:** the compose intentionally does not mount `/`, `/etc`, or any project source. Workflows that need data on the host must do so via Docker volumes. +- **Future hardening (out of scope of v1):** migrate to **rootless Docker** on the runner host, or to a **DinD (Docker-in-Docker) sidecar** so the runner cannot escape into the host daemon. Decided when the org's RSSI confirms the security posture, or when the runner host is shared with anything else of value. + +### Image pinning + +The compose pins `gitea/act_runner:0.2.13`. Update the pin deliberately, not via `:latest`: + +1. Read the act_runner [release notes](https://gitea.com/gitea/act_runner/releases) for breaking changes. +2. Edit the three image references (`runner-1`, `runner-2`, `runner-3`). +3. Commit on a feature branch with a `chore(deps):` Conventional Commits subject. +4. Roll one runner at a time (rotation tip above). + +The matching CI workflows refer to runner _labels_ (not images), so a runner-image upgrade does not affect `.gitea/workflows/*`. + +--- + +## Future infra concerns — placeholders + +These are listed here so a contributor knows where to expect related files; they don't exist yet. + +| File | Purpose | Triggered by | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `local/dev.compose.yml` | Postgres 17 + Redis (Sentinel-flavoured single-node) + OTel collector for local dev | First feature that needs Redis or end-to-end OTel traces | +| `local/init/postgres/*.sql` | Bootstrap SQL to create the Postgres roles `audit_owner` / `audit_writer` / `audit_reader` / `audit_archiver` (per ADR-0013) and provision the dev DB | Same as above | +| `prod/*` | On-prem deployment manifests (k8s, Compose, or whatever the on-prem infra ADR settles on) | The on-prem infrastructure ADR (phase 3b) | +| `runbooks/*.md` | Operational runbooks (incident response, secret rotation, runner upgrade procedure, …) | First incident, or when ops cadence justifies them | diff --git a/infra/ci-runners.compose.yml b/infra/ci-runners.compose.yml new file mode 100644 index 0000000..0c3fe94 --- /dev/null +++ b/infra/ci-runners.compose.yml @@ -0,0 +1,62 @@ +# Self-hosted Gitea Actions runners — per ADR-0015 §"Runners". +# +# Three act_runner instances registered with the project's Gitea +# organisation, labelled self-hosted + on-prem (the labels referenced +# by every job in .gitea/workflows/*). +# +# First-time registration, scaling, image pinning, and security notes +# are documented in infra/README.md. + +name: apf-portal-ci-runners + +services: + runner-1: + image: gitea/act_runner:0.2.13 + container_name: apf-portal-runner-1 + restart: unless-stopped + environment: + GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:?GITEA_INSTANCE_URL must be set in infra/.env} + GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:-} + GITEA_RUNNER_NAME: apf-portal-runner-1 + GITEA_RUNNER_LABELS: self-hosted,on-prem + volumes: + # Docker socket — see security note in infra/README.md. + - /var/run/docker.sock:/var/run/docker.sock + # Persistent state: runner credentials live here after first registration. + - ./data/runner-1:/data + networks: + - act-runners + + runner-2: + image: gitea/act_runner:0.2.13 + container_name: apf-portal-runner-2 + restart: unless-stopped + environment: + GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:?GITEA_INSTANCE_URL must be set in infra/.env} + GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:-} + GITEA_RUNNER_NAME: apf-portal-runner-2 + GITEA_RUNNER_LABELS: self-hosted,on-prem + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./data/runner-2:/data + networks: + - act-runners + + runner-3: + image: gitea/act_runner:0.2.13 + container_name: apf-portal-runner-3 + restart: unless-stopped + environment: + GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:?GITEA_INSTANCE_URL must be set in infra/.env} + GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:-} + GITEA_RUNNER_NAME: apf-portal-runner-3 + GITEA_RUNNER_LABELS: self-hosted,on-prem + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./data/runner-3:/data + networks: + - act-runners + +networks: + act-runners: + name: apf-portal-act-runners diff --git a/infra/data/.gitignore b/infra/data/.gitignore new file mode 100644 index 0000000..874b71a --- /dev/null +++ b/infra/data/.gitignore @@ -0,0 +1,5 @@ +# Runtime state of the act_runner instances (credentials, cache, +# work-tree). Track only this .gitignore so the directory exists in +# git and the docker compose mounts have somewhere to bind to. +* +!.gitignore -- 2.30.2