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 · 22m ago
4b8c9e1d
| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "path/filepath" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | jobcache "rodent.software/vole/ci/cache" |
| 9 | ) |
| 10 | |
| 11 | // A cache is shared mutable state, so which namespace a job gets is a policy |
| 12 | // decision: one repository never sees another's, and an untrusted contributor |
| 13 | // branch never sees what a protected branch builds into. |
| 14 | func TestCacheNamespacesByRepoAndTrust(t *testing.T) { |
| 15 | t.Parallel() |
| 16 | root := t.TempDir() |
| 17 | s := &runnerSession{cacheRoot: root, cacheReuse: true} |
| 18 | |
| 19 | mounts, release := s.cacheFor(&runnerJob{Repo: "demo", Ref: "refs/heads/main", Image: "alpine:3.22"}) |
| 20 | defer release() |
| 21 | if len(mounts) != 1 || mounts[0].Path != "/cache" { |
| 22 | t.Fatalf("mounts = %+v, want one at /cache", mounts) |
| 23 | } |
| 24 | if !strings.HasPrefix(mounts[0].Host, root+string(filepath.Separator)) { |
| 25 | t.Fatalf("cache %q is not under the runner's cache root", mounts[0].Host) |
| 26 | } |
| 27 | if !strings.Contains(mounts[0].Host, "demo") { |
| 28 | t.Fatalf("cache %q is not namespaced by repository", mounts[0].Host) |
| 29 | } |
| 30 | |
| 31 | contribMounts, releaseContrib := s.cacheFor(&runnerJob{ |
| 32 | Repo: "demo", Ref: "refs/heads/contrib/alice/patch", Image: "alpine:3.22", |
| 33 | }) |
| 34 | defer releaseContrib() |
| 35 | if contribMounts[0].Host == mounts[0].Host { |
| 36 | t.Fatalf("a contributor branch shares the protected branch's cache: %s", mounts[0].Host) |
| 37 | } |
| 38 | |
| 39 | otherMounts, releaseOther := s.cacheFor(&runnerJob{Repo: "other", Ref: "refs/heads/main", Image: "alpine:3.22"}) |
| 40 | defer releaseOther() |
| 41 | if otherMounts[0].Host == mounts[0].Host { |
| 42 | t.Fatal("two repositories share a cache namespace") |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | func TestCacheCanBeTurnedOff(t *testing.T) { |
| 47 | t.Parallel() |
| 48 | s := &runnerSession{} |
| 49 | mounts, release := s.cacheFor(&runnerJob{Repo: "demo"}) |
| 50 | defer release() |
| 51 | if mounts != nil { |
| 52 | t.Fatalf("a disabled cache produced %v", mounts) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | // Two contributors get two caches. One contributor's untrusted branch must not be |
| 57 | // able to seed the cache another contributor's branch builds from, or the second |
| 58 | // branch inherits whatever the first one wrote. |
| 59 | func TestContributorCachesAreSeparatedByAccount(t *testing.T) { |
| 60 | t.Parallel() |
| 61 | s := &runnerSession{cacheRoot: t.TempDir(), cacheReuse: true} |
| 62 | alice, releaseAlice := s.cacheFor(&runnerJob{Repo: "demo", Ref: "refs/heads/contrib/alice/one", Image: "alpine:3.22"}) |
| 63 | defer releaseAlice() |
| 64 | bob, releaseBob := s.cacheFor(&runnerJob{Repo: "demo", Ref: "refs/heads/contrib/bob/one", Image: "alpine:3.22"}) |
| 65 | defer releaseBob() |
| 66 | if alice[0].Host == bob[0].Host { |
| 67 | t.Fatalf("two contributors share a cache: %s", alice[0].Host) |
| 68 | } |
| 69 | // The same contributor's second branch reuses their cache, which is the point of |
| 70 | // namespacing by account rather than by branch. |
| 71 | again, releaseAgain := s.cacheFor(&runnerJob{Repo: "demo", Ref: "refs/heads/contrib/alice/two", Image: "alpine:3.22"}) |
| 72 | defer releaseAgain() |
| 73 | if again[0].Host != alice[0].Host { |
| 74 | t.Fatalf("one contributor's branches got different caches: %s vs %s", alice[0].Host, again[0].Host) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // A job holds its namespace, so eviction has to skip it: the trimmer runs on the |
| 79 | // runner's own beat and may pass a mounted namespace in the middle of a build. |
| 80 | func TestAReusedCacheIsHeldAgainstEviction(t *testing.T) { |
| 81 | t.Parallel() |
| 82 | s := &runnerSession{cacheRoot: t.TempDir(), cacheReuse: true} |
| 83 | mounts, release := s.cacheFor(&runnerJob{Repo: "demo", Ref: "refs/heads/main", Image: "alpine:3.22"}) |
| 84 | defer release() |
| 85 | if _, held := jobcache.TryLockExclusive(s.cacheRoot, mounts[0].Host); held { |
| 86 | t.Fatal("eviction could take a namespace a job has mounted") |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // Two jobs of the same repository share their namespace: they are meant to run at |
| 91 | // once, and the tools inside them provide their own locking. Taking the second |
| 92 | // shared lock returning at all is the assertion. |
| 93 | func TestTwoJobsMayShareOneNamespace(t *testing.T) { |
| 94 | t.Parallel() |
| 95 | s := &runnerSession{cacheRoot: t.TempDir(), cacheReuse: true} |
| 96 | job := &runnerJob{Repo: "demo", Ref: "refs/heads/main", Image: "alpine:3.22"} |
| 97 | first, releaseFirst := s.cacheFor(job) |
| 98 | defer releaseFirst() |
| 99 | second, releaseSecond := s.cacheFor(job) |
| 100 | defer releaseSecond() |
| 101 | if first[0].Host != second[0].Host { |
| 102 | t.Fatalf("one repository got two caches: %s vs %s", first[0].Host, second[0].Host) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | // --no-cache empties the namespace when the job ends, so it must not run while |
| 107 | // anybody else is inside it. |
| 108 | func TestNoCacheHoldsTheNamespaceExclusively(t *testing.T) { |
| 109 | t.Parallel() |
| 110 | s := &runnerSession{cacheRoot: t.TempDir(), cacheReuse: false} |
| 111 | mounts, release := s.cacheFor(&runnerJob{Repo: "demo", Ref: "refs/heads/main", Image: "alpine:3.22"}) |
| 112 | defer release() |
| 113 | other, held := jobcache.TryLockExclusive(s.cacheRoot, mounts[0].Host) |
| 114 | if held { |
| 115 | other() |
| 116 | t.Fatal("a --no-cache job did not hold its namespace exclusively") |
| 117 | } |
| 118 | } |
| 119 |