gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
187 lines
6.7 KiB
Go
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)
|
|
}
|