v0.284.1 — the image clean-up runs on the household's Remove button too; one summary line per pass (decision 53, R-736)
gates / gates (push) Successful in 26s
gates / gates (push) Successful in 26s
Found live on 9202: v0.284.0 wired the remove half into DeleteStack only; the app page's Remove runs RemoveStack. Now RemoveStack reads the app's image repositories before its compose down and runs the retention after. Every retention pass logs one line (images seen, candidates, deleted), so a pass that kept everything is visible. Tests TestImageRetention_TheRemoveButtonRunsIt / ADoneUpdateRunsItWithThePrevious, red-proofed. v0.284.0 was never floored (scratch 9202 only). MinAgent: 0.131.0 (unchanged). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -367,7 +367,7 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
m.logger.Printf("[WARN] Rescan after delete failed: %v", err)
|
||||
}
|
||||
go m.RetainImagesAfterRemove(name, removedRepos) // decision 53 (R-736): the removed app's images, if nothing keeps them
|
||||
retainAfterRemoveFn(m, name, removedRepos) // decision 53 (R-736): the removed app's images, if nothing keeps them
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -619,7 +619,12 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Run docker compose down --volumes (keep images for potential redeploy)
|
||||
// decision 53 (R-736, v0.284.1): the app's image repositories, read BEFORE the remove. The images are deleted after
|
||||
// it under the retention rule (the old "keep images for a redeploy" is superseded: a redeploy pulls). Found on 9202
|
||||
// 2026-09-30: v0.284.0 wired only DeleteStack, and the household's Remove button runs THIS function.
|
||||
removedRepos := appImageRepos(stackDir, LoadAppConfig(stackDir))
|
||||
|
||||
// Step 2: Run docker compose down --volumes
|
||||
env := m.stackEnv(stackDir)
|
||||
// R-489 (v0.242.0): the volumes are listed BEFORE and AFTER; the difference is what was removed.
|
||||
// Parsing compose's progress output reported `null` over volumes it did remove — measured five
|
||||
@@ -754,6 +759,7 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stack %s removed successfully (took %.1fs)", name, time.Since(start).Seconds())
|
||||
retainAfterRemoveFn(m, name, removedRepos) // decision 53 (R-736)
|
||||
|
||||
// Step 7: Update in-memory state and rescan
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -212,6 +212,13 @@ func (m *Manager) deleteUnkeptImages(why string, repos map[string]bool, except s
|
||||
}
|
||||
sort.Strings(ids)
|
||||
var deleted []string
|
||||
candidates := 0
|
||||
defer func() {
|
||||
// ONE line per pass, whatever it did (v0.284.1): an absent deletion line must never be read as "it ran and
|
||||
// found nothing" — this line is the positive observable that the pass ran (R-96 rule 3).
|
||||
m.logger.Printf("[INFO] [stacks] image retention (%s): pass over %d image(s) of %v — %d candidate(s), %d deleted, the rest kept",
|
||||
why, len(ids), sortedKeys(repos), candidates, len(deleted))
|
||||
}()
|
||||
for _, id := range ids {
|
||||
group := byID[id]
|
||||
if keep[id] {
|
||||
@@ -227,6 +234,7 @@ func (m *Manager) deleteUnkeptImages(why string, repos map[string]bool, except s
|
||||
if !inRepos {
|
||||
continue
|
||||
}
|
||||
candidates++
|
||||
if len(uniqueRepos(group)) > 1 {
|
||||
m.logger.Printf("[INFO] [stacks] image retention (%s): %s carries several repositories' names %v — left alone", why, shortID(id), names)
|
||||
continue
|
||||
@@ -291,6 +299,11 @@ func (m *Manager) retainAfterUpdate(name string, previous map[string]InstalledIm
|
||||
retainAfterUpdateFn(m, name, previous)
|
||||
}
|
||||
|
||||
// retainAfterRemoveFn runs the retention after a remove (a seam, so a test can see the remove path calls it).
|
||||
var retainAfterRemoveFn = func(m *Manager, name string, repos map[string]bool) {
|
||||
go m.RetainImagesAfterRemove(name, repos)
|
||||
}
|
||||
|
||||
// RetainImagesAfterRemove deletes a removed app's images (its repos, read BEFORE the remove) that nothing else keeps.
|
||||
func (m *Manager) RetainImagesAfterRemove(name string, repos map[string]bool) {
|
||||
if len(repos) == 0 {
|
||||
@@ -351,3 +364,12 @@ func (m *Manager) RunImageRetentionOnce() []string {
|
||||
_ = os.WriteFile(m.imageRetentionMarker(), []byte(fmt.Sprintf("deleted %d\n%s\n", len(deleted), strings.Join(deleted, "\n"))), 0o644)
|
||||
return deleted
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -242,3 +242,51 @@ func TestImageRetention_NoPassWhileAnUpdateRuns(t *testing.T) {
|
||||
t.Fatalf("a pass ran while docs was updating: %v", f.rmi)
|
||||
}
|
||||
}
|
||||
|
||||
// The household's Remove button runs RemoveStack — the retention must run there (v0.284.0 wired only DeleteStack;
|
||||
// found live on 9202 2026-09-30).
|
||||
// COMPANION RED-PROOF: drop the retainAfterRemoveFn call in RemoveStack → "RemoveStack did not run the retention".
|
||||
func TestImageRetention_TheRemoveButtonRunsIt(t *testing.T) {
|
||||
drive := t.TempDir()
|
||||
m, _, _ := newR442Manager(t, "app", ssdCompose, driveAppYAML(drive), drive)
|
||||
var got string
|
||||
var repos map[string]bool
|
||||
prev := retainAfterRemoveFn
|
||||
retainAfterRemoveFn = func(_ *Manager, name string, r map[string]bool) { got, repos = name, r }
|
||||
defer func() { retainAfterRemoveFn = prev }()
|
||||
if _, err := m.RemoveStack("app", false, nil); err != nil {
|
||||
t.Fatalf("RemoveStack: %v", err)
|
||||
}
|
||||
if got != "app" || len(repos) == 0 {
|
||||
t.Fatalf("RemoveStack did not run the retention with the app's repos (got %q, %v)", got, repos)
|
||||
}
|
||||
}
|
||||
|
||||
// A guarded Update that ends done runs the retention with what the app ran BEFORE (the previous image to keep).
|
||||
// COMPANION RED-PROOF: drop the retainAfterUpdate call at the end of verifyAndConclude → "the done update did not run it".
|
||||
func TestImageRetention_ADoneUpdateRunsItWithThePrevious(t *testing.T) {
|
||||
m, dir, _, _, _ := ladderManager(t, true)
|
||||
cfg := LoadAppConfig(dir)
|
||||
cfg.InstalledImages = map[string]InstalledImage{"web": {Ref: ladderA, Digest: dA, At: "2026-09-20T00:00:00Z"}}
|
||||
must(t, SaveAppConfig(dir, cfg, m.encKey, nil))
|
||||
must(t, m.ScanStacks())
|
||||
ch := make(chan map[string]InstalledImage, 1)
|
||||
prev := retainAfterUpdateFn
|
||||
retainAfterUpdateFn = func(_ *Manager, name string, p map[string]InstalledImage) { ch <- p }
|
||||
defer func() { retainAfterUpdateFn = prev }()
|
||||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := waitUpdateDone(t, m, "nextcloud")
|
||||
if st.UpdatePhase != UpdatePhaseDone {
|
||||
t.Fatalf("the update ended %q (%s)", st.UpdatePhase, st.UpdateError)
|
||||
}
|
||||
select {
|
||||
case p := <-ch:
|
||||
if p["web"].Ref != ladderA {
|
||||
t.Fatalf("the previous image handed on is %+v, want %s", p, ladderA)
|
||||
}
|
||||
default:
|
||||
t.Fatal("the done update did not run the retention")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user