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.
Add packaging, a CI workflow, and containerised local runs
3b39a84c913cabe15bfa6a60dd176ff2dd28d241 · Keenan Nemetz · 14d ago · parent 30a72490
Add packaging, a CI workflow, and containerised local runs A v* tag now builds a .deb and a .rpm for amd64 and arm64 with nfpm and attaches them, with checksums, to the release. The package ships the systemd unit, a config at /etc/vole/vole.toml, and a postinstall that creates the service account and prepares the instance that config names; `vole admin init --config` is what makes that possible, and the binary's version is stamped from the tag instead of the source tree. scripts/ci.sh becomes .vole/workflows/ci.yaml, so `vole workflow run ci` executes exactly what a push runs, and the Markdown link check moves to scripts/check-links.py. Local runs now always execute in the job image and share ci/container with the runner; --no-container is gone, because a run in a different environment proves nothing about CI. The runner mounts a per-repository namespace at /cache and sets no variable for it: a job opts in through its image's env or the new job-level env, which is pinned with the queue and delivered with the lease. Whole namespaces are evicted least-recently-written first so a module cache is never half-deleted, and contributor branches get their own namespace so an untrusted tip cannot seed the cache a protected branch builds into. Running the suite in that container found four bugs worth naming: /tmp was mounted noexec, which broke `go test` and `go run`; the read-only root is gone, because images make toolchain directories world-writable deliberately and the mount set is what protects the host; OpenSSH needs a passwd entry for the job's uid; and a lost metadata push reported as "cannot lock ref" was not classified as a race, so concurrent CLI writes gave up on the first collision. That retry now backs off exponentially with jitter.
ci/container/container_test.go +126
| @@ -0,0 +1,126 @@ | |||
| 1 | package container | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // The container arguments are the boundary in one list. What has to stay closed: | ||
| 9 | // only the job tree is mounted, capabilities are dropped, and no host environment, | ||
| 10 | // socket, or SSH agent is passed through. The container's own filesystem is not | ||
| 11 | // read-only — images make toolchain directories world-writable on purpose, and the | ||
| 12 | // layer dies with the container. | ||
| 13 | func TestRunArgsAreClosed(t *testing.T) { | ||
| 14 | t.Parallel() | ||
| 15 | cfg := Config{ | ||
| 16 | Runtime: "podman", | ||
| 17 | Network: "none", | ||
| 18 | Pull: "missing", | ||
| 19 | User: "1000:1000", | ||
| 20 | Userns: "keep-id", | ||
| 21 | Memory: "2g", | ||
| 22 | CPUs: "2", | ||
| 23 | PIDs: 512, | ||
| 24 | } | ||
| 25 | args := strings.Join(RunArgs(cfg, Spec{ | ||
| 26 | Image: "golang:1.27@sha256:abc", | ||
| 27 | Name: "vole-runner1-run1-1", | ||
| 28 | Work: "/work/job", | ||
| 29 | Labels: map[string]string{"vole.runner": "1", "vole.runner.id": "runner1"}, | ||
| 30 | Mounts: []Mount{{Host: "/var/cache/demo", Path: "/cache"}}, | ||
| 31 | Env: map[string]string{"GOCACHE": "/cache/go-build"}, | ||
| 32 | }), " ") | ||
| 33 | |||
| 34 | for _, want := range []string{ | ||
| 35 | "run -d --rm", | ||
| 36 | "--name vole-runner1-run1-1", | ||
| 37 | "--label vole.runner=1", | ||
| 38 | "--label vole.runner.id=runner1", | ||
| 39 | "--workdir /workspace", | ||
| 40 | "--user 1000:1000", | ||
| 41 | "--userns keep-id", | ||
| 42 | "--volume /work/job:/workspace:rw", | ||
| 43 | "--volume /var/cache/demo:/cache:rw", | ||
| 44 | "--env GOCACHE=/cache/go-build", | ||
| 45 | "--cap-drop ALL", | ||
| 46 | "--security-opt no-new-privileges", | ||
| 47 | "--network none", | ||
| 48 | "--pull missing", | ||
| 49 | "--pids-limit 512", | ||
| 50 | "--memory 2g", | ||
| 51 | "--cpus 2", | ||
| 52 | "--tmpfs /tmp:rw,nosuid,nodev,exec,mode=1777", | ||
| 53 | "--entrypoint /bin/sh", | ||
| 54 | "golang:1.27@sha256:abc", | ||
| 55 | } { | ||
| 56 | if !strings.Contains(args, want) { | ||
| 57 | t.Fatalf("container arguments do not contain %q:\n%s", want, args) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | for _, unwanted := range []string{ | ||
| 61 | "--privileged", | ||
| 62 | "--network host", | ||
| 63 | "/var/run/docker.sock", | ||
| 64 | "SSH_AUTH_SOCK", | ||
| 65 | "/home/runner", | ||
| 66 | } { | ||
| 67 | if strings.Contains(args, unwanted) { | ||
| 68 | t.Fatalf("container arguments contain %q:\n%s", unwanted, args) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | // One workspace plus the declared cache; anything else would be a host path the | ||
| 72 | // job can reach without the caller knowing. | ||
| 73 | if strings.Count(args, "--volume") != 2 { | ||
| 74 | t.Fatalf("expected the workspace and the cache mount, got:\n%s", args) | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | // TestRunsAsRoot: the warning a caller prints depends on this, so it has to read | ||
| 79 | // the uid and not the whole "uid:gid" string. | ||
| 80 | func TestRunsAsRoot(t *testing.T) { | ||
| 81 | t.Parallel() | ||
| 82 | for _, tc := range []struct { | ||
| 83 | user string | ||
| 84 | want bool | ||
| 85 | }{ | ||
| 86 | {"0:0", true}, | ||
| 87 | {"0", true}, | ||
| 88 | {"root", true}, | ||
| 89 | {"1000:1000", false}, | ||
| 90 | {"0:1000", true}, | ||
| 91 | {"10:0", false}, | ||
| 92 | {"rootless:1000", false}, | ||
| 93 | } { | ||
| 94 | if got := RunsAsRoot(Config{User: tc.user}); got != tc.want { | ||
| 95 | t.Errorf("RunsAsRoot(%q) = %v, want %v", tc.user, got, tc.want) | ||
| 96 | } | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | func TestRunArgsOmitEmptyLimits(t *testing.T) { | ||
| 101 | t.Parallel() | ||
| 102 | cfg := Config{Runtime: "docker", Network: "none", Pull: "never", User: "1000:1000"} | ||
| 103 | args := strings.Join(RunArgs(cfg, Spec{Image: "alpine:3", Name: "name", Work: "/work"}), " ") | ||
| 104 | for _, unwanted := range []string{"--memory", "--cpus", "--pids-limit"} { | ||
| 105 | if strings.Contains(args, unwanted) { | ||
| 106 | t.Fatalf("an unset limit was passed as %q:\n%s", unwanted, args) | ||
| 107 | } | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | // A definition's digest is what its image means, so the tag follows the content | ||
| 112 | // rather than the name alone. | ||
| 113 | func TestImageTagFollowsTheDigest(t *testing.T) { | ||
| 114 | t.Parallel() | ||
| 115 | a := ImageTag("go", "0123456789abcdef0123") | ||
| 116 | b := ImageTag("go", "fedcba9876543210fedc") | ||
| 117 | if a == b { | ||
| 118 | t.Fatalf("two definitions produced the same tag %q", a) | ||
| 119 | } | ||
| 120 | if !strings.HasPrefix(a, "vole/image-file:go-") { | ||
| 121 | t.Fatalf("tag = %q", a) | ||
| 122 | } | ||
| 123 | if strings.Contains(a, "0123456789abcdef0123") { | ||
| 124 | t.Fatalf("tag kept the whole digest: %q", a) | ||
| 125 | } | ||
| 126 | } | ||