v0.4.2 257b319

guest_test.go RawHistoryFolder

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}

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