Commit f32cde0608
f32cde06088358893b61140efbb754d2f87b343b
parent: 3f7409ca2e
Verified · cmc ci/build: success ci/sonar: success ci/test: success ci/vuln: success
cmc <hello@cleberg.net> · 2026-09-06 18:48 UTC
deploy, wiki: prepare a runner host for rootless podman
Host preparation ships before the runner that needs it: the runner will
refuse to start without a working podman, so an unprepared host would
stop every build. Idempotent setup script, subuid/subgid delegation, the
systemd changes rootless podman needs, and weekly image pruning.
Ref #144
Layout: unified · split
.gitbay/wiki/Admin.org
+39
| @@ -406,6 +406,45 @@ deploy, an archive publish, an automated MR branch — needs an explicit |
| 406 | grant on that repo: =repo access grant <owner/name> ci write=. Private |
406 | grant on that repo: =repo access grant <owner/name> ci write=. Private |
| 407 | repos likewise need at least read for the clone. |
407 | repos likewise need at least read for the clone. |
| 408 | |
408 | |
| |
409 | ** Container isolation |
| |
410 | |
| |
411 | Builds currently run as the runner's own user with no container; the |
| |
412 | service drop-in and =-repos= are the controls (krz/gitbay#144). The step |
| |
413 | environment is constructed rather than inherited, so a build sees =PATH=, |
| |
414 | =HOME= (its workspace), =LANG=, =CI=, its =GITBAY_*= variables and its |
| |
415 | secrets and nothing else — but a step can still read what that user can |
| |
416 | read, and concurrent builds share a =-workdir=. |
| |
417 | |
| |
418 | Rootless podman is the chosen remedy; the host preparation ships ahead of |
| |
419 | the runner that uses it, so the order is fixed: |
| |
420 | |
| |
421 | #+begin_src sh |
| |
422 | ssh -p 2222 root@<host> 'sh -s' < deploy/runner-podman-setup.sh |
| |
423 | make deploy-runner |
| |
424 | #+end_src |
| |
425 | |
| |
426 | The script installs podman, delegates a subuid/subgid range to |
| |
427 | =ci-runner=, checks that user namespaces are enabled rather than |
| |
428 | assuming, enables lingering, and verifies rootless podman actually runs |
| |
429 | as that user. It is idempotent. |
| |
430 | |
| |
431 | *Do not deploy an isolating runner to a host that has not been |
| |
432 | prepared.* The runner is specified to refuse to start without a working |
| |
433 | podman rather than fall back to running builds unsandboxed — a fallback |
| |
434 | that silently drops isolation is worse than a stopped runner, because |
| |
435 | nothing surfaces it. On an unprepared host that refusal stops every |
| |
436 | build on the instance. |
| |
437 | |
| |
438 | The service drop-in carries =Delegate=yes= for rootless cgroup |
| |
439 | management and =ReadWritePaths= for podman's store under |
| |
440 | =/var/lib/gitbay-runner=, which =ProtectSystem=full= would otherwise |
| |
441 | make read-only. Those paths are prefixed =-= so they are ignored when |
| |
442 | absent: the drop-in installs on unprepared hosts too, and a unit that |
| |
443 | refused to start would stop every build. |
| |
444 | =gitbay-runner-prune.timer= prunes unused images weekly, as the runner's |
| |
445 | user: rootless storage belongs to that user, and root's prune would not |
| |
446 | see it. An unpruned image store on a 40GB host is a slow outage. |
| |
447 | |
| 409 | * LFS storage |
448 | * LFS storage |
| 410 | |
449 | |
| 411 | Objects live content-addressed under =[lfs] root= (default |
450 | Objects live content-addressed under =[lfs] root= (default |
deploy/gitbay-runner-prune.service
added
+13
| @@ -0,0 +1,13 @@ |
| |
1 | # Paired with gitbay-runner-prune.timer. Runs as the runner's own user, |
| |
2 | # because rootless podman's store belongs to that user and root's prune |
| |
3 | # would not see it. |
| |
4 | [Unit] |
| |
5 | Description=Prune unused podman images on the CI runner |
| |
6 | |
| |
7 | [Service] |
| |
8 | Type=oneshot |
| |
9 | User=ci-runner |
| |
10 | # Images not used by a container and older than a week. A build that |
| |
11 | # names an image again re-pulls it; the cost is one pull, not a failure. |
| |
12 | ExecStart=/usr/bin/podman image prune --all --force --filter until=168h |
| |
13 | ExecStart=/usr/bin/podman container prune --force |
deploy/gitbay-runner-prune.timer
added
+13
| @@ -0,0 +1,13 @@ |
| |
1 | # Podman's image store grows without bound: every `image:` a repository |
| |
2 | # names is pulled and kept. On a 40GB host that is a slow outage, so |
| |
3 | # prune weekly (#144). |
| |
4 | [Unit] |
| |
5 | Description=Prune unused podman images on the CI runner |
| |
6 | |
| |
7 | [Timer] |
| |
8 | OnCalendar=Sun 04:00 |
| |
9 | RandomizedDelaySec=30m |
| |
10 | Persistent=true |
| |
11 | |
| |
12 | [Install] |
| |
13 | WantedBy=timers.target |
deploy/gitbay-runner.override.conf
+13
| @@ -16,6 +16,12 @@ |
| 16 | # `keys add --scope runner`, which confines it to the runner protocol |
16 | # `keys add --scope runner`, which confines it to the runner protocol |
| 17 | # and read-only git, and the sandboxing below keeps a step from |
17 | # and read-only git, and the sandboxing below keeps a step from |
| 18 | # touching the system outside its workspace. |
18 | # touching the system outside its workspace. |
| |
19 | # |
| |
20 | # Delegate=yes and the storage path below are what rootless podman needs |
| |
21 | # (#144): it manages its own cgroups for a container, and its image and |
| |
22 | # container store lives under the runner's home, which ProtectSystem |
| |
23 | # would otherwise make read-only. Prepare the host with |
| |
24 | # deploy/runner-podman-setup.sh before deploying a runner that isolates. |
| 19 | [Service] |
25 | [Service] |
| 20 | Nice=10 |
26 | Nice=10 |
| 21 | CPUWeight=30 |
27 | CPUWeight=30 |
| @@ -25,3 +31,10 @@ ProtectSystem=full |
| 25 | ProtectKernelTunables=yes |
31 | ProtectKernelTunables=yes |
| 26 | ProtectControlGroups=yes |
32 | ProtectControlGroups=yes |
| 27 | RestrictSUIDSGID=yes |
33 | RestrictSUIDSGID=yes |
| |
34 | Delegate=yes |
| |
35 | # The runner's home is /var/lib/gitbay-runner (see the Admin page), and |
| |
36 | # the leading - makes a missing path ignored rather than fatal: this |
| |
37 | # drop-in installs on hosts that have not been prepared for podman yet, |
| |
38 | # and a unit that refuses to start would stop every build on the |
| |
39 | # instance. |
| |
40 | ReadWritePaths=-/var/lib/gitbay-runner/.local/share/containers -/var/lib/gitbay-runner/.config/containers |
deploy/runner-podman-setup.sh
added
+64
| @@ -0,0 +1,64 @@ |
| |
1 | #!/bin/sh |
| |
2 | # Prepare a runner host for container-isolated builds (#144). |
| |
3 | # |
| |
4 | # Run this on the runner host as root BEFORE deploying a gitbay-runner |
| |
5 | # that requires isolation. The runner refuses to start without a working |
| |
6 | # podman rather than falling back to running builds unsandboxed, so the |
| |
7 | # order matters: prepare the host, then `make deploy-runner`. |
| |
8 | # |
| |
9 | # ssh -p 2222 root@bay1 'sh -s' < deploy/runner-podman-setup.sh |
| |
10 | # |
| |
11 | # Idempotent: safe to re-run. |
| |
12 | set -eu |
| |
13 | |
| |
14 | RUNNER_USER="${RUNNER_USER:-ci-runner}" |
| |
15 | |
| |
16 | # The runner's home is wherever the account was created with; podman's |
| |
17 | # store lives under it and the systemd drop-in names the same path. |
| |
18 | |
| |
19 | if ! id "$RUNNER_USER" >/dev/null 2>&1; then |
| |
20 | echo "no such user: $RUNNER_USER" >&2 |
| |
21 | exit 1 |
| |
22 | fi |
| |
23 | |
| |
24 | echo "==> installing podman" |
| |
25 | if ! command -v podman >/dev/null 2>&1; then |
| |
26 | apt-get update |
| |
27 | DEBIAN_FRONTEND=noninteractive apt-get install -y podman uidmap |
| |
28 | fi |
| |
29 | podman --version |
| |
30 | |
| |
31 | # Rootless podman maps container uids into a range delegated to the user. |
| |
32 | # Without these the runner's `podman run` fails with a mapping error. |
| |
33 | echo "==> subuid/subgid for $RUNNER_USER" |
| |
34 | for f in /etc/subuid /etc/subgid; do |
| |
35 | if ! grep -q "^$RUNNER_USER:" "$f" 2>/dev/null; then |
| |
36 | echo "$RUNNER_USER:200000:65536" >>"$f" |
| |
37 | echo " added to $f" |
| |
38 | else |
| |
39 | echo " already in $f" |
| |
40 | fi |
| |
41 | done |
| |
42 | |
| |
43 | # User namespaces are what rootless podman is built on. Debian 13 enables |
| |
44 | # them by default; check rather than assume, because a build silently |
| |
45 | # running as the host user is exactly what this is meant to prevent. |
| |
46 | echo "==> kernel support" |
| |
47 | max_ns=$(cat /proc/sys/user/max_user_namespaces 2>/dev/null || echo 0) |
| |
48 | if [ "$max_ns" -lt 1 ]; then |
| |
49 | echo "user namespaces are disabled (user.max_user_namespaces=$max_ns);" >&2 |
| |
50 | echo "rootless podman cannot work until they are enabled" >&2 |
| |
51 | exit 1 |
| |
52 | fi |
| |
53 | echo " max_user_namespaces=$max_ns" |
| |
54 | |
| |
55 | # Lingering keeps the user's systemd session — and so podman's storage |
| |
56 | # and any running container — alive when nobody is logged in. |
| |
57 | echo "==> lingering for $RUNNER_USER" |
| |
58 | loginctl enable-linger "$RUNNER_USER" |
| |
59 | |
| |
60 | echo "==> verifying rootless podman as $RUNNER_USER" |
| |
61 | su - "$RUNNER_USER" -s /bin/sh -c 'podman info --format "{{.Host.Security.Rootless}}"' |
| |
62 | |
| |
63 | echo |
| |
64 | echo "host is ready; now: make deploy-runner" |