diff --git a/CHANGELOG.md b/CHANGELOG.md index 09253e0..71364be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## v0.284.1 — the image clean-up also runs on the household's Remove button; one summary line per pass (2026-09-30) + +**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). No new strings. **v0.284.0 was never floored** — it +ran only on scratch guest 9202, where this was found; the fleet goes from 0.283.1 to 0.284.1. + +- **Found live on 9202:** v0.284.0 wired the remove half of decision 53 into `DeleteStack` only; the app page's Remove + (`POST /api/stacks//remove`) runs `RemoveStack`, so a removed app's images stayed (the "seam built but never wired" + class, a fifth instance). Now `RemoveStack` reads the app's image repositories before its `compose down` and runs the + retention after (seam `retainAfterRemoveFn`). Its old comment "keep images for potential redeploy" is superseded by + decision 53 (a redeploy pulls). +- **One INFO line per retention pass, whatever it did** (`pass over N image(s) of [repos] — C candidate(s), D deleted`): + v0.284.0 logged deletions only, so a pass that ran and kept everything was indistinguishable from one that never ran. +- Tests `TestImageRetention_TheRemoveButtonRunsIt`, `TestImageRetention_ADoneUpdateRunsItWithThePrevious`; red-proofs + (the call removed → each fails). + ## v0.284.0 — a box deletes old app images (decision 53); an after_install app is held until its known login is replaced (R-741) (2026-09-30) **MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: `app_info.install_hold_title`, diff --git a/CONTEXT.md b/CONTEXT.md index 4f903f9..c079240 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,7 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-09-30 late evening (v0.284.0 — image retention, the install hold) +Last updated: 2026-09-30 late evening (v0.284.0 + v0.284.1 — image retention, the install hold; v0.284.1 wires RemoveStack) > **2026-09-30 late — v0.284.0.** Operator rulings: `09` §3 decision 53 (R-736 A: keep running + previous image per > service, delete older, never an image a container/compose/record names) → `stacks/image_retention.go`, with a diff --git a/REPORT.md b/REPORT.md index 20ea688..3bd1e66 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,7 @@ -# REPORT — v0.284.0 (2026-09-30 late evening) +# REPORT — v0.284.0 + v0.284.1 (2026-09-30 late evening) + +- **v0.284.1:** the remove half of decision 53 wired into `RemoveStack` (the household's Remove button — v0.284.0 wired only + `DeleteStack`, found live on 9202); one summary line per retention pass. v0.284.0 was never floored (9202 only). - **Image retention (`09` §3 decision 53, R-736):** after a done/undone guarded Update and at remove, an app's older images are deleted; the box-wide keep set (containers, installed composes, installed/previous records) is read at diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index ff4842f..dcdc517 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -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() diff --git a/controller/internal/stacks/image_retention.go b/controller/internal/stacks/image_retention.go index c408f88..fb407bd 100644 --- a/controller/internal/stacks/image_retention.go +++ b/controller/internal/stacks/image_retention.go @@ -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 +} diff --git a/controller/internal/stacks/image_retention_test.go b/controller/internal/stacks/image_retention_test.go index 284cbaa..ca35d2c 100644 --- a/controller/internal/stacks/image_retention_test.go +++ b/controller/internal/stacks/image_retention_test.go @@ -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") + } +}