Commit 38bdad0c9f
Verified · cmc ci/build: success ci/test: success
Layout: unified · split
docs/specs/2026-09-05-runner-isolation-design.md added +150
| @@ -0,0 +1,150 @@ | ||
| 1 | # Runner isolation with rootless podman | |
| 2 | ||
| 3 | Ref #144. Milestone v1.14.0. Splits from #115, whose concurrency half (`-jobs N`) | |
| 4 | is done. | |
| 5 | ||
| 6 | ## Problem | |
| 7 | ||
| 8 | A build runs whatever a repository's `.gitbay/ci.yml` says, through `sh -c`, as | |
| 9 | the runner process's own user (`cmd/gitbay-runner/main.go:309`). | |
| 10 | ||
| 11 | `deploy/gitbay-runner.override.conf` constrains the *service* — | |
| 12 | `NoNewPrivileges`, `ProtectSystem=full`, `RestrictSUIDSGID`, CPU and IO weight — | |
| 13 | and the runner's key carries scope `runner`, so a build cannot administer the | |
| 14 | instance. Three things it can still do: | |
| 15 | ||
| 16 | - **Read the runner's SSH private key.** It is on disk, owned by `ci-runner`, | |
| 17 | and a step runs as `ci-runner`. Scope `runner` is not nothing: it claims | |
| 18 | builds, posts logs, and marks builds done, for every repository the runner | |
| 19 | serves. | |
| 20 | - **Read the runner's environment.** `cmd.Env = append(os.Environ(), …)` | |
| 21 | (`main.go:311`) hands each step the runner's entire environment, not a | |
| 22 | constructed one. | |
| 23 | - **See other builds in flight.** `-jobs N` puts N workspaces under one | |
| 24 | `-workdir`, all readable by the same user. | |
| 25 | ||
| 26 | Acceptable while every repository on the instance is the operator's. It stops | |
| 27 | being acceptable the moment a fork's CI runs, which `registration = "open"` | |
| 28 | makes reachable. | |
| 29 | ||
| 30 | ## Decision | |
| 31 | ||
| 32 | Rootless podman, chosen over bubblewrap and a hand-rolled `unshare` sandbox. | |
| 33 | ||
| 34 | The deciding factor was not isolation strength — bubblewrap would have been | |
| 35 | enough for that, at one small package with no daemon. It was `image:` per job. | |
| 36 | An OCI runtime is the only option that gets there, and a CI system where every | |
| 37 | build runs against whatever the host happens to have installed is a CI system | |
| 38 | people work around rather than with. | |
| 39 | ||
| 40 | The cost is real and should be stated rather than discovered: podman is a | |
| 41 | substantially larger dependency on bay1 than anything the runner has needed so | |
| 42 | far, it needs subuid/subgid delegation for `ci-runner`, and it introduces image | |
| 43 | storage that grows without pruning. The runner stops being a single Go binary | |
| 44 | plus `git`. | |
| 45 | ||
| 46 | ## Design | |
| 47 | ||
| 48 | ### What runs where | |
| 49 | ||
| 50 | The split matters more than the flags: | |
| 51 | ||
| 52 | - **The runner clones**, outside any container, using its own key. The container | |
| 53 | never sees `GIT_SSH_COMMAND`, the key, or the runner's environment. | |
| 54 | - **The container runs the steps**, with the already-cloned workspace bind | |
| 55 | mounted read-write at a fixed path. | |
| 56 | ||
| 57 | That alone closes the key-theft path, independently of how good the sandbox is. | |
| 58 | ||
| 59 | ### Per-step or per-job | |
| 60 | ||
| 61 | Per job, one container for all of a job's steps. Steps in a job share state | |
| 62 | today — a build step writes what a test step reads — and per-step containers | |
| 63 | would break that or force a layer-caching scheme nobody asked for. `sh -c` per | |
| 64 | step stays, inside the one container. | |
| 65 | ||
| 66 | ### The environment | |
| 67 | ||
| 68 | Stop inheriting. Build the step environment explicitly: `PATH`, `HOME`, `CI`, | |
| 69 | the build's own variables, and secrets when `b.Trusted`. `os.Environ()` must not | |
| 70 | appear in the container's environment. | |
| 71 | ||
| 72 | Secrets stay env vars inside the container. They are visible to `podman inspect` | |
| 73 | on the host, which is the runner's own user — the same user that already holds | |
| 74 | them in memory, so this is not a new exposure. It is worth a comment saying so, | |
| 75 | because it looks like one. | |
| 76 | ||
| 77 | ### `image:` in ci.yml | |
| 78 | ||
| 79 | `Job` gains `Image string`. Empty means the instance default, which is | |
| 80 | configuration on the runner (`-image`), not a value baked into the binary. | |
| 81 | ||
| 82 | Validation belongs in `ci.Parse` beside the existing job checks: a reference, | |
| 83 | not a command line. Reject anything with a shell metacharacter or whitespace — | |
| 84 | this string reaches `podman run`, and the whole point is that a repository's | |
| 85 | config file cannot become an argument injection. | |
| 86 | ||
| 87 | ### Network | |
| 88 | ||
| 89 | Default on. A build that cannot fetch dependencies is useless to most projects, | |
| 90 | and this design's threat model is about what a build can *reach on the host*, | |
| 91 | not about exfiltration. `network: none` per job is a plausible later addition | |
| 92 | and explicitly out of scope here. | |
| 93 | ||
| 94 | ### Failure modes | |
| 95 | ||
| 96 | **Podman missing or broken.** The runner must refuse to run builds rather than | |
| 97 | falling back to running them unsandboxed. A fallback that silently drops | |
| 98 | isolation is worse than a stopped runner, because nothing surfaces it. Check at | |
| 99 | startup, fail loudly, and say what is wrong. | |
| 100 | ||
| 101 | **Image pull failure.** Fail the build with the pull error in the log. Do not | |
| 102 | retry indefinitely; do not fall back to another image. | |
| 103 | ||
| 104 | **Storage growth.** Images accumulate. Prune on a schedule, and document it — | |
| 105 | an unbounded cache on a 40GB host is a slow outage. | |
| 106 | ||
| 107 | ### Host changes on bay1 | |
| 108 | ||
| 109 | - `apt install podman` (Debian 13 has it; kernel 6.12, cgroup2, user namespaces | |
| 110 | enabled with `max_user_namespaces` = 31556 — all already verified). | |
| 111 | - `/etc/subuid` and `/etc/subgid` entries for `ci-runner`. | |
| 112 | - The systemd override needs `Delegate=yes` for rootless cgroup management, and | |
| 113 | `ProtectSystem=full` must be checked against podman's storage under | |
| 114 | `~/.local/share/containers` — it may need an explicit `ReadWritePaths`. | |
| 115 | ||
| 116 | These are deployment changes, not code, and belong in `deploy/` with the rest. | |
| 117 | ||
| 118 | ## Sequencing | |
| 119 | ||
| 120 | Three merge requests, because the middle one is the risky one and should be | |
| 121 | reviewable on its own: | |
| 122 | ||
| 123 | 1. **Environment hygiene.** Stop inheriting `os.Environ()`; construct the step | |
| 124 | environment explicitly. Independently valuable, no new dependency, and | |
| 125 | testable today. | |
| 126 | 2. **Podman execution.** `Image` in `ci.yml` with validation, the clone/run | |
| 127 | split, container invocation, startup check, failure modes. | |
| 128 | 3. **Deployment.** `deploy/` changes, subuid/subgid, the systemd override, | |
| 129 | pruning, and the operational notes. | |
| 130 | ||
| 131 | ## Tests | |
| 132 | ||
| 133 | - A step cannot read the runner's SSH key. | |
| 134 | - A step's environment contains what was constructed and nothing from the | |
| 135 | runner's own environment. | |
| 136 | - Two concurrent builds cannot see each other's workspaces. | |
| 137 | - An `image:` containing a shell metacharacter is refused by `ci.Parse`. | |
| 138 | - A missing podman fails the runner at startup rather than running a build | |
| 139 | unsandboxed — the fallback that must not exist. | |
| 140 | - A pull failure fails the build with the error in the log. | |
| 141 | ||
| 142 | The isolation tests need podman on the machine running them. Gate them so the | |
| 143 | suite still passes without it, and make the skip visible: a silently skipped | |
| 144 | isolation test is how this regresses. | |
| 145 | ||
| 146 | ## What this does not do | |
| 147 | ||
| 148 | No `network: none`, no per-step images, no image caching strategy beyond | |
| 149 | pruning, no resource limits per build beyond what the service already applies. | |
| 150 | Each is a separate decision. | |