feat(images): add scoped prune with default retention and confirmation
Make 'gordon images prune' run both dangling-runtime and registry cleanup by default, keeping latest + 3 previous release tags per repository. Add --dangling and --registry scope flags to restrict which subsystems run, --keep-releases to control tag retention, --no-confirm to skip the interactive confirmation prompt, and reuse the existing RunConfirm component for the destructive-operation gate. The options contract flows CLI -> remote client -> admin handler -> usecase service via domain.ImagePruneOptions, with the handler and scheduled job both defaulting to both-scopes-enabled.
- commit
- 24a521d514ffc24b38669c67ae5413e253b5b3d3
- author
- brice · · 2026-02-13 05:59 UTC
- committer
- brice · · 2026-02-13 06:07 UTC
- parent
- 857a4f3
- signature
- unsigned
16 files · +614 -123
M docs/cli/images.md +28 -14
| @@ -11,7 +11,7 @@ gordon images <subcommand> | |||
| 11 | 11 | Subcommands: | |
| 12 | 12 | ||
| 13 | 13 | - `list` - List runtime images and registry tags. | |
| 14 | - | - `prune` - Prune dangling runtime images and optionally registry data. | |
| 14 | + | - `prune` - Prune dangling runtime images and old registry tags. | |
| 15 | 15 | ||
| 16 | 16 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | |
| 17 | 17 | ||
| @@ -27,25 +27,33 @@ Rows that exist only in the registry (not currently present in the runtime cache | |||
| 27 | 27 | ## gordon images prune | |
| 28 | 28 | ||
| 29 | 29 | ```bash | |
| 30 | - | gordon images prune [--dry-run] [--keep <n>] [--runtime-only] | |
| 30 | + | gordon images prune [--dry-run] [--keep-releases <n>] [--dangling] [--registry] [--no-confirm] | |
| 31 | 31 | ``` | |
| 32 | 32 | ||
| 33 | + | By default, prune removes dangling runtime images **and** applies registry tag retention (keeping `latest` + 3 previous non-`latest` tags per repository). A confirmation prompt is shown before destructive operations. | |
| 34 | + | ||
| 33 | 35 | Flags: | |
| 34 | 36 | ||
| 35 | 37 | | Flag | Default | Description | | |
| 36 | 38 | |------|---------|-------------| | |
| 37 | 39 | | `--dry-run` | `false` | Show prune behavior without applying changes | | |
| 38 | - | | `--keep` | `0` | Number of previous tags to keep per repository (`latest` is always preserved when present; `0` disables registry cleanup) | | |
| 39 | - | | `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off | | |
| 40 | + | | `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) | | |
| 41 | + | | `--dangling` | `false` | Restrict scope to dangling runtime images only | | |
| 42 | + | | `--registry` | `false` | Restrict scope to registry tag retention only | | |
| 43 | + | | `--no-confirm` | `false` | Skip the confirmation prompt | | |
| 44 | + | ||
| 45 | + | ### Scope resolution | |
| 40 | 46 | ||
| 41 | - | `--keep` defaults to `0` for ad-hoc CLI safety (registry cleanup off by default), while scheduled pruning uses `images.prune.keep_last` from config (default `3`). Use config for recurring behavior; when a CLI `--keep` value is provided, that request value is used. | |
| 47 | + | - **No scope flags** (default): both runtime and registry cleanup run. | |
| 48 | + | - **`--dangling`**: only runtime dangling images are pruned; registry is skipped. | |
| 49 | + | - **`--registry`**: only registry tag retention runs; runtime is skipped. | |
| 50 | + | - **`--dangling --registry`**: both scopes run (same as default, but explicit). | |
| 42 | 51 | ||
| 43 | - | Behavior notes: | |
| 52 | + | ### Retention semantics | |
| 44 | 53 | ||
| 45 | - | - `--runtime-only` forces `keep_last = 0`. | |
| 46 | - | - `--keep` must be `>= 0`. | |
| 47 | - | - `--dry-run` reports what would be pruned. | |
| 48 | - | - When `--keep` is greater than `0`, registry cleanup keeps `latest` (if present) plus the requested number of most-recent non-`latest` tags. | |
| 54 | + | - `latest` is always preserved when present. | |
| 55 | + | - `--keep-releases` counts non-`latest` tags, ordered by most recent first. | |
| 56 | + | - `--keep-releases=0` with registry scope enabled still runs registry cleanup but keeps no non-`latest` tags. | |
| 49 | 57 | ||
| 50 | 58 | ## Examples | |
| 51 | 59 | ||
| @@ -53,14 +61,20 @@ Behavior notes: | |||
| 53 | 61 | # List images | |
| 54 | 62 | gordon images list | |
| 55 | 63 | ||
| 64 | + | # Prune everything with defaults (dangling + registry, keep latest + 3) | |
| 65 | + | gordon images prune | |
| 66 | + | ||
| 56 | 67 | # Prune dangling runtime images only | |
| 57 | - | gordon images prune --runtime-only | |
| 68 | + | gordon images prune --dangling | |
| 58 | 69 | ||
| 59 | - | # Prune runtime images and keep latest + 3 previous tags per repository | |
| 60 | - | gordon images prune --keep 3 | |
| 70 | + | # Prune registry tags only, keeping latest + 5 previous | |
| 71 | + | gordon images prune --registry --keep-releases 5 | |
| 61 | 72 | ||
| 62 | 73 | # Preview cleanup without applying | |
| 63 | - | gordon images prune --dry-run --keep 5 | |
| 74 | + | gordon images prune --dry-run | |
| 75 | + | ||
| 76 | + | # Skip confirmation prompt | |
| 77 | + | gordon images prune --no-confirm | |
| 64 | 78 | ``` | |
| 65 | 79 | ||
| 66 | 80 | ## Required Permissions | |
| @@ -11,7 +11,7 @@ gordon images <subcommand> | |||||
| 11 | Subcommands: | 11 | Subcommands: | ||
| 12 | 12 | ||||
| 13 | - `list` - List runtime images and registry tags. | 13 | - `list` - List runtime images and registry tags. | ||
| 14 | - | - `prune` - Prune dangling runtime images and optionally registry data. | 14 | + | - `prune` - Prune dangling runtime images and old registry tags. |
| 15 | 15 | ||||
| 16 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | 16 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | ||
| 17 | 17 | ||||
| @@ -27,25 +27,33 @@ Rows that exist only in the registry (not currently present in the runtime cache | |||||
| 27 | ## gordon images prune | 27 | ## gordon images prune | ||
| 28 | 28 | ||||
| 29 | ```bash | 29 | ```bash | ||
| 30 | - | gordon images prune [--dry-run] [--keep <n>] [--runtime-only] | 30 | + | gordon images prune [--dry-run] [--keep-releases <n>] [--dangling] [--registry] [--no-confirm] |
| 31 | ``` | 31 | ``` | ||
| 32 | 32 | ||||
| 33 | + | By default, prune removes dangling runtime images **and** applies registry tag retention (keeping `latest` + 3 previous non-`latest` tags per repository). A confirmation prompt is shown before destructive operations. | |||
| 34 | + | ||||
| 33 | Flags: | 35 | Flags: | ||
| 34 | 36 | ||||
| 35 | | Flag | Default | Description | | 37 | | Flag | Default | Description | | ||
| 36 | |------|---------|-------------| | 38 | |------|---------|-------------| | ||
| 37 | | `--dry-run` | `false` | Show prune behavior without applying changes | | 39 | | `--dry-run` | `false` | Show prune behavior without applying changes | | ||
| 38 | - | | `--keep` | `0` | Number of previous tags to keep per repository (`latest` is always preserved when present; `0` disables registry cleanup) | | 40 | + | | `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) | |
| 39 | - | | `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off | | 41 | + | | `--dangling` | `false` | Restrict scope to dangling runtime images only | |
| 42 | + | | `--registry` | `false` | Restrict scope to registry tag retention only | | |||
| 43 | + | | `--no-confirm` | `false` | Skip the confirmation prompt | | |||
| 44 | + | ||||
| 45 | + | ### Scope resolution | |||
| 40 | 46 | ||||
| 41 | - | `--keep` defaults to `0` for ad-hoc CLI safety (registry cleanup off by default), while scheduled pruning uses `images.prune.keep_last` from config (default `3`). Use config for recurring behavior; when a CLI `--keep` value is provided, that request value is used. | 47 | + | - **No scope flags** (default): both runtime and registry cleanup run. |
| 48 | + | - **`--dangling`**: only runtime dangling images are pruned; registry is skipped. | |||
| 49 | + | - **`--registry`**: only registry tag retention runs; runtime is skipped. | |||
| 50 | + | - **`--dangling --registry`**: both scopes run (same as default, but explicit). | |||
| 42 | 51 | ||||
| 43 | - | Behavior notes: | 52 | + | ### Retention semantics |
| 44 | 53 | ||||
| 45 | - | - `--runtime-only` forces `keep_last = 0`. | 54 | + | - `latest` is always preserved when present. |
| 46 | - | - `--keep` must be `>= 0`. | 55 | + | - `--keep-releases` counts non-`latest` tags, ordered by most recent first. |
| 47 | - | - `--dry-run` reports what would be pruned. | 56 | + | - `--keep-releases=0` with registry scope enabled still runs registry cleanup but keeps no non-`latest` tags. |
| 48 | - | - When `--keep` is greater than `0`, registry cleanup keeps `latest` (if present) plus the requested number of most-recent non-`latest` tags. | |||
| 49 | 57 | ||||
| 50 | ## Examples | 58 | ## Examples | ||
| 51 | 59 | ||||
| @@ -53,14 +61,20 @@ Behavior notes: | |||||
| 53 | # List images | 61 | # List images | ||
| 54 | gordon images list | 62 | gordon images list | ||
| 55 | 63 | ||||
| 64 | + | # Prune everything with defaults (dangling + registry, keep latest + 3) | |||
| 65 | + | gordon images prune | |||
| 66 | + | ||||
| 56 | # Prune dangling runtime images only | 67 | # Prune dangling runtime images only | ||
| 57 | - | gordon images prune --runtime-only | 68 | + | gordon images prune --dangling |
| 58 | 69 | ||||
| 59 | - | # Prune runtime images and keep latest + 3 previous tags per repository | 70 | + | # Prune registry tags only, keeping latest + 5 previous |
| 60 | - | gordon images prune --keep 3 | 71 | + | gordon images prune --registry --keep-releases 5 |
| 61 | 72 | ||||
| 62 | # Preview cleanup without applying | 73 | # Preview cleanup without applying | ||
| 63 | - | gordon images prune --dry-run --keep 5 | 74 | + | gordon images prune --dry-run |
| 75 | + | ||||
| 76 | + | # Skip confirmation prompt | |||
| 77 | + | gordon images prune --no-confirm | |||
| 64 | ``` | 78 | ``` | ||
| 65 | 79 | ||||
| 66 | ## Required Permissions | 80 | ## Required Permissions | ||
M docs/config/images.md +2 -0
| @@ -11,6 +11,8 @@ When enabled, Gordon runs a scheduled image prune job that: | |||
| 11 | 11 | - Preserves the `latest` tag. | |
| 12 | 12 | - Removes unreferenced blobs after tag cleanup. | |
| 13 | 13 | ||
| 14 | + | The CLI `gordon images prune` uses the same defaults as the scheduled job (keep `latest` + 3 previous tags, both scopes enabled: dangling runtime images and registry tag retention). | |
| 15 | + | ||
| 14 | 16 | ## Configuration | |
| 15 | 17 | ||
| 16 | 18 | ```toml | |
| @@ -11,6 +11,8 @@ When enabled, Gordon runs a scheduled image prune job that: | |||||
| 11 | - Preserves the `latest` tag. | 11 | - Preserves the `latest` tag. | ||
| 12 | - Removes unreferenced blobs after tag cleanup. | 12 | - Removes unreferenced blobs after tag cleanup. | ||
| 13 | 13 | ||||
| 14 | + | The CLI `gordon images prune` uses the same defaults as the scheduled job (keep `latest` + 3 previous tags, both scopes enabled: dangling runtime images and registry tag retention). | |||
| 15 | + | ||||
| 14 | ## Configuration | 16 | ## Configuration | ||
| 15 | 17 | ||||
| 16 | ```toml | 18 | ```toml | ||
M internal/adapters/dto/admin_images.go +3 -1
| @@ -19,7 +19,9 @@ type ImagesResponse struct { | |||
| 19 | 19 | ||
| 20 | 20 | // ImagePruneRequest triggers image pruning. | |
| 21 | 21 | type ImagePruneRequest struct { | |
| 22 | - | KeepLast *int `json:"keep_last,omitempty"` | |
| 22 | + | KeepLast *int `json:"keep_last,omitempty"` | |
| 23 | + | PruneDangling *bool `json:"prune_dangling,omitempty"` | |
| 24 | + | PruneRegistry *bool `json:"prune_registry,omitempty"` | |
| 23 | 25 | } | |
| 24 | 26 | ||
| 25 | 27 | // RuntimePruneResult represents runtime prune results. | |
| @@ -19,7 +19,9 @@ type ImagesResponse struct { | |||||
| 19 | 19 | ||||
| 20 | // ImagePruneRequest triggers image pruning. | 20 | // ImagePruneRequest triggers image pruning. | ||
| 21 | type ImagePruneRequest struct { | 21 | type ImagePruneRequest struct { | ||
| 22 | - | KeepLast *int `json:"keep_last,omitempty"` | 22 | + | KeepLast *int `json:"keep_last,omitempty"` |
| 23 | + | PruneDangling *bool `json:"prune_dangling,omitempty"` | |||
| 24 | + | PruneRegistry *bool `json:"prune_registry,omitempty"` | |||
| 23 | } | 25 | } | ||
| 24 | 26 | ||||
| 25 | // RuntimePruneResult represents runtime prune results. | 27 | // RuntimePruneResult represents runtime prune results. | ||
M internal/adapters/in/cli/images.go +102 -39
| @@ -12,19 +12,34 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 14 | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | type imagesClient interface { | |
| 18 | 19 | ListImages(ctx context.Context) ([]dto.Image, error) | |
| 19 | - | PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) | |
| 20 | + | PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | type imagesPruneOptions struct { | |
| 23 | - | DryRun bool | |
| 24 | - | KeepLast int | |
| 25 | - | RuntimeOnly bool | |
| 24 | + | DryRun bool | |
| 25 | + | KeepReleases int | |
| 26 | + | Dangling bool // scope flag: prune dangling runtime images | |
| 27 | + | Registry bool // scope flag: prune registry tags | |
| 28 | + | NoConfirm bool | |
| 26 | 29 | } | |
| 27 | 30 | ||
| 31 | + | // pruneConfirmFunc is the confirmation callback used before destructive prune. | |
| 32 | + | // It is a package-level variable so tests can replace it without spawning a | |
| 33 | + | // real Bubble Tea program. | |
| 34 | + | var pruneConfirmFunc = defaultPruneConfirm | |
| 35 | + | ||
| 36 | + | func defaultPruneConfirm(prompt string) (bool, error) { | |
| 37 | + | return components.RunConfirm(prompt, components.WithDefaultYes()) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Column widths are fixed to keep table output predictable across terminals. | |
| 41 | + | // Values are chosen for typical repository names and SHA-prefixed IDs; | |
| 42 | + | // the table component truncates with ellipsis when content overflows. | |
| 28 | 43 | const ( | |
| 29 | 44 | imagesListRepositoryColumnWidth = 44 | |
| 30 | 45 | imagesListTagColumnWidth = 14 | |
| @@ -79,6 +94,10 @@ func newImagesPruneCmd() *cobra.Command { | |||
| 79 | 94 | cmd := &cobra.Command{ | |
| 80 | 95 | Use: "prune", | |
| 81 | 96 | Short: "Prune unused images", | |
| 97 | + | Long: `Remove dangling runtime images and/or old registry tags. | |
| 98 | + | ||
| 99 | + | By default both runtime and registry cleanup run, keeping latest + 3 previous | |
| 100 | + | release tags per repository. Use --dangling or --registry to restrict scope.`, | |
| 82 | 101 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 83 | 102 | client, isRemote := GetRemoteClient() | |
| 84 | 103 | if !isRemote { | |
| @@ -90,12 +109,24 @@ func newImagesPruneCmd() *cobra.Command { | |||
| 90 | 109 | } | |
| 91 | 110 | ||
| 92 | 111 | cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes") | |
| 93 | - | cmd.Flags().IntVar(&opts.KeepLast, "keep", 0, "Number of previous version tags to keep per repository (latest is always kept when present; 0 disables registry cleanup)") | |
| 94 | - | cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)") | |
| 112 | + | cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast, | |
| 113 | + | "Number of previous non-latest tags to keep per repository (latest is always kept)") | |
| 114 | + | cmd.Flags().BoolVar(&opts.Dangling, "dangling", false, "Prune dangling runtime images only") | |
| 115 | + | cmd.Flags().BoolVar(&opts.Registry, "registry", false, "Prune old registry tags only") | |
| 116 | + | cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "Skip confirmation prompt") | |
| 95 | 117 | ||
| 96 | 118 | return cmd | |
| 97 | 119 | } | |
| 98 | 120 | ||
| 121 | + | // resolvePruneScopes determines which prune subsystems are active. | |
| 122 | + | // No scope flags → both true. Any scope flag present → only selected scopes. | |
| 123 | + | func resolvePruneScopes(opts imagesPruneOptions) (pruneDangling, pruneRegistry bool) { | |
| 124 | + | if !opts.Dangling && !opts.Registry { | |
| 125 | + | return true, true | |
| 126 | + | } | |
| 127 | + | return opts.Dangling, opts.Registry | |
| 128 | + | } | |
| 129 | + | ||
| 99 | 130 | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | |
| 100 | 131 | images, err := client.ListImages(ctx) | |
| 101 | 132 | if err != nil { | |
| @@ -149,68 +180,100 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||
| 149 | 180 | } | |
| 150 | 181 | ||
| 151 | 182 | func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOptions, out io.Writer) error { | |
| 152 | - | if opts.KeepLast < 0 { | |
| 153 | - | return fmt.Errorf("--keep must be >= 0") | |
| 183 | + | if opts.KeepReleases < 0 { | |
| 184 | + | return fmt.Errorf("--keep-releases must be >= 0") | |
| 154 | 185 | } | |
| 155 | 186 | ||
| 156 | - | keepLast := opts.KeepLast | |
| 157 | - | if opts.RuntimeOnly { | |
| 158 | - | keepLast = 0 | |
| 159 | - | } | |
| 187 | + | pruneDangling, pruneRegistry := resolvePruneScopes(opts) | |
| 160 | 188 | ||
| 161 | 189 | if opts.DryRun { | |
| 162 | - | images, err := client.ListImages(ctx) | |
| 190 | + | return runImagesPruneDryRun(ctx, client, opts, pruneDangling, pruneRegistry, out) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // Confirmation prompt for destructive operations. | |
| 194 | + | if !opts.NoConfirm { | |
| 195 | + | confirmed, err := pruneConfirmFunc("Prune images? This cannot be undone.") | |
| 163 | 196 | if err != nil { | |
| 164 | - | return fmt.Errorf("failed to list images: %w", err) | |
| 197 | + | return fmt.Errorf("confirmation failed: %w", err) | |
| 165 | 198 | } | |
| 166 | - | ||
| 167 | - | danglingCount := 0 | |
| 168 | - | for _, img := range images { | |
| 169 | - | if img.Dangling { | |
| 170 | - | danglingCount++ | |
| 171 | - | } | |
| 199 | + | if !confirmed { | |
| 200 | + | return cliWriteLine(out, cliRenderMuted("Prune cancelled")) | |
| 172 | 201 | } | |
| 202 | + | } | |
| 173 | 203 | ||
| 174 | - | if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil { | |
| 175 | - | return err | |
| 176 | - | } | |
| 177 | - | if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { | |
| 178 | - | return err | |
| 179 | - | } | |
| 180 | - | if opts.RuntimeOnly { | |
| 181 | - | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--runtime-only)")) | |
| 204 | + | req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry) | |
| 205 | + | ||
| 206 | + | resp, err := client.PruneImages(ctx, req) | |
| 207 | + | if err != nil { | |
| 208 | + | return fmt.Errorf("failed to prune images: %w", err) | |
| 209 | + | } | |
| 210 | + | if resp == nil { | |
| 211 | + | return fmt.Errorf("failed to prune images: empty response") | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | if pruneDangling { | |
| 215 | + | if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { | |
| 182 | 216 | return err | |
| 183 | 217 | } | |
| 184 | - | if keepLast == 0 { | |
| 185 | - | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--keep=0)")) | |
| 218 | + | } else { | |
| 219 | + | if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil { | |
| 186 | 220 | return err | |
| 187 | 221 | } | |
| 222 | + | } | |
| 188 | 223 | ||
| 189 | - | err = cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast) | |
| 224 | + | if pruneRegistry { | |
| 225 | + | err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) | |
| 190 | 226 | return err | |
| 191 | 227 | } | |
| 192 | 228 | ||
| 193 | - | resp, err := client.PruneImages(ctx, keepLast) | |
| 229 | + | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")) | |
| 230 | + | return err | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesPruneOptions, pruneDangling, pruneRegistry bool, out io.Writer) error { | |
| 234 | + | images, err := client.ListImages(ctx) | |
| 194 | 235 | if err != nil { | |
| 195 | - | return fmt.Errorf("failed to prune images: %w", err) | |
| 236 | + | return fmt.Errorf("failed to list images: %w", err) | |
| 196 | 237 | } | |
| 197 | - | if resp == nil { | |
| 198 | - | return fmt.Errorf("failed to prune images: empty response") | |
| 238 | + | ||
| 239 | + | danglingCount := 0 | |
| 240 | + | for _, img := range images { | |
| 241 | + | if img.Dangling { | |
| 242 | + | danglingCount++ | |
| 243 | + | } | |
| 199 | 244 | } | |
| 200 | 245 | ||
| 201 | - | if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { | |
| 246 | + | if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil { | |
| 202 | 247 | return err | |
| 203 | 248 | } | |
| 204 | 249 | ||
| 205 | - | if opts.RuntimeOnly { | |
| 206 | - | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--runtime-only)")) | |
| 250 | + | if pruneDangling { | |
| 251 | + | if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { | |
| 252 | + | return err | |
| 253 | + | } | |
| 254 | + | } else { | |
| 255 | + | if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil { | |
| 256 | + | return err | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | if pruneRegistry { | |
| 261 | + | err = cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", opts.KeepReleases) | |
| 207 | 262 | return err | |
| 208 | 263 | } | |
| 209 | 264 | ||
| 210 | - | err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) | |
| 265 | + | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")) | |
| 211 | 266 | return err | |
| 212 | 267 | } | |
| 213 | 268 | ||
| 269 | + | func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest { | |
| 270 | + | return dto.ImagePruneRequest{ | |
| 271 | + | KeepLast: &keepReleases, | |
| 272 | + | PruneDangling: &pruneDangling, | |
| 273 | + | PruneRegistry: &pruneRegistry, | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | ||
| 214 | 277 | func formatImageCreatedAt(t time.Time) string { | |
| 215 | 278 | if t.IsZero() { | |
| 216 | 279 | return "-" | |
| @@ -12,19 +12,34 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | "github.com/bnema/gordon/internal/adapters/dto" | 13 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 14 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | 14 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | ||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | ) | 16 | ) | ||
| 16 | 17 | ||||
| 17 | type imagesClient interface { | 18 | type imagesClient interface { | ||
| 18 | ListImages(ctx context.Context) ([]dto.Image, error) | 19 | ListImages(ctx context.Context) ([]dto.Image, error) | ||
| 19 | - | PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) | 20 | + | PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) |
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 22 | type imagesPruneOptions struct { | 23 | type imagesPruneOptions struct { | ||
| 23 | - | DryRun bool | 24 | + | DryRun bool |
| 24 | - | KeepLast int | 25 | + | KeepReleases int |
| 25 | - | RuntimeOnly bool | 26 | + | Dangling bool // scope flag: prune dangling runtime images |
| 27 | + | Registry bool // scope flag: prune registry tags | |||
| 28 | + | NoConfirm bool | |||
| 26 | } | 29 | } | ||
| 27 | 30 | ||||
| 31 | + | // pruneConfirmFunc is the confirmation callback used before destructive prune. | |||
| 32 | + | // It is a package-level variable so tests can replace it without spawning a | |||
| 33 | + | // real Bubble Tea program. | |||
| 34 | + | var pruneConfirmFunc = defaultPruneConfirm | |||
| 35 | + | ||||
| 36 | + | func defaultPruneConfirm(prompt string) (bool, error) { | |||
| 37 | + | return components.RunConfirm(prompt, components.WithDefaultYes()) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Column widths are fixed to keep table output predictable across terminals. | |||
| 41 | + | // Values are chosen for typical repository names and SHA-prefixed IDs; | |||
| 42 | + | // the table component truncates with ellipsis when content overflows. | |||
| 28 | const ( | 43 | const ( | ||
| 29 | imagesListRepositoryColumnWidth = 44 | 44 | imagesListRepositoryColumnWidth = 44 | ||
| 30 | imagesListTagColumnWidth = 14 | 45 | imagesListTagColumnWidth = 14 | ||
| @@ -79,6 +94,10 @@ func newImagesPruneCmd() *cobra.Command { | |||||
| 79 | cmd := &cobra.Command{ | 94 | cmd := &cobra.Command{ | ||
| 80 | Use: "prune", | 95 | Use: "prune", | ||
| 81 | Short: "Prune unused images", | 96 | Short: "Prune unused images", | ||
| 97 | + | Long: `Remove dangling runtime images and/or old registry tags. | |||
| 98 | + | ||||
| 99 | + | By default both runtime and registry cleanup run, keeping latest + 3 previous | |||
| 100 | + | release tags per repository. Use --dangling or --registry to restrict scope.`, | |||
| 82 | RunE: func(cmd *cobra.Command, _ []string) error { | 101 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| 83 | client, isRemote := GetRemoteClient() | 102 | client, isRemote := GetRemoteClient() | ||
| 84 | if !isRemote { | 103 | if !isRemote { | ||
| @@ -90,12 +109,24 @@ func newImagesPruneCmd() *cobra.Command { | |||||
| 90 | } | 109 | } | ||
| 91 | 110 | ||||
| 92 | cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes") | 111 | cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes") | ||
| 93 | - | cmd.Flags().IntVar(&opts.KeepLast, "keep", 0, "Number of previous version tags to keep per repository (latest is always kept when present; 0 disables registry cleanup)") | 112 | + | cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast, |
| 94 | - | cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)") | 113 | + | "Number of previous non-latest tags to keep per repository (latest is always kept)") |
| 114 | + | cmd.Flags().BoolVar(&opts.Dangling, "dangling", false, "Prune dangling runtime images only") | |||
| 115 | + | cmd.Flags().BoolVar(&opts.Registry, "registry", false, "Prune old registry tags only") | |||
| 116 | + | cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "Skip confirmation prompt") | |||
| 95 | 117 | ||||
| 96 | return cmd | 118 | return cmd | ||
| 97 | } | 119 | } | ||
| 98 | 120 | ||||
| 121 | + | // resolvePruneScopes determines which prune subsystems are active. | |||
| 122 | + | // No scope flags → both true. Any scope flag present → only selected scopes. | |||
| 123 | + | func resolvePruneScopes(opts imagesPruneOptions) (pruneDangling, pruneRegistry bool) { | |||
| 124 | + | if !opts.Dangling && !opts.Registry { | |||
| 125 | + | return true, true | |||
| 126 | + | } | |||
| 127 | + | return opts.Dangling, opts.Registry | |||
| 128 | + | } | |||
| 129 | + | ||||
| 99 | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | 130 | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | ||
| 100 | images, err := client.ListImages(ctx) | 131 | images, err := client.ListImages(ctx) | ||
| 101 | if err != nil { | 132 | if err != nil { | ||
| @@ -149,68 +180,100 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||||
| 149 | } | 180 | } | ||
| 150 | 181 | ||||
| 151 | func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOptions, out io.Writer) error { | 182 | func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOptions, out io.Writer) error { | ||
| 152 | - | if opts.KeepLast < 0 { | 183 | + | if opts.KeepReleases < 0 { |
| 153 | - | return fmt.Errorf("--keep must be >= 0") | 184 | + | return fmt.Errorf("--keep-releases must be >= 0") |
| 154 | } | 185 | } | ||
| 155 | 186 | ||||
| 156 | - | keepLast := opts.KeepLast | 187 | + | pruneDangling, pruneRegistry := resolvePruneScopes(opts) |
| 157 | - | if opts.RuntimeOnly { | |||
| 158 | - | keepLast = 0 | |||
| 159 | - | } | |||
| 160 | 188 | ||||
| 161 | if opts.DryRun { | 189 | if opts.DryRun { | ||
| 162 | - | images, err := client.ListImages(ctx) | 190 | + | return runImagesPruneDryRun(ctx, client, opts, pruneDangling, pruneRegistry, out) |
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // Confirmation prompt for destructive operations. | |||
| 194 | + | if !opts.NoConfirm { | |||
| 195 | + | confirmed, err := pruneConfirmFunc("Prune images? This cannot be undone.") | |||
| 163 | if err != nil { | 196 | if err != nil { | ||
| 164 | - | return fmt.Errorf("failed to list images: %w", err) | 197 | + | return fmt.Errorf("confirmation failed: %w", err) |
| 165 | } | 198 | } | ||
| 166 | - | 199 | + | if !confirmed { | |
| 167 | - | danglingCount := 0 | 200 | + | return cliWriteLine(out, cliRenderMuted("Prune cancelled")) |
| 168 | - | for _, img := range images { | |||
| 169 | - | if img.Dangling { | |||
| 170 | - | danglingCount++ | |||
| 171 | - | } | |||
| 172 | } | 201 | } | ||
| 202 | + | } | |||
| 173 | 203 | ||||
| 174 | - | if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil { | 204 | + | req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry) |
| 175 | - | return err | 205 | + | |
| 176 | - | } | 206 | + | resp, err := client.PruneImages(ctx, req) |
| 177 | - | if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { | 207 | + | if err != nil { |
| 178 | - | return err | 208 | + | return fmt.Errorf("failed to prune images: %w", err) |
| 179 | - | } | 209 | + | } |
| 180 | - | if opts.RuntimeOnly { | 210 | + | if resp == nil { |
| 181 | - | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--runtime-only)")) | 211 | + | return fmt.Errorf("failed to prune images: empty response") |
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | if pruneDangling { | |||
| 215 | + | if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { | |||
| 182 | return err | 216 | return err | ||
| 183 | } | 217 | } | ||
| 184 | - | if keepLast == 0 { | 218 | + | } else { |
| 185 | - | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--keep=0)")) | 219 | + | if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil { |
| 186 | return err | 220 | return err | ||
| 187 | } | 221 | } | ||
| 222 | + | } | |||
| 188 | 223 | ||||
| 189 | - | err = cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast) | 224 | + | if pruneRegistry { |
| 225 | + | err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) | |||
| 190 | return err | 226 | return err | ||
| 191 | } | 227 | } | ||
| 192 | 228 | ||||
| 193 | - | resp, err := client.PruneImages(ctx, keepLast) | 229 | + | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")) |
| 230 | + | return err | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesPruneOptions, pruneDangling, pruneRegistry bool, out io.Writer) error { | |||
| 234 | + | images, err := client.ListImages(ctx) | |||
| 194 | if err != nil { | 235 | if err != nil { | ||
| 195 | - | return fmt.Errorf("failed to prune images: %w", err) | 236 | + | return fmt.Errorf("failed to list images: %w", err) |
| 196 | } | 237 | } | ||
| 197 | - | if resp == nil { | 238 | + | |
| 198 | - | return fmt.Errorf("failed to prune images: empty response") | 239 | + | danglingCount := 0 |
| 240 | + | for _, img := range images { | |||
| 241 | + | if img.Dangling { | |||
| 242 | + | danglingCount++ | |||
| 243 | + | } | |||
| 199 | } | 244 | } | ||
| 200 | 245 | ||||
| 201 | - | if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { | 246 | + | if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil { |
| 202 | return err | 247 | return err | ||
| 203 | } | 248 | } | ||
| 204 | 249 | ||||
| 205 | - | if opts.RuntimeOnly { | 250 | + | if pruneDangling { |
| 206 | - | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--runtime-only)")) | 251 | + | if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { |
| 252 | + | return err | |||
| 253 | + | } | |||
| 254 | + | } else { | |||
| 255 | + | if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil { | |||
| 256 | + | return err | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | if pruneRegistry { | |||
| 261 | + | err = cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", opts.KeepReleases) | |||
| 207 | return err | 262 | return err | ||
| 208 | } | 263 | } | ||
| 209 | 264 | ||||
| 210 | - | err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) | 265 | + | err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")) |
| 211 | return err | 266 | return err | ||
| 212 | } | 267 | } | ||
| 213 | 268 | ||||
| 269 | + | func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest { | |||
| 270 | + | return dto.ImagePruneRequest{ | |||
| 271 | + | KeepLast: &keepReleases, | |||
| 272 | + | PruneDangling: &pruneDangling, | |||
| 273 | + | PruneRegistry: &pruneRegistry, | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | ||||
| 214 | func formatImageCreatedAt(t time.Time) string { | 277 | func formatImageCreatedAt(t time.Time) string { | ||
| 215 | if t.IsZero() { | 278 | if t.IsZero() { | ||
| 216 | return "-" | 279 | return "-" | ||
M internal/adapters/in/cli/images_test.go +263 -31
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/stretchr/testify/require" | |
| 14 | 14 | ||
| 15 | 15 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 16 | + | "github.com/bnema/gordon/internal/domain" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | type fakeImagesClient struct { | |
| @@ -23,7 +24,7 @@ type fakeImagesClient struct { | |||
| 23 | 24 | ||
| 24 | 25 | listImagesCalls int | |
| 25 | 26 | pruneCalls int | |
| 26 | - | lastPruneKeep int | |
| 27 | + | lastPruneOpts dto.ImagePruneRequest | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) { | |
| @@ -34,15 +35,19 @@ func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) { | |||
| 34 | 35 | return f.listImagesResp, nil | |
| 35 | 36 | } | |
| 36 | 37 | ||
| 37 | - | func (f *fakeImagesClient) PruneImages(_ context.Context, keepLast int) (*dto.ImagePruneResponse, error) { | |
| 38 | + | func (f *fakeImagesClient) PruneImages(_ context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) { | |
| 38 | 39 | f.pruneCalls++ | |
| 39 | - | f.lastPruneKeep = keepLast | |
| 40 | + | f.lastPruneOpts = req | |
| 40 | 41 | if f.pruneErr != nil { | |
| 41 | 42 | return nil, f.pruneErr | |
| 42 | 43 | } | |
| 43 | 44 | return f.pruneResp, nil | |
| 44 | 45 | } | |
| 45 | 46 | ||
| 47 | + | // --------------------------------------------------------------------------- | |
| 48 | + | // images list tests (unchanged) | |
| 49 | + | // --------------------------------------------------------------------------- | |
| 50 | + | ||
| 46 | 51 | func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | |
| 47 | 52 | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 48 | 53 | client := &fakeImagesClient{ | |
| @@ -181,13 +186,123 @@ func TestRunImagesList_EmptyOutput(t *testing.T) { | |||
| 181 | 186 | assert.Equal(t, 1, client.listImagesCalls) | |
| 182 | 187 | } | |
| 183 | 188 | ||
| 184 | - | func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) { | |
| 189 | + | // --------------------------------------------------------------------------- | |
| 190 | + | // images prune: default scope behavior | |
| 191 | + | // --------------------------------------------------------------------------- | |
| 192 | + | ||
| 193 | + | func TestRunImagesPrune_DefaultScopeIsBothDanglingAndRegistry(t *testing.T) { | |
| 194 | + | // No scope flags → both scopes enabled, keep-releases defaults to domain default. | |
| 195 | + | client := &fakeImagesClient{ | |
| 196 | + | pruneResp: &dto.ImagePruneResponse{ | |
| 197 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 512}, | |
| 198 | + | Registry: dto.RegistryPruneResult{TagsRemoved: 2, BlobsRemoved: 1, SpaceReclaimed: 1024}, | |
| 199 | + | }, | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | var out bytes.Buffer | |
| 203 | + | opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true} | |
| 204 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 205 | + | require.NoError(t, err) | |
| 206 | + | ||
| 207 | + | assert.Equal(t, 1, client.pruneCalls) | |
| 208 | + | require.NotNil(t, client.lastPruneOpts.KeepLast) | |
| 209 | + | assert.Equal(t, domain.DefaultImagePruneKeepLast, *client.lastPruneOpts.KeepLast) | |
| 210 | + | require.NotNil(t, client.lastPruneOpts.PruneDangling) | |
| 211 | + | assert.True(t, *client.lastPruneOpts.PruneDangling) | |
| 212 | + | require.NotNil(t, client.lastPruneOpts.PruneRegistry) | |
| 213 | + | assert.True(t, *client.lastPruneOpts.PruneRegistry) | |
| 214 | + | ||
| 215 | + | text := out.String() | |
| 216 | + | assert.Contains(t, text, "Runtime: deleted=1") | |
| 217 | + | assert.Contains(t, text, "Registry: tags_removed=2") | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func TestRunImagesPrune_DanglingOnlyScopeSkipsRegistry(t *testing.T) { | |
| 221 | + | client := &fakeImagesClient{ | |
| 222 | + | pruneResp: &dto.ImagePruneResponse{ | |
| 223 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 3, SpaceReclaimed: 2048}, | |
| 224 | + | }, | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | var out bytes.Buffer | |
| 228 | + | opts := imagesPruneOptions{Dangling: true, NoConfirm: true} | |
| 229 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | ||
| 232 | + | assert.Equal(t, 1, client.pruneCalls) | |
| 233 | + | require.NotNil(t, client.lastPruneOpts.PruneDangling) | |
| 234 | + | assert.True(t, *client.lastPruneOpts.PruneDangling) | |
| 235 | + | require.NotNil(t, client.lastPruneOpts.PruneRegistry) | |
| 236 | + | assert.False(t, *client.lastPruneOpts.PruneRegistry) | |
| 237 | + | ||
| 238 | + | text := out.String() | |
| 239 | + | assert.Contains(t, text, "Runtime: deleted=3") | |
| 240 | + | assert.Contains(t, text, "Registry cleanup skipped") | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func TestRunImagesPrune_RegistryOnlyScopeSkipsRuntime(t *testing.T) { | |
| 244 | + | client := &fakeImagesClient{ | |
| 245 | + | pruneResp: &dto.ImagePruneResponse{ | |
| 246 | + | Registry: dto.RegistryPruneResult{TagsRemoved: 4, BlobsRemoved: 2, SpaceReclaimed: 4096}, | |
| 247 | + | }, | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | var out bytes.Buffer | |
| 251 | + | opts := imagesPruneOptions{Registry: true, KeepReleases: 5, NoConfirm: true} | |
| 252 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 253 | + | require.NoError(t, err) | |
| 254 | + | ||
| 255 | + | assert.Equal(t, 1, client.pruneCalls) | |
| 256 | + | require.NotNil(t, client.lastPruneOpts.PruneDangling) | |
| 257 | + | assert.False(t, *client.lastPruneOpts.PruneDangling) | |
| 258 | + | require.NotNil(t, client.lastPruneOpts.PruneRegistry) | |
| 259 | + | assert.True(t, *client.lastPruneOpts.PruneRegistry) | |
| 260 | + | require.NotNil(t, client.lastPruneOpts.KeepLast) | |
| 261 | + | assert.Equal(t, 5, *client.lastPruneOpts.KeepLast) | |
| 262 | + | ||
| 263 | + | text := out.String() | |
| 264 | + | assert.Contains(t, text, "Runtime cleanup skipped") | |
| 265 | + | assert.Contains(t, text, "Registry: tags_removed=4") | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) { | |
| 269 | + | client := &fakeImagesClient{ | |
| 270 | + | pruneResp: &dto.ImagePruneResponse{ | |
| 271 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 100}, | |
| 272 | + | Registry: dto.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 0, SpaceReclaimed: 200}, | |
| 273 | + | }, | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | var out bytes.Buffer | |
| 277 | + | opts := imagesPruneOptions{Dangling: true, Registry: true, KeepReleases: 2, NoConfirm: true} | |
| 278 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 279 | + | require.NoError(t, err) | |
| 280 | + | ||
| 281 | + | assert.Equal(t, 1, client.pruneCalls) | |
| 282 | + | require.NotNil(t, client.lastPruneOpts.PruneDangling) | |
| 283 | + | assert.True(t, *client.lastPruneOpts.PruneDangling) | |
| 284 | + | require.NotNil(t, client.lastPruneOpts.PruneRegistry) | |
| 285 | + | assert.True(t, *client.lastPruneOpts.PruneRegistry) | |
| 286 | + | require.NotNil(t, client.lastPruneOpts.KeepLast) | |
| 287 | + | assert.Equal(t, 2, *client.lastPruneOpts.KeepLast) | |
| 288 | + | ||
| 289 | + | text := out.String() | |
| 290 | + | assert.Contains(t, text, "Runtime: deleted=1") | |
| 291 | + | assert.Contains(t, text, "Registry: tags_removed=1") | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // --------------------------------------------------------------------------- | |
| 295 | + | // images prune: dry-run with scope flags | |
| 296 | + | // --------------------------------------------------------------------------- | |
| 297 | + | ||
| 298 | + | func TestRunImagesPrune_DryRunBothScopes(t *testing.T) { | |
| 185 | 299 | client := &fakeImagesClient{ | |
| 186 | 300 | listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}}, | |
| 187 | 301 | } | |
| 188 | 302 | ||
| 189 | 303 | var out bytes.Buffer | |
| 190 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 3}, &out) | |
| 304 | + | opts := imagesPruneOptions{DryRun: true, KeepReleases: 3} | |
| 305 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 191 | 306 | require.NoError(t, err) | |
| 192 | 307 | ||
| 193 | 308 | text := out.String() | |
| @@ -198,71 +313,188 @@ func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) { | |||
| 198 | 313 | assert.Equal(t, 0, client.pruneCalls) | |
| 199 | 314 | } | |
| 200 | 315 | ||
| 201 | - | func TestRunImagesPrune_DryRunKeepZeroSkipsRegistry(t *testing.T) { | |
| 202 | - | client := &fakeImagesClient{listImagesResp: []dto.Image{{Dangling: true}}} | |
| 316 | + | func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) { | |
| 317 | + | client := &fakeImagesClient{ | |
| 318 | + | listImagesResp: []dto.Image{{Dangling: true}}, | |
| 319 | + | } | |
| 203 | 320 | ||
| 204 | 321 | var out bytes.Buffer | |
| 205 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 0}, &out) | |
| 322 | + | opts := imagesPruneOptions{DryRun: true, Dangling: true} | |
| 323 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 206 | 324 | require.NoError(t, err) | |
| 207 | 325 | ||
| 208 | 326 | text := out.String() | |
| 209 | - | assert.Contains(t, text, "Registry cleanup skipped (--keep=0)") | |
| 210 | - | assert.Equal(t, 1, client.listImagesCalls) | |
| 327 | + | assert.Contains(t, text, "would prune 1 dangling runtime images") | |
| 328 | + | assert.Contains(t, text, "Registry cleanup skipped") | |
| 211 | 329 | assert.Equal(t, 0, client.pruneCalls) | |
| 212 | 330 | } | |
| 213 | 331 | ||
| 214 | - | func TestRunImagesPrune_RuntimeOnlyForcesKeepZero(t *testing.T) { | |
| 215 | - | client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}} | |
| 332 | + | func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) { | |
| 333 | + | client := &fakeImagesClient{ | |
| 334 | + | listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}}, | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | var out bytes.Buffer | |
| 338 | + | opts := imagesPruneOptions{DryRun: true, Registry: true, KeepReleases: 5} | |
| 339 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 340 | + | require.NoError(t, err) | |
| 341 | + | ||
| 342 | + | text := out.String() | |
| 343 | + | assert.Contains(t, text, "Runtime cleanup skipped") | |
| 344 | + | assert.Contains(t, text, "would keep latest + 5 previous tags") | |
| 345 | + | assert.Equal(t, 0, client.pruneCalls) | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // --------------------------------------------------------------------------- | |
| 349 | + | // images prune: keep-releases semantics | |
| 350 | + | // --------------------------------------------------------------------------- | |
| 351 | + | ||
| 352 | + | func TestRunImagesPrune_KeepReleasesControlsRetention(t *testing.T) { | |
| 353 | + | client := &fakeImagesClient{ | |
| 354 | + | pruneResp: &dto.ImagePruneResponse{ | |
| 355 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 0}, | |
| 356 | + | Registry: dto.RegistryPruneResult{TagsRemoved: 7, BlobsRemoved: 3, SpaceReclaimed: 9000}, | |
| 357 | + | }, | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | var out bytes.Buffer | |
| 361 | + | opts := imagesPruneOptions{KeepReleases: 10, NoConfirm: true} | |
| 362 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 363 | + | require.NoError(t, err) | |
| 364 | + | ||
| 365 | + | require.NotNil(t, client.lastPruneOpts.KeepLast) | |
| 366 | + | assert.Equal(t, 10, *client.lastPruneOpts.KeepLast) | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | func TestRunImagesPrune_RejectsNegativeKeepReleases(t *testing.T) { | |
| 370 | + | err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepReleases: -1}, &bytes.Buffer{}) | |
| 371 | + | require.Error(t, err) | |
| 372 | + | assert.Contains(t, err.Error(), "--keep-releases must be >= 0") | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // --------------------------------------------------------------------------- | |
| 376 | + | // images prune: confirmation behavior | |
| 377 | + | // --------------------------------------------------------------------------- | |
| 378 | + | ||
| 379 | + | func TestRunImagesPrune_NonDryRunCallsConfirm(t *testing.T) { | |
| 380 | + | confirmCalled := false | |
| 381 | + | origConfirm := pruneConfirmFunc | |
| 382 | + | pruneConfirmFunc = func(_ string) (bool, error) { | |
| 383 | + | confirmCalled = true | |
| 384 | + | return true, nil | |
| 385 | + | } | |
| 386 | + | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |
| 387 | + | ||
| 388 | + | client := &fakeImagesClient{ | |
| 389 | + | pruneResp: &dto.ImagePruneResponse{ | |
| 390 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 1}, | |
| 391 | + | Registry: dto.RegistryPruneResult{TagsRemoved: 1}, | |
| 392 | + | }, | |
| 393 | + | } | |
| 216 | 394 | ||
| 217 | 395 | var out bytes.Buffer | |
| 218 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 9, RuntimeOnly: true}, &out) | |
| 396 | + | opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast} | |
| 397 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 219 | 398 | require.NoError(t, err) | |
| 220 | 399 | ||
| 400 | + | assert.True(t, confirmCalled, "confirmation should have been called") | |
| 221 | 401 | assert.Equal(t, 1, client.pruneCalls) | |
| 222 | - | assert.Equal(t, 0, client.lastPruneKeep) | |
| 223 | - | assert.Contains(t, out.String(), "Registry cleanup skipped") | |
| 224 | 402 | } | |
| 225 | 403 | ||
| 226 | - | func TestRunImagesPrune_UsesKeepFlagWhenNotRuntimeOnly(t *testing.T) { | |
| 404 | + | func TestRunImagesPrune_ConfirmRejectCancelsOperation(t *testing.T) { | |
| 405 | + | origConfirm := pruneConfirmFunc | |
| 406 | + | pruneConfirmFunc = func(_ string) (bool, error) { | |
| 407 | + | return false, nil | |
| 408 | + | } | |
| 409 | + | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |
| 410 | + | ||
| 411 | + | client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}} | |
| 412 | + | ||
| 413 | + | var out bytes.Buffer | |
| 414 | + | opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast} | |
| 415 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 416 | + | require.NoError(t, err) | |
| 417 | + | ||
| 418 | + | assert.Equal(t, 0, client.pruneCalls, "prune should not be called after rejection") | |
| 419 | + | assert.Contains(t, out.String(), "cancelled") | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | func TestRunImagesPrune_NoConfirmSkipsPrompt(t *testing.T) { | |
| 423 | + | confirmCalled := false | |
| 424 | + | origConfirm := pruneConfirmFunc | |
| 425 | + | pruneConfirmFunc = func(_ string) (bool, error) { | |
| 426 | + | confirmCalled = true | |
| 427 | + | return true, nil | |
| 428 | + | } | |
| 429 | + | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |
| 430 | + | ||
| 227 | 431 | client := &fakeImagesClient{ | |
| 228 | 432 | pruneResp: &dto.ImagePruneResponse{ | |
| 229 | - | Runtime: dto.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, | |
| 230 | - | Registry: dto.RegistryPruneResult{TagsRemoved: 3, BlobsRemoved: 1, SpaceReclaimed: 8192}, | |
| 433 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 1}, | |
| 231 | 434 | }, | |
| 232 | 435 | } | |
| 233 | 436 | ||
| 234 | 437 | var out bytes.Buffer | |
| 235 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 5}, &out) | |
| 438 | + | opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true} | |
| 439 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 236 | 440 | require.NoError(t, err) | |
| 237 | 441 | ||
| 238 | - | text := out.String() | |
| 442 | + | assert.False(t, confirmCalled, "confirmation should not be called with --no-confirm") | |
| 239 | 443 | assert.Equal(t, 1, client.pruneCalls) | |
| 240 | - | assert.Equal(t, 5, client.lastPruneKeep) | |
| 241 | - | assert.Contains(t, text, "Runtime: deleted=2") | |
| 242 | - | assert.Contains(t, text, "Registry: tags_removed=3") | |
| 243 | 444 | } | |
| 244 | 445 | ||
| 446 | + | func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) { | |
| 447 | + | confirmCalled := false | |
| 448 | + | origConfirm := pruneConfirmFunc | |
| 449 | + | pruneConfirmFunc = func(_ string) (bool, error) { | |
| 450 | + | confirmCalled = true | |
| 451 | + | return true, nil | |
| 452 | + | } | |
| 453 | + | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |
| 454 | + | ||
| 455 | + | client := &fakeImagesClient{ | |
| 456 | + | listImagesResp: []dto.Image{{Dangling: true}}, | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | var out bytes.Buffer | |
| 460 | + | opts := imagesPruneOptions{DryRun: true, KeepReleases: 3} | |
| 461 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 462 | + | require.NoError(t, err) | |
| 463 | + | ||
| 464 | + | assert.False(t, confirmCalled, "confirmation should not be called during dry-run") | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | // --------------------------------------------------------------------------- | |
| 468 | + | // images prune: error handling | |
| 469 | + | // --------------------------------------------------------------------------- | |
| 470 | + | ||
| 245 | 471 | func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) { | |
| 472 | + | origConfirm := pruneConfirmFunc | |
| 473 | + | pruneConfirmFunc = func(_ string) (bool, error) { return true, nil } | |
| 474 | + | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |
| 475 | + | ||
| 246 | 476 | client := &fakeImagesClient{pruneErr: errors.New("request failed")} | |
| 247 | 477 | ||
| 248 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 2}, &bytes.Buffer{}) | |
| 478 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 2}, &bytes.Buffer{}) | |
| 249 | 479 | require.Error(t, err) | |
| 250 | 480 | assert.Contains(t, err.Error(), "failed to prune images") | |
| 251 | 481 | } | |
| 252 | 482 | ||
| 253 | - | func TestRunImagesPrune_RejectsNegativeKeep(t *testing.T) { | |
| 254 | - | err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepLast: -1}, &bytes.Buffer{}) | |
| 255 | - | require.Error(t, err) | |
| 256 | - | assert.Contains(t, err.Error(), "--keep must be >= 0") | |
| 257 | - | } | |
| 258 | - | ||
| 259 | 483 | func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) { | |
| 484 | + | origConfirm := pruneConfirmFunc | |
| 485 | + | pruneConfirmFunc = func(_ string) (bool, error) { return true, nil } | |
| 486 | + | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |
| 487 | + | ||
| 260 | 488 | client := &fakeImagesClient{pruneResp: nil} | |
| 261 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 1}, &bytes.Buffer{}) | |
| 489 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 1}, &bytes.Buffer{}) | |
| 262 | 490 | require.Error(t, err) | |
| 263 | 491 | assert.Contains(t, err.Error(), "empty response") | |
| 264 | 492 | } | |
| 265 | 493 | ||
| 494 | + | // --------------------------------------------------------------------------- | |
| 495 | + | // test helpers | |
| 496 | + | // --------------------------------------------------------------------------- | |
| 497 | + | ||
| 266 | 498 | func stripANSI(value string) string { | |
| 267 | 499 | ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) | |
| 268 | 500 | return ansiRegex.ReplaceAllString(value, "") | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | 14 | ||||
| 15 | "github.com/bnema/gordon/internal/adapters/dto" | 15 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 16 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | type fakeImagesClient struct { | 19 | type fakeImagesClient struct { | ||
| @@ -23,7 +24,7 @@ type fakeImagesClient struct { | |||||
| 23 | 24 | ||||
| 24 | listImagesCalls int | 25 | listImagesCalls int | ||
| 25 | pruneCalls int | 26 | pruneCalls int | ||
| 26 | - | lastPruneKeep int | 27 | + | lastPruneOpts dto.ImagePruneRequest |
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 29 | func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) { | 30 | func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) { | ||
| @@ -34,15 +35,19 @@ func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) { | |||||
| 34 | return f.listImagesResp, nil | 35 | return f.listImagesResp, nil | ||
| 35 | } | 36 | } | ||
| 36 | 37 | ||||
| 37 | - | func (f *fakeImagesClient) PruneImages(_ context.Context, keepLast int) (*dto.ImagePruneResponse, error) { | 38 | + | func (f *fakeImagesClient) PruneImages(_ context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) { |
| 38 | f.pruneCalls++ | 39 | f.pruneCalls++ | ||
| 39 | - | f.lastPruneKeep = keepLast | 40 | + | f.lastPruneOpts = req |
| 40 | if f.pruneErr != nil { | 41 | if f.pruneErr != nil { | ||
| 41 | return nil, f.pruneErr | 42 | return nil, f.pruneErr | ||
| 42 | } | 43 | } | ||
| 43 | return f.pruneResp, nil | 44 | return f.pruneResp, nil | ||
| 44 | } | 45 | } | ||
| 45 | 46 | ||||
| 47 | + | // --------------------------------------------------------------------------- | |||
| 48 | + | // images list tests (unchanged) | |||
| 49 | + | // --------------------------------------------------------------------------- | |||
| 50 | + | ||||
| 46 | func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | 51 | func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | ||
| 47 | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | 52 | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | ||
| 48 | client := &fakeImagesClient{ | 53 | client := &fakeImagesClient{ | ||
| @@ -181,13 +186,123 @@ func TestRunImagesList_EmptyOutput(t *testing.T) { | |||||
| 181 | assert.Equal(t, 1, client.listImagesCalls) | 186 | assert.Equal(t, 1, client.listImagesCalls) | ||
| 182 | } | 187 | } | ||
| 183 | 188 | ||||
| 184 | - | func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) { | 189 | + | // --------------------------------------------------------------------------- |
| 190 | + | // images prune: default scope behavior | |||
| 191 | + | // --------------------------------------------------------------------------- | |||
| 192 | + | ||||
| 193 | + | func TestRunImagesPrune_DefaultScopeIsBothDanglingAndRegistry(t *testing.T) { | |||
| 194 | + | // No scope flags → both scopes enabled, keep-releases defaults to domain default. | |||
| 195 | + | client := &fakeImagesClient{ | |||
| 196 | + | pruneResp: &dto.ImagePruneResponse{ | |||
| 197 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 512}, | |||
| 198 | + | Registry: dto.RegistryPruneResult{TagsRemoved: 2, BlobsRemoved: 1, SpaceReclaimed: 1024}, | |||
| 199 | + | }, | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | var out bytes.Buffer | |||
| 203 | + | opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true} | |||
| 204 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 205 | + | require.NoError(t, err) | |||
| 206 | + | ||||
| 207 | + | assert.Equal(t, 1, client.pruneCalls) | |||
| 208 | + | require.NotNil(t, client.lastPruneOpts.KeepLast) | |||
| 209 | + | assert.Equal(t, domain.DefaultImagePruneKeepLast, *client.lastPruneOpts.KeepLast) | |||
| 210 | + | require.NotNil(t, client.lastPruneOpts.PruneDangling) | |||
| 211 | + | assert.True(t, *client.lastPruneOpts.PruneDangling) | |||
| 212 | + | require.NotNil(t, client.lastPruneOpts.PruneRegistry) | |||
| 213 | + | assert.True(t, *client.lastPruneOpts.PruneRegistry) | |||
| 214 | + | ||||
| 215 | + | text := out.String() | |||
| 216 | + | assert.Contains(t, text, "Runtime: deleted=1") | |||
| 217 | + | assert.Contains(t, text, "Registry: tags_removed=2") | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func TestRunImagesPrune_DanglingOnlyScopeSkipsRegistry(t *testing.T) { | |||
| 221 | + | client := &fakeImagesClient{ | |||
| 222 | + | pruneResp: &dto.ImagePruneResponse{ | |||
| 223 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 3, SpaceReclaimed: 2048}, | |||
| 224 | + | }, | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | var out bytes.Buffer | |||
| 228 | + | opts := imagesPruneOptions{Dangling: true, NoConfirm: true} | |||
| 229 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 230 | + | require.NoError(t, err) | |||
| 231 | + | ||||
| 232 | + | assert.Equal(t, 1, client.pruneCalls) | |||
| 233 | + | require.NotNil(t, client.lastPruneOpts.PruneDangling) | |||
| 234 | + | assert.True(t, *client.lastPruneOpts.PruneDangling) | |||
| 235 | + | require.NotNil(t, client.lastPruneOpts.PruneRegistry) | |||
| 236 | + | assert.False(t, *client.lastPruneOpts.PruneRegistry) | |||
| 237 | + | ||||
| 238 | + | text := out.String() | |||
| 239 | + | assert.Contains(t, text, "Runtime: deleted=3") | |||
| 240 | + | assert.Contains(t, text, "Registry cleanup skipped") | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func TestRunImagesPrune_RegistryOnlyScopeSkipsRuntime(t *testing.T) { | |||
| 244 | + | client := &fakeImagesClient{ | |||
| 245 | + | pruneResp: &dto.ImagePruneResponse{ | |||
| 246 | + | Registry: dto.RegistryPruneResult{TagsRemoved: 4, BlobsRemoved: 2, SpaceReclaimed: 4096}, | |||
| 247 | + | }, | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | var out bytes.Buffer | |||
| 251 | + | opts := imagesPruneOptions{Registry: true, KeepReleases: 5, NoConfirm: true} | |||
| 252 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 253 | + | require.NoError(t, err) | |||
| 254 | + | ||||
| 255 | + | assert.Equal(t, 1, client.pruneCalls) | |||
| 256 | + | require.NotNil(t, client.lastPruneOpts.PruneDangling) | |||
| 257 | + | assert.False(t, *client.lastPruneOpts.PruneDangling) | |||
| 258 | + | require.NotNil(t, client.lastPruneOpts.PruneRegistry) | |||
| 259 | + | assert.True(t, *client.lastPruneOpts.PruneRegistry) | |||
| 260 | + | require.NotNil(t, client.lastPruneOpts.KeepLast) | |||
| 261 | + | assert.Equal(t, 5, *client.lastPruneOpts.KeepLast) | |||
| 262 | + | ||||
| 263 | + | text := out.String() | |||
| 264 | + | assert.Contains(t, text, "Runtime cleanup skipped") | |||
| 265 | + | assert.Contains(t, text, "Registry: tags_removed=4") | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func TestRunImagesPrune_BothScopeFlagsExplicit(t *testing.T) { | |||
| 269 | + | client := &fakeImagesClient{ | |||
| 270 | + | pruneResp: &dto.ImagePruneResponse{ | |||
| 271 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 1, SpaceReclaimed: 100}, | |||
| 272 | + | Registry: dto.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 0, SpaceReclaimed: 200}, | |||
| 273 | + | }, | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | var out bytes.Buffer | |||
| 277 | + | opts := imagesPruneOptions{Dangling: true, Registry: true, KeepReleases: 2, NoConfirm: true} | |||
| 278 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 279 | + | require.NoError(t, err) | |||
| 280 | + | ||||
| 281 | + | assert.Equal(t, 1, client.pruneCalls) | |||
| 282 | + | require.NotNil(t, client.lastPruneOpts.PruneDangling) | |||
| 283 | + | assert.True(t, *client.lastPruneOpts.PruneDangling) | |||
| 284 | + | require.NotNil(t, client.lastPruneOpts.PruneRegistry) | |||
| 285 | + | assert.True(t, *client.lastPruneOpts.PruneRegistry) | |||
| 286 | + | require.NotNil(t, client.lastPruneOpts.KeepLast) | |||
| 287 | + | assert.Equal(t, 2, *client.lastPruneOpts.KeepLast) | |||
| 288 | + | ||||
| 289 | + | text := out.String() | |||
| 290 | + | assert.Contains(t, text, "Runtime: deleted=1") | |||
| 291 | + | assert.Contains(t, text, "Registry: tags_removed=1") | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | // --------------------------------------------------------------------------- | |||
| 295 | + | // images prune: dry-run with scope flags | |||
| 296 | + | // --------------------------------------------------------------------------- | |||
| 297 | + | ||||
| 298 | + | func TestRunImagesPrune_DryRunBothScopes(t *testing.T) { | |||
| 185 | client := &fakeImagesClient{ | 299 | client := &fakeImagesClient{ | ||
| 186 | listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}}, | 300 | listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}}, | ||
| 187 | } | 301 | } | ||
| 188 | 302 | ||||
| 189 | var out bytes.Buffer | 303 | var out bytes.Buffer | ||
| 190 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 3}, &out) | 304 | + | opts := imagesPruneOptions{DryRun: true, KeepReleases: 3} |
| 305 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 191 | require.NoError(t, err) | 306 | require.NoError(t, err) | ||
| 192 | 307 | ||||
| 193 | text := out.String() | 308 | text := out.String() | ||
| @@ -198,71 +313,188 @@ func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) { | |||||
| 198 | assert.Equal(t, 0, client.pruneCalls) | 313 | assert.Equal(t, 0, client.pruneCalls) | ||
| 199 | } | 314 | } | ||
| 200 | 315 | ||||
| 201 | - | func TestRunImagesPrune_DryRunKeepZeroSkipsRegistry(t *testing.T) { | 316 | + | func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) { |
| 202 | - | client := &fakeImagesClient{listImagesResp: []dto.Image{{Dangling: true}}} | 317 | + | client := &fakeImagesClient{ |
| 318 | + | listImagesResp: []dto.Image{{Dangling: true}}, | |||
| 319 | + | } | |||
| 203 | 320 | ||||
| 204 | var out bytes.Buffer | 321 | var out bytes.Buffer | ||
| 205 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 0}, &out) | 322 | + | opts := imagesPruneOptions{DryRun: true, Dangling: true} |
| 323 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 206 | require.NoError(t, err) | 324 | require.NoError(t, err) | ||
| 207 | 325 | ||||
| 208 | text := out.String() | 326 | text := out.String() | ||
| 209 | - | assert.Contains(t, text, "Registry cleanup skipped (--keep=0)") | 327 | + | assert.Contains(t, text, "would prune 1 dangling runtime images") |
| 210 | - | assert.Equal(t, 1, client.listImagesCalls) | 328 | + | assert.Contains(t, text, "Registry cleanup skipped") |
| 211 | assert.Equal(t, 0, client.pruneCalls) | 329 | assert.Equal(t, 0, client.pruneCalls) | ||
| 212 | } | 330 | } | ||
| 213 | 331 | ||||
| 214 | - | func TestRunImagesPrune_RuntimeOnlyForcesKeepZero(t *testing.T) { | 332 | + | func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) { |
| 215 | - | client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}} | 333 | + | client := &fakeImagesClient{ |
| 334 | + | listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}}, | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | var out bytes.Buffer | |||
| 338 | + | opts := imagesPruneOptions{DryRun: true, Registry: true, KeepReleases: 5} | |||
| 339 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 340 | + | require.NoError(t, err) | |||
| 341 | + | ||||
| 342 | + | text := out.String() | |||
| 343 | + | assert.Contains(t, text, "Runtime cleanup skipped") | |||
| 344 | + | assert.Contains(t, text, "would keep latest + 5 previous tags") | |||
| 345 | + | assert.Equal(t, 0, client.pruneCalls) | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | // --------------------------------------------------------------------------- | |||
| 349 | + | // images prune: keep-releases semantics | |||
| 350 | + | // --------------------------------------------------------------------------- | |||
| 351 | + | ||||
| 352 | + | func TestRunImagesPrune_KeepReleasesControlsRetention(t *testing.T) { | |||
| 353 | + | client := &fakeImagesClient{ | |||
| 354 | + | pruneResp: &dto.ImagePruneResponse{ | |||
| 355 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 0}, | |||
| 356 | + | Registry: dto.RegistryPruneResult{TagsRemoved: 7, BlobsRemoved: 3, SpaceReclaimed: 9000}, | |||
| 357 | + | }, | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | var out bytes.Buffer | |||
| 361 | + | opts := imagesPruneOptions{KeepReleases: 10, NoConfirm: true} | |||
| 362 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 363 | + | require.NoError(t, err) | |||
| 364 | + | ||||
| 365 | + | require.NotNil(t, client.lastPruneOpts.KeepLast) | |||
| 366 | + | assert.Equal(t, 10, *client.lastPruneOpts.KeepLast) | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | func TestRunImagesPrune_RejectsNegativeKeepReleases(t *testing.T) { | |||
| 370 | + | err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepReleases: -1}, &bytes.Buffer{}) | |||
| 371 | + | require.Error(t, err) | |||
| 372 | + | assert.Contains(t, err.Error(), "--keep-releases must be >= 0") | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // --------------------------------------------------------------------------- | |||
| 376 | + | // images prune: confirmation behavior | |||
| 377 | + | // --------------------------------------------------------------------------- | |||
| 378 | + | ||||
| 379 | + | func TestRunImagesPrune_NonDryRunCallsConfirm(t *testing.T) { | |||
| 380 | + | confirmCalled := false | |||
| 381 | + | origConfirm := pruneConfirmFunc | |||
| 382 | + | pruneConfirmFunc = func(_ string) (bool, error) { | |||
| 383 | + | confirmCalled = true | |||
| 384 | + | return true, nil | |||
| 385 | + | } | |||
| 386 | + | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |||
| 387 | + | ||||
| 388 | + | client := &fakeImagesClient{ | |||
| 389 | + | pruneResp: &dto.ImagePruneResponse{ | |||
| 390 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 1}, | |||
| 391 | + | Registry: dto.RegistryPruneResult{TagsRemoved: 1}, | |||
| 392 | + | }, | |||
| 393 | + | } | |||
| 216 | 394 | ||||
| 217 | var out bytes.Buffer | 395 | var out bytes.Buffer | ||
| 218 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 9, RuntimeOnly: true}, &out) | 396 | + | opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast} |
| 397 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 219 | require.NoError(t, err) | 398 | require.NoError(t, err) | ||
| 220 | 399 | ||||
| 400 | + | assert.True(t, confirmCalled, "confirmation should have been called") | |||
| 221 | assert.Equal(t, 1, client.pruneCalls) | 401 | assert.Equal(t, 1, client.pruneCalls) | ||
| 222 | - | assert.Equal(t, 0, client.lastPruneKeep) | |||
| 223 | - | assert.Contains(t, out.String(), "Registry cleanup skipped") | |||
| 224 | } | 402 | } | ||
| 225 | 403 | ||||
| 226 | - | func TestRunImagesPrune_UsesKeepFlagWhenNotRuntimeOnly(t *testing.T) { | 404 | + | func TestRunImagesPrune_ConfirmRejectCancelsOperation(t *testing.T) { |
| 405 | + | origConfirm := pruneConfirmFunc | |||
| 406 | + | pruneConfirmFunc = func(_ string) (bool, error) { | |||
| 407 | + | return false, nil | |||
| 408 | + | } | |||
| 409 | + | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |||
| 410 | + | ||||
| 411 | + | client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}} | |||
| 412 | + | ||||
| 413 | + | var out bytes.Buffer | |||
| 414 | + | opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast} | |||
| 415 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 416 | + | require.NoError(t, err) | |||
| 417 | + | ||||
| 418 | + | assert.Equal(t, 0, client.pruneCalls, "prune should not be called after rejection") | |||
| 419 | + | assert.Contains(t, out.String(), "cancelled") | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | func TestRunImagesPrune_NoConfirmSkipsPrompt(t *testing.T) { | |||
| 423 | + | confirmCalled := false | |||
| 424 | + | origConfirm := pruneConfirmFunc | |||
| 425 | + | pruneConfirmFunc = func(_ string) (bool, error) { | |||
| 426 | + | confirmCalled = true | |||
| 427 | + | return true, nil | |||
| 428 | + | } | |||
| 429 | + | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |||
| 430 | + | ||||
| 227 | client := &fakeImagesClient{ | 431 | client := &fakeImagesClient{ | ||
| 228 | pruneResp: &dto.ImagePruneResponse{ | 432 | pruneResp: &dto.ImagePruneResponse{ | ||
| 229 | - | Runtime: dto.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, | 433 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 1}, |
| 230 | - | Registry: dto.RegistryPruneResult{TagsRemoved: 3, BlobsRemoved: 1, SpaceReclaimed: 8192}, | |||
| 231 | }, | 434 | }, | ||
| 232 | } | 435 | } | ||
| 233 | 436 | ||||
| 234 | var out bytes.Buffer | 437 | var out bytes.Buffer | ||
| 235 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 5}, &out) | 438 | + | opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast, NoConfirm: true} |
| 439 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 236 | require.NoError(t, err) | 440 | require.NoError(t, err) | ||
| 237 | 441 | ||||
| 238 | - | text := out.String() | 442 | + | assert.False(t, confirmCalled, "confirmation should not be called with --no-confirm") |
| 239 | assert.Equal(t, 1, client.pruneCalls) | 443 | assert.Equal(t, 1, client.pruneCalls) | ||
| 240 | - | assert.Equal(t, 5, client.lastPruneKeep) | |||
| 241 | - | assert.Contains(t, text, "Runtime: deleted=2") | |||
| 242 | - | assert.Contains(t, text, "Registry: tags_removed=3") | |||
| 243 | } | 444 | } | ||
| 244 | 445 | ||||
| 446 | + | func TestRunImagesPrune_DryRunNeverPrompts(t *testing.T) { | |||
| 447 | + | confirmCalled := false | |||
| 448 | + | origConfirm := pruneConfirmFunc | |||
| 449 | + | pruneConfirmFunc = func(_ string) (bool, error) { | |||
| 450 | + | confirmCalled = true | |||
| 451 | + | return true, nil | |||
| 452 | + | } | |||
| 453 | + | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |||
| 454 | + | ||||
| 455 | + | client := &fakeImagesClient{ | |||
| 456 | + | listImagesResp: []dto.Image{{Dangling: true}}, | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | var out bytes.Buffer | |||
| 460 | + | opts := imagesPruneOptions{DryRun: true, KeepReleases: 3} | |||
| 461 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 462 | + | require.NoError(t, err) | |||
| 463 | + | ||||
| 464 | + | assert.False(t, confirmCalled, "confirmation should not be called during dry-run") | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | // --------------------------------------------------------------------------- | |||
| 468 | + | // images prune: error handling | |||
| 469 | + | // --------------------------------------------------------------------------- | |||
| 470 | + | ||||
| 245 | func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) { | 471 | func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) { | ||
| 472 | + | origConfirm := pruneConfirmFunc | |||
| 473 | + | pruneConfirmFunc = func(_ string) (bool, error) { return true, nil } | |||
| 474 | + | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |||
| 475 | + | ||||
| 246 | client := &fakeImagesClient{pruneErr: errors.New("request failed")} | 476 | client := &fakeImagesClient{pruneErr: errors.New("request failed")} | ||
| 247 | 477 | ||||
| 248 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 2}, &bytes.Buffer{}) | 478 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 2}, &bytes.Buffer{}) |
| 249 | require.Error(t, err) | 479 | require.Error(t, err) | ||
| 250 | assert.Contains(t, err.Error(), "failed to prune images") | 480 | assert.Contains(t, err.Error(), "failed to prune images") | ||
| 251 | } | 481 | } | ||
| 252 | 482 | ||||
| 253 | - | func TestRunImagesPrune_RejectsNegativeKeep(t *testing.T) { | |||
| 254 | - | err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepLast: -1}, &bytes.Buffer{}) | |||
| 255 | - | require.Error(t, err) | |||
| 256 | - | assert.Contains(t, err.Error(), "--keep must be >= 0") | |||
| 257 | - | } | |||
| 258 | - | ||||
| 259 | func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) { | 483 | func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) { | ||
| 484 | + | origConfirm := pruneConfirmFunc | |||
| 485 | + | pruneConfirmFunc = func(_ string) (bool, error) { return true, nil } | |||
| 486 | + | t.Cleanup(func() { pruneConfirmFunc = origConfirm }) | |||
| 487 | + | ||||
| 260 | client := &fakeImagesClient{pruneResp: nil} | 488 | client := &fakeImagesClient{pruneResp: nil} | ||
| 261 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 1}, &bytes.Buffer{}) | 489 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 1}, &bytes.Buffer{}) |
| 262 | require.Error(t, err) | 490 | require.Error(t, err) | ||
| 263 | assert.Contains(t, err.Error(), "empty response") | 491 | assert.Contains(t, err.Error(), "empty response") | ||
| 264 | } | 492 | } | ||
| 265 | 493 | ||||
| 494 | + | // --------------------------------------------------------------------------- | |||
| 495 | + | // test helpers | |||
| 496 | + | // --------------------------------------------------------------------------- | |||
| 497 | + | ||||
| 266 | func stripANSI(value string) string { | 498 | func stripANSI(value string) string { | ||
| 267 | ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) | 499 | ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) | ||
| 268 | return ansiRegex.ReplaceAllString(value, "") | 500 | return ansiRegex.ReplaceAllString(value, "") | ||
M internal/adapters/in/cli/presentation.go +2 -1
| @@ -30,7 +30,8 @@ func cliRenderEmptyState(msg string) string { | |||
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | 32 | func cliRenderListItem(msg string) string { | |
| 33 | - | return styles.RenderListItem(msg, false) | |
| 33 | + | const selected = false // CLI list items are never in a selected/highlighted state | |
| 34 | + | return styles.RenderListItem(msg, selected) | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | func cliRenderMeta(label, value string) string { | |
| @@ -30,7 +30,8 @@ func cliRenderEmptyState(msg string) string { | |||||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | func cliRenderListItem(msg string) string { | 32 | func cliRenderListItem(msg string) string { | ||
| 33 | - | return styles.RenderListItem(msg, false) | 33 | + | const selected = false // CLI list items are never in a selected/highlighted state |
| 34 | + | return styles.RenderListItem(msg, selected) | |||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | func cliRenderMeta(label, value string) string { | 37 | func cliRenderMeta(label, value string) string { | ||
M internal/adapters/in/cli/remote/client.go +3 -3
| @@ -501,12 +501,12 @@ func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) { | |||
| 501 | 501 | } | |
| 502 | 502 | ||
| 503 | 503 | // PruneImages prunes runtime and registry images. | |
| 504 | - | func (c *Client) PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) { | |
| 505 | - | if keepLast < 0 { | |
| 504 | + | func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) { | |
| 505 | + | if req.KeepLast != nil && *req.KeepLast < 0 { | |
| 506 | 506 | return nil, fmt.Errorf("keepLast must be >= 0") | |
| 507 | 507 | } | |
| 508 | 508 | ||
| 509 | - | resp, err := c.request(ctx, http.MethodPost, "/images/prune", dto.ImagePruneRequest{KeepLast: &keepLast}) | |
| 509 | + | resp, err := c.request(ctx, http.MethodPost, "/images/prune", req) | |
| 510 | 510 | if err != nil { | |
| 511 | 511 | return nil, err | |
| 512 | 512 | } | |
| @@ -501,12 +501,12 @@ func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) { | |||||
| 501 | } | 501 | } | ||
| 502 | 502 | ||||
| 503 | // PruneImages prunes runtime and registry images. | 503 | // PruneImages prunes runtime and registry images. | ||
| 504 | - | func (c *Client) PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) { | 504 | + | func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) { |
| 505 | - | if keepLast < 0 { | 505 | + | if req.KeepLast != nil && *req.KeepLast < 0 { |
| 506 | return nil, fmt.Errorf("keepLast must be >= 0") | 506 | return nil, fmt.Errorf("keepLast must be >= 0") | ||
| 507 | } | 507 | } | ||
| 508 | 508 | ||||
| 509 | - | resp, err := c.request(ctx, http.MethodPost, "/images/prune", dto.ImagePruneRequest{KeepLast: &keepLast}) | 509 | + | resp, err := c.request(ctx, http.MethodPost, "/images/prune", req) |
| 510 | if err != nil { | 510 | if err != nil { | ||
| 511 | return nil, err | 511 | return nil, err | ||
| 512 | } | 512 | } | ||
M internal/adapters/in/cli/remote/client_test.go +12 -1
| @@ -138,14 +138,25 @@ func TestClientPruneImages(t *testing.T) { | |||
| 138 | 138 | require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) | |
| 139 | 139 | require.NotNil(t, req.KeepLast) | |
| 140 | 140 | assert.Equal(t, 4, *req.KeepLast) | |
| 141 | + | require.NotNil(t, req.PruneDangling) | |
| 142 | + | assert.True(t, *req.PruneDangling) | |
| 143 | + | require.NotNil(t, req.PruneRegistry) | |
| 144 | + | assert.True(t, *req.PruneRegistry) | |
| 141 | 145 | ||
| 142 | 146 | w.Header().Set("Content-Type", "application/json") | |
| 143 | 147 | _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`)) | |
| 144 | 148 | })) | |
| 145 | 149 | defer srv.Close() | |
| 146 | 150 | ||
| 151 | + | keepLast := 4 | |
| 152 | + | pruneDangling := true | |
| 153 | + | pruneRegistry := true | |
| 147 | 154 | client := NewClient(srv.URL) | |
| 148 | - | resp, err := client.PruneImages(context.Background(), 4) | |
| 155 | + | resp, err := client.PruneImages(context.Background(), dto.ImagePruneRequest{ | |
| 156 | + | KeepLast: &keepLast, | |
| 157 | + | PruneDangling: &pruneDangling, | |
| 158 | + | PruneRegistry: &pruneRegistry, | |
| 159 | + | }) | |
| 149 | 160 | require.NoError(t, err) | |
| 150 | 161 | require.NotNil(t, resp) | |
| 151 | 162 | assert.Equal(t, 2, resp.Runtime.DeletedCount) | |
| @@ -138,14 +138,25 @@ func TestClientPruneImages(t *testing.T) { | |||||
| 138 | require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) | 138 | require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) | ||
| 139 | require.NotNil(t, req.KeepLast) | 139 | require.NotNil(t, req.KeepLast) | ||
| 140 | assert.Equal(t, 4, *req.KeepLast) | 140 | assert.Equal(t, 4, *req.KeepLast) | ||
| 141 | + | require.NotNil(t, req.PruneDangling) | |||
| 142 | + | assert.True(t, *req.PruneDangling) | |||
| 143 | + | require.NotNil(t, req.PruneRegistry) | |||
| 144 | + | assert.True(t, *req.PruneRegistry) | |||
| 141 | 145 | ||||
| 142 | w.Header().Set("Content-Type", "application/json") | 146 | w.Header().Set("Content-Type", "application/json") | ||
| 143 | _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`)) | 147 | _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`)) | ||
| 144 | })) | 148 | })) | ||
| 145 | defer srv.Close() | 149 | defer srv.Close() | ||
| 146 | 150 | ||||
| 151 | + | keepLast := 4 | |||
| 152 | + | pruneDangling := true | |||
| 153 | + | pruneRegistry := true | |||
| 147 | client := NewClient(srv.URL) | 154 | client := NewClient(srv.URL) | ||
| 148 | - | resp, err := client.PruneImages(context.Background(), 4) | 155 | + | resp, err := client.PruneImages(context.Background(), dto.ImagePruneRequest{ |
| 156 | + | KeepLast: &keepLast, | |||
| 157 | + | PruneDangling: &pruneDangling, | |||
| 158 | + | PruneRegistry: &pruneRegistry, | |||
| 159 | + | }) | |||
| 149 | require.NoError(t, err) | 160 | require.NoError(t, err) | ||
| 150 | require.NotNil(t, resp) | 161 | require.NotNil(t, resp) | ||
| 151 | assert.Equal(t, 2, resp.Runtime.DeletedCount) | 162 | assert.Equal(t, 2, resp.Runtime.DeletedCount) | ||
M internal/adapters/in/cli/ui_adoption_test.go +7 -6
| @@ -27,6 +27,9 @@ var uiAdoptionHelperCalls = map[string]struct{}{ | |||
| 27 | 27 | "cliRenderInfo": {}, | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | + | // uiAdoptionSeamMu guards mutations of the package-level cliWriteLine and | |
| 31 | + | // cliWritef variables. Any test that overrides these seams must hold this | |
| 32 | + | // mutex for the duration of the override and restore the originals on cleanup. | |
| 30 | 33 | var uiAdoptionSeamMu sync.Mutex | |
| 31 | 34 | ||
| 32 | 35 | func TestPresentationHelpers(t *testing.T) { | |
| @@ -120,11 +123,9 @@ func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool { | |||
| 120 | 123 | ||
| 121 | 124 | if pkgIdent.Name == "fmt" { | |
| 122 | 125 | switch sel.Sel.Name { | |
| 123 | - | case "Print", "Printf", "Println", "Fprint", "Fprintf", "Fprintln": | |
| 124 | - | if sel.Sel.Name == "Print" || sel.Sel.Name == "Printf" || sel.Sel.Name == "Println" { | |
| 125 | - | return true | |
| 126 | - | } | |
| 127 | - | ||
| 126 | + | case "Print", "Printf", "Println": | |
| 127 | + | return true | |
| 128 | + | case "Fprint", "Fprintf", "Fprintln": | |
| 128 | 129 | if len(call.Args) == 0 { | |
| 129 | 130 | return true | |
| 130 | 131 | } | |
| @@ -162,7 +163,7 @@ func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) { | |||
| 162 | 163 | return s.listResp, nil | |
| 163 | 164 | } | |
| 164 | 165 | ||
| 165 | - | func (s imagesClientStub) PruneImages(context.Context, int) (*dto.ImagePruneResponse, error) { | |
| 166 | + | func (s imagesClientStub) PruneImages(context.Context, dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) { | |
| 166 | 167 | if s.err != nil { | |
| 167 | 168 | return nil, s.err | |
| 168 | 169 | } | |
| @@ -27,6 +27,9 @@ var uiAdoptionHelperCalls = map[string]struct{}{ | |||||
| 27 | "cliRenderInfo": {}, | 27 | "cliRenderInfo": {}, | ||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | + | // uiAdoptionSeamMu guards mutations of the package-level cliWriteLine and | |||
| 31 | + | // cliWritef variables. Any test that overrides these seams must hold this | |||
| 32 | + | // mutex for the duration of the override and restore the originals on cleanup. | |||
| 30 | var uiAdoptionSeamMu sync.Mutex | 33 | var uiAdoptionSeamMu sync.Mutex | ||
| 31 | 34 | ||||
| 32 | func TestPresentationHelpers(t *testing.T) { | 35 | func TestPresentationHelpers(t *testing.T) { | ||
| @@ -120,11 +123,9 @@ func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool { | |||||
| 120 | 123 | ||||
| 121 | if pkgIdent.Name == "fmt" { | 124 | if pkgIdent.Name == "fmt" { | ||
| 122 | switch sel.Sel.Name { | 125 | switch sel.Sel.Name { | ||
| 123 | - | case "Print", "Printf", "Println", "Fprint", "Fprintf", "Fprintln": | 126 | + | case "Print", "Printf", "Println": |
| 124 | - | if sel.Sel.Name == "Print" || sel.Sel.Name == "Printf" || sel.Sel.Name == "Println" { | 127 | + | return true |
| 125 | - | return true | 128 | + | case "Fprint", "Fprintf", "Fprintln": |
| 126 | - | } | |||
| 127 | - | ||||
| 128 | if len(call.Args) == 0 { | 129 | if len(call.Args) == 0 { | ||
| 129 | return true | 130 | return true | ||
| 130 | } | 131 | } | ||
| @@ -162,7 +163,7 @@ func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) { | |||||
| 162 | return s.listResp, nil | 163 | return s.listResp, nil | ||
| 163 | } | 164 | } | ||
| 164 | 165 | ||||
| 165 | - | func (s imagesClientStub) PruneImages(context.Context, int) (*dto.ImagePruneResponse, error) { | 166 | + | func (s imagesClientStub) PruneImages(context.Context, dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) { |
| 166 | if s.err != nil { | 167 | if s.err != nil { | ||
| 167 | return nil, s.err | 168 | return nil, s.err | ||
| 168 | } | 169 | } | ||
M internal/adapters/in/http/admin/handler_test.go +83 -6
| @@ -21,7 +21,7 @@ import ( | |||
| 21 | 21 | ||
| 22 | 22 | type stubImageService struct { | |
| 23 | 23 | listImagesFunc func(context.Context) ([]domain.ImageInfo, error) | |
| 24 | - | pruneFunc func(context.Context, int) (domain.ImagePruneReport, error) | |
| 24 | + | pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | |
| @@ -31,11 +31,11 @@ func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, | |||
| 31 | 31 | return s.listImagesFunc(ctx) | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | - | func (s *stubImageService) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | |
| 34 | + | func (s *stubImageService) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |
| 35 | 35 | if s.pruneFunc == nil { | |
| 36 | 36 | return domain.ImagePruneReport{}, nil | |
| 37 | 37 | } | |
| 38 | - | return s.pruneFunc(ctx, keepLast) | |
| 38 | + | return s.pruneFunc(ctx, opts) | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | 41 | func testLogger() zerowrap.Logger { | |
| @@ -1393,9 +1393,9 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) { | |||
| 1393 | 1393 | t.Run(tt.name, func(t *testing.T) { | |
| 1394 | 1394 | called := false | |
| 1395 | 1395 | imageSvc := &stubImageService{ | |
| 1396 | - | pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) { | |
| 1396 | + | pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |
| 1397 | 1397 | called = true | |
| 1398 | - | assert.Equal(t, tt.expectedKeepLast, keepLast) | |
| 1398 | + | assert.Equal(t, tt.expectedKeepLast, opts.KeepLast) | |
| 1399 | 1399 | return domain.ImagePruneReport{ | |
| 1400 | 1400 | Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, | |
| 1401 | 1401 | Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048}, | |
| @@ -1452,7 +1452,7 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||
| 1452 | 1452 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1453 | 1453 | ||
| 1454 | 1454 | imageSvc := &stubImageService{ | |
| 1455 | - | pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) { | |
| 1455 | + | pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |
| 1456 | 1456 | return domain.ImagePruneReport{}, errors.New("prune failed: confidential details") | |
| 1457 | 1457 | }, | |
| 1458 | 1458 | } | |
| @@ -1509,6 +1509,83 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||
| 1509 | 1509 | }) | |
| 1510 | 1510 | } | |
| 1511 | 1511 | ||
| 1512 | + | func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) { | |
| 1513 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 1514 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 1515 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 1516 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 1517 | + | ||
| 1518 | + | tests := []struct { | |
| 1519 | + | name string | |
| 1520 | + | body string | |
| 1521 | + | expectedPruneDangling bool | |
| 1522 | + | expectedPruneRegistry bool | |
| 1523 | + | expectedKeepLast int | |
| 1524 | + | wantStatus int | |
| 1525 | + | }{ | |
| 1526 | + | { | |
| 1527 | + | name: "missing scope fields defaults to both true", | |
| 1528 | + | body: `{}`, | |
| 1529 | + | expectedPruneDangling: true, | |
| 1530 | + | expectedPruneRegistry: true, | |
| 1531 | + | expectedKeepLast: domain.DefaultImagePruneKeepLast, | |
| 1532 | + | wantStatus: http.StatusOK, | |
| 1533 | + | }, | |
| 1534 | + | { | |
| 1535 | + | name: "explicit dangling only", | |
| 1536 | + | body: `{"prune_dangling": true, "prune_registry": false}`, | |
| 1537 | + | expectedPruneDangling: true, | |
| 1538 | + | expectedPruneRegistry: false, | |
| 1539 | + | expectedKeepLast: domain.DefaultImagePruneKeepLast, | |
| 1540 | + | wantStatus: http.StatusOK, | |
| 1541 | + | }, | |
| 1542 | + | { | |
| 1543 | + | name: "explicit registry only", | |
| 1544 | + | body: `{"prune_dangling": false, "prune_registry": true, "keep_last": 5}`, | |
| 1545 | + | expectedPruneDangling: false, | |
| 1546 | + | expectedPruneRegistry: true, | |
| 1547 | + | expectedKeepLast: 5, | |
| 1548 | + | wantStatus: http.StatusOK, | |
| 1549 | + | }, | |
| 1550 | + | { | |
| 1551 | + | name: "both scopes false returns 400", | |
| 1552 | + | body: `{"prune_dangling": false, "prune_registry": false}`, | |
| 1553 | + | wantStatus: http.StatusBadRequest, | |
| 1554 | + | }, | |
| 1555 | + | } | |
| 1556 | + | ||
| 1557 | + | for _, tt := range tests { | |
| 1558 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1559 | + | called := false | |
| 1560 | + | imageSvc := &stubImageService{ | |
| 1561 | + | pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |
| 1562 | + | called = true | |
| 1563 | + | assert.Equal(t, tt.expectedPruneDangling, opts.PruneDangling) | |
| 1564 | + | assert.Equal(t, tt.expectedPruneRegistry, opts.PruneRegistry) | |
| 1565 | + | assert.Equal(t, tt.expectedKeepLast, opts.KeepLast) | |
| 1566 | + | return domain.ImagePruneReport{}, nil | |
| 1567 | + | }, | |
| 1568 | + | } | |
| 1569 | + | ||
| 1570 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | |
| 1571 | + | ||
| 1572 | + | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) | |
| 1573 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |
| 1574 | + | rec := httptest.NewRecorder() | |
| 1575 | + | ||
| 1576 | + | handler.ServeHTTP(rec, req) | |
| 1577 | + | ||
| 1578 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |
| 1579 | + | if tt.wantStatus == http.StatusOK { | |
| 1580 | + | assert.True(t, called, "prune should have been called") | |
| 1581 | + | } | |
| 1582 | + | if tt.wantStatus == http.StatusBadRequest { | |
| 1583 | + | assert.Contains(t, rec.Body.String(), "at least one prune scope") | |
| 1584 | + | } | |
| 1585 | + | }) | |
| 1586 | + | } | |
| 1587 | + | } | |
| 1588 | + | ||
| 1512 | 1589 | func TestHandler_Images_Authorization(t *testing.T) { | |
| 1513 | 1590 | configSvc := inmocks.NewMockConfigService(t) | |
| 1514 | 1591 | authSvc := inmocks.NewMockAuthService(t) | |
| @@ -21,7 +21,7 @@ import ( | |||||
| 21 | 21 | ||||
| 22 | type stubImageService struct { | 22 | type stubImageService struct { | ||
| 23 | listImagesFunc func(context.Context) ([]domain.ImageInfo, error) | 23 | listImagesFunc func(context.Context) ([]domain.ImageInfo, error) | ||
| 24 | - | pruneFunc func(context.Context, int) (domain.ImagePruneReport, error) | 24 | + | pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) |
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | 27 | func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | ||
| @@ -31,11 +31,11 @@ func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, | |||||
| 31 | return s.listImagesFunc(ctx) | 31 | return s.listImagesFunc(ctx) | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | - | func (s *stubImageService) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | 34 | + | func (s *stubImageService) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { |
| 35 | if s.pruneFunc == nil { | 35 | if s.pruneFunc == nil { | ||
| 36 | return domain.ImagePruneReport{}, nil | 36 | return domain.ImagePruneReport{}, nil | ||
| 37 | } | 37 | } | ||
| 38 | - | return s.pruneFunc(ctx, keepLast) | 38 | + | return s.pruneFunc(ctx, opts) |
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | func testLogger() zerowrap.Logger { | 41 | func testLogger() zerowrap.Logger { | ||
| @@ -1393,9 +1393,9 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) { | |||||
| 1393 | t.Run(tt.name, func(t *testing.T) { | 1393 | t.Run(tt.name, func(t *testing.T) { | ||
| 1394 | called := false | 1394 | called := false | ||
| 1395 | imageSvc := &stubImageService{ | 1395 | imageSvc := &stubImageService{ | ||
| 1396 | - | pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) { | 1396 | + | pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { |
| 1397 | called = true | 1397 | called = true | ||
| 1398 | - | assert.Equal(t, tt.expectedKeepLast, keepLast) | 1398 | + | assert.Equal(t, tt.expectedKeepLast, opts.KeepLast) |
| 1399 | return domain.ImagePruneReport{ | 1399 | return domain.ImagePruneReport{ | ||
| 1400 | Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, | 1400 | Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, | ||
| 1401 | Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048}, | 1401 | Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048}, | ||
| @@ -1452,7 +1452,7 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||||
| 1452 | secretSvc := inmocks.NewMockSecretService(t) | 1452 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1453 | 1453 | ||||
| 1454 | imageSvc := &stubImageService{ | 1454 | imageSvc := &stubImageService{ | ||
| 1455 | - | pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) { | 1455 | + | pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) { |
| 1456 | return domain.ImagePruneReport{}, errors.New("prune failed: confidential details") | 1456 | return domain.ImagePruneReport{}, errors.New("prune failed: confidential details") | ||
| 1457 | }, | 1457 | }, | ||
| 1458 | } | 1458 | } | ||
| @@ -1509,6 +1509,83 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||||
| 1509 | }) | 1509 | }) | ||
| 1510 | } | 1510 | } | ||
| 1511 | 1511 | ||||
| 1512 | + | func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) { | |||
| 1513 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1514 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1515 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1516 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1517 | + | ||||
| 1518 | + | tests := []struct { | |||
| 1519 | + | name string | |||
| 1520 | + | body string | |||
| 1521 | + | expectedPruneDangling bool | |||
| 1522 | + | expectedPruneRegistry bool | |||
| 1523 | + | expectedKeepLast int | |||
| 1524 | + | wantStatus int | |||
| 1525 | + | }{ | |||
| 1526 | + | { | |||
| 1527 | + | name: "missing scope fields defaults to both true", | |||
| 1528 | + | body: `{}`, | |||
| 1529 | + | expectedPruneDangling: true, | |||
| 1530 | + | expectedPruneRegistry: true, | |||
| 1531 | + | expectedKeepLast: domain.DefaultImagePruneKeepLast, | |||
| 1532 | + | wantStatus: http.StatusOK, | |||
| 1533 | + | }, | |||
| 1534 | + | { | |||
| 1535 | + | name: "explicit dangling only", | |||
| 1536 | + | body: `{"prune_dangling": true, "prune_registry": false}`, | |||
| 1537 | + | expectedPruneDangling: true, | |||
| 1538 | + | expectedPruneRegistry: false, | |||
| 1539 | + | expectedKeepLast: domain.DefaultImagePruneKeepLast, | |||
| 1540 | + | wantStatus: http.StatusOK, | |||
| 1541 | + | }, | |||
| 1542 | + | { | |||
| 1543 | + | name: "explicit registry only", | |||
| 1544 | + | body: `{"prune_dangling": false, "prune_registry": true, "keep_last": 5}`, | |||
| 1545 | + | expectedPruneDangling: false, | |||
| 1546 | + | expectedPruneRegistry: true, | |||
| 1547 | + | expectedKeepLast: 5, | |||
| 1548 | + | wantStatus: http.StatusOK, | |||
| 1549 | + | }, | |||
| 1550 | + | { | |||
| 1551 | + | name: "both scopes false returns 400", | |||
| 1552 | + | body: `{"prune_dangling": false, "prune_registry": false}`, | |||
| 1553 | + | wantStatus: http.StatusBadRequest, | |||
| 1554 | + | }, | |||
| 1555 | + | } | |||
| 1556 | + | ||||
| 1557 | + | for _, tt := range tests { | |||
| 1558 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1559 | + | called := false | |||
| 1560 | + | imageSvc := &stubImageService{ | |||
| 1561 | + | pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |||
| 1562 | + | called = true | |||
| 1563 | + | assert.Equal(t, tt.expectedPruneDangling, opts.PruneDangling) | |||
| 1564 | + | assert.Equal(t, tt.expectedPruneRegistry, opts.PruneRegistry) | |||
| 1565 | + | assert.Equal(t, tt.expectedKeepLast, opts.KeepLast) | |||
| 1566 | + | return domain.ImagePruneReport{}, nil | |||
| 1567 | + | }, | |||
| 1568 | + | } | |||
| 1569 | + | ||||
| 1570 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | |||
| 1571 | + | ||||
| 1572 | + | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) | |||
| 1573 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 1574 | + | rec := httptest.NewRecorder() | |||
| 1575 | + | ||||
| 1576 | + | handler.ServeHTTP(rec, req) | |||
| 1577 | + | ||||
| 1578 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 1579 | + | if tt.wantStatus == http.StatusOK { | |||
| 1580 | + | assert.True(t, called, "prune should have been called") | |||
| 1581 | + | } | |||
| 1582 | + | if tt.wantStatus == http.StatusBadRequest { | |||
| 1583 | + | assert.Contains(t, rec.Body.String(), "at least one prune scope") | |||
| 1584 | + | } | |||
| 1585 | + | }) | |||
| 1586 | + | } | |||
| 1587 | + | } | |||
| 1588 | + | ||||
| 1512 | func TestHandler_Images_Authorization(t *testing.T) { | 1589 | func TestHandler_Images_Authorization(t *testing.T) { | ||
| 1513 | configSvc := inmocks.NewMockConfigService(t) | 1590 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1514 | authSvc := inmocks.NewMockAuthService(t) | 1591 | authSvc := inmocks.NewMockAuthService(t) | ||
M internal/adapters/in/http/admin/images.go +14 -4
| @@ -97,18 +97,28 @@ func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) { | |||
| 97 | 97 | return | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | - | keepLast := domain.DefaultImagePruneKeepLast | |
| 100 | + | opts := domain.DefaultImagePruneOptions() | |
| 101 | 101 | if req.KeepLast != nil { | |
| 102 | 102 | if *req.KeepLast < 0 { | |
| 103 | 103 | h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0") | |
| 104 | 104 | return | |
| 105 | 105 | } | |
| 106 | - | keepLast = *req.KeepLast | |
| 106 | + | opts.KeepLast = *req.KeepLast | |
| 107 | + | } | |
| 108 | + | if req.PruneDangling != nil { | |
| 109 | + | opts.PruneDangling = *req.PruneDangling | |
| 110 | + | } | |
| 111 | + | if req.PruneRegistry != nil { | |
| 112 | + | opts.PruneRegistry = *req.PruneRegistry | |
| 113 | + | } | |
| 114 | + | if !opts.PruneDangling && !opts.PruneRegistry { | |
| 115 | + | h.sendError(w, http.StatusBadRequest, "at least one prune scope must be enabled") | |
| 116 | + | return | |
| 107 | 117 | } | |
| 108 | 118 | ||
| 109 | - | report, err := h.imageSvc.Prune(ctx, keepLast) | |
| 119 | + | report, err := h.imageSvc.Prune(ctx, opts) | |
| 110 | 120 | if err != nil { | |
| 111 | - | log.Error().Err(err).Int("keep_last", keepLast).Msg("image prune failed") | |
| 121 | + | log.Error().Err(err).Int("keep_last", opts.KeepLast).Msg("image prune failed") | |
| 112 | 122 | h.sendError(w, http.StatusInternalServerError, "failed to prune images") | |
| 113 | 123 | return | |
| 114 | 124 | } | |
| @@ -97,18 +97,28 @@ func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) { | |||||
| 97 | return | 97 | return | ||
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | - | keepLast := domain.DefaultImagePruneKeepLast | 100 | + | opts := domain.DefaultImagePruneOptions() |
| 101 | if req.KeepLast != nil { | 101 | if req.KeepLast != nil { | ||
| 102 | if *req.KeepLast < 0 { | 102 | if *req.KeepLast < 0 { | ||
| 103 | h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0") | 103 | h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0") | ||
| 104 | return | 104 | return | ||
| 105 | } | 105 | } | ||
| 106 | - | keepLast = *req.KeepLast | 106 | + | opts.KeepLast = *req.KeepLast |
| 107 | + | } | |||
| 108 | + | if req.PruneDangling != nil { | |||
| 109 | + | opts.PruneDangling = *req.PruneDangling | |||
| 110 | + | } | |||
| 111 | + | if req.PruneRegistry != nil { | |||
| 112 | + | opts.PruneRegistry = *req.PruneRegistry | |||
| 113 | + | } | |||
| 114 | + | if !opts.PruneDangling && !opts.PruneRegistry { | |||
| 115 | + | h.sendError(w, http.StatusBadRequest, "at least one prune scope must be enabled") | |||
| 116 | + | return | |||
| 107 | } | 117 | } | ||
| 108 | 118 | ||||
| 109 | - | report, err := h.imageSvc.Prune(ctx, keepLast) | 119 | + | report, err := h.imageSvc.Prune(ctx, opts) |
| 110 | if err != nil { | 120 | if err != nil { | ||
| 111 | - | log.Error().Err(err).Int("keep_last", keepLast).Msg("image prune failed") | 121 | + | log.Error().Err(err).Int("keep_last", opts.KeepLast).Msg("image prune failed") |
| 112 | h.sendError(w, http.StatusInternalServerError, "failed to prune images") | 122 | h.sendError(w, http.StatusInternalServerError, "failed to prune images") | ||
| 113 | return | 123 | return | ||
| 114 | } | 124 | } | ||
M internal/app/run.go +5 -1
| @@ -1812,7 +1812,11 @@ func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, lo | |||
| 1812 | 1812 | keepLast = domain.DefaultImagePruneKeepLast | |
| 1813 | 1813 | } | |
| 1814 | 1814 | ||
| 1815 | - | report, err := svc.imageSvc.Prune(jobCtx, keepLast) | |
| 1815 | + | report, err := svc.imageSvc.Prune(jobCtx, domain.ImagePruneOptions{ | |
| 1816 | + | KeepLast: keepLast, | |
| 1817 | + | PruneDangling: true, | |
| 1818 | + | PruneRegistry: true, | |
| 1819 | + | }) | |
| 1816 | 1820 | if err != nil { | |
| 1817 | 1821 | return err | |
| 1818 | 1822 | } | |
| @@ -1812,7 +1812,11 @@ func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, lo | |||||
| 1812 | keepLast = domain.DefaultImagePruneKeepLast | 1812 | keepLast = domain.DefaultImagePruneKeepLast | ||
| 1813 | } | 1813 | } | ||
| 1814 | 1814 | ||||
| 1815 | - | report, err := svc.imageSvc.Prune(jobCtx, keepLast) | 1815 | + | report, err := svc.imageSvc.Prune(jobCtx, domain.ImagePruneOptions{ |
| 1816 | + | KeepLast: keepLast, | |||
| 1817 | + | PruneDangling: true, | |||
| 1818 | + | PruneRegistry: true, | |||
| 1819 | + | }) | |||
| 1816 | if err != nil { | 1820 | if err != nil { | ||
| 1817 | return err | 1821 | return err | ||
| 1818 | } | 1822 | } | ||
M internal/boundaries/in/images.go +2 -2
| @@ -12,6 +12,6 @@ type ImageService interface { | |||
| 12 | 12 | // ListImages returns runtime images and known registry tags. | |
| 13 | 13 | ListImages(ctx context.Context) ([]domain.ImageInfo, error) | |
| 14 | 14 | ||
| 15 | - | // Prune removes unused images and applies retention. | |
| 16 | - | Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) | |
| 15 | + | // Prune removes unused images and applies retention based on options. | |
| 16 | + | Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) | |
| 17 | 17 | } | |
| @@ -12,6 +12,6 @@ type ImageService interface { | |||||
| 12 | // ListImages returns runtime images and known registry tags. | 12 | // ListImages returns runtime images and known registry tags. | ||
| 13 | ListImages(ctx context.Context) ([]domain.ImageInfo, error) | 13 | ListImages(ctx context.Context) ([]domain.ImageInfo, error) | ||
| 14 | 14 | ||||
| 15 | - | // Prune removes unused images and applies retention. | 15 | + | // Prune removes unused images and applies retention based on options. |
| 16 | - | Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) | 16 | + | Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) |
| 17 | } | 17 | } | ||
M internal/domain/images.go +19 -0
| @@ -4,6 +4,25 @@ import "time" | |||
| 4 | 4 | ||
| 5 | 5 | const DefaultImagePruneKeepLast = 3 | |
| 6 | 6 | ||
| 7 | + | // ImagePruneOptions controls which prune subsystems are activated. | |
| 8 | + | type ImagePruneOptions struct { | |
| 9 | + | // KeepLast is the number of most-recent non-latest tags to retain per repository. | |
| 10 | + | KeepLast int | |
| 11 | + | // PruneDangling enables runtime dangling image cleanup. | |
| 12 | + | PruneDangling bool | |
| 13 | + | // PruneRegistry enables registry tag retention and blob garbage collection. | |
| 14 | + | PruneRegistry bool | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // DefaultImagePruneOptions returns options that prune both scopes with the default retention. | |
| 18 | + | func DefaultImagePruneOptions() ImagePruneOptions { | |
| 19 | + | return ImagePruneOptions{ | |
| 20 | + | KeepLast: DefaultImagePruneKeepLast, | |
| 21 | + | PruneDangling: true, | |
| 22 | + | PruneRegistry: true, | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 7 | 26 | // ImagePruneConfig defines scheduled image cleanup behavior. | |
| 8 | 27 | type ImagePruneConfig struct { | |
| 9 | 28 | Enabled bool | |
| @@ -4,6 +4,25 @@ import "time" | |||||
| 4 | 4 | ||||
| 5 | const DefaultImagePruneKeepLast = 3 | 5 | const DefaultImagePruneKeepLast = 3 | ||
| 6 | 6 | ||||
| 7 | + | // ImagePruneOptions controls which prune subsystems are activated. | |||
| 8 | + | type ImagePruneOptions struct { | |||
| 9 | + | // KeepLast is the number of most-recent non-latest tags to retain per repository. | |||
| 10 | + | KeepLast int | |||
| 11 | + | // PruneDangling enables runtime dangling image cleanup. | |||
| 12 | + | PruneDangling bool | |||
| 13 | + | // PruneRegistry enables registry tag retention and blob garbage collection. | |||
| 14 | + | PruneRegistry bool | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // DefaultImagePruneOptions returns options that prune both scopes with the default retention. | |||
| 18 | + | func DefaultImagePruneOptions() ImagePruneOptions { | |||
| 19 | + | return ImagePruneOptions{ | |||
| 20 | + | KeepLast: DefaultImagePruneKeepLast, | |||
| 21 | + | PruneDangling: true, | |||
| 22 | + | PruneRegistry: true, | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 7 | // ImagePruneConfig defines scheduled image cleanup behavior. | 26 | // ImagePruneConfig defines scheduled image cleanup behavior. | ||
| 8 | type ImagePruneConfig struct { | 27 | type ImagePruneConfig struct { | ||
| 9 | Enabled bool | 28 | Enabled bool | ||
M internal/usecase/images/service.go +18 -12
| @@ -331,30 +331,36 @@ func (s *Service) collectKeptTagDigests( | |||
| 331 | 331 | return nil | |
| 332 | 332 | } | |
| 333 | 333 | ||
| 334 | - | // Prune runs runtime prune and registry prune and aggregates reports. | |
| 335 | - | func (s *Service) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | |
| 334 | + | // Prune runs runtime and/or registry prune based on options and aggregates reports. | |
| 335 | + | func (s *Service) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |
| 336 | 336 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 337 | 337 | zerowrap.FieldLayer: "usecase", | |
| 338 | 338 | zerowrap.FieldUseCase: "Prune", | |
| 339 | - | "keepLast": keepLast, | |
| 339 | + | "keepLast": opts.KeepLast, | |
| 340 | + | "pruneDangling": opts.PruneDangling, | |
| 341 | + | "pruneRegistry": opts.PruneRegistry, | |
| 340 | 342 | }) | |
| 341 | 343 | log := zerowrap.FromCtx(ctx) | |
| 342 | 344 | ||
| 343 | 345 | var report domain.ImagePruneReport | |
| 344 | 346 | ||
| 345 | - | runtimeReport, err := s.PruneRuntime(ctx) | |
| 346 | - | if err != nil { | |
| 347 | - | log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune") | |
| 348 | - | } else { | |
| 349 | - | report.Runtime = runtimeReport.Runtime | |
| 347 | + | if opts.PruneDangling { | |
| 348 | + | runtimeReport, err := s.PruneRuntime(ctx) | |
| 349 | + | if err != nil { | |
| 350 | + | log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune") | |
| 351 | + | } else { | |
| 352 | + | report.Runtime = runtimeReport.Runtime | |
| 353 | + | } | |
| 350 | 354 | } | |
| 351 | 355 | ||
| 352 | - | registryReport, err := s.PruneRegistry(ctx, keepLast) | |
| 353 | - | if err != nil { | |
| 354 | - | return report, err | |
| 356 | + | if opts.PruneRegistry { | |
| 357 | + | registryReport, err := s.PruneRegistry(ctx, opts.KeepLast) | |
| 358 | + | if err != nil { | |
| 359 | + | return report, err | |
| 360 | + | } | |
| 361 | + | report.Registry = registryReport.Registry | |
| 355 | 362 | } | |
| 356 | 363 | ||
| 357 | - | report.Registry = registryReport.Registry | |
| 358 | 364 | return report, nil | |
| 359 | 365 | } | |
| 360 | 366 | ||
| @@ -331,30 +331,36 @@ func (s *Service) collectKeptTagDigests( | |||||
| 331 | return nil | 331 | return nil | ||
| 332 | } | 332 | } | ||
| 333 | 333 | ||||
| 334 | - | // Prune runs runtime prune and registry prune and aggregates reports. | 334 | + | // Prune runs runtime and/or registry prune based on options and aggregates reports. |
| 335 | - | func (s *Service) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | 335 | + | func (s *Service) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { |
| 336 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 336 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 337 | zerowrap.FieldLayer: "usecase", | 337 | zerowrap.FieldLayer: "usecase", | ||
| 338 | zerowrap.FieldUseCase: "Prune", | 338 | zerowrap.FieldUseCase: "Prune", | ||
| 339 | - | "keepLast": keepLast, | 339 | + | "keepLast": opts.KeepLast, |
| 340 | + | "pruneDangling": opts.PruneDangling, | |||
| 341 | + | "pruneRegistry": opts.PruneRegistry, | |||
| 340 | }) | 342 | }) | ||
| 341 | log := zerowrap.FromCtx(ctx) | 343 | log := zerowrap.FromCtx(ctx) | ||
| 342 | 344 | ||||
| 343 | var report domain.ImagePruneReport | 345 | var report domain.ImagePruneReport | ||
| 344 | 346 | ||||
| 345 | - | runtimeReport, err := s.PruneRuntime(ctx) | 347 | + | if opts.PruneDangling { |
| 346 | - | if err != nil { | 348 | + | runtimeReport, err := s.PruneRuntime(ctx) |
| 347 | - | log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune") | 349 | + | if err != nil { |
| 348 | - | } else { | 350 | + | log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune") |
| 349 | - | report.Runtime = runtimeReport.Runtime | 351 | + | } else { |
| 352 | + | report.Runtime = runtimeReport.Runtime | |||
| 353 | + | } | |||
| 350 | } | 354 | } | ||
| 351 | 355 | ||||
| 352 | - | registryReport, err := s.PruneRegistry(ctx, keepLast) | 356 | + | if opts.PruneRegistry { |
| 353 | - | if err != nil { | 357 | + | registryReport, err := s.PruneRegistry(ctx, opts.KeepLast) |
| 354 | - | return report, err | 358 | + | if err != nil { |
| 359 | + | return report, err | |||
| 360 | + | } | |||
| 361 | + | report.Registry = registryReport.Registry | |||
| 355 | } | 362 | } | ||
| 356 | 363 | ||||
| 357 | - | report.Registry = registryReport.Registry | |||
| 358 | return report, nil | 364 | return report, nil | ||
| 359 | } | 365 | } | ||
| 360 | 366 | ||||
M internal/usecase/images/service_test.go +51 -2
| @@ -469,7 +469,9 @@ func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | |||
| 469 | 469 | ||
| 470 | 470 | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 471 | 471 | ||
| 472 | - | report, err := svc.Prune(context.Background(), 1) | |
| 472 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 473 | + | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |
| 474 | + | }) | |
| 473 | 475 | ||
| 474 | 476 | require.NoError(t, err) | |
| 475 | 477 | assert.Equal(t, 1, report.Runtime.DeletedCount) | |
| @@ -494,7 +496,9 @@ func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) { | |||
| 494 | 496 | ||
| 495 | 497 | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 496 | 498 | ||
| 497 | - | report, err := svc.Prune(context.Background(), 1) | |
| 499 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 500 | + | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |
| 501 | + | }) | |
| 498 | 502 | ||
| 499 | 503 | require.NoError(t, err) | |
| 500 | 504 | assert.Equal(t, 0, report.Runtime.DeletedCount) | |
| @@ -505,6 +509,51 @@ func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) { | |||
| 505 | 509 | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | |
| 506 | 510 | } | |
| 507 | 511 | ||
| 512 | + | func TestService_Prune_DanglingOnlySkipsRegistry(t *testing.T) { | |
| 513 | + | manifestStorage := newFakeManifestStorage() | |
| 514 | + | manifestStorage.repositories = []string{"gordon/api"} | |
| 515 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | |
| 516 | + | blobStorage := &fakeBlobStorage{} | |
| 517 | + | rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 512}} | |
| 518 | + | ||
| 519 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 520 | + | ||
| 521 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 522 | + | KeepLast: 3, PruneDangling: true, PruneRegistry: false, | |
| 523 | + | }) | |
| 524 | + | ||
| 525 | + | require.NoError(t, err) | |
| 526 | + | assert.True(t, rt.pruneCalled, "runtime prune should have been called") | |
| 527 | + | assert.Equal(t, 1, report.Runtime.DeletedCount) | |
| 528 | + | assert.Equal(t, 0, report.Registry.TagsRemoved, "registry should not be pruned") | |
| 529 | + | assert.Equal(t, 0, manifestStorage.listRepositoriesCalls, "should not list repositories when registry disabled") | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) { | |
| 533 | + | manifestStorage := newFakeManifestStorage() | |
| 534 | + | manifestStorage.repositories = []string{"gordon/api"} | |
| 535 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |
| 536 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 537 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 538 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 539 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 540 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |
| 541 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |
| 542 | + | rt := &fakeRuntime{} | |
| 543 | + | ||
| 544 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 545 | + | ||
| 546 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 547 | + | KeepLast: 1, PruneDangling: false, PruneRegistry: true, | |
| 548 | + | }) | |
| 549 | + | ||
| 550 | + | require.NoError(t, err) | |
| 551 | + | assert.False(t, rt.pruneCalled, "runtime prune should not have been called") | |
| 552 | + | assert.Equal(t, 0, report.Runtime.DeletedCount) | |
| 553 | + | assert.Equal(t, 1, report.Registry.TagsRemoved) | |
| 554 | + | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |
| 555 | + | } | |
| 556 | + | ||
| 508 | 557 | type fakeRuntime struct { | |
| 509 | 558 | pkgruntime.Runtime | |
| 510 | 559 | ||
| @@ -469,7 +469,9 @@ func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | |||||
| 469 | 469 | ||||
| 470 | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | 470 | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | ||
| 471 | 471 | ||||
| 472 | - | report, err := svc.Prune(context.Background(), 1) | 472 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ |
| 473 | + | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |||
| 474 | + | }) | |||
| 473 | 475 | ||||
| 474 | require.NoError(t, err) | 476 | require.NoError(t, err) | ||
| 475 | assert.Equal(t, 1, report.Runtime.DeletedCount) | 477 | assert.Equal(t, 1, report.Runtime.DeletedCount) | ||
| @@ -494,7 +496,9 @@ func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) { | |||||
| 494 | 496 | ||||
| 495 | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | 497 | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | ||
| 496 | 498 | ||||
| 497 | - | report, err := svc.Prune(context.Background(), 1) | 499 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ |
| 500 | + | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |||
| 501 | + | }) | |||
| 498 | 502 | ||||
| 499 | require.NoError(t, err) | 503 | require.NoError(t, err) | ||
| 500 | assert.Equal(t, 0, report.Runtime.DeletedCount) | 504 | assert.Equal(t, 0, report.Runtime.DeletedCount) | ||
| @@ -505,6 +509,51 @@ func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) { | |||||
| 505 | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | 509 | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | ||
| 506 | } | 510 | } | ||
| 507 | 511 | ||||
| 512 | + | func TestService_Prune_DanglingOnlySkipsRegistry(t *testing.T) { | |||
| 513 | + | manifestStorage := newFakeManifestStorage() | |||
| 514 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 515 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | |||
| 516 | + | blobStorage := &fakeBlobStorage{} | |||
| 517 | + | rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 512}} | |||
| 518 | + | ||||
| 519 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 520 | + | ||||
| 521 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |||
| 522 | + | KeepLast: 3, PruneDangling: true, PruneRegistry: false, | |||
| 523 | + | }) | |||
| 524 | + | ||||
| 525 | + | require.NoError(t, err) | |||
| 526 | + | assert.True(t, rt.pruneCalled, "runtime prune should have been called") | |||
| 527 | + | assert.Equal(t, 1, report.Runtime.DeletedCount) | |||
| 528 | + | assert.Equal(t, 0, report.Registry.TagsRemoved, "registry should not be pruned") | |||
| 529 | + | assert.Equal(t, 0, manifestStorage.listRepositoriesCalls, "should not list repositories when registry disabled") | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) { | |||
| 533 | + | manifestStorage := newFakeManifestStorage() | |||
| 534 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 535 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |||
| 536 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 537 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |||
| 538 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |||
| 539 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |||
| 540 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |||
| 541 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |||
| 542 | + | rt := &fakeRuntime{} | |||
| 543 | + | ||||
| 544 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 545 | + | ||||
| 546 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |||
| 547 | + | KeepLast: 1, PruneDangling: false, PruneRegistry: true, | |||
| 548 | + | }) | |||
| 549 | + | ||||
| 550 | + | require.NoError(t, err) | |||
| 551 | + | assert.False(t, rt.pruneCalled, "runtime prune should not have been called") | |||
| 552 | + | assert.Equal(t, 0, report.Runtime.DeletedCount) | |||
| 553 | + | assert.Equal(t, 1, report.Registry.TagsRemoved) | |||
| 554 | + | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |||
| 555 | + | } | |||
| 556 | + | ||||
| 508 | type fakeRuntime struct { | 557 | type fakeRuntime struct { | ||
| 509 | pkgruntime.Runtime | 558 | pkgruntime.Runtime | ||
| 510 | 559 | ||||