Commit 7e7a919b3b

7e7a919b3b0b78ac43292cb6c8b722a2755dbea9

parent: b9b563bb11

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-08 06:45 UTC

e2e, wiki: stop tests for the runner's drain

Three tests. TestRunnerDrainsOnSIGTERM signals a runner mid-step and
asserts it exits 0 with the build reported a success and the step run
to its end. TestRunnerDropInLetsTheDrainHappen reads
deploy/gitbay-runner.override.conf and fails unless KillMode is mixed
and TimeoutStopSec outlasts ExecStart's -timeout plus the report
retries. TestRunnerDrainUnderSystemd runs the runner as a transient
user unit through systemd-run and stops it with systemctl: under the
drop-in's KillMode the build is a success, under control-group it is
not. It skips where there is no systemd user manager, which includes
the container CI runs in.

Ref #184, Ref #179
.gitbay/wiki/Admin.org +7 −1
@@ -511,7 +511,13 @@ runner drains a build that is already dead. So =make deploy-runner=
511511waits for a running build rather than orphaning it, and a build's result
512512is retried for half a minute if gitbayd is restarting at that moment. A
513513second SIGTERM ends the runner at once, abandoning the build to the
514reaper.
514reaper. The suite checks all three: =TestRunnerDrainsOnSIGTERM= signals
515the process, =TestRunnerDropInLetsTheDrainHappen= reads the drop-in's
516=KillMode= and =TimeoutStopSec=, and =TestRunnerDrainUnderSystemd= runs
517the runner as a transient user unit under =systemd-run= and stops it
518under both kill modes. That last one needs a systemd user manager, so it
519skips in the container CI runs in; run it on a Linux host with
520=go test ./e2e -run TestRunnerDrainUnderSystemd -v=.
515521
516522*Validate podman mode on a scratch repository before pointing the runner
517523at real ones.* Every deploy that switched the whole instance to
e2e/runnerstop_test.go added +220
@@ -0,0 +1,220 @@
1package e2e
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "regexp"
11 "strings"
12 "syscall"
13 "testing"
14 "time"
15)
16
17// The runner drains on SIGTERM: the build in flight runs to its end and
18// is reported, and nothing more is claimed (#179). The drain shipped
19// broken once because only the process was tested and never the unit:
20// systemd's default KillMode signals every process in the cgroup, the
21// step and the log session included, so the runner drained a build
22// whose steps were already dead. deploy/gitbay-runner.override.conf
23// sets KillMode=mixed for that reason, and the systemd test below runs
24// the runner as a real transient unit under both modes (#184).
25
26// stopFixture starts an instance with one repository whose single job
27// sleeps long enough to be stopped mid-step, and returns the admin key
28// that both pushes and runs the runner.
29func stopFixture(t *testing.T) (*instance, string) {
30 t.Helper()
31 inst := startInstance(t)
32 inst.runner = buildRunner(t)
33 key := inst.newKey(t, "alice")
34 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub", "--admin")
35 if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/app"); code != 0 {
36 t.Fatalf("repo create: %s", errOut)
37 }
38 work := t.TempDir()
39 env := inst.gitEnv(key)
40 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
41 dir := filepath.Join(work, "w")
42 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
43 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"),
44 []byte("jobs:\n slow:\n steps:\n - sleep 4; echo drained\n"), 0o644)
45 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
46 mustGit(t, dir, env, "add", ".")
47 mustGit(t, dir, env, "commit", "-q", "-m", "ci")
48 mustGit(t, dir, env, "push", "-q", "origin", "main")
49 return inst, key
50}
51
52// runnerArgs is the command line the runner tests use, minus the binary.
53func (i *instance) runnerArgs(t *testing.T, key string) []string {
54 opts := fmt.Sprintf("-p %d -i %s -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=%s -o BatchMode=yes",
55 i.port, key, filepath.Join(i.sshDir, "known_hosts"))
56 return []string{
57 "-poll", "200ms",
58 "-isolation", "none",
59 "-remote", "git@127.0.0.1",
60 "-ssh-opts", opts,
61 "-clone-base", fmt.Sprintf("ssh://git@127.0.0.1:%d", i.port),
62 "-workdir", t.TempDir(),
63 }
64}
65
66func (i *instance) buildStatus(t *testing.T, key string) string {
67 t.Helper()
68 out, _, _ := i.ssh(t, key, "", "build", "list", "alice/app", "--json")
69 var env struct {
70 Data []struct {
71 Status string `json:"status"`
72 } `json:"data"`
73 }
74 json.Unmarshal([]byte(out), &env)
75 if len(env.Data) == 0 {
76 return ""
77 }
78 return env.Data[0].Status
79}
80
81func TestRunnerDrainsOnSIGTERM(t *testing.T) {
82 inst, key := stopFixture(t)
83 cmd := exec.Command(inst.runner, inst.runnerArgs(t, key)...)
84 cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
85 var buf bytes.Buffer
86 cmd.Stdout, cmd.Stderr = &buf, &buf
87 if err := cmd.Start(); err != nil {
88 t.Fatal(err)
89 }
90 defer func() { cmd.Process.Kill(); cmd.Wait() }()
91
92 waitFor(t, "the build to be claimed", func() bool { return inst.buildStatus(t, key) == "running" })
93 // The step is asleep. Signal the runner alone, as KillMode=mixed does.
94 if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
95 t.Fatal(err)
96 }
97 exited := make(chan error, 1)
98 go func() { exited <- cmd.Wait() }()
99 select {
100 case err := <-exited:
101 if err != nil {
102 t.Fatalf("runner exited %v after SIGTERM; output:\n%s", err, buf.String())
103 }
104 case <-time.After(30 * time.Second):
105 t.Fatalf("runner did not exit within 30s of SIGTERM; output:\n%s", buf.String())
106 }
107 if !strings.Contains(buf.String(), "draining") {
108 t.Fatalf("runner did not announce the drain:\n%s", buf.String())
109 }
110 if st := inst.buildStatus(t, key); st != "success" {
111 t.Fatalf("build after drain is %q, want success; runner output:\n%s", st, buf.String())
112 }
113 if out, _, _ := inst.ssh(t, key, "", "build", "log", "alice/app", "1"); !strings.Contains(out, "drained") {
114 t.Fatalf("step did not run to its end:\n%s", out)
115 }
116}
117
118// The drop-in the runner runs under. Read here so a change to it fails a
119// test rather than the next production stop.
120func runnerDropIn(t *testing.T) string {
121 t.Helper()
122 b, err := os.ReadFile(filepath.Join("..", "deploy", "gitbay-runner.override.conf"))
123 if err != nil {
124 t.Fatal(err)
125 }
126 return string(b)
127}
128
129func dropInValue(conf, key string) string {
130 m := regexp.MustCompile(`(?m)^`+key+`=(.*)$`).FindAllStringSubmatch(conf, -1)
131 if len(m) == 0 {
132 return ""
133 }
134 return strings.TrimSpace(m[len(m)-1][1]) // the last assignment wins, as in systemd
135}
136
137// The unit must let the drain happen: the stop signal reaches the runner
138// alone, and the stop timeout outlasts a build plus the report retries.
139func TestRunnerDropInLetsTheDrainHappen(t *testing.T) {
140 conf := runnerDropIn(t)
141 if mode := dropInValue(conf, "KillMode"); mode != "mixed" {
142 t.Fatalf("KillMode=%q, want mixed: control-group signals the step and the log session with the runner", mode)
143 }
144 stop, err := time.ParseDuration(strings.ReplaceAll(dropInValue(conf, "TimeoutStopSec"), "min", "m"))
145 if err != nil {
146 t.Fatalf("TimeoutStopSec: %v", err)
147 }
148 m := regexp.MustCompile(`-timeout (\S+)`).FindStringSubmatch(dropInValue(conf, "ExecStart"))
149 if m == nil {
150 t.Fatal("ExecStart has no -timeout")
151 }
152 build, err := time.ParseDuration(m[1])
153 if err != nil {
154 t.Fatalf("-timeout: %v", err)
155 }
156 // Half a minute of report retries after the build's own limit (#179).
157 if stop < build+30*time.Second {
158 t.Fatalf("TimeoutStopSec %v cannot outlast a %v build and its report retries", stop, build)
159 }
160}
161
162// haveUserSystemd reports whether transient user units can be started
163// here: a Linux host with a systemd user manager for this account.
164func haveUserSystemd(t *testing.T) bool {
165 t.Helper()
166 if _, err := exec.LookPath("systemd-run"); err != nil {
167 return false
168 }
169 return exec.Command("systemd-run", "--user", "--quiet", "--wait", "--collect", "true").Run() == nil
170}
171
172// The runner as a transient unit, stopped by systemd. Under the drop-in's
173// KillMode the build in flight is reported a success; under systemd's
174// default it is not, which is the failure that shipped once.
175func TestRunnerDrainUnderSystemd(t *testing.T) {
176 if !haveUserSystemd(t) {
177 t.Skip("no systemd user manager")
178 }
179 mode := dropInValue(runnerDropIn(t), "KillMode")
180 for _, tc := range []struct {
181 mode string
182 drained bool
183 }{
184 {mode, true},
185 {"control-group", false},
186 } {
187 t.Run(tc.mode, func(t *testing.T) {
188 inst, key := stopFixture(t)
189 unit := fmt.Sprintf("gitbay-runner-test-%d-%s", os.Getpid(), tc.mode)
190 args := append([]string{"--user", "--quiet", "--collect", "--unit", unit,
191 "-p", "KillMode=" + tc.mode, "-p", "TimeoutStopSec=60",
192 "--setenv=GIT_CONFIG_NOSYSTEM=1", "--setenv=GIT_CONFIG_GLOBAL=/dev/null",
193 inst.runner}, inst.runnerArgs(t, key)...)
194 if out, err := exec.Command("systemd-run", args...).CombinedOutput(); err != nil {
195 t.Fatalf("systemd-run: %v\n%s", err, out)
196 }
197 defer exec.Command("systemctl", "--user", "kill", "-s", "SIGKILL", unit).Run()
198
199 waitFor(t, "the build to be claimed", func() bool { return inst.buildStatus(t, key) == "running" })
200 // systemctl stop returns when the unit is down: after the
201 // drain, or after the cgroup was signalled and emptied.
202 stop := exec.Command("systemctl", "--user", "stop", unit)
203 if out, err := stop.CombinedOutput(); err != nil {
204 t.Fatalf("systemctl stop: %v\n%s", err, out)
205 }
206 if tc.drained {
207 if st := inst.buildStatus(t, key); st != "success" {
208 t.Fatalf("KillMode=%s: build after stop is %q, want success", tc.mode, st)
209 }
210 return
211 }
212 // The step was signalled with the runner, so it cannot have
213 // finished: the runner reports a failure, or died before
214 // reporting and left the build running for the reaper.
215 if st := inst.buildStatus(t, key); st == "success" {
216 t.Fatalf("KillMode=%s: build reported success, so the stop test cannot tell the modes apart", tc.mode)
217 }
218 })
219 }
220}