runner, deploy, wiki: a build home per repository, and a memory cap on bay1 !332

merged merged by cmc on 2026-09-07 19:47 UTC · krz/gitbay:runner-home-memory-184 into main

6 files changed, +128 −23

Layout: unified · split

.gitbay/wiki/Admin.org +19 −2
@@ -391,6 +391,11 @@ gitbay-runner -remote git@gitbay.org -repos krz/site,krz/docs \
391 -workdir /var/lib/gitbay-runner/work 391 -workdir /var/lib/gitbay-runner/work
392#+end_src 392#+end_src
393 393
394gitbay.org's runner is scoped: it builds the forge's own repositories
395and the isolation canary, nothing else, because it shares the host with
396the forge. A =.gitbay/ci.yml= in another repository there queues builds
397no runner claims. Whether that changes is krz/gitbay#184.
398
394Naming no repositories is the old behaviour and stays the right choice for 399Naming no repositories is the old behaviour and stays the right choice for
395the runner on the server itself. The scoping is what the runner asks for, 400the runner on the server itself. The scoping is what the runner asks for,
396not an ACL the server holds over it: a runner account is admin by 401not an ACL the server holds over it: a runner account is admin by
@@ -444,8 +449,20 @@ overrides it with =image:= in =.gitbay/ci.yml=, validated as a reference
444so a config file cannot turn it into podman arguments. 449so a config file cannot turn it into podman arguments.
445 450
446=-cpus= and =-memory= cap one build's container (podman's own units, 451=-cpus= and =-memory= cap one build's container (podman's own units,
447e.g. =-cpus 2 -memory 4g=); unset means uncapped. bay1 runs =-cpus 3= 452e.g. =-cpus 2 -memory 4g=); unset means uncapped. They only take effect
448and no memory cap, since the e2e suite needs most of the host's 7GB. 453where podman can create a cgroup for the container. Under rootless
454podman with the cgroupfs manager, which is what a system service gets,
455it cannot: the container runs inside the service's own cgroup and both
456flags are accepted and ignored (krz/gitbay#188). Cap the unit instead.
457bay1's drop-in sets =MemoryMax=6G= and =CPUQuota=300%= on a 7.7GB
458four-core host with no swap: the memory cap is what keeps the forge
459alive when a build allocates without bound, and it sits above the e2e
460suite's 5GB peak rather than at a fair share. =OOMPolicy=continue= keeps
461systemd from stopping the runner when a build is OOM-killed.
462
463Each repository gets its own build home under the runner's workdir,
464mounted into its containers as =HOME=. Caches persist between builds of
465one repository and are never read by another's.
449 466
450*Images are provisioned, never pulled by a build.* The runner passes 467*Images are provisioned, never pulled by a build.* The runner passes
451=--pull=never=. Two reasons, and the second is the better one: the 468=--pull=never=. Two reasons, and the second is the better one: the
.gitbay/wiki/FAQ.org +6
@@ -17,3 +17,9 @@
17 email patch flow (revisit only if sourcehut-style demand appears), 17 email patch flow (revisit only if sourcehut-style demand appears),
18 federation and Postgres (no need at this scale). Recorded so the 18 federation and Postgres (no need at this scale). Recorded so the
19 absence reads as a decision, not an oversight. 19 absence reads as a decision, not an oversight.
20- Does CI run for my repository on gitbay.org? :: Not yet. The runner
21 there is scoped to the forge's own repositories and its isolation
22 canary, since it shares the host with the forge. A =.gitbay/ci.yml=
23 in your repository queues builds nothing claims. krz/gitbay#184 is
24 where that gets decided. A self-hosted instance runs the same runner
25 for whichever repositories its operator names.
.gitbay/wiki/Threat-Model.org +7 −5
@@ -166,11 +166,13 @@ runner, polling over SSH, clones the commit and runs its steps.
166 166
167Under =-isolation none=, anything a step can do as the runner's user a 167Under =-isolation none=, anything a step can do as the runner's user a
168pushed =ci.yml= can do. Under podman a step is confined to its 168pushed =ci.yml= can do. Under podman a step is confined to its
169container and the bind-mounted workspace, but the build home's caches 169container, the bind-mounted workspace and the repository's own build
170are shared between builds, so one build can still leave something a 170home, so what a build leaves in a cache is read only by later builds of
171later build reads. Treat the runner host as executing untrusted code: 171the same repository. Treat the runner host as executing untrusted code
172keep it off the daemon's host where the database lives, or scope it to 172all the same: keep it off the daemon's host where the database lives,
173repositories whose writers you trust. 173or scope it to repositories whose writers you trust. gitbay.org does
174the latter — its runner builds only the repositories the operator
175names.
174 176
175* What has not been audited 177* What has not been audited
176 178
cmd/gitbay-runner/home_test.go added +53
@@ -0,0 +1,53 @@
1package main
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7)
8
9// The build home is per repository: one shared home let a step poison
10// the module cache or plant a .gitconfig that another repository's build
11// would honour (#184).
12func TestBuildHomeIsPerRepository(t *testing.T) {
13 work := t.TempDir()
14 a, err := buildHomeFor(work, "alice/app")
15 if err != nil {
16 t.Fatal(err)
17 }
18 b, err := buildHomeFor(work, "bob/app")
19 if err != nil {
20 t.Fatal(err)
21 }
22 if a == b {
23 t.Fatalf("two repositories share a build home: %s", a)
24 }
25 for _, dir := range []string{a, b} {
26 rel, err := filepath.Rel(filepath.Join(work, "home"), dir)
27 if err != nil || rel == "." || filepath.IsAbs(rel) || rel[0] == '.' {
28 t.Fatalf("build home %s is not under %s/home", dir, work)
29 }
30 st, err := os.Stat(dir)
31 if err != nil {
32 t.Fatalf("build home not created: %v", err)
33 }
34 if st.Mode().Perm() != 0o700 {
35 t.Fatalf("build home mode %o, want 0700", st.Mode().Perm())
36 }
37 }
38 // The same repository gets the same home back: that is what makes it
39 // a cache.
40 again, _ := buildHomeFor(work, "alice/app")
41 if again != a {
42 t.Fatalf("build home moved between builds: %s then %s", a, again)
43 }
44}
45
46// A repository path is server-validated, but the home must still never
47// resolve outside the runner's home root.
48func TestBuildHomeRefusesTraversal(t *testing.T) {
49 work := t.TempDir()
50 if _, err := buildHomeFor(work, "../../etc"); err == nil {
51 t.Fatal("a traversing repository path produced a build home")
52 }
53}
cmd/gitbay-runner/main.go +26 −12
@@ -267,13 +267,12 @@ func (r *runner) run(j job) bool {
267 // credential dotfiles are. A directory beside the workspaces is 267 // credential dotfiles are. A directory beside the workspaces is
268 // neither. 268 // neither.
269 // 269 //
270 // It is shared by every build on this runner, so a step can poison a 270 // One per repository: shared across repositories, a step could poison
271 // cache another repository's build will read. That is already true of 271 // the module cache or plant a .gitconfig that another repository's
272 // anything a step can reach as this user — see the wiki's 272 // build would honour, and the container mounts the home read-write
273 // Threat-Model on the runner — and is what container isolation (#144) 273 // (#184).
274 // is for; -repos is the control until then. 274 buildHome, err := buildHomeFor(r.workdir, j.Repo)
275 buildHome := filepath.Join(r.workdir, "home") 275 if err != nil {
276 if err := os.MkdirAll(buildHome, 0o700); err != nil {
277 log.Printf("build %d: build home: %v", j.ID, err) 276 log.Printf("build %d: build home: %v", j.ID, err)
278 return false 277 return false
279 } 278 }
@@ -388,14 +387,29 @@ func (r *runner) run(j job) bool {
388// the runner's entire environment, including anything an operator set on 387// the runner's entire environment, including anything an operator set on
389// the service (#144). 388// the service (#144).
390// 389//
391// HOME is a build home shared by this runner's builds, not the runner's 390// HOME is the repository's build home, not the runner's own: tools read
392// own: tools read credentials out of dotfiles — .netrc, .npmrc, 391// credentials out of dotfiles — .netrc, .npmrc, .gitconfig — and a build
393// .gitconfig — and a build has no business finding the runner's. It is 392// has no business finding the runner's. It is not the workspace either,
394// not the workspace either, because the workspace is deleted after every 393// because the workspace is deleted after every build and every tool
395// build and every tool cache lives under HOME. 394// cache lives under HOME.
396// 395//
397// PATH is the one thing carried over: without it a step cannot find the 396// PATH is the one thing carried over: without it a step cannot find the
398// tools the host was provisioned with. 397// tools the host was provisioned with.
398// buildHomeFor is the build home for one repository: <workdir>/home/<owner>/<name>,
399// created on first use. The repository path comes from the server, but a
400// home must still never resolve outside the home root.
401func buildHomeFor(workdir, repo string) (string, error) {
402 root := filepath.Join(workdir, "home")
403 dir := filepath.Join(root, filepath.FromSlash(repo))
404 if rel, err := filepath.Rel(root, dir); err != nil || rel == "." || strings.HasPrefix(rel, "..") {
405 return "", fmt.Errorf("repository path %q escapes the build home root", repo)
406 }
407 if err := os.MkdirAll(dir, 0o700); err != nil {
408 return "", err
409 }
410 return dir, nil
411}
412
399func stepEnv(j job, home string) []string { 413func stepEnv(j job, home string) []string {
400 path := os.Getenv("PATH") 414 path := os.Getenv("PATH")
401 if path == "" { 415 if path == "" {
deploy/gitbay-runner.override.conf +17 −4
@@ -27,9 +27,22 @@
27# repositories never claims a build it is not scoped to, so the canary 27# repositories never claims a build it is not scoped to, so the canary
28# must be listed or its scheduled build waits forever. 28# must be listed or its scheduled build waits forever.
29# 29#
30# -cpus 3 of the host's 4 leaves a core for gitbayd and sshd while a build 30# Resource caps are on the unit, not on the container. Under rootless
31# runs. No -memory: the e2e suite peaks past 5GB of the 7GB, and a cap 31# podman with the cgroupfs manager the container runs inside this
32# that kills it is an outage rather than a limit (#144). 32# service's own cgroup: no child cgroup is created, so podman's --cpus
33# and --memory are accepted and never applied (#188). MemoryMax and
34# CPUQuota below bound the runner and every build together, which is
35# what keeps the forge alive when a build allocates without bound.
36#
37# 6G of the host's 7.7GB, no swap: the e2e suite peaks past 5GB, so the
38# cap sits above that rather than at a fair share. CPUQuota=300% is
39# three of the four cores, leaving one for gitbayd and sshd (#144, #184).
40# OOMPolicy=continue: systemd's default stops the whole service when any
41# process in it is OOM-killed, which would end the runner mid-build; the
42# build fails and the runner carries on.
43MemoryMax=6G
44CPUQuota=300%
45OOMPolicy=continue
33# 46#
34# ExecStart is overridden here rather than left in the unit so the flags 47# ExecStart is overridden here rather than left in the unit so the flags
35# and the sandboxing that has to match them live in one file: -isolation 48# and the sandboxing that has to match them live in one file: -isolation
@@ -57,7 +70,7 @@ TimeoutStopSec=50min
57# when it exits is killed. 70# when it exits is killed.
58KillMode=mixed 71KillMode=mixed
59ExecStart= 72ExecStart=
60ExecStart=/usr/local/bin/gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work -poll 5s -timeout 45m -repos krz/gitbay,cmc/ci-smoke -isolation podman -image localhost/gitbay-ci:1 -cpus 3 73ExecStart=/usr/local/bin/gitbay-runner -remote git@127.0.0.1 -workdir /var/lib/gitbay-runner/work -poll 5s -timeout 45m -repos krz/gitbay,cmc/ci-smoke -isolation podman -image localhost/gitbay-ci:1
61Nice=10 74Nice=10
62CPUWeight=30 75CPUWeight=30
63IOWeight=30 76IOWeight=30