vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
3b39a84c
Keenan Nemetz
14d ago
|
1 | package cache |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
|
a29ce1f7
Keenan Nemetz
3d ago
|
6 | "strings" |
|
3b39a84c
Keenan Nemetz
14d ago
|
7 | "testing" |
| 8 | "time" | |
| 9 | ) | |
| 10 | ||
| 11 | // The namespace is the only thing separating one repository's cache from another's, | |
| 12 | // and an untrusted branch's from a protected one's, so the layout is a security | |
| 13 | // property and not an implementation detail. | |
| 14 | func TestNamespaceSeparatesReposAndTrust(t *testing.T) { | |
| 15 | t.Parallel() | |
| 16 | root := t.TempDir() | |
| 17 | direct := Namespace(root, "demo", "direct", ImageKey("abc123", "")) | |
| 18 | contrib := Namespace(root, "demo", "contrib", ImageKey("abc123", "")) | |
| 19 | other := Namespace(root, "other", "direct", ImageKey("abc123", "")) | |
| 20 | ||
| 21 | if direct == contrib || direct == other || contrib == other { | |
| 22 | t.Fatalf("namespaces collide:\n%s\n%s\n%s", direct, contrib, other) | |
| 23 | } | |
| 24 | for _, ns := range []string{direct, contrib, other} { | |
| 25 | if filepath.Dir(filepath.Dir(filepath.Dir(ns))) != root { | |
| 26 | t.Fatalf("%s is not three levels under %s", ns, root) | |
| 27 | } | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | // A definition's digest and a registry reference must not land on the same key, | |
| 32 | // and a reference full of slashes and colons must still be one path component. | |
| 33 | func TestImageKey(t *testing.T) { | |
| 34 | t.Parallel() | |
| 35 | a := ImageKey("0123456789abcdef0123", "") | |
| 36 | if a != "file-0123456789abcdef" { | |
| 37 | t.Fatalf("digest key = %q", a) | |
| 38 | } | |
| 39 | b := ImageKey("", "golang:1.27-bookworm") | |
| 40 | c := ImageKey("", "golang:1.27-bookworm") | |
| 41 | if b != c { | |
| 42 | t.Fatalf("the same reference produced two keys: %q and %q", b, c) | |
| 43 | } | |
| 44 | if b != filepath.Base(b) { | |
| 45 | t.Fatalf("key %q is not a single path component", b) | |
| 46 | } | |
| 47 | if d := ImageKey("", "golang:1.28-bookworm"); d == b { | |
| 48 | t.Fatalf("two references produced the same key %q", b) | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | func TestParseSize(t *testing.T) { | |
| 53 | t.Parallel() | |
| 54 | for input, want := range map[string]int64{ | |
| 55 | "1": 1, | |
| 56 | "512K": 512 << 10, | |
| 57 | "512KiB": 512 << 10, | |
| 58 | "100MB": 100 << 20, | |
| 59 | "5G": 5 << 30, | |
| 60 | "5GiB": 5 << 30, | |
| 61 | "2T": 2 << 40, | |
| 62 | " 64 M ": 64 << 20, | |
| 63 | } { | |
| 64 | got, err := ParseSize(input) | |
| 65 | if err != nil || got != want { | |
| 66 | t.Errorf("ParseSize(%q) = %d, %v; want %d", input, got, err, want) | |
| 67 | } | |
| 68 | } | |
| 69 | for _, bad := range []string{"", "0", "-1", "five", "5X", "G", "1.5G"} { | |
| 70 | if _, err := ParseSize(bad); err == nil { | |
| 71 | t.Errorf("ParseSize(%q) was accepted", bad) | |
| 72 | } | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | // A namespace is the eviction unit. Trimming must drop the least recently written | |
| 77 | // one entirely, including directories a compiler made read-only. | |
| 78 | func TestTrimDropsTheOldestNamespaceWhole(t *testing.T) { | |
| 79 | t.Parallel() | |
| 80 | root := t.TempDir() | |
| 81 | ||
| 82 | mk := func(repo, image string, bytes int, age time.Duration, readOnly bool) string { | |
| 83 | ns := Namespace(root, repo, "direct", image) | |
| 84 | if err := os.MkdirAll(ns, 0o755); err != nil { | |
| 85 | t.Fatal(err) | |
| 86 | } | |
| 87 | // Two files, one in a read-only directory: the shape a Go cache has. | |
| 88 | sub := filepath.Join(ns, "locked") | |
| 89 | if err := os.MkdirAll(sub, 0o755); err != nil { | |
| 90 | t.Fatal(err) | |
| 91 | } | |
| 92 | for _, p := range []string{filepath.Join(ns, "a"), filepath.Join(sub, "b")} { | |
| 93 | if err := os.WriteFile(p, make([]byte, bytes/2), 0o644); err != nil { | |
| 94 | t.Fatal(err) | |
| 95 | } | |
| 96 | } | |
| 97 | when := time.Now().Add(-age) | |
| 98 | _ = os.Chtimes(filepath.Join(ns, "a"), when, when) | |
| 99 | _ = os.Chtimes(filepath.Join(sub, "b"), when, when) | |
| 100 | if readOnly { | |
| 101 | if err := os.Chmod(sub, 0o555); err != nil { | |
| 102 | t.Fatal(err) | |
| 103 | } | |
| 104 | // The namespace is expected to be removed by the test; if it is not, the | |
| 105 | // failure is the assertion, not a cleanup error. | |
| 106 | t.Cleanup(func() { _ = os.Chmod(sub, 0o755) }) | |
| 107 | } | |
| 108 | return ns | |
| 109 | } | |
| 110 | ||
| 111 | old := mk("old", "ref-a", 1000, 2*time.Hour, true) | |
| 112 | fresh := mk("fresh", "ref-b", 1000, time.Minute, false) | |
| 113 | ||
| 114 | // Under the cap: nothing goes. | |
| 115 | if removed, err := Trim(root, 4000); err != nil || removed != 0 { | |
| 116 | t.Fatalf("trim under the cap = %d, %v", removed, err) | |
| 117 | } | |
| 118 | if _, err := os.Stat(old); err != nil { | |
| 119 | t.Fatalf("a namespace was removed under the cap: %v", err) | |
| 120 | } | |
| 121 | ||
| 122 | // Half the cap: the older namespace goes, whole, read-only parts included. | |
| 123 | if removed, err := Trim(root, 1500); err != nil || removed != 1000 { | |
| 124 | t.Fatalf("trim = %d, %v; want 1000 bytes", removed, err) | |
| 125 | } | |
| 126 | if _, err := os.Stat(old); !os.IsNotExist(err) { | |
| 127 | t.Fatalf("the oldest namespace survived: %v", err) | |
| 128 | } | |
| 129 | if _, err := os.Stat(fresh); err != nil { | |
| 130 | t.Fatalf("the newest namespace was removed: %v", err) | |
| 131 | } | |
| 132 | } | |
| 133 | ||
|
e6f0d8b9
Keenan Nemetz
22h ago
|
134 | // A namespace is aged by the work it holds, not by when its tree was unpacked. |
| 135 | // Directories are created when a cache is extracted, so counting their mtimes let a | |
| 136 | // two-hour-old cache outlive a one-hour-old one. That is the shape of the failure | |
| 137 | // that made this test pass on ext4 and fail on a runner. | |
| 138 | func TestTrimOrdersByContentNotDirectories(t *testing.T) { | |
| 139 | t.Parallel() | |
| 140 | root := t.TempDir() | |
| 141 | ||
| 142 | mk := func(repo, image string, content, dirs time.Time) string { | |
| 143 | ns := Namespace(root, repo, "direct", image) | |
| 144 | if err := os.MkdirAll(ns, 0o755); err != nil { | |
| 145 | t.Fatal(err) | |
| 146 | } | |
| 147 | p := filepath.Join(ns, "a") | |
| 148 | if err := os.WriteFile(p, make([]byte, 1000), 0o644); err != nil { | |
| 149 | t.Fatal(err) | |
| 150 | } | |
| 151 | if err := os.Chtimes(p, content, content); err != nil { | |
| 152 | t.Fatal(err) | |
| 153 | } | |
| 154 | if err := os.Chtimes(ns, dirs, dirs); err != nil { | |
| 155 | t.Fatal(err) | |
| 156 | } | |
| 157 | return ns | |
| 158 | } | |
| 159 | ||
| 160 | now := time.Now() | |
| 161 | old := mk("old", "ref-a", now.Add(-2*time.Hour), now) | |
| 162 | fresh := mk("fresh", "ref-b", now.Add(-time.Hour), now.Add(-time.Hour)) | |
| 163 | ||
| 164 | if removed, err := Trim(root, 1500); err != nil || removed != 1000 { | |
| 165 | t.Fatalf("trim = %d, %v; want 1000 bytes", removed, err) | |
| 166 | } | |
| 167 | if _, err := os.Stat(old); !os.IsNotExist(err) { | |
| 168 | t.Fatalf("the older content survived because its directory was touched later: %v", err) | |
| 169 | } | |
| 170 | if _, err := os.Stat(fresh); err != nil { | |
| 171 | t.Fatalf("the newer content was removed: %v", err) | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| 175 | // Two namespaces written at the same instant must not be ordered by chance: the same | |
| 176 | // one goes on every pass, so a runner sharing a directory does not evict a different | |
| 177 | // cache depending on which way the sort fell. | |
| 178 | func TestTrimBreaksATieDeterministically(t *testing.T) { | |
| 179 | t.Parallel() | |
| 180 | when := time.Now().Add(-time.Hour).Truncate(time.Second) | |
| 181 | victim := "" | |
| 182 | for run := 0; run < 20; run++ { | |
| 183 | root := t.TempDir() | |
| 184 | var built []string | |
| 185 | for _, image := range []string{"ref-a", "ref-b"} { | |
| 186 | ns := Namespace(root, "demo", "direct", image) | |
| 187 | if err := os.MkdirAll(ns, 0o755); err != nil { | |
| 188 | t.Fatal(err) | |
| 189 | } | |
| 190 | p := filepath.Join(ns, "a") | |
| 191 | if err := os.WriteFile(p, make([]byte, 1000), 0o644); err != nil { | |
| 192 | t.Fatal(err) | |
| 193 | } | |
| 194 | for _, path := range []string{p, ns} { | |
| 195 | if err := os.Chtimes(path, when, when); err != nil { | |
| 196 | t.Fatal(err) | |
| 197 | } | |
| 198 | } | |
| 199 | built = append(built, ns) | |
| 200 | } | |
| 201 | if removed, err := Trim(root, 1500); err != nil || removed != 1000 { | |
| 202 | t.Fatalf("run %d: trim = %d, %v; want 1000 bytes", run, removed, err) | |
| 203 | } | |
| 204 | gone := "" | |
| 205 | for _, ns := range built { | |
| 206 | if _, err := os.Stat(ns); os.IsNotExist(err) { | |
| 207 | gone = ns | |
| 208 | } | |
| 209 | } | |
| 210 | if gone == "" { | |
| 211 | t.Fatalf("run %d: nothing was removed", run) | |
| 212 | } | |
| 213 | if victim == "" { | |
| 214 | victim = gone | |
| 215 | continue | |
| 216 | } | |
| 217 | // t.TempDir makes a new root per run, so compare the namespace that went, | |
| 218 | // not where it was: the same name must be evicted every time. | |
| 219 | if filepath.Base(gone) != filepath.Base(victim) { | |
| 220 | t.Fatalf("run %d evicted %s, the first run evicted %s", | |
| 221 | run, filepath.Base(gone), filepath.Base(victim)) | |
| 222 | } | |
| 223 | } | |
| 224 | } | |
| 225 | ||
|
3b39a84c
Keenan Nemetz
14d ago
|
226 | // Clear is what --no-cache uses: the directory stays mounted, but nothing survives. |
| 227 | func TestClearRemovesEverything(t *testing.T) { | |
| 228 | t.Parallel() | |
| 229 | root := t.TempDir() | |
| 230 | for _, repo := range []string{"a", "b"} { | |
| 231 | ns := Namespace(root, repo, "direct", "ref-x") | |
| 232 | if err := os.MkdirAll(filepath.Join(ns, "locked"), 0o755); err != nil { | |
| 233 | t.Fatal(err) | |
| 234 | } | |
| 235 | if err := os.WriteFile(filepath.Join(ns, "locked", "f"), []byte("x"), 0o644); err != nil { | |
| 236 | t.Fatal(err) | |
| 237 | } | |
| 238 | if err := os.Chmod(filepath.Join(ns, "locked"), 0o555); err != nil { | |
| 239 | t.Fatal(err) | |
| 240 | } | |
| 241 | t.Cleanup(func() { _ = os.Chmod(filepath.Join(ns, "locked"), 0o755) }) | |
| 242 | } | |
| 243 | if err := Clear(root); err != nil { | |
| 244 | t.Fatalf("clear: %v", err) | |
| 245 | } | |
|
a29ce1f7
Keenan Nemetz
3d ago
|
246 | // The lock files under .locks are bookkeeping, not cache content: they stay, |
| 247 | // because removing one races with the next holder opening it. Everything a job | |
| 248 | // could have written is gone. | |
| 249 | var survived []string | |
| 250 | for _, m := range globAll(t, filepath.Join(root, "*", "*", "*")) { | |
| 251 | if !strings.Contains(m, string(filepath.Separator)+".locks"+string(filepath.Separator)) { | |
| 252 | survived = append(survived, m) | |
| 253 | } | |
| 254 | } | |
| 255 | if len(survived) != 0 { | |
| 256 | t.Fatalf("namespaces survived Clear: %v", survived) | |
| 257 | } | |
| 258 | } | |
| 259 | ||
| 260 | // globAll is filepath.Glob for a test, which turns a bad pattern into a failure | |
| 261 | // rather than a silently empty list. | |
| 262 | func globAll(t *testing.T, pattern string) []string { | |
| 263 | t.Helper() | |
| 264 | matches, err := filepath.Glob(pattern) | |
| 265 | if err != nil { | |
| 266 | t.Fatalf("glob %q: %v", pattern, err) | |
|
3b39a84c
Keenan Nemetz
14d ago
|
267 | } |
|
a29ce1f7
Keenan Nemetz
3d ago
|
268 | return matches |
|
3b39a84c
Keenan Nemetz
14d ago
|
269 | } |
| 270 | ||
| 271 | func TestTrimIsANoOpWithoutACap(t *testing.T) { | |
| 272 | t.Parallel() | |
| 273 | root := t.TempDir() | |
| 274 | ns := Namespace(root, "demo", "direct", "ref-a") | |
| 275 | if err := os.MkdirAll(ns, 0o755); err != nil { | |
| 276 | t.Fatal(err) | |
| 277 | } | |
| 278 | if removed, err := Trim(root, 0); removed != 0 || err != nil { | |
| 279 | t.Fatalf("trim with no cap = %d, %v", removed, err) | |
| 280 | } | |
| 281 | if _, err := os.Stat(ns); err != nil { | |
| 282 | t.Fatalf("a namespace was removed with no cap set: %v", err) | |
| 283 | } | |
| 284 | } | |
|
a29ce1f7
Keenan Nemetz
3d ago
|
285 | |
| 286 | // writeNamespace fills a namespace with one file of the given size and stamps it | |
| 287 | // with an age, which is what Trim sorts by. | |
| 288 | func writeNamespace(t *testing.T, ns string, bytes int, age time.Duration) { | |
| 289 | t.Helper() | |
| 290 | if err := os.MkdirAll(ns, 0o755); err != nil { | |
| 291 | t.Fatal(err) | |
| 292 | } | |
| 293 | file := filepath.Join(ns, "content") | |
| 294 | if err := os.WriteFile(file, make([]byte, bytes), 0o644); err != nil { | |
| 295 | t.Fatal(err) | |
| 296 | } | |
| 297 | when := time.Now().Add(-age) | |
| 298 | if err := os.Chtimes(file, when, when); err != nil { | |
| 299 | t.Fatal(err) | |
| 300 | } | |
| 301 | } | |
| 302 | ||
| 303 | // A job holds its namespace while it runs, so a trim that reaches it must move on | |
| 304 | // to the next candidate rather than delete a module cache out from under a build. | |
| 305 | func TestTrimSkipsANamespaceInUse(t *testing.T) { | |
| 306 | t.Parallel() | |
| 307 | root := t.TempDir() | |
| 308 | old := Namespace(root, "old", "direct", "ref-a") | |
| 309 | fresh := Namespace(root, "fresh", "direct", "ref-b") | |
| 310 | writeNamespace(t, old, 1000, 2*time.Hour) | |
| 311 | writeNamespace(t, fresh, 1000, time.Minute) | |
| 312 | ||
| 313 | release, err := Lock(root, old) | |
| 314 | if err != nil { | |
| 315 | t.Fatalf("lock: %v", err) | |
| 316 | } | |
| 317 | ||
| 318 | // The cap wants 500 bytes gone. The oldest namespace is the one in use, so the | |
| 319 | // trimmer takes the next one instead. | |
| 320 | removed, err := Trim(root, 1500) | |
| 321 | if err != nil || removed != 1000 { | |
| 322 | t.Fatalf("trim = %d, %v; want 1000 bytes from the newest namespace", removed, err) | |
| 323 | } | |
| 324 | if _, err := os.Stat(old); err != nil { | |
| 325 | t.Fatalf("a mounted namespace was removed: %v", err) | |
| 326 | } | |
| 327 | if _, err := os.Stat(fresh); !os.IsNotExist(err) { | |
| 328 | t.Fatalf("the trimmer did not fall through to the next namespace: %v", err) | |
| 329 | } | |
| 330 | ||
| 331 | // Once the job is done the namespace is a candidate again. | |
| 332 | release() | |
| 333 | if removed, err := Trim(root, 500); err != nil || removed != 1000 { | |
| 334 | t.Fatalf("trim after release = %d, %v; want 1000 bytes", removed, err) | |
| 335 | } | |
| 336 | if _, err := os.Stat(old); !os.IsNotExist(err) { | |
| 337 | t.Fatalf("the namespace survived once nobody held it: %v", err) | |
| 338 | } | |
| 339 | } | |
| 340 | ||
| 341 | // Clear is what --no-cache uses. It empties what nobody has mounted and leaves a | |
| 342 | // build alone, which is the difference between a fresh cache and a broken one. | |
| 343 | func TestClearSkipsANamespaceInUse(t *testing.T) { | |
| 344 | t.Parallel() | |
| 345 | root := t.TempDir() | |
| 346 | mine := Namespace(root, "mine", "direct", "ref-a") | |
| 347 | theirs := Namespace(root, "theirs", "direct", "ref-b") | |
| 348 | writeNamespace(t, mine, 10, time.Minute) | |
| 349 | writeNamespace(t, theirs, 10, time.Minute) | |
| 350 | ||
| 351 | release, err := Lock(root, theirs) | |
| 352 | if err != nil { | |
| 353 | t.Fatalf("lock: %v", err) | |
| 354 | } | |
| 355 | if err := Clear(root); err != nil { | |
| 356 | t.Fatalf("clear: %v", err) | |
| 357 | } | |
| 358 | if _, err := os.Stat(mine); !os.IsNotExist(err) { | |
| 359 | t.Fatalf("Clear left an unused namespace behind: %v", err) | |
| 360 | } | |
| 361 | if _, err := os.Stat(theirs); err != nil { | |
| 362 | t.Fatalf("Clear emptied a namespace a job has mounted: %v", err) | |
| 363 | } | |
| 364 | ||
| 365 | release() | |
| 366 | if err := Clear(root); err != nil { | |
| 367 | t.Fatalf("clear after release: %v", err) | |
| 368 | } | |
| 369 | if _, err := os.Stat(theirs); !os.IsNotExist(err) { | |
| 370 | t.Fatalf("the namespace survived once nobody held it: %v", err) | |
| 371 | } | |
| 372 | } | |
| 373 | ||
| 374 | // The lock file lives beside the namespace, not inside it: removing a namespace | |
| 375 | // must not remove the lock its holder is using, and the sweep must not mistake a | |
| 376 | // lock for a cache. | |
| 377 | func TestTheLockFileOutlivesItsNamespace(t *testing.T) { | |
| 378 | t.Parallel() | |
| 379 | root := t.TempDir() | |
| 380 | ns := Namespace(root, "demo", "direct", "ref-a") | |
| 381 | writeNamespace(t, ns, 10, time.Minute) | |
| 382 | ||
| 383 | lock, err := lockPath(root, ns) | |
| 384 | if err != nil { | |
| 385 | t.Fatalf("lock path: %v", err) | |
| 386 | } | |
| 387 | if strings.HasPrefix(lock, ns+string(filepath.Separator)) { | |
| 388 | t.Fatalf("the lock %q is inside the namespace it protects", lock) | |
| 389 | } | |
| 390 | ||
| 391 | release, err := Lock(root, ns) | |
| 392 | if err != nil { | |
| 393 | t.Fatalf("lock: %v", err) | |
| 394 | } | |
| 395 | release() | |
| 396 | if _, err := os.Stat(lock); err != nil { | |
| 397 | t.Fatalf("no lock file beside the namespace: %v", err) | |
| 398 | } | |
| 399 | ||
| 400 | if removed, err := Trim(root, 1); err != nil || removed != 10 { | |
| 401 | t.Fatalf("trim = %d, %v; want 10 bytes", removed, err) | |
| 402 | } | |
| 403 | if _, err := os.Stat(lock); err != nil { | |
| 404 | t.Fatalf("trimming the namespace removed the lock beside it: %v", err) | |
| 405 | } | |
| 406 | // Removing a lock file races with the next holder opening it, so it stays; a | |
| 407 | // later job has to be able to take it. | |
| 408 | again, err := Lock(root, ns) | |
| 409 | if err != nil { | |
| 410 | t.Fatalf("lock after the namespace was evicted: %v", err) | |
| 411 | } | |
| 412 | again() | |
| 413 | } | |
| 414 | ||
| 415 | // Two jobs and a trimmer on one root: neither holder may lose its tree, however | |
| 416 | // often the disk-pressure check runs. | |
| 417 | func TestATrimmerNeverTakesANamespaceInUse(t *testing.T) { | |
| 418 | t.Parallel() | |
| 419 | root := t.TempDir() | |
| 420 | first := Namespace(root, "one", "direct", "ref-a") | |
| 421 | second := Namespace(root, "two", "direct", "ref-b") | |
| 422 | writeNamespace(t, first, 100, time.Minute) | |
| 423 | writeNamespace(t, second, 100, time.Minute) | |
| 424 | ||
| 425 | releaseFirst, err := Lock(root, first) | |
| 426 | if err != nil { | |
| 427 | t.Fatalf("lock: %v", err) | |
| 428 | } | |
| 429 | defer releaseFirst() | |
| 430 | releaseSecond, err := Lock(root, second) | |
| 431 | if err != nil { | |
| 432 | t.Fatalf("lock: %v", err) | |
| 433 | } | |
| 434 | defer releaseSecond() | |
| 435 | ||
| 436 | done := make(chan struct{}) | |
| 437 | go func() { | |
| 438 | defer close(done) | |
| 439 | for i := 0; i < 20; i++ { | |
| 440 | // A cap that wants everything gone: every pass has to skip both. | |
| 441 | if _, err := Trim(root, 1); err != nil { | |
| 442 | return | |
| 443 | } | |
| 444 | } | |
| 445 | }() | |
| 446 | <-done | |
| 447 | ||
| 448 | for _, ns := range []string{first, second} { | |
| 449 | if _, err := os.Stat(ns); err != nil { | |
| 450 | t.Fatalf("the trimmer removed a mounted namespace: %v", err) | |
| 451 | } | |
| 452 | } | |
| 453 | } |