1// SPDX-License-Identifier: MIT
2
3package guest
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "net"
10 "net/http"
11 "os"
12 "path/filepath"
13 "reflect"
14 "strconv"
15 "strings"
16 "sync"
17 "sync/atomic"
18 "syscall"
19 "testing"
20 "time"
21
22 "github.com/bnema/zerowrap"
23
24 "bnema.dev/ermite-runner/internal/execstream"
25 "bnema.dev/ermite-runner/internal/execstream/execpb"
26)
27
28func TestParseCmdline(t *testing.T) {
29 got := ParseCmdline("console=ttyS0 ro ermite.connect=vsock:1024 x=\"a b\" ermite.gw=10.0.0.1 ermite.gw=10.0.0.9 -- init\n")
30 want := map[string]string{"console": "ttyS0", "ro": "", "ermite.connect": "vsock:1024", "x": "a b", "ermite.gw": "10.0.0.9", "--": "", "init": ""}
31 if !reflect.DeepEqual(got, want) {
32 t.Fatalf("got %v", got)
33 }
34 if len(ParseCmdline("")) != 0 {
35 t.Fatal("empty command line")
36 }
37}
38
39func TestParseTarget(t *testing.T) {
40 for in, want := range map[string]Target{
41 "unix:/run/ermite/exec.sock": {"unix", "/run/ermite/exec.sock"},
42 "vsock:1024": {"vsock", "1024"},
43 } {
44 got, err := ParseTarget(in)
45 if err != nil || got != want || got.String() != in {
46 t.Errorf("%q: %v %v", in, got, err)
47 }
48 }
49 for _, in := range []string{"", "unix", "unix:", "vsock:0", "vsock:abc", "tcp:1.2.3.4:5", "vsock:99999999999"} {
50 if _, err := ParseTarget(in); err == nil {
51 t.Errorf("%q accepted", in)
52 }
53 }
54}
55
56// host plays the host process on a unix socket and runs one session.
57func host(t *testing.T, script string, cancelAfter time.Duration, tweak func(*ExecConfig)) (out, errOut string, exit *execpb.Exit, hello *execpb.Hello, code int, execErr error) {
58 t.Helper()
59 sock := filepath.Join(t.TempDir(), "s")
60 ln, err := net.Listen("unix", sock)
61 if err != nil {
62 t.Skip(err)
63 }
64 defer func() { _ = ln.Close() }()
65 type res struct {
66 code int
67 err error
68 }
69 done := make(chan res, 1)
70 go func() {
71 cfg := ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", Shell: "/bin/sh", RunTimeout: 5 * time.Second}
72 if tweak != nil {
73 tweak(&cfg)
74 }
75 c, err := Exec(context.Background(), cfg)
76 done <- res{c, err}
77 }()
78 conn, err := ln.Accept()
79 if err != nil {
80 t.Fatal(err)
81 }
82 defer func() { _ = conn.Close() }()
83 var cancel chan *execpb.Cancel
84 if cancelAfter > 0 {
85 cancel = make(chan *execpb.Cancel, 1)
86 time.AfterFunc(cancelAfter, func() { cancel <- &execpb.Cancel{Reason: "test"} })
87 }
88 var so, se strings.Builder
89 hello, exit, err = execstream.Drive(conn, &execpb.Run{Script: script, Env: map[string]string{"STUB_VAR": "from-host"}}, cancel, func(o *execpb.Output) {
90 if o.Stream == execpb.StreamKind_STREAM_KIND_STDERR {
91 se.Write(o.Data)
92 } else {
93 so.Write(o.Data)
94 }
95 })
96 if err != nil {
97 t.Fatal(err)
98 }
99 r := <-done
100 return so.String(), se.String(), exit, hello, r.code, r.err
101}
102
103func TestExecStreamsOutputAndExitCode(t *testing.T) {
104 out, errOut, exit, hello, code, err := host(t, `echo out-line; echo err-line >&2; echo "$STUB_VAR"; exit 3`, 0, nil)
105 if err != nil {
106 t.Fatal(err)
107 }
108 if out != "out-line\nfrom-host\n" || errOut != "err-line\n" {
109 t.Fatalf("out=%q err=%q", out, errOut)
110 }
111 if exit.Code != 3 || code != 3 || exit.Error != "" {
112 t.Fatalf("exit=%v code=%d", exit, code)
113 }
114 if hello.Sandbox != "container" || hello.Version != "test" || hello.Hostname == "" {
115 t.Fatalf("hello=%v", hello)
116 }
117}
118
119func TestExecDefaultScriptRunsDiagnostics(t *testing.T) {
120 // The default script is the CI-1 stub: uname, id, os-release, docker info.
121 // docker may be absent here: only the layout of the output is checked.
122 out, errOut, exit, _, _, _ := host(t, "", 0, nil)
123 if !strings.Contains(out, "Linux") || !strings.Contains(out, "uid=") {
124 t.Fatalf("uname/id missing: out=%q err=%q", out, errOut)
125 }
126 if exit == nil {
127 t.Fatal("no exit frame")
128 }
129 if !strings.Contains(DefaultScript, "docker info") || !strings.Contains(DefaultScript, "os-release") {
130 t.Fatal("default script must run what the plan says")
131 }
132}
133
134func TestExecLargeOutputIsChunkedAndComplete(t *testing.T) {
135 out, _, exit, _, _, _ := host(t, `head -c 300000 /dev/zero | tr '\0' 'x'`, 0, nil)
136 if len(out) != 300000 || exit.Code != 0 {
137 t.Fatalf("len=%d exit=%v", len(out), exit)
138 }
139}
140
141func TestExecCancelSendsSIGINT(t *testing.T) {
142 start := time.Now()
143 out, _, exit, _, _, _ := host(t, `trap 'echo got-int; exit 130' INT; echo ready; while :; do sleep 0.05; done`, 500*time.Millisecond, nil)
144 if !strings.Contains(out, "got-int") || exit.Code != 130 {
145 t.Fatalf("out=%q exit=%v", out, exit)
146 }
147 if time.Since(start) > 5*time.Second {
148 t.Fatal("cancellation too slow")
149 }
150}
151
152// sigRecorder replaces the signal function: it records (signal, time since
153// start) and still delivers the signal.
154type sigRecorder struct {
155 mu sync.Mutex
156 start time.Time
157 got []string
158 times []time.Duration
159}
160
161func (r *sigRecorder) fn(pgid int, sig syscall.Signal) {
162 r.mu.Lock()
163 r.got = append(r.got, sig.String())
164 r.times = append(r.times, time.Since(r.start))
165 r.mu.Unlock()
166 _ = syscall.Kill(-pgid, sig)
167}
168
169// C1: a script that ignores SIGINT and SIGTERM is stopped by the escalation:
170// SIGINT at once, SIGTERM after TermAfter, SIGKILL KillAfter later.
171func TestExecCancelEscalatesToKill(t *testing.T) {
172 rec := &sigRecorder{start: time.Now()}
173 termAfter, killAfter := 400*time.Millisecond, 300*time.Millisecond
174 _, _, exit, _, _, _ := host(t, `trap '' INT TERM; echo ready; while :; do sleep 0.05; done`, 300*time.Millisecond, func(c *ExecConfig) {
175 c.TermAfter, c.KillAfter, c.signal = termAfter, killAfter, rec.fn
176 })
177 if exit.Code != 128+int32(syscall.SIGKILL) {
178 t.Fatalf("a script ignoring INT and TERM must end by SIGKILL, exit=%v", exit)
179 }
180 rec.mu.Lock()
181 defer rec.mu.Unlock()
182 // SIGINT, SIGTERM, SIGKILL (from the escalation), then the final group kill
183 // issued after the shell was reaped.
184 if len(rec.got) < 3 || rec.got[0] != "interrupt" || rec.got[1] != "terminated" || rec.got[2] != "killed" {
185 t.Fatalf("signals: %v", rec.got)
186 }
187 cancelAt := rec.times[0]
188 if d := rec.times[1] - cancelAt; d < termAfter-50*time.Millisecond || d > termAfter+400*time.Millisecond {
189 t.Errorf("SIGTERM %v after SIGINT, want about %v", d, termAfter)
190 }
191 if d := rec.times[2] - rec.times[1]; d < killAfter-50*time.Millisecond || d > killAfter+400*time.Millisecond {
192 t.Errorf("SIGKILL %v after SIGTERM, want about %v", d, killAfter)
193 }
194}
195
196// C1 + C9: a script that exits on SIGINT stops the escalation: the timers are
197// stopped when Wait returns, so no SIGTERM/SIGKILL follows, and nothing is
198// signalled after the shell was reaped except the final group sweep.
199func TestExecEscalationStopsWhenScriptExits(t *testing.T) {
200 rec := &sigRecorder{start: time.Now()}
201 _, _, exit, _, _, _ := host(t, `trap 'exit 130' INT; while :; do sleep 0.05; done`, 200*time.Millisecond, func(c *ExecConfig) {
202 c.TermAfter, c.KillAfter, c.signal = 300*time.Millisecond, 300*time.Millisecond, rec.fn
203 })
204 if exit.Code != 130 {
205 t.Fatalf("exit=%v", exit)
206 }
207 time.Sleep(1200 * time.Millisecond) // well past TermAfter+KillAfter
208 rec.mu.Lock()
209 defer rec.mu.Unlock()
210 if len(rec.got) != 2 || rec.got[0] != "interrupt" || rec.got[1] != "killed" {
211 t.Fatalf("want SIGINT then the final sweep only, got %v", rec.got)
212 }
213}
214
215// C9: after the shell was reaped the escalator never signals again, even if its
216// timers had fired concurrently.
217func TestEscalatorNeverSignalsAfterFinish(t *testing.T) {
218 var n atomic.Int32
219 e := &escalator{cfg: ExecConfig{TermAfter: time.Millisecond, KillAfter: time.Millisecond, signal: func(int, syscall.Signal) { n.Add(1) }}, pgid: 1 << 30}
220 e.cancel()
221 e.finish()
222 after := n.Load()
223 time.Sleep(50 * time.Millisecond)
224 e.send(syscall.SIGTERM)
225 e.cancel()
226 if n.Load() != after {
227 t.Fatalf("signalled %d times after finish", n.Load()-after)
228 }
229}
230
231// C2: a background child that keeps stdout open must not hang the executor: it
232// reports the script's exit promptly and the child is killed.
233func TestExecBackgroundChildDoesNotHoldTheSession(t *testing.T) {
234 marker := filepath.Join(t.TempDir(), "child.pid")
235 start := time.Now()
236 out, _, exit, _, _, _ := host(t, `sleep 30 & echo $! > `+marker+`; echo parent-done`, 0, func(c *ExecConfig) { c.WaitDelay = 300 * time.Millisecond })
237 if time.Since(start) > 5*time.Second {
238 t.Fatalf("the session was held by a background child for %v", time.Since(start))
239 }
240 if exit == nil || exit.Code != 0 || !strings.Contains(out, "parent-done") {
241 t.Fatalf("exit=%v out=%q", exit, out)
242 }
243 b, err := os.ReadFile(marker)
244 if err != nil {
245 t.Fatal(err)
246 }
247 pid, _ := strconv.Atoi(strings.TrimSpace(string(b)))
248 deadline := time.Now().Add(3 * time.Second)
249 for time.Now().Before(deadline) {
250 if st, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)); err != nil || strings.Contains(string(st), ") Z") {
251 return
252 }
253 time.Sleep(50 * time.Millisecond)
254 }
255 t.Fatalf("background child %d survived the job", pid)
256}
257
258// C2: output written by the script before the shell exits is complete even when
259// the stream is large and a background child holds the pipe.
260func TestExecOutputCompleteWithBackgroundChild(t *testing.T) {
261 out, _, exit, _, _, _ := host(t, `sleep 30 & head -c 200000 /dev/zero | tr '\0' 'y'`, 0, func(c *ExecConfig) { c.WaitDelay = 300 * time.Millisecond })
262 if exit.Code != 0 || len(out) != 200000 {
263 t.Fatalf("len=%d exit=%v", len(out), exit)
264 }
265}
266
267func TestExecKilledBySignalReports128PlusSignal(t *testing.T) {
268 _, _, exit, _, _, _ := host(t, `kill -TERM $$`, 0, nil)
269 if exit.Code != 128+int32(syscall.SIGTERM) {
270 t.Fatalf("exit=%v", exit)
271 }
272}
273
274func TestExecMissingShell(t *testing.T) {
275 sock := filepath.Join(t.TempDir(), "s")
276 ln, err := net.Listen("unix", sock)
277 if err != nil {
278 t.Skip(err)
279 }
280 defer func() { _ = ln.Close() }()
281 go func() {
282 c, _ := ln.Accept()
283 defer func() { _ = c.Close() }()
284 _, exit, _ := execstream.Drive(c, &execpb.Run{Script: "true"}, nil, nil)
285 if exit == nil || exit.Code != 127 || exit.Error == "" {
286 t.Errorf("want 127 with an error message, got %v", exit)
287 }
288 }()
289 code, _ := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Shell: "/nonexistent/sh", RunTimeout: 3 * time.Second})
290 if code != 127 {
291 t.Fatalf("code=%d", code)
292 }
293}
294
295func TestExecHostGoneKillsTheScript(t *testing.T) {
296 sock := filepath.Join(t.TempDir(), "s")
297 ln, err := net.Listen("unix", sock)
298 if err != nil {
299 t.Skip(err)
300 }
301 defer func() { _ = ln.Close() }()
302 done := make(chan int, 1)
303 go func() {
304 c, _ := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Shell: "/bin/sh", RunTimeout: 3 * time.Second})
305 done <- c
306 }()
307 conn, _ := ln.Accept()
308 g := execstream.NewConn(conn)
309 _, _ = g.Recv() // hello
310 _ = g.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: &execpb.Run{Script: "sleep 60"}}})
311 time.Sleep(200 * time.Millisecond)
312 _ = conn.Close() // the host died
313 select {
314 case code := <-done:
315 if code == 0 {
316 t.Fatalf("code=%d", code)
317 }
318 case <-time.After(10 * time.Second):
319 t.Fatal("the executor kept running after the host went away")
320 }
321}
322
323func TestExecConnectTimeout(t *testing.T) {
324 start := time.Now()
325 code, err := Exec(context.Background(), ExecConfig{Target: Target{"unix", filepath.Join(t.TempDir(), "nobody")}, ConnectTimeout: 300 * time.Millisecond})
326 if code == 0 || err == nil || time.Since(start) > 3*time.Second {
327 t.Fatalf("code=%d err=%v after %v", code, err, time.Since(start))
328 }
329}
330
331func TestExecRunTimeout(t *testing.T) {
332 sock := filepath.Join(t.TempDir(), "s")
333 ln, err := net.Listen("unix", sock)
334 if err != nil {
335 t.Skip(err)
336 }
337 defer func() { _ = ln.Close() }()
338 go func() { c, _ := ln.Accept(); time.Sleep(2 * time.Second); _ = c.Close() }()
339 code, err := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, RunTimeout: 200 * time.Millisecond})
340 if code == 0 || err == nil || !strings.Contains(err.Error(), "run frame") {
341 t.Fatalf("code=%d err=%v", code, err)
342 }
343}
344
345func TestInitInContainerSkipsVMSetup(t *testing.T) {
346 // In a container (not a VM's PID 1) Init must not mount anything: with a
347 // command line that says "vm" but a pid that is not 1 it behaves as a
348 // container, runs the payload and returns the executor's code.
349 sock := filepath.Join(t.TempDir(), "s")
350 ln, err := net.Listen("unix", sock)
351 if err != nil {
352 t.Skip(err)
353 }
354 defer func() { _ = ln.Close() }()
355 go func() {
356 c, err := ln.Accept()
357 if err != nil {
358 return
359 }
360 defer func() { _ = c.Close() }()
361 _, _, _ = execstream.Drive(c, &execpb.Run{Script: "exit 0"}, nil, nil)
362 }()
363 code := Init(context.Background(), InitConfig{
364 Cmdline: "ermite.sandbox=vm ermite.connect=unix:" + sock,
365 Getpid: func() int { return 4242 },
366 ExecArgs: []string{"/bin/sh", "-c", "exit 5"},
367 SkipDocker: true,
368 })
369 // ExecArgs is a stand-in for the executor: its exit code is Init's.
370 if code != 5 {
371 t.Fatalf("code=%d", code)
372 }
373}
374
375func TestInitWithoutTargetFails(t *testing.T) {
376 if code := Init(context.Background(), InitConfig{Cmdline: "ermite.sandbox=container", Getpid: func() int { return 77 }, ExecArgs: []string{"/bin/true"}, SkipDocker: true}); code == 0 {
377 t.Fatal("init without a connect target must fail")
378 }
379}
380
381func TestWriteResolvConfNeedsGateway(t *testing.T) {
382 if err := writeResolvConf(""); err == nil {
383 t.Fatal("empty gateway accepted")
384 }
385}
386
387// C11: init forwards SIGTERM to the executor and finishes with the executor's
388// own exit code, instead of dying with its child still running.
389func TestInitForwardsSIGTERMToTheExecutor(t *testing.T) {
390 sigs := make(chan os.Signal, 1)
391 done := make(chan int, 1)
392 marker := filepath.Join(t.TempDir(), "got-term")
393 go func() {
394 done <- Init(context.Background(), InitConfig{
395 Cmdline: "ermite.sandbox=container ermite.connect=unix:/nonexistent",
396 Getpid: func() int { return 4242 },
397 SkipDocker: true,
398 Signals: sigs,
399 ExecArgs: []string{"/bin/sh", "-c", `trap 'echo term > ` + marker + `; exit 9' TERM; while :; do sleep 0.05; done`},
400 })
401 }()
402 time.Sleep(500 * time.Millisecond)
403 sigs <- syscall.SIGTERM
404 select {
405 case code := <-done:
406 if code != 9 {
407 t.Fatalf("init must return the executor's exit code, got %d", code)
408 }
409 case <-time.After(10 * time.Second):
410 t.Fatal("init did not finish after SIGTERM")
411 }
412 if _, err := os.Stat(marker); err != nil {
413 t.Fatal("the executor never received SIGTERM")
414 }
415}
416
417// fakeDocker serves the Docker API on a temp unix socket from the moment it is
418// called; ping decides the /_ping answer.
419func fakeDocker(t *testing.T, ping http.HandlerFunc) string {
420 t.Helper()
421 sock := filepath.Join(t.TempDir(), "docker.sock")
422 l, err := net.Listen("unix", sock)
423 if err != nil {
424 t.Fatal(err)
425 }
426 mux := http.NewServeMux()
427 mux.HandleFunc("/_ping", ping)
428 srv := &http.Server{Handler: mux}
429 go func() { _ = srv.Serve(l) }()
430 t.Cleanup(func() { _ = srv.Close() })
431 return sock
432}
433
434func sleeper(t *testing.T) int {
435 t.Helper()
436 pid, err := spawn([]string{"/bin/sh", "-c", "sleep 60"}, nil)
437 if err != nil {
438 t.Fatal(err)
439 }
440 t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL); _, _ = wait4NoHang(pid) })
441 return pid
442}
443
444var testLog = zerowrap.FromCtx(context.Background())
445
446func TestWaitDockerReadyWaitsForPing(t *testing.T) {
447 var calls atomic.Int32
448 sock := fakeDocker(t, func(w http.ResponseWriter, _ *http.Request) {
449 if calls.Add(1) < 4 { // dockerd is up but still starting
450 http.Error(w, "starting", http.StatusServiceUnavailable)
451 return
452 }
453 _, _ = w.Write([]byte("OK"))
454 })
455 start := time.Now()
456 if err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 10*time.Second, nil); err != nil {
457 t.Fatal(err)
458 }
459 if calls.Load() < 4 || time.Since(start) < 200*time.Millisecond {
460 t.Fatalf("returned before the API answered OK (calls=%d)", calls.Load())
461 }
462}
463
464func TestWaitDockerReadyLateSocket(t *testing.T) {
465 sock := filepath.Join(t.TempDir(), "docker.sock")
466 go func() {
467 time.Sleep(400 * time.Millisecond)
468 l, err := net.Listen("unix", sock)
469 if err != nil {
470 return
471 }
472 srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("OK")) })}
473 go func() { _ = srv.Serve(l) }()
474 time.AfterFunc(5*time.Second, func() { _ = srv.Close() })
475 }()
476 start := time.Now()
477 if err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 10*time.Second, nil); err != nil {
478 t.Fatal(err)
479 }
480 if time.Since(start) < 400*time.Millisecond {
481 t.Fatal("returned before the socket existed")
482 }
483}
484
485func TestWaitDockerReadyTimesOut(t *testing.T) {
486 sock := fakeDocker(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusServiceUnavailable) })
487 start := time.Now()
488 err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 500*time.Millisecond, nil)
489 if err == nil || time.Since(start) > 5*time.Second {
490 t.Fatalf("want a timeout error, got %v after %s", err, time.Since(start))
491 }
492 var exited *dockerdExitError
493 var abort *abortBootError
494 if errors.As(err, &exited) || errors.As(err, &abort) {
495 t.Fatalf("a timeout is neither a dockerd exit nor an abort: %v", err)
496 }
497}
498
499// A daemon that accepts the connection and never answers must not delay the
500// deadline, nor a signal.
501func TestWaitDockerReadyStalledServer(t *testing.T) {
502 release := make(chan struct{})
503 t.Cleanup(func() { close(release) })
504 sock := fakeDocker(t, func(http.ResponseWriter, *http.Request) { <-release })
505
506 start := time.Now()
507 err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 500*time.Millisecond, nil)
508 var abort *abortBootError
509 if err == nil || errors.As(err, &abort) || time.Since(start) > 1500*time.Millisecond {
510 t.Fatalf("want the deadline error within ~0.5 s, got %v after %s", err, time.Since(start))
511 }
512
513 sigs := make(chan os.Signal, 1)
514 time.AfterFunc(200*time.Millisecond, func() { sigs <- syscall.SIGTERM })
515 start = time.Now()
516 err = waitDockerReady(context.Background(), testLog, sock, sleeper(t), 30*time.Second, sigs)
517 if !errors.As(err, &abort) || time.Since(start) > 1500*time.Millisecond {
518 t.Fatalf("want an abort right after the signal, got %v after %s", err, time.Since(start))
519 }
520}
521
522func TestWaitDockerReadyDockerdExits(t *testing.T) {
523 pid, err := spawn([]string{"/bin/sh", "-c", "exit 3"}, nil)
524 if err != nil {
525 t.Fatal(err)
526 }
527 start := time.Now()
528 err = waitDockerReady(context.Background(), testLog, filepath.Join(t.TempDir(), "none.sock"), pid, 30*time.Second, nil)
529 var exited *dockerdExitError
530 if !errors.As(err, &exited) || exited.status.ExitStatus() != 3 {
531 t.Fatalf("want dockerd exit 3, got %v", err)
532 }
533 if time.Since(start) > 5*time.Second {
534 t.Fatal("did not notice the exit promptly")
535 }
536}
537
538// Orphans that init adopts while it waits must be reaped at once, not left as
539// zombies until the wait ends.
540func TestWaitDockerReadyReapsAdoptedOrphans(t *testing.T) {
541 // A child that exits immediately stands for an adopted orphan.
542 orphan, err := spawn([]string{"/bin/sh", "-c", "exit 0"}, nil)
543 if err != nil {
544 t.Fatal(err)
545 }
546 zombie := func() bool {
547 b, err := os.ReadFile("/proc/" + strconv.Itoa(orphan) + "/stat")
548 return err == nil && strings.Contains(string(b), ") Z ")
549 }
550 deadline := time.Now().Add(5 * time.Second)
551 for !zombie() && time.Now().Before(deadline) {
552 time.Sleep(10 * time.Millisecond)
553 }
554 if !zombie() {
555 t.Fatal("setup: the orphan did not become a zombie")
556 }
557 sigs := make(chan os.Signal, 1)
558 time.AfterFunc(600*time.Millisecond, func() { sigs <- syscall.SIGTERM })
559 done := make(chan error, 1)
560 go func() {
561 done <- waitDockerReady(context.Background(), testLog, filepath.Join(t.TempDir(), "none.sock"), sleeper(t), 30*time.Second, sigs)
562 }()
563 // The zombie must vanish while the wait is still running.
564 deadline = time.Now().Add(400 * time.Millisecond)
565 for zombie() && time.Now().Before(deadline) {
566 time.Sleep(10 * time.Millisecond)
567 }
568 if zombie() {
569 t.Fatal("the adopted orphan stayed a zombie during the wait")
570 }
571 <-done
572}
573
574// A shutdown signal during the wait aborts the boot (exit 128+signal) instead
575// of starting the executor; so does a cancelled context.
576func TestWaitDockerReadyAbortsOnSignalAndContext(t *testing.T) {
577 sock := filepath.Join(t.TempDir(), "none.sock")
578 sigs := make(chan os.Signal, 1)
579 sigs <- syscall.SIGINT
580 var abort *abortBootError
581 err := waitDockerReady(context.Background(), testLog, sock, sleeper(t), 30*time.Second, sigs)
582 if !errors.As(err, &abort) || abort.code != 128+int(syscall.SIGINT) {
583 t.Fatalf("err=%v", err)
584 }
585 ctx, cancel := context.WithCancel(context.Background())
586 cancel()
587 err = waitDockerReady(ctx, testLog, sock, sleeper(t), 30*time.Second, nil)
588 if !errors.As(err, &abort) || abort.code != 128+int(syscall.SIGTERM) {
589 t.Fatalf("err=%v", err)
590 }
591}
592
593func TestStopDockerdReapsAndZeroIsNoop(t *testing.T) {
594 stopDockerd(0)
595 pid := sleeper(t)
596 stopDockerd(pid)
597 if _, err := os.Stat("/proc/" + strconv.Itoa(pid)); err == nil {
598 t.Fatal("dockerd is still there")
599 }
600}
601
602func TestDockerPingRejectsNonOK(t *testing.T) {
603 sock := fakeDocker(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("nope")) })
604 client := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
605 var d net.Dialer
606 return d.DialContext(ctx, "unix", sock)
607 }}}
608 if dockerPing(context.Background(), client) {
609 t.Fatal("a 200 without OK must not count as ready")
610 }
611}
612
613func initWithFakeDockerd(t *testing.T, sigs <-chan os.Signal, timeout time.Duration, dockerd string) (code int, marker string) {
614 t.Helper()
615 marker = filepath.Join(t.TempDir(), "executor-ran")
616 code = Init(context.Background(), InitConfig{
617 Cmdline: "ermite.sandbox=container ermite.connect=unix:/nonexistent",
618 Getpid: func() int { return 4242 },
619 Signals: sigs,
620 DockerdArgs: []string{"/bin/sh", "-c", dockerd},
621 DockerSocket: filepath.Join(t.TempDir(), "none.sock"),
622 DockerReadyTimeout: timeout,
623 ExecArgs: []string{"/bin/sh", "-c", "touch " + marker + "; exit 7"},
624 })
625 return code, marker
626}
627
628// A shutdown signal while waiting for dockerd: no executor, dockerd stopped,
629// exit 128+signal.
630func TestInitSignalDuringDockerWaitDoesNotStartTheExecutor(t *testing.T) {
631 sigs := make(chan os.Signal, 1)
632 sigs <- syscall.SIGTERM
633 code, marker := initWithFakeDockerd(t, sigs, 30*time.Second, "sleep 60")
634 if code != 128+int(syscall.SIGTERM) {
635 t.Fatalf("code=%d", code)
636 }
637 if _, err := os.Stat(marker); err == nil {
638 t.Fatal("the executor was started")
639 }
640}
641
642// The deadline and a dying dockerd still start the executor.
643func TestInitStartsTheExecutorWhenDockerNeverGetsReady(t *testing.T) {
644 for name, tc := range map[string]struct {
645 dockerd string
646 timeout time.Duration
647 }{"timeout": {"sleep 60", 400 * time.Millisecond}, "dockerd exits": {"exit 1", 30 * time.Second}} {
648 t.Run(name, func(t *testing.T) {
649 code, marker := initWithFakeDockerd(t, nil, tc.timeout, tc.dockerd)
650 if code != 7 {
651 t.Fatalf("want the executor's code 7, got %d", code)
652 }
653 if _, err := os.Stat(marker); err != nil {
654 t.Fatal("the executor did not run")
655 }
656 })
657 }
658}
659
660// A dockerd that ignores SIGTERM goes through the SIGKILL path and is reaped
661// (no zombie left).
662func TestStopDockerdKillsAndReapsATermIgnoringDaemon(t *testing.T) {
663 pid, err := spawn([]string{"/bin/sh", "-c", "trap '' TERM; while :; do sleep 0.1; done"}, nil)
664 if err != nil {
665 t.Fatal(err)
666 }
667 t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) })
668 time.Sleep(200 * time.Millisecond) // let the shell install its trap
669 start := time.Now()
670 stopDockerd(pid)
671 if d := time.Since(start); d < 5*time.Second || d > 8*time.Second {
672 t.Fatalf("expected the SIGKILL path after the 5 s grace, took %s", d)
673 }
674 if _, err := os.Stat("/proc/" + strconv.Itoa(pid)); err == nil {
675 t.Fatal("dockerd was not reaped (zombie or alive)")
676 }
677}
678
679// A step runs with GitHub's umask 022 even when the executor was started with
680// 0, as the kernel starts a VM's init: its files and directories are not
681// world-writable.
682func TestExecStepsGetUmask022(t *testing.T) {
683 old := syscall.Umask(0)
684 defer syscall.Umask(old)
685 out, _, _, _, code, err := host(t, `umask`, 0, nil)
686 if err != nil || code != 0 {
687 t.Fatalf("code=%d err=%v", code, err)
688 }
689 if strings.TrimSpace(out) != "0022" {
690 t.Fatalf("umask = %q, want 0022", out)
691 }
692}