ac8ea72025
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
189 lines
7.3 KiB
Go
189 lines
7.3 KiB
Go
package stacks
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
|
)
|
|
|
|
// ── Controller image retention (R-745, `09` §3 decision 56) ───────────────────────────────────────────
|
|
//
|
|
// Decision 53's app sweep never touches the controller's own repository (deleteUnkeptImages skips it), so every
|
|
// release a box ever ran stayed: ~80 controller images on demo-hp's guest on 2026-10-01. The ruling: a box keeps the
|
|
// controller image it RUNS and the one BEFORE it; older ones are deleted by the same in-use rule as decision 53.
|
|
//
|
|
// MEASURED before building (2026-10-01, `audits/rulings-2026-10-01/B/`):
|
|
// - the agent's swap rolls back to whatever /etc/felhom-controller-image named when the swap began — the RUNNING
|
|
// image (felhom-agent internal/localapi/controllerswap.go Swap/rollback). The controller does NOT hand it a
|
|
// previous image; SwapController carries the target only. So the self-update's own roll-back needs only the
|
|
// running image, which a container uses and is never a candidate. "The previous" is kept for a hand roll-back.
|
|
// - the bootstrap unit `docker run`s the image the file names at every guest boot; the file always names the
|
|
// running image after a swap (done or rolled back). The golden's baked image is the running one until the first
|
|
// swap, and nothing reads it after that.
|
|
// - a whole-guest restore brings /var/lib/docker back with the guest (mp0 backup=1), so the image it ran then.
|
|
//
|
|
// THE KEEP SET (rebuilt at every pass): every image a container uses (the running controller among them); the tag
|
|
// of the running version; the PREVIOUS — the self-update's own record (update-state.json previous_image of a swap
|
|
// that ended on the running version), or, when there is no such record or its image is gone, the highest version
|
|
// below the running one (logged as "by version order"); every version ABOVE the running one (a pulled target the
|
|
// swap has not taken yet); every tag that is not a plain X.Y.Z (latest, -rc) — left alone, logged. A candidate is an
|
|
// untagged (<none>) controller image or one whose every tag is a version below the previous. Deleted by exact
|
|
// name/ID, never forced, never by prune; an ID that also carries another repository's name is left alone. NOTHING is
|
|
// deleted while the controller is swapping itself (selfUpdatingNow). Registry tags are never touched.
|
|
// Pinned by internal/stacks/controller_image_retention_test.go.
|
|
|
|
// ControllerImageRecord is what the caller knows about the controller's own images.
|
|
type ControllerImageRecord struct {
|
|
Repo string // the controller repository, no tag (cfg.SelfUpdate.Image)
|
|
Running string // this process's version
|
|
Previous string // the self-update's record: the image before Running ("" when none)
|
|
}
|
|
|
|
// RetainControllerImages applies decision 56 once. Returns the names it deleted.
|
|
func (m *Manager) RetainControllerImages(rec ControllerImageRecord) ([]string, error) {
|
|
imageRetentionMu.Lock()
|
|
defer imageRetentionMu.Unlock()
|
|
running, err := util.ParseVersion(rec.Running)
|
|
if err != nil || rec.Repo == "" {
|
|
m.logger.Printf("[INFO] [stacks] controller image retention: skipped — running version %q is not a release (or no repository)", rec.Running)
|
|
return nil, nil
|
|
}
|
|
if m.selfUpdatingNow() {
|
|
m.logger.Printf("[INFO] [stacks] controller image retention: skipped — the controller is swapping itself (the target it pulled is named by nothing yet)")
|
|
return nil, nil
|
|
}
|
|
imgs, err := listLocalImages()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
used, err := imagesUsedByContainers()
|
|
if err != nil {
|
|
m.logger.Printf("[WARN] [stacks] controller image retention: the containers could not be read (%v) — NOTHING is deleted", err)
|
|
return nil, err
|
|
}
|
|
repo := normRepo(rec.Repo)
|
|
byID := map[string][]localImage{}
|
|
for _, im := range imgs {
|
|
byID[im.ID] = append(byID[im.ID], im)
|
|
}
|
|
|
|
// The previous: the swap's own record first, version order only when the record names nothing present.
|
|
prevTag, prevHow := "", ""
|
|
if rec.Previous != "" {
|
|
if r, t, _ := splitRepoTag(rec.Previous); normRepo(r) == repo {
|
|
for _, im := range imgs {
|
|
if im.Repo == repo && im.Tag == t {
|
|
prevTag, prevHow = t, "the self-update's record"
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if prevTag == "" {
|
|
var best *util.Version
|
|
for _, im := range imgs {
|
|
if im.Repo != repo {
|
|
continue
|
|
}
|
|
if v, err := util.ParseVersion(im.Tag); err == nil && v.Compare(running) < 0 && (best == nil || v.Compare(*best) > 0) {
|
|
vv := v
|
|
best = &vv
|
|
}
|
|
}
|
|
if best != nil {
|
|
prevTag, prevHow = best.Raw, "by version order (no swap record names one present)"
|
|
}
|
|
}
|
|
var prev *util.Version
|
|
if prevTag != "" {
|
|
if v, err := util.ParseVersion(prevTag); err == nil {
|
|
prev = &v
|
|
}
|
|
}
|
|
|
|
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 pass, whatever it did — the positive observable that the pass ran (R-96 rule 3).
|
|
m.logger.Printf("[INFO] [stacks] controller image retention: pass over %d controller image(s) — running %s, previous %q (%s), %d candidate(s), %d deleted, the rest kept",
|
|
considered, rec.Running, prevTag, prevHow, 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.ParseVersion(im.Tag)
|
|
switch {
|
|
case err != nil:
|
|
deletable, odd = false, im.Tag // latest, -rc: not ours to judge
|
|
case v.Compare(running) >= 0:
|
|
deletable = false // the running one, or a pulled target not swapped to yet
|
|
case prev == nil || v.Compare(*prev) >= 0:
|
|
deletable = false // the previous (or nothing to call previous: keep)
|
|
}
|
|
}
|
|
if !deletable {
|
|
if odd != "" {
|
|
m.logger.Printf("[INFO] [stacks] controller 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] controller image retention: %s also carries another repository's name — left alone", shortID(id))
|
|
continue
|
|
}
|
|
// A tagged image is removed by its names (rmi by ID refuses an ID with several tags); an untagged one by ID.
|
|
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] controller 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] controller image retention: deleted %s (%s, %s) — older than the previous controller and no container uses it (decision 56)", label, shortID(id), mine[0].Size)
|
|
deleted = append(deleted, fmt.Sprintf("%s@%s", label, shortID(id)))
|
|
}
|
|
return deleted, nil
|
|
}
|