Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 25m ago 4b8c9e1d
history blame raw 13.0 KiB
1package container
2
3import (
4 "context"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12// The container arguments are the boundary in one list. What has to stay closed:
13// only the job tree is mounted, capabilities are dropped, and no host environment,
14// socket, or SSH agent is passed through. The container's own filesystem is not
15// read-only — images make toolchain directories world-writable on purpose, and the
16// layer dies with the container.
17func TestRunArgsAreClosed(t *testing.T) {
18 t.Parallel()
19 cfg := Config{
20 Runtime: "podman",
21 Network: "none",
22 Pull: "missing",
23 User: "1000:1000",
24 Userns: "keep-id",
25 Memory: "2g",
26 CPUs: "2",
27 PIDs: 512,
28 }
29 args := strings.Join(RunArgs(cfg, Spec{
30 Image: "golang:1.27@sha256:abc",
31 Name: "vole-runner1-run1-1",
32 Work: "/work/job",
33 Labels: map[string]string{"vole.runner": "1", "vole.runner.id": "runner1"},
34 Mounts: []Mount{{Host: "/var/cache/demo", Path: "/cache"}},
35 Env: map[string]string{"GOCACHE": "/cache/go-build"},
36 }), " ")
37
38 for _, want := range []string{
39 "run -d --rm",
40 "--name vole-runner1-run1-1",
41 "--label vole.runner=1",
42 "--label vole.runner.id=runner1",
43 "--workdir /workspace",
44 "--user 1000:1000",
45 "--userns keep-id",
46 "--volume /work/job:/workspace:rw",
47 "--volume /var/cache/demo:/cache:rw",
48 "--env GOCACHE=/cache/go-build",
49 "--cap-drop ALL",
50 "--security-opt no-new-privileges",
51 "--network none",
52 "--pull missing",
53 "--pids-limit 512",
54 "--memory 2g",
55 "--cpus 2",
56 "--tmpfs /tmp:rw,nosuid,nodev,exec,mode=1777,size=1g",
57 "--entrypoint /bin/sh",
58 "golang:1.27@sha256:abc",
59 } {
60 if !strings.Contains(args, want) {
61 t.Fatalf("container arguments do not contain %q:\n%s", want, args)
62 }
63 }
64 for _, unwanted := range []string{
65 "--privileged",
66 "--cap-add",
67 "--device",
68 "--pid host",
69 "--ipc host",
70 "--network host",
71 "/var/run/docker.sock",
72 "/run/podman/podman.sock",
73 "SSH_AUTH_SOCK",
74 "/home/runner",
75 "/etc/",
76 } {
77 if strings.Contains(args, unwanted) {
78 t.Fatalf("container arguments contain %q:\n%s", unwanted, args)
79 }
80 }
81 // One workspace plus the declared cache; anything else would be a host path the
82 // job can reach without the caller knowing.
83 if strings.Count(args, "--volume") != 2 {
84 t.Fatalf("expected the workspace and the cache mount, got:\n%s", args)
85 }
86}
87
88// TestRunsAsRoot: the warning a caller prints depends on this, so it has to read
89// the uid and not the whole "uid:gid" string.
90func TestRunsAsRoot(t *testing.T) {
91 t.Parallel()
92 for _, tc := range []struct {
93 user string
94 want bool
95 }{
96 {"0:0", true},
97 {"0", true},
98 {"root", true},
99 {"1000:1000", false},
100 {"0:1000", true},
101 {"10:0", false},
102 {"rootless:1000", false},
103 } {
104 if got := RunsAsRoot(Config{User: tc.user}); got != tc.want {
105 t.Errorf("RunsAsRoot(%q) = %v, want %v", tc.user, got, tc.want)
106 }
107 }
108}
109
110func TestRunArgsOmitEmptyLimits(t *testing.T) {
111 t.Parallel()
112 cfg := Config{Runtime: "docker", Network: "none", Pull: "never", User: "1000:1000"}
113 args := strings.Join(RunArgs(cfg, Spec{Image: "alpine:3", Name: "name", Work: "/work"}), " ")
114 for _, unwanted := range []string{"--memory", "--cpus", "--pids-limit"} {
115 if strings.Contains(args, unwanted) {
116 t.Fatalf("an unset limit was passed as %q:\n%s", unwanted, args)
117 }
118 }
119}
120
121// A definition's digest is what its image means, so the tag follows the content
122// rather than the name alone.
123func TestImageTagFollowsTheDigest(t *testing.T) {
124 t.Parallel()
125 a := ImageTag("go", "0123456789abcdef0123")
126 b := ImageTag("go", "fedcba9876543210fedc")
127 if a == b {
128 t.Fatalf("two definitions produced the same tag %q", a)
129 }
130 if !strings.HasPrefix(a, "vole/image-file:go-") {
131 t.Fatalf("tag = %q", a)
132 }
133 if strings.Contains(a, "0123456789abcdef0123") {
134 t.Fatalf("tag kept the whole digest: %q", a)
135 }
136}
137
138// A host path becomes a `--volume` field, and a colon, NUL or newline in one would
139// be read as an extra field or split the argument. The runner builds these paths,
140// so the check is a guarantee rather than a discovery.
141func TestInvalidVolumePathsAreRefused(t *testing.T) {
142 t.Parallel()
143 for _, spec := range []Spec{
144 {Work: "/work/job:ro"},
145 {Work: "/work/job\x00"},
146 {Work: "/work/job\n"},
147 {Work: "/work/job", Mounts: []Mount{{Host: "/cache:x", Path: "/cache"}}},
148 {Work: "/work/job", Mounts: []Mount{{Host: "/cache", Path: "/cache\r"}}},
149 {Work: ""},
150 } {
151 if err := ValidatePaths(spec); err == nil {
152 t.Fatalf("ValidatePaths(%+v) = nil", spec)
153 }
154 }
155 if err := ValidatePaths(Spec{
156 Work: "/work/job",
157 Mounts: []Mount{{Host: "/var/cache/vole/demo", Path: "/cache"}},
158 }); err != nil {
159 t.Fatalf("a valid spec was refused: %v", err)
160 }
161}
162
163// A secret's value must reach the step's shell exactly, and must never be an
164// argument to the runtime. These two tests are the two halves of that: the file the
165// step sources, and the command line that sources it.
166func TestSecretsEnvIsQuotedForTheShell(t *testing.T) {
167 t.Parallel()
168 values := map[string]string{
169 "PLAIN": "s3cret",
170 "MULTI": "line one\nline two",
171 "QUOTES": "it's a 'value'",
172 "MONEY": "$HOME `id` $(id)",
173 }
174 script, err := SecretsEnv(values)
175 if err != nil {
176 t.Fatal(err)
177 }
178 // Read them back through the same shell the steps use: a value that survives
179 // the round trip is one the step sees unchanged.
180 cmd := exec.Command("/bin/sh", "-c", string(script)+`
181printf '%s|%s|%s|%s' "$PLAIN" "$MULTI" "$QUOTES" "$MONEY"`)
182 out, err := cmd.Output()
183 if err != nil {
184 t.Fatalf("the generated file did not run: %v\n%s", err, script)
185 }
186 want := "s3cret|line one\nline two|it's a 'value'|$HOME `id` $(id)"
187 if string(out) != want {
188 t.Fatalf("read back %q, want %q\n%s", out, want, script)
189 }
190
191 if _, err := SecretsEnv(map[string]string{"KEY": "a\x00b"}); err == nil {
192 t.Fatal("a value with a NUL byte was rendered")
193 }
194}
195
196func TestAStepSourcesTheSecretsFileFirst(t *testing.T) {
197 t.Parallel()
198 box := &Container{cfg: Config{Runtime: "docker"}, name: "job", work: t.TempDir(), secrets: "/run/vole/secrets.env"}
199 args := box.Exec(context.Background(), "echo hi").Args
200 script := args[len(args)-1]
201 if !strings.HasPrefix(script, ". /run/vole/secrets.env\necho hi") {
202 t.Fatalf("the step does not source the file first: %q", script)
203 }
204 // A value never becomes an argument, so no part of the command line carries one.
205 for _, arg := range args {
206 if strings.Contains(arg, "s3cret") {
207 t.Fatalf("a value reached the command line: %q", arg)
208 }
209 }
210
211 plain := (&Container{cfg: Config{Runtime: "docker"}, name: "job", work: t.TempDir()}).Exec(context.Background(), "echo hi")
212 if got := plain.Args[len(plain.Args)-1]; got != "echo hi" {
213 t.Fatalf("a job with no secrets had its script changed: %q", got)
214 }
215}
216
217func TestASecretsFileMustBeAContainerPath(t *testing.T) {
218 t.Parallel()
219 err := ValidatePaths(Spec{Work: "/work/job", SecretsFile: "secrets.env"})
220 if err == nil {
221 t.Fatal("a relative secrets path was accepted")
222 }
223 if err := ValidatePaths(Spec{Work: "/work/job", SecretsFile: "/run/vole/secrets.env"}); err != nil {
224 t.Fatalf("a container path was refused: %v", err)
225 }
226}
227
228// A service gets the job's isolation and nothing of the job's data: no workspace,
229// no cache, no secrets file, and no entrypoint override, so the image's own command
230// is what runs.
231func TestServiceRunArgsAreClosed(t *testing.T) {
232 t.Parallel()
233 cfg := Config{
234 Runtime: "podman",
235 Network: "vole-runner1-run1-1-net",
236 Pull: "missing",
237 User: "1000:1000",
238 Userns: "keep-id",
239 Memory: "2g",
240 CPUs: "2",
241 PIDs: 512,
242 }
243 args := strings.Join(ServiceRunArgs(cfg, ServiceSpec{
244 Name: "vole-runner1-run1-1-svc-db",
245 Alias: "db",
246 Image: "postgres:17@sha256:abc",
247 Env: map[string]string{"POSTGRES_PASSWORD": "test"},
248 Labels: map[string]string{"vole.runner.id": "runner1"},
249 }), " ")
250
251 for _, want := range []string{
252 "run -d",
253 "--name vole-runner1-run1-1-svc-db",
254 "--label vole.runner.id=runner1",
255 "--user 1000:1000",
256 "--userns keep-id",
257 "--network-alias db",
258 "--env POSTGRES_PASSWORD=test",
259 "--tmpfs /tmp:rw,nosuid,nodev,exec,mode=1777,size=1g",
260 "--cap-drop ALL",
261 "--security-opt no-new-privileges",
262 "--network vole-runner1-run1-1-net",
263 "--pull missing",
264 "--pids-limit 512",
265 "--memory 2g",
266 "--cpus 2",
267 "postgres:17@sha256:abc",
268 } {
269 if !strings.Contains(args, want) {
270 t.Fatalf("service arguments do not contain %q:\n%s", want, args)
271 }
272 }
273 for _, unwanted := range []string{
274 "--rm",
275 "--workdir",
276 "--volume",
277 "--entrypoint",
278 "--cap-add",
279 "--privileged",
280 "/workspace",
281 } {
282 if strings.Contains(args, unwanted) {
283 t.Fatalf("service arguments contain %q:\n%s", unwanted, args)
284 }
285 }
286}
287
288func TestTailBufferKeepsTheEnd(t *testing.T) {
289 t.Parallel()
290 b := &tailBuffer{max: 5}
291 _, _ = b.Write([]byte("1234"))
292 _, _ = b.Write([]byte("5678"))
293 if got := string(b.Bytes()); got != "45678" {
294 t.Fatalf("tail = %q, want 45678", got)
295 }
296 _, _ = b.Write([]byte("abcdefgh"))
297 if got := string(b.Bytes()); got != "defgh" {
298 t.Fatalf("tail after a long write = %q, want defgh", got)
299 }
300}
301
302// fakeRuntime puts a script on PATH under the runtime's name, so Validate's
303// LookPath and Probe's own invocation see a runtime without one being installed.
304func fakeRuntime(t *testing.T, name, script string) {
305 t.Helper()
306 dir := t.TempDir()
307 if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil {
308 t.Fatal(err)
309 }
310 t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
311}
312
313// A runtime on PATH is not a runtime that can make a container. The probe has to
314// run it, and the error has to carry the runtime's own reason: "exited 125" is what
315// made a broken runner look like a repository whose build failed.
316func TestProbeReportsWhatTheRuntimeSaid(t *testing.T) {
317 fakeRuntime(t, "podman", `#!/bin/sh
318[ "$1" = info ] || exit 99
319echo 'level=warning msg="RunRoot is pointing to a path (/run/vole-runner/containers) which is not writable"' >&2
320echo 'Error: default OCI runtime "crun" not found: invalid argument' >&2
321exit 125
322`)
323 cfg, err := Validate(Config{Runtime: "podman"})
324 if err != nil {
325 t.Fatal(err)
326 }
327 err = Probe(context.Background(), cfg)
328 want := `cannot run a container with podman: Error: default OCI runtime "crun" not found: invalid argument`
329 if err == nil || err.Error() != want {
330 t.Fatalf("Probe = %v, want %q", err, want)
331 }
332}
333
334func TestProbeAcceptsARuntimeThatAnswers(t *testing.T) {
335 fakeRuntime(t, "docker", `#!/bin/sh
336[ "$1" = info ] || exit 99
337echo "Server Version: 29.8.1"
338`)
339 cfg, err := Validate(Config{Runtime: "docker"})
340 if err != nil {
341 t.Fatal(err)
342 }
343 if err := Probe(context.Background(), cfg); err != nil {
344 t.Fatalf("Probe on a working runtime = %v", err)
345 }
346}
347
348// The warning podman prints above the fatal line is not the cause. A runtime that
349// says nothing leaves the exec error, so the message is never empty.
350func TestLastLineSkipsWarningsAndBlankLines(t *testing.T) {
351 t.Parallel()
352 cases := map[string]string{
353 "one line\n": "one line",
354 "warning\n\nError: the real reason\n\n": "Error: the real reason",
355 "": "",
356 "\n \n": "",
357 }
358 for in, want := range cases {
359 if got := lastLine(in); got != want {
360 t.Fatalf("lastLine(%q) = %q, want %q", in, got, want)
361 }
362 }
363}
364
365// The resource limits belong to the operator, and the defaults are limits too: a job
366// cannot arrive with no pids bound, and its /tmp is a tmpfs in RAM, so an unsized one
367// is a build that fills the machine without writing to the workspace.
368func TestContainerLimitsAreFiniteWithoutBeingSet(t *testing.T) {
369 fakeRuntime(t, "docker", "#!/bin/sh\nexit 0\n")
370 cfg, err := Validate(Config{Runtime: "docker"})
371 if err != nil {
372 t.Fatal(err)
373 }
374 if cfg.PIDs != DefaultPIDs {
375 t.Fatalf("pids = %d, want the default %d", cfg.PIDs, DefaultPIDs)
376 }
377 if cfg.Tmpfs != DefaultTmpfs {
378 t.Fatalf("tmpfs = %q, want the default %q", cfg.Tmpfs, DefaultTmpfs)
379 }
380 args := strings.Join(RunArgs(cfg, Spec{Image: "alpine:3.22", Name: "job"}), " ")
381 for _, want := range []string{
382 "--pids-limit 2048",
383 "--tmpfs /tmp:rw,nosuid,nodev,exec,mode=1777,size=1g",
384 } {
385 if !strings.Contains(args, want) {
386 t.Fatalf("container arguments do not contain %q:\n%s", want, args)
387 }
388 }
389
390 // What the operator sets is what both a job and its services get.
391 cfg, err = Validate(Config{Runtime: "docker", PIDs: 256, Tmpfs: "2g", Memory: "4g"})
392 if err != nil {
393 t.Fatal(err)
394 }
395 for _, got := range []string{
396 strings.Join(RunArgs(cfg, Spec{Image: "alpine:3.22", Name: "job"}), " "),
397 strings.Join(ServiceRunArgs(cfg, ServiceSpec{Name: "db", Image: "postgres:17"}), " "),
398 } {
399 for _, want := range []string{"--pids-limit 256", "size=2g", "--memory 4g"} {
400 if !strings.Contains(got, want) {
401 t.Fatalf("arguments do not contain %q:\n%s", want, got)
402 }
403 }
404 }
405}
406
407// A size with a space or a newline in it would split into two container arguments.
408func TestValidateRefusesATmpfsThatIsNotASize(t *testing.T) {
409 fakeRuntime(t, "docker", "#!/bin/sh\nexit 0\n")
410 for _, raw := range []string{"1g 2g", "1g\nextra", "1g\textra"} {
411 if _, err := Validate(Config{Runtime: "docker", Tmpfs: raw}); err == nil {
412 t.Fatalf("--container-tmpfs %q was accepted", raw)
413 }
414 }
415}
416