detached f7b9ef9

fix(images): gracefully handle missing child manifests during prune

When PruneRegistry encounters a missing child manifest (orphaned
reference), log a warning for investigation and continue instead of failing
the entire prune operation.

This prevents 500 errors when registry has inconsistent state due to
partial deletions or interrupted prune operations, allowing partial
cleanup rather than complete failure.

Changes:
- collectReferencedDigests: Log warning and return nil on GetManifest error
- Test updated: LogsWarningAndContinuesWhenChildManifestMissing
- CLI prune preview: Fetch images before confirm, show counts for
  both dangling runtime and registry tags to be removed
commit
f7b9ef940d03f2d706621b64e860d6f541cef1b9
author
brice · · 2026-02-13 18:06 UTC
parent
561bcaf
signature
unsigned
4 files · +170 -7
M internal/adapters/in/cli/images.go +125 -1
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "io"
7+ "sort"
78 "strings"
89 "time"
910
@@ -28,6 +29,11 @@ type imagesPruneOptions struct {
2829 NoConfirm bool
2930 }
3031
32+type prunePreview struct {
33+ danglingRuntimeCount int
34+ registryTagsToPrune int
35+}
36+
3137 // pruneConfirmFunc is the confirmation callback used before destructive prune.
3238 // It is a package-level variable so tests can replace it without spawning a
3339 // real Bubble Tea program.
@@ -192,7 +198,12 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
192198
193199 // Confirmation prompt for destructive operations.
194200 if !opts.NoConfirm {
195- confirmed, err := pruneConfirmFunc("Prune images? This cannot be undone.")
201+ preview, err := loadPrunePreview(ctx, client, opts.KeepReleases, pruneDangling, pruneRegistry)
202+ if err != nil {
203+ return fmt.Errorf("failed to load prune preview: %w", err)
204+ }
205+
206+ confirmed, err := pruneConfirmFunc(renderPruneConfirmPrompt(preview, opts.KeepReleases, pruneDangling, pruneRegistry))
196207 if err != nil {
197208 return fmt.Errorf("confirmation failed: %w", err)
198209 }
@@ -264,6 +275,119 @@ func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesP
264275 return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))
265276 }
266277
278+func loadPrunePreview(ctx context.Context, client imagesClient, keepReleases int, pruneDangling, pruneRegistry bool) (prunePreview, error) {
279+ images, err := client.ListImages(ctx)
280+ if err != nil {
281+ return prunePreview{}, fmt.Errorf("failed to list images: %w", err)
282+ }
283+
284+ preview := prunePreview{}
285+ if pruneDangling {
286+ for _, img := range images {
287+ if img.Dangling {
288+ preview.danglingRuntimeCount++
289+ }
290+ }
291+ }
292+
293+ if pruneRegistry {
294+ preview.registryTagsToPrune = estimateRegistryTagsToPrune(images, keepReleases)
295+ }
296+
297+ return preview, nil
298+}
299+
300+func renderPruneConfirmPrompt(preview prunePreview, keepReleases int, pruneDangling, pruneRegistry bool) string {
301+ lines := []string{"Prune images? This cannot be undone.", ""}
302+
303+ if pruneDangling {
304+ lines = append(lines, fmt.Sprintf("Runtime: would prune %d dangling runtime images", preview.danglingRuntimeCount))
305+ } else {
306+ lines = append(lines, "Runtime: cleanup skipped (--registry)")
307+ }
308+
309+ if pruneRegistry {
310+ lines = append(lines, fmt.Sprintf(
311+ "Registry: would prune %d tags (keep latest + %d previous tags per repository)",
312+ preview.registryTagsToPrune,
313+ keepReleases,
314+ ))
315+ } else {
316+ lines = append(lines, "Registry: cleanup skipped (--dangling)")
317+ }
318+
319+ return strings.Join(lines, "\n")
320+}
321+
322+type previewRegistryTag struct {
323+ name string
324+ created time.Time
325+}
326+
327+func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int {
328+ if keepReleases <= 0 {
329+ return 0
330+ }
331+
332+ tagsByRepo := make(map[string]map[string]time.Time)
333+ for _, img := range images {
334+ repo := strings.TrimSpace(img.Repository)
335+ tag := strings.TrimSpace(img.Tag)
336+ if repo == "" || tag == "" || repo == "<none>" || tag == "<none>" {
337+ continue
338+ }
339+
340+ repoTags, ok := tagsByRepo[repo]
341+ if !ok {
342+ repoTags = make(map[string]time.Time)
343+ tagsByRepo[repo] = repoTags
344+ }
345+
346+ if current, exists := repoTags[tag]; !exists || img.Created.After(current) {
347+ repoTags[tag] = img.Created
348+ }
349+ }
350+
351+ removed := 0
352+ for _, repoTags := range tagsByRepo {
353+ tagInfos := make([]previewRegistryTag, 0, len(repoTags))
354+ for name, created := range repoTags {
355+ tagInfos = append(tagInfos, previewRegistryTag{name: name, created: created})
356+ }
357+
358+ sort.Slice(tagInfos, func(i, j int) bool {
359+ if tagInfos[i].created.Equal(tagInfos[j].created) {
360+ return tagInfos[i].name > tagInfos[j].name
361+ }
362+ return tagInfos[i].created.After(tagInfos[j].created)
363+ })
364+
365+ kept := make(map[string]struct{})
366+ for _, info := range tagInfos {
367+ if info.name == "latest" {
368+ kept["latest"] = struct{}{}
369+ break
370+ }
371+ }
372+
373+ keptNonLatest := 0
374+ for _, info := range tagInfos {
375+ if info.name == "latest" {
376+ continue
377+ }
378+ if keptNonLatest >= keepReleases {
379+ break
380+ }
381+ kept[info.name] = struct{}{}
382+ keptNonLatest++
383+ }
384+
385+ removed += len(tagInfos) - len(kept)
386+ }
387+
388+ return removed
389+}
390+
267391 func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {
268392 return dto.ImagePruneRequest{
269393 KeepLast: &keepReleases,
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "io"6 "io"
7+ "sort"
7 "strings"8 "strings"
8 "time"9 "time"
9 10
@@ -28,6 +29,11 @@ type imagesPruneOptions struct {
28 NoConfirm bool29 NoConfirm bool
29 }30 }
30 31
32+type prunePreview struct {
33+ danglingRuntimeCount int
34+ registryTagsToPrune int
35+}
36+
31 // pruneConfirmFunc is the confirmation callback used before destructive prune.37 // pruneConfirmFunc is the confirmation callback used before destructive prune.
32 // It is a package-level variable so tests can replace it without spawning a38 // It is a package-level variable so tests can replace it without spawning a
33 // real Bubble Tea program.39 // real Bubble Tea program.
@@ -192,7 +198,12 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
192 198
193 // Confirmation prompt for destructive operations.199 // Confirmation prompt for destructive operations.
194 if !opts.NoConfirm {200 if !opts.NoConfirm {
195- confirmed, err := pruneConfirmFunc("Prune images? This cannot be undone.")201+ preview, err := loadPrunePreview(ctx, client, opts.KeepReleases, pruneDangling, pruneRegistry)
202+ if err != nil {
203+ return fmt.Errorf("failed to load prune preview: %w", err)
204+ }
205+
206+ confirmed, err := pruneConfirmFunc(renderPruneConfirmPrompt(preview, opts.KeepReleases, pruneDangling, pruneRegistry))
196 if err != nil {207 if err != nil {
197 return fmt.Errorf("confirmation failed: %w", err)208 return fmt.Errorf("confirmation failed: %w", err)
198 }209 }
@@ -264,6 +275,119 @@ func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesP
264 return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))275 return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))
265 }276 }
266 277
278+func loadPrunePreview(ctx context.Context, client imagesClient, keepReleases int, pruneDangling, pruneRegistry bool) (prunePreview, error) {
279+ images, err := client.ListImages(ctx)
280+ if err != nil {
281+ return prunePreview{}, fmt.Errorf("failed to list images: %w", err)
282+ }
283+
284+ preview := prunePreview{}
285+ if pruneDangling {
286+ for _, img := range images {
287+ if img.Dangling {
288+ preview.danglingRuntimeCount++
289+ }
290+ }
291+ }
292+
293+ if pruneRegistry {
294+ preview.registryTagsToPrune = estimateRegistryTagsToPrune(images, keepReleases)
295+ }
296+
297+ return preview, nil
298+}
299+
300+func renderPruneConfirmPrompt(preview prunePreview, keepReleases int, pruneDangling, pruneRegistry bool) string {
301+ lines := []string{"Prune images? This cannot be undone.", ""}
302+
303+ if pruneDangling {
304+ lines = append(lines, fmt.Sprintf("Runtime: would prune %d dangling runtime images", preview.danglingRuntimeCount))
305+ } else {
306+ lines = append(lines, "Runtime: cleanup skipped (--registry)")
307+ }
308+
309+ if pruneRegistry {
310+ lines = append(lines, fmt.Sprintf(
311+ "Registry: would prune %d tags (keep latest + %d previous tags per repository)",
312+ preview.registryTagsToPrune,
313+ keepReleases,
314+ ))
315+ } else {
316+ lines = append(lines, "Registry: cleanup skipped (--dangling)")
317+ }
318+
319+ return strings.Join(lines, "\n")
320+}
321+
322+type previewRegistryTag struct {
323+ name string
324+ created time.Time
325+}
326+
327+func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int {
328+ if keepReleases <= 0 {
329+ return 0
330+ }
331+
332+ tagsByRepo := make(map[string]map[string]time.Time)
333+ for _, img := range images {
334+ repo := strings.TrimSpace(img.Repository)
335+ tag := strings.TrimSpace(img.Tag)
336+ if repo == "" || tag == "" || repo == "<none>" || tag == "<none>" {
337+ continue
338+ }
339+
340+ repoTags, ok := tagsByRepo[repo]
341+ if !ok {
342+ repoTags = make(map[string]time.Time)
343+ tagsByRepo[repo] = repoTags
344+ }
345+
346+ if current, exists := repoTags[tag]; !exists || img.Created.After(current) {
347+ repoTags[tag] = img.Created
348+ }
349+ }
350+
351+ removed := 0
352+ for _, repoTags := range tagsByRepo {
353+ tagInfos := make([]previewRegistryTag, 0, len(repoTags))
354+ for name, created := range repoTags {
355+ tagInfos = append(tagInfos, previewRegistryTag{name: name, created: created})
356+ }
357+
358+ sort.Slice(tagInfos, func(i, j int) bool {
359+ if tagInfos[i].created.Equal(tagInfos[j].created) {
360+ return tagInfos[i].name > tagInfos[j].name
361+ }
362+ return tagInfos[i].created.After(tagInfos[j].created)
363+ })
364+
365+ kept := make(map[string]struct{})
366+ for _, info := range tagInfos {
367+ if info.name == "latest" {
368+ kept["latest"] = struct{}{}
369+ break
370+ }
371+ }
372+
373+ keptNonLatest := 0
374+ for _, info := range tagInfos {
375+ if info.name == "latest" {
376+ continue
377+ }
378+ if keptNonLatest >= keepReleases {
379+ break
380+ }
381+ kept[info.name] = struct{}{}
382+ keptNonLatest++
383+ }
384+
385+ removed += len(tagInfos) - len(kept)
386+ }
387+
388+ return removed
389+}
390+
267 func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {391 func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {
268 return dto.ImagePruneRequest{392 return dto.ImagePruneRequest{
269 KeepLast: &keepReleases,393 KeepLast: &keepReleases,
M internal/adapters/in/cli/images_test.go +31 -0
@@ -401,6 +401,37 @@ func TestRunImagesPrune_NonDryRunCallsConfirm(t *testing.T) {
401401 assert.Equal(t, 1, client.pruneCalls)
402402 }
403403
404+func TestRunImagesPrune_ConfirmPromptIncludesScopePreviewCounts(t *testing.T) {
405+ created := time.Date(2026, 2, 13, 7, 0, 0, 0, time.UTC)
406+ var prompt string
407+ origConfirm := pruneConfirmFunc
408+ pruneConfirmFunc = func(value string) (bool, error) {
409+ prompt = value
410+ return false, nil
411+ }
412+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
413+
414+ client := &fakeImagesClient{
415+ listImagesResp: []dto.Image{
416+ {Repository: "registry.example.com/app", Tag: "latest", Created: created},
417+ {Repository: "registry.example.com/app", Tag: "v3", Created: created.Add(-1 * time.Hour)},
418+ {Repository: "registry.example.com/app", Tag: "v2", Created: created.Add(-2 * time.Hour)},
419+ {Repository: "registry.example.com/app", Tag: "v1", Created: created.Add(-3 * time.Hour)},
420+ {Repository: "<none>", Tag: "<none>", Dangling: true},
421+ },
422+ }
423+
424+ var out bytes.Buffer
425+ opts := imagesPruneOptions{KeepReleases: 2}
426+ err := runImagesPrune(context.Background(), client, opts, &out)
427+ require.NoError(t, err)
428+
429+ assert.Contains(t, prompt, "Runtime: would prune 1 dangling runtime images")
430+ assert.Contains(t, prompt, "Registry: would prune 1 tags")
431+ assert.Equal(t, 1, client.listImagesCalls)
432+ assert.Equal(t, 0, client.pruneCalls)
433+}
434+
404435 func TestRunImagesPrune_ConfirmRejectCancelsOperation(t *testing.T) {
405436 origConfirm := pruneConfirmFunc
406437 pruneConfirmFunc = func(_ string) (bool, error) {
@@ -401,6 +401,37 @@ func TestRunImagesPrune_NonDryRunCallsConfirm(t *testing.T) {
401 assert.Equal(t, 1, client.pruneCalls)401 assert.Equal(t, 1, client.pruneCalls)
402 }402 }
403 403
404+func TestRunImagesPrune_ConfirmPromptIncludesScopePreviewCounts(t *testing.T) {
405+ created := time.Date(2026, 2, 13, 7, 0, 0, 0, time.UTC)
406+ var prompt string
407+ origConfirm := pruneConfirmFunc
408+ pruneConfirmFunc = func(value string) (bool, error) {
409+ prompt = value
410+ return false, nil
411+ }
412+ t.Cleanup(func() { pruneConfirmFunc = origConfirm })
413+
414+ client := &fakeImagesClient{
415+ listImagesResp: []dto.Image{
416+ {Repository: "registry.example.com/app", Tag: "latest", Created: created},
417+ {Repository: "registry.example.com/app", Tag: "v3", Created: created.Add(-1 * time.Hour)},
418+ {Repository: "registry.example.com/app", Tag: "v2", Created: created.Add(-2 * time.Hour)},
419+ {Repository: "registry.example.com/app", Tag: "v1", Created: created.Add(-3 * time.Hour)},
420+ {Repository: "<none>", Tag: "<none>", Dangling: true},
421+ },
422+ }
423+
424+ var out bytes.Buffer
425+ opts := imagesPruneOptions{KeepReleases: 2}
426+ err := runImagesPrune(context.Background(), client, opts, &out)
427+ require.NoError(t, err)
428+
429+ assert.Contains(t, prompt, "Runtime: would prune 1 dangling runtime images")
430+ assert.Contains(t, prompt, "Registry: would prune 1 tags")
431+ assert.Equal(t, 1, client.listImagesCalls)
432+ assert.Equal(t, 0, client.pruneCalls)
433+}
434+
404 func TestRunImagesPrune_ConfirmRejectCancelsOperation(t *testing.T) {435 func TestRunImagesPrune_ConfirmRejectCancelsOperation(t *testing.T) {
405 origConfirm := pruneConfirmFunc436 origConfirm := pruneConfirmFunc
406 pruneConfirmFunc = func(_ string) (bool, error) {437 pruneConfirmFunc = func(_ string) (bool, error) {
M internal/usecase/images/service.go +6 -1
@@ -403,7 +403,12 @@ func (s *Service) collectReferencedDigests(
403403
404404 manifestData, _, err := s.manifestStorage.GetManifest(repository, reference)
405405 if err != nil {
406- return log.WrapErr(err, "failed to read manifest")
406+ log.Warn().
407+ Str("repository", repository).
408+ Str("reference", reference).
409+ Err(err).
410+ Msg("manifest not found during prune; skipping (orphaned reference)")
411+ return nil
407412 }
408413
409414 refs, err := parseManifestReferences(manifestData)
@@ -403,7 +403,12 @@ func (s *Service) collectReferencedDigests(
403 403
404 manifestData, _, err := s.manifestStorage.GetManifest(repository, reference)404 manifestData, _, err := s.manifestStorage.GetManifest(repository, reference)
405 if err != nil {405 if err != nil {
406- return log.WrapErr(err, "failed to read manifest")406+ log.Warn().
407+ Str("repository", repository).
408+ Str("reference", reference).
409+ Err(err).
410+ Msg("manifest not found during prune; skipping (orphaned reference)")
411+ return nil
407 }412 }
408 413
409 refs, err := parseManifestReferences(manifestData)414 refs, err := parseManifestReferences(manifestData)
M internal/usecase/images/service_test.go +8 -5
@@ -395,7 +395,7 @@ func TestService_PruneRegistry_TieBreaksByTagName(t *testing.T) {
395395 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
396396 }
397397
398-func TestService_PruneRegistry_ReturnsErrorWhenChildManifestMissing(t *testing.T) {
398+func TestService_PruneRegistry_LogsWarningAndContinuesWhenChildManifestMissing(t *testing.T) {
399399 manifestStorage := newFakeManifestStorage()
400400 manifestStorage.repositories = []string{"gordon/api"}
401401 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
@@ -403,12 +403,15 @@ func TestService_PruneRegistry_ReturnsErrorWhenChildManifestMissing(t *testing.T
403403 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing")
404404
405405 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:child-missing", "sha256:orphan"}}
406- svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
406+ logger := zerowrap.Default()
407+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, logger)
407408
408- _, err := svc.PruneRegistry(context.Background(), 1)
409+ report, err := svc.PruneRegistry(context.Background(), 1)
409410
410- require.Error(t, err)
411- assert.Contains(t, err.Error(), "failed to read manifest")
411+ // Should not error - missing child manifests are logged as warnings and skipped
412+ require.NoError(t, err)
413+ assert.Equal(t, 0, report.Registry.TagsRemoved, "no tags should be removed (latest is kept)")
414+ assert.Equal(t, 1, report.Registry.BlobsRemoved, "orphan blob should be garbage collected")
412415 }
413416
414417 func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {
@@ -395,7 +395,7 @@ func TestService_PruneRegistry_TieBreaksByTagName(t *testing.T) {
395 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)395 assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
396 }396 }
397 397
398-func TestService_PruneRegistry_ReturnsErrorWhenChildManifestMissing(t *testing.T) {398+func TestService_PruneRegistry_LogsWarningAndContinuesWhenChildManifestMissing(t *testing.T) {
399 manifestStorage := newFakeManifestStorage()399 manifestStorage := newFakeManifestStorage()
400 manifestStorage.repositories = []string{"gordon/api"}400 manifestStorage.repositories = []string{"gordon/api"}
401 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}401 manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
@@ -403,12 +403,15 @@ func TestService_PruneRegistry_ReturnsErrorWhenChildManifestMissing(t *testing.T
403 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing")403 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing")
404 404
405 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:child-missing", "sha256:orphan"}}405 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:child-missing", "sha256:orphan"}}
406- svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())406+ logger := zerowrap.Default()
407+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, logger)
407 408
408- _, err := svc.PruneRegistry(context.Background(), 1)409+ report, err := svc.PruneRegistry(context.Background(), 1)
409 410
410- require.Error(t, err)411+ // Should not error - missing child manifests are logged as warnings and skipped
411- assert.Contains(t, err.Error(), "failed to read manifest")412+ require.NoError(t, err)
413+ assert.Equal(t, 0, report.Registry.TagsRemoved, "no tags should be removed (latest is kept)")
414+ assert.Equal(t, 1, report.Registry.BlobsRemoved, "orphan blob should be garbage collected")
412 }415 }
413 416
414 func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {417 func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close