Merge pull request #77 from bnema/feat/images-prune-spinner-idempotent
feat(images): prune improvements
2 files · +159 -37
M internal/adapters/in/cli/images.go +123 -37
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "strings" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | + | "github.com/charmbracelet/bubbles/spinner" | |
| 11 | 12 | "github.com/charmbracelet/lipgloss" | |
| 12 | 13 | "github.com/spf13/cobra" | |
| 13 | 14 | ||
| @@ -133,6 +134,63 @@ func resolvePruneScopes(opts imagesPruneOptions) (pruneDangling, pruneRegistry b | |||
| 133 | 134 | return opts.Dangling, opts.Registry | |
| 134 | 135 | } | |
| 135 | 136 | ||
| 137 | + | func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePruneRequest, pruneDangling, pruneRegistry bool) (*dto.ImagePruneResponse, error) { | |
| 138 | + | if !isInteractiveTerminal() { | |
| 139 | + | return client.PruneImages(ctx, req) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | type pruneResult struct { | |
| 143 | + | resp *dto.ImagePruneResponse | |
| 144 | + | err error | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | done := make(chan pruneResult, 1) | |
| 148 | + | go func() { | |
| 149 | + | resp, err := client.PruneImages(ctx, req) | |
| 150 | + | done <- pruneResult{resp: resp, err: err} | |
| 151 | + | }() | |
| 152 | + | ||
| 153 | + | // Build spinner message based on scopes | |
| 154 | + | msg := "Pruning images" | |
| 155 | + | if pruneDangling && pruneRegistry { | |
| 156 | + | msg = "Pruning runtime and registry images" | |
| 157 | + | } else if pruneDangling { | |
| 158 | + | msg = "Pruning runtime images" | |
| 159 | + | } else if pruneRegistry { | |
| 160 | + | msg = "Pruning registry images" | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | model := components.NewSpinner( | |
| 164 | + | components.WithMessage(msg), | |
| 165 | + | components.WithSpinnerType(components.SpinnerMiniDot), | |
| 166 | + | ) | |
| 167 | + | ||
| 168 | + | for { | |
| 169 | + | select { | |
| 170 | + | case result := <-done: | |
| 171 | + | if result.err != nil { | |
| 172 | + | return nil, result.err | |
| 173 | + | } | |
| 174 | + | if result.resp == nil { | |
| 175 | + | return nil, fmt.Errorf("prune operation completed but no response received") | |
| 176 | + | } | |
| 177 | + | // Success - set final message and render | |
| 178 | + | model.SetMessage(cliRenderSuccess("Pruning complete")) | |
| 179 | + | updatedModel, _ := model.Update(spinner.TickMsg{}) | |
| 180 | + | if m, ok := updatedModel.(components.SpinnerModel); ok { | |
| 181 | + | fmt.Print("\r" + m.View() + "\n") | |
| 182 | + | } | |
| 183 | + | return result.resp, nil | |
| 184 | + | case <-time.After(100 * time.Millisecond): | |
| 185 | + | updatedModel, _ := model.Update(spinner.TickMsg{}) | |
| 186 | + | if m, ok := updatedModel.(components.SpinnerModel); ok { | |
| 187 | + | model = m | |
| 188 | + | fmt.Print("\r" + m.View()) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 136 | 194 | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | |
| 137 | 195 | images, err := client.ListImages(ctx) | |
| 138 | 196 | if err != nil { | |
| @@ -214,7 +272,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||
| 214 | 272 | ||
| 215 | 273 | req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry) | |
| 216 | 274 | ||
| 217 | - | resp, err := client.PruneImages(ctx, req) | |
| 275 | + | resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry) | |
| 218 | 276 | if err != nil { | |
| 219 | 277 | return fmt.Errorf("failed to prune images: %w", err) | |
| 220 | 278 | } | |
| @@ -325,11 +383,21 @@ type previewRegistryTag struct { | |||
| 325 | 383 | } | |
| 326 | 384 | ||
| 327 | 385 | func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int { | |
| 328 | - | if keepReleases <= 0 { | |
| 329 | - | return 0 | |
| 386 | + | tagsByRepo := collectTagsByRepository(images) | |
| 387 | + | removed := 0 | |
| 388 | + | ||
| 389 | + | for _, repoTags := range tagsByRepo { | |
| 390 | + | removed += countTagsToRemove(repoTags, keepReleases) | |
| 330 | 391 | } | |
| 331 | 392 | ||
| 393 | + | return removed | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | // collectTagsByRepository groups images by repository and returns | |
| 397 | + | // map of repo -> tag name -> creation time | |
| 398 | + | func collectTagsByRepository(images []dto.Image) map[string]map[string]time.Time { | |
| 332 | 399 | tagsByRepo := make(map[string]map[string]time.Time) | |
| 400 | + | ||
| 333 | 401 | for _, img := range images { | |
| 334 | 402 | repo := strings.TrimSpace(img.Repository) | |
| 335 | 403 | tag := strings.TrimSpace(img.Tag) | |
| @@ -337,55 +405,73 @@ func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int { | |||
| 337 | 405 | continue | |
| 338 | 406 | } | |
| 339 | 407 | ||
| 340 | - | repoTags, ok := tagsByRepo[repo] | |
| 341 | - | if !ok { | |
| 342 | - | repoTags = make(map[string]time.Time) | |
| 343 | - | tagsByRepo[repo] = repoTags | |
| 408 | + | if _, ok := tagsByRepo[repo]; !ok { | |
| 409 | + | tagsByRepo[repo] = make(map[string]time.Time) | |
| 344 | 410 | } | |
| 345 | 411 | ||
| 412 | + | repoTags := tagsByRepo[repo] | |
| 346 | 413 | if current, exists := repoTags[tag]; !exists || img.Created.After(current) { | |
| 347 | 414 | repoTags[tag] = img.Created | |
| 348 | 415 | } | |
| 349 | 416 | } | |
| 350 | 417 | ||
| 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 | - | } | |
| 418 | + | return tagsByRepo | |
| 419 | + | } | |
| 357 | 420 | ||
| 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 | - | }) | |
| 421 | + | // countTagsToRemove counts how many tags in a repository would be removed | |
| 422 | + | // based on keepReleases retention policy | |
| 423 | + | func countTagsToRemove(repoTags map[string]time.Time, keepReleases int) int { | |
| 424 | + | tagInfos := toSortedTagInfos(repoTags) | |
| 364 | 425 | ||
| 365 | - | kept := make(map[string]struct{}) | |
| 366 | - | for _, info := range tagInfos { | |
| 367 | - | if info.name == "latest" { | |
| 368 | - | kept["latest"] = struct{}{} | |
| 369 | - | break | |
| 370 | - | } | |
| 426 | + | kept := selectKeptTags(tagInfos, keepReleases) | |
| 427 | + | ||
| 428 | + | return len(tagInfos) - len(kept) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // toSortedTagInfos converts repo tags map to sorted slice | |
| 432 | + | func toSortedTagInfos(repoTags map[string]time.Time) []previewRegistryTag { | |
| 433 | + | tagInfos := make([]previewRegistryTag, 0, len(repoTags)) | |
| 434 | + | for name, created := range repoTags { | |
| 435 | + | tagInfos = append(tagInfos, previewRegistryTag{name: name, created: created}) | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | sort.Slice(tagInfos, func(i, j int) bool { | |
| 439 | + | if tagInfos[i].created.Equal(tagInfos[j].created) { | |
| 440 | + | return tagInfos[i].name > tagInfos[j].name | |
| 371 | 441 | } | |
| 442 | + | return tagInfos[i].created.After(tagInfos[j].created) | |
| 443 | + | }) | |
| 372 | 444 | ||
| 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++ | |
| 445 | + | return tagInfos | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // selectKeptTags returns which tags would be kept based on | |
| 449 | + | // retention policy (latest + keepReleases non-latest) | |
| 450 | + | func selectKeptTags(tagInfos []previewRegistryTag, keepReleases int) map[string]struct{} { | |
| 451 | + | kept := make(map[string]struct{}) | |
| 452 | + | ||
| 453 | + | // Always keep latest if present | |
| 454 | + | for _, info := range tagInfos { | |
| 455 | + | if info.name == "latest" { | |
| 456 | + | kept["latest"] = struct{}{} | |
| 457 | + | break | |
| 383 | 458 | } | |
| 459 | + | } | |
| 384 | 460 | ||
| 385 | - | removed += len(tagInfos) - len(kept) | |
| 461 | + | // Keep most recent non-latest tags | |
| 462 | + | keptCount := 0 | |
| 463 | + | for _, info := range tagInfos { | |
| 464 | + | if info.name == "latest" { | |
| 465 | + | continue | |
| 466 | + | } | |
| 467 | + | if keptCount >= keepReleases { | |
| 468 | + | break | |
| 469 | + | } | |
| 470 | + | kept[info.name] = struct{}{} | |
| 471 | + | keptCount++ | |
| 386 | 472 | } | |
| 387 | 473 | ||
| 388 | - | return removed | |
| 474 | + | return kept | |
| 389 | 475 | } | |
| 390 | 476 | ||
| 391 | 477 | func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest { | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "strings" | 8 | "strings" | ||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | + | "github.com/charmbracelet/bubbles/spinner" | |||
| 11 | "github.com/charmbracelet/lipgloss" | 12 | "github.com/charmbracelet/lipgloss" | ||
| 12 | "github.com/spf13/cobra" | 13 | "github.com/spf13/cobra" | ||
| 13 | 14 | ||||
| @@ -133,6 +134,63 @@ func resolvePruneScopes(opts imagesPruneOptions) (pruneDangling, pruneRegistry b | |||||
| 133 | return opts.Dangling, opts.Registry | 134 | return opts.Dangling, opts.Registry | ||
| 134 | } | 135 | } | ||
| 135 | 136 | ||||
| 137 | + | func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePruneRequest, pruneDangling, pruneRegistry bool) (*dto.ImagePruneResponse, error) { | |||
| 138 | + | if !isInteractiveTerminal() { | |||
| 139 | + | return client.PruneImages(ctx, req) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | type pruneResult struct { | |||
| 143 | + | resp *dto.ImagePruneResponse | |||
| 144 | + | err error | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | done := make(chan pruneResult, 1) | |||
| 148 | + | go func() { | |||
| 149 | + | resp, err := client.PruneImages(ctx, req) | |||
| 150 | + | done <- pruneResult{resp: resp, err: err} | |||
| 151 | + | }() | |||
| 152 | + | ||||
| 153 | + | // Build spinner message based on scopes | |||
| 154 | + | msg := "Pruning images" | |||
| 155 | + | if pruneDangling && pruneRegistry { | |||
| 156 | + | msg = "Pruning runtime and registry images" | |||
| 157 | + | } else if pruneDangling { | |||
| 158 | + | msg = "Pruning runtime images" | |||
| 159 | + | } else if pruneRegistry { | |||
| 160 | + | msg = "Pruning registry images" | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | model := components.NewSpinner( | |||
| 164 | + | components.WithMessage(msg), | |||
| 165 | + | components.WithSpinnerType(components.SpinnerMiniDot), | |||
| 166 | + | ) | |||
| 167 | + | ||||
| 168 | + | for { | |||
| 169 | + | select { | |||
| 170 | + | case result := <-done: | |||
| 171 | + | if result.err != nil { | |||
| 172 | + | return nil, result.err | |||
| 173 | + | } | |||
| 174 | + | if result.resp == nil { | |||
| 175 | + | return nil, fmt.Errorf("prune operation completed but no response received") | |||
| 176 | + | } | |||
| 177 | + | // Success - set final message and render | |||
| 178 | + | model.SetMessage(cliRenderSuccess("Pruning complete")) | |||
| 179 | + | updatedModel, _ := model.Update(spinner.TickMsg{}) | |||
| 180 | + | if m, ok := updatedModel.(components.SpinnerModel); ok { | |||
| 181 | + | fmt.Print("\r" + m.View() + "\n") | |||
| 182 | + | } | |||
| 183 | + | return result.resp, nil | |||
| 184 | + | case <-time.After(100 * time.Millisecond): | |||
| 185 | + | updatedModel, _ := model.Update(spinner.TickMsg{}) | |||
| 186 | + | if m, ok := updatedModel.(components.SpinnerModel); ok { | |||
| 187 | + | model = m | |||
| 188 | + | fmt.Print("\r" + m.View()) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 136 | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | 194 | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | ||
| 137 | images, err := client.ListImages(ctx) | 195 | images, err := client.ListImages(ctx) | ||
| 138 | if err != nil { | 196 | if err != nil { | ||
| @@ -214,7 +272,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||||
| 214 | 272 | ||||
| 215 | req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry) | 273 | req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry) | ||
| 216 | 274 | ||||
| 217 | - | resp, err := client.PruneImages(ctx, req) | 275 | + | resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry) |
| 218 | if err != nil { | 276 | if err != nil { | ||
| 219 | return fmt.Errorf("failed to prune images: %w", err) | 277 | return fmt.Errorf("failed to prune images: %w", err) | ||
| 220 | } | 278 | } | ||
| @@ -325,11 +383,21 @@ type previewRegistryTag struct { | |||||
| 325 | } | 383 | } | ||
| 326 | 384 | ||||
| 327 | func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int { | 385 | func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int { | ||
| 328 | - | if keepReleases <= 0 { | 386 | + | tagsByRepo := collectTagsByRepository(images) |
| 329 | - | return 0 | 387 | + | removed := 0 |
| 388 | + | ||||
| 389 | + | for _, repoTags := range tagsByRepo { | |||
| 390 | + | removed += countTagsToRemove(repoTags, keepReleases) | |||
| 330 | } | 391 | } | ||
| 331 | 392 | ||||
| 393 | + | return removed | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | // collectTagsByRepository groups images by repository and returns | |||
| 397 | + | // map of repo -> tag name -> creation time | |||
| 398 | + | func collectTagsByRepository(images []dto.Image) map[string]map[string]time.Time { | |||
| 332 | tagsByRepo := make(map[string]map[string]time.Time) | 399 | tagsByRepo := make(map[string]map[string]time.Time) | ||
| 400 | + | ||||
| 333 | for _, img := range images { | 401 | for _, img := range images { | ||
| 334 | repo := strings.TrimSpace(img.Repository) | 402 | repo := strings.TrimSpace(img.Repository) | ||
| 335 | tag := strings.TrimSpace(img.Tag) | 403 | tag := strings.TrimSpace(img.Tag) | ||
| @@ -337,55 +405,73 @@ func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int { | |||||
| 337 | continue | 405 | continue | ||
| 338 | } | 406 | } | ||
| 339 | 407 | ||||
| 340 | - | repoTags, ok := tagsByRepo[repo] | 408 | + | if _, ok := tagsByRepo[repo]; !ok { |
| 341 | - | if !ok { | 409 | + | tagsByRepo[repo] = make(map[string]time.Time) |
| 342 | - | repoTags = make(map[string]time.Time) | |||
| 343 | - | tagsByRepo[repo] = repoTags | |||
| 344 | } | 410 | } | ||
| 345 | 411 | ||||
| 412 | + | repoTags := tagsByRepo[repo] | |||
| 346 | if current, exists := repoTags[tag]; !exists || img.Created.After(current) { | 413 | if current, exists := repoTags[tag]; !exists || img.Created.After(current) { | ||
| 347 | repoTags[tag] = img.Created | 414 | repoTags[tag] = img.Created | ||
| 348 | } | 415 | } | ||
| 349 | } | 416 | } | ||
| 350 | 417 | ||||
| 351 | - | removed := 0 | 418 | + | return tagsByRepo |
| 352 | - | for _, repoTags := range tagsByRepo { | 419 | + | } |
| 353 | - | tagInfos := make([]previewRegistryTag, 0, len(repoTags)) | |||
| 354 | - | for name, created := range repoTags { | |||
| 355 | - | tagInfos = append(tagInfos, previewRegistryTag{name: name, created: created}) | |||
| 356 | - | } | |||
| 357 | 420 | ||||
| 358 | - | sort.Slice(tagInfos, func(i, j int) bool { | 421 | + | // countTagsToRemove counts how many tags in a repository would be removed |
| 359 | - | if tagInfos[i].created.Equal(tagInfos[j].created) { | 422 | + | // based on keepReleases retention policy |
| 360 | - | return tagInfos[i].name > tagInfos[j].name | 423 | + | func countTagsToRemove(repoTags map[string]time.Time, keepReleases int) int { |
| 361 | - | } | 424 | + | tagInfos := toSortedTagInfos(repoTags) |
| 362 | - | return tagInfos[i].created.After(tagInfos[j].created) | |||
| 363 | - | }) | |||
| 364 | 425 | ||||
| 365 | - | kept := make(map[string]struct{}) | 426 | + | kept := selectKeptTags(tagInfos, keepReleases) |
| 366 | - | for _, info := range tagInfos { | 427 | + | |
| 367 | - | if info.name == "latest" { | 428 | + | return len(tagInfos) - len(kept) |
| 368 | - | kept["latest"] = struct{}{} | 429 | + | } |
| 369 | - | break | 430 | + | |
| 370 | - | } | 431 | + | // toSortedTagInfos converts repo tags map to sorted slice |
| 432 | + | func toSortedTagInfos(repoTags map[string]time.Time) []previewRegistryTag { | |||
| 433 | + | tagInfos := make([]previewRegistryTag, 0, len(repoTags)) | |||
| 434 | + | for name, created := range repoTags { | |||
| 435 | + | tagInfos = append(tagInfos, previewRegistryTag{name: name, created: created}) | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | sort.Slice(tagInfos, func(i, j int) bool { | |||
| 439 | + | if tagInfos[i].created.Equal(tagInfos[j].created) { | |||
| 440 | + | return tagInfos[i].name > tagInfos[j].name | |||
| 371 | } | 441 | } | ||
| 442 | + | return tagInfos[i].created.After(tagInfos[j].created) | |||
| 443 | + | }) | |||
| 372 | 444 | ||||
| 373 | - | keptNonLatest := 0 | 445 | + | return tagInfos |
| 374 | - | for _, info := range tagInfos { | 446 | + | } |
| 375 | - | if info.name == "latest" { | 447 | + | |
| 376 | - | continue | 448 | + | // selectKeptTags returns which tags would be kept based on |
| 377 | - | } | 449 | + | // retention policy (latest + keepReleases non-latest) |
| 378 | - | if keptNonLatest >= keepReleases { | 450 | + | func selectKeptTags(tagInfos []previewRegistryTag, keepReleases int) map[string]struct{} { |
| 379 | - | break | 451 | + | kept := make(map[string]struct{}) |
| 380 | - | } | 452 | + | |
| 381 | - | kept[info.name] = struct{}{} | 453 | + | // Always keep latest if present |
| 382 | - | keptNonLatest++ | 454 | + | for _, info := range tagInfos { |
| 455 | + | if info.name == "latest" { | |||
| 456 | + | kept["latest"] = struct{}{} | |||
| 457 | + | break | |||
| 383 | } | 458 | } | ||
| 459 | + | } | |||
| 384 | 460 | ||||
| 385 | - | removed += len(tagInfos) - len(kept) | 461 | + | // Keep most recent non-latest tags |
| 462 | + | keptCount := 0 | |||
| 463 | + | for _, info := range tagInfos { | |||
| 464 | + | if info.name == "latest" { | |||
| 465 | + | continue | |||
| 466 | + | } | |||
| 467 | + | if keptCount >= keepReleases { | |||
| 468 | + | break | |||
| 469 | + | } | |||
| 470 | + | kept[info.name] = struct{}{} | |||
| 471 | + | keptCount++ | |||
| 386 | } | 472 | } | ||
| 387 | 473 | ||||
| 388 | - | return removed | 474 | + | return kept |
| 389 | } | 475 | } | ||
| 390 | 476 | ||||
| 391 | func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest { | 477 | func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest { | ||
M internal/usecase/images/service_test.go +36 -0
| @@ -458,6 +458,42 @@ func TestService_PruneRegistry_EmptyRepository(t *testing.T) { | |||
| 458 | 458 | assert.Empty(t, blobStorage.deletedBlobs) | |
| 459 | 459 | } | |
| 460 | 460 | ||
| 461 | + | func TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate(t *testing.T) { | |
| 462 | + | manifestStorage := newFakeManifestStorage() | |
| 463 | + | manifestStorage.repositories = []string{"gordon/api"} | |
| 464 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |
| 465 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 466 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 467 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 468 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 469 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |
| 470 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |
| 471 | + | rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}} | |
| 472 | + | ||
| 473 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 474 | + | ||
| 475 | + | // First prune - should remove v1 and orphan | |
| 476 | + | report1, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 477 | + | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |
| 478 | + | }) | |
| 479 | + | require.NoError(t, err) | |
| 480 | + | assert.Equal(t, 1, report1.Registry.TagsRemoved, "first prune should remove v1") | |
| 481 | + | assert.Equal(t, 1, report1.Registry.BlobsRemoved, "first prune should remove orphan") | |
| 482 | + | ||
| 483 | + | // Reset runtime prune report to test idempotency properly (runtime would have nothing to prune second time) | |
| 484 | + | rt.pruneReport = pkgruntime.PruneReport{} | |
| 485 | + | ||
| 486 | + | // Second prune - should be idempotent (nothing else to remove) | |
| 487 | + | report2, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 488 | + | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |
| 489 | + | }) | |
| 490 | + | require.NoError(t, err) | |
| 491 | + | assert.Equal(t, 0, report2.Registry.TagsRemoved, "second prune should remove nothing (idempotent)") | |
| 492 | + | assert.Equal(t, 0, report2.Registry.BlobsRemoved, "second prune should remove nothing (idempotent)") | |
| 493 | + | assert.Equal(t, 0, report2.Runtime.DeletedCount, "second prune should remove no runtime images") | |
| 494 | + | assert.Equal(t, int64(0), report2.Runtime.SpaceReclaimed, "second prune should reclaim no runtime space") | |
| 495 | + | } | |
| 496 | + | ||
| 461 | 497 | func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | |
| 462 | 498 | manifestStorage := newFakeManifestStorage() | |
| 463 | 499 | manifestStorage.repositories = []string{"gordon/api"} | |
| @@ -458,6 +458,42 @@ func TestService_PruneRegistry_EmptyRepository(t *testing.T) { | |||||
| 458 | assert.Empty(t, blobStorage.deletedBlobs) | 458 | assert.Empty(t, blobStorage.deletedBlobs) | ||
| 459 | } | 459 | } | ||
| 460 | 460 | ||||
| 461 | + | func TestService_Prune_IdempotentMultiplePrunesDoNotAccumulate(t *testing.T) { | |||
| 462 | + | manifestStorage := newFakeManifestStorage() | |||
| 463 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 464 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |||
| 465 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 466 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |||
| 467 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |||
| 468 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |||
| 469 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |||
| 470 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |||
| 471 | + | rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}} | |||
| 472 | + | ||||
| 473 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 474 | + | ||||
| 475 | + | // First prune - should remove v1 and orphan | |||
| 476 | + | report1, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |||
| 477 | + | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |||
| 478 | + | }) | |||
| 479 | + | require.NoError(t, err) | |||
| 480 | + | assert.Equal(t, 1, report1.Registry.TagsRemoved, "first prune should remove v1") | |||
| 481 | + | assert.Equal(t, 1, report1.Registry.BlobsRemoved, "first prune should remove orphan") | |||
| 482 | + | ||||
| 483 | + | // Reset runtime prune report to test idempotency properly (runtime would have nothing to prune second time) | |||
| 484 | + | rt.pruneReport = pkgruntime.PruneReport{} | |||
| 485 | + | ||||
| 486 | + | // Second prune - should be idempotent (nothing else to remove) | |||
| 487 | + | report2, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |||
| 488 | + | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |||
| 489 | + | }) | |||
| 490 | + | require.NoError(t, err) | |||
| 491 | + | assert.Equal(t, 0, report2.Registry.TagsRemoved, "second prune should remove nothing (idempotent)") | |||
| 492 | + | assert.Equal(t, 0, report2.Registry.BlobsRemoved, "second prune should remove nothing (idempotent)") | |||
| 493 | + | assert.Equal(t, 0, report2.Runtime.DeletedCount, "second prune should remove no runtime images") | |||
| 494 | + | assert.Equal(t, int64(0), report2.Runtime.SpaceReclaimed, "second prune should reclaim no runtime space") | |||
| 495 | + | } | |||
| 496 | + | ||||
| 461 | func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | 497 | func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | ||
| 462 | manifestStorage := newFakeManifestStorage() | 498 | manifestStorage := newFakeManifestStorage() | ||
| 463 | manifestStorage.repositories = []string{"gordon/api"} | 499 | manifestStorage.repositories = []string{"gordon/api"} | ||