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

@ -45,10 +45,95 @@ Subcommands:
d.newImageListCommand(),
d.newImageShowCommand(),
d.newImageDeleteCommand(),
d.newImageCacheCommand(),
)
return cmd
}
// newImageCacheCommand groups OCI-cache lifecycle subcommands. Today
// the only one is `prune`; future additions (size, list, etc.) plug
// in here without polluting the top-level `image` namespace.
func (d *deps) newImageCacheCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "Manage banger's OCI layer-blob cache",
Long: strings.TrimSpace(`
banger keeps a local copy of every OCI layer it downloads so a re-pull
of the same image (or any image that shares a base layer) skips the
network round-trip. The cache lives under the daemon's CacheDir
(see 'banger doctor' or docs/config.md). Layers accumulate forever;
'banger image cache prune' is the cheap way to reclaim disk.
`),
Example: strings.TrimSpace(`
banger image cache prune --dry-run
banger image cache prune
`),
RunE: helpNoArgs,
}
cmd.AddCommand(d.newImageCachePruneCommand())
return cmd
}
func (d *deps) newImageCachePruneCommand() *cobra.Command {
var dryRun bool
cmd := &cobra.Command{
Use: "prune",
Short: "Remove every cached OCI layer blob",
Long: strings.TrimSpace(`
Removes every layer blob under the OCI cache. Registered banger
images are independent of the cache (each pull flattens layers into
a self-contained ext4), so prune only loses re-pull avoidance the
next pull of the same image re-downloads the layers it needs.
Safe to run any time the daemon is idle. If you have an image pull
in flight when you run prune, that pull may fail and need a retry.
--dry-run reports the byte count without removing anything.
`),
Args: noArgsUsage("usage: banger image cache prune [--dry-run]"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := rpc.Call[api.ImageCachePruneResult](cmd.Context(), layout.SocketPath, "image.cache.prune", api.ImageCachePruneParams{DryRun: dryRun})
if err != nil {
return err
}
out := cmd.OutOrStdout()
verb := "freed"
if result.DryRun {
verb = "would free"
}
_, err = fmt.Fprintf(out, "%s %s across %d blob(s) in %s\n",
verb, formatBytes(result.BytesFreed), result.BlobsFreed, result.CacheDir)
return err
},
}
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "report the size that would be freed without deleting anything")
return cmd
}
// formatBytes renders a byte count as a short human-readable string
// (e.g. "1.2 GiB", "456 MiB"). Zero stays "0 B" for clarity.
func formatBytes(n int64) string {
const (
ki = 1024
mi = ki * 1024
gi = mi * 1024
)
switch {
case n >= gi:
return fmt.Sprintf("%.1f GiB", float64(n)/float64(gi))
case n >= mi:
return fmt.Sprintf("%.1f MiB", float64(n)/float64(mi))
case n >= ki:
return fmt.Sprintf("%.1f KiB", float64(n)/float64(ki))
default:
return fmt.Sprintf("%d B", n)
}
}
func (d *deps) newImageRegisterCommand() *cobra.Command {
var params api.ImageRegisterParams
cmd := &cobra.Command{