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