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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 62 | 62 | run := noMountRunner{} | |
| 63 | 63 | empty := filepath.Join(dir, "mountinfo") | |
| 64 | 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 | 66 | Mounter: &disk.Mounter{Run: run, Mountinfo: empty}}) | |
| 67 | 67 | if err != nil { | |
| 68 | 68 | 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 { | |||
| 53 | 53 | Net netsetup.Plumbing | |
| 54 | 54 | // engine overrides the Docker client (tests). | |
| 55 | 55 | 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 | |
| 60 | 61 | // ConnectTimeout bounds container start + init + executor connection | |
| 61 | 62 | // (default 90 s). | |
| 62 | 63 | ConnectTimeout time.Duration | |
| @@ -133,7 +134,7 @@ func New(ctx context.Context, cfg sandbox.Config, opt Options) (*Driver, error) | |||
| 133 | 134 | if err != nil { | |
| 134 | 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 | 138 | return fail(errRootless) | |
| 138 | 139 | } | |
| 139 | 140 | if info.OSType != "" && info.OSType != "linux" { | |
| @@ -53,10 +53,11 @@ type Options struct { | |||||
| 53 | Net netsetup.Plumbing | 53 | Net netsetup.Plumbing | ||
| 54 | // engine overrides the Docker client (tests). | 54 | // engine overrides the Docker client (tests). | ||
| 55 | engine engine | 55 | engine engine | ||
| 56 | - | // allowRootless lets tests exercise the Engine API against the rootless | 56 | + | // AllowRootless lets tests exercise the Engine API and the executor against |
| 57 | - | // daemon of a workstation. Never set outside tests: a nested dockerd cannot | 57 | + | // 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 bool | 59 | + | // Docker. The factories of package drivers never set it. |
| 60 | + | AllowRootless bool | |||
| 60 | // ConnectTimeout bounds container start + init + executor connection | 61 | // ConnectTimeout bounds container start + init + executor connection | ||
| 61 | // (default 90 s). | 62 | // (default 90 s). | ||
| 62 | ConnectTimeout time.Duration | 63 | 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 | |||
| 6 | 6 | import ( | |
| 7 | 7 | "context" | |
| 8 | 8 | "encoding/binary" | |
| 9 | + | "io" | |
| 9 | 10 | "net/netip" | |
| 10 | 11 | "os" | |
| 11 | 12 | "sync" | |
| 12 | 13 | "testing" | |
| 13 | 14 | ||
| 15 | + | "bnema.dev/ermite-runner/internal/cmdrun" | |
| 14 | 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 | 32 | // WriteStaticELF writes a minimal statically linked ELF64 file (one PT_LOAD, no | |
| 18 | 33 | // PT_INTERP): enough for sandbox.CheckStatic, never executed. | |
| 19 | 34 | 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, no | 32 | // 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) { | ||