package daemon import ( "context" crand "crypto/rand" "encoding/hex" "fmt" "io/fs" "os" "path/filepath" "banger/internal/api" ) // PruneOCICache removes every blob under the OCI layer cache. The // cache is purely a re-pull-avoidance (every flattened image is // independent of the blobs that sourced it), so the worst-case // outcome of pruning is "next pull of the same ref re-downloads its // layers" — a reasonable disk-hygiene knob. // // DryRun=true walks the cache and returns the size that WOULD be // freed without touching anything; tests and CLI consumers print // that summary so the operator can decide. // // Concurrent in-flight pulls may break if they're mid-fetch when // the rename happens. That tradeoff is documented in the CLI help // and docs/oci-import.md; the prune is an operator action, not a // background sweep. func (s *ImageService) PruneOCICache(_ context.Context, params api.ImageCachePruneParams) (api.ImageCachePruneResult, error) { cacheDir := s.layout.OCICacheDir bytes, blobs, err := walkCacheUsage(cacheDir) if err != nil { return api.ImageCachePruneResult{}, fmt.Errorf("inspect oci cache: %w", err) } res := api.ImageCachePruneResult{ BytesFreed: bytes, BlobsFreed: blobs, DryRun: params.DryRun, CacheDir: cacheDir, } if params.DryRun || blobs == 0 { return res, nil } // Atomic rename aside so a follow-up pull doesn't see a half- // removed tree, then rm -rf the renamed dir, then recreate the // empty cache so future pulls find their write target. aside, err := renameAside(cacheDir) if err != nil { if os.IsNotExist(err) { return res, nil } return api.ImageCachePruneResult{}, fmt.Errorf("rename oci cache aside: %w", err) } if err := os.MkdirAll(cacheDir, 0o755); err != nil { // Best-effort restore: try to rename back so the caller // isn't left with a vanished cache dir. If both moves // failed, surface both — the operator needs to know. if restoreErr := os.Rename(aside, cacheDir); restoreErr != nil { return api.ImageCachePruneResult{}, fmt.Errorf("recreate oci cache: %w (also failed to restore from %s: %v)", err, aside, restoreErr) } return api.ImageCachePruneResult{}, fmt.Errorf("recreate oci cache: %w", err) } if err := os.RemoveAll(aside); err != nil { return api.ImageCachePruneResult{}, fmt.Errorf("remove old oci cache (%s): %w", aside, err) } return res, nil } func walkCacheUsage(cacheDir string) (int64, int, error) { var bytes int64 var blobs int err := filepath.WalkDir(cacheDir, func(path string, d fs.DirEntry, err error) error { if err != nil { // Cache dir doesn't exist yet (fresh install, no OCI // pulls so far) — that's not a prune error, it's a // 0-byte / 0-blob result. if os.IsNotExist(err) && path == cacheDir { return filepath.SkipAll } return err } if d.IsDir() { return nil } info, err := d.Info() if err != nil { return err } bytes += info.Size() blobs++ return nil }) if err != nil { return 0, 0, err } return bytes, blobs, nil } // renameAside moves cacheDir to a sibling temp path so the prune can // rm-rf it without racing against fresh writes. Returns the aside // path on success. func renameAside(cacheDir string) (string, error) { var suffix [8]byte if _, err := crand.Read(suffix[:]); err != nil { return "", err } aside := cacheDir + ".pruning-" + hex.EncodeToString(suffix[:]) if err := os.Rename(cacheDir, aside); err != nil { return "", err } return aside, nil }