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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 25m ago
4b8c9e1d
| 1 | package container |
| 2 | |
| 3 | import ( |
| 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. |
| 17 | func 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. |
| 90 | func 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 | |
| 110 | func 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. |
| 123 | func 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. |
| 141 | func 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. |
| 166 | func 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)+` |
| 181 | printf '%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 | |
| 196 | func 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 | |
| 217 | func 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. |
| 231 | func 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 | |
| 288 | func 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. |
| 304 | func 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. |
| 316 | func TestProbeReportsWhatTheRuntimeSaid(t *testing.T) { |
| 317 | fakeRuntime(t, "podman", `#!/bin/sh |
| 318 | [ "$1" = info ] || exit 99 |
| 319 | echo 'level=warning msg="RunRoot is pointing to a path (/run/vole-runner/containers) which is not writable"' >&2 |
| 320 | echo 'Error: default OCI runtime "crun" not found: invalid argument' >&2 |
| 321 | exit 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 | |
| 334 | func TestProbeAcceptsARuntimeThatAnswers(t *testing.T) { |
| 335 | fakeRuntime(t, "docker", `#!/bin/sh |
| 336 | [ "$1" = info ] || exit 99 |
| 337 | echo "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. |
| 350 | func 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. |
| 368 | func 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. |
| 408 | func 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 |