Files
felhom-controller/controller/internal/stacks/infra_image_retention.go
T

187 lines
6.7 KiB
Go

package stacks
import (
"context"
"fmt"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// ── Infrastructure image retention (`09` §3 decision 285, operator 2026-11-09) ──────────────────────────
//
// Decision 53 cleans app images and decision 56 the controller's own; the pinned infrastructure images (traefik,
// cloudflared, FileBrowser, Samba — infra.Images()) were cleaned by nothing, so every pin a box ever ran stayed:
// on demo-hp's guest on 2026-11-09, 2 old traefik, 3 old cloudflared, 2 old FileBrowser and 1 old Samba image
// (about 1 GB). Every one of them rides the whole-guest backup (mp0 holds /var/lib/docker), on a system disk the
// local tier already struggles to fit (R-548).
//
// THE RULE, decision 56's shape applied per pinned repository: a box keeps the PINNED tag (what this controller
// runs), every version ABOVE it (a box rolled back to an older controller keeps the newer pin's image for the way
// forward), the highest version BELOW it (the previous pin — a hand roll-back), and every image a container uses
// (running or stopped). An untagged image of the repository that no container uses is a candidate; a tag that is
// not a version (latest) is left alone. Deleted by exact name/ID, never forced, never by prune; an ID that also
// carries another repository's name is left alone. No pass while an image-pulling compose command runs (R-863),
// and none while the controller swaps itself. Pinned by internal/stacks/infra_image_retention_test.go.
// RetainInfraImages applies the rule once to every pinned infrastructure image. Returns what it deleted, and
// errImageRetentionBusy when image work was in flight (nothing was judged; try again later).
func (m *Manager) RetainInfraImages(pins []string) ([]string, error) {
imageRetentionMu.Lock()
defer imageRetentionMu.Unlock()
if m.selfUpdatingNow() {
m.logger.Printf("[INFO] [stacks] infra image retention: skipped — the controller is swapping itself")
return nil, errImageRetentionBusy
}
endCleanup, ok := dockerexec.TryImageCleanup()
if !ok {
m.logger.Printf("[INFO] [stacks] infra image retention: skipped — an app install, update, restore or undo is pulling images now (R-863); tried again later")
return nil, errImageRetentionBusy
}
defer endCleanup()
imgs, err := listLocalImages()
if err != nil {
return nil, err
}
used, err := imagesUsedByContainers()
if err != nil {
m.logger.Printf("[WARN] [stacks] infra image retention: the containers could not be read (%v) — NOTHING is deleted", err)
return nil, err
}
var deleted []string
for _, pin := range pins {
deleted = append(deleted, m.retainOneInfraRepo(pin, imgs, used)...)
}
return deleted, nil
}
func (m *Manager) retainOneInfraRepo(pin string, imgs []localImage, used map[string]bool) []string {
r, tag, _ := splitRepoTag(pin)
repo := normRepo(r)
pinned, err := util.ParseVersionPre(tag)
if err != nil {
m.logger.Printf("[INFO] [stacks] infra image retention: %s — the pin %q is not a version; the repository is left alone", repo, tag)
return nil
}
byID := map[string][]localImage{}
var prev *util.Version
for _, im := range imgs {
byID[im.ID] = append(byID[im.ID], im)
if im.Repo != repo {
continue
}
if v, err := util.ParseVersionPre(im.Tag); err == nil && v.Compare(pinned) < 0 && (prev == nil || v.Compare(*prev) > 0) {
vv := v
prev = &vv
}
}
prevTag := ""
if prev != nil {
prevTag = prev.Raw
}
ids := make([]string, 0, len(byID))
for id := range byID {
ids = append(ids, id)
}
sort.Strings(ids)
var deleted []string
considered, candidates := 0, 0
defer func() {
// ONE line per repository per pass, whatever it did — the positive observable that the pass ran (R-96 rule 3).
m.logger.Printf("[INFO] [stacks] infra image retention: %s — pass over %d image(s), pinned %s, previous %q, %d candidate(s), %d deleted, the rest kept",
repo, considered, tag, prevTag, candidates, len(deleted))
}()
for _, id := range ids {
group := byID[id]
var mine []localImage
other := false
for _, im := range group {
if im.Repo == repo {
mine = append(mine, im)
} else {
other = true
}
}
if len(mine) == 0 {
continue
}
considered++
if used[id] {
continue
}
deletable, odd := true, ""
var names []string
for _, im := range mine {
if im.Tag == "<none>" || im.Tag == "" {
continue
}
names = append(names, im.Repo+":"+im.Tag)
v, err := util.ParseVersionPre(im.Tag)
switch {
case err != nil:
deletable, odd = false, im.Tag // latest: not a version, not ours to judge
case v.Compare(pinned) >= 0:
deletable = false // the pin, or a newer pin's image
case prev == nil || v.Compare(*prev) >= 0:
deletable = false // the previous pin
}
}
if !deletable {
if odd != "" {
m.logger.Printf("[INFO] [stacks] infra image retention: %s carries a tag that is not a version (%s) — left alone", shortID(id), odd)
}
continue
}
candidates++
if other {
m.logger.Printf("[INFO] [stacks] infra image retention: %s also carries another repository's name — left alone", shortID(id))
continue
}
targets := names
if len(targets) == 0 {
targets = []string{id}
}
ok := true
for _, t := range targets {
if out, err := imageDocker("rmi", t); err != nil {
m.logger.Printf("[WARN] [stacks] infra image retention: docker refused to delete %s (%s): %s", t, shortID(id), truncateStr(strings.TrimSpace(out), 160))
ok = false
break
}
}
if !ok {
continue
}
label := strings.Join(names, ",")
if label == "" {
label = repo + ":<none>"
}
m.logger.Printf("[INFO] [stacks] infra image retention: deleted %s (%s, %s) — older than the previous pin and no container uses it (decision 285)", label, shortID(id), mine[0].Size)
deleted = append(deleted, fmt.Sprintf("%s@%s", label, shortID(id)))
}
return deleted
}
// RunInfraImageRetention runs RetainInfraImages, and again every `every` while image work is in flight, at most
// `tries` times (R-863's retry shape: a household's first installs land in the minutes after a start).
func (m *Manager) RunInfraImageRetention(ctx context.Context, pins []string, every time.Duration, tries int) {
for i := 0; i < tries; i++ {
_, err := m.RetainInfraImages(pins)
if err != errImageRetentionBusy {
if err != nil {
m.logger.Printf("[WARN] [stacks] infra image retention: %v — tried again at the next start", err)
}
return
}
select {
case <-ctx.Done():
return
case <-time.After(every):
}
}
m.logger.Printf("[WARN] [stacks] infra image retention: not run after %d tries — tried again at the next start", tries)
}