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.

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 }