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.
|
c5902451
Keenan Nemetz
16d ago
|
1 | package forge |
|
64ac5071
Keenan Nemetz
17d ago
|
2 | |
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "crypto/sha256" | |
| 6 | "encoding/hex" | |
| 7 | "testing" | |
| 8 | "time" | |
| 9 | ||
|
d37d301f
Keenan Nemetz
16h ago
|
10 | "rodent.software/vole/core/git" |
| 11 | "rodent.software/vole/core/lfs" | |
|
64ac5071
Keenan Nemetz
17d ago
|
12 | ) |
| 13 | ||
| 14 | // repoWithPointer builds a bare repository whose main branch commits a pointer | |
| 15 | // naming oid, then a second commit that replaces it with plain text. The object | |
| 16 | // is therefore reachable only through history — which is still reachable. | |
|
65b88b3b
Keenan Nemetz
16d ago
|
17 | // repoWithoutPointer is a repository whose history never mentions an LFS pointer. |
| 18 | func repoWithoutPointer(t *testing.T) string { | |
| 19 | t.Helper() | |
| 20 | dir := t.TempDir() + "/repo.git" | |
|
c5902451
Keenan Nemetz
16d ago
|
21 | if err := git.InitBare(dir, "main"); err != nil { |
|
65b88b3b
Keenan Nemetz
16d ago
|
22 | t.Fatal(err) |
| 23 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
24 | g := git.Open(dir) |
|
65b88b3b
Keenan Nemetz
16d ago
|
25 | text, err := g.HashObject([]byte("nothing but text here\n")) |
| 26 | if err != nil { | |
| 27 | t.Fatal(err) | |
| 28 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
29 | tree, err := g.MkTree([]git.Entry{{Mode: "100644", Type: "blob", SHA: text, Name: "README.md"}}) |
|
65b88b3b
Keenan Nemetz
16d ago
|
30 | if err != nil { |
| 31 | t.Fatal(err) | |
| 32 | } | |
| 33 | c, err := g.CommitTree(tree, "", "text only") | |
| 34 | if err != nil { | |
| 35 | t.Fatal(err) | |
| 36 | } | |
| 37 | if err := g.UpdateRef("refs/heads/main", c); err != nil { | |
| 38 | t.Fatal(err) | |
| 39 | } | |
| 40 | return dir | |
| 41 | } | |
| 42 | ||
|
64ac5071
Keenan Nemetz
17d ago
|
43 | func repoWithPointer(t *testing.T, oid string) string { |
| 44 | t.Helper() | |
| 45 | dir := t.TempDir() + "/repo.git" | |
|
c5902451
Keenan Nemetz
16d ago
|
46 | if err := git.InitBare(dir, "main"); err != nil { |
|
64ac5071
Keenan Nemetz
17d ago
|
47 | t.Fatal(err) |
| 48 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
49 | g := git.Open(dir) |
|
64ac5071
Keenan Nemetz
17d ago
|
50 | pointer, err := g.HashObject(lfs.Pointer{OID: oid, Size: 12}.Encode()) |
| 51 | if err != nil { | |
| 52 | t.Fatal(err) | |
| 53 | } | |
| 54 | text, err := g.HashObject([]byte("readme\n")) | |
| 55 | if err != nil { | |
| 56 | t.Fatal(err) | |
| 57 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
58 | tree1, err := g.MkTree([]git.Entry{ |
|
64ac5071
Keenan Nemetz
17d ago
|
59 | {Mode: "100644", Type: "blob", SHA: pointer, Name: "artifact.bin"}, |
| 60 | {Mode: "100644", Type: "blob", SHA: text, Name: "README.md"}, | |
| 61 | }) | |
| 62 | if err != nil { | |
| 63 | t.Fatal(err) | |
| 64 | } | |
| 65 | c1, err := g.CommitTree(tree1, "", "add the artifact") | |
| 66 | if err != nil { | |
| 67 | t.Fatal(err) | |
| 68 | } | |
|
c5902451
Keenan Nemetz
16d ago
|
69 | tree2, err := g.MkTree([]git.Entry{ |
|
64ac5071
Keenan Nemetz
17d ago
|
70 | {Mode: "100644", Type: "blob", SHA: text, Name: "README.md"}, |
| 71 | }) | |
| 72 | if err != nil { | |
| 73 | t.Fatal(err) | |
| 74 | } | |
| 75 | c2, err := g.CommitTree(tree2, c1, "drop the artifact") | |
| 76 | if err != nil { | |
| 77 | t.Fatal(err) | |
| 78 | } | |
| 79 | if err := g.UpdateRef("refs/heads/main", c2); err != nil { | |
| 80 | t.Fatal(err) | |
| 81 | } | |
| 82 | return dir | |
| 83 | } | |
| 84 | ||
| 85 | func storeWith(t *testing.T, blobs ...[]byte) (*lfs.Store, []string) { | |
| 86 | t.Helper() | |
| 87 | s := lfs.New(t.TempDir(), 1<<20) | |
| 88 | var oids []string | |
| 89 | for _, b := range blobs { | |
| 90 | sum := sha256.Sum256(b) | |
| 91 | oid := hex.EncodeToString(sum[:]) | |
|
65b88b3b
Keenan Nemetz
16d ago
|
92 | if _, _, err := s.Put("demo", oid, int64(len(b)), bytes.NewReader(b)); err != nil { |
|
64ac5071
Keenan Nemetz
17d ago
|
93 | t.Fatal(err) |
| 94 | } | |
| 95 | oids = append(oids, oid) | |
| 96 | } | |
| 97 | return s, oids | |
| 98 | } | |
| 99 | ||
| 100 | func TestReachableThroughHistorySurvivesAndGarbageDoesNot(t *testing.T) { | |
|
44530e74
Keenan Nemetz
16d ago
|
101 | t.Parallel() |
|
64ac5071
Keenan Nemetz
17d ago
|
102 | kept := []byte("the release artifact") |
| 103 | garbage := []byte("nobody points at this") | |
| 104 | store, oids := storeWith(t, kept, garbage) | |
| 105 | reachable, orphan := oids[0], oids[1] | |
| 106 | dir := repoWithPointer(t, reachable) | |
| 107 | ||
|
c5902451
Keenan Nemetz
16d ago
|
108 | res, err := GC(GCOptions{ |
| 109 | Repos: []GCRepo{{Name: "demo", Dir: dir}}, | |
|
64ac5071
Keenan Nemetz
17d ago
|
110 | Store: store, |
| 111 | Grace: 0, | |
| 112 | Now: time.Now().Add(time.Hour), | |
| 113 | }) | |
| 114 | if err != nil { | |
| 115 | t.Fatal(err) | |
| 116 | } | |
|
65b88b3b
Keenan Nemetz
16d ago
|
117 | if !store.Has("demo", reachable) { |
|
64ac5071
Keenan Nemetz
17d ago
|
118 | t.Fatal("swept an object a live ref reaches through history") |
| 119 | } | |
|
65b88b3b
Keenan Nemetz
16d ago
|
120 | if store.Has("demo", orphan) { |
|
64ac5071
Keenan Nemetz
17d ago
|
121 | t.Fatal("kept an object nothing references") |
| 122 | } | |
| 123 | if res.Swept != 1 || res.Marked != 1 { | |
| 124 | t.Fatalf("result %+v; want 1 marked, 1 swept", res) | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 128 | func TestGraceProtectsAFreshUpload(t *testing.T) { | |
|
44530e74
Keenan Nemetz
16d ago
|
129 | t.Parallel() |
|
64ac5071
Keenan Nemetz
17d ago
|
130 | kept := []byte("the release artifact") |
| 131 | fresh := []byte("uploaded a moment ago") | |
| 132 | store, oids := storeWith(t, kept, fresh) | |
| 133 | dir := repoWithPointer(t, oids[0]) | |
| 134 | ||
|
c5902451
Keenan Nemetz
16d ago
|
135 | res, err := GC(GCOptions{Repos: []GCRepo{{Name: "demo", Dir: dir}}, Store: store, Grace: time.Hour}) |
|
64ac5071
Keenan Nemetz
17d ago
|
136 | if err != nil { |
| 137 | t.Fatal(err) | |
| 138 | } | |
|
65b88b3b
Keenan Nemetz
16d ago
|
139 | if !store.Has("demo", oids[1]) { |
|
64ac5071
Keenan Nemetz
17d ago
|
140 | t.Fatal("an upload inside the grace period was swept") |
| 141 | } | |
| 142 | if res.Within != 1 || res.Swept != 0 { | |
| 143 | t.Fatalf("result %+v; want 1 within grace, 0 swept", res) | |
| 144 | } | |
| 145 | // The same store, an hour later: the grace has run out. | |
|
c5902451
Keenan Nemetz
16d ago
|
146 | res, err = GC(GCOptions{ |
| 147 | Repos: []GCRepo{{Name: "demo", Dir: dir}}, Store: store, Grace: time.Hour, | |
|
64ac5071
Keenan Nemetz
17d ago
|
148 | Now: time.Now().Add(2 * time.Hour), |
| 149 | }) | |
| 150 | if err != nil { | |
| 151 | t.Fatal(err) | |
| 152 | } | |
|
65b88b3b
Keenan Nemetz
16d ago
|
153 | if store.Has("demo", oids[1]) || res.Swept != 1 { |
|
64ac5071
Keenan Nemetz
17d ago
|
154 | t.Fatalf("result %+v; want the stale object swept", res) |
| 155 | } | |
|
65b88b3b
Keenan Nemetz
16d ago
|
156 | if !store.Has("demo", oids[0]) { |
|
64ac5071
Keenan Nemetz
17d ago
|
157 | t.Fatal("swept the reachable object along with the stale one") |
| 158 | } | |
| 159 | } | |
| 160 | ||
| 161 | func TestArchivedLogIsARoot(t *testing.T) { | |
|
44530e74
Keenan Nemetz
16d ago
|
162 | t.Parallel() |
|
64ac5071
Keenan Nemetz
17d ago
|
163 | log := []byte("$ go test ./...\nok\n") |
| 164 | store, oids := storeWith(t, log) | |
| 165 | ||
|
c5902451
Keenan Nemetz
16d ago
|
166 | res, err := GC(GCOptions{ |
|
64ac5071
Keenan Nemetz
17d ago
|
167 | Repos: nil, |
| 168 | Store: store, | |
|
65b88b3b
Keenan Nemetz
16d ago
|
169 | RunOIDs: map[string][]string{"demo": oids}, |
|
64ac5071
Keenan Nemetz
17d ago
|
170 | Grace: 0, |
| 171 | Now: time.Now().Add(time.Hour), | |
| 172 | }) | |
| 173 | if err != nil { | |
| 174 | t.Fatal(err) | |
| 175 | } | |
|
65b88b3b
Keenan Nemetz
16d ago
|
176 | if !store.Has("demo", oids[0]) { |
|
64ac5071
Keenan Nemetz
17d ago
|
177 | t.Fatal("swept an archived CI log; no git ref reaches one, so it must be a root") |
| 178 | } | |
| 179 | if res.Swept != 0 || res.Marked != 1 { | |
| 180 | t.Fatalf("result %+v", res) | |
| 181 | } | |
| 182 | } | |
| 183 | ||
| 184 | func TestDryRunRemovesNothing(t *testing.T) { | |
|
44530e74
Keenan Nemetz
16d ago
|
185 | t.Parallel() |
|
64ac5071
Keenan Nemetz
17d ago
|
186 | store, oids := storeWith(t, []byte("garbage")) |
|
c5902451
Keenan Nemetz
16d ago
|
187 | res, err := GC(GCOptions{Store: store, Grace: 0, DryRun: true, Now: time.Now().Add(time.Hour)}) |
|
64ac5071
Keenan Nemetz
17d ago
|
188 | if err != nil { |
| 189 | t.Fatal(err) | |
| 190 | } | |
|
65b88b3b
Keenan Nemetz
16d ago
|
191 | if !store.Has("demo", oids[0]) { |
|
64ac5071
Keenan Nemetz
17d ago
|
192 | t.Fatal("--dry-run deleted an object") |
| 193 | } | |
| 194 | if res.Swept != 1 { | |
| 195 | t.Fatalf("dry run reported %d removals; want 1", res.Swept) | |
| 196 | } | |
| 197 | } | |
|
65b88b3b
Keenan Nemetz
16d ago
|
198 | |
| 199 | // The sweep is also the repair. Nothing else creates a repository's links, so an object | |
| 200 | // reachable from a ref but absent from its namespace is invisible to that repository until | |
| 201 | // a pass links it — which is the path an instance upgrading onto scoped storage takes. | |
| 202 | func TestLinkRepairsAMissingNamespace(t *testing.T) { | |
| 203 | t.Parallel() | |
| 204 | kept := []byte("the release artifact") | |
| 205 | store, oids := storeWith(t, kept) | |
| 206 | reachable := oids[0] | |
| 207 | dir := repoWithPointer(t, reachable) | |
| 208 | ||
| 209 | // Drop the link, which is what an unscoped store looks like from a repository's side. | |
| 210 | if err := store.Unlink("demo", reachable); err != nil { | |
| 211 | t.Fatal(err) | |
| 212 | } | |
| 213 | if store.Has("demo", reachable) { | |
| 214 | t.Fatal("the object is still linked; the fixture proves nothing") | |
| 215 | } | |
| 216 | ||
|
c5902451
Keenan Nemetz
16d ago
|
217 | res, err := GCLink(GCOptions{ |
| 218 | Repos: []GCRepo{{Name: "demo", Dir: dir}}, | |
|
65b88b3b
Keenan Nemetz
16d ago
|
219 | Store: store, |
| 220 | Grace: 0, | |
| 221 | Now: time.Now().Add(time.Hour), | |
| 222 | }) | |
| 223 | if err != nil { | |
| 224 | t.Fatal(err) | |
| 225 | } | |
| 226 | if res.Linked != 1 { | |
| 227 | t.Fatalf("linked %d object(s), want 1 (%+v)", res.Linked, res) | |
| 228 | } | |
| 229 | if !store.Has("demo", reachable) { | |
| 230 | t.Fatal("the repair did not make the object reachable") | |
| 231 | } | |
| 232 | // A repair deletes nothing, however stale the store looks. | |
| 233 | if res.Swept != 0 || res.Unlinked != 0 { | |
| 234 | t.Fatalf("the repair pass changed more than links: %+v", res) | |
| 235 | } | |
| 236 | // And it is idempotent. | |
|
c5902451
Keenan Nemetz
16d ago
|
237 | again, err := GCLink(GCOptions{Repos: []GCRepo{{Name: "demo", Dir: dir}}, Store: store, Now: time.Now()}) |
|
65b88b3b
Keenan Nemetz
16d ago
|
238 | if err != nil { |
| 239 | t.Fatal(err) | |
| 240 | } | |
| 241 | if again.Linked != 0 { | |
| 242 | t.Fatalf("a second repair linked %d more object(s)", again.Linked) | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | // One repository dropping its pointer must not unlink the other repository's view: the | |
| 247 | // bytes are shared, the reachability is not. | |
| 248 | func TestOneRepositoryLosingAPointerKeepsTheOthersLink(t *testing.T) { | |
| 249 | t.Parallel() | |
| 250 | shared := []byte("one artifact, two projects") | |
| 251 | store, oids := storeWith(t, shared) | |
| 252 | oid := oids[0] | |
| 253 | // Give the second repository the same object, as a cross-repo upload would. | |
| 254 | if _, _, err := store.Put("other", oid, int64(len(shared)), bytes.NewReader(shared)); err != nil { | |
| 255 | t.Fatal(err) | |
| 256 | } | |
| 257 | dirA := repoWithPointer(t, oid) // demo reaches it | |
| 258 | dirB := repoWithoutPointer(t) // other does not | |
| 259 | ||
|
c5902451
Keenan Nemetz
16d ago
|
260 | res, err := GC(GCOptions{ |
| 261 | Repos: []GCRepo{{Name: "demo", Dir: dirA}, {Name: "other", Dir: dirB}}, | |
|
65b88b3b
Keenan Nemetz
16d ago
|
262 | Store: store, |
| 263 | Grace: 0, | |
| 264 | Now: time.Now().Add(time.Hour), | |
| 265 | }) | |
| 266 | if err != nil { | |
| 267 | t.Fatal(err) | |
| 268 | } | |
| 269 | if !store.Has("demo", oid) { | |
| 270 | t.Fatalf("the repository that still points at the object lost it (%+v)", res) | |
| 271 | } | |
| 272 | if store.Has("other", oid) { | |
| 273 | t.Fatalf("a repository that no longer points at the object kept its link (%+v)", res) | |
| 274 | } | |
| 275 | if res.Swept != 0 { | |
| 276 | t.Fatalf("swept the shared bytes while one repository still reaches them: %+v", res) | |
| 277 | } | |
| 278 | } |