detached 1e4636c

test(e2e): run jobs through the real image in a container: runner user, ownership, PATH/ENV files, cancel and orphan cleanup

commit
1e4636c358bd2e0fdd9f0bc21de8a9c014e25557
author
brice · · 2026-10-12 18:49 UTC
parent
b70b211
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +310 -6
A internal/e2e/e2e_test.go +281 -0
@@ -0,0 +1,281 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package e2e runs jobs through the real executor in a real sandbox. The tests
4+// are opt-in because they need Docker (and, for the VM, KVM and Firecracker):
5+//
6+// ERMITE_RUNNER_IT_E2E=1 ERMITE_RUNNER_TEST_IMAGE=ermite-runner-ubuntu-24.04:dev go test ./internal/e2e
7+//
8+// On a workstation with a rootless Docker daemon the container driver is used
9+// with AllowRootless (a test-only option): the jobs run as in production except
10+// that the nested dockerd of the image cannot start, which these tests do not
11+// need. The production paths that need root (jailer, rootful Docker, sysbox)
12+// are not covered here.
13+package e2e
14+
15+import (
16+ "context"
17+ "fmt"
18+ "io"
19+ "os"
20+ "os/exec"
21+ "path/filepath"
22+ "strings"
23+ "sync"
24+ "testing"
25+ "time"
26+
27+ "bnema.dev/ermite-runner/internal/execstream"
28+ "bnema.dev/ermite-runner/internal/execstream/execpb"
29+ "bnema.dev/ermite-runner/internal/sandbox"
30+ "bnema.dev/ermite-runner/internal/sandbox/container"
31+ "bnema.dev/ermite-runner/internal/sandbox/disk"
32+ "bnema.dev/ermite-runner/internal/sandbox/sandboxtest"
33+)
34+
35+func needE2E(t *testing.T) string {
36+ t.Helper()
37+ if os.Getenv("ERMITE_RUNNER_IT_E2E") != "1" {
38+ t.Skip("set ERMITE_RUNNER_IT_E2E=1 and ERMITE_RUNNER_TEST_IMAGE (needs Docker; see the package comment)")
39+ }
40+ image := os.Getenv("ERMITE_RUNNER_TEST_IMAGE")
41+ if image == "" {
42+ t.Fatal("set ERMITE_RUNNER_TEST_IMAGE to the runner image")
43+ }
44+ if _, err := exec.LookPath("docker"); err != nil {
45+ t.Skip("docker CLI not found")
46+ }
47+ return image
48+}
49+
50+// buildRunner compiles the static runner binary that is attached to the sandbox.
51+func buildRunner(t *testing.T, dir string) string {
52+ t.Helper()
53+ exe := filepath.Join(dir, "ermite-runner")
54+ root, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output()
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ cmd := exec.Command("go", "build", "-o", exe, "bnema.dev/ermite-runner")
59+ cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
60+ cmd.Dir = strings.TrimSpace(string(root))
61+ if out, err := cmd.CombinedOutput(); err != nil {
62+ t.Fatalf("build the runner: %v\n%s", err, out)
63+ }
64+ return exe
65+}
66+
67+// newDriver starts the container driver with test-only settings: no firewall
68+// plumbing, no loop mounts, a rootless daemon allowed.
69+func newDriver(t *testing.T, image string) sandbox.Driver {
70+ t.Helper()
71+ dir := t.TempDir()
72+ cfg := sandbox.Config{
73+ Isolation: sandbox.Container, StateDir: filepath.Join(dir, "state"), Capacity: 2, Image: image,
74+ JobCPUs: 2, JobMemoryMiB: 1024, JobDiskMiB: 2048, UIDBase: 900000,
75+ Network: netPrefix("10.214.0.0/16"), Executable: buildRunner(t, dir), DNSPort: 0,
76+ LockFile: filepath.Join(dir, "host.lock"), JobPids: 4096,
77+ }
78+ empty := filepath.Join(dir, "mountinfo")
79+ _ = os.WriteFile(empty, nil, 0o644)
80+ run := rootlessMountRunner{image: image, mountinfo: empty, mu: &sync.Mutex{}}
81+ d, err := container.New(context.Background(), cfg, container.Options{
82+ Net: sandboxtest.NewPlumbing(), AllowRootless: true, ConnectTimeout: 90 * time.Second, Run: run,
83+ Mounter: &disk.Mounter{Run: run, Mountinfo: empty},
84+ })
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ t.Cleanup(func() { _ = d.Close() })
89+ if err := d.Reap(context.Background()); err != nil {
90+ t.Fatal(err)
91+ }
92+ return d
93+}
94+
95+// rootlessMountRunner stands in for mount and umount, which need root. The
96+// scratch directory stays a plain directory; "mount" records it in a fake
97+// mountinfo file so that the driver believes it is mounted and calls "umount"
98+// at the end. On a rootless daemon the files that the runner user (uid 1001 in
99+// the container) creates belong to a subordinate uid that the host user cannot
100+// delete, so "umount" empties the directory from inside a container, where
101+// root can. With the real loop mount the scratch image is simply unmounted and
102+// deleted.
103+type rootlessMountRunner struct {
104+ image string
105+ mountinfo string
106+ mu *sync.Mutex
107+}
108+
109+func (r rootlessMountRunner) Run(ctx context.Context, in io.Reader, name string, args ...string) ([]byte, error) {
110+ switch name {
111+ case "mount":
112+ r.mu.Lock()
113+ defer r.mu.Unlock()
114+ f, err := os.OpenFile(r.mountinfo, os.O_APPEND|os.O_WRONLY, 0o644)
115+ if err != nil {
116+ return nil, err
117+ }
118+ defer func() { _ = f.Close() }()
119+ _, err = fmt.Fprintf(f, "1 1 0:1 / %s rw - ext4 fake rw\n", args[len(args)-1])
120+ return nil, err
121+ case "umount":
122+ dir := args[len(args)-1]
123+ out, err := exec.CommandContext(ctx, "docker", "run", "--rm", "--entrypoint", "sh", "-v", dir+":/d", r.image,
124+ "-c", "rm -rf /d/* /d/.[!.]* 2>/dev/null; true").CombinedOutput()
125+ r.mu.Lock()
126+ defer r.mu.Unlock()
127+ b, _ := os.ReadFile(r.mountinfo)
128+ var keep []string
129+ for _, l := range strings.Split(string(b), "\n") {
130+ if l != "" && !strings.Contains(l, " "+dir+" ") {
131+ keep = append(keep, l)
132+ }
133+ }
134+ _ = os.WriteFile(r.mountinfo, []byte(strings.Join(keep, "\n")+"\n"), 0o644)
135+ return out, err
136+ }
137+ return sandboxtest.NoMountRunner{}.Run(ctx, in, name, args...)
138+}
139+
140+// session is the outcome of one job driven through the stream.
141+type session struct {
142+ Steps []string
143+ Concl map[string]execpb.StepConclusion
144+ Logs []string // text of every log line, in order
145+ LogStep []int32
146+ Result *execpb.JobResult
147+ Exit *execpb.Exit
148+}
149+
150+func (s *session) log(step int32) []string {
151+ var out []string
152+ for i, l := range s.Logs {
153+ if s.LogStep[i] == step {
154+ out = append(out, l)
155+ }
156+ }
157+ return out
158+}
159+
160+// runJob starts a sandbox, drives the job and stops the sandbox.
161+func runJob(t *testing.T, d sandbox.Driver, spec *execpb.JobSpec, cancel <-chan *execpb.Cancel) *session {
162+ t.Helper()
163+ sb, err := d.New()
164+ if err != nil {
165+ t.Fatal(err)
166+ }
167+ defer func() {
168+ if err := sb.Stop(context.Background()); err != nil {
169+ t.Errorf("stopping the sandbox: %v", err)
170+ }
171+ }()
172+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "e2e"})
173+ if err != nil {
174+ t.Fatal(err)
175+ }
176+ s := &session{Concl: map[string]execpb.StepConclusion{}}
177+ _, exit, err := execstream.DriveJob(conn, &execpb.Run{Job: spec}, cancel, execstream.JobHandler{
178+ StepStarted: func(x *execpb.StepStarted) { s.Steps = append(s.Steps, x.Name) },
179+ StepCompleted: func(x *execpb.StepCompleted) {
180+ s.Concl[x.Name] = x.Conclusion
181+ },
182+ Log: func(l *execpb.Log) { s.Logs = append(s.Logs, l.Text); s.LogStep = append(s.LogStep, l.Step) },
183+ JobResult: func(r *execpb.JobResult) { s.Result = r },
184+ })
185+ if err != nil {
186+ t.Fatalf("driving the job: %v", err)
187+ }
188+ s.Exit = exit
189+ return s
190+}
191+
192+func jobSpec(yaml string) *execpb.JobSpec {
193+ return &execpb.JobSpec{
194+ WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello",
195+ Contexts: `{"github":{"repository":"octo/hello","repository_owner":"octo","event_name":"push","job":"j","ref":"refs/heads/main","ref_name":"main","ref_type":"branch","sha":"0123456789abcdef0123456789abcdef01234567","run_id":"1","run_number":"1","run_attempt":"1","server_url":"https://ermite.example","workflow":"w","actor":"octo","event":{"ref":"refs/heads/main"}}}`,
196+ Matrix: "{}", RunnerName: "e2e", RunnerVersion: "test", RunnerEnvironment: "self-hosted", TimeoutMinutes: 10,
197+ }
198+}
199+
200+func TestContainerRunsAJobAsTheRunnerUser(t *testing.T) {
201+ image := needE2E(t)
202+ d := newDriver(t, image)
203+ s := runJob(t, d, jobSpec(`on: push
204+jobs:
205+ j:
206+ runs-on: x
207+ outputs:
208+ who: ${{ steps.id.outputs.who }}
209+ steps:
210+ - id: id
211+ run: |
212+ echo "who=$(id -un):$(id -u):$(id -g)" >> "$GITHUB_OUTPUT"
213+ echo "home=$HOME pwd=$PWD"
214+ echo "ws=$GITHUB_WORKSPACE temp=$RUNNER_TEMP tool=$RUNNER_TOOL_CACHE"
215+ touch "$RUNNER_TEMP/t" "$GITHUB_WORKSPACE/w" "$HOME/h"
216+ echo "script=$0"
217+ - name: env and path
218+ run: |
219+ mkdir -p "$RUNNER_TEMP/bin" && printf '#!/bin/sh\necho from-path\n' > "$RUNNER_TEMP/bin/mytool" && chmod +x "$RUNNER_TEMP/bin/mytool"
220+ echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
221+ echo "FOO=bar" >> "$GITHUB_ENV"
222+ - run: mytool; echo "FOO=$FOO CI=$CI GITHUB_ACTIONS=$GITHUB_ACTIONS ERMITE_CONNECT=${ERMITE_CONNECT-unset}"
223+ - name: python
224+ shell: python3 {0}
225+ run: print("py", 6*7)
226+`), nil)
227+ if s.Result == nil || s.Result.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS || s.Exit.Code != 0 {
228+ t.Fatalf("result %v exit %v\n%s", s.Result, s.Exit, strings.Join(s.Logs, "\n"))
229+ }
230+ if got := s.Result.Outputs["who"]; got != "runner:1001:1001" {
231+ t.Errorf("steps run as %q, want runner:1001:1001", got)
232+ }
233+ all := strings.Join(s.Logs, "\n")
234+ for _, want := range []string{
235+ "home=/home/runner pwd=/home/runner/work/hello/hello",
236+ "ws=/home/runner/work/hello/hello temp=/home/runner/work/_temp tool=/opt/hostedtoolcache",
237+ "from-path", "FOO=bar CI=true GITHUB_ACTIONS=true ERMITE_CONNECT=unset", "py 42",
238+ } {
239+ if !strings.Contains(all, want) {
240+ t.Errorf("log lacks %q\n%s", want, all)
241+ }
242+ }
243+ if !strings.HasPrefix(strings.Join(s.Steps, "|"), "Set up job|Run echo") || s.Steps[len(s.Steps)-1] != "Complete job" {
244+ t.Errorf("steps %v", s.Steps)
245+ }
246+}
247+
248+func TestContainerCancelStopsTheJobAndOrphans(t *testing.T) {
249+ image := needE2E(t)
250+ d := newDriver(t, image)
251+ cancel := make(chan *execpb.Cancel, 1)
252+ time.AfterFunc(4*time.Second, func() { cancel <- &execpb.Cancel{Reason: "test", Kind: execpb.CancelKind_CANCEL_KIND_USER} })
253+ start := time.Now()
254+ s := runJob(t, d, jobSpec(`on: push
255+jobs:
256+ j:
257+ runs-on: x
258+ steps:
259+ - run: |
260+ (sleep 300 &)
261+ trap 'echo got-int; exit 130' INT
262+ echo ready
263+ while :; do sleep 0.2; done
264+ - if: always()
265+ run: echo cleanup-ran
266+`), cancel)
267+ if s.Result == nil || s.Result.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_CANCELLED {
268+ t.Fatalf("result %v\n%s", s.Result, strings.Join(s.Logs, "\n"))
269+ }
270+ all := strings.Join(s.Logs, "\n")
271+ if !strings.Contains(all, "got-int") || !strings.Contains(all, "cleanup-ran") {
272+ t.Errorf("SIGINT or the always() step missing:\n%s", all)
273+ }
274+ // "Complete job" kills the background sleep that the step left behind.
275+ if !strings.Contains(all, "Terminate orphan process: pid (") || !strings.Contains(all, ") (sleep)") {
276+ t.Errorf("orphan cleanup missing:\n%s", all)
277+ }
278+ if time.Since(start) > 40*time.Second {
279+ t.Errorf("took %v", time.Since(start))
280+ }
281+}
@@ -0,0 +1,281 @@
1+// SPDX-License-Identifier: MIT
2+
3+// Package e2e runs jobs through the real executor in a real sandbox. The tests
4+// are opt-in because they need Docker (and, for the VM, KVM and Firecracker):
5+//
6+// ERMITE_RUNNER_IT_E2E=1 ERMITE_RUNNER_TEST_IMAGE=ermite-runner-ubuntu-24.04:dev go test ./internal/e2e
7+//
8+// On a workstation with a rootless Docker daemon the container driver is used
9+// with AllowRootless (a test-only option): the jobs run as in production except
10+// that the nested dockerd of the image cannot start, which these tests do not
11+// need. The production paths that need root (jailer, rootful Docker, sysbox)
12+// are not covered here.
13+package e2e
14+
15+import (
16+ "context"
17+ "fmt"
18+ "io"
19+ "os"
20+ "os/exec"
21+ "path/filepath"
22+ "strings"
23+ "sync"
24+ "testing"
25+ "time"
26+
27+ "bnema.dev/ermite-runner/internal/execstream"
28+ "bnema.dev/ermite-runner/internal/execstream/execpb"
29+ "bnema.dev/ermite-runner/internal/sandbox"
30+ "bnema.dev/ermite-runner/internal/sandbox/container"
31+ "bnema.dev/ermite-runner/internal/sandbox/disk"
32+ "bnema.dev/ermite-runner/internal/sandbox/sandboxtest"
33+)
34+
35+func needE2E(t *testing.T) string {
36+ t.Helper()
37+ if os.Getenv("ERMITE_RUNNER_IT_E2E") != "1" {
38+ t.Skip("set ERMITE_RUNNER_IT_E2E=1 and ERMITE_RUNNER_TEST_IMAGE (needs Docker; see the package comment)")
39+ }
40+ image := os.Getenv("ERMITE_RUNNER_TEST_IMAGE")
41+ if image == "" {
42+ t.Fatal("set ERMITE_RUNNER_TEST_IMAGE to the runner image")
43+ }
44+ if _, err := exec.LookPath("docker"); err != nil {
45+ t.Skip("docker CLI not found")
46+ }
47+ return image
48+}
49+
50+// buildRunner compiles the static runner binary that is attached to the sandbox.
51+func buildRunner(t *testing.T, dir string) string {
52+ t.Helper()
53+ exe := filepath.Join(dir, "ermite-runner")
54+ root, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").Output()
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ cmd := exec.Command("go", "build", "-o", exe, "bnema.dev/ermite-runner")
59+ cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
60+ cmd.Dir = strings.TrimSpace(string(root))
61+ if out, err := cmd.CombinedOutput(); err != nil {
62+ t.Fatalf("build the runner: %v\n%s", err, out)
63+ }
64+ return exe
65+}
66+
67+// newDriver starts the container driver with test-only settings: no firewall
68+// plumbing, no loop mounts, a rootless daemon allowed.
69+func newDriver(t *testing.T, image string) sandbox.Driver {
70+ t.Helper()
71+ dir := t.TempDir()
72+ cfg := sandbox.Config{
73+ Isolation: sandbox.Container, StateDir: filepath.Join(dir, "state"), Capacity: 2, Image: image,
74+ JobCPUs: 2, JobMemoryMiB: 1024, JobDiskMiB: 2048, UIDBase: 900000,
75+ Network: netPrefix("10.214.0.0/16"), Executable: buildRunner(t, dir), DNSPort: 0,
76+ LockFile: filepath.Join(dir, "host.lock"), JobPids: 4096,
77+ }
78+ empty := filepath.Join(dir, "mountinfo")
79+ _ = os.WriteFile(empty, nil, 0o644)
80+ run := rootlessMountRunner{image: image, mountinfo: empty, mu: &sync.Mutex{}}
81+ d, err := container.New(context.Background(), cfg, container.Options{
82+ Net: sandboxtest.NewPlumbing(), AllowRootless: true, ConnectTimeout: 90 * time.Second, Run: run,
83+ Mounter: &disk.Mounter{Run: run, Mountinfo: empty},
84+ })
85+ if err != nil {
86+ t.Fatal(err)
87+ }
88+ t.Cleanup(func() { _ = d.Close() })
89+ if err := d.Reap(context.Background()); err != nil {
90+ t.Fatal(err)
91+ }
92+ return d
93+}
94+
95+// rootlessMountRunner stands in for mount and umount, which need root. The
96+// scratch directory stays a plain directory; "mount" records it in a fake
97+// mountinfo file so that the driver believes it is mounted and calls "umount"
98+// at the end. On a rootless daemon the files that the runner user (uid 1001 in
99+// the container) creates belong to a subordinate uid that the host user cannot
100+// delete, so "umount" empties the directory from inside a container, where
101+// root can. With the real loop mount the scratch image is simply unmounted and
102+// deleted.
103+type rootlessMountRunner struct {
104+ image string
105+ mountinfo string
106+ mu *sync.Mutex
107+}
108+
109+func (r rootlessMountRunner) Run(ctx context.Context, in io.Reader, name string, args ...string) ([]byte, error) {
110+ switch name {
111+ case "mount":
112+ r.mu.Lock()
113+ defer r.mu.Unlock()
114+ f, err := os.OpenFile(r.mountinfo, os.O_APPEND|os.O_WRONLY, 0o644)
115+ if err != nil {
116+ return nil, err
117+ }
118+ defer func() { _ = f.Close() }()
119+ _, err = fmt.Fprintf(f, "1 1 0:1 / %s rw - ext4 fake rw\n", args[len(args)-1])
120+ return nil, err
121+ case "umount":
122+ dir := args[len(args)-1]
123+ out, err := exec.CommandContext(ctx, "docker", "run", "--rm", "--entrypoint", "sh", "-v", dir+":/d", r.image,
124+ "-c", "rm -rf /d/* /d/.[!.]* 2>/dev/null; true").CombinedOutput()
125+ r.mu.Lock()
126+ defer r.mu.Unlock()
127+ b, _ := os.ReadFile(r.mountinfo)
128+ var keep []string
129+ for _, l := range strings.Split(string(b), "\n") {
130+ if l != "" && !strings.Contains(l, " "+dir+" ") {
131+ keep = append(keep, l)
132+ }
133+ }
134+ _ = os.WriteFile(r.mountinfo, []byte(strings.Join(keep, "\n")+"\n"), 0o644)
135+ return out, err
136+ }
137+ return sandboxtest.NoMountRunner{}.Run(ctx, in, name, args...)
138+}
139+
140+// session is the outcome of one job driven through the stream.
141+type session struct {
142+ Steps []string
143+ Concl map[string]execpb.StepConclusion
144+ Logs []string // text of every log line, in order
145+ LogStep []int32
146+ Result *execpb.JobResult
147+ Exit *execpb.Exit
148+}
149+
150+func (s *session) log(step int32) []string {
151+ var out []string
152+ for i, l := range s.Logs {
153+ if s.LogStep[i] == step {
154+ out = append(out, l)
155+ }
156+ }
157+ return out
158+}
159+
160+// runJob starts a sandbox, drives the job and stops the sandbox.
161+func runJob(t *testing.T, d sandbox.Driver, spec *execpb.JobSpec, cancel <-chan *execpb.Cancel) *session {
162+ t.Helper()
163+ sb, err := d.New()
164+ if err != nil {
165+ t.Fatal(err)
166+ }
167+ defer func() {
168+ if err := sb.Stop(context.Background()); err != nil {
169+ t.Errorf("stopping the sandbox: %v", err)
170+ }
171+ }()
172+ conn, err := sb.Start(context.Background(), sandbox.JobSpec{JobID: "e2e"})
173+ if err != nil {
174+ t.Fatal(err)
175+ }
176+ s := &session{Concl: map[string]execpb.StepConclusion{}}
177+ _, exit, err := execstream.DriveJob(conn, &execpb.Run{Job: spec}, cancel, execstream.JobHandler{
178+ StepStarted: func(x *execpb.StepStarted) { s.Steps = append(s.Steps, x.Name) },
179+ StepCompleted: func(x *execpb.StepCompleted) {
180+ s.Concl[x.Name] = x.Conclusion
181+ },
182+ Log: func(l *execpb.Log) { s.Logs = append(s.Logs, l.Text); s.LogStep = append(s.LogStep, l.Step) },
183+ JobResult: func(r *execpb.JobResult) { s.Result = r },
184+ })
185+ if err != nil {
186+ t.Fatalf("driving the job: %v", err)
187+ }
188+ s.Exit = exit
189+ return s
190+}
191+
192+func jobSpec(yaml string) *execpb.JobSpec {
193+ return &execpb.JobSpec{
194+ WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello",
195+ Contexts: `{"github":{"repository":"octo/hello","repository_owner":"octo","event_name":"push","job":"j","ref":"refs/heads/main","ref_name":"main","ref_type":"branch","sha":"0123456789abcdef0123456789abcdef01234567","run_id":"1","run_number":"1","run_attempt":"1","server_url":"https://ermite.example","workflow":"w","actor":"octo","event":{"ref":"refs/heads/main"}}}`,
196+ Matrix: "{}", RunnerName: "e2e", RunnerVersion: "test", RunnerEnvironment: "self-hosted", TimeoutMinutes: 10,
197+ }
198+}
199+
200+func TestContainerRunsAJobAsTheRunnerUser(t *testing.T) {
201+ image := needE2E(t)
202+ d := newDriver(t, image)
203+ s := runJob(t, d, jobSpec(`on: push
204+jobs:
205+ j:
206+ runs-on: x
207+ outputs:
208+ who: ${{ steps.id.outputs.who }}
209+ steps:
210+ - id: id
211+ run: |
212+ echo "who=$(id -un):$(id -u):$(id -g)" >> "$GITHUB_OUTPUT"
213+ echo "home=$HOME pwd=$PWD"
214+ echo "ws=$GITHUB_WORKSPACE temp=$RUNNER_TEMP tool=$RUNNER_TOOL_CACHE"
215+ touch "$RUNNER_TEMP/t" "$GITHUB_WORKSPACE/w" "$HOME/h"
216+ echo "script=$0"
217+ - name: env and path
218+ run: |
219+ mkdir -p "$RUNNER_TEMP/bin" && printf '#!/bin/sh\necho from-path\n' > "$RUNNER_TEMP/bin/mytool" && chmod +x "$RUNNER_TEMP/bin/mytool"
220+ echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
221+ echo "FOO=bar" >> "$GITHUB_ENV"
222+ - run: mytool; echo "FOO=$FOO CI=$CI GITHUB_ACTIONS=$GITHUB_ACTIONS ERMITE_CONNECT=${ERMITE_CONNECT-unset}"
223+ - name: python
224+ shell: python3 {0}
225+ run: print("py", 6*7)
226+`), nil)
227+ if s.Result == nil || s.Result.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS || s.Exit.Code != 0 {
228+ t.Fatalf("result %v exit %v\n%s", s.Result, s.Exit, strings.Join(s.Logs, "\n"))
229+ }
230+ if got := s.Result.Outputs["who"]; got != "runner:1001:1001" {
231+ t.Errorf("steps run as %q, want runner:1001:1001", got)
232+ }
233+ all := strings.Join(s.Logs, "\n")
234+ for _, want := range []string{
235+ "home=/home/runner pwd=/home/runner/work/hello/hello",
236+ "ws=/home/runner/work/hello/hello temp=/home/runner/work/_temp tool=/opt/hostedtoolcache",
237+ "from-path", "FOO=bar CI=true GITHUB_ACTIONS=true ERMITE_CONNECT=unset", "py 42",
238+ } {
239+ if !strings.Contains(all, want) {
240+ t.Errorf("log lacks %q\n%s", want, all)
241+ }
242+ }
243+ if !strings.HasPrefix(strings.Join(s.Steps, "|"), "Set up job|Run echo") || s.Steps[len(s.Steps)-1] != "Complete job" {
244+ t.Errorf("steps %v", s.Steps)
245+ }
246+}
247+
248+func TestContainerCancelStopsTheJobAndOrphans(t *testing.T) {
249+ image := needE2E(t)
250+ d := newDriver(t, image)
251+ cancel := make(chan *execpb.Cancel, 1)
252+ time.AfterFunc(4*time.Second, func() { cancel <- &execpb.Cancel{Reason: "test", Kind: execpb.CancelKind_CANCEL_KIND_USER} })
253+ start := time.Now()
254+ s := runJob(t, d, jobSpec(`on: push
255+jobs:
256+ j:
257+ runs-on: x
258+ steps:
259+ - run: |
260+ (sleep 300 &)
261+ trap 'echo got-int; exit 130' INT
262+ echo ready
263+ while :; do sleep 0.2; done
264+ - if: always()
265+ run: echo cleanup-ran
266+`), cancel)
267+ if s.Result == nil || s.Result.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_CANCELLED {
268+ t.Fatalf("result %v\n%s", s.Result, strings.Join(s.Logs, "\n"))
269+ }
270+ all := strings.Join(s.Logs, "\n")
271+ if !strings.Contains(all, "got-int") || !strings.Contains(all, "cleanup-ran") {
272+ t.Errorf("SIGINT or the always() step missing:\n%s", all)
273+ }
274+ // "Complete job" kills the background sleep that the step left behind.
275+ if !strings.Contains(all, "Terminate orphan process: pid (") || !strings.Contains(all, ") (sleep)") {
276+ t.Errorf("orphan cleanup missing:\n%s", all)
277+ }
278+ if time.Since(start) > 40*time.Second {
279+ t.Errorf("took %v", time.Since(start))
280+ }
281+}
A internal/e2e/util_test.go +7 -0
@@ -0,0 +1,7 @@
1+// SPDX-License-Identifier: MIT
2+
3+package e2e
4+
5+import "net/netip"
6+
7+func netPrefix(s string) netip.Prefix { return netip.MustParsePrefix(s) }
@@ -0,0 +1,7 @@
1+// SPDX-License-Identifier: MIT
2+
3+package e2e
4+
5+import "net/netip"
6+
7+func netPrefix(s string) netip.Prefix { return netip.MustParsePrefix(s) }
M internal/sandbox/container/docker_api_test.go +1 -1
@@ -62,7 +62,7 @@ func TestDockerAPIWiring(t *testing.T) {
6262 run := noMountRunner{}
6363 empty := filepath.Join(dir, "mountinfo")
6464 _ = os.WriteFile(empty, nil, 0o644)
65- d, err := New(ctx, cfg, Options{Net: plumb, allowRootless: true, ConnectTimeout: 60 * time.Second, Run: run,
65+ d, err := New(ctx, cfg, Options{Net: plumb, AllowRootless: true, ConnectTimeout: 60 * time.Second, Run: run,
6666 Mounter: &disk.Mounter{Run: run, Mountinfo: empty}})
6767 if err != nil {
6868 t.Fatal(err)
@@ -62,7 +62,7 @@ func TestDockerAPIWiring(t *testing.T) {
62 run := noMountRunner{}62 run := noMountRunner{}
63 empty := filepath.Join(dir, "mountinfo")63 empty := filepath.Join(dir, "mountinfo")
64 _ = os.WriteFile(empty, nil, 0o644)64 _ = os.WriteFile(empty, nil, 0o644)
65- d, err := New(ctx, cfg, Options{Net: plumb, allowRootless: true, ConnectTimeout: 60 * time.Second, Run: run,65+ d, err := New(ctx, cfg, Options{Net: plumb, AllowRootless: true, ConnectTimeout: 60 * time.Second, Run: run,
66 Mounter: &disk.Mounter{Run: run, Mountinfo: empty}})66 Mounter: &disk.Mounter{Run: run, Mountinfo: empty}})
67 if err != nil {67 if err != nil {
68 t.Fatal(err)68 t.Fatal(err)
M internal/sandbox/container/driver.go +6 -5
@@ -53,10 +53,11 @@ type Options struct {
5353 Net netsetup.Plumbing
5454 // engine overrides the Docker client (tests).
5555 engine engine
56- // allowRootless lets tests exercise the Engine API against the rootless
57- // daemon of a workstation. Never set outside tests: a nested dockerd cannot
58- // work in a rootless daemon.
59- allowRootless bool
56+ // AllowRootless lets tests exercise the Engine API and the executor against
57+ // the rootless daemon of a workstation. Never set outside tests: a nested
58+ // dockerd cannot work in a rootless daemon, so the jobs would run without
59+ // Docker. The factories of package drivers never set it.
60+ AllowRootless bool
6061 // ConnectTimeout bounds container start + init + executor connection
6162 // (default 90 s).
6263 ConnectTimeout time.Duration
@@ -133,7 +134,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
133134 if err != nil {
134135 return fail(fmt.Errorf("cannot reach the Docker daemon (DOCKER_HOST): %w", err))
135136 }
136- if info.Rootless && !opt.allowRootless {
137+ if info.Rootless && !opt.AllowRootless {
137138 return fail(errRootless)
138139 }
139140 if info.OSType != "" && info.OSType != "linux" {
@@ -53,10 +53,11 @@ type Options struct {
53 Net netsetup.Plumbing53 Net netsetup.Plumbing
54 // engine overrides the Docker client (tests).54 // engine overrides the Docker client (tests).
55 engine engine55 engine engine
56- // allowRootless lets tests exercise the Engine API against the rootless56+ // AllowRootless lets tests exercise the Engine API and the executor against
57- // daemon of a workstation. Never set outside tests: a nested dockerd cannot57+ // the rootless daemon of a workstation. Never set outside tests: a nested
58- // work in a rootless daemon.58+ // dockerd cannot work in a rootless daemon, so the jobs would run without
59- allowRootless bool59+ // Docker. The factories of package drivers never set it.
60+ AllowRootless bool
60 // ConnectTimeout bounds container start + init + executor connection61 // ConnectTimeout bounds container start + init + executor connection
61 // (default 90 s).62 // (default 90 s).
62 ConnectTimeout time.Duration63 ConnectTimeout time.Duration
@@ -133,7 +134,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error)
133 if err != nil {134 if err != nil {
134 return fail(fmt.Errorf("cannot reach the Docker daemon (DOCKER_HOST): %w", err))135 return fail(fmt.Errorf("cannot reach the Docker daemon (DOCKER_HOST): %w", err))
135 }136 }
136- if info.Rootless && !opt.allowRootless {137+ if info.Rootless && !opt.AllowRootless {
137 return fail(errRootless)138 return fail(errRootless)
138 }139 }
139 if info.OSType != "" && info.OSType != "linux" {140 if info.OSType != "" && info.OSType != "linux" {
M internal/sandbox/sandboxtest/sandboxtest.go +15 -0
@@ -6,14 +6,29 @@ package sandboxtest
66 import (
77 "context"
88 "encoding/binary"
9+ "io"
910 "net/netip"
1011 "os"
1112 "sync"
1213 "testing"
1314
15+ "bnema.dev/ermite-runner/internal/cmdrun"
1416 "bnema.dev/ermite-runner/internal/netpolicy"
1517 )
1618
19+// NoMountRunner runs real commands except mount and umount, which succeed
20+// without doing anything: loop mounts need root, and the tests that use it
21+// replace them by plain directories (mkfs still runs).
22+type NoMountRunner struct{}
23+
24+// Run implements cmdrun.Runner.
25+func (NoMountRunner) Run(ctx context.Context, in io.Reader, name string, args ...string) ([]byte, error) {
26+ if name == "mount" || name == "umount" {
27+ return nil, nil
28+ }
29+ return cmdrun.Exec{}.Run(ctx, in, name, args...)
30+}
31+
1732 // WriteStaticELF writes a minimal statically linked ELF64 file (one PT_LOAD, no
1833 // PT_INTERP): enough for sandbox.CheckStatic, never executed.
1934 func WriteStaticELF(t testing.TB, path string) {
@@ -6,14 +6,29 @@ package sandboxtest
6 import (6 import (
7 "context"7 "context"
8 "encoding/binary"8 "encoding/binary"
9+ "io"
9 "net/netip"10 "net/netip"
10 "os"11 "os"
11 "sync"12 "sync"
12 "testing"13 "testing"
13 14
15+ "bnema.dev/ermite-runner/internal/cmdrun"
14 "bnema.dev/ermite-runner/internal/netpolicy"16 "bnema.dev/ermite-runner/internal/netpolicy"
15 )17 )
16 18
19+// NoMountRunner runs real commands except mount and umount, which succeed
20+// without doing anything: loop mounts need root, and the tests that use it
21+// replace them by plain directories (mkfs still runs).
22+type NoMountRunner struct{}
23+
24+// Run implements cmdrun.Runner.
25+func (NoMountRunner) Run(ctx context.Context, in io.Reader, name string, args ...string) ([]byte, error) {
26+ if name == "mount" || name == "umount" {
27+ return nil, nil
28+ }
29+ return cmdrun.Exec{}.Run(ctx, in, name, args...)
30+}
31+
17 // WriteStaticELF writes a minimal statically linked ELF64 file (one PT_LOAD, no32 // WriteStaticELF writes a minimal statically linked ELF64 file (one PT_LOAD, no
18 // PT_INTERP): enough for sandbox.CheckStatic, never executed.33 // PT_INTERP): enough for sandbox.CheckStatic, never executed.
19 func WriteStaticELF(t testing.TB, path string) {34 func WriteStaticELF(t testing.TB, path string) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close