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}