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
406406grant on that repo: =repo access grant <owner/name> ci write=. Private
407407repos likewise need at least read for the clone.
408408
409** Container isolation
410
411Builds currently run as the runner's own user with no container; the
412service drop-in and =-repos= are the controls (krz/gitbay#144). The step
413environment is constructed rather than inherited, so a build sees =PATH=,
414=HOME= (its workspace), =LANG=, =CI=, its =GITBAY_*= variables and its
415secrets and nothing else — but a step can still read what that user can
416read, and concurrent builds share a =-workdir=.
417
418Rootless podman is the chosen remedy; the host preparation ships ahead of
419the runner that uses it, so the order is fixed:
420
421#+begin_src sh
422ssh -p 2222 root@<host> 'sh -s' < deploy/runner-podman-setup.sh
423make deploy-runner
424#+end_src
425
426The script installs podman, delegates a subuid/subgid range to
427=ci-runner=, checks that user namespaces are enabled rather than
428assuming, enables lingering, and verifies rootless podman actually runs
429as that user. It is idempotent.
430
431*Do not deploy an isolating runner to a host that has not been
432prepared.* The runner is specified to refuse to start without a working
433podman rather than fall back to running builds unsandboxed — a fallback
434that silently drops isolation is worse than a stopped runner, because
435nothing surfaces it. On an unprepared host that refusal stops every
436build on the instance.
437
438The service drop-in carries =Delegate=yes= for rootless cgroup
439management and =ReadWritePaths= for podman's store under
440=/var/lib/gitbay-runner=, which =ProtectSystem=full= would otherwise
441make read-only. Those paths are prefixed =-= so they are ignored when
442absent: the drop-in installs on unprepared hosts too, and a unit that
443refused to start would stop every build.
444=gitbay-runner-prune.timer= prunes unused images weekly, as the runner's
445user: rootless storage belongs to that user, and root's prune would not
446see it. An unpruned image store on a 40GB host is a slow outage.
447
409448* LFS storage
410449
411450Objects 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]
5Description=Prune unused podman images on the CI runner
6
7[Service]
8Type=oneshot
9User=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.
12ExecStart=/usr/bin/podman image prune --all --force --filter until=168h
13ExecStart=/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]
5Description=Prune unused podman images on the CI runner
6
7[Timer]
8OnCalendar=Sun 04:00
9RandomizedDelaySec=30m
10Persistent=true
11
12[Install]
13WantedBy=timers.target
deploy/gitbay-runner.override.conf +13
@@ -16,6 +16,12 @@
1616# `keys add --scope runner`, which confines it to the runner protocol
1717# and read-only git, and the sandboxing below keeps a step from
1818# 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.
1925[Service]
2026Nice=10
2127CPUWeight=30
@@ -25,3 +31,10 @@ ProtectSystem=full
2531ProtectKernelTunables=yes
2632ProtectControlGroups=yes
2733RestrictSUIDSGID=yes
34Delegate=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.
40ReadWritePaths=-/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.
12set -eu
13
14RUNNER_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
19if ! id "$RUNNER_USER" >/dev/null 2>&1; then
20 echo "no such user: $RUNNER_USER" >&2
21 exit 1
22fi
23
24echo "==> installing podman"
25if ! command -v podman >/dev/null 2>&1; then
26 apt-get update
27 DEBIAN_FRONTEND=noninteractive apt-get install -y podman uidmap
28fi
29podman --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.
33echo "==> subuid/subgid for $RUNNER_USER"
34for 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
41done
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.
46echo "==> kernel support"
47max_ns=$(cat /proc/sys/user/max_user_namespaces 2>/dev/null || echo 0)
48if [ "$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
52fi
53echo " 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.
57echo "==> lingering for $RUNNER_USER"
58loginctl enable-linger "$RUNNER_USER"
59
60echo "==> verifying rootless podman as $RUNNER_USER"
61su - "$RUNNER_USER" -s /bin/sh -c 'podman info --format "{{.Host.Security.Rootless}}"'
62
63echo
64echo "host is ready; now: make deploy-runner"