Luigit
repositories / bugabinga.net

bugabinga.net

personal infrastructure for bugabinga!

owned by admin

services/luci/internal/podman/storage.go

Raw
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, "<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:") }