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