diff --git a/CHANGELOG.md b/CHANGELOG.md index 71364be..a2eb6d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## v0.284.2 — the image clean-up sees digest-pulled (untagged) images (2026-09-30) + +**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). No new strings. v0.284.0 and v0.284.1 were never +floored (scratch 9202 only); the fleet goes from 0.283.1 to 0.284.2. + +- **Found live on 9202:** the product pins `tag@digest` (v0.269), and an image pulled that way is stored UNTAGGED + (`repo:`). `docker image ls` without `-a` did not list any of them, so the retention could not see most app + images: the one-time sweep deleted 3 images while dozens of old untagged app images stayed. Now `image ls -a`; an + untagged image keeps its repository name, so it is attributed like any other; a fully anonymous `:` + entry is never a candidate. The one-time marker is `image-retention-v2.done`, so the corrected sweep runs once on + every box (9202 ran the blind v1). +- Test `TestImageRetention_SeesUntaggedDigestPulledImages` (the fake Docker hides untagged images without `-a`, as + measured); red-proof: drop `-a` → it fails. + ## 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 diff --git a/CONTEXT.md b/CONTEXT.md index c079240..f879545 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 + v0.284.1 — image retention, the install hold; v0.284.1 wires RemoveStack) +Last updated: 2026-09-30 late evening (v0.284.0 + v0.284.1 — image retention, the install hold; v0.284.1 wires RemoveStack; v0.284.2 sees untagged digest-pulled images) > **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 3bd1e66..c0c6ecd 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,4 +1,7 @@ -# REPORT — v0.284.0 + v0.284.1 (2026-09-30 late evening) +# REPORT — v0.284.0 + v0.284.1 + v0.284.2 (2026-09-30 late evening) + +- **v0.284.2:** the retention lists images with `-a` — digest-pulled app images are untagged and were invisible (found + live on 9202); marker `image-retention-v2.done`. 0.284.0/0.284.1 were never floored. - **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). diff --git a/controller/README.md b/controller/README.md index 7ec3911..176b8b5 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1938,7 +1938,7 @@ that folder is never a dead end, and an install never runs into it silently (R-6 - **Image retention (v0.284.0, decision 53)** — after a guarded Update and at remove, an app's older images are deleted: kept are every container's image, every installed compose's, and each installed app's running + `previous_images`. By exact id, never forced or pruned; no pass while any update runs; a one-time sweep at the first start after the - release (marker `image-retention-v1.done` in the data dir). See `internal/stacks/image_retention.go`. + release (marker `image-retention-v2.done` in the data dir). See `internal/stacks/image_retention.go`. - **The setup gate (v0.280.0, decision 46)** — `.felhom.yml` `setup_gate: true` + optional `setup_done_probe: {url, field, done}`. A FRESH install is closed to everyone but the household: the traefik file `/traefik/dynamic/setup-gate-.yml` is written BEFORE the first start (a failed write refuses the install) and diff --git a/controller/internal/stacks/image_retention.go b/controller/internal/stacks/image_retention.go index fb407bd..5f98fbd 100644 --- a/controller/internal/stacks/image_retention.go +++ b/controller/internal/stacks/image_retention.go @@ -56,7 +56,10 @@ func normRepo(r string) string { } func listLocalImages() ([]localImage, error) { - out, err := imageDocker("image", "ls", "--digests", "--no-trunc", "--format", "{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Digest}}\t{{.Size}}") + // -a (v0.284.2): the product pins `tag@digest`, and an image pulled that way is stored UNTAGGED (`repo:`) — + // measured on 9202 2026-09-30: `docker image ls` without -a did not list any of them, so v0.284.1 could not see most + // app images. A fully anonymous `:` entry names no repository and is never a candidate. + out, err := imageDocker("image", "ls", "-a", "--digests", "--no-trunc", "--format", "{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Digest}}\t{{.Size}}") if err != nil { return nil, fmt.Errorf("docker image ls: %v: %s", err, truncateStr(out, 200)) } @@ -336,7 +339,7 @@ func (m *Manager) catalogImageRepos() map[string]bool { // imageRetentionMarker: the one-time sweep runs once per box (decision 53's clean-up for boxes older than it). func (m *Manager) imageRetentionMarker() string { - return filepath.Join(m.cfg.Paths.DataDir, "image-retention-v1.done") + return filepath.Join(m.cfg.Paths.DataDir, "image-retention-v2.done") // v2 (v0.284.2): the v1 sweep could not see untagged images } // RunImageRetentionOnce is the one-time clean-up at the first start of this release: the same rule, applied to every diff --git a/controller/internal/stacks/image_retention_test.go b/controller/internal/stacks/image_retention_test.go index ca35d2c..ddf3d6a 100644 --- a/controller/internal/stacks/image_retention_test.go +++ b/controller/internal/stacks/image_retention_test.go @@ -22,8 +22,15 @@ type fakeImages struct { func (f *fakeImages) run(args ...string) (string, error) { switch { case args[0] == "image" && args[1] == "ls": + all := false + for _, a := range args { + all = all || a == "-a" + } var b strings.Builder for _, im := range f.imgs { + if im.Tag == "" && !all { + continue // measured on 9202: `docker image ls` without -a hides untagged (digest-pulled) images + } fmt.Fprintf(&b, "%s\t%s\t%s\t%s\t%s\n", im.ID, im.Repo, im.Tag, im.Digest, im.Size) } return b.String(), nil @@ -290,3 +297,24 @@ func TestImageRetention_ADoneUpdateRunsItWithThePrevious(t *testing.T) { t.Fatal("the done update did not run the retention") } } + +// The product pins tag@digest, so app images sit UNTAGGED (`repo:`): the pass must see them. +// COMPANION RED-PROOF: drop "-a" from listLocalImages → "an untagged old image was not deleted". +func TestImageRetention_SeesUntaggedDigestPulledImages(t *testing.T) { + m := retentionManager(t) + f := baseImages() + f.imgs = append(f.imgs, localImage{ID: "sha256:W0", Repo: "acme/web", Tag: "", Digest: "sha256:w0", Size: "100MB"}, + localImage{ID: "sha256:ANON", Repo: "", Tag: "", Digest: "", Size: "1MB"}) + withFakeImages(t, f) + st, _ := m.GetStack("web") + if _, err := m.deleteUnkeptImages("test", appImageRepos(filepath.Dir(st.ComposePath), st.AppConfig), ""); err != nil { + t.Fatal(err) + } + got := strings.Join(f.rmi, ",") + if !strings.Contains(got, "sha256:W0") { + t.Fatalf("an untagged old image was not deleted: %s", got) + } + if strings.Contains(got, "sha256:ANON") { + t.Fatalf("an anonymous : entry was touched: %s", got) + } +}