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/cache/cache_test.go +171

@@ -0,0 +1,171 @@
1 package cache
2
3 import (
4 "os"
5 "path/filepath"
6 "testing"
7 "time"
8 )
9
10 // The namespace is the only thing separating one repository's cache from another's,
11 // and an untrusted branch's from a protected one's, so the layout is a security
12 // property and not an implementation detail.
13 func TestNamespaceSeparatesReposAndTrust(t *testing.T) {
14 t.Parallel()
15 root := t.TempDir()
16 direct := Namespace(root, "demo", "direct", ImageKey("abc123", ""))
17 contrib := Namespace(root, "demo", "contrib", ImageKey("abc123", ""))
18 other := Namespace(root, "other", "direct", ImageKey("abc123", ""))
19
20 if direct == contrib || direct == other || contrib == other {
21 t.Fatalf("namespaces collide:\n%s\n%s\n%s", direct, contrib, other)
22 }
23 for _, ns := range []string{direct, contrib, other} {
24 if filepath.Dir(filepath.Dir(filepath.Dir(ns))) != root {
25 t.Fatalf("%s is not three levels under %s", ns, root)
26 }
27 }
28 }
29
30 // A definition's digest and a registry reference must not land on the same key,
31 // and a reference full of slashes and colons must still be one path component.
32 func TestImageKey(t *testing.T) {
33 t.Parallel()
34 a := ImageKey("0123456789abcdef0123", "")
35 if a != "file-0123456789abcdef" {
36 t.Fatalf("digest key = %q", a)
37 }
38 b := ImageKey("", "golang:1.27-bookworm")
39 c := ImageKey("", "golang:1.27-bookworm")
40 if b != c {
41 t.Fatalf("the same reference produced two keys: %q and %q", b, c)
42 }
43 if b != filepath.Base(b) {
44 t.Fatalf("key %q is not a single path component", b)
45 }
46 if d := ImageKey("", "golang:1.28-bookworm"); d == b {
47 t.Fatalf("two references produced the same key %q", b)
48 }
49 }
50
51 func TestParseSize(t *testing.T) {
52 t.Parallel()
53 for input, want := range map[string]int64{
54 "1": 1,
55 "512K": 512 << 10,
56 "512KiB": 512 << 10,
57 "100MB": 100 << 20,
58 "5G": 5 << 30,
59 "5GiB": 5 << 30,
60 "2T": 2 << 40,
61 " 64 M ": 64 << 20,
62 } {
63 got, err := ParseSize(input)
64 if err != nil || got != want {
65 t.Errorf("ParseSize(%q) = %d, %v; want %d", input, got, err, want)
66 }
67 }
68 for _, bad := range []string{"", "0", "-1", "five", "5X", "G", "1.5G"} {
69 if _, err := ParseSize(bad); err == nil {
70 t.Errorf("ParseSize(%q) was accepted", bad)
71 }
72 }
73 }
74
75 // A namespace is the eviction unit. Trimming must drop the least recently written
76 // one entirely, including directories a compiler made read-only.
77 func TestTrimDropsTheOldestNamespaceWhole(t *testing.T) {
78 t.Parallel()
79 root := t.TempDir()
80
81 mk := func(repo, image string, bytes int, age time.Duration, readOnly bool) string {
82 ns := Namespace(root, repo, "direct", image)
83 if err := os.MkdirAll(ns, 0o755); err != nil {
84 t.Fatal(err)
85 }
86 // Two files, one in a read-only directory: the shape a Go cache has.
87 sub := filepath.Join(ns, "locked")
88 if err := os.MkdirAll(sub, 0o755); err != nil {
89 t.Fatal(err)
90 }
91 for _, p := range []string{filepath.Join(ns, "a"), filepath.Join(sub, "b")} {
92 if err := os.WriteFile(p, make([]byte, bytes/2), 0o644); err != nil {
93 t.Fatal(err)
94 }
95 }
96 when := time.Now().Add(-age)
97 _ = os.Chtimes(filepath.Join(ns, "a"), when, when)
98 _ = os.Chtimes(filepath.Join(sub, "b"), when, when)
99 if readOnly {
100 if err := os.Chmod(sub, 0o555); err != nil {
101 t.Fatal(err)
102 }
103 // The namespace is expected to be removed by the test; if it is not, the
104 // failure is the assertion, not a cleanup error.
105 t.Cleanup(func() { _ = os.Chmod(sub, 0o755) })
106 }
107 return ns
108 }
109
110 old := mk("old", "ref-a", 1000, 2*time.Hour, true)
111 fresh := mk("fresh", "ref-b", 1000, time.Minute, false)
112
113 // Under the cap: nothing goes.
114 if removed, err := Trim(root, 4000); err != nil || removed != 0 {
115 t.Fatalf("trim under the cap = %d, %v", removed, err)
116 }
117 if _, err := os.Stat(old); err != nil {
118 t.Fatalf("a namespace was removed under the cap: %v", err)
119 }
120
121 // Half the cap: the older namespace goes, whole, read-only parts included.
122 if removed, err := Trim(root, 1500); err != nil || removed != 1000 {
123 t.Fatalf("trim = %d, %v; want 1000 bytes", removed, err)
124 }
125 if _, err := os.Stat(old); !os.IsNotExist(err) {
126 t.Fatalf("the oldest namespace survived: %v", err)
127 }
128 if _, err := os.Stat(fresh); err != nil {
129 t.Fatalf("the newest namespace was removed: %v", err)
130 }
131 }
132
133 // Clear is what --no-cache uses: the directory stays mounted, but nothing survives.
134 func TestClearRemovesEverything(t *testing.T) {
135 t.Parallel()
136 root := t.TempDir()
137 for _, repo := range []string{"a", "b"} {
138 ns := Namespace(root, repo, "direct", "ref-x")
139 if err := os.MkdirAll(filepath.Join(ns, "locked"), 0o755); err != nil {
140 t.Fatal(err)
141 }
142 if err := os.WriteFile(filepath.Join(ns, "locked", "f"), []byte("x"), 0o644); err != nil {
143 t.Fatal(err)
144 }
145 if err := os.Chmod(filepath.Join(ns, "locked"), 0o555); err != nil {
146 t.Fatal(err)
147 }
148 t.Cleanup(func() { _ = os.Chmod(filepath.Join(ns, "locked"), 0o755) })
149 }
150 if err := Clear(root); err != nil {
151 t.Fatalf("clear: %v", err)
152 }
153 if matches, _ := filepath.Glob(filepath.Join(root, "*", "*", "*")); len(matches) != 0 {
154 t.Fatalf("namespaces survived Clear: %v", matches)
155 }
156 }
157
158 func TestTrimIsANoOpWithoutACap(t *testing.T) {
159 t.Parallel()
160 root := t.TempDir()
161 ns := Namespace(root, "demo", "direct", "ref-a")
162 if err := os.MkdirAll(ns, 0o755); err != nil {
163 t.Fatal(err)
164 }
165 if removed, err := Trim(root, 0); removed != 0 || err != nil {
166 t.Fatalf("trim with no cap = %d, %v", removed, err)
167 }
168 if _, err := os.Stat(ns); err != nil {
169 t.Fatalf("a namespace was removed with no cap set: %v", err)
170 }
171 }