v0.284.2 — the image clean-up sees digest-pulled (untagged) images (decision 53, R-736)
gates / gates (push) Successful in 26s
gates / gates (push) Successful in 26s
Found live on 9202: the product pins tag@digest, and such images are stored untagged (repo:<none>); `docker image ls` without -a did not list them, so the retention saw almost no app image. Now `image ls -a`; an anonymous <none>:<none> entry is never a candidate; the one-time marker is v2 so the corrected sweep runs once everywhere. Test TestImageRetention_SeesUntaggedDigestPulledImages, red-proofed. 0.284.0/0.284.1 were never floored. 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:
@@ -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
|
||||
`<stacks>/traefik/dynamic/setup-gate-<app>.yml` is written BEFORE the first start (a failed write refuses the install) and
|
||||
|
||||
@@ -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:<none>`) —
|
||||
// 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 `<none>:<none>` 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
|
||||
|
||||
@@ -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 == "<none>" && !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:<none>`): 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: "<none>", Digest: "sha256:w0", Size: "100MB"},
|
||||
localImage{ID: "sha256:ANON", Repo: "<none>", Tag: "<none>", Digest: "<none>", 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 <none>:<none> entry was touched: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user