package podman import ( "bytes" "context" "encoding/json" "fmt" "io" "sort" "strings" "time" ) const BuildCacheLabel = "io.luci.build=cache-v1" const maxEngineOutput = 16 << 20 type StorageInfo struct { Allocated int64 Used int64 } func (s StorageInfo) Free() int64 { return s.Allocated - s.Used } type Image struct { ID string `json:"Id"` Size int64 `json:"Size"` Created int64 `json:"Created"` Containers int `json:"Containers"` Names []string `json:"Names"` Labels map[string]string `json:"Labels"` } func (i Image) Dangling() bool { if i.Containers != 0 || len(i.Names) == 0 { return i.Containers == 0 && len(i.Names) == 0 } for _, name := range i.Names { if !strings.HasPrefix(name, "") { return false } } return true } func (i Image) CreatedAt() time.Time { return time.Unix(i.Created, 0) } type CacheReport struct { Bytes int64 Removed []string Skipped []string } // StorageInfoFor asks the selected Podman engine, not Luci's data directory. // graphRootAllocated and graphRootUsed are reported by podman info's Store. func StorageInfoFor(ctx context.Context, executor Executor) (StorageInfo, error) { data, err := output(ctx, executor, []string{"info", "--format", "json"}) if err != nil { return StorageInfo{}, fmt.Errorf("query Podman storage: %w", err) } var info struct { Store struct { Allocated int64 `json:"graphRootAllocated"` Used int64 `json:"graphRootUsed"` } `json:"store"` } if err := json.Unmarshal(data, &info); err != nil { return StorageInfo{}, fmt.Errorf("decode Podman storage: %w", err) } if info.Store.Allocated <= 0 || info.Store.Used < 0 || info.Store.Used > info.Store.Allocated { return StorageInfo{}, fmt.Errorf("podman engine did not report usable graphRootAllocated/graphRootUsed; mount and configure an operator-owned graphroot filesystem or use a Podman version exposing these fields") } return StorageInfo{Allocated: info.Store.Allocated, Used: info.Store.Used}, nil } // LuciBuildImages returns only engine images carrying Luci's exact cache label. func LuciBuildImages(ctx context.Context, executor Executor) ([]Image, error) { data, err := output(ctx, executor, []string{"image", "ls", "--all", "--filter", "label=" + BuildCacheLabel, "--format", "json"}) if err != nil { return nil, fmt.Errorf("list Luci build cache: %w", err) } var images []Image if err := json.Unmarshal(data, &images); err != nil { return nil, fmt.Errorf("decode Luci build cache: %w", err) } unique := make(map[string]Image, len(images)) for _, image := range images { if image.ID == "" || image.Size < 0 || image.Labels["io.luci.build"] != "cache-v1" { continue } unique[image.ID] = image } images = images[:0] for _, image := range unique { images = append(images, image) } sort.Slice(images, func(i, j int) bool { return images[i].Created < images[j].Created }) return images, nil } // PruneLuciBuildCache removes only Luci-labelled, untagged, unused images. // Size accounting is the engine's per-image byte report, intentionally // conservative because shared layers cannot be attributed exactly. Removal is // always non-forcing, so a reference appearing after inspection is preserved. func PruneLuciBuildCache(ctx context.Context, executor Executor, maxBytes int64, maxAge time.Duration, protected map[string]bool, now time.Time) (CacheReport, error) { images, err := LuciBuildImages(ctx, executor) if err != nil { return CacheReport{}, err } report := CacheReport{} candidates := make([]Image, 0, len(images)) for _, image := range images { if !image.Dangling() { continue } report.Bytes += image.Size if protected[image.ID] || protected[trimID(image.ID)] { report.Skipped = append(report.Skipped, image.ID) continue } candidates = append(candidates, image) } for _, image := range candidates { tooOld := maxAge > 0 && now.Sub(image.CreatedAt()) > maxAge if !tooOld && report.Bytes <= maxBytes { continue } if _, err := output(ctx, executor, []string{"image", "rm", image.ID}); err != nil { return report, fmt.Errorf("remove Luci build cache image %s without force: %w", image.ID, err) } report.Bytes -= image.Size report.Removed = append(report.Removed, image.ID) } if report.Bytes > maxBytes { return report, fmt.Errorf("luci build cache reports %d bytes after safe cleanup, above budget %d; active, tagged, or externally referenced Luci images were preserved", report.Bytes, maxBytes) } return report, nil } func output(ctx context.Context, executor Executor, args []string) ([]byte, error) { var stdout limitedBuffer var stderr limitedBuffer err := executor.Run(ctx, args, &stdout, &stderr) if stdout.err != nil || stderr.err != nil { return nil, fmt.Errorf("podman output exceeds %d bytes", maxEngineOutput) } if err != nil { return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) } return stdout.Bytes(), nil } type limitedBuffer struct { bytes.Buffer err error } func (b *limitedBuffer) Write(p []byte) (int, error) { if b.Len()+len(p) > maxEngineOutput { b.err = io.ErrShortBuffer return 0, b.err } return b.Buffer.Write(p) } func trimID(id string) string { return strings.TrimPrefix(id, "sha256:") }