v0.351.0 (unreleased): old -rc controller images and old infra images are deleted (decision 56 amended, decision 285)
gates / gates (push) Successful in 31s
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
1 parent
64b99b74e3
commit
27360d6abe
10 files changed
+471
-31
No files matched your search
@@ -0,0 +1,186 @@
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user