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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 147 | 147 | 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 | 150 | // runJob starts a sandbox, drives the job and stops the sandbox. | |
| 161 | 151 | func runJob(t *testing.T, d sandbox.Driver, spec *execpb.JobSpec, cancel <-chan *execpb.Cancel) *session { | |
| 162 | 152 | t.Helper() | |
| @@ -147,16 +147,6 @@ type session struct { | |||||
| 147 | Exit *execpb.Exit | 147 | 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 ( | |||
| 8 | 8 | "strings" | |
| 9 | 9 | "syscall" | |
| 10 | 10 | "testing" | |
| 11 | + | "time" | |
| 11 | 12 | ||
| 13 | + | "bnema.dev/ermite-runner/internal/execstream" | |
| 14 | + | "bnema.dev/ermite-runner/internal/execstream/execpb" | |
| 12 | 15 | "bnema.dev/ermite-runner/internal/sandbox" | |
| 13 | 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 | 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 | + | } | |
| @@ -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 | + | } | |||