detached 35d0c10

test(integration): run a job through the executor in a micro-VM

commit
35d0c10d203644a44ea185932f8183b5321150dd
author
brice · · 2026-10-12 18:51 UTC
parent
1e4636c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +107 -10
M internal/e2e/e2e_test.go +0 -10
@@ -147,16 +147,6 @@ type session struct {
147147 Exit *execpb.Exit
148148 }
149149
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-
160150 // runJob starts a sandbox, drives the job and stops the sandbox.
161151 func runJob(t *testing.T, d sandbox.Driver, spec *execpb.JobSpec, cancel <-chan *execpb.Cancel) *session {
162152 t.Helper()
@@ -147,16 +147,6 @@ type session struct {
147 Exit *execpb.Exit147 Exit *execpb.Exit
148 }148 }
149 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.150 // 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 {151 func runJob(t *testing.T, d sandbox.Driver, spec *execpb.JobSpec, cancel <-chan *execpb.Cancel) *session {
162 t.Helper()152 t.Helper()
M internal/sandbox/integration/vm_test.go +107 -0
@@ -8,7 +8,10 @@ import (
88 "strings"
99 "syscall"
1010 "testing"
11+ "time"
1112
13+ "bnema.dev/ermite-runner/internal/execstream"
14+ "bnema.dev/ermite-runner/internal/execstream/execpb"
1215 "bnema.dev/ermite-runner/internal/sandbox"
1316 "bnema.dev/ermite-runner/internal/sandbox/drivers"
1417 )
@@ -101,3 +104,107 @@ ip -6 addr show scope global | grep -c inet6 || true
101104 t.Errorf("the DNS forwarder on the gateway must answer:\n%s", r.out)
102105 }
103106 }
107+
108+// TestVMRunsAJob drives a job (not the stub script) through the executor in a
109+// micro-VM: the runner user, the work layout, file commands, outputs, a failing
110+// step and cancellation by SIGINT. The VM is rootful-free like the other VM
111+// tests (user + network namespace, jailer off).
112+func TestVMRunsAJob(t *testing.T) {
113+ if os.Getenv("ERMITE_RUNNER_IT_VM") != "1" {
114+ t.Skip("set ERMITE_RUNNER_IT_VM=1 (needs KVM, firecracker, a kernel and a base image; see README)")
115+ }
116+ need(t, "mkfs.ext4")
117+ needNetAdmin(t)
118+ ctx := ctxWithLog(t)
119+ cfg := loadConfig(t, sandbox.VM)
120+ if why := sandbox.VMUnavailable(cfg, sandbox.Prober{}); len(why) > 0 {
121+ t.Fatalf("vm isolation is not available here: %s", strings.Join(why, "; "))
122+ }
123+ drv, err := sandbox.Select(ctx, cfg, drivers.Factories(), sandbox.Prober{})
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ t.Cleanup(func() {
128+ _ = drv.Close()
129+ if d2, err := sandbox.Select(context.Background(), cfg, drivers.Factories(), sandbox.Prober{}); err == nil {
130+ _ = d2.Reap(context.Background())
131+ _ = d2.Close()
132+ }
133+ })
134+ if err := drv.Reap(ctx); err != nil {
135+ t.Fatal(err)
136+ }
137+
138+ run := func(yaml string, cancelAfter time.Duration) (steps []string, logs []string, res *execpb.JobResult, exit *execpb.Exit) {
139+ t.Helper()
140+ sb, err := drv.New()
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ defer func() {
145+ if err := sb.Stop(ctx); err != nil {
146+ t.Errorf("stop: %v", err)
147+ }
148+ }()
149+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "vm-job"})
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ var cancel chan *execpb.Cancel
154+ if cancelAfter > 0 {
155+ cancel = make(chan *execpb.Cancel, 1)
156+ time.AfterFunc(cancelAfter, func() { cancel <- &execpb.Cancel{Reason: "test"} })
157+ }
158+ _, exit, err = execstream.DriveJob(conn, &execpb.Run{Job: &execpb.JobSpec{
159+ WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "it", RunnerVersion: "test",
160+ RunnerEnvironment: "self-hosted", Matrix: "{}",
161+ Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j","ref":"refs/heads/main","sha":"0123456789abcdef0123456789abcdef01234567"}}`,
162+ }}, cancel, execstream.JobHandler{
163+ StepStarted: func(s *execpb.StepStarted) { steps = append(steps, s.Name) },
164+ Log: func(l *execpb.Log) { logs = append(logs, l.Text) },
165+ JobResult: func(r *execpb.JobResult) { res = r },
166+ })
167+ if err != nil {
168+ t.Fatalf("driving the job: %v", err)
169+ }
170+ return
171+ }
172+
173+ _, logs, res, exit := run(`on: push
174+jobs:
175+ j:
176+ runs-on: x
177+ outputs:
178+ who: ${{ steps.a.outputs.who }}
179+ steps:
180+ - id: a
181+ run: |
182+ echo "who=$(id -un):$(id -u)" >> "$GITHUB_OUTPUT"
183+ echo "pwd=$PWD home=$HOME"
184+ echo "FOO=bar" >> "$GITHUB_ENV"
185+ - run: echo "FOO=$FOO CI=$CI"
186+ - run: exit 3
187+ - if: always()
188+ run: echo still-here
189+`, 0)
190+ all := strings.Join(logs, "\n")
191+ if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_FAILURE || exit.Code == 0 {
192+ t.Fatalf("result %v exit %v\n%s", res, exit, all)
193+ }
194+ if res.Outputs["who"] != "runner:1001" {
195+ t.Errorf("steps ran as %q\n%s", res.Outputs["who"], all)
196+ }
197+ for _, w := range []string{"pwd=/home/runner/work/hello/hello home=/home/runner", "FOO=bar CI=true", "##[error]Process completed with exit code 3.", "still-here"} {
198+ if !strings.Contains(all, w) {
199+ t.Errorf("log lacks %q\n%s", w, all)
200+ }
201+ }
202+
203+ start := time.Now()
204+ _, logs, res, _ = run("on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: |\n trap 'echo got-int; exit 130' INT\n echo ready\n while :; do sleep 0.2; done\n", 8*time.Second)
205+ all = strings.Join(logs, "\n")
206+ if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_CANCELLED || !strings.Contains(all, "got-int") {
207+ t.Fatalf("cancel: %v\n%s", res, all)
208+ }
209+ t.Logf("cancelled job ended %v after the start", time.Since(start))
210+}
@@ -8,7 +8,10 @@ import (
8 "strings"8 "strings"
9 "syscall"9 "syscall"
10 "testing"10 "testing"
11+ "time"
11 12
13+ "bnema.dev/ermite-runner/internal/execstream"
14+ "bnema.dev/ermite-runner/internal/execstream/execpb"
12 "bnema.dev/ermite-runner/internal/sandbox"15 "bnema.dev/ermite-runner/internal/sandbox"
13 "bnema.dev/ermite-runner/internal/sandbox/drivers"16 "bnema.dev/ermite-runner/internal/sandbox/drivers"
14 )17 )
@@ -101,3 +104,107 @@ ip -6 addr show scope global | grep -c inet6 || true
101 t.Errorf("the DNS forwarder on the gateway must answer:\n%s", r.out)104 t.Errorf("the DNS forwarder on the gateway must answer:\n%s", r.out)
102 }105 }
103 }106 }
107+
108+// TestVMRunsAJob drives a job (not the stub script) through the executor in a
109+// micro-VM: the runner user, the work layout, file commands, outputs, a failing
110+// step and cancellation by SIGINT. The VM is rootful-free like the other VM
111+// tests (user + network namespace, jailer off).
112+func TestVMRunsAJob(t *testing.T) {
113+ if os.Getenv("ERMITE_RUNNER_IT_VM") != "1" {
114+ t.Skip("set ERMITE_RUNNER_IT_VM=1 (needs KVM, firecracker, a kernel and a base image; see README)")
115+ }
116+ need(t, "mkfs.ext4")
117+ needNetAdmin(t)
118+ ctx := ctxWithLog(t)
119+ cfg := loadConfig(t, sandbox.VM)
120+ if why := sandbox.VMUnavailable(cfg, sandbox.Prober{}); len(why) > 0 {
121+ t.Fatalf("vm isolation is not available here: %s", strings.Join(why, "; "))
122+ }
123+ drv, err := sandbox.Select(ctx, cfg, drivers.Factories(), sandbox.Prober{})
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ t.Cleanup(func() {
128+ _ = drv.Close()
129+ if d2, err := sandbox.Select(context.Background(), cfg, drivers.Factories(), sandbox.Prober{}); err == nil {
130+ _ = d2.Reap(context.Background())
131+ _ = d2.Close()
132+ }
133+ })
134+ if err := drv.Reap(ctx); err != nil {
135+ t.Fatal(err)
136+ }
137+
138+ run := func(yaml string, cancelAfter time.Duration) (steps []string, logs []string, res *execpb.JobResult, exit *execpb.Exit) {
139+ t.Helper()
140+ sb, err := drv.New()
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ defer func() {
145+ if err := sb.Stop(ctx); err != nil {
146+ t.Errorf("stop: %v", err)
147+ }
148+ }()
149+ conn, err := sb.Start(ctx, sandbox.JobSpec{JobID: "vm-job"})
150+ if err != nil {
151+ t.Fatal(err)
152+ }
153+ var cancel chan *execpb.Cancel
154+ if cancelAfter > 0 {
155+ cancel = make(chan *execpb.Cancel, 1)
156+ time.AfterFunc(cancelAfter, func() { cancel <- &execpb.Cancel{Reason: "test"} })
157+ }
158+ _, exit, err = execstream.DriveJob(conn, &execpb.Run{Job: &execpb.JobSpec{
159+ WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "it", RunnerVersion: "test",
160+ RunnerEnvironment: "self-hosted", Matrix: "{}",
161+ Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j","ref":"refs/heads/main","sha":"0123456789abcdef0123456789abcdef01234567"}}`,
162+ }}, cancel, execstream.JobHandler{
163+ StepStarted: func(s *execpb.StepStarted) { steps = append(steps, s.Name) },
164+ Log: func(l *execpb.Log) { logs = append(logs, l.Text) },
165+ JobResult: func(r *execpb.JobResult) { res = r },
166+ })
167+ if err != nil {
168+ t.Fatalf("driving the job: %v", err)
169+ }
170+ return
171+ }
172+
173+ _, logs, res, exit := run(`on: push
174+jobs:
175+ j:
176+ runs-on: x
177+ outputs:
178+ who: ${{ steps.a.outputs.who }}
179+ steps:
180+ - id: a
181+ run: |
182+ echo "who=$(id -un):$(id -u)" >> "$GITHUB_OUTPUT"
183+ echo "pwd=$PWD home=$HOME"
184+ echo "FOO=bar" >> "$GITHUB_ENV"
185+ - run: echo "FOO=$FOO CI=$CI"
186+ - run: exit 3
187+ - if: always()
188+ run: echo still-here
189+`, 0)
190+ all := strings.Join(logs, "\n")
191+ if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_FAILURE || exit.Code == 0 {
192+ t.Fatalf("result %v exit %v\n%s", res, exit, all)
193+ }
194+ if res.Outputs["who"] != "runner:1001" {
195+ t.Errorf("steps ran as %q\n%s", res.Outputs["who"], all)
196+ }
197+ for _, w := range []string{"pwd=/home/runner/work/hello/hello home=/home/runner", "FOO=bar CI=true", "##[error]Process completed with exit code 3.", "still-here"} {
198+ if !strings.Contains(all, w) {
199+ t.Errorf("log lacks %q\n%s", w, all)
200+ }
201+ }
202+
203+ start := time.Now()
204+ _, logs, res, _ = run("on: push\njobs:\n j:\n runs-on: x\n steps:\n - run: |\n trap 'echo got-int; exit 130' INT\n echo ready\n while :; do sleep 0.2; done\n", 8*time.Second)
205+ all = strings.Join(logs, "\n")
206+ if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_CANCELLED || !strings.Contains(all, "got-int") {
207+ t.Fatalf("cancel: %v\n%s", res, all)
208+ }
209+ t.Logf("cancelled job ended %v after the start", time.Since(start))
210+}

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