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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "io" | |
| 7 | + | "sort" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -28,6 +29,11 @@ type imagesPruneOptions struct { | |||
| 28 | 29 | NoConfirm bool | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 32 | + | type prunePreview struct { | |
| 33 | + | danglingRuntimeCount int | |
| 34 | + | registryTagsToPrune int | |
| 35 | + | } | |
| 36 | + | ||
| 31 | 37 | // pruneConfirmFunc is the confirmation callback used before destructive prune. | |
| 32 | 38 | // It is a package-level variable so tests can replace it without spawning a | |
| 33 | 39 | // real Bubble Tea program. | |
| @@ -192,7 +198,12 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||
| 192 | 198 | ||
| 193 | 199 | // Confirmation prompt for destructive operations. | |
| 194 | 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 | 207 | if err != nil { | |
| 197 | 208 | return fmt.Errorf("confirmation failed: %w", err) | |
| 198 | 209 | } | |
| @@ -264,6 +275,119 @@ func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesP | |||
| 264 | 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 | 391 | func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest { | |
| 268 | 392 | return dto.ImagePruneRequest{ | |
| 269 | 393 | 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 bool | 29 | 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 a | 38 | // 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) { | |||
| 401 | 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 | 435 | func TestRunImagesPrune_ConfirmRejectCancelsOperation(t *testing.T) { | |
| 405 | 436 | origConfirm := pruneConfirmFunc | |
| 406 | 437 | 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 := pruneConfirmFunc | 436 | 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( | |||
| 403 | 403 | ||
| 404 | 404 | manifestData, _, err := s.manifestStorage.GetManifest(repository, reference) | |
| 405 | 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 | 414 | 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) { | |||
| 395 | 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 | 399 | manifestStorage := newFakeManifestStorage() | |
| 400 | 400 | manifestStorage.repositories = []string{"gordon/api"} | |
| 401 | 401 | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"} | |
| @@ -403,12 +403,15 @@ func TestService_PruneRegistry_ReturnsErrorWhenChildManifestMissing(t *testing.T | |||
| 403 | 403 | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing") | |
| 404 | 404 | ||
| 405 | 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 | - | 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") | |
| 412 | 415 | } | |
| 413 | 416 | ||
| 414 | 417 | 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) { | ||