300 lines
8.5 KiB
Go
300 lines
8.5 KiB
Go
// Package registryprune deletes the manifests in the platform registry that
|
|
// nothing uses any more, so the registry-gc sidecar's next sweep can free their
|
|
// layers. Without it the registry only grows: every rebuild of a tag leaves the
|
|
// previous manifest behind, a whitelist entry an admin removes keeps its image,
|
|
// and each installer run adds a platform image.
|
|
//
|
|
// A manifest is kept when any of these hold, and so is every manifest a kept
|
|
// image index names (its per-platform images and attestations):
|
|
//
|
|
// - an image reference the platform still depends on names it: a whitelist
|
|
// entry (by tag, by wildcard tag, or by digest), a server's spec (servers are
|
|
// pinned by digest, so a sleeping server keeps the exact build it was created
|
|
// with even after its tag moved on), or the images the control plane and the
|
|
// build Jobs run;
|
|
// - it was pushed less than Grace ago, which covers a build that pushed but has
|
|
// not been admitted to the whitelist yet;
|
|
// - it lives under a platform-reserved root (felis/, mirror/), is tagged, and is
|
|
// among the KeepTagged most recently pushed tagged manifests of its
|
|
// repository. The installer owns those tags; the newest few stay for a
|
|
// rollback, the older ones go.
|
|
//
|
|
// Everything else is deleted through the registry gate as the prune principal,
|
|
// which may do nothing but delete a manifest by digest.
|
|
package registryprune
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"felis.lolicon.best/internal/registrygate"
|
|
)
|
|
|
|
// Defaults for Pruner's zero fields.
|
|
const (
|
|
DefaultGrace = 24 * time.Hour
|
|
DefaultKeepTagged = 5
|
|
)
|
|
|
|
// Registry is the pruner's view of the registry (Client in production).
|
|
type Registry interface {
|
|
Repositories(ctx context.Context) ([]string, error)
|
|
Index(ctx context.Context, repo string) (*registrygate.Index, error)
|
|
Delete(ctx context.Context, repo, digest string) error
|
|
}
|
|
|
|
// Pruner decides and deletes.
|
|
type Pruner struct {
|
|
Registry Registry
|
|
// Host is the registry host[:port] image refs spell (registry.felis.svc:5000).
|
|
// Refs under any other host are not this registry's and are ignored.
|
|
Host string
|
|
// Refs lists every image reference still in use. An error aborts the run: a
|
|
// partial view could delete an image something depends on.
|
|
Refs func(ctx context.Context) ([]string, error)
|
|
// Grace keeps manifests pushed this recently. Zero means DefaultGrace.
|
|
Grace time.Duration
|
|
// KeepTagged is how many tagged manifests per reserved repository survive by
|
|
// recency alone. Zero means DefaultKeepTagged.
|
|
KeepTagged int
|
|
// Now is the clock; nil means time.Now.
|
|
Now func() time.Time
|
|
// Log receives one line per deletion and a summary. Nil discards.
|
|
Log *slog.Logger
|
|
}
|
|
|
|
// Target is one manifest the pruner deletes.
|
|
type Target struct {
|
|
Repo, Digest string
|
|
}
|
|
|
|
func (t Target) String() string { return t.Repo + "@" + t.Digest }
|
|
|
|
// Report summarizes one run.
|
|
type Report struct {
|
|
Repositories int
|
|
Manifests int
|
|
Deleted []Target
|
|
// Failed counts deletions the registry refused; they are retried next run.
|
|
Failed int
|
|
}
|
|
|
|
// Run lists the registry, decides, and deletes.
|
|
func (p *Pruner) Run(ctx context.Context) (Report, error) {
|
|
var rep Report
|
|
if p.Registry == nil || p.Refs == nil || p.Host == "" {
|
|
return rep, errors.New("registryprune: Registry, Refs and Host are required")
|
|
}
|
|
refs, err := p.Refs(ctx)
|
|
if err != nil {
|
|
return rep, fmt.Errorf("registryprune: list image references: %w", err)
|
|
}
|
|
repos, err := p.Registry.Repositories(ctx)
|
|
if err != nil {
|
|
return rep, fmt.Errorf("registryprune: list repositories: %w", err)
|
|
}
|
|
indexes := make(map[string]*registrygate.Index, len(repos))
|
|
for _, repo := range repos {
|
|
idx, err := p.Registry.Index(ctx, repo)
|
|
if err != nil {
|
|
return rep, fmt.Errorf("registryprune: index %s: %w", repo, err)
|
|
}
|
|
indexes[repo] = idx
|
|
rep.Manifests += len(idx.Revisions)
|
|
}
|
|
rep.Repositories = len(repos)
|
|
|
|
now := time.Now
|
|
if p.Now != nil {
|
|
now = p.Now
|
|
}
|
|
for _, t := range Plan(indexes, refs, p.Host, now(), p.grace(), p.keepTagged()) {
|
|
if err := p.Registry.Delete(ctx, t.Repo, t.Digest); err != nil {
|
|
rep.Failed++
|
|
p.log().Warn("registry prune: delete failed", "manifest", t.String(), "err", err)
|
|
continue
|
|
}
|
|
rep.Deleted = append(rep.Deleted, t)
|
|
p.log().Info("registry prune: deleted manifest", "manifest", t.String())
|
|
}
|
|
return rep, nil
|
|
}
|
|
|
|
// Loop runs Run every interval until ctx ends, starting after one interval's
|
|
// tenth so a restart loop does not hammer the registry.
|
|
func (p *Pruner) Loop(ctx context.Context, every time.Duration) {
|
|
t := time.NewTimer(every / 10)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
}
|
|
rep, err := p.Run(ctx)
|
|
if err != nil {
|
|
p.log().Error("registry prune failed", "err", err)
|
|
} else {
|
|
p.log().Info("registry prune finished", "repositories", rep.Repositories,
|
|
"manifests", rep.Manifests, "deleted", len(rep.Deleted), "failed", rep.Failed)
|
|
}
|
|
t.Reset(every)
|
|
}
|
|
}
|
|
|
|
func (p *Pruner) grace() time.Duration {
|
|
if p.Grace > 0 {
|
|
return p.Grace
|
|
}
|
|
return DefaultGrace
|
|
}
|
|
|
|
func (p *Pruner) keepTagged() int {
|
|
if p.KeepTagged > 0 {
|
|
return p.KeepTagged
|
|
}
|
|
return DefaultKeepTagged
|
|
}
|
|
|
|
func (p *Pruner) log() *slog.Logger {
|
|
if p.Log != nil {
|
|
return p.Log
|
|
}
|
|
return slog.New(slog.DiscardHandler)
|
|
}
|
|
|
|
// Plan decides which manifests to delete. It is pure so every keep rule is
|
|
// tested without a registry.
|
|
func Plan(indexes map[string]*registrygate.Index, refs []string, host string, now time.Time, grace time.Duration, keepTagged int) []Target {
|
|
keep := map[Target]bool{}
|
|
for _, ref := range refs {
|
|
repo, tag, digest, ok := ParseRef(ref, host)
|
|
if !ok {
|
|
continue
|
|
}
|
|
idx := indexes[repo]
|
|
switch {
|
|
case digest != "":
|
|
keep[Target{repo, digest}] = true
|
|
case idx == nil:
|
|
case tag == "*":
|
|
for _, d := range idx.Tags {
|
|
keep[Target{repo, d}] = true
|
|
}
|
|
default:
|
|
if d, ok := idx.Tags[tag]; ok {
|
|
keep[Target{repo, d}] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
repos := make([]string, 0, len(indexes))
|
|
for repo := range indexes {
|
|
repos = append(repos, repo)
|
|
}
|
|
sort.Strings(repos)
|
|
|
|
var out []Target
|
|
for _, repo := range repos {
|
|
idx := indexes[repo]
|
|
pushed := make(map[string]time.Time, len(idx.Revisions))
|
|
for _, r := range idx.Revisions {
|
|
pushed[r.Digest] = r.Pushed
|
|
}
|
|
if reserved(repo) {
|
|
for _, d := range newestTagged(idx, pushed, keepTagged) {
|
|
keep[Target{repo, d}] = true
|
|
}
|
|
}
|
|
for _, r := range idx.Revisions {
|
|
if now.Sub(r.Pushed) < grace {
|
|
keep[Target{repo, r.Digest}] = true
|
|
}
|
|
}
|
|
keepChildren(repo, idx, keep)
|
|
for _, r := range idx.Revisions {
|
|
if t := (Target{repo, r.Digest}); !keep[t] {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// keepChildren extends keep from each kept image index to the manifests it names:
|
|
// the per-platform images and their attestations, which no ref spells but every
|
|
// pull of the index fetches. An index can name another index, so it runs to a
|
|
// fixed point.
|
|
func keepChildren(repo string, idx *registrygate.Index, keep map[Target]bool) {
|
|
for grew := true; grew; {
|
|
grew = false
|
|
for _, r := range idx.Revisions {
|
|
if !keep[Target{repo, r.Digest}] {
|
|
continue
|
|
}
|
|
for _, c := range r.Children {
|
|
if t := (Target{repo, c}); !keep[t] {
|
|
keep[t], grew = true, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// newestTagged returns the n most recently pushed distinct digests any tag in idx
|
|
// names.
|
|
func newestTagged(idx *registrygate.Index, pushed map[string]time.Time, n int) []string {
|
|
seen := map[string]bool{}
|
|
var tagged []string
|
|
for _, d := range idx.Tags {
|
|
if !seen[d] {
|
|
seen[d] = true
|
|
tagged = append(tagged, d)
|
|
}
|
|
}
|
|
sort.Slice(tagged, func(i, j int) bool {
|
|
pi, pj := pushed[tagged[i]], pushed[tagged[j]]
|
|
if !pi.Equal(pj) {
|
|
return pi.After(pj)
|
|
}
|
|
return tagged[i] < tagged[j]
|
|
})
|
|
if len(tagged) > n {
|
|
tagged = tagged[:n]
|
|
}
|
|
return tagged
|
|
}
|
|
|
|
func reserved(repo string) bool {
|
|
root, _, _ := strings.Cut(repo, "/")
|
|
for _, r := range registrygate.ReservedRepoRoots {
|
|
if root == r {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ParseRef splits host/repo[:tag][@digest] for refs under host. A ref with
|
|
// neither tag nor digest means :latest, as it does for every image client.
|
|
func ParseRef(ref, host string) (repo, tag, digest string, ok bool) {
|
|
rest, ok := strings.CutPrefix(strings.TrimSpace(ref), host+"/")
|
|
if !ok || rest == "" {
|
|
return "", "", "", false
|
|
}
|
|
name, digest, _ := strings.Cut(rest, "@")
|
|
repo, tag = name, ""
|
|
if colon := strings.LastIndex(name, ":"); colon > strings.LastIndex(name, "/") {
|
|
repo, tag = name[:colon], name[colon+1:]
|
|
}
|
|
if tag == "" && digest == "" {
|
|
tag = "latest"
|
|
}
|
|
return repo, tag, digest, repo != ""
|
|
}
|