v0.4.0 76a5504

jobexec_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package guest
4
5import (
6	"context"
7	"net"
8	"os/exec"
9	"os/user"
10	"path/filepath"
11	"strings"
12	"sync"
13	"testing"
14	"time"
15
16	"google.golang.org/protobuf/proto"
17
18	"bnema.dev/ermite-runner/internal/execstream"
19	"bnema.dev/ermite-runner/internal/execstream/execpb"
20)
21
22// runJobSession starts Exec on a unix socket and drives it with a job.
23func runJobSession(t *testing.T, yaml string, cancelOn string, tweak func(*ExecConfig)) (steps []string, logs []*execpb.Log, res *execpb.JobResult, exit *execpb.Exit) {
24	t.Helper()
25	return runJobSessionSpec(t, yaml, cancelOn, tweak, nil)
26}
27
28// runJobSessionSpec is runJobSession with a change to the JobSpec sent.
29func runJobSessionSpec(t *testing.T, yaml string, cancelOn string, tweak func(*ExecConfig), spec func(*execpb.JobSpec)) (steps []string, logs []*execpb.Log, res *execpb.JobResult, exit *execpb.Exit) {
30	t.Helper()
31	sock := filepath.Join(t.TempDir(), "s")
32	ln, err := net.Listen("unix", sock)
33	if err != nil {
34		t.Skip(err)
35	}
36	defer func() { _ = ln.Close() }()
37	home := t.TempDir()
38	t.Setenv("HOME", home)
39	t.Setenv("RUNNER_TOOL_CACHE", filepath.Join(home, "toolcache"))
40	t.Setenv("ERMITE_CONNECT", "unix:"+sock)
41	done := make(chan struct{})
42	go func() {
43		defer close(done)
44		cfg := ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", RunTimeout: 5 * time.Second,
45			TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 300 * time.Millisecond}
46		if tweak != nil {
47			tweak(&cfg)
48		}
49		_, _ = Exec(context.Background(), cfg)
50	}()
51	conn, err := ln.Accept()
52	if err != nil {
53		t.Fatal(err)
54	}
55	defer func() { _ = conn.Close() }()
56	// With cancelOn, the job is cancelled when it logs that line (the script
57	// prints it once it is ready to be interrupted): no timer to guess.
58	var cancel chan *execpb.Cancel
59	if cancelOn != "" {
60		cancel = make(chan *execpb.Cancel, 1)
61	}
62	var cancelOnce sync.Once
63	var mu sync.Mutex
64	js := &execpb.JobSpec{
65		WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "r", RunnerVersion: "v",
66		Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j"}}`, Matrix: "{}",
67	}
68	if spec != nil {
69		spec(js)
70	}
71	_, exit, err = execstream.DriveJob(conn, &execpb.Run{Job: js}, cancel, execstream.JobHandler{
72		StepStarted: func(s *execpb.StepStarted) {
73			mu.Lock()
74			steps = append(steps, s.Name)
75			mu.Unlock()
76		},
77		Log: func(l *execpb.Log) {
78			logs = append(logs, l)
79			if cancelOn != "" && l.Text == cancelOn {
80				cancelOnce.Do(func() { cancel <- &execpb.Cancel{Reason: "test", Kind: execpb.CancelKind_CANCEL_KIND_USER} })
81			}
82		},
83		JobResult: func(r *execpb.JobResult) { res = r },
84	})
85	if err != nil {
86		t.Fatal(err)
87	}
88	<-done
89	return
90}
91
92func requireBash(t *testing.T) {
93	t.Helper()
94	if _, err := lookPath("bash"); err != nil {
95		t.Skip("bash not installed")
96	}
97}
98
99func TestExecRunsAJob(t *testing.T) {
100	requireBash(t)
101	steps, logs, res, exit := runJobSession(t, "on: push\njobs:\n  j:\n    runs-on: x\n    outputs:\n      o: ${{ steps.s.outputs.v }}\n    steps:\n      - id: s\n        run: |\n          echo hello-from-job\n          echo v=42 >> \"$GITHUB_OUTPUT\"\n", "", nil)
102	if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS || res.Outputs["o"] != "42" || exit.Code != 0 {
103		t.Fatalf("res=%v exit=%v", res, exit)
104	}
105	if strings.Join(steps, "|") != "Set up job|Run echo hello-from-job|Complete job" {
106		t.Fatalf("steps %v", steps)
107	}
108	found := false
109	for _, l := range logs {
110		if l.Text == "hello-from-job" && l.Step == 2 && l.UnixNano > 0 && !l.Continued {
111			found = true
112		}
113	}
114	if !found {
115		t.Fatalf("log line missing: %d lines", len(logs))
116	}
117}
118
119func TestExecJobFailureExitsNonZero(t *testing.T) {
120	requireBash(t)
121	_, _, res, exit := runJobSession(t, "on: push\njobs:\n  j:\n    runs-on: x\n    steps:\n      - run: exit 4\n", "", nil)
122	if res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_FAILURE || exit.Code == 0 {
123		t.Fatalf("res=%v exit=%v", res, exit)
124	}
125}
126
127func TestExecJobLongLinesAreContinued(t *testing.T) {
128	requireBash(t)
129	_, logs, res, _ := runJobSession(t, "on: push\njobs:\n  j:\n    runs-on: x\n    steps:\n      - run: head -c 150000 /dev/zero | tr '\\0' y; echo\n", "", nil)
130	if res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS {
131		t.Fatalf("%v", res)
132	}
133	var total int
134	var pieces int
135	for _, l := range logs {
136		if strings.HasPrefix(l.Text, "yyyy") {
137			pieces++
138			total += len(l.Text)
139			if len(l.Text) > execstream.MaxLogLine || (pieces > 1) != l.Continued {
140				t.Fatalf("piece %d: len %d continued %v", pieces, len(l.Text), l.Continued)
141			}
142		}
143	}
144	if pieces != 3 || total != 150000 {
145		t.Fatalf("pieces=%d total=%d", pieces, total)
146	}
147}
148
149func TestExecJobCancel(t *testing.T) {
150	requireBash(t)
151	start := time.Now()
152	_, _, res, _ := runJobSession(t, "on: push\njobs:\n  j:\n    runs-on: x\n    steps:\n      - run: |\n          echo ready\n          sleep 30\n", "ready", nil)
153	if res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_CANCELLED || res.RunnerShutdown {
154		t.Fatalf("%v", res)
155	}
156	if time.Since(start) > 8*time.Second {
157		t.Fatal("cancel too slow")
158	}
159}
160
161func TestExecJobHostGoneIsAShutdown(t *testing.T) {
162	requireBash(t)
163	sock := filepath.Join(t.TempDir(), "s")
164	ln, err := net.Listen("unix", sock)
165	if err != nil {
166		t.Skip(err)
167	}
168	defer func() { _ = ln.Close() }()
169	t.Setenv("HOME", t.TempDir())
170	t.Setenv("RUNNER_TOOL_CACHE", filepath.Join(t.TempDir(), "toolcache"))
171	done := make(chan int, 1)
172	go func() {
173		c, _ := Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, RunTimeout: 5 * time.Second,
174			TermAfter: 100 * time.Millisecond, KillAfter: 100 * time.Millisecond, WaitDelay: 200 * time.Millisecond})
175		done <- c
176	}()
177	conn, _ := ln.Accept()
178	c := execstream.NewConn(conn)
179	if _, err := c.Recv(); err != nil {
180		t.Fatal(err)
181	}
182	_ = c.Send(&execpb.Frame{Body: &execpb.Frame_Run{Run: &execpb.Run{Job: &execpb.JobSpec{
183		WorkflowYaml: "on: push\njobs:\n  j:\n    runs-on: x\n    steps:\n      - run: echo running; sleep 60\n", JobKey: "j", Repo: "hello",
184		Contexts: `{"github":{"repository":"o/hello"}}`,
185	}}}})
186	// Close the stream once the step is running (its first line arrived).
187	for {
188		f, err := c.Recv()
189		if err != nil {
190			t.Fatal(err)
191		}
192		if l := f.GetLog(); l != nil && l.Text == "running" {
193			break
194		}
195	}
196	_ = conn.Close()
197	select {
198	case <-done:
199	case <-time.After(10 * time.Second):
200		t.Fatal("the executor kept running without a host")
201	}
202}
203
204func TestStepEnv(t *testing.T) {
205	got := stepEnv([]string{"PATH=/bin", "HOME=/root", "ERMITE_CONNECT=x", "USER=root", "RUNNER_TOOL_CACHE=/x", "FOO=1"}, StepUser{Name: "runner", Home: "/home/runner"})
206	want := "PATH=/bin FOO=1 HOME=/home/runner USER=runner LOGNAME=runner"
207	if strings.Join(got, " ") != want {
208		t.Fatalf("%v", got)
209	}
210}
211
212func lookPath(name string) (string, error) { return exec.LookPath(name) }
213
214func TestMissingStepUserFailsTheJobUnlessTestOptionSet(t *testing.T) {
215	none := func(string) (*user.User, error) { return nil, user.UnknownUserError("runner") }
216	// As root with no such user: refuse.
217	if _, err := lookupStepUser(0, none, "runner", false); err == nil || !strings.Contains(err.Error(), `no user "runner"`) || !strings.Contains(err.Error(), "refusing to run them as root") {
218		t.Fatalf("%v", err)
219	}
220	// The test option allows root.
221	if su, err := lookupStepUser(0, none, "runner", true); err != nil || su.Name != "root" || su.Credential != nil {
222		t.Fatalf("%+v %v", su, err)
223	}
224	// Not root: the steps run as the current user, whatever the name.
225	if su, err := lookupStepUser(1000, none, "runner", false); err != nil || su.Credential != nil {
226		t.Fatalf("%+v %v", su, err)
227	}
228	// As root with the user: switch to it.
229	found := func(string) (*user.User, error) {
230		return &user.User{Uid: "1001", Gid: "1001", Username: "runner", HomeDir: "/home/runner"}, nil
231	}
232	su, err := lookupStepUser(0, found, "runner", false)
233	if err != nil || su.Credential == nil || su.Credential.Uid != 1001 || su.Credential.Gid != 1001 || su.Home != "/home/runner" {
234		t.Fatalf("%+v %v", su, err)
235	}
236}
237
238// The failure is reported like a failed "Set up job".
239func TestFailSetupReportsAFailedFirstStep(t *testing.T) {
240	host, guest := net.Pipe()
241	got := make(chan []string, 1)
242	go func() {
243		var frames []string
244		c := execstream.NewConn(host)
245		for {
246			f, err := c.Recv()
247			if err != nil {
248				got <- frames
249				return
250			}
251			switch b := f.Body.(type) {
252			case *execpb.Frame_StepStarted:
253				frames = append(frames, "start:"+b.StepStarted.Name)
254			case *execpb.Frame_Log:
255				frames = append(frames, "log:"+b.Log.Text)
256			case *execpb.Frame_StepCompleted:
257				frames = append(frames, "done:"+b.StepCompleted.Conclusion.String())
258			case *execpb.Frame_JobResult:
259				frames = append(frames, "result:"+b.JobResult.Conclusion.String())
260			}
261		}
262	}()
263	if code := failSetup(execstream.NewConn(guest), "no user"); code != 1 {
264		t.Fatal(code)
265	}
266	_ = guest.Close()
267	want := "start:Set up job|log:##[error]no user|done:STEP_CONCLUSION_FAILURE|result:STEP_CONCLUSION_FAILURE"
268	if g := strings.Join(<-got, "|"); g != want {
269		t.Fatalf("%s", g)
270	}
271}
272
273// The executor over the stream: annotations, summary and outcome frames
274// arrive before the StepCompleted of their step, only when the host asked.
275func TestExecSendsResultsFrames(t *testing.T) {
276	requireBash(t)
277	yaml := "on: push\njobs:\n  j:\n    runs-on: x\n    steps:\n      - run: |\n          echo '::warning title=T::careful'\n          echo '# sum' >> \"$GITHUB_STEP_SUMMARY\"\n          exit 1\n        continue-on-error: true\n"
278	for _, on := range []bool{true, false} {
279		sock := filepath.Join(t.TempDir(), "s")
280		ln, err := net.Listen("unix", sock)
281		if err != nil {
282			t.Skip(err)
283		}
284		home := t.TempDir()
285		t.Setenv("HOME", home)
286		t.Setenv("RUNNER_TOOL_CACHE", filepath.Join(home, "toolcache"))
287		done := make(chan struct{})
288		go func() {
289			defer close(done)
290			_, _ = Exec(context.Background(), ExecConfig{Target: Target{"unix", sock}, Sandbox: "container", Version: "test", RunTimeout: 5 * time.Second,
291				TermAfter: 200 * time.Millisecond, KillAfter: 200 * time.Millisecond, WaitDelay: 300 * time.Millisecond})
292		}()
293		conn, err := ln.Accept()
294		if err != nil {
295			t.Fatal(err)
296		}
297		var order []string
298		var anns *execpb.StepAnnotations
299		var sum *execpb.StepSummary
300		var done2 *execpb.StepCompleted
301		_, _, err = execstream.DriveJob(conn, &execpb.Run{Job: &execpb.JobSpec{
302			WorkflowPath: ".github/workflows/ci.yml", WorkflowYaml: yaml, JobKey: "j", Repo: "hello", RunnerName: "r", RunnerVersion: "v",
303			Contexts: `{"github":{"repository":"octo/hello","event_name":"push","job":"j"}}`, Matrix: "{}",
304			SendAnnotations: on, SendSummaries: on,
305		}}, nil, execstream.JobHandler{
306			StepAnnotations: func(a *execpb.StepAnnotations) { order = append(order, "ann"); anns = a },
307			StepSummary:     func(s *execpb.StepSummary) { order = append(order, "sum"); sum = s },
308			StepCompleted: func(s *execpb.StepCompleted) {
309				if s.Number == 2 {
310					order = append(order, "done")
311					done2 = s
312				}
313			},
314		})
315		_ = conn.Close()
316		_ = ln.Close()
317		<-done
318		if err != nil {
319			t.Fatal(err)
320		}
321		if done2 == nil || done2.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS || done2.Outcome != execpb.StepConclusion_STEP_CONCLUSION_FAILURE {
322			t.Fatalf("on=%v: %+v", on, done2)
323		}
324		if !on {
325			if len(order) != 1 {
326				t.Errorf("frames without the features: %v", order)
327			}
328			continue
329		}
330		if strings.Join(order, ",") != "ann,sum,done" {
331			t.Errorf("order %v", order)
332		}
333		// the failing exit code is also an annotation
334		var msgs []string
335		for _, a := range anns.Annotations {
336			msgs = append(msgs, a.Level.String()+":"+a.Title+":"+a.Message)
337		}
338		if anns.Step != 2 || len(msgs) != 2 || msgs[0] != "ANNOTATION_LEVEL_WARNING:T:careful" || msgs[1] != "ANNOTATION_LEVEL_FAILURE::Process completed with exit code 1." {
339			t.Errorf("annotations %v", msgs)
340		}
341		if sum == nil || sum.Step != 2 || sum.Markdown != "# sum\n" {
342			t.Errorf("summary %+v", sum)
343		}
344	}
345}
346
347// The permissions the server sent reach "Set up job" through the Run frame as
348// the guest reads them: absent keeps the workflow's, present but empty is
349// "Metadata only", populated is printed as sent. The frame is marshalled and
350// parsed on the way, where a nil and an empty map would look alike.
351func TestExecJobPrintsTheServersTokenPermissions(t *testing.T) {
352	requireBash(t)
353	yaml := "on: push\npermissions: write-all\njobs:\n  j:\n    runs-on: x\n    steps:\n      - run: echo hi\n"
354	group := func(logs []*execpb.Log) string {
355		var lines []string
356		in := false
357		for _, l := range logs {
358			switch {
359			case l.Text == "##[group]GITHUB_TOKEN Permissions":
360				in = true
361			case l.Text == "##[endgroup]":
362				in = false
363			case in:
364				lines = append(lines, l.Text)
365			}
366		}
367		return strings.Join(lines, "\n")
368	}
369	for _, tc := range []struct {
370		name string
371		set  func(*execpb.JobSpec)
372		want string
373	}{
374		{"absent: from the workflow", nil, "Actions: write\nArtifactMetadata: write\nAttestations: write\nChecks: write\nCodeQuality: write\nContents: write\n" +
375			"CopilotRequests: write\nDeployments: write\nDiscussions: write\nDrives: write\nIssues: write\nMetadata: read\nModels: read\nPackages: write\n" +
376			"Pages: write\nPullRequests: write\nRepositoryProjects: write\nSecurityEvents: write\nStatuses: write\nVulnerabilityAlerts: read"},
377		{"present and empty: metadata only", func(s *execpb.JobSpec) { s.TokenPermissions = &execpb.TokenPermissions{} }, "Metadata: read"},
378		{"populated: as sent", func(s *execpb.JobSpec) {
379			s.TokenPermissions = &execpb.TokenPermissions{Levels: map[string]string{"contents": "read", "issues": "none"}}
380		}, "Contents: read\nMetadata: read"},
381	} {
382		_, logs, res, _ := runJobSessionSpec(t, yaml, "", nil, tc.set)
383		if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS {
384			t.Fatalf("%s: %+v", tc.name, res)
385		}
386		if got := group(logs); got != tc.want {
387			t.Errorf("%s:\n%s\nwant:\n%s", tc.name, got, tc.want)
388		}
389	}
390}
391
392func TestTokenPermissionsNilVersusEmpty(t *testing.T) {
393	for name, tc := range map[string]struct {
394		spec    *execpb.JobSpec
395		wantNil bool
396		want    int
397	}{
398		"absent":    {&execpb.JobSpec{}, true, 0},
399		"empty":     {&execpb.JobSpec{TokenPermissions: &execpb.TokenPermissions{}}, false, 0},
400		"populated": {&execpb.JobSpec{TokenPermissions: &execpb.TokenPermissions{Levels: map[string]string{"contents": "read"}}}, false, 1},
401	} {
402		// After the trip a frame makes.
403		raw, err := proto.Marshal(tc.spec)
404		if err != nil {
405			t.Fatal(err)
406		}
407		var got execpb.JobSpec
408		if err := proto.Unmarshal(raw, &got); err != nil {
409			t.Fatal(err)
410		}
411		m := tokenPermissions(&got)
412		if (m == nil) != tc.wantNil || len(m) != tc.want {
413			t.Errorf("%s: %#v", name, m)
414		}
415	}
416}
417
418// JobSpec.id_token_url and id_token_request_token reach the run steps as
419// ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, the token
420// masked in every log line.
421func TestExecJobGetsIDTokenRequest(t *testing.T) {
422	requireBash(t)
423	const url, token = "http://ermite.example:8088/-/oidc/token?api-version=2.0", "ermiteoidc_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
424	yaml := "on: push\njobs:\n  j:\n    runs-on: x\n    steps:\n      - run: echo \"oidc [$ACTIONS_ID_TOKEN_REQUEST_URL] [$ACTIONS_ID_TOKEN_REQUEST_TOKEN]\"\n"
425	_, logs, res, _ := runJobSessionSpec(t, yaml, "", nil, func(s *execpb.JobSpec) { s.IdTokenUrl, s.IdTokenRequestToken = url, token })
426	if res == nil || res.Conclusion != execpb.StepConclusion_STEP_CONCLUSION_SUCCESS {
427		t.Fatalf("%+v", res)
428	}
429	found := false
430	for _, l := range logs {
431		if strings.Contains(l.Text, token) {
432			t.Errorf("the token is in the log: %q", l.Text)
433		}
434		found = found || l.Text == "oidc ["+url+"] [***]"
435	}
436	if !found {
437		t.Error("the step did not print the variables")
438	}
439}

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