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 == "" || 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 + ":" } 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) }