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 | } | ||