detached 24a521d

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>
1111 Subcommands:
1212
1313 - `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.
1515
1616 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
1717
@@ -27,25 +27,33 @@ Rows that exist only in the registry (not currently present in the runtime cache
2727 ## gordon images prune
2828
2929 ```bash
30-gordon images prune [--dry-run] [--keep <n>] [--runtime-only]
30+gordon images prune [--dry-run] [--keep-releases <n>] [--dangling] [--registry] [--no-confirm]
3131 ```
3232
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+
3335 Flags:
3436
3537 | Flag | Default | Description |
3638 |------|---------|-------------|
3739 | `--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
4046
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).
4251
43-Behavior notes:
52+### Retention semantics
4453
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.
4957
5058 ## Examples
5159
@@ -53,14 +61,20 @@ Behavior notes:
5361 # List images
5462 gordon images list
5563
64+# Prune everything with defaults (dangling + registry, keep latest + 3)
65+gordon images prune
66+
5667 # Prune dangling runtime images only
57-gordon images prune --runtime-only
68+gordon images prune --dangling
5869
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
6172
6273 # 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
6478 ```
6579
6680 ## 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 prune27 ## gordon images prune
28 28
29 ```bash29 ```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 ## Examples58 ## Examples
51 59
@@ -53,14 +61,20 @@ Behavior notes:
53 # List images61 # List images
54 gordon images list62 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 only67 # Prune dangling runtime images only
57-gordon images prune --runtime-only68+gordon images prune --dangling
58 69
59-# Prune runtime images and keep latest + 3 previous tags per repository70+# Prune registry tags only, keeping latest + 5 previous
60-gordon images prune --keep 371+gordon images prune --registry --keep-releases 5
61 72
62 # Preview cleanup without applying73 # Preview cleanup without applying
63-gordon images prune --dry-run --keep 574+gordon images prune --dry-run
75+
76+# Skip confirmation prompt
77+gordon images prune --no-confirm
64 ```78 ```
65 79
66 ## Required Permissions80 ## Required Permissions
M docs/config/images.md +2 -0
@@ -11,6 +11,8 @@ When enabled, Gordon runs a scheduled image prune job that:
1111 - Preserves the `latest` tag.
1212 - Removes unreferenced blobs after tag cleanup.
1313
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+
1416 ## Configuration
1517
1618 ```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 ## Configuration16 ## Configuration
15 17
16 ```toml18 ```toml
M internal/adapters/dto/admin_images.go +3 -1
@@ -19,7 +19,9 @@ type ImagesResponse struct {
1919
2020 // ImagePruneRequest triggers image pruning.
2121 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"`
2325 }
2426
2527 // 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 (
1212
1313 "github.com/bnema/gordon/internal/adapters/dto"
1414 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
15+ "github.com/bnema/gordon/internal/domain"
1516 )
1617
1718 type imagesClient interface {
1819 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)
2021 }
2122
2223 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
2629 }
2730
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.
2843 const (
2944 imagesListRepositoryColumnWidth = 44
3045 imagesListTagColumnWidth = 14
@@ -79,6 +94,10 @@ func newImagesPruneCmd() *cobra.Command {
7994 cmd := &cobra.Command{
8095 Use: "prune",
8196 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.`,
82101 RunE: func(cmd *cobra.Command, _ []string) error {
83102 client, isRemote := GetRemoteClient()
84103 if !isRemote {
@@ -90,12 +109,24 @@ func newImagesPruneCmd() *cobra.Command {
90109 }
91110
92111 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")
95117
96118 return cmd
97119 }
98120
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+
99130 func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error {
100131 images, err := client.ListImages(ctx)
101132 if err != nil {
@@ -149,68 +180,100 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
149180 }
150181
151182 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")
154185 }
155186
156- keepLast := opts.KeepLast
157- if opts.RuntimeOnly {
158- keepLast = 0
159- }
187+ pruneDangling, pruneRegistry := resolvePruneScopes(opts)
160188
161189 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.")
163196 if err != nil {
164- return fmt.Errorf("failed to list images: %w", err)
197+ return fmt.Errorf("confirmation failed: %w", err)
165198 }
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"))
172201 }
202+ }
173203
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 {
182216 return err
183217 }
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 {
186220 return err
187221 }
222+ }
188223
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)
190226 return err
191227 }
192228
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)
194235 if err != nil {
195- return fmt.Errorf("failed to prune images: %w", err)
236+ return fmt.Errorf("failed to list images: %w", err)
196237 }
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+ }
199244 }
200245
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 {
202247 return err
203248 }
204249
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)
207262 return err
208263 }
209264
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)"))
211266 return err
212267 }
213268
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+
214277 func formatImageCreatedAt(t time.Time) string {
215278 if t.IsZero() {
216279 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 bool24+ DryRun bool
24- KeepLast int25+ KeepReleases int
25- RuntimeOnly bool26+ 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 = 4444 imagesListRepositoryColumnWidth = 44
30 imagesListTagColumnWidth = 1445 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 cmd118 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.KeepLast187+ 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 := 0200+ 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 err205+
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 err208+ 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 err216 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 err220 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 err226 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 err247 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 err262 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 err266 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 (
1313 "github.com/stretchr/testify/require"
1414
1515 "github.com/bnema/gordon/internal/adapters/dto"
16+ "github.com/bnema/gordon/internal/domain"
1617 )
1718
1819 type fakeImagesClient struct {
@@ -23,7 +24,7 @@ type fakeImagesClient struct {
2324
2425 listImagesCalls int
2526 pruneCalls int
26- lastPruneKeep int
27+ lastPruneOpts dto.ImagePruneRequest
2728 }
2829
2930 func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) {
@@ -34,15 +35,19 @@ func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) {
3435 return f.listImagesResp, nil
3536 }
3637
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) {
3839 f.pruneCalls++
39- f.lastPruneKeep = keepLast
40+ f.lastPruneOpts = req
4041 if f.pruneErr != nil {
4142 return nil, f.pruneErr
4243 }
4344 return f.pruneResp, nil
4445 }
4546
47+// ---------------------------------------------------------------------------
48+// images list tests (unchanged)
49+// ---------------------------------------------------------------------------
50+
4651 func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
4752 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
4853 client := &fakeImagesClient{
@@ -181,13 +186,123 @@ func TestRunImagesList_EmptyOutput(t *testing.T) {
181186 assert.Equal(t, 1, client.listImagesCalls)
182187 }
183188
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) {
185299 client := &fakeImagesClient{
186300 listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}},
187301 }
188302
189303 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)
191306 require.NoError(t, err)
192307
193308 text := out.String()
@@ -198,71 +313,188 @@ func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) {
198313 assert.Equal(t, 0, client.pruneCalls)
199314 }
200315
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+ }
203320
204321 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)
206324 require.NoError(t, err)
207325
208326 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")
211329 assert.Equal(t, 0, client.pruneCalls)
212330 }
213331
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+ }
216394
217395 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)
219398 require.NoError(t, err)
220399
400+ assert.True(t, confirmCalled, "confirmation should have been called")
221401 assert.Equal(t, 1, client.pruneCalls)
222- assert.Equal(t, 0, client.lastPruneKeep)
223- assert.Contains(t, out.String(), "Registry cleanup skipped")
224402 }
225403
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+
227431 client := &fakeImagesClient{
228432 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},
231434 },
232435 }
233436
234437 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)
236440 require.NoError(t, err)
237441
238- text := out.String()
442+ assert.False(t, confirmCalled, "confirmation should not be called with --no-confirm")
239443 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")
243444 }
244445
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+
245471 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+
246476 client := &fakeImagesClient{pruneErr: errors.New("request failed")}
247477
248- err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 2}, &bytes.Buffer{})
478+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 2}, &bytes.Buffer{})
249479 require.Error(t, err)
250480 assert.Contains(t, err.Error(), "failed to prune images")
251481 }
252482
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-
259483 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+
260488 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{})
262490 require.Error(t, err)
263491 assert.Contains(t, err.Error(), "empty response")
264492 }
265493
494+// ---------------------------------------------------------------------------
495+// test helpers
496+// ---------------------------------------------------------------------------
497+
266498 func stripANSI(value string) string {
267499 ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
268500 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 int25 listImagesCalls int
25 pruneCalls int26 pruneCalls int
26- lastPruneKeep int27+ 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, nil35 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 = keepLast40+ f.lastPruneOpts = req
40 if f.pruneErr != nil {41 if f.pruneErr != nil {
41 return nil, f.pruneErr42 return nil, f.pruneErr
42 }43 }
43 return f.pruneResp, nil44 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.Buffer303 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.Buffer321 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.Buffer395 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.Buffer437 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 {
3030 }
3131
3232 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)
3435 }
3536
3637 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) {
501501 }
502502
503503 // 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 {
506506 return nil, fmt.Errorf("keepLast must be >= 0")
507507 }
508508
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)
510510 if err != nil {
511511 return nil, err
512512 }
@@ -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, err511 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) {
138138 require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
139139 require.NotNil(t, req.KeepLast)
140140 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)
141145
142146 w.Header().Set("Content-Type", "application/json")
143147 _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`))
144148 }))
145149 defer srv.Close()
146150
151+ keepLast := 4
152+ pruneDangling := true
153+ pruneRegistry := true
147154 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+ })
149160 require.NoError(t, err)
150161 require.NotNil(t, resp)
151162 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{}{
2727 "cliRenderInfo": {},
2828 }
2929
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.
3033 var uiAdoptionSeamMu sync.Mutex
3134
3235 func TestPresentationHelpers(t *testing.T) {
@@ -120,11 +123,9 @@ func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool {
120123
121124 if pkgIdent.Name == "fmt" {
122125 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":
128129 if len(call.Args) == 0 {
129130 return true
130131 }
@@ -162,7 +163,7 @@ func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) {
162163 return s.listResp, nil
163164 }
164165
165-func (s imagesClientStub) PruneImages(context.Context, int) (*dto.ImagePruneResponse, error) {
166+func (s imagesClientStub) PruneImages(context.Context, dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) {
166167 if s.err != nil {
167168 return nil, s.err
168169 }
@@ -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.Mutex33 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 true128+ case "Fprint", "Fprintf", "Fprintln":
126- }
127-
128 if len(call.Args) == 0 {129 if len(call.Args) == 0 {
129 return true130 return true
130 }131 }
@@ -162,7 +163,7 @@ func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) {
162 return s.listResp, nil163 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.err168 return nil, s.err
168 }169 }
M internal/adapters/in/http/admin/handler_test.go +83 -6
@@ -21,7 +21,7 @@ import (
2121
2222 type stubImageService struct {
2323 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)
2525 }
2626
2727 func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
@@ -31,11 +31,11 @@ func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo,
3131 return s.listImagesFunc(ctx)
3232 }
3333
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) {
3535 if s.pruneFunc == nil {
3636 return domain.ImagePruneReport{}, nil
3737 }
38- return s.pruneFunc(ctx, keepLast)
38+ return s.pruneFunc(ctx, opts)
3939 }
4040
4141 func testLogger() zerowrap.Logger {
@@ -1393,9 +1393,9 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
13931393 t.Run(tt.name, func(t *testing.T) {
13941394 called := false
13951395 imageSvc := &stubImageService{
1396- pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) {
1396+ pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
13971397 called = true
1398- assert.Equal(t, tt.expectedKeepLast, keepLast)
1398+ assert.Equal(t, tt.expectedKeepLast, opts.KeepLast)
13991399 return domain.ImagePruneReport{
14001400 Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
14011401 Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
@@ -1452,7 +1452,7 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
14521452 secretSvc := inmocks.NewMockSecretService(t)
14531453
14541454 imageSvc := &stubImageService{
1455- pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) {
1455+ pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) {
14561456 return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
14571457 },
14581458 }
@@ -1509,6 +1509,83 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
15091509 })
15101510 }
15111511
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+
15121589 func TestHandler_Images_Authorization(t *testing.T) {
15131590 configSvc := inmocks.NewMockConfigService(t)
15141591 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{}, nil36 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 := false1394 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 = true1397 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) {
9797 return
9898 }
9999
100- keepLast := domain.DefaultImagePruneKeepLast
100+ opts := domain.DefaultImagePruneOptions()
101101 if req.KeepLast != nil {
102102 if *req.KeepLast < 0 {
103103 h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0")
104104 return
105105 }
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
107117 }
108118
109- report, err := h.imageSvc.Prune(ctx, keepLast)
119+ report, err := h.imageSvc.Prune(ctx, opts)
110120 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")
112122 h.sendError(w, http.StatusInternalServerError, "failed to prune images")
113123 return
114124 }
@@ -97,18 +97,28 @@ func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) {
97 return97 return
98 }98 }
99 99
100- keepLast := domain.DefaultImagePruneKeepLast100+ 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 return104 return
105 }105 }
106- keepLast = *req.KeepLast106+ 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 return123 return
114 }124 }
M internal/app/run.go +5 -1
@@ -1812,7 +1812,11 @@ func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, lo
18121812 keepLast = domain.DefaultImagePruneKeepLast
18131813 }
18141814
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+ })
18161820 if err != nil {
18171821 return err
18181822 }
@@ -1812,7 +1812,11 @@ func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, lo
1812 keepLast = domain.DefaultImagePruneKeepLast1812 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 err1821 return err
1818 }1822 }
M internal/boundaries/in/images.go +2 -2
@@ -12,6 +12,6 @@ type ImageService interface {
1212 // ListImages returns runtime images and known registry tags.
1313 ListImages(ctx context.Context) ([]domain.ImageInfo, error)
1414
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)
1717 }
@@ -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"
44
55 const DefaultImagePruneKeepLast = 3
66
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+
726 // ImagePruneConfig defines scheduled image cleanup behavior.
827 type ImagePruneConfig struct {
928 Enabled bool
@@ -4,6 +4,25 @@ import "time"
4 4
5 const DefaultImagePruneKeepLast = 35 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 bool28 Enabled bool
M internal/usecase/images/service.go +18 -12
@@ -331,30 +331,36 @@ func (s *Service) collectKeptTagDigests(
331331 return nil
332332 }
333333
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) {
336336 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
337337 zerowrap.FieldLayer: "usecase",
338338 zerowrap.FieldUseCase: "Prune",
339- "keepLast": keepLast,
339+ "keepLast": opts.KeepLast,
340+ "pruneDangling": opts.PruneDangling,
341+ "pruneRegistry": opts.PruneRegistry,
340342 })
341343 log := zerowrap.FromCtx(ctx)
342344
343345 var report domain.ImagePruneReport
344346
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+ }
350354 }
351355
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
355362 }
356363
357- report.Registry = registryReport.Registry
358364 return report, nil
359365 }
360366
@@ -331,30 +331,36 @@ func (s *Service) collectKeptTagDigests(
331 return nil331 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.ImagePruneReport345 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.Runtime351+ } 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, err358+ 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, nil364 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) {
469469
470470 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
471471
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+ })
473475
474476 require.NoError(t, err)
475477 assert.Equal(t, 1, report.Runtime.DeletedCount)
@@ -494,7 +496,9 @@ func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
494496
495497 svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
496498
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+ })
498502
499503 require.NoError(t, err)
500504 assert.Equal(t, 0, report.Runtime.DeletedCount)
@@ -505,6 +509,51 @@ func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
505509 assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
506510 }
507511
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+
508557 type fakeRuntime struct {
509558 pkgruntime.Runtime
510559
@@ -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.Runtime558 pkgruntime.Runtime
510 559

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close