image: add banger image cache prune for OCI cache cleanup

OCI layer blobs accumulate forever — every pull writes layers to
~/.cache/banger/oci/blobs/sha256/<hex> via go-containerregistry's
filesystem cache, and nothing ever evicts them. The cache is purely
a re-pull-avoidance (every flattened image is independent of the
blobs that sourced it), so it's a perfect candidate for an opt-in
operator-driven prune.

New surface:
  * api: ImageCachePruneParams{DryRun}, ImageCachePruneResult
    {BytesFreed, BlobsFreed, DryRun, CacheDir}.
  * daemon: ImageService.PruneOCICache walks layout.OCICacheDir for
    a (bytes, blobs) tally, then — outside dry-run — atomically
    renames the cache aside, recreates it empty, and rm -rf's the
    aside dir. The rename-then-rm avoids leaving the cache in a
    half-removed state if a pull starts mid-prune (the in-flight
    pull's open files survive the rename via standard Linux
    semantics; it just sees a fresh empty cache afterwards). Missing
    cache dir is treated as zero — fresh installs that have never
    pulled an OCI image don't error.
  * dispatch: image.cache.prune RPC (paramHandler-wrapped, mirroring
    every other image RPC). Documented-methods test list updated.
  * cli: `banger image cache` group with a `prune` subcommand
    (--dry-run flag). Output is a single line: "freed 1.2 GiB
    across 47 blob(s) in /var/cache/banger/oci" or "would free …".
    formatBytes helper for the size pretty-print.

docs/oci-import.md: replaced the "Tech debt: cache eviction" bullet
with a "Cache lifecycle" section describing the new command and
the in-flight-pull caveat.

Tests: PruneOCICache covers the happy path (real prune empties the
cache, recreates an empty dir, doesn't leak the .pruning- aside),
the dry-run path (returns size, leaves blobs intact), and the
fresh-install path (cache dir absent → zero result, no error).
Smoke at JOBS=4 still green; live exercise against an empty cache
on a system install prints the expected zero summary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-28 16:32:57 -03:00
parent 182bccf8af
commit 4d8dca6b72
No known key found for this signature in database
GPG key ID: 33112E6833C34679
7 changed files with 360 additions and 9 deletions

View file

@ -0,0 +1,125 @@
package daemon
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"banger/internal/api"
"banger/internal/paths"
)
// seedFakeOCICache drops a few fixed-size files that mimic an OCI
// layer cache layout (blobs/sha256/<hex>) so tests don't depend on
// real registry round-trips.
func seedFakeOCICache(t *testing.T, cacheDir string) (totalBytes int64, blobCount int) {
t.Helper()
blobsDir := filepath.Join(cacheDir, "blobs", "sha256")
if err := os.MkdirAll(blobsDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
for i, payload := range []string{"layer-a", "layer-b-bigger", "layer-c"} {
name := strings.Repeat("ab", 32) // 64 hex chars stand-in
path := filepath.Join(blobsDir, name+"-"+string(rune('0'+i)))
if err := os.WriteFile(path, []byte(payload), 0o644); err != nil {
t.Fatalf("write blob: %v", err)
}
totalBytes += int64(len(payload))
blobCount++
}
return totalBytes, blobCount
}
func TestPruneOCICacheDryRunReportsSizeWithoutDeleting(t *testing.T) {
cacheRoot := t.TempDir()
cacheDir := filepath.Join(cacheRoot, "oci")
wantBytes, wantBlobs := seedFakeOCICache(t, cacheDir)
d := &Daemon{layout: paths.Layout{OCICacheDir: cacheDir}}
wireServices(d)
res, err := d.img.PruneOCICache(context.Background(), api.ImageCachePruneParams{DryRun: true})
if err != nil {
t.Fatalf("PruneOCICache: %v", err)
}
if res.BytesFreed != wantBytes {
t.Fatalf("BytesFreed = %d, want %d", res.BytesFreed, wantBytes)
}
if res.BlobsFreed != wantBlobs {
t.Fatalf("BlobsFreed = %d, want %d", res.BlobsFreed, wantBlobs)
}
if !res.DryRun {
t.Error("result.DryRun = false, want true")
}
// Blobs must still exist.
entries, _ := os.ReadDir(filepath.Join(cacheDir, "blobs", "sha256"))
if len(entries) != wantBlobs {
t.Fatalf("blobs dir: got %d entries, want %d (dry-run must not delete)", len(entries), wantBlobs)
}
}
func TestPruneOCICacheRemovesAllBlobs(t *testing.T) {
cacheRoot := t.TempDir()
cacheDir := filepath.Join(cacheRoot, "oci")
wantBytes, wantBlobs := seedFakeOCICache(t, cacheDir)
d := &Daemon{layout: paths.Layout{OCICacheDir: cacheDir}}
wireServices(d)
res, err := d.img.PruneOCICache(context.Background(), api.ImageCachePruneParams{})
if err != nil {
t.Fatalf("PruneOCICache: %v", err)
}
if res.BytesFreed != wantBytes {
t.Fatalf("BytesFreed = %d, want %d", res.BytesFreed, wantBytes)
}
if res.BlobsFreed != wantBlobs {
t.Fatalf("BlobsFreed = %d, want %d", res.BlobsFreed, wantBlobs)
}
if res.DryRun {
t.Error("result.DryRun = true on a real prune")
}
// Cache dir must exist (recreated empty) so the next pull has a
// place to write blobs.
info, err := os.Stat(cacheDir)
if err != nil {
t.Fatalf("cache dir gone after prune: %v", err)
}
if !info.IsDir() {
t.Fatal("cache path is not a directory after prune")
}
// Blobs subdir is gone (the rename took everything aside; the
// recreate left only the bare cache dir).
if _, err := os.Stat(filepath.Join(cacheDir, "blobs")); !os.IsNotExist(err) {
t.Fatalf("blobs dir survived prune: %v", err)
}
// Aside dirs must have been cleaned up too.
roots, _ := os.ReadDir(cacheRoot)
for _, e := range roots {
if strings.Contains(e.Name(), ".pruning-") {
t.Errorf("aside dir leaked: %s", e.Name())
}
}
}
// TestPruneOCICacheMissingDirIsZeroResult covers the fresh-install
// case: no OCI pulls have ever happened, so the cache dir doesn't
// exist. Prune must report zero, not error.
func TestPruneOCICacheMissingDirIsZeroResult(t *testing.T) {
cacheRoot := t.TempDir()
cacheDir := filepath.Join(cacheRoot, "oci")
// Don't create cacheDir.
d := &Daemon{layout: paths.Layout{OCICacheDir: cacheDir}}
wireServices(d)
res, err := d.img.PruneOCICache(context.Background(), api.ImageCachePruneParams{})
if err != nil {
t.Fatalf("PruneOCICache(missing): %v", err)
}
if res.BytesFreed != 0 || res.BlobsFreed != 0 {
t.Fatalf("missing cache should be zero; got %+v", res)
}
}