repositories / bugabinga.net
bugabinga.net
personal infrastructure for bugabinga!
owned by admin
services/luci/internal/podman/storage.go
Rawpackage 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, "<none>") {
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:") }