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.
| 1 | package cache |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 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 | |
| 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 | |
| 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 | } |
| 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) |
| 267 | } |
| 268 | return matches |
| 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 | } |
| 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 | } |
| 454 |