Merge pull request #76 from bnema/refactor/cli-ui-style-unification
refactor(cli): unify CLI presentation and redesign images prune
22 files · +1418 -153
this diff holds invisible characters (direction controls, zero-width characters). They are shown as <U+XXXX> markers, never applied.
M docs/cli/images.md +28 -14
| @@ -11,7 +11,7 @@ gordon images <subcommand> | |||
| 11 | 11 | Subcommands: | |
| 12 | 12 | ||
| 13 | 13 | - `list` - List runtime images and registry tags. | |
| 14 | - | - `prune` - Prune dangling runtime images and optionally registry data. | |
| 14 | + | - `prune` - Prune dangling runtime images and old registry tags. | |
| 15 | 15 | ||
| 16 | 16 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | |
| 17 | 17 | ||
| @@ -27,25 +27,33 @@ Rows that exist only in the registry (not currently present in the runtime cache | |||
| 27 | 27 | ## gordon images prune | |
| 28 | 28 | ||
| 29 | 29 | ```bash | |
| 30 | - | gordon images prune [--dry-run] [--keep <n>] [--runtime-only] | |
| 30 | + | gordon images prune [--dry-run] [--keep-releases <n>] [--dangling] [--registry] [--no-confirm] | |
| 31 | 31 | ``` | |
| 32 | 32 | ||
| 33 | + | By default, prune removes dangling runtime images **and** applies registry tag retention (keeping `latest` + 3 previous non-`latest` tags per repository). A confirmation prompt is shown before destructive operations. | |
| 34 | + | ||
| 33 | 35 | Flags: | |
| 34 | 36 | ||
| 35 | 37 | | Flag | Default | Description | | |
| 36 | 38 | |------|---------|-------------| | |
| 37 | 39 | | `--dry-run` | `false` | Show prune behavior without applying changes | | |
| 38 | - | | `--keep` | `0` | Number of previous tags to keep per repository (`latest` is always preserved when present; `0` disables registry cleanup) | | |
| 39 | - | | `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off | | |
| 40 | + | | `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) | | |
| 41 | + | | `--dangling` | `false` | Restrict scope to dangling runtime images only | | |
| 42 | + | | `--registry` | `false` | Restrict scope to registry tag retention only | | |
| 43 | + | | `--no-confirm` | `false` | Skip the confirmation prompt | | |
| 44 | + | ||
| 45 | + | ### Scope resolution | |
| 40 | 46 | ||
| 41 | - | `--keep` defaults to `0` for ad-hoc CLI safety (registry cleanup off by default), while scheduled pruning uses `images.prune.keep_last` from config (default `3`). Use config for recurring behavior; when a CLI `--keep` value is provided, that request value is used. | |
| 47 | + | - **No scope flags** (default): both runtime and registry cleanup run. | |
| 48 | + | - **`--dangling`**: only runtime dangling images are pruned; registry is skipped. | |
| 49 | + | - **`--registry`**: only registry tag retention runs; runtime is skipped. | |
| 50 | + | - **`--dangling --registry`**: both scopes run (same as default, but explicit). | |
| 42 | 51 | ||
| 43 | - | Behavior notes: | |
| 52 | + | ### Retention semantics | |
| 44 | 53 | ||
| 45 | - | - `--runtime-only` forces `keep_last = 0`. | |
| 46 | - | - `--keep` must be `>= 0`. | |
| 47 | - | - `--dry-run` reports what would be pruned. | |
| 48 | - | - When `--keep` is greater than `0`, registry cleanup keeps `latest` (if present) plus the requested number of most-recent non-`latest` tags. | |
| 54 | + | - `latest` is always preserved when present. | |
| 55 | + | - `--keep-releases` counts non-`latest` tags, ordered by most recent first. | |
| 56 | + | - `--keep-releases=0` with registry scope enabled still runs registry cleanup but keeps no non-`latest` tags. | |
| 49 | 57 | ||
| 50 | 58 | ## Examples | |
| 51 | 59 | ||
| @@ -53,14 +61,20 @@ Behavior notes: | |||
| 53 | 61 | # List images | |
| 54 | 62 | gordon images list | |
| 55 | 63 | ||
| 64 | + | # Prune everything with defaults (dangling + registry, keep latest + 3) | |
| 65 | + | gordon images prune | |
| 66 | + | ||
| 56 | 67 | # Prune dangling runtime images only | |
| 57 | - | gordon images prune --runtime-only | |
| 68 | + | gordon images prune --dangling | |
| 58 | 69 | ||
| 59 | - | # Prune runtime images and keep latest + 3 previous tags per repository | |
| 60 | - | gordon images prune --keep 3 | |
| 70 | + | # Prune registry tags only, keeping latest + 5 previous | |
| 71 | + | gordon images prune --registry --keep-releases 5 | |
| 61 | 72 | ||
| 62 | 73 | # Preview cleanup without applying | |
| 63 | - | gordon images prune --dry-run --keep 5 | |
| 74 | + | gordon images prune --dry-run | |
| 75 | + | ||
| 76 | + | # Skip confirmation prompt | |
| 77 | + | gordon images prune --no-confirm | |
| 64 | 78 | ``` | |
| 65 | 79 | ||
| 66 | 80 | ## Required Permissions | |
| @@ -11,7 +11,7 @@ gordon images <subcommand> | |||||
| 11 | Subcommands: | 11 | Subcommands: | ||
| 12 | 12 | ||||
| 13 | - `list` - List runtime images and registry tags. | 13 | - `list` - List runtime images and registry tags. | ||
| 14 | - | - `prune` - Prune dangling runtime images and optionally registry data. | 14 | + | - `prune` - Prune dangling runtime images and old registry tags. |
| 15 | 15 | ||||
| 16 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | 16 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | ||
| 17 | 17 | ||||
| @@ -27,25 +27,33 @@ Rows that exist only in the registry (not currently present in the runtime cache | |||||
| 27 | ## gordon images prune | 27 | ## gordon images prune | ||
| 28 | 28 | ||||
| 29 | ```bash | 29 | ```bash | ||
| 30 | - | gordon images prune [--dry-run] [--keep <n>] [--runtime-only] | 30 | + | gordon images prune [--dry-run] [--keep-releases <n>] [--dangling] [--registry] [--no-confirm] |
| 31 | ``` | 31 | ``` | ||
| 32 | 32 | ||||
| 33 | + | By default, prune removes dangling runtime images **and** applies registry tag retention (keeping `latest` + 3 previous non-`latest` tags per repository). A confirmation prompt is shown before destructive operations. | |||
| 34 | + | ||||
| 33 | Flags: | 35 | Flags: | ||
| 34 | 36 | ||||
| 35 | | Flag | Default | Description | | 37 | | Flag | Default | Description | | ||
| 36 | |------|---------|-------------| | 38 | |------|---------|-------------| | ||
| 37 | | `--dry-run` | `false` | Show prune behavior without applying changes | | 39 | | `--dry-run` | `false` | Show prune behavior without applying changes | | ||
| 38 | - | | `--keep` | `0` | Number of previous tags to keep per repository (`latest` is always preserved when present; `0` disables registry cleanup) | | 40 | + | | `--keep-releases` | `3` | Number of previous non-`latest` tags to keep per repository (`latest` is always preserved) | |
| 39 | - | | `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off | | 41 | + | | `--dangling` | `false` | Restrict scope to dangling runtime images only | |
| 42 | + | | `--registry` | `false` | Restrict scope to registry tag retention only | | |||
| 43 | + | | `--no-confirm` | `false` | Skip the confirmation prompt | | |||
| 44 | + | ||||
| 45 | + | ### Scope resolution | |||
| 40 | 46 | ||||
| 41 | - | `--keep` defaults to `0` for ad-hoc CLI safety (registry cleanup off by default), while scheduled pruning uses `images.prune.keep_last` from config (default `3`). Use config for recurring behavior; when a CLI `--keep` value is provided, that request value is used. | 47 | + | - **No scope flags** (default): both runtime and registry cleanup run. |
| 48 | + | - **`--dangling`**: only runtime dangling images are pruned; registry is skipped. | |||
| 49 | + | - **`--registry`**: only registry tag retention runs; runtime is skipped. | |||
| 50 | + | - **`--dangling --registry`**: both scopes run (same as default, but explicit). | |||
| 42 | 51 | ||||
| 43 | - | Behavior notes: | 52 | + | ### Retention semantics |
| 44 | 53 | ||||
| 45 | - | - `--runtime-only` forces `keep_last = 0`. | 54 | + | - `latest` is always preserved when present. |
| 46 | - | - `--keep` must be `>= 0`. | 55 | + | - `--keep-releases` counts non-`latest` tags, ordered by most recent first. |
| 47 | - | - `--dry-run` reports what would be pruned. | 56 | + | - `--keep-releases=0` with registry scope enabled still runs registry cleanup but keeps no non-`latest` tags. |
| 48 | - | - When `--keep` is greater than `0`, registry cleanup keeps `latest` (if present) plus the requested number of most-recent non-`latest` tags. | |||
| 49 | 57 | ||||
| 50 | ## Examples | 58 | ## Examples | ||
| 51 | 59 | ||||
| @@ -53,14 +61,20 @@ Behavior notes: | |||||
| 53 | # List images | 61 | # List images | ||
| 54 | gordon images list | 62 | gordon images list | ||
| 55 | 63 | ||||
| 64 | + | # Prune everything with defaults (dangling + registry, keep latest + 3) | |||
| 65 | + | gordon images prune | |||
| 66 | + | ||||
| 56 | # Prune dangling runtime images only | 67 | # Prune dangling runtime images only | ||
| 57 | - | gordon images prune --runtime-only | 68 | + | gordon images prune --dangling |
| 58 | 69 | ||||
| 59 | - | # Prune runtime images and keep latest + 3 previous tags per repository | 70 | + | # Prune registry tags only, keeping latest + 5 previous |
| 60 | - | gordon images prune --keep 3 | 71 | + | gordon images prune --registry --keep-releases 5 |
| 61 | 72 | ||||
| 62 | # Preview cleanup without applying | 73 | # Preview cleanup without applying | ||
| 63 | - | gordon images prune --dry-run --keep 5 | 74 | + | gordon images prune --dry-run |
| 75 | + | ||||
| 76 | + | # Skip confirmation prompt | |||
| 77 | + | gordon images prune --no-confirm | |||
| 64 | ``` | 78 | ``` | ||
| 65 | 79 | ||||
| 66 | ## Required Permissions | 80 | ## Required Permissions | ||
M docs/config/images.md +2 -0
| @@ -11,6 +11,8 @@ When enabled, Gordon runs a scheduled image prune job that: | |||
| 11 | 11 | - Preserves the `latest` tag. | |
| 12 | 12 | - Removes unreferenced blobs after tag cleanup. | |
| 13 | 13 | ||
| 14 | + | The CLI `gordon images prune` uses the same defaults as the scheduled job (keep `latest` + 3 previous tags, both scopes enabled: dangling runtime images and registry tag retention). | |
| 15 | + | ||
| 14 | 16 | ## Configuration | |
| 15 | 17 | ||
| 16 | 18 | ```toml | |
| @@ -11,6 +11,8 @@ When enabled, Gordon runs a scheduled image prune job that: | |||||
| 11 | - Preserves the `latest` tag. | 11 | - Preserves the `latest` tag. | ||
| 12 | - Removes unreferenced blobs after tag cleanup. | 12 | - Removes unreferenced blobs after tag cleanup. | ||
| 13 | 13 | ||||
| 14 | + | The CLI `gordon images prune` uses the same defaults as the scheduled job (keep `latest` + 3 previous tags, both scopes enabled: dangling runtime images and registry tag retention). | |||
| 15 | + | ||||
| 14 | ## Configuration | 16 | ## Configuration | ||
| 15 | 17 | ||||
| 16 | ```toml | 18 | ```toml | ||
M internal/adapters/dto/admin_images.go +3 -1
| @@ -19,7 +19,9 @@ type ImagesResponse struct { | |||
| 19 | 19 | ||
| 20 | 20 | // ImagePruneRequest triggers image pruning. | |
| 21 | 21 | type ImagePruneRequest struct { | |
| 22 | - | KeepLast *int `json:"keep_last,omitempty"` | |
| 22 | + | KeepLast *int `json:"keep_last,omitempty"` | |
| 23 | + | PruneDangling *bool `json:"prune_dangling,omitempty"` | |
| 24 | + | PruneRegistry *bool `json:"prune_registry,omitempty"` | |
| 23 | 25 | } | |
| 24 | 26 | ||
| 25 | 27 | // RuntimePruneResult represents runtime prune results. | |
| @@ -19,7 +19,9 @@ type ImagesResponse struct { | |||||
| 19 | 19 | ||||
| 20 | // ImagePruneRequest triggers image pruning. | 20 | // ImagePruneRequest triggers image pruning. | ||
| 21 | type ImagePruneRequest struct { | 21 | type ImagePruneRequest struct { | ||
| 22 | - | KeepLast *int `json:"keep_last,omitempty"` | 22 | + | KeepLast *int `json:"keep_last,omitempty"` |
| 23 | + | PruneDangling *bool `json:"prune_dangling,omitempty"` | |||
| 24 | + | PruneRegistry *bool `json:"prune_registry,omitempty"` | |||
| 23 | } | 25 | } | ||
| 24 | 26 | ||||
| 25 | // RuntimePruneResult represents runtime prune results. | 27 | // RuntimePruneResult represents runtime prune results. | ||
M internal/adapters/in/cli/backup.go +21 -3
| @@ -52,9 +52,14 @@ func newBackupListCmd() *cobra.Command { | |||
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | 54 | if len(jobs) == 0 { | |
| 55 | - | fmt.Println("No backups found") | |
| 55 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 56 | 58 | return nil | |
| 57 | 59 | } | |
| 60 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil { | |
| 61 | + | return err | |
| 62 | + | } | |
| 58 | 63 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 59 | 64 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | |
| 60 | 65 | return err | |
| @@ -96,6 +101,9 @@ func newBackupRunCmd() *cobra.Command { | |||
| 96 | 101 | if result.Backup == nil { | |
| 97 | 102 | return fmt.Errorf("backup run completed without backup payload") | |
| 98 | 103 | } | |
| 104 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Result")); err != nil { | |
| 105 | + | return err | |
| 106 | + | } | |
| 99 | 107 | ||
| 100 | 108 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 101 | 109 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil { | |
| @@ -133,9 +141,14 @@ func newBackupDetectCmd() *cobra.Command { | |||
| 133 | 141 | } | |
| 134 | 142 | ||
| 135 | 143 | if len(dbs) == 0 { | |
| 136 | - | fmt.Println("No supported databases detected") | |
| 144 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("No supported databases detected")); err != nil { | |
| 145 | + | return err | |
| 146 | + | } | |
| 137 | 147 | return nil | |
| 138 | 148 | } | |
| 149 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Detected Databases")); err != nil { | |
| 150 | + | return err | |
| 151 | + | } | |
| 139 | 152 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 140 | 153 | if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil { | |
| 141 | 154 | return err | |
| @@ -172,9 +185,14 @@ func newBackupStatusCmd() *cobra.Command { | |||
| 172 | 185 | } | |
| 173 | 186 | ||
| 174 | 187 | if len(jobs) == 0 { | |
| 175 | - | fmt.Println("No backup status available") | |
| 188 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("No backup status available")); err != nil { | |
| 189 | + | return err | |
| 190 | + | } | |
| 176 | 191 | return nil | |
| 177 | 192 | } | |
| 193 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Status")); err != nil { | |
| 194 | + | return err | |
| 195 | + | } | |
| 178 | 196 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | |
| 179 | 197 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil { | |
| 180 | 198 | return err | |
| @@ -52,9 +52,14 @@ func newBackupListCmd() *cobra.Command { | |||||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | if len(jobs) == 0 { | 54 | if len(jobs) == 0 { | ||
| 55 | - | fmt.Println("No backups found") | 55 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("No backups found")); err != nil { |
| 56 | + | return err | |||
| 57 | + | } | |||
| 56 | return nil | 58 | return nil | ||
| 57 | } | 59 | } | ||
| 60 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backups")); err != nil { | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 58 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | 63 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | ||
| 59 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | 64 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil { | ||
| 60 | return err | 65 | return err | ||
| @@ -96,6 +101,9 @@ func newBackupRunCmd() *cobra.Command { | |||||
| 96 | if result.Backup == nil { | 101 | if result.Backup == nil { | ||
| 97 | return fmt.Errorf("backup run completed without backup payload") | 102 | return fmt.Errorf("backup run completed without backup payload") | ||
| 98 | } | 103 | } | ||
| 104 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Result")); err != nil { | |||
| 105 | + | return err | |||
| 106 | + | } | |||
| 99 | 107 | ||||
| 100 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | 108 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | ||
| 101 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil { | 109 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil { | ||
| @@ -133,9 +141,14 @@ func newBackupDetectCmd() *cobra.Command { | |||||
| 133 | } | 141 | } | ||
| 134 | 142 | ||||
| 135 | if len(dbs) == 0 { | 143 | if len(dbs) == 0 { | ||
| 136 | - | fmt.Println("No supported databases detected") | 144 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("No supported databases detected")); err != nil { |
| 145 | + | return err | |||
| 146 | + | } | |||
| 137 | return nil | 147 | return nil | ||
| 138 | } | 148 | } | ||
| 149 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Detected Databases")); err != nil { | |||
| 150 | + | return err | |||
| 151 | + | } | |||
| 139 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | 152 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | ||
| 140 | if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil { | 153 | if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil { | ||
| 141 | return err | 154 | return err | ||
| @@ -172,9 +185,14 @@ func newBackupStatusCmd() *cobra.Command { | |||||
| 172 | } | 185 | } | ||
| 173 | 186 | ||||
| 174 | if len(jobs) == 0 { | 187 | if len(jobs) == 0 { | ||
| 175 | - | fmt.Println("No backup status available") | 188 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("No backup status available")); err != nil { |
| 189 | + | return err | |||
| 190 | + | } | |||
| 176 | return nil | 191 | return nil | ||
| 177 | } | 192 | } | ||
| 193 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Status")); err != nil { | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 178 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | 196 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) | ||
| 179 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil { | 197 | if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil { | ||
| 180 | return err | 198 | return err | ||
M internal/adapters/in/cli/images.go +142 -50
| @@ -5,23 +5,57 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "io" | |
| 7 | 7 | "strings" | |
| 8 | - | "text/tabwriter" | |
| 9 | 8 | "time" | |
| 10 | 9 | ||
| 10 | + | "github.com/charmbracelet/lipgloss" | |
| 11 | 11 | "github.com/spf13/cobra" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | type imagesClient interface { | |
| 17 | 19 | ListImages(ctx context.Context) ([]dto.Image, error) | |
| 18 | - | PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) | |
| 20 | + | PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) | |
| 19 | 21 | } | |
| 20 | 22 | ||
| 21 | 23 | type imagesPruneOptions struct { | |
| 22 | - | DryRun bool | |
| 23 | - | KeepLast int | |
| 24 | - | 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 | |
| 29 | + | } | |
| 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. | |
| 43 | + | const ( | |
| 44 | + | imagesListRepositoryColumnWidth = 44 | |
| 45 | + | imagesListTagColumnWidth = 14 | |
| 46 | + | imagesListSizeColumnWidth = 10 | |
| 47 | + | imagesListCreatedColumnWidth = 22 | |
| 48 | + | imagesListImageIDColumnWidth = 22 | |
| 49 | + | imagesListDanglingColumnWidth = 10 | |
| 50 | + | ) | |
| 51 | + | ||
| 52 | + | var imagesListTableColumns = []components.TableColumn{ | |
| 53 | + | {Title: "REPOSITORY", Width: imagesListRepositoryColumnWidth}, | |
| 54 | + | {Title: "TAG", Width: imagesListTagColumnWidth}, | |
| 55 | + | {Title: "SIZE", Width: imagesListSizeColumnWidth}, | |
| 56 | + | {Title: "CREATED", Width: imagesListCreatedColumnWidth}, | |
| 57 | + | {Title: "IMAGE_ID", Width: imagesListImageIDColumnWidth}, | |
| 58 | + | {Title: "DANGLING", Width: imagesListDanglingColumnWidth}, | |
| 25 | 59 | } | |
| 26 | 60 | ||
| 27 | 61 | func newImagesCmd() *cobra.Command { | |
| @@ -60,6 +94,10 @@ func newImagesPruneCmd() *cobra.Command { | |||
| 60 | 94 | cmd := &cobra.Command{ | |
| 61 | 95 | Use: "prune", | |
| 62 | 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.`, | |
| 63 | 101 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 64 | 102 | client, isRemote := GetRemoteClient() | |
| 65 | 103 | if !isRemote { | |
| @@ -71,12 +109,24 @@ func newImagesPruneCmd() *cobra.Command { | |||
| 71 | 109 | } | |
| 72 | 110 | ||
| 73 | 111 | cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes") | |
| 74 | - | 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)") | |
| 75 | - | 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, "Include dangling runtime images (default: both scopes)") | |
| 115 | + | cmd.Flags().BoolVar(&opts.Registry, "registry", false, "Include old registry tags (default: both scopes)") | |
| 116 | + | cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "Skip confirmation prompt") | |
| 76 | 117 | ||
| 77 | 118 | return cmd | |
| 78 | 119 | } | |
| 79 | 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 | + | ||
| 80 | 130 | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | |
| 81 | 131 | images, err := client.ListImages(ctx) | |
| 82 | 132 | if err != nil { | |
| @@ -84,19 +134,19 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||
| 84 | 134 | } | |
| 85 | 135 | ||
| 86 | 136 | if len(images) == 0 { | |
| 87 | - | if _, err := fmt.Fprintln(out, "No images found"); err != nil { | |
| 137 | + | if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil { | |
| 88 | 138 | return err | |
| 89 | 139 | } | |
| 90 | - | _, err := fmt.Fprintln(out, "Total images: 0 (dangling: 0)") | |
| 140 | + | err := cliWriteLine(out, cliRenderInfo("Total images: 0 (dangling: 0)")) | |
| 91 | 141 | return err | |
| 92 | 142 | } | |
| 93 | 143 | ||
| 94 | - | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |
| 95 | - | if _, err := fmt.Fprintln(w, "REPOSITORY\tTAG\tSIZE\tCREATED\tIMAGE_ID\tDANGLING"); err != nil { | |
| 144 | + | if err := cliWriteLine(out, cliRenderTitle("Images")); err != nil { | |
| 96 | 145 | return err | |
| 97 | 146 | } | |
| 98 | 147 | ||
| 99 | 148 | danglingCount := 0 | |
| 149 | + | rows := make([][]string, 0, len(images)) | |
| 100 | 150 | for _, img := range images { | |
| 101 | 151 | dangling := "no" | |
| 102 | 152 | if img.Dangling { | |
| @@ -104,30 +154,88 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||
| 104 | 154 | danglingCount++ | |
| 105 | 155 | } | |
| 106 | 156 | ||
| 107 | - | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), formatImageID(img.ID), dangling); err != nil { | |
| 108 | - | return err | |
| 109 | - | } | |
| 157 | + | rows = append(rows, []string{ | |
| 158 | + | img.Repository, | |
| 159 | + | img.Tag, | |
| 160 | + | formatImageSize(img.Size), | |
| 161 | + | formatImageCreatedAt(img.Created), | |
| 162 | + | formatImageID(img.ID), | |
| 163 | + | dangling, | |
| 164 | + | }) | |
| 110 | 165 | } | |
| 111 | 166 | ||
| 112 | - | if err := w.Flush(); err != nil { | |
| 167 | + | table := components.NewTable( | |
| 168 | + | components.WithColumns(imagesListTableColumns), | |
| 169 | + | components.WithRows(rows), | |
| 170 | + | components.WithHeaderStyle(lipgloss.NewStyle().Bold(true)), | |
| 171 | + | components.WithCellStyle(lipgloss.NewStyle()), | |
| 172 | + | ) | |
| 173 | + | ||
| 174 | + | if err := cliWriteLine(out, table.View()); err != nil { | |
| 113 | 175 | return err | |
| 114 | 176 | } | |
| 115 | 177 | ||
| 116 | - | _, err = fmt.Fprintf(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount) | |
| 178 | + | err = cliWritef(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount) | |
| 117 | 179 | return err | |
| 118 | 180 | } | |
| 119 | 181 | ||
| 120 | 182 | func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOptions, out io.Writer) error { | |
| 121 | - | if opts.KeepLast < 0 { | |
| 122 | - | return fmt.Errorf("--keep must be >= 0") | |
| 183 | + | if opts.KeepReleases < 0 { | |
| 184 | + | return fmt.Errorf("--keep-releases must be >= 0") | |
| 123 | 185 | } | |
| 124 | 186 | ||
| 125 | - | keepLast := opts.KeepLast | |
| 126 | - | if opts.RuntimeOnly { | |
| 127 | - | keepLast = 0 | |
| 128 | - | } | |
| 187 | + | pruneDangling, pruneRegistry := resolvePruneScopes(opts) | |
| 129 | 188 | ||
| 130 | 189 | if opts.DryRun { | |
| 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.") | |
| 196 | + | if err != nil { | |
| 197 | + | return fmt.Errorf("confirmation failed: %w", err) | |
| 198 | + | } | |
| 199 | + | if !confirmed { | |
| 200 | + | return cliWriteLine(out, cliRenderMuted("Prune cancelled")) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 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 { | |
| 216 | + | return err | |
| 217 | + | } | |
| 218 | + | } else { | |
| 219 | + | if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil { | |
| 220 | + | return err | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 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) | |
| 226 | + | return err | |
| 227 | + | } | |
| 228 | + | ||
| 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 | + | if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil { | |
| 235 | + | return err | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | if pruneDangling { | |
| 131 | 239 | images, err := client.ListImages(ctx) | |
| 132 | 240 | if err != nil { | |
| 133 | 241 | return fmt.Errorf("failed to list images: %w", err) | |
| @@ -140,44 +248,28 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||
| 140 | 248 | } | |
| 141 | 249 | } | |
| 142 | 250 | ||
| 143 | - | if _, err := fmt.Fprintln(out, "Dry run: no changes applied"); err != nil { | |
| 144 | - | return err | |
| 145 | - | } | |
| 146 | - | if _, err := fmt.Fprintf(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { | |
| 251 | + | if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { | |
| 147 | 252 | return err | |
| 148 | 253 | } | |
| 149 | - | if opts.RuntimeOnly { | |
| 150 | - | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)") | |
| 254 | + | } else { | |
| 255 | + | if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil { | |
| 151 | 256 | return err | |
| 152 | 257 | } | |
| 153 | - | if keepLast == 0 { | |
| 154 | - | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--keep=0)") | |
| 155 | - | return err | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | _, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast) | |
| 159 | - | return err | |
| 160 | 258 | } | |
| 161 | 259 | ||
| 162 | - | resp, err := client.PruneImages(ctx, keepLast) | |
| 163 | - | if err != nil { | |
| 164 | - | return fmt.Errorf("failed to prune images: %w", err) | |
| 165 | - | } | |
| 166 | - | if resp == nil { | |
| 167 | - | return fmt.Errorf("failed to prune images: empty response") | |
| 260 | + | if pruneRegistry { | |
| 261 | + | return cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", opts.KeepReleases) | |
| 168 | 262 | } | |
| 169 | 263 | ||
| 170 | - | if _, err := fmt.Fprintf(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { | |
| 171 | - | return err | |
| 172 | - | } | |
| 264 | + | return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")) | |
| 265 | + | } | |
| 173 | 266 | ||
| 174 | - | if opts.RuntimeOnly { | |
| 175 | - | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)") | |
| 176 | - | return err | |
| 267 | + | func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest { | |
| 268 | + | return dto.ImagePruneRequest{ | |
| 269 | + | KeepLast: &keepReleases, | |
| 270 | + | PruneDangling: &pruneDangling, | |
| 271 | + | PruneRegistry: &pruneRegistry, | |
| 177 | 272 | } | |
| 178 | - | ||
| 179 | - | _, err = fmt.Fprintf(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) | |
| 180 | - | return err | |
| 181 | 273 | } | |
| 182 | 274 | ||
| 183 | 275 | func formatImageCreatedAt(t time.Time) string { | |
| @@ -5,23 +5,57 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "io" | 6 | "io" | ||
| 7 | "strings" | 7 | "strings" | ||
| 8 | - | "text/tabwriter" | |||
| 9 | "time" | 8 | "time" | ||
| 10 | 9 | ||||
| 10 | + | "github.com/charmbracelet/lipgloss" | |||
| 11 | "github.com/spf13/cobra" | 11 | "github.com/spf13/cobra" | ||
| 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" | |||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | type imagesClient interface { | 18 | type imagesClient interface { | ||
| 17 | ListImages(ctx context.Context) ([]dto.Image, error) | 19 | ListImages(ctx context.Context) ([]dto.Image, error) | ||
| 18 | - | PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) | 20 | + | PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) |
| 19 | } | 21 | } | ||
| 20 | 22 | ||||
| 21 | type imagesPruneOptions struct { | 23 | type imagesPruneOptions struct { | ||
| 22 | - | DryRun bool | 24 | + | DryRun bool |
| 23 | - | KeepLast int | 25 | + | KeepReleases int |
| 24 | - | RuntimeOnly bool | 26 | + | Dangling bool // scope flag: prune dangling runtime images |
| 27 | + | Registry bool // scope flag: prune registry tags | |||
| 28 | + | NoConfirm bool | |||
| 29 | + | } | |||
| 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. | |||
| 43 | + | const ( | |||
| 44 | + | imagesListRepositoryColumnWidth = 44 | |||
| 45 | + | imagesListTagColumnWidth = 14 | |||
| 46 | + | imagesListSizeColumnWidth = 10 | |||
| 47 | + | imagesListCreatedColumnWidth = 22 | |||
| 48 | + | imagesListImageIDColumnWidth = 22 | |||
| 49 | + | imagesListDanglingColumnWidth = 10 | |||
| 50 | + | ) | |||
| 51 | + | ||||
| 52 | + | var imagesListTableColumns = []components.TableColumn{ | |||
| 53 | + | {Title: "REPOSITORY", Width: imagesListRepositoryColumnWidth}, | |||
| 54 | + | {Title: "TAG", Width: imagesListTagColumnWidth}, | |||
| 55 | + | {Title: "SIZE", Width: imagesListSizeColumnWidth}, | |||
| 56 | + | {Title: "CREATED", Width: imagesListCreatedColumnWidth}, | |||
| 57 | + | {Title: "IMAGE_ID", Width: imagesListImageIDColumnWidth}, | |||
| 58 | + | {Title: "DANGLING", Width: imagesListDanglingColumnWidth}, | |||
| 25 | } | 59 | } | ||
| 26 | 60 | ||||
| 27 | func newImagesCmd() *cobra.Command { | 61 | func newImagesCmd() *cobra.Command { | ||
| @@ -60,6 +94,10 @@ func newImagesPruneCmd() *cobra.Command { | |||||
| 60 | cmd := &cobra.Command{ | 94 | cmd := &cobra.Command{ | ||
| 61 | Use: "prune", | 95 | Use: "prune", | ||
| 62 | 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.`, | |||
| 63 | RunE: func(cmd *cobra.Command, _ []string) error { | 101 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| 64 | client, isRemote := GetRemoteClient() | 102 | client, isRemote := GetRemoteClient() | ||
| 65 | if !isRemote { | 103 | if !isRemote { | ||
| @@ -71,12 +109,24 @@ func newImagesPruneCmd() *cobra.Command { | |||||
| 71 | } | 109 | } | ||
| 72 | 110 | ||||
| 73 | 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") | ||
| 74 | - | 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, |
| 75 | - | 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, "Include dangling runtime images (default: both scopes)") | |||
| 115 | + | cmd.Flags().BoolVar(&opts.Registry, "registry", false, "Include old registry tags (default: both scopes)") | |||
| 116 | + | cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "Skip confirmation prompt") | |||
| 76 | 117 | ||||
| 77 | return cmd | 118 | return cmd | ||
| 78 | } | 119 | } | ||
| 79 | 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 | + | ||||
| 80 | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | 130 | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | ||
| 81 | images, err := client.ListImages(ctx) | 131 | images, err := client.ListImages(ctx) | ||
| 82 | if err != nil { | 132 | if err != nil { | ||
| @@ -84,19 +134,19 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||||
| 84 | } | 134 | } | ||
| 85 | 135 | ||||
| 86 | if len(images) == 0 { | 136 | if len(images) == 0 { | ||
| 87 | - | if _, err := fmt.Fprintln(out, "No images found"); err != nil { | 137 | + | if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil { |
| 88 | return err | 138 | return err | ||
| 89 | } | 139 | } | ||
| 90 | - | _, err := fmt.Fprintln(out, "Total images: 0 (dangling: 0)") | 140 | + | err := cliWriteLine(out, cliRenderInfo("Total images: 0 (dangling: 0)")) |
| 91 | return err | 141 | return err | ||
| 92 | } | 142 | } | ||
| 93 | 143 | ||||
| 94 | - | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | 144 | + | if err := cliWriteLine(out, cliRenderTitle("Images")); err != nil { |
| 95 | - | if _, err := fmt.Fprintln(w, "REPOSITORY\tTAG\tSIZE\tCREATED\tIMAGE_ID\tDANGLING"); err != nil { | |||
| 96 | return err | 145 | return err | ||
| 97 | } | 146 | } | ||
| 98 | 147 | ||||
| 99 | danglingCount := 0 | 148 | danglingCount := 0 | ||
| 149 | + | rows := make([][]string, 0, len(images)) | |||
| 100 | for _, img := range images { | 150 | for _, img := range images { | ||
| 101 | dangling := "no" | 151 | dangling := "no" | ||
| 102 | if img.Dangling { | 152 | if img.Dangling { | ||
| @@ -104,30 +154,88 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||||
| 104 | danglingCount++ | 154 | danglingCount++ | ||
| 105 | } | 155 | } | ||
| 106 | 156 | ||||
| 107 | - | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), formatImageID(img.ID), dangling); err != nil { | 157 | + | rows = append(rows, []string{ |
| 108 | - | return err | 158 | + | img.Repository, |
| 109 | - | } | 159 | + | img.Tag, |
| 160 | + | formatImageSize(img.Size), | |||
| 161 | + | formatImageCreatedAt(img.Created), | |||
| 162 | + | formatImageID(img.ID), | |||
| 163 | + | dangling, | |||
| 164 | + | }) | |||
| 110 | } | 165 | } | ||
| 111 | 166 | ||||
| 112 | - | if err := w.Flush(); err != nil { | 167 | + | table := components.NewTable( |
| 168 | + | components.WithColumns(imagesListTableColumns), | |||
| 169 | + | components.WithRows(rows), | |||
| 170 | + | components.WithHeaderStyle(lipgloss.NewStyle().Bold(true)), | |||
| 171 | + | components.WithCellStyle(lipgloss.NewStyle()), | |||
| 172 | + | ) | |||
| 173 | + | ||||
| 174 | + | if err := cliWriteLine(out, table.View()); err != nil { | |||
| 113 | return err | 175 | return err | ||
| 114 | } | 176 | } | ||
| 115 | 177 | ||||
| 116 | - | _, err = fmt.Fprintf(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount) | 178 | + | err = cliWritef(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount) |
| 117 | return err | 179 | return err | ||
| 118 | } | 180 | } | ||
| 119 | 181 | ||||
| 120 | 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 { | ||
| 121 | - | if opts.KeepLast < 0 { | 183 | + | if opts.KeepReleases < 0 { |
| 122 | - | return fmt.Errorf("--keep must be >= 0") | 184 | + | return fmt.Errorf("--keep-releases must be >= 0") |
| 123 | } | 185 | } | ||
| 124 | 186 | ||||
| 125 | - | keepLast := opts.KeepLast | 187 | + | pruneDangling, pruneRegistry := resolvePruneScopes(opts) |
| 126 | - | if opts.RuntimeOnly { | |||
| 127 | - | keepLast = 0 | |||
| 128 | - | } | |||
| 129 | 188 | ||||
| 130 | if opts.DryRun { | 189 | if opts.DryRun { | ||
| 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.") | |||
| 196 | + | if err != nil { | |||
| 197 | + | return fmt.Errorf("confirmation failed: %w", err) | |||
| 198 | + | } | |||
| 199 | + | if !confirmed { | |||
| 200 | + | return cliWriteLine(out, cliRenderMuted("Prune cancelled")) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 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 { | |||
| 216 | + | return err | |||
| 217 | + | } | |||
| 218 | + | } else { | |||
| 219 | + | if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil { | |||
| 220 | + | return err | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 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) | |||
| 226 | + | return err | |||
| 227 | + | } | |||
| 228 | + | ||||
| 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 | + | if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil { | |||
| 235 | + | return err | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | if pruneDangling { | |||
| 131 | images, err := client.ListImages(ctx) | 239 | images, err := client.ListImages(ctx) | ||
| 132 | if err != nil { | 240 | if err != nil { | ||
| 133 | return fmt.Errorf("failed to list images: %w", err) | 241 | return fmt.Errorf("failed to list images: %w", err) | ||
| @@ -140,44 +248,28 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||||
| 140 | } | 248 | } | ||
| 141 | } | 249 | } | ||
| 142 | 250 | ||||
| 143 | - | if _, err := fmt.Fprintln(out, "Dry run: no changes applied"); err != nil { | 251 | + | if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { |
| 144 | - | return err | |||
| 145 | - | } | |||
| 146 | - | if _, err := fmt.Fprintf(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { | |||
| 147 | return err | 252 | return err | ||
| 148 | } | 253 | } | ||
| 149 | - | if opts.RuntimeOnly { | 254 | + | } else { |
| 150 | - | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)") | 255 | + | if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil { |
| 151 | return err | 256 | return err | ||
| 152 | } | 257 | } | ||
| 153 | - | if keepLast == 0 { | |||
| 154 | - | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--keep=0)") | |||
| 155 | - | return err | |||
| 156 | - | } | |||
| 157 | - | ||||
| 158 | - | _, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast) | |||
| 159 | - | return err | |||
| 160 | } | 258 | } | ||
| 161 | 259 | ||||
| 162 | - | resp, err := client.PruneImages(ctx, keepLast) | 260 | + | if pruneRegistry { |
| 163 | - | if err != nil { | 261 | + | return cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", opts.KeepReleases) |
| 164 | - | return fmt.Errorf("failed to prune images: %w", err) | |||
| 165 | - | } | |||
| 166 | - | if resp == nil { | |||
| 167 | - | return fmt.Errorf("failed to prune images: empty response") | |||
| 168 | } | 262 | } | ||
| 169 | 263 | ||||
| 170 | - | if _, err := fmt.Fprintf(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { | 264 | + | return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)")) |
| 171 | - | return err | 265 | + | } |
| 172 | - | } | |||
| 173 | 266 | ||||
| 174 | - | if opts.RuntimeOnly { | 267 | + | func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest { |
| 175 | - | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)") | 268 | + | return dto.ImagePruneRequest{ |
| 176 | - | return err | 269 | + | KeepLast: &keepReleases, |
| 270 | + | PruneDangling: &pruneDangling, | |||
| 271 | + | PruneRegistry: &pruneRegistry, | |||
| 177 | } | 272 | } | ||
| 178 | - | ||||
| 179 | - | _, err = fmt.Fprintf(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) | |||
| 180 | - | return err | |||
| 181 | } | 273 | } | ||
| 182 | 274 | ||||
| 183 | func formatImageCreatedAt(t time.Time) string { | 275 | func formatImageCreatedAt(t time.Time) string { | ||
M internal/adapters/in/cli/images_test.go +439 -31
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "regexp" | |
| 8 | + | "strings" | |
| 7 | 9 | "testing" | |
| 8 | 10 | "time" | |
| 9 | 11 | ||
| @@ -11,6 +13,7 @@ import ( | |||
| 11 | 13 | "github.com/stretchr/testify/require" | |
| 12 | 14 | ||
| 13 | 15 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 16 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | 17 | ) | |
| 15 | 18 | ||
| 16 | 19 | type fakeImagesClient struct { | |
| @@ -21,7 +24,7 @@ type fakeImagesClient struct { | |||
| 21 | 24 | ||
| 22 | 25 | listImagesCalls int | |
| 23 | 26 | pruneCalls int | |
| 24 | - | lastPruneKeep int | |
| 27 | + | lastPruneOpts dto.ImagePruneRequest | |
| 25 | 28 | } | |
| 26 | 29 | ||
| 27 | 30 | func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) { | |
| @@ -32,15 +35,19 @@ func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) { | |||
| 32 | 35 | return f.listImagesResp, nil | |
| 33 | 36 | } | |
| 34 | 37 | ||
| 35 | - | func (f *fakeImagesClient) PruneImages(_ context.Context, keepLast int) (*dto.ImagePruneResponse, error) { | |
| 38 | + | func (f *fakeImagesClient) PruneImages(_ context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) { | |
| 36 | 39 | f.pruneCalls++ | |
| 37 | - | f.lastPruneKeep = keepLast | |
| 40 | + | f.lastPruneOpts = req | |
| 38 | 41 | if f.pruneErr != nil { | |
| 39 | 42 | return nil, f.pruneErr | |
| 40 | 43 | } | |
| 41 | 44 | return f.pruneResp, nil | |
| 42 | 45 | } | |
| 43 | 46 | ||
| 47 | + | // --------------------------------------------------------------------------- | |
| 48 | + | // images list tests (unchanged) | |
| 49 | + | // --------------------------------------------------------------------------- | |
| 50 | + | ||
| 44 | 51 | func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | |
| 45 | 52 | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 46 | 53 | client := &fakeImagesClient{ | |
| @@ -62,6 +69,110 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | |||
| 62 | 69 | assert.Equal(t, 1, client.listImagesCalls) | |
| 63 | 70 | } | |
| 64 | 71 | ||
| 72 | + | func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) { | |
| 73 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 74 | + | client := &fakeImagesClient{ | |
| 75 | + | listImagesResp: []dto.Image{ | |
| 76 | + | {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}, | |
| 77 | + | }, | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | var out bytes.Buffer | |
| 81 | + | err := runImagesList(context.Background(), client, &out) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | ||
| 84 | + | text := stripANSI(out.String()) | |
| 85 | + | header := findHeaderLine(text) | |
| 86 | + | assert.NotEmpty(t, header) | |
| 87 | + | assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG")) | |
| 88 | + | assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE")) | |
| 89 | + | assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED")) | |
| 90 | + | assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID")) | |
| 91 | + | assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING")) | |
| 92 | + | assert.Contains(t, text, "Total images: 1 (dangling: 0)") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) { | |
| 96 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 97 | + | longRepository := "registry.example.com/team/this-is-a-very-long-service-name-with-extra-segments/and/more/segments/than/anyone/should/reasonably/use" | |
| 98 | + | longImageID := "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra-suffix-for-overflow" | |
| 99 | + | client := &fakeImagesClient{ | |
| 100 | + | listImagesResp: []dto.Image{ | |
| 101 | + | {Repository: longRepository, Tag: "latest", Size: 12_000_000, Created: createdAt, ID: longImageID, Dangling: false}, | |
| 102 | + | }, | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | var out bytes.Buffer | |
| 106 | + | err := runImagesList(context.Background(), client, &out) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | ||
| 109 | + | text := stripANSI(out.String()) | |
| 110 | + | header := findHeaderLine(text) | |
| 111 | + | assert.NotEmpty(t, header) | |
| 112 | + | assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG")) | |
| 113 | + | assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE")) | |
| 114 | + | assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED")) | |
| 115 | + | assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID")) | |
| 116 | + | assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING")) | |
| 117 | + | ||
| 118 | + | row := findImageListRowLine(text, "latest") | |
| 119 | + | assert.NotEmpty(t, row) | |
| 120 | + | ||
| 121 | + | cols := splitTableColumns(row) | |
| 122 | + | require.Len(t, cols, 6) | |
| 123 | + | ||
| 124 | + | repositoryField := cols[0] | |
| 125 | + | imageIDField := cols[4] | |
| 126 | + | ||
| 127 | + | assert.NotEqual(t, longRepository, repositoryField) | |
| 128 | + | assert.NotEqual(t, longImageID, imageIDField) | |
| 129 | + | assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis") | |
| 130 | + | assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis") | |
| 131 | + | assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "..."))) | |
| 132 | + | assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "..."))) | |
| 133 | + | ||
| 134 | + | assert.Contains(t, text, "Total images: 1 (dangling: 0)") | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) { | |
| 138 | + | longRepository := "registry.example.com/very/long/repository/path/with/deep/nesting/and-digest-like-ref@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra" | |
| 139 | + | longTag := "release-2026-02-12-build-metadata-with-a-very-very-long-tag-name" | |
| 140 | + | longImageID := "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" | |
| 141 | + | client := &fakeImagesClient{ | |
| 142 | + | listImagesResp: []dto.Image{ | |
| 143 | + | {Repository: longRepository, Tag: longTag, Size: 0, Created: time.Time{}, ID: longImageID, Dangling: true}, | |
| 144 | + | }, | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | var out bytes.Buffer | |
| 148 | + | err := runImagesList(context.Background(), client, &out) | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | ||
| 151 | + | text := stripANSI(out.String()) | |
| 152 | + | rows := findImageListRows(text) | |
| 153 | + | require.Len(t, rows, 1, "expected a single, no-wrap data row") | |
| 154 | + | ||
| 155 | + | cols := rows[0] | |
| 156 | + | require.Len(t, cols, 6) | |
| 157 | + | ||
| 158 | + | repositoryField := cols[0] | |
| 159 | + | tagField := cols[1] | |
| 160 | + | sizeField := cols[2] | |
| 161 | + | createdField := cols[3] | |
| 162 | + | imageIDField := cols[4] | |
| 163 | + | ||
| 164 | + | assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis") | |
| 165 | + | assert.True(t, strings.HasSuffix(tagField, "..."), "tag should be truncated with ellipsis") | |
| 166 | + | assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis") | |
| 167 | + | assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "..."))) | |
| 168 | + | assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "..."))) | |
| 169 | + | assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "..."))) | |
| 170 | + | ||
| 171 | + | assert.Equal(t, "-", sizeField) | |
| 172 | + | assert.Equal(t, "-", createdField) | |
| 173 | + | assert.Contains(t, text, "Total images: 1 (dangling: 1)") | |
| 174 | + | } | |
| 175 | + | ||
| 65 | 176 | func TestRunImagesList_EmptyOutput(t *testing.T) { | |
| 66 | 177 | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | |
| 67 | 178 | ||
| @@ -75,13 +186,123 @@ func TestRunImagesList_EmptyOutput(t *testing.T) { | |||
| 75 | 186 | assert.Equal(t, 1, client.listImagesCalls) | |
| 76 | 187 | } | |
| 77 | 188 | ||
| 78 | - | 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) { | |
| 79 | 299 | client := &fakeImagesClient{ | |
| 80 | 300 | listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}}, | |
| 81 | 301 | } | |
| 82 | 302 | ||
| 83 | 303 | var out bytes.Buffer | |
| 84 | - | 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) | |
| 85 | 306 | require.NoError(t, err) | |
| 86 | 307 | ||
| 87 | 308 | text := out.String() | |
| @@ -92,67 +313,254 @@ func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) { | |||
| 92 | 313 | assert.Equal(t, 0, client.pruneCalls) | |
| 93 | 314 | } | |
| 94 | 315 | ||
| 95 | - | func TestRunImagesPrune_DryRunKeepZeroSkipsRegistry(t *testing.T) { | |
| 96 | - | client := &fakeImagesClient{listImagesResp: []dto.Image{{Dangling: true}}} | |
| 316 | + | func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) { | |
| 317 | + | client := &fakeImagesClient{ | |
| 318 | + | listImagesResp: []dto.Image{{Dangling: true}}, | |
| 319 | + | } | |
| 97 | 320 | ||
| 98 | 321 | var out bytes.Buffer | |
| 99 | - | 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) | |
| 100 | 324 | require.NoError(t, err) | |
| 101 | 325 | ||
| 102 | 326 | text := out.String() | |
| 103 | - | assert.Contains(t, text, "Registry cleanup skipped (--keep=0)") | |
| 104 | - | 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") | |
| 105 | 329 | assert.Equal(t, 0, client.pruneCalls) | |
| 106 | 330 | } | |
| 107 | 331 | ||
| 108 | - | func TestRunImagesPrune_RuntimeOnlyForcesKeepZero(t *testing.T) { | |
| 109 | - | 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 | + | } | |
| 110 | 359 | ||
| 111 | 360 | var out bytes.Buffer | |
| 112 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 9, RuntimeOnly: true}, &out) | |
| 361 | + | opts := imagesPruneOptions{KeepReleases: 10, NoConfirm: true} | |
| 362 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 113 | 363 | require.NoError(t, err) | |
| 114 | 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 | + | } | |
| 394 | + | ||
| 395 | + | var out bytes.Buffer | |
| 396 | + | opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast} | |
| 397 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |
| 398 | + | require.NoError(t, err) | |
| 399 | + | ||
| 400 | + | assert.True(t, confirmCalled, "confirmation should have been called") | |
| 115 | 401 | assert.Equal(t, 1, client.pruneCalls) | |
| 116 | - | assert.Equal(t, 0, client.lastPruneKeep) | |
| 117 | - | assert.Contains(t, out.String(), "Registry cleanup skipped") | |
| 118 | 402 | } | |
| 119 | 403 | ||
| 120 | - | 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 | + | ||
| 121 | 431 | client := &fakeImagesClient{ | |
| 122 | 432 | pruneResp: &dto.ImagePruneResponse{ | |
| 123 | - | Runtime: dto.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, | |
| 124 | - | Registry: dto.RegistryPruneResult{TagsRemoved: 3, BlobsRemoved: 1, SpaceReclaimed: 8192}, | |
| 433 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 1}, | |
| 125 | 434 | }, | |
| 126 | 435 | } | |
| 127 | 436 | ||
| 128 | 437 | var out bytes.Buffer | |
| 129 | - | 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) | |
| 130 | 440 | require.NoError(t, err) | |
| 131 | 441 | ||
| 132 | - | text := out.String() | |
| 442 | + | assert.False(t, confirmCalled, "confirmation should not be called with --no-confirm") | |
| 133 | 443 | assert.Equal(t, 1, client.pruneCalls) | |
| 134 | - | assert.Equal(t, 5, client.lastPruneKeep) | |
| 135 | - | assert.Contains(t, text, "Runtime: deleted=2") | |
| 136 | - | assert.Contains(t, text, "Registry: tags_removed=3") | |
| 137 | 444 | } | |
| 138 | 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 | + | ||
| 139 | 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 | + | ||
| 140 | 476 | client := &fakeImagesClient{pruneErr: errors.New("request failed")} | |
| 141 | 477 | ||
| 142 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 2}, &bytes.Buffer{}) | |
| 478 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 2}, &bytes.Buffer{}) | |
| 143 | 479 | require.Error(t, err) | |
| 144 | 480 | assert.Contains(t, err.Error(), "failed to prune images") | |
| 145 | 481 | } | |
| 146 | 482 | ||
| 147 | - | func TestRunImagesPrune_RejectsNegativeKeep(t *testing.T) { | |
| 148 | - | err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepLast: -1}, &bytes.Buffer{}) | |
| 149 | - | require.Error(t, err) | |
| 150 | - | assert.Contains(t, err.Error(), "--keep must be >= 0") | |
| 151 | - | } | |
| 152 | - | ||
| 153 | 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 | + | ||
| 154 | 488 | client := &fakeImagesClient{pruneResp: nil} | |
| 155 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 1}, &bytes.Buffer{}) | |
| 489 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 1}, &bytes.Buffer{}) | |
| 156 | 490 | require.Error(t, err) | |
| 157 | 491 | assert.Contains(t, err.Error(), "empty response") | |
| 158 | 492 | } | |
| 493 | + | ||
| 494 | + | // --------------------------------------------------------------------------- | |
| 495 | + | // test helpers | |
| 496 | + | // --------------------------------------------------------------------------- | |
| 497 | + | ||
| 498 | + | func stripANSI(value string) string { | |
| 499 | + | ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) | |
| 500 | + | return ansiRegex.ReplaceAllString(value, "") | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | func findHeaderLine(text string) string { | |
| 504 | + | for _, line := range strings.Split(text, "\n") { | |
| 505 | + | if strings.Contains(line, "REPOSITORY") && strings.Contains(line, "DANGLING") { | |
| 506 | + | return line | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | return "" | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | func findImageListRowLine(text string, tag string) string { | |
| 514 | + | for _, line := range strings.Split(text, "\n") { | |
| 515 | + | trimmed := strings.TrimSpace(line) | |
| 516 | + | if trimmed == "" { | |
| 517 | + | continue | |
| 518 | + | } | |
| 519 | + | if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") { | |
| 520 | + | continue | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | cols := splitTableColumns(line) | |
| 524 | + | if len(cols) == 6 && cols[1] == tag { | |
| 525 | + | return line | |
| 526 | + | } | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | return "" | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | func findImageListRows(text string) [][]string { | |
| 533 | + | rows := make([][]string, 0) | |
| 534 | + | for _, line := range strings.Split(text, "\n") { | |
| 535 | + | trimmed := strings.TrimSpace(line) | |
| 536 | + | if trimmed == "" { | |
| 537 | + | continue | |
| 538 | + | } | |
| 539 | + | if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") { | |
| 540 | + | continue | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | cols := splitTableColumns(line) | |
| 544 | + | if len(cols) == 6 && cols[0] != "REPOSITORY" { | |
| 545 | + | rows = append(rows, cols) | |
| 546 | + | } | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | return rows | |
| 550 | + | } | |
| 551 | + | ||
| 552 | + | func splitTableColumns(line string) []string { | |
| 553 | + | if strings.Contains(line, "│") { | |
| 554 | + | parts := strings.Split(line, "│") | |
| 555 | + | cols := make([]string, 0, len(parts)) | |
| 556 | + | for _, part := range parts { | |
| 557 | + | cell := strings.TrimSpace(part) | |
| 558 | + | if cell != "" { | |
| 559 | + | cols = append(cols, cell) | |
| 560 | + | } | |
| 561 | + | } | |
| 562 | + | return cols | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | return regexp.MustCompile(`\s{2,}`).Split(strings.TrimSpace(line), -1) | |
| 566 | + | } | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "regexp" | |||
| 8 | + | "strings" | |||
| 7 | "testing" | 9 | "testing" | ||
| 8 | "time" | 10 | "time" | ||
| 9 | 11 | ||||
| @@ -11,6 +13,7 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 12 | 14 | ||||
| 13 | "github.com/bnema/gordon/internal/adapters/dto" | 15 | "github.com/bnema/gordon/internal/adapters/dto" | ||
| 16 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | ) | 17 | ) | ||
| 15 | 18 | ||||
| 16 | type fakeImagesClient struct { | 19 | type fakeImagesClient struct { | ||
| @@ -21,7 +24,7 @@ type fakeImagesClient struct { | |||||
| 21 | 24 | ||||
| 22 | listImagesCalls int | 25 | listImagesCalls int | ||
| 23 | pruneCalls int | 26 | pruneCalls int | ||
| 24 | - | lastPruneKeep int | 27 | + | lastPruneOpts dto.ImagePruneRequest |
| 25 | } | 28 | } | ||
| 26 | 29 | ||||
| 27 | func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) { | 30 | func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) { | ||
| @@ -32,15 +35,19 @@ func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) { | |||||
| 32 | return f.listImagesResp, nil | 35 | return f.listImagesResp, nil | ||
| 33 | } | 36 | } | ||
| 34 | 37 | ||||
| 35 | - | func (f *fakeImagesClient) PruneImages(_ context.Context, keepLast int) (*dto.ImagePruneResponse, error) { | 38 | + | func (f *fakeImagesClient) PruneImages(_ context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) { |
| 36 | f.pruneCalls++ | 39 | f.pruneCalls++ | ||
| 37 | - | f.lastPruneKeep = keepLast | 40 | + | f.lastPruneOpts = req |
| 38 | if f.pruneErr != nil { | 41 | if f.pruneErr != nil { | ||
| 39 | return nil, f.pruneErr | 42 | return nil, f.pruneErr | ||
| 40 | } | 43 | } | ||
| 41 | return f.pruneResp, nil | 44 | return f.pruneResp, nil | ||
| 42 | } | 45 | } | ||
| 43 | 46 | ||||
| 47 | + | // --------------------------------------------------------------------------- | |||
| 48 | + | // images list tests (unchanged) | |||
| 49 | + | // --------------------------------------------------------------------------- | |||
| 50 | + | ||||
| 44 | func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | 51 | func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | ||
| 45 | 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) | ||
| 46 | client := &fakeImagesClient{ | 53 | client := &fakeImagesClient{ | ||
| @@ -62,6 +69,110 @@ func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | |||||
| 62 | assert.Equal(t, 1, client.listImagesCalls) | 69 | assert.Equal(t, 1, client.listImagesCalls) | ||
| 63 | } | 70 | } | ||
| 64 | 71 | ||||
| 72 | + | func TestRunImagesList_HeaderOrderAndSummaryLine(t *testing.T) { | |||
| 73 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 74 | + | client := &fakeImagesClient{ | |||
| 75 | + | listImagesResp: []dto.Image{ | |||
| 76 | + | {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}, | |||
| 77 | + | }, | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | var out bytes.Buffer | |||
| 81 | + | err := runImagesList(context.Background(), client, &out) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | ||||
| 84 | + | text := stripANSI(out.String()) | |||
| 85 | + | header := findHeaderLine(text) | |||
| 86 | + | assert.NotEmpty(t, header) | |||
| 87 | + | assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG")) | |||
| 88 | + | assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE")) | |||
| 89 | + | assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED")) | |||
| 90 | + | assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID")) | |||
| 91 | + | assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING")) | |||
| 92 | + | assert.Contains(t, text, "Total images: 1 (dangling: 0)") | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestRunImagesList_TruncatesLongValuesAndKeepsBoundedRowShape(t *testing.T) { | |||
| 96 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 97 | + | longRepository := "registry.example.com/team/this-is-a-very-long-service-name-with-extra-segments/and/more/segments/than/anyone/should/reasonably/use" | |||
| 98 | + | longImageID := "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra-suffix-for-overflow" | |||
| 99 | + | client := &fakeImagesClient{ | |||
| 100 | + | listImagesResp: []dto.Image{ | |||
| 101 | + | {Repository: longRepository, Tag: "latest", Size: 12_000_000, Created: createdAt, ID: longImageID, Dangling: false}, | |||
| 102 | + | }, | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | var out bytes.Buffer | |||
| 106 | + | err := runImagesList(context.Background(), client, &out) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | ||||
| 109 | + | text := stripANSI(out.String()) | |||
| 110 | + | header := findHeaderLine(text) | |||
| 111 | + | assert.NotEmpty(t, header) | |||
| 112 | + | assert.Less(t, strings.Index(header, "REPOSITORY"), strings.Index(header, "TAG")) | |||
| 113 | + | assert.Less(t, strings.Index(header, "TAG"), strings.Index(header, "SIZE")) | |||
| 114 | + | assert.Less(t, strings.Index(header, "SIZE"), strings.Index(header, "CREATED")) | |||
| 115 | + | assert.Less(t, strings.Index(header, "CREATED"), strings.Index(header, "IMAGE_ID")) | |||
| 116 | + | assert.Less(t, strings.Index(header, "IMAGE_ID"), strings.Index(header, "DANGLING")) | |||
| 117 | + | ||||
| 118 | + | row := findImageListRowLine(text, "latest") | |||
| 119 | + | assert.NotEmpty(t, row) | |||
| 120 | + | ||||
| 121 | + | cols := splitTableColumns(row) | |||
| 122 | + | require.Len(t, cols, 6) | |||
| 123 | + | ||||
| 124 | + | repositoryField := cols[0] | |||
| 125 | + | imageIDField := cols[4] | |||
| 126 | + | ||||
| 127 | + | assert.NotEqual(t, longRepository, repositoryField) | |||
| 128 | + | assert.NotEqual(t, longImageID, imageIDField) | |||
| 129 | + | assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis") | |||
| 130 | + | assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis") | |||
| 131 | + | assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "..."))) | |||
| 132 | + | assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "..."))) | |||
| 133 | + | ||||
| 134 | + | assert.Contains(t, text, "Total images: 1 (dangling: 0)") | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func TestRunImagesList_LongDigestLikeValuesAndEmptyFormattedFields(t *testing.T) { | |||
| 138 | + | longRepository := "registry.example.com/very/long/repository/path/with/deep/nesting/and-digest-like-ref@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef-extra" | |||
| 139 | + | longTag := "release-2026-02-12-build-metadata-with-a-very-very-long-tag-name" | |||
| 140 | + | longImageID := "sha256:abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" | |||
| 141 | + | client := &fakeImagesClient{ | |||
| 142 | + | listImagesResp: []dto.Image{ | |||
| 143 | + | {Repository: longRepository, Tag: longTag, Size: 0, Created: time.Time{}, ID: longImageID, Dangling: true}, | |||
| 144 | + | }, | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | var out bytes.Buffer | |||
| 148 | + | err := runImagesList(context.Background(), client, &out) | |||
| 149 | + | require.NoError(t, err) | |||
| 150 | + | ||||
| 151 | + | text := stripANSI(out.String()) | |||
| 152 | + | rows := findImageListRows(text) | |||
| 153 | + | require.Len(t, rows, 1, "expected a single, no-wrap data row") | |||
| 154 | + | ||||
| 155 | + | cols := rows[0] | |||
| 156 | + | require.Len(t, cols, 6) | |||
| 157 | + | ||||
| 158 | + | repositoryField := cols[0] | |||
| 159 | + | tagField := cols[1] | |||
| 160 | + | sizeField := cols[2] | |||
| 161 | + | createdField := cols[3] | |||
| 162 | + | imageIDField := cols[4] | |||
| 163 | + | ||||
| 164 | + | assert.True(t, strings.HasSuffix(repositoryField, "..."), "repository should be truncated with ellipsis") | |||
| 165 | + | assert.True(t, strings.HasSuffix(tagField, "..."), "tag should be truncated with ellipsis") | |||
| 166 | + | assert.True(t, strings.HasSuffix(imageIDField, "..."), "image id should be truncated with ellipsis") | |||
| 167 | + | assert.True(t, strings.HasPrefix(longRepository, strings.TrimSuffix(repositoryField, "..."))) | |||
| 168 | + | assert.True(t, strings.HasPrefix(longTag, strings.TrimSuffix(tagField, "..."))) | |||
| 169 | + | assert.True(t, strings.HasPrefix(longImageID, strings.TrimSuffix(imageIDField, "..."))) | |||
| 170 | + | ||||
| 171 | + | assert.Equal(t, "-", sizeField) | |||
| 172 | + | assert.Equal(t, "-", createdField) | |||
| 173 | + | assert.Contains(t, text, "Total images: 1 (dangling: 1)") | |||
| 174 | + | } | |||
| 175 | + | ||||
| 65 | func TestRunImagesList_EmptyOutput(t *testing.T) { | 176 | func TestRunImagesList_EmptyOutput(t *testing.T) { | ||
| 66 | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | 177 | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | ||
| 67 | 178 | ||||
| @@ -75,13 +186,123 @@ func TestRunImagesList_EmptyOutput(t *testing.T) { | |||||
| 75 | assert.Equal(t, 1, client.listImagesCalls) | 186 | assert.Equal(t, 1, client.listImagesCalls) | ||
| 76 | } | 187 | } | ||
| 77 | 188 | ||||
| 78 | - | 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) { | |||
| 79 | client := &fakeImagesClient{ | 299 | client := &fakeImagesClient{ | ||
| 80 | listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}}, | 300 | listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}}, | ||
| 81 | } | 301 | } | ||
| 82 | 302 | ||||
| 83 | var out bytes.Buffer | 303 | var out bytes.Buffer | ||
| 84 | - | 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) | |||
| 85 | require.NoError(t, err) | 306 | require.NoError(t, err) | ||
| 86 | 307 | ||||
| 87 | text := out.String() | 308 | text := out.String() | ||
| @@ -92,67 +313,254 @@ func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) { | |||||
| 92 | assert.Equal(t, 0, client.pruneCalls) | 313 | assert.Equal(t, 0, client.pruneCalls) | ||
| 93 | } | 314 | } | ||
| 94 | 315 | ||||
| 95 | - | func TestRunImagesPrune_DryRunKeepZeroSkipsRegistry(t *testing.T) { | 316 | + | func TestRunImagesPrune_DryRunDanglingOnlyScope(t *testing.T) { |
| 96 | - | client := &fakeImagesClient{listImagesResp: []dto.Image{{Dangling: true}}} | 317 | + | client := &fakeImagesClient{ |
| 318 | + | listImagesResp: []dto.Image{{Dangling: true}}, | |||
| 319 | + | } | |||
| 97 | 320 | ||||
| 98 | var out bytes.Buffer | 321 | var out bytes.Buffer | ||
| 99 | - | 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) | |||
| 100 | require.NoError(t, err) | 324 | require.NoError(t, err) | ||
| 101 | 325 | ||||
| 102 | text := out.String() | 326 | text := out.String() | ||
| 103 | - | assert.Contains(t, text, "Registry cleanup skipped (--keep=0)") | 327 | + | assert.Contains(t, text, "would prune 1 dangling runtime images") |
| 104 | - | assert.Equal(t, 1, client.listImagesCalls) | 328 | + | assert.Contains(t, text, "Registry cleanup skipped") |
| 105 | assert.Equal(t, 0, client.pruneCalls) | 329 | assert.Equal(t, 0, client.pruneCalls) | ||
| 106 | } | 330 | } | ||
| 107 | 331 | ||||
| 108 | - | func TestRunImagesPrune_RuntimeOnlyForcesKeepZero(t *testing.T) { | 332 | + | func TestRunImagesPrune_DryRunRegistryOnlyScope(t *testing.T) { |
| 109 | - | 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 | + | } | |||
| 110 | 359 | ||||
| 111 | var out bytes.Buffer | 360 | var out bytes.Buffer | ||
| 112 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 9, RuntimeOnly: true}, &out) | 361 | + | opts := imagesPruneOptions{KeepReleases: 10, NoConfirm: true} |
| 362 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 113 | require.NoError(t, err) | 363 | require.NoError(t, err) | ||
| 114 | 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 | + | } | |||
| 394 | + | ||||
| 395 | + | var out bytes.Buffer | |||
| 396 | + | opts := imagesPruneOptions{KeepReleases: domain.DefaultImagePruneKeepLast} | |||
| 397 | + | err := runImagesPrune(context.Background(), client, opts, &out) | |||
| 398 | + | require.NoError(t, err) | |||
| 399 | + | ||||
| 400 | + | assert.True(t, confirmCalled, "confirmation should have been called") | |||
| 115 | assert.Equal(t, 1, client.pruneCalls) | 401 | assert.Equal(t, 1, client.pruneCalls) | ||
| 116 | - | assert.Equal(t, 0, client.lastPruneKeep) | |||
| 117 | - | assert.Contains(t, out.String(), "Registry cleanup skipped") | |||
| 118 | } | 402 | } | ||
| 119 | 403 | ||||
| 120 | - | 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 | + | ||||
| 121 | client := &fakeImagesClient{ | 431 | client := &fakeImagesClient{ | ||
| 122 | pruneResp: &dto.ImagePruneResponse{ | 432 | pruneResp: &dto.ImagePruneResponse{ | ||
| 123 | - | Runtime: dto.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, | 433 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 1}, |
| 124 | - | Registry: dto.RegistryPruneResult{TagsRemoved: 3, BlobsRemoved: 1, SpaceReclaimed: 8192}, | |||
| 125 | }, | 434 | }, | ||
| 126 | } | 435 | } | ||
| 127 | 436 | ||||
| 128 | var out bytes.Buffer | 437 | var out bytes.Buffer | ||
| 129 | - | 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) | |||
| 130 | require.NoError(t, err) | 440 | require.NoError(t, err) | ||
| 131 | 441 | ||||
| 132 | - | text := out.String() | 442 | + | assert.False(t, confirmCalled, "confirmation should not be called with --no-confirm") |
| 133 | assert.Equal(t, 1, client.pruneCalls) | 443 | assert.Equal(t, 1, client.pruneCalls) | ||
| 134 | - | assert.Equal(t, 5, client.lastPruneKeep) | |||
| 135 | - | assert.Contains(t, text, "Runtime: deleted=2") | |||
| 136 | - | assert.Contains(t, text, "Registry: tags_removed=3") | |||
| 137 | } | 444 | } | ||
| 138 | 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 | + | ||||
| 139 | 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 | + | ||||
| 140 | client := &fakeImagesClient{pruneErr: errors.New("request failed")} | 476 | client := &fakeImagesClient{pruneErr: errors.New("request failed")} | ||
| 141 | 477 | ||||
| 142 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 2}, &bytes.Buffer{}) | 478 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 2}, &bytes.Buffer{}) |
| 143 | require.Error(t, err) | 479 | require.Error(t, err) | ||
| 144 | assert.Contains(t, err.Error(), "failed to prune images") | 480 | assert.Contains(t, err.Error(), "failed to prune images") | ||
| 145 | } | 481 | } | ||
| 146 | 482 | ||||
| 147 | - | func TestRunImagesPrune_RejectsNegativeKeep(t *testing.T) { | |||
| 148 | - | err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepLast: -1}, &bytes.Buffer{}) | |||
| 149 | - | require.Error(t, err) | |||
| 150 | - | assert.Contains(t, err.Error(), "--keep must be >= 0") | |||
| 151 | - | } | |||
| 152 | - | ||||
| 153 | 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 | + | ||||
| 154 | client := &fakeImagesClient{pruneResp: nil} | 488 | client := &fakeImagesClient{pruneResp: nil} | ||
| 155 | - | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 1}, &bytes.Buffer{}) | 489 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepReleases: 1}, &bytes.Buffer{}) |
| 156 | require.Error(t, err) | 490 | require.Error(t, err) | ||
| 157 | assert.Contains(t, err.Error(), "empty response") | 491 | assert.Contains(t, err.Error(), "empty response") | ||
| 158 | } | 492 | } | ||
| 493 | + | ||||
| 494 | + | // --------------------------------------------------------------------------- | |||
| 495 | + | // test helpers | |||
| 496 | + | // --------------------------------------------------------------------------- | |||
| 497 | + | ||||
| 498 | + | func stripANSI(value string) string { | |||
| 499 | + | ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) | |||
| 500 | + | return ansiRegex.ReplaceAllString(value, "") | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | func findHeaderLine(text string) string { | |||
| 504 | + | for _, line := range strings.Split(text, "\n") { | |||
| 505 | + | if strings.Contains(line, "REPOSITORY") && strings.Contains(line, "DANGLING") { | |||
| 506 | + | return line | |||
| 507 | + | } | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | return "" | |||
| 511 | + | } | |||
| 512 | + | ||||
| 513 | + | func findImageListRowLine(text string, tag string) string { | |||
| 514 | + | for _, line := range strings.Split(text, "\n") { | |||
| 515 | + | trimmed := strings.TrimSpace(line) | |||
| 516 | + | if trimmed == "" { | |||
| 517 | + | continue | |||
| 518 | + | } | |||
| 519 | + | if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") { | |||
| 520 | + | continue | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | cols := splitTableColumns(line) | |||
| 524 | + | if len(cols) == 6 && cols[1] == tag { | |||
| 525 | + | return line | |||
| 526 | + | } | |||
| 527 | + | } | |||
| 528 | + | ||||
| 529 | + | return "" | |||
| 530 | + | } | |||
| 531 | + | ||||
| 532 | + | func findImageListRows(text string) [][]string { | |||
| 533 | + | rows := make([][]string, 0) | |||
| 534 | + | for _, line := range strings.Split(text, "\n") { | |||
| 535 | + | trimmed := strings.TrimSpace(line) | |||
| 536 | + | if trimmed == "" { | |||
| 537 | + | continue | |||
| 538 | + | } | |||
| 539 | + | if strings.HasPrefix(trimmed, "Images") || strings.HasPrefix(trimmed, "Total images:") { | |||
| 540 | + | continue | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | cols := splitTableColumns(line) | |||
| 544 | + | if len(cols) == 6 && cols[0] != "REPOSITORY" { | |||
| 545 | + | rows = append(rows, cols) | |||
| 546 | + | } | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | return rows | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | func splitTableColumns(line string) []string { | |||
| 553 | + | if strings.Contains(line, "│") { | |||
| 554 | + | parts := strings.Split(line, "│") | |||
| 555 | + | cols := make([]string, 0, len(parts)) | |||
| 556 | + | for _, part := range parts { | |||
| 557 | + | cell := strings.TrimSpace(part) | |||
| 558 | + | if cell != "" { | |||
| 559 | + | cols = append(cols, cell) | |||
| 560 | + | } | |||
| 561 | + | } | |||
| 562 | + | return cols | |||
| 563 | + | } | |||
| 564 | + | ||||
| 565 | + | return regexp.MustCompile(`\s{2,}`).Split(strings.TrimSpace(line), -1) | |||
| 566 | + | } | |||
M internal/adapters/in/cli/parity_matrix_test.go +65 -0
| @@ -1,11 +1,43 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "go/ast" | |
| 5 | + | "go/parser" | |
| 6 | + | "go/token" | |
| 4 | 7 | "os" | |
| 5 | 8 | "strings" | |
| 6 | 9 | "testing" | |
| 7 | 10 | ) | |
| 8 | 11 | ||
| 12 | + | type uiAdoptionExpectation struct { | |
| 13 | + | family string | |
| 14 | + | file string | |
| 15 | + | functions []string | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | var uiAdoptionExpectations = []uiAdoptionExpectation{ | |
| 19 | + | { | |
| 20 | + | family: "server", | |
| 21 | + | file: "serve.go", | |
| 22 | + | functions: []string{"newStartCmd"}, | |
| 23 | + | }, | |
| 24 | + | { | |
| 25 | + | family: "root/server", | |
| 26 | + | file: "root.go", | |
| 27 | + | functions: []string{"newVersionCmd", "runReloadRemote", "runLogsRemote", "streamLogsRemote", "showContainerLogsLocal"}, | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | family: "backups", | |
| 31 | + | file: "backup.go", | |
| 32 | + | functions: []string{"newBackupListCmd", "newBackupRunCmd", "newBackupDetectCmd", "newBackupStatusCmd"}, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | family: "images", | |
| 36 | + | file: "images.go", | |
| 37 | + | functions: []string{"runImagesList", "runImagesPrune"}, | |
| 38 | + | }, | |
| 39 | + | } | |
| 40 | + | ||
| 9 | 41 | func TestLocalParityMatrix(t *testing.T) { | |
| 10 | 42 | checks := []struct { | |
| 11 | 43 | file string | |
| @@ -29,3 +61,36 @@ func TestLocalParityMatrix(t *testing.T) { | |||
| 29 | 61 | } | |
| 30 | 62 | } | |
| 31 | 63 | } | |
| 64 | + | ||
| 65 | + | func TestUIAdoptionMatrixCoverage(t *testing.T) { | |
| 66 | + | for _, expect := range uiAdoptionExpectations { | |
| 67 | + | expect := expect | |
| 68 | + | t.Run(expect.family, func(t *testing.T) { | |
| 69 | + | if len(expect.functions) == 0 { | |
| 70 | + | t.Fatalf("ui adoption matrix for %s is empty", expect.family) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | fset := token.NewFileSet() | |
| 74 | + | fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors) | |
| 75 | + | if err != nil { | |
| 76 | + | t.Fatalf("failed to parse %s: %v", expect.file, err) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | found := make(map[string]bool, len(expect.functions)) | |
| 80 | + | ast.Inspect(fileNode, func(n ast.Node) bool { | |
| 81 | + | fn, ok := n.(*ast.FuncDecl) | |
| 82 | + | if !ok { | |
| 83 | + | return true | |
| 84 | + | } | |
| 85 | + | found[fn.Name.Name] = true | |
| 86 | + | return true | |
| 87 | + | }) | |
| 88 | + | ||
| 89 | + | for _, fn := range expect.functions { | |
| 90 | + | if !found[fn] { | |
| 91 | + | t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | } | |
| @@ -1,11 +1,43 @@ | |||||
| 1 | package cli | 1 | package cli | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "go/ast" | |||
| 5 | + | "go/parser" | |||
| 6 | + | "go/token" | |||
| 4 | "os" | 7 | "os" | ||
| 5 | "strings" | 8 | "strings" | ||
| 6 | "testing" | 9 | "testing" | ||
| 7 | ) | 10 | ) | ||
| 8 | 11 | ||||
| 12 | + | type uiAdoptionExpectation struct { | |||
| 13 | + | family string | |||
| 14 | + | file string | |||
| 15 | + | functions []string | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | var uiAdoptionExpectations = []uiAdoptionExpectation{ | |||
| 19 | + | { | |||
| 20 | + | family: "server", | |||
| 21 | + | file: "serve.go", | |||
| 22 | + | functions: []string{"newStartCmd"}, | |||
| 23 | + | }, | |||
| 24 | + | { | |||
| 25 | + | family: "root/server", | |||
| 26 | + | file: "root.go", | |||
| 27 | + | functions: []string{"newVersionCmd", "runReloadRemote", "runLogsRemote", "streamLogsRemote", "showContainerLogsLocal"}, | |||
| 28 | + | }, | |||
| 29 | + | { | |||
| 30 | + | family: "backups", | |||
| 31 | + | file: "backup.go", | |||
| 32 | + | functions: []string{"newBackupListCmd", "newBackupRunCmd", "newBackupDetectCmd", "newBackupStatusCmd"}, | |||
| 33 | + | }, | |||
| 34 | + | { | |||
| 35 | + | family: "images", | |||
| 36 | + | file: "images.go", | |||
| 37 | + | functions: []string{"runImagesList", "runImagesPrune"}, | |||
| 38 | + | }, | |||
| 39 | + | } | |||
| 40 | + | ||||
| 9 | func TestLocalParityMatrix(t *testing.T) { | 41 | func TestLocalParityMatrix(t *testing.T) { | ||
| 10 | checks := []struct { | 42 | checks := []struct { | ||
| 11 | file string | 43 | file string | ||
| @@ -29,3 +61,36 @@ func TestLocalParityMatrix(t *testing.T) { | |||||
| 29 | } | 61 | } | ||
| 30 | } | 62 | } | ||
| 31 | } | 63 | } | ||
| 64 | + | ||||
| 65 | + | func TestUIAdoptionMatrixCoverage(t *testing.T) { | |||
| 66 | + | for _, expect := range uiAdoptionExpectations { | |||
| 67 | + | expect := expect | |||
| 68 | + | t.Run(expect.family, func(t *testing.T) { | |||
| 69 | + | if len(expect.functions) == 0 { | |||
| 70 | + | t.Fatalf("ui adoption matrix for %s is empty", expect.family) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | fset := token.NewFileSet() | |||
| 74 | + | fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors) | |||
| 75 | + | if err != nil { | |||
| 76 | + | t.Fatalf("failed to parse %s: %v", expect.file, err) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | found := make(map[string]bool, len(expect.functions)) | |||
| 80 | + | ast.Inspect(fileNode, func(n ast.Node) bool { | |||
| 81 | + | fn, ok := n.(*ast.FuncDecl) | |||
| 82 | + | if !ok { | |||
| 83 | + | return true | |||
| 84 | + | } | |||
| 85 | + | found[fn.Name.Name] = true | |||
| 86 | + | return true | |||
| 87 | + | }) | |||
| 88 | + | ||||
| 89 | + | for _, fn := range expect.functions { | |||
| 90 | + | if !found[fn] { | |||
| 91 | + | t.Fatalf("ui adoption matrix references missing function %s in %s", fn, expect.file) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 96 | + | } | |||
A internal/adapters/in/cli/presentation.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var cliWriteLine = func(w io.Writer, msg string) error { | |
| 11 | + | _, err := fmt.Fprintln(w, msg) | |
| 12 | + | return err | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | var cliWritef = func(w io.Writer, format string, args ...any) error { | |
| 16 | + | _, err := fmt.Fprintf(w, format, args...) | |
| 17 | + | return err | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func cliRenderTitle(msg string) string { | |
| 21 | + | return styles.Theme.Title.Render(msg) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func cliRenderMuted(msg string) string { | |
| 25 | + | return styles.Theme.Muted.Render(msg) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func cliRenderEmptyState(msg string) string { | |
| 29 | + | return cliRenderMuted(msg) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func cliRenderListItem(msg string) string { | |
| 33 | + | const selected = false // CLI list items are never in a selected/highlighted state | |
| 34 | + | return styles.RenderListItem(msg, selected) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func cliRenderMeta(label, value string) string { | |
| 38 | + | return styles.Theme.Bold.Render(label) + " " + styles.Theme.Muted.Render(value) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func cliRenderSuccess(msg string) string { | |
| 42 | + | return styles.RenderSuccess(msg) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func cliRenderWarning(msg string) string { | |
| 46 | + | return styles.RenderWarning(msg) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func cliRenderInfo(msg string) string { | |
| 50 | + | return styles.RenderInfo(msg) | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "io" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | var cliWriteLine = func(w io.Writer, msg string) error { | |||
| 11 | + | _, err := fmt.Fprintln(w, msg) | |||
| 12 | + | return err | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | var cliWritef = func(w io.Writer, format string, args ...any) error { | |||
| 16 | + | _, err := fmt.Fprintf(w, format, args...) | |||
| 17 | + | return err | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func cliRenderTitle(msg string) string { | |||
| 21 | + | return styles.Theme.Title.Render(msg) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func cliRenderMuted(msg string) string { | |||
| 25 | + | return styles.Theme.Muted.Render(msg) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func cliRenderEmptyState(msg string) string { | |||
| 29 | + | return cliRenderMuted(msg) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func cliRenderListItem(msg string) string { | |||
| 33 | + | const selected = false // CLI list items are never in a selected/highlighted state | |||
| 34 | + | return styles.RenderListItem(msg, selected) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func cliRenderMeta(label, value string) string { | |||
| 38 | + | return styles.Theme.Bold.Render(label) + " " + styles.Theme.Muted.Render(value) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func cliRenderSuccess(msg string) string { | |||
| 42 | + | return styles.RenderSuccess(msg) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func cliRenderWarning(msg string) string { | |||
| 46 | + | return styles.RenderWarning(msg) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func cliRenderInfo(msg string) string { | |||
| 50 | + | return styles.RenderInfo(msg) | |||
| 51 | + | } | |||
M internal/adapters/in/cli/remote/client.go +4 -4
| @@ -501,12 +501,12 @@ func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) { | |||
| 501 | 501 | } | |
| 502 | 502 | ||
| 503 | 503 | // PruneImages prunes runtime and registry images. | |
| 504 | - | func (c *Client) PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) { | |
| 505 | - | if keepLast < 0 { | |
| 506 | - | return nil, fmt.Errorf("keepLast must be >= 0") | |
| 504 | + | func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) { | |
| 505 | + | if req.KeepLast != nil && *req.KeepLast < 0 { | |
| 506 | + | return nil, fmt.Errorf("keep_last must be >= 0") | |
| 507 | 507 | } | |
| 508 | 508 | ||
| 509 | - | resp, err := c.request(ctx, http.MethodPost, "/images/prune", dto.ImagePruneRequest{KeepLast: &keepLast}) | |
| 509 | + | resp, err := c.request(ctx, http.MethodPost, "/images/prune", req) | |
| 510 | 510 | if err != nil { | |
| 511 | 511 | return nil, err | |
| 512 | 512 | } | |
| @@ -501,12 +501,12 @@ func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) { | |||||
| 501 | } | 501 | } | ||
| 502 | 502 | ||||
| 503 | // PruneImages prunes runtime and registry images. | 503 | // PruneImages prunes runtime and registry images. | ||
| 504 | - | func (c *Client) PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) { | 504 | + | func (c *Client) PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) { |
| 505 | - | if keepLast < 0 { | 505 | + | if req.KeepLast != nil && *req.KeepLast < 0 { |
| 506 | - | return nil, fmt.Errorf("keepLast must be >= 0") | 506 | + | return nil, fmt.Errorf("keep_last must be >= 0") |
| 507 | } | 507 | } | ||
| 508 | 508 | ||||
| 509 | - | resp, err := c.request(ctx, http.MethodPost, "/images/prune", dto.ImagePruneRequest{KeepLast: &keepLast}) | 509 | + | resp, err := c.request(ctx, http.MethodPost, "/images/prune", req) |
| 510 | if err != nil { | 510 | if err != nil { | ||
| 511 | return nil, err | 511 | return nil, err | ||
| 512 | } | 512 | } | ||
M internal/adapters/in/cli/remote/client_test.go +12 -1
| @@ -138,14 +138,25 @@ func TestClientPruneImages(t *testing.T) { | |||
| 138 | 138 | require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) | |
| 139 | 139 | require.NotNil(t, req.KeepLast) | |
| 140 | 140 | assert.Equal(t, 4, *req.KeepLast) | |
| 141 | + | require.NotNil(t, req.PruneDangling) | |
| 142 | + | assert.True(t, *req.PruneDangling) | |
| 143 | + | require.NotNil(t, req.PruneRegistry) | |
| 144 | + | assert.True(t, *req.PruneRegistry) | |
| 141 | 145 | ||
| 142 | 146 | w.Header().Set("Content-Type", "application/json") | |
| 143 | 147 | _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`)) | |
| 144 | 148 | })) | |
| 145 | 149 | defer srv.Close() | |
| 146 | 150 | ||
| 151 | + | keepLast := 4 | |
| 152 | + | pruneDangling := true | |
| 153 | + | pruneRegistry := true | |
| 147 | 154 | client := NewClient(srv.URL) | |
| 148 | - | resp, err := client.PruneImages(context.Background(), 4) | |
| 155 | + | resp, err := client.PruneImages(context.Background(), dto.ImagePruneRequest{ | |
| 156 | + | KeepLast: &keepLast, | |
| 157 | + | PruneDangling: &pruneDangling, | |
| 158 | + | PruneRegistry: &pruneRegistry, | |
| 159 | + | }) | |
| 149 | 160 | require.NoError(t, err) | |
| 150 | 161 | require.NotNil(t, resp) | |
| 151 | 162 | assert.Equal(t, 2, resp.Runtime.DeletedCount) | |
| @@ -138,14 +138,25 @@ func TestClientPruneImages(t *testing.T) { | |||||
| 138 | require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) | 138 | require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) | ||
| 139 | require.NotNil(t, req.KeepLast) | 139 | require.NotNil(t, req.KeepLast) | ||
| 140 | assert.Equal(t, 4, *req.KeepLast) | 140 | assert.Equal(t, 4, *req.KeepLast) | ||
| 141 | + | require.NotNil(t, req.PruneDangling) | |||
| 142 | + | assert.True(t, *req.PruneDangling) | |||
| 143 | + | require.NotNil(t, req.PruneRegistry) | |||
| 144 | + | assert.True(t, *req.PruneRegistry) | |||
| 141 | 145 | ||||
| 142 | w.Header().Set("Content-Type", "application/json") | 146 | w.Header().Set("Content-Type", "application/json") | ||
| 143 | _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`)) | 147 | _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`)) | ||
| 144 | })) | 148 | })) | ||
| 145 | defer srv.Close() | 149 | defer srv.Close() | ||
| 146 | 150 | ||||
| 151 | + | keepLast := 4 | |||
| 152 | + | pruneDangling := true | |||
| 153 | + | pruneRegistry := true | |||
| 147 | client := NewClient(srv.URL) | 154 | client := NewClient(srv.URL) | ||
| 148 | - | resp, err := client.PruneImages(context.Background(), 4) | 155 | + | resp, err := client.PruneImages(context.Background(), dto.ImagePruneRequest{ |
| 156 | + | KeepLast: &keepLast, | |||
| 157 | + | PruneDangling: &pruneDangling, | |||
| 158 | + | PruneRegistry: &pruneRegistry, | |||
| 159 | + | }) | |||
| 149 | require.NoError(t, err) | 160 | require.NoError(t, err) | ||
| 150 | require.NotNil(t, resp) | 161 | require.NotNil(t, resp) | ||
| 151 | assert.Equal(t, 2, resp.Runtime.DeletedCount) | 162 | assert.Equal(t, 2, resp.Runtime.DeletedCount) | ||
M internal/adapters/in/cli/root.go +48 -15
| @@ -195,10 +195,18 @@ func newVersionCmd() *cobra.Command { | |||
| 195 | 195 | return &cobra.Command{ | |
| 196 | 196 | Use: "version", | |
| 197 | 197 | Short: "Print version information", | |
| 198 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 199 | - | cmd.Printf("Gordon %s\n", Version) | |
| 200 | - | cmd.Printf("Commit: %s\n", Commit) | |
| 201 | - | cmd.Printf("Build Date: %s\n", BuildDate) | |
| 198 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 199 | + | out := cmd.OutOrStdout() | |
| 200 | + | if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil { | |
| 201 | + | return err | |
| 202 | + | } | |
| 203 | + | if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil { | |
| 204 | + | return err | |
| 205 | + | } | |
| 206 | + | if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil { | |
| 207 | + | return err | |
| 208 | + | } | |
| 209 | + | return nil | |
| 202 | 210 | }, | |
| 203 | 211 | } | |
| 204 | 212 | } | |
| @@ -214,15 +222,21 @@ func runReloadRemote(ctx context.Context, client *remote.Client) error { | |||
| 214 | 222 | if shouldFallbackToLocal(err) { | |
| 215 | 223 | localErr := runReload() | |
| 216 | 224 | if localErr == nil { | |
| 217 | - | fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err) | |
| 218 | - | fmt.Println("Configuration reloaded successfully") | |
| 225 | + | if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil { | |
| 226 | + | return writeErr | |
| 227 | + | } | |
| 228 | + | if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil { | |
| 229 | + | return writeErr | |
| 230 | + | } | |
| 219 | 231 | return nil | |
| 220 | 232 | } | |
| 221 | 233 | return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr) | |
| 222 | 234 | } | |
| 223 | 235 | return fmt.Errorf("failed to reload: %w", err) | |
| 224 | 236 | } | |
| 225 | - | fmt.Println("Configuration reloaded successfully") | |
| 237 | + | if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil { | |
| 238 | + | return err | |
| 239 | + | } | |
| 226 | 240 | return nil | |
| 227 | 241 | } | |
| 228 | 242 | ||
| @@ -291,7 +305,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i | |||
| 291 | 305 | return fmt.Errorf("failed to get process logs: %w", err) | |
| 292 | 306 | } | |
| 293 | 307 | for _, line := range logLines { | |
| 294 | - | fmt.Println(line) | |
| 308 | + | if err := cliWriteLine(os.Stdout, line); err != nil { | |
| 309 | + | return err | |
| 310 | + | } | |
| 295 | 311 | } | |
| 296 | 312 | } else { | |
| 297 | 313 | // Container logs | |
| @@ -300,7 +316,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i | |||
| 300 | 316 | return fmt.Errorf("failed to get container logs: %w", err) | |
| 301 | 317 | } | |
| 302 | 318 | for _, line := range logLines { | |
| 303 | - | fmt.Println(line) | |
| 319 | + | if err := cliWriteLine(os.Stdout, line); err != nil { | |
| 320 | + | return err | |
| 321 | + | } | |
| 304 | 322 | } | |
| 305 | 323 | } | |
| 306 | 324 | return nil | |
| @@ -321,7 +339,9 @@ func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain stri | |||
| 321 | 339 | } | |
| 322 | 340 | ||
| 323 | 341 | for line := range ch { | |
| 324 | - | fmt.Println(line) | |
| 342 | + | if err := cliWriteLine(os.Stdout, line); err != nil { | |
| 343 | + | return err | |
| 344 | + | } | |
| 325 | 345 | } | |
| 326 | 346 | return nil | |
| 327 | 347 | } | |
| @@ -342,13 +362,26 @@ func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines | |||
| 342 | 362 | // For local container logs, we need Docker access which requires | |
| 343 | 363 | // the runtime to be initialized. For now, suggest using remote mode | |
| 344 | 364 | // or direct docker logs command. | |
| 345 | - | fmt.Printf("Container logs for %s\n", logDomain) | |
| 346 | - | fmt.Println("To view container logs locally, use:") | |
| 347 | - | fmt.Printf(" docker logs --tail %d %s\n", lines, logDomain) | |
| 365 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil { | |
| 366 | + | return err | |
| 367 | + | } | |
| 368 | + | if err := cliWriteLine(os.Stdout, cliRenderInfo("To view container logs locally, use:")); err != nil { | |
| 369 | + | return err | |
| 370 | + | } | |
| 371 | + | if err := cliWritef(os.Stdout, " docker logs --tail %d %s\n", lines, logDomain); err != nil { | |
| 372 | + | return err | |
| 373 | + | } | |
| 348 | 374 | if follow { | |
| 349 | - | fmt.Printf(" docker logs -f --tail %d %s\n", lines, logDomain) | |
| 375 | + | if err := cliWritef(os.Stdout, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil { | |
| 376 | + | return err | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | if err := cliWriteLine(os.Stdout, ""); err != nil { | |
| 380 | + | return err | |
| 381 | + | } | |
| 382 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil { | |
| 383 | + | return err | |
| 350 | 384 | } | |
| 351 | - | fmt.Println("\nOr use remote mode to access logs via the admin API.") | |
| 352 | 385 | return nil | |
| 353 | 386 | } | |
| 354 | 387 | ||
| @@ -195,10 +195,18 @@ func newVersionCmd() *cobra.Command { | |||||
| 195 | return &cobra.Command{ | 195 | return &cobra.Command{ | ||
| 196 | Use: "version", | 196 | Use: "version", | ||
| 197 | Short: "Print version information", | 197 | Short: "Print version information", | ||
| 198 | - | Run: func(cmd *cobra.Command, args []string) { | 198 | + | RunE: func(cmd *cobra.Command, args []string) error { |
| 199 | - | cmd.Printf("Gordon %s\n", Version) | 199 | + | out := cmd.OutOrStdout() |
| 200 | - | cmd.Printf("Commit: %s\n", Commit) | 200 | + | if err := cliWriteLine(out, cliRenderTitle("Gordon "+Version)); err != nil { |
| 201 | - | cmd.Printf("Build Date: %s\n", BuildDate) | 201 | + | return err |
| 202 | + | } | |||
| 203 | + | if err := cliWriteLine(out, cliRenderMeta("Commit:", Commit)); err != nil { | |||
| 204 | + | return err | |||
| 205 | + | } | |||
| 206 | + | if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil { | |||
| 207 | + | return err | |||
| 208 | + | } | |||
| 209 | + | return nil | |||
| 202 | }, | 210 | }, | ||
| 203 | } | 211 | } | ||
| 204 | } | 212 | } | ||
| @@ -214,15 +222,21 @@ func runReloadRemote(ctx context.Context, client *remote.Client) error { | |||||
| 214 | if shouldFallbackToLocal(err) { | 222 | if shouldFallbackToLocal(err) { | ||
| 215 | localErr := runReload() | 223 | localErr := runReload() | ||
| 216 | if localErr == nil { | 224 | if localErr == nil { | ||
| 217 | - | fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err) | 225 | + | if writeErr := cliWriteLine(os.Stdout, cliRenderWarning(fmt.Sprintf("Remote reload failed (%v), used local signal fallback", err))); writeErr != nil { |
| 218 | - | fmt.Println("Configuration reloaded successfully") | 226 | + | return writeErr |
| 227 | + | } | |||
| 228 | + | if writeErr := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); writeErr != nil { | |||
| 229 | + | return writeErr | |||
| 230 | + | } | |||
| 219 | return nil | 231 | return nil | ||
| 220 | } | 232 | } | ||
| 221 | return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr) | 233 | return fmt.Errorf("remote reload failed: %w; local fallback failed: %v", err, localErr) | ||
| 222 | } | 234 | } | ||
| 223 | return fmt.Errorf("failed to reload: %w", err) | 235 | return fmt.Errorf("failed to reload: %w", err) | ||
| 224 | } | 236 | } | ||
| 225 | - | fmt.Println("Configuration reloaded successfully") | 237 | + | if err := cliWriteLine(os.Stdout, cliRenderSuccess("Configuration reloaded successfully")); err != nil { |
| 238 | + | return err | |||
| 239 | + | } | |||
| 226 | return nil | 240 | return nil | ||
| 227 | } | 241 | } | ||
| 228 | 242 | ||||
| @@ -291,7 +305,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i | |||||
| 291 | return fmt.Errorf("failed to get process logs: %w", err) | 305 | return fmt.Errorf("failed to get process logs: %w", err) | ||
| 292 | } | 306 | } | ||
| 293 | for _, line := range logLines { | 307 | for _, line := range logLines { | ||
| 294 | - | fmt.Println(line) | 308 | + | if err := cliWriteLine(os.Stdout, line); err != nil { |
| 309 | + | return err | |||
| 310 | + | } | |||
| 295 | } | 311 | } | ||
| 296 | } else { | 312 | } else { | ||
| 297 | // Container logs | 313 | // Container logs | ||
| @@ -300,7 +316,9 @@ func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines i | |||||
| 300 | return fmt.Errorf("failed to get container logs: %w", err) | 316 | return fmt.Errorf("failed to get container logs: %w", err) | ||
| 301 | } | 317 | } | ||
| 302 | for _, line := range logLines { | 318 | for _, line := range logLines { | ||
| 303 | - | fmt.Println(line) | 319 | + | if err := cliWriteLine(os.Stdout, line); err != nil { |
| 320 | + | return err | |||
| 321 | + | } | |||
| 304 | } | 322 | } | ||
| 305 | } | 323 | } | ||
| 306 | return nil | 324 | return nil | ||
| @@ -321,7 +339,9 @@ func streamLogsRemote(ctx context.Context, client *remote.Client, logDomain stri | |||||
| 321 | } | 339 | } | ||
| 322 | 340 | ||||
| 323 | for line := range ch { | 341 | for line := range ch { | ||
| 324 | - | fmt.Println(line) | 342 | + | if err := cliWriteLine(os.Stdout, line); err != nil { |
| 343 | + | return err | |||
| 344 | + | } | |||
| 325 | } | 345 | } | ||
| 326 | return nil | 346 | return nil | ||
| 327 | } | 347 | } | ||
| @@ -342,13 +362,26 @@ func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines | |||||
| 342 | // For local container logs, we need Docker access which requires | 362 | // For local container logs, we need Docker access which requires | ||
| 343 | // the runtime to be initialized. For now, suggest using remote mode | 363 | // the runtime to be initialized. For now, suggest using remote mode | ||
| 344 | // or direct docker logs command. | 364 | // or direct docker logs command. | ||
| 345 | - | fmt.Printf("Container logs for %s\n", logDomain) | 365 | + | if err := cliWriteLine(os.Stdout, cliRenderTitle(fmt.Sprintf("Container logs for %s", logDomain))); err != nil { |
| 346 | - | fmt.Println("To view container logs locally, use:") | 366 | + | return err |
| 347 | - | fmt.Printf(" docker logs --tail %d %s\n", lines, logDomain) | 367 | + | } |
| 368 | + | if err := cliWriteLine(os.Stdout, cliRenderInfo("To view container logs locally, use:")); err != nil { | |||
| 369 | + | return err | |||
| 370 | + | } | |||
| 371 | + | if err := cliWritef(os.Stdout, " docker logs --tail %d %s\n", lines, logDomain); err != nil { | |||
| 372 | + | return err | |||
| 373 | + | } | |||
| 348 | if follow { | 374 | if follow { | ||
| 349 | - | fmt.Printf(" docker logs -f --tail %d %s\n", lines, logDomain) | 375 | + | if err := cliWritef(os.Stdout, " docker logs -f --tail %d %s\n", lines, logDomain); err != nil { |
| 376 | + | return err | |||
| 377 | + | } | |||
| 378 | + | } | |||
| 379 | + | if err := cliWriteLine(os.Stdout, ""); err != nil { | |||
| 380 | + | return err | |||
| 381 | + | } | |||
| 382 | + | if err := cliWriteLine(os.Stdout, cliRenderMuted("Or use remote mode to access logs via the admin API.")); err != nil { | |||
| 383 | + | return err | |||
| 350 | } | 384 | } | ||
| 351 | - | fmt.Println("\nOr use remote mode to access logs via the admin API.") | |||
| 352 | return nil | 385 | return nil | ||
| 353 | } | 386 | } | ||
| 354 | 387 | ||||
M internal/adapters/in/cli/serve.go +3 -2
| @@ -2,7 +2,6 @@ package cli | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "fmt" | |
| 6 | 5 | "os" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/gordon/internal/app" | |
| @@ -40,7 +39,9 @@ func newStartCmd() *cobra.Command { | |||
| 40 | 39 | Deprecated: "use 'gordon serve' instead", | |
| 41 | 40 | Hidden: true, // Hide from help but still works | |
| 42 | 41 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 43 | - | fmt.Fprintln(os.Stderr, "\nWarning: 'gordon start' is deprecated, use 'gordon serve' instead") | |
| 42 | + | if err := cliWriteLine(os.Stderr, cliRenderWarning("Warning: 'gordon start' is deprecated, use 'gordon serve' instead")); err != nil { | |
| 43 | + | return err | |
| 44 | + | } | |
| 44 | 45 | return app.Run(context.Background(), configPath) | |
| 45 | 46 | }, | |
| 46 | 47 | } | |
| @@ -2,7 +2,6 @@ package cli | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "fmt" | |||
| 6 | "os" | 5 | "os" | ||
| 7 | 6 | ||||
| 8 | "github.com/bnema/gordon/internal/app" | 7 | "github.com/bnema/gordon/internal/app" | ||
| @@ -40,7 +39,9 @@ func newStartCmd() *cobra.Command { | |||||
| 40 | Deprecated: "use 'gordon serve' instead", | 39 | Deprecated: "use 'gordon serve' instead", | ||
| 41 | Hidden: true, // Hide from help but still works | 40 | Hidden: true, // Hide from help but still works | ||
| 42 | RunE: func(cmd *cobra.Command, args []string) error { | 41 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 43 | - | fmt.Fprintln(os.Stderr, "\nWarning: 'gordon start' is deprecated, use 'gordon serve' instead") | 42 | + | if err := cliWriteLine(os.Stderr, cliRenderWarning("Warning: 'gordon start' is deprecated, use 'gordon serve' instead")); err != nil { |
| 43 | + | return err | |||
| 44 | + | } | |||
| 44 | return app.Run(context.Background(), configPath) | 45 | return app.Run(context.Background(), configPath) | ||
| 45 | }, | 46 | }, | ||
| 46 | } | 47 | } | ||
M internal/adapters/in/cli/ui/components/table.go +67 -5
| @@ -1,10 +1,14 @@ | |||
| 1 | 1 | package components | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/charmbracelet/lipgloss" | |
| 7 | 9 | "github.com/charmbracelet/lipgloss/table" | |
| 10 | + | "github.com/mattn/go-runewidth" | |
| 11 | + | "github.com/rivo/uniseg" | |
| 8 | 12 | ) | |
| 9 | 13 | ||
| 10 | 14 | // TableColumn defines a table column. | |
| @@ -137,7 +141,19 @@ func (t *TableModel) Render() string { | |||
| 137 | 141 | // Extract headers | |
| 138 | 142 | headers := make([]string, len(t.columns)) | |
| 139 | 143 | for i, col := range t.columns { | |
| 140 | - | headers[i] = col.Title | |
| 144 | + | headers[i] = truncateCell(col.Title, col.Width) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | rows := make([][]string, len(t.rows)) | |
| 148 | + | for rowIdx, row := range t.rows { | |
| 149 | + | rows[rowIdx] = make([]string, len(row)) | |
| 150 | + | for colIdx, cell := range row { | |
| 151 | + | width := 0 | |
| 152 | + | if colIdx < len(t.columns) { | |
| 153 | + | width = t.columns[colIdx].Width | |
| 154 | + | } | |
| 155 | + | rows[rowIdx][colIdx] = truncateCell(cell, width) | |
| 156 | + | } | |
| 141 | 157 | } | |
| 142 | 158 | ||
| 143 | 159 | // Create table | |
| @@ -145,15 +161,27 @@ func (t *TableModel) Render() string { | |||
| 145 | 161 | Border(t.border). | |
| 146 | 162 | BorderStyle(t.borderStyle). | |
| 147 | 163 | Headers(headers...). | |
| 148 | - | Rows(t.rows...). | |
| 164 | + | Rows(rows...). | |
| 149 | 165 | StyleFunc(func(row, col int) lipgloss.Style { | |
| 166 | + | width := 0 | |
| 167 | + | if col >= 0 && col < len(t.columns) { | |
| 168 | + | width = t.columns[col].Width | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | applyWidth := func(s lipgloss.Style) lipgloss.Style { | |
| 172 | + | if width > 0 { | |
| 173 | + | return s.Width(width).MaxWidth(width) | |
| 174 | + | } | |
| 175 | + | return s | |
| 176 | + | } | |
| 177 | + | ||
| 150 | 178 | if row == table.HeaderRow { | |
| 151 | - | return t.headerStyle | |
| 179 | + | return applyWidth(t.headerStyle) | |
| 152 | 180 | } | |
| 153 | 181 | if row%2 == 0 { | |
| 154 | - | return t.evenStyle | |
| 182 | + | return applyWidth(t.evenStyle) | |
| 155 | 183 | } | |
| 156 | - | return t.oddStyle | |
| 184 | + | return applyWidth(t.oddStyle) | |
| 157 | 185 | }) | |
| 158 | 186 | ||
| 159 | 187 | // Apply width if set | |
| @@ -164,6 +192,40 @@ func (t *TableModel) Render() string { | |||
| 164 | 192 | return tbl.String() | |
| 165 | 193 | } | |
| 166 | 194 | ||
| 195 | + | func truncateCell(value string, maxWidth int) string { | |
| 196 | + | if strings.Contains(value, "\x1b[") { | |
| 197 | + | return value | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | if maxWidth <= 0 || runewidth.StringWidth(value) <= maxWidth { | |
| 201 | + | return value | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | if maxWidth <= 3 { | |
| 205 | + | return strings.Repeat(".", maxWidth) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | targetWidth := maxWidth - 3 | |
| 209 | + | b := strings.Builder{} | |
| 210 | + | currentWidth := 0 | |
| 211 | + | g := uniseg.NewGraphemes(value) | |
| 212 | + | for g.Next() { | |
| 213 | + | grapheme := g.Str() | |
| 214 | + | graphemeWidth := runewidth.StringWidth(grapheme) | |
| 215 | + | if currentWidth+graphemeWidth > targetWidth { | |
| 216 | + | break | |
| 217 | + | } | |
| 218 | + | b.WriteString(grapheme) | |
| 219 | + | currentWidth += graphemeWidth | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | if b.Len() == 0 { | |
| 223 | + | return strings.Repeat(".", maxWidth) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | return b.String() + "..." | |
| 227 | + | } | |
| 228 | + | ||
| 167 | 229 | // View returns the table view (alias for Render). | |
| 168 | 230 | func (t *TableModel) View() string { | |
| 169 | 231 | return t.Render() | |
| @@ -1,10 +1,14 @@ | |||||
| 1 | package components | 1 | package components | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | 6 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | ||
| 5 | 7 | ||||
| 6 | "github.com/charmbracelet/lipgloss" | 8 | "github.com/charmbracelet/lipgloss" | ||
| 7 | "github.com/charmbracelet/lipgloss/table" | 9 | "github.com/charmbracelet/lipgloss/table" | ||
| 10 | + | "github.com/mattn/go-runewidth" | |||
| 11 | + | "github.com/rivo/uniseg" | |||
| 8 | ) | 12 | ) | ||
| 9 | 13 | ||||
| 10 | // TableColumn defines a table column. | 14 | // TableColumn defines a table column. | ||
| @@ -137,7 +141,19 @@ func (t *TableModel) Render() string { | |||||
| 137 | // Extract headers | 141 | // Extract headers | ||
| 138 | headers := make([]string, len(t.columns)) | 142 | headers := make([]string, len(t.columns)) | ||
| 139 | for i, col := range t.columns { | 143 | for i, col := range t.columns { | ||
| 140 | - | headers[i] = col.Title | 144 | + | headers[i] = truncateCell(col.Title, col.Width) |
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | rows := make([][]string, len(t.rows)) | |||
| 148 | + | for rowIdx, row := range t.rows { | |||
| 149 | + | rows[rowIdx] = make([]string, len(row)) | |||
| 150 | + | for colIdx, cell := range row { | |||
| 151 | + | width := 0 | |||
| 152 | + | if colIdx < len(t.columns) { | |||
| 153 | + | width = t.columns[colIdx].Width | |||
| 154 | + | } | |||
| 155 | + | rows[rowIdx][colIdx] = truncateCell(cell, width) | |||
| 156 | + | } | |||
| 141 | } | 157 | } | ||
| 142 | 158 | ||||
| 143 | // Create table | 159 | // Create table | ||
| @@ -145,15 +161,27 @@ func (t *TableModel) Render() string { | |||||
| 145 | Border(t.border). | 161 | Border(t.border). | ||
| 146 | BorderStyle(t.borderStyle). | 162 | BorderStyle(t.borderStyle). | ||
| 147 | Headers(headers...). | 163 | Headers(headers...). | ||
| 148 | - | Rows(t.rows...). | 164 | + | Rows(rows...). |
| 149 | StyleFunc(func(row, col int) lipgloss.Style { | 165 | StyleFunc(func(row, col int) lipgloss.Style { | ||
| 166 | + | width := 0 | |||
| 167 | + | if col >= 0 && col < len(t.columns) { | |||
| 168 | + | width = t.columns[col].Width | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | applyWidth := func(s lipgloss.Style) lipgloss.Style { | |||
| 172 | + | if width > 0 { | |||
| 173 | + | return s.Width(width).MaxWidth(width) | |||
| 174 | + | } | |||
| 175 | + | return s | |||
| 176 | + | } | |||
| 177 | + | ||||
| 150 | if row == table.HeaderRow { | 178 | if row == table.HeaderRow { | ||
| 151 | - | return t.headerStyle | 179 | + | return applyWidth(t.headerStyle) |
| 152 | } | 180 | } | ||
| 153 | if row%2 == 0 { | 181 | if row%2 == 0 { | ||
| 154 | - | return t.evenStyle | 182 | + | return applyWidth(t.evenStyle) |
| 155 | } | 183 | } | ||
| 156 | - | return t.oddStyle | 184 | + | return applyWidth(t.oddStyle) |
| 157 | }) | 185 | }) | ||
| 158 | 186 | ||||
| 159 | // Apply width if set | 187 | // Apply width if set | ||
| @@ -164,6 +192,40 @@ func (t *TableModel) Render() string { | |||||
| 164 | return tbl.String() | 192 | return tbl.String() | ||
| 165 | } | 193 | } | ||
| 166 | 194 | ||||
| 195 | + | func truncateCell(value string, maxWidth int) string { | |||
| 196 | + | if strings.Contains(value, "\x1b[") { | |||
| 197 | + | return value | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | if maxWidth <= 0 || runewidth.StringWidth(value) <= maxWidth { | |||
| 201 | + | return value | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | if maxWidth <= 3 { | |||
| 205 | + | return strings.Repeat(".", maxWidth) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | targetWidth := maxWidth - 3 | |||
| 209 | + | b := strings.Builder{} | |||
| 210 | + | currentWidth := 0 | |||
| 211 | + | g := uniseg.NewGraphemes(value) | |||
| 212 | + | for g.Next() { | |||
| 213 | + | grapheme := g.Str() | |||
| 214 | + | graphemeWidth := runewidth.StringWidth(grapheme) | |||
| 215 | + | if currentWidth+graphemeWidth > targetWidth { | |||
| 216 | + | break | |||
| 217 | + | } | |||
| 218 | + | b.WriteString(grapheme) | |||
| 219 | + | currentWidth += graphemeWidth | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | if b.Len() == 0 { | |||
| 223 | + | return strings.Repeat(".", maxWidth) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | return b.String() + "..." | |||
| 227 | + | } | |||
| 228 | + | ||||
| 167 | // View returns the table view (alias for Render). | 229 | // View returns the table view (alias for Render). | ||
| 168 | func (t *TableModel) View() string { | 230 | func (t *TableModel) View() string { | ||
| 169 | return t.Render() | 231 | return t.Render() | ||
A internal/adapters/in/cli/ui/components/table_test.go +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | package components | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "regexp" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/charmbracelet/lipgloss" | |
| 9 | + | "github.com/mattn/go-runewidth" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestTableRender_AppliesConfiguredColumnWidth(t *testing.T) { | |
| 15 | + | tableModel := NewTable( | |
| 16 | + | WithColumns([]TableColumn{{Title: "ID", Width: 5}}), | |
| 17 | + | WithRows([][]string{{"abc"}}), | |
| 18 | + | WithHeaderStyle(lipgloss.NewStyle()), | |
| 19 | + | WithCellStyle(lipgloss.NewStyle()), | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | rendered := stripANSI(tableModel.Render()) | |
| 23 | + | lines := strings.Split(rendered, "\n") | |
| 24 | + | ||
| 25 | + | var rowLine string | |
| 26 | + | for _, line := range lines { | |
| 27 | + | if strings.Contains(line, "abc") { | |
| 28 | + | rowLine = line | |
| 29 | + | break | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | require.NotEmpty(t, rowLine) | |
| 34 | + | assert.Contains(t, rowLine, "abc ") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestTableRender_TruncatesLongCellTextWithEllipsis(t *testing.T) { | |
| 38 | + | tableModel := NewTable( | |
| 39 | + | WithColumns([]TableColumn{{Title: "ID", Width: 5}}), | |
| 40 | + | WithRows([][]string{{"abcdef"}}), | |
| 41 | + | WithHeaderStyle(lipgloss.NewStyle()), | |
| 42 | + | WithCellStyle(lipgloss.NewStyle()), | |
| 43 | + | ) | |
| 44 | + | ||
| 45 | + | rendered := stripANSI(tableModel.Render()) | |
| 46 | + | assert.Contains(t, rendered, "ab...") | |
| 47 | + | assert.NotContains(t, rendered, "abcdef") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestTruncateCell_LeavesShortTextUnchanged(t *testing.T) { | |
| 51 | + | assert.Equal(t, "abc", truncateCell("abc", 5)) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestTruncateCell_EdgeCases(t *testing.T) { | |
| 55 | + | tests := []struct { | |
| 56 | + | name string | |
| 57 | + | value string | |
| 58 | + | maxWidth int | |
| 59 | + | expected string | |
| 60 | + | }{ | |
| 61 | + | {name: "zero width passthrough", value: "abcdef", maxWidth: 0, expected: "abcdef"}, | |
| 62 | + | {name: "width three all dots", value: "abcdef", maxWidth: 3, expected: "..."}, | |
| 63 | + | {name: "ascii truncates", value: "abcdef", maxWidth: 5, expected: "ab..."}, | |
| 64 | + | {name: "cjk truncates by display width", value: "你好世界", maxWidth: 5, expected: "你..."}, | |
| 65 | + | {name: "grapheme-safe emoji truncation", value: "👨<U+200D>👩<U+200D>👧<U+200D>👦abcd", maxWidth: 5, expected: "👨<U+200D>👩<U+200D>👧<U+200D>👦..."}, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | for _, tt := range tests { | |
| 69 | + | tt := tt | |
| 70 | + | t.Run(tt.name, func(t *testing.T) { | |
| 71 | + | got := truncateCell(tt.value, tt.maxWidth) | |
| 72 | + | assert.Equal(t, tt.expected, got) | |
| 73 | + | if tt.maxWidth > 0 { | |
| 74 | + | assert.LessOrEqual(t, runewidth.StringWidth(got), tt.maxWidth) | |
| 75 | + | } | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TestTruncateCell_AnsiInputPassthrough(t *testing.T) { | |
| 81 | + | styled := "\x1b[32mactive\x1b[0m" | |
| 82 | + | got := truncateCell(styled, 3) | |
| 83 | + | assert.Equal(t, styled, got) | |
| 84 | + | assert.Contains(t, got, "\x1b[") | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func stripANSI(input string) string { | |
| 88 | + | ansiPattern := regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) | |
| 89 | + | return ansiPattern.ReplaceAllString(input, "") | |
| 90 | + | } | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "regexp" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/charmbracelet/lipgloss" | |||
| 9 | + | "github.com/mattn/go-runewidth" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestTableRender_AppliesConfiguredColumnWidth(t *testing.T) { | |||
| 15 | + | tableModel := NewTable( | |||
| 16 | + | WithColumns([]TableColumn{{Title: "ID", Width: 5}}), | |||
| 17 | + | WithRows([][]string{{"abc"}}), | |||
| 18 | + | WithHeaderStyle(lipgloss.NewStyle()), | |||
| 19 | + | WithCellStyle(lipgloss.NewStyle()), | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | rendered := stripANSI(tableModel.Render()) | |||
| 23 | + | lines := strings.Split(rendered, "\n") | |||
| 24 | + | ||||
| 25 | + | var rowLine string | |||
| 26 | + | for _, line := range lines { | |||
| 27 | + | if strings.Contains(line, "abc") { | |||
| 28 | + | rowLine = line | |||
| 29 | + | break | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | require.NotEmpty(t, rowLine) | |||
| 34 | + | assert.Contains(t, rowLine, "abc ") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestTableRender_TruncatesLongCellTextWithEllipsis(t *testing.T) { | |||
| 38 | + | tableModel := NewTable( | |||
| 39 | + | WithColumns([]TableColumn{{Title: "ID", Width: 5}}), | |||
| 40 | + | WithRows([][]string{{"abcdef"}}), | |||
| 41 | + | WithHeaderStyle(lipgloss.NewStyle()), | |||
| 42 | + | WithCellStyle(lipgloss.NewStyle()), | |||
| 43 | + | ) | |||
| 44 | + | ||||
| 45 | + | rendered := stripANSI(tableModel.Render()) | |||
| 46 | + | assert.Contains(t, rendered, "ab...") | |||
| 47 | + | assert.NotContains(t, rendered, "abcdef") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestTruncateCell_LeavesShortTextUnchanged(t *testing.T) { | |||
| 51 | + | assert.Equal(t, "abc", truncateCell("abc", 5)) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestTruncateCell_EdgeCases(t *testing.T) { | |||
| 55 | + | tests := []struct { | |||
| 56 | + | name string | |||
| 57 | + | value string | |||
| 58 | + | maxWidth int | |||
| 59 | + | expected string | |||
| 60 | + | }{ | |||
| 61 | + | {name: "zero width passthrough", value: "abcdef", maxWidth: 0, expected: "abcdef"}, | |||
| 62 | + | {name: "width three all dots", value: "abcdef", maxWidth: 3, expected: "..."}, | |||
| 63 | + | {name: "ascii truncates", value: "abcdef", maxWidth: 5, expected: "ab..."}, | |||
| 64 | + | {name: "cjk truncates by display width", value: "你好世界", maxWidth: 5, expected: "你..."}, | |||
| 65 | + | {name: "grapheme-safe emoji truncation", value: "👨<U+200D>👩<U+200D>👧<U+200D>👦abcd", maxWidth: 5, expected: "👨<U+200D>👩<U+200D>👧<U+200D>👦..."}, | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | for _, tt := range tests { | |||
| 69 | + | tt := tt | |||
| 70 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 71 | + | got := truncateCell(tt.value, tt.maxWidth) | |||
| 72 | + | assert.Equal(t, tt.expected, got) | |||
| 73 | + | if tt.maxWidth > 0 { | |||
| 74 | + | assert.LessOrEqual(t, runewidth.StringWidth(got), tt.maxWidth) | |||
| 75 | + | } | |||
| 76 | + | }) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestTruncateCell_AnsiInputPassthrough(t *testing.T) { | |||
| 81 | + | styled := "\x1b[32mactive\x1b[0m" | |||
| 82 | + | got := truncateCell(styled, 3) | |||
| 83 | + | assert.Equal(t, styled, got) | |||
| 84 | + | assert.Contains(t, got, "\x1b[") | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func stripANSI(input string) string { | |||
| 88 | + | ansiPattern := regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) | |||
| 89 | + | return ansiPattern.ReplaceAllString(input, "") | |||
| 90 | + | } | |||
A internal/adapters/in/cli/ui_adoption_test.go +233 -0
| @@ -0,0 +1,233 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "go/ast" | |
| 7 | + | "go/parser" | |
| 8 | + | "go/token" | |
| 9 | + | "io" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "testing" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | var uiAdoptionHelperCalls = map[string]struct{}{ | |
| 18 | + | "cliWriteLine": {}, | |
| 19 | + | "cliWritef": {}, | |
| 20 | + | "cliRenderTitle": {}, | |
| 21 | + | "cliRenderMuted": {}, | |
| 22 | + | "cliRenderEmptyState": {}, | |
| 23 | + | "cliRenderListItem": {}, | |
| 24 | + | "cliRenderMeta": {}, | |
| 25 | + | "cliRenderSuccess": {}, | |
| 26 | + | "cliRenderWarning": {}, | |
| 27 | + | "cliRenderInfo": {}, | |
| 28 | + | } | |
| 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. | |
| 33 | + | var uiAdoptionSeamMu sync.Mutex | |
| 34 | + | ||
| 35 | + | func TestPresentationHelpers(t *testing.T) { | |
| 36 | + | if got := cliRenderTitle("Title"); got == "" { | |
| 37 | + | t.Fatal("cliRenderTitle returned empty output") | |
| 38 | + | } | |
| 39 | + | if got := cliRenderEmptyState("none"); got == "" { | |
| 40 | + | t.Fatal("cliRenderEmptyState returned empty output") | |
| 41 | + | } | |
| 42 | + | if got := cliRenderSuccess("ok"); got == "" { | |
| 43 | + | t.Fatal("cliRenderSuccess returned empty output") | |
| 44 | + | } | |
| 45 | + | if got := cliRenderWarning("warn"); got == "" { | |
| 46 | + | t.Fatal("cliRenderWarning returned empty output") | |
| 47 | + | } | |
| 48 | + | if got := cliRenderInfo("info"); got == "" { | |
| 49 | + | t.Fatal("cliRenderInfo returned empty output") | |
| 50 | + | } | |
| 51 | + | if got := cliRenderListItem("item"); got == "" { | |
| 52 | + | t.Fatal("cliRenderListItem returned empty output") | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestUIAdoption(t *testing.T) { | |
| 57 | + | for _, expect := range uiAdoptionExpectations { | |
| 58 | + | expect := expect | |
| 59 | + | t.Run(expect.family, func(t *testing.T) { | |
| 60 | + | fset := token.NewFileSet() | |
| 61 | + | fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors) | |
| 62 | + | if err != nil { | |
| 63 | + | t.Fatalf("failed to parse %s: %v", expect.file, err) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | for _, fnName := range expect.functions { | |
| 67 | + | fnName := fnName | |
| 68 | + | t.Run(fnName, func(t *testing.T) { | |
| 69 | + | fn := findFuncDecl(fileNode, fnName) | |
| 70 | + | if fn == nil { | |
| 71 | + | t.Fatalf("function %s not found in %s", fnName, expect.file) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | hasHelperCall := false | |
| 75 | + | hasSharedUIUsage := false | |
| 76 | + | hasForbiddenRawPrint := false | |
| 77 | + | ||
| 78 | + | ast.Inspect(fn.Body, func(n ast.Node) bool { | |
| 79 | + | call, ok := n.(*ast.CallExpr) | |
| 80 | + | if !ok { | |
| 81 | + | return true | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | switch fun := call.Fun.(type) { | |
| 85 | + | case *ast.Ident: | |
| 86 | + | if _, ok := uiAdoptionHelperCalls[fun.Name]; ok { | |
| 87 | + | hasHelperCall = true | |
| 88 | + | } | |
| 89 | + | case *ast.SelectorExpr: | |
| 90 | + | if isForbiddenRawPrintCall(call, fun) { | |
| 91 | + | hasForbiddenRawPrint = true | |
| 92 | + | } | |
| 93 | + | pkgIdent, ok := fun.X.(*ast.Ident) | |
| 94 | + | if !ok { | |
| 95 | + | return true | |
| 96 | + | } | |
| 97 | + | if pkgIdent.Name == "styles" || pkgIdent.Name == "components" { | |
| 98 | + | hasSharedUIUsage = true | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | return true | |
| 103 | + | }) | |
| 104 | + | ||
| 105 | + | if !hasHelperCall && !hasSharedUIUsage { | |
| 106 | + | t.Fatalf("%s in %s does not use presentation helpers or shared ui styles/components", fnName, expect.file) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | if hasForbiddenRawPrint { | |
| 110 | + | t.Fatalf("%s in %s still uses forbidden raw print calls", fnName, expect.file) | |
| 111 | + | } | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | }) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool { | |
| 119 | + | pkgIdent, ok := sel.X.(*ast.Ident) | |
| 120 | + | if !ok { | |
| 121 | + | return false | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | if pkgIdent.Name == "fmt" { | |
| 125 | + | switch sel.Sel.Name { | |
| 126 | + | case "Print", "Printf", "Println": | |
| 127 | + | return true | |
| 128 | + | case "Fprint", "Fprintf", "Fprintln": | |
| 129 | + | if len(call.Args) == 0 { | |
| 130 | + | return true | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | switch dst := call.Args[0].(type) { | |
| 134 | + | case *ast.SelectorExpr: | |
| 135 | + | if dstPkg, ok := dst.X.(*ast.Ident); ok && dstPkg.Name == "os" && (dst.Sel.Name == "Stdout" || dst.Sel.Name == "Stderr") { | |
| 136 | + | return true | |
| 137 | + | } | |
| 138 | + | case *ast.Ident: | |
| 139 | + | if dst.Name == "out" { | |
| 140 | + | return true | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | if pkgIdent.Name == "cmd" && strings.HasPrefix(sel.Sel.Name, "Print") { | |
| 147 | + | return true | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | return false | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | type imagesClientStub struct { | |
| 154 | + | listResp []dto.Image | |
| 155 | + | pruneResp *dto.ImagePruneResponse | |
| 156 | + | err error | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) { | |
| 160 | + | if s.err != nil { | |
| 161 | + | return nil, s.err | |
| 162 | + | } | |
| 163 | + | return s.listResp, nil | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (s imagesClientStub) PruneImages(context.Context, dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) { | |
| 167 | + | if s.err != nil { | |
| 168 | + | return nil, s.err | |
| 169 | + | } | |
| 170 | + | return s.pruneResp, nil | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestUIAdoptionRuntimeSeams(t *testing.T) { | |
| 174 | + | uiAdoptionSeamMu.Lock() | |
| 175 | + | defer uiAdoptionSeamMu.Unlock() | |
| 176 | + | ||
| 177 | + | origWriteLine := cliWriteLine | |
| 178 | + | origWritef := cliWritef | |
| 179 | + | defer func() { | |
| 180 | + | cliWriteLine = origWriteLine | |
| 181 | + | cliWritef = origWritef | |
| 182 | + | }() | |
| 183 | + | ||
| 184 | + | lineCalls := 0 | |
| 185 | + | writefCalls := 0 | |
| 186 | + | cliWriteLine = func(w io.Writer, msg string) error { | |
| 187 | + | lineCalls++ | |
| 188 | + | _, err := io.WriteString(w, msg+"\n") | |
| 189 | + | return err | |
| 190 | + | } | |
| 191 | + | cliWritef = func(w io.Writer, format string, args ...any) error { | |
| 192 | + | writefCalls++ | |
| 193 | + | _, err := io.WriteString(w, "formatted\n") | |
| 194 | + | return err | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | versionCmd := newVersionCmd() | |
| 198 | + | versionCmd.SetArgs(nil) | |
| 199 | + | versionCmd.SetOut(new(bytes.Buffer)) | |
| 200 | + | if err := versionCmd.Execute(); err != nil { | |
| 201 | + | t.Fatalf("version command execution failed: %v", err) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | imgBuf := new(bytes.Buffer) | |
| 205 | + | if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil { | |
| 206 | + | t.Fatalf("runImagesList failed: %v", err) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | pruneBuf := new(bytes.Buffer) | |
| 210 | + | if err := runImagesPrune(context.Background(), imagesClientStub{listResp: []dto.Image{}, pruneResp: &dto.ImagePruneResponse{}}, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil { | |
| 211 | + | t.Fatalf("runImagesPrune failed: %v", err) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | if lineCalls == 0 { | |
| 215 | + | t.Fatal("expected cliWriteLine to be called") | |
| 216 | + | } | |
| 217 | + | if writefCalls == 0 { | |
| 218 | + | t.Fatal("expected cliWritef to be called") | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func findFuncDecl(file *ast.File, name string) *ast.FuncDecl { | |
| 223 | + | for _, decl := range file.Decls { | |
| 224 | + | fn, ok := decl.(*ast.FuncDecl) | |
| 225 | + | if !ok { | |
| 226 | + | continue | |
| 227 | + | } | |
| 228 | + | if fn.Name.Name == name { | |
| 229 | + | return fn | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | return nil | |
| 233 | + | } | |
| @@ -0,0 +1,233 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "go/ast" | |||
| 7 | + | "go/parser" | |||
| 8 | + | "go/token" | |||
| 9 | + | "io" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | "testing" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | var uiAdoptionHelperCalls = map[string]struct{}{ | |||
| 18 | + | "cliWriteLine": {}, | |||
| 19 | + | "cliWritef": {}, | |||
| 20 | + | "cliRenderTitle": {}, | |||
| 21 | + | "cliRenderMuted": {}, | |||
| 22 | + | "cliRenderEmptyState": {}, | |||
| 23 | + | "cliRenderListItem": {}, | |||
| 24 | + | "cliRenderMeta": {}, | |||
| 25 | + | "cliRenderSuccess": {}, | |||
| 26 | + | "cliRenderWarning": {}, | |||
| 27 | + | "cliRenderInfo": {}, | |||
| 28 | + | } | |||
| 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. | |||
| 33 | + | var uiAdoptionSeamMu sync.Mutex | |||
| 34 | + | ||||
| 35 | + | func TestPresentationHelpers(t *testing.T) { | |||
| 36 | + | if got := cliRenderTitle("Title"); got == "" { | |||
| 37 | + | t.Fatal("cliRenderTitle returned empty output") | |||
| 38 | + | } | |||
| 39 | + | if got := cliRenderEmptyState("none"); got == "" { | |||
| 40 | + | t.Fatal("cliRenderEmptyState returned empty output") | |||
| 41 | + | } | |||
| 42 | + | if got := cliRenderSuccess("ok"); got == "" { | |||
| 43 | + | t.Fatal("cliRenderSuccess returned empty output") | |||
| 44 | + | } | |||
| 45 | + | if got := cliRenderWarning("warn"); got == "" { | |||
| 46 | + | t.Fatal("cliRenderWarning returned empty output") | |||
| 47 | + | } | |||
| 48 | + | if got := cliRenderInfo("info"); got == "" { | |||
| 49 | + | t.Fatal("cliRenderInfo returned empty output") | |||
| 50 | + | } | |||
| 51 | + | if got := cliRenderListItem("item"); got == "" { | |||
| 52 | + | t.Fatal("cliRenderListItem returned empty output") | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestUIAdoption(t *testing.T) { | |||
| 57 | + | for _, expect := range uiAdoptionExpectations { | |||
| 58 | + | expect := expect | |||
| 59 | + | t.Run(expect.family, func(t *testing.T) { | |||
| 60 | + | fset := token.NewFileSet() | |||
| 61 | + | fileNode, err := parser.ParseFile(fset, expect.file, nil, parser.AllErrors) | |||
| 62 | + | if err != nil { | |||
| 63 | + | t.Fatalf("failed to parse %s: %v", expect.file, err) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | for _, fnName := range expect.functions { | |||
| 67 | + | fnName := fnName | |||
| 68 | + | t.Run(fnName, func(t *testing.T) { | |||
| 69 | + | fn := findFuncDecl(fileNode, fnName) | |||
| 70 | + | if fn == nil { | |||
| 71 | + | t.Fatalf("function %s not found in %s", fnName, expect.file) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | hasHelperCall := false | |||
| 75 | + | hasSharedUIUsage := false | |||
| 76 | + | hasForbiddenRawPrint := false | |||
| 77 | + | ||||
| 78 | + | ast.Inspect(fn.Body, func(n ast.Node) bool { | |||
| 79 | + | call, ok := n.(*ast.CallExpr) | |||
| 80 | + | if !ok { | |||
| 81 | + | return true | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | switch fun := call.Fun.(type) { | |||
| 85 | + | case *ast.Ident: | |||
| 86 | + | if _, ok := uiAdoptionHelperCalls[fun.Name]; ok { | |||
| 87 | + | hasHelperCall = true | |||
| 88 | + | } | |||
| 89 | + | case *ast.SelectorExpr: | |||
| 90 | + | if isForbiddenRawPrintCall(call, fun) { | |||
| 91 | + | hasForbiddenRawPrint = true | |||
| 92 | + | } | |||
| 93 | + | pkgIdent, ok := fun.X.(*ast.Ident) | |||
| 94 | + | if !ok { | |||
| 95 | + | return true | |||
| 96 | + | } | |||
| 97 | + | if pkgIdent.Name == "styles" || pkgIdent.Name == "components" { | |||
| 98 | + | hasSharedUIUsage = true | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | return true | |||
| 103 | + | }) | |||
| 104 | + | ||||
| 105 | + | if !hasHelperCall && !hasSharedUIUsage { | |||
| 106 | + | t.Fatalf("%s in %s does not use presentation helpers or shared ui styles/components", fnName, expect.file) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | if hasForbiddenRawPrint { | |||
| 110 | + | t.Fatalf("%s in %s still uses forbidden raw print calls", fnName, expect.file) | |||
| 111 | + | } | |||
| 112 | + | }) | |||
| 113 | + | } | |||
| 114 | + | }) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func isForbiddenRawPrintCall(call *ast.CallExpr, sel *ast.SelectorExpr) bool { | |||
| 119 | + | pkgIdent, ok := sel.X.(*ast.Ident) | |||
| 120 | + | if !ok { | |||
| 121 | + | return false | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | if pkgIdent.Name == "fmt" { | |||
| 125 | + | switch sel.Sel.Name { | |||
| 126 | + | case "Print", "Printf", "Println": | |||
| 127 | + | return true | |||
| 128 | + | case "Fprint", "Fprintf", "Fprintln": | |||
| 129 | + | if len(call.Args) == 0 { | |||
| 130 | + | return true | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | switch dst := call.Args[0].(type) { | |||
| 134 | + | case *ast.SelectorExpr: | |||
| 135 | + | if dstPkg, ok := dst.X.(*ast.Ident); ok && dstPkg.Name == "os" && (dst.Sel.Name == "Stdout" || dst.Sel.Name == "Stderr") { | |||
| 136 | + | return true | |||
| 137 | + | } | |||
| 138 | + | case *ast.Ident: | |||
| 139 | + | if dst.Name == "out" { | |||
| 140 | + | return true | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | if pkgIdent.Name == "cmd" && strings.HasPrefix(sel.Sel.Name, "Print") { | |||
| 147 | + | return true | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | return false | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | type imagesClientStub struct { | |||
| 154 | + | listResp []dto.Image | |||
| 155 | + | pruneResp *dto.ImagePruneResponse | |||
| 156 | + | err error | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func (s imagesClientStub) ListImages(context.Context) ([]dto.Image, error) { | |||
| 160 | + | if s.err != nil { | |||
| 161 | + | return nil, s.err | |||
| 162 | + | } | |||
| 163 | + | return s.listResp, nil | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func (s imagesClientStub) PruneImages(context.Context, dto.ImagePruneRequest) (*dto.ImagePruneResponse, error) { | |||
| 167 | + | if s.err != nil { | |||
| 168 | + | return nil, s.err | |||
| 169 | + | } | |||
| 170 | + | return s.pruneResp, nil | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestUIAdoptionRuntimeSeams(t *testing.T) { | |||
| 174 | + | uiAdoptionSeamMu.Lock() | |||
| 175 | + | defer uiAdoptionSeamMu.Unlock() | |||
| 176 | + | ||||
| 177 | + | origWriteLine := cliWriteLine | |||
| 178 | + | origWritef := cliWritef | |||
| 179 | + | defer func() { | |||
| 180 | + | cliWriteLine = origWriteLine | |||
| 181 | + | cliWritef = origWritef | |||
| 182 | + | }() | |||
| 183 | + | ||||
| 184 | + | lineCalls := 0 | |||
| 185 | + | writefCalls := 0 | |||
| 186 | + | cliWriteLine = func(w io.Writer, msg string) error { | |||
| 187 | + | lineCalls++ | |||
| 188 | + | _, err := io.WriteString(w, msg+"\n") | |||
| 189 | + | return err | |||
| 190 | + | } | |||
| 191 | + | cliWritef = func(w io.Writer, format string, args ...any) error { | |||
| 192 | + | writefCalls++ | |||
| 193 | + | _, err := io.WriteString(w, "formatted\n") | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | versionCmd := newVersionCmd() | |||
| 198 | + | versionCmd.SetArgs(nil) | |||
| 199 | + | versionCmd.SetOut(new(bytes.Buffer)) | |||
| 200 | + | if err := versionCmd.Execute(); err != nil { | |||
| 201 | + | t.Fatalf("version command execution failed: %v", err) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | imgBuf := new(bytes.Buffer) | |||
| 205 | + | if err := runImagesList(context.Background(), imagesClientStub{listResp: []dto.Image{{Repository: "repo/app", Tag: "latest", ID: "sha256:abc", Size: 1}}}, imgBuf); err != nil { | |||
| 206 | + | t.Fatalf("runImagesList failed: %v", err) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | pruneBuf := new(bytes.Buffer) | |||
| 210 | + | if err := runImagesPrune(context.Background(), imagesClientStub{listResp: []dto.Image{}, pruneResp: &dto.ImagePruneResponse{}}, imagesPruneOptions{DryRun: true}, pruneBuf); err != nil { | |||
| 211 | + | t.Fatalf("runImagesPrune failed: %v", err) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | if lineCalls == 0 { | |||
| 215 | + | t.Fatal("expected cliWriteLine to be called") | |||
| 216 | + | } | |||
| 217 | + | if writefCalls == 0 { | |||
| 218 | + | t.Fatal("expected cliWritef to be called") | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func findFuncDecl(file *ast.File, name string) *ast.FuncDecl { | |||
| 223 | + | for _, decl := range file.Decls { | |||
| 224 | + | fn, ok := decl.(*ast.FuncDecl) | |||
| 225 | + | if !ok { | |||
| 226 | + | continue | |||
| 227 | + | } | |||
| 228 | + | if fn.Name.Name == name { | |||
| 229 | + | return fn | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | return nil | |||
| 233 | + | } | |||
M internal/adapters/in/http/admin/handler_test.go +83 -6
| @@ -21,7 +21,7 @@ import ( | |||
| 21 | 21 | ||
| 22 | 22 | type stubImageService struct { | |
| 23 | 23 | listImagesFunc func(context.Context) ([]domain.ImageInfo, error) | |
| 24 | - | pruneFunc func(context.Context, int) (domain.ImagePruneReport, error) | |
| 24 | + | pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | |
| @@ -31,11 +31,11 @@ func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, | |||
| 31 | 31 | return s.listImagesFunc(ctx) | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | - | func (s *stubImageService) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | |
| 34 | + | func (s *stubImageService) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |
| 35 | 35 | if s.pruneFunc == nil { | |
| 36 | 36 | return domain.ImagePruneReport{}, nil | |
| 37 | 37 | } | |
| 38 | - | return s.pruneFunc(ctx, keepLast) | |
| 38 | + | return s.pruneFunc(ctx, opts) | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | 41 | func testLogger() zerowrap.Logger { | |
| @@ -1393,9 +1393,9 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) { | |||
| 1393 | 1393 | t.Run(tt.name, func(t *testing.T) { | |
| 1394 | 1394 | called := false | |
| 1395 | 1395 | imageSvc := &stubImageService{ | |
| 1396 | - | pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) { | |
| 1396 | + | pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |
| 1397 | 1397 | called = true | |
| 1398 | - | assert.Equal(t, tt.expectedKeepLast, keepLast) | |
| 1398 | + | assert.Equal(t, tt.expectedKeepLast, opts.KeepLast) | |
| 1399 | 1399 | return domain.ImagePruneReport{ | |
| 1400 | 1400 | Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, | |
| 1401 | 1401 | Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048}, | |
| @@ -1452,7 +1452,7 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||
| 1452 | 1452 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1453 | 1453 | ||
| 1454 | 1454 | imageSvc := &stubImageService{ | |
| 1455 | - | pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) { | |
| 1455 | + | pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |
| 1456 | 1456 | return domain.ImagePruneReport{}, errors.New("prune failed: confidential details") | |
| 1457 | 1457 | }, | |
| 1458 | 1458 | } | |
| @@ -1509,6 +1509,83 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||
| 1509 | 1509 | }) | |
| 1510 | 1510 | } | |
| 1511 | 1511 | ||
| 1512 | + | func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) { | |
| 1513 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 1514 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 1515 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 1516 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 1517 | + | ||
| 1518 | + | tests := []struct { | |
| 1519 | + | name string | |
| 1520 | + | body string | |
| 1521 | + | expectedPruneDangling bool | |
| 1522 | + | expectedPruneRegistry bool | |
| 1523 | + | expectedKeepLast int | |
| 1524 | + | wantStatus int | |
| 1525 | + | }{ | |
| 1526 | + | { | |
| 1527 | + | name: "missing scope fields defaults to both true", | |
| 1528 | + | body: `{}`, | |
| 1529 | + | expectedPruneDangling: true, | |
| 1530 | + | expectedPruneRegistry: true, | |
| 1531 | + | expectedKeepLast: domain.DefaultImagePruneKeepLast, | |
| 1532 | + | wantStatus: http.StatusOK, | |
| 1533 | + | }, | |
| 1534 | + | { | |
| 1535 | + | name: "explicit dangling only", | |
| 1536 | + | body: `{"prune_dangling": true, "prune_registry": false}`, | |
| 1537 | + | expectedPruneDangling: true, | |
| 1538 | + | expectedPruneRegistry: false, | |
| 1539 | + | expectedKeepLast: domain.DefaultImagePruneKeepLast, | |
| 1540 | + | wantStatus: http.StatusOK, | |
| 1541 | + | }, | |
| 1542 | + | { | |
| 1543 | + | name: "explicit registry only", | |
| 1544 | + | body: `{"prune_dangling": false, "prune_registry": true, "keep_last": 5}`, | |
| 1545 | + | expectedPruneDangling: false, | |
| 1546 | + | expectedPruneRegistry: true, | |
| 1547 | + | expectedKeepLast: 5, | |
| 1548 | + | wantStatus: http.StatusOK, | |
| 1549 | + | }, | |
| 1550 | + | { | |
| 1551 | + | name: "both scopes false returns 400", | |
| 1552 | + | body: `{"prune_dangling": false, "prune_registry": false}`, | |
| 1553 | + | wantStatus: http.StatusBadRequest, | |
| 1554 | + | }, | |
| 1555 | + | } | |
| 1556 | + | ||
| 1557 | + | for _, tt := range tests { | |
| 1558 | + | t.Run(tt.name, func(t *testing.T) { | |
| 1559 | + | called := false | |
| 1560 | + | imageSvc := &stubImageService{ | |
| 1561 | + | pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |
| 1562 | + | called = true | |
| 1563 | + | assert.Equal(t, tt.expectedPruneDangling, opts.PruneDangling) | |
| 1564 | + | assert.Equal(t, tt.expectedPruneRegistry, opts.PruneRegistry) | |
| 1565 | + | assert.Equal(t, tt.expectedKeepLast, opts.KeepLast) | |
| 1566 | + | return domain.ImagePruneReport{}, nil | |
| 1567 | + | }, | |
| 1568 | + | } | |
| 1569 | + | ||
| 1570 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | |
| 1571 | + | ||
| 1572 | + | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) | |
| 1573 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |
| 1574 | + | rec := httptest.NewRecorder() | |
| 1575 | + | ||
| 1576 | + | handler.ServeHTTP(rec, req) | |
| 1577 | + | ||
| 1578 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |
| 1579 | + | if tt.wantStatus == http.StatusOK { | |
| 1580 | + | assert.True(t, called, "prune should have been called") | |
| 1581 | + | } | |
| 1582 | + | if tt.wantStatus == http.StatusBadRequest { | |
| 1583 | + | assert.Contains(t, rec.Body.String(), "at least one prune scope") | |
| 1584 | + | } | |
| 1585 | + | }) | |
| 1586 | + | } | |
| 1587 | + | } | |
| 1588 | + | ||
| 1512 | 1589 | func TestHandler_Images_Authorization(t *testing.T) { | |
| 1513 | 1590 | configSvc := inmocks.NewMockConfigService(t) | |
| 1514 | 1591 | authSvc := inmocks.NewMockAuthService(t) | |
| @@ -21,7 +21,7 @@ import ( | |||||
| 21 | 21 | ||||
| 22 | type stubImageService struct { | 22 | type stubImageService struct { | ||
| 23 | listImagesFunc func(context.Context) ([]domain.ImageInfo, error) | 23 | listImagesFunc func(context.Context) ([]domain.ImageInfo, error) | ||
| 24 | - | pruneFunc func(context.Context, int) (domain.ImagePruneReport, error) | 24 | + | pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) |
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | 27 | func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | ||
| @@ -31,11 +31,11 @@ func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, | |||||
| 31 | return s.listImagesFunc(ctx) | 31 | return s.listImagesFunc(ctx) | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | - | func (s *stubImageService) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | 34 | + | func (s *stubImageService) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { |
| 35 | if s.pruneFunc == nil { | 35 | if s.pruneFunc == nil { | ||
| 36 | return domain.ImagePruneReport{}, nil | 36 | return domain.ImagePruneReport{}, nil | ||
| 37 | } | 37 | } | ||
| 38 | - | return s.pruneFunc(ctx, keepLast) | 38 | + | return s.pruneFunc(ctx, opts) |
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | func testLogger() zerowrap.Logger { | 41 | func testLogger() zerowrap.Logger { | ||
| @@ -1393,9 +1393,9 @@ func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) { | |||||
| 1393 | t.Run(tt.name, func(t *testing.T) { | 1393 | t.Run(tt.name, func(t *testing.T) { | ||
| 1394 | called := false | 1394 | called := false | ||
| 1395 | imageSvc := &stubImageService{ | 1395 | imageSvc := &stubImageService{ | ||
| 1396 | - | pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) { | 1396 | + | pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { |
| 1397 | called = true | 1397 | called = true | ||
| 1398 | - | assert.Equal(t, tt.expectedKeepLast, keepLast) | 1398 | + | assert.Equal(t, tt.expectedKeepLast, opts.KeepLast) |
| 1399 | return domain.ImagePruneReport{ | 1399 | return domain.ImagePruneReport{ | ||
| 1400 | Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, | 1400 | Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, | ||
| 1401 | Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048}, | 1401 | Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048}, | ||
| @@ -1452,7 +1452,7 @@ func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||||
| 1452 | secretSvc := inmocks.NewMockSecretService(t) | 1452 | secretSvc := inmocks.NewMockSecretService(t) | ||
| 1453 | 1453 | ||||
| 1454 | imageSvc := &stubImageService{ | 1454 | imageSvc := &stubImageService{ | ||
| 1455 | - | pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) { | 1455 | + | pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) { |
| 1456 | return domain.ImagePruneReport{}, errors.New("prune failed: confidential details") | 1456 | return domain.ImagePruneReport{}, errors.New("prune failed: confidential details") | ||
| 1457 | }, | 1457 | }, | ||
| 1458 | } | 1458 | } | ||
| @@ -1509,6 +1509,83 @@ func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||||
| 1509 | }) | 1509 | }) | ||
| 1510 | } | 1510 | } | ||
| 1511 | 1511 | ||||
| 1512 | + | func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) { | |||
| 1513 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1514 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1515 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1516 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1517 | + | ||||
| 1518 | + | tests := []struct { | |||
| 1519 | + | name string | |||
| 1520 | + | body string | |||
| 1521 | + | expectedPruneDangling bool | |||
| 1522 | + | expectedPruneRegistry bool | |||
| 1523 | + | expectedKeepLast int | |||
| 1524 | + | wantStatus int | |||
| 1525 | + | }{ | |||
| 1526 | + | { | |||
| 1527 | + | name: "missing scope fields defaults to both true", | |||
| 1528 | + | body: `{}`, | |||
| 1529 | + | expectedPruneDangling: true, | |||
| 1530 | + | expectedPruneRegistry: true, | |||
| 1531 | + | expectedKeepLast: domain.DefaultImagePruneKeepLast, | |||
| 1532 | + | wantStatus: http.StatusOK, | |||
| 1533 | + | }, | |||
| 1534 | + | { | |||
| 1535 | + | name: "explicit dangling only", | |||
| 1536 | + | body: `{"prune_dangling": true, "prune_registry": false}`, | |||
| 1537 | + | expectedPruneDangling: true, | |||
| 1538 | + | expectedPruneRegistry: false, | |||
| 1539 | + | expectedKeepLast: domain.DefaultImagePruneKeepLast, | |||
| 1540 | + | wantStatus: http.StatusOK, | |||
| 1541 | + | }, | |||
| 1542 | + | { | |||
| 1543 | + | name: "explicit registry only", | |||
| 1544 | + | body: `{"prune_dangling": false, "prune_registry": true, "keep_last": 5}`, | |||
| 1545 | + | expectedPruneDangling: false, | |||
| 1546 | + | expectedPruneRegistry: true, | |||
| 1547 | + | expectedKeepLast: 5, | |||
| 1548 | + | wantStatus: http.StatusOK, | |||
| 1549 | + | }, | |||
| 1550 | + | { | |||
| 1551 | + | name: "both scopes false returns 400", | |||
| 1552 | + | body: `{"prune_dangling": false, "prune_registry": false}`, | |||
| 1553 | + | wantStatus: http.StatusBadRequest, | |||
| 1554 | + | }, | |||
| 1555 | + | } | |||
| 1556 | + | ||||
| 1557 | + | for _, tt := range tests { | |||
| 1558 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1559 | + | called := false | |||
| 1560 | + | imageSvc := &stubImageService{ | |||
| 1561 | + | pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |||
| 1562 | + | called = true | |||
| 1563 | + | assert.Equal(t, tt.expectedPruneDangling, opts.PruneDangling) | |||
| 1564 | + | assert.Equal(t, tt.expectedPruneRegistry, opts.PruneRegistry) | |||
| 1565 | + | assert.Equal(t, tt.expectedKeepLast, opts.KeepLast) | |||
| 1566 | + | return domain.ImagePruneReport{}, nil | |||
| 1567 | + | }, | |||
| 1568 | + | } | |||
| 1569 | + | ||||
| 1570 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | |||
| 1571 | + | ||||
| 1572 | + | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) | |||
| 1573 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 1574 | + | rec := httptest.NewRecorder() | |||
| 1575 | + | ||||
| 1576 | + | handler.ServeHTTP(rec, req) | |||
| 1577 | + | ||||
| 1578 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 1579 | + | if tt.wantStatus == http.StatusOK { | |||
| 1580 | + | assert.True(t, called, "prune should have been called") | |||
| 1581 | + | } | |||
| 1582 | + | if tt.wantStatus == http.StatusBadRequest { | |||
| 1583 | + | assert.Contains(t, rec.Body.String(), "at least one prune scope") | |||
| 1584 | + | } | |||
| 1585 | + | }) | |||
| 1586 | + | } | |||
| 1587 | + | } | |||
| 1588 | + | ||||
| 1512 | func TestHandler_Images_Authorization(t *testing.T) { | 1589 | func TestHandler_Images_Authorization(t *testing.T) { | ||
| 1513 | configSvc := inmocks.NewMockConfigService(t) | 1590 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1514 | authSvc := inmocks.NewMockAuthService(t) | 1591 | authSvc := inmocks.NewMockAuthService(t) | ||
M internal/adapters/in/http/admin/images.go +14 -4
| @@ -97,18 +97,28 @@ func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) { | |||
| 97 | 97 | return | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | - | keepLast := domain.DefaultImagePruneKeepLast | |
| 100 | + | opts := domain.DefaultImagePruneOptions() | |
| 101 | 101 | if req.KeepLast != nil { | |
| 102 | 102 | if *req.KeepLast < 0 { | |
| 103 | 103 | h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0") | |
| 104 | 104 | return | |
| 105 | 105 | } | |
| 106 | - | keepLast = *req.KeepLast | |
| 106 | + | opts.KeepLast = *req.KeepLast | |
| 107 | + | } | |
| 108 | + | if req.PruneDangling != nil { | |
| 109 | + | opts.PruneDangling = *req.PruneDangling | |
| 110 | + | } | |
| 111 | + | if req.PruneRegistry != nil { | |
| 112 | + | opts.PruneRegistry = *req.PruneRegistry | |
| 113 | + | } | |
| 114 | + | if !opts.PruneDangling && !opts.PruneRegistry { | |
| 115 | + | h.sendError(w, http.StatusBadRequest, "at least one prune scope must be enabled") | |
| 116 | + | return | |
| 107 | 117 | } | |
| 108 | 118 | ||
| 109 | - | report, err := h.imageSvc.Prune(ctx, keepLast) | |
| 119 | + | report, err := h.imageSvc.Prune(ctx, opts) | |
| 110 | 120 | if err != nil { | |
| 111 | - | log.Error().Err(err).Int("keep_last", keepLast).Msg("image prune failed") | |
| 121 | + | log.Error().Err(err).Int("keep_last", opts.KeepLast).Msg("image prune failed") | |
| 112 | 122 | h.sendError(w, http.StatusInternalServerError, "failed to prune images") | |
| 113 | 123 | return | |
| 114 | 124 | } | |
| @@ -97,18 +97,28 @@ func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) { | |||||
| 97 | return | 97 | return | ||
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | - | keepLast := domain.DefaultImagePruneKeepLast | 100 | + | opts := domain.DefaultImagePruneOptions() |
| 101 | if req.KeepLast != nil { | 101 | if req.KeepLast != nil { | ||
| 102 | if *req.KeepLast < 0 { | 102 | if *req.KeepLast < 0 { | ||
| 103 | h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0") | 103 | h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0") | ||
| 104 | return | 104 | return | ||
| 105 | } | 105 | } | ||
| 106 | - | keepLast = *req.KeepLast | 106 | + | opts.KeepLast = *req.KeepLast |
| 107 | + | } | |||
| 108 | + | if req.PruneDangling != nil { | |||
| 109 | + | opts.PruneDangling = *req.PruneDangling | |||
| 110 | + | } | |||
| 111 | + | if req.PruneRegistry != nil { | |||
| 112 | + | opts.PruneRegistry = *req.PruneRegistry | |||
| 113 | + | } | |||
| 114 | + | if !opts.PruneDangling && !opts.PruneRegistry { | |||
| 115 | + | h.sendError(w, http.StatusBadRequest, "at least one prune scope must be enabled") | |||
| 116 | + | return | |||
| 107 | } | 117 | } | ||
| 108 | 118 | ||||
| 109 | - | report, err := h.imageSvc.Prune(ctx, keepLast) | 119 | + | report, err := h.imageSvc.Prune(ctx, opts) |
| 110 | if err != nil { | 120 | if err != nil { | ||
| 111 | - | log.Error().Err(err).Int("keep_last", keepLast).Msg("image prune failed") | 121 | + | log.Error().Err(err).Int("keep_last", opts.KeepLast).Msg("image prune failed") |
| 112 | h.sendError(w, http.StatusInternalServerError, "failed to prune images") | 122 | h.sendError(w, http.StatusInternalServerError, "failed to prune images") | ||
| 113 | return | 123 | return | ||
| 114 | } | 124 | } | ||
M internal/app/run.go +5 -1
| @@ -1812,7 +1812,11 @@ func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, lo | |||
| 1812 | 1812 | keepLast = domain.DefaultImagePruneKeepLast | |
| 1813 | 1813 | } | |
| 1814 | 1814 | ||
| 1815 | - | report, err := svc.imageSvc.Prune(jobCtx, keepLast) | |
| 1815 | + | report, err := svc.imageSvc.Prune(jobCtx, domain.ImagePruneOptions{ | |
| 1816 | + | KeepLast: keepLast, | |
| 1817 | + | PruneDangling: true, | |
| 1818 | + | PruneRegistry: true, | |
| 1819 | + | }) | |
| 1816 | 1820 | if err != nil { | |
| 1817 | 1821 | return err | |
| 1818 | 1822 | } | |
| @@ -1812,7 +1812,11 @@ func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, lo | |||||
| 1812 | keepLast = domain.DefaultImagePruneKeepLast | 1812 | keepLast = domain.DefaultImagePruneKeepLast | ||
| 1813 | } | 1813 | } | ||
| 1814 | 1814 | ||||
| 1815 | - | report, err := svc.imageSvc.Prune(jobCtx, keepLast) | 1815 | + | report, err := svc.imageSvc.Prune(jobCtx, domain.ImagePruneOptions{ |
| 1816 | + | KeepLast: keepLast, | |||
| 1817 | + | PruneDangling: true, | |||
| 1818 | + | PruneRegistry: true, | |||
| 1819 | + | }) | |||
| 1816 | if err != nil { | 1820 | if err != nil { | ||
| 1817 | return err | 1821 | return err | ||
| 1818 | } | 1822 | } | ||
M internal/boundaries/in/images.go +2 -2
| @@ -12,6 +12,6 @@ type ImageService interface { | |||
| 12 | 12 | // ListImages returns runtime images and known registry tags. | |
| 13 | 13 | ListImages(ctx context.Context) ([]domain.ImageInfo, error) | |
| 14 | 14 | ||
| 15 | - | // Prune removes unused images and applies retention. | |
| 16 | - | Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) | |
| 15 | + | // Prune removes unused images and applies retention based on options. | |
| 16 | + | Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) | |
| 17 | 17 | } | |
| @@ -12,6 +12,6 @@ type ImageService interface { | |||||
| 12 | // ListImages returns runtime images and known registry tags. | 12 | // ListImages returns runtime images and known registry tags. | ||
| 13 | ListImages(ctx context.Context) ([]domain.ImageInfo, error) | 13 | ListImages(ctx context.Context) ([]domain.ImageInfo, error) | ||
| 14 | 14 | ||||
| 15 | - | // Prune removes unused images and applies retention. | 15 | + | // Prune removes unused images and applies retention based on options. |
| 16 | - | Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) | 16 | + | Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) |
| 17 | } | 17 | } | ||
M internal/domain/images.go +19 -0
| @@ -4,6 +4,25 @@ import "time" | |||
| 4 | 4 | ||
| 5 | 5 | const DefaultImagePruneKeepLast = 3 | |
| 6 | 6 | ||
| 7 | + | // ImagePruneOptions controls which prune subsystems are activated. | |
| 8 | + | type ImagePruneOptions struct { | |
| 9 | + | // KeepLast is the number of most-recent non-latest tags to retain per repository. | |
| 10 | + | KeepLast int | |
| 11 | + | // PruneDangling enables runtime dangling image cleanup. | |
| 12 | + | PruneDangling bool | |
| 13 | + | // PruneRegistry enables registry tag retention and blob garbage collection. | |
| 14 | + | PruneRegistry bool | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // DefaultImagePruneOptions returns options that prune both scopes with the default retention. | |
| 18 | + | func DefaultImagePruneOptions() ImagePruneOptions { | |
| 19 | + | return ImagePruneOptions{ | |
| 20 | + | KeepLast: DefaultImagePruneKeepLast, | |
| 21 | + | PruneDangling: true, | |
| 22 | + | PruneRegistry: true, | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 7 | 26 | // ImagePruneConfig defines scheduled image cleanup behavior. | |
| 8 | 27 | type ImagePruneConfig struct { | |
| 9 | 28 | Enabled bool | |
| @@ -4,6 +4,25 @@ import "time" | |||||
| 4 | 4 | ||||
| 5 | const DefaultImagePruneKeepLast = 3 | 5 | const DefaultImagePruneKeepLast = 3 | ||
| 6 | 6 | ||||
| 7 | + | // ImagePruneOptions controls which prune subsystems are activated. | |||
| 8 | + | type ImagePruneOptions struct { | |||
| 9 | + | // KeepLast is the number of most-recent non-latest tags to retain per repository. | |||
| 10 | + | KeepLast int | |||
| 11 | + | // PruneDangling enables runtime dangling image cleanup. | |||
| 12 | + | PruneDangling bool | |||
| 13 | + | // PruneRegistry enables registry tag retention and blob garbage collection. | |||
| 14 | + | PruneRegistry bool | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // DefaultImagePruneOptions returns options that prune both scopes with the default retention. | |||
| 18 | + | func DefaultImagePruneOptions() ImagePruneOptions { | |||
| 19 | + | return ImagePruneOptions{ | |||
| 20 | + | KeepLast: DefaultImagePruneKeepLast, | |||
| 21 | + | PruneDangling: true, | |||
| 22 | + | PruneRegistry: true, | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 7 | // ImagePruneConfig defines scheduled image cleanup behavior. | 26 | // ImagePruneConfig defines scheduled image cleanup behavior. | ||
| 8 | type ImagePruneConfig struct { | 27 | type ImagePruneConfig struct { | ||
| 9 | Enabled bool | 28 | Enabled bool | ||
M internal/usecase/images/service.go +23 -12
| @@ -4,6 +4,7 @@ package images | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | "encoding/json" | |
| 7 | + | "fmt" | |
| 7 | 8 | "sort" | |
| 8 | 9 | "strings" | |
| 9 | 10 | "time" | |
| @@ -331,30 +332,40 @@ func (s *Service) collectKeptTagDigests( | |||
| 331 | 332 | return nil | |
| 332 | 333 | } | |
| 333 | 334 | ||
| 334 | - | // Prune runs runtime prune and registry prune and aggregates reports. | |
| 335 | - | func (s *Service) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | |
| 335 | + | // Prune runs runtime and/or registry prune based on options and aggregates reports. | |
| 336 | + | func (s *Service) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { | |
| 336 | 337 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 337 | 338 | zerowrap.FieldLayer: "usecase", | |
| 338 | 339 | zerowrap.FieldUseCase: "Prune", | |
| 339 | - | "keepLast": keepLast, | |
| 340 | + | "keepLast": opts.KeepLast, | |
| 341 | + | "pruneDangling": opts.PruneDangling, | |
| 342 | + | "pruneRegistry": opts.PruneRegistry, | |
| 340 | 343 | }) | |
| 341 | 344 | log := zerowrap.FromCtx(ctx) | |
| 342 | 345 | ||
| 343 | 346 | var report domain.ImagePruneReport | |
| 344 | 347 | ||
| 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 | |
| 348 | + | if opts.PruneDangling { | |
| 349 | + | runtimeReport, err := s.PruneRuntime(ctx) | |
| 350 | + | if err != nil { | |
| 351 | + | if !opts.PruneRegistry { | |
| 352 | + | // Dangling-only: surface the error to the caller. | |
| 353 | + | return report, fmt.Errorf("runtime prune failed: %w", err) | |
| 354 | + | } | |
| 355 | + | log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune") | |
| 356 | + | } else { | |
| 357 | + | report.Runtime = runtimeReport.Runtime | |
| 358 | + | } | |
| 350 | 359 | } | |
| 351 | 360 | ||
| 352 | - | registryReport, err := s.PruneRegistry(ctx, keepLast) | |
| 353 | - | if err != nil { | |
| 354 | - | return report, err | |
| 361 | + | if opts.PruneRegistry { | |
| 362 | + | registryReport, err := s.PruneRegistry(ctx, opts.KeepLast) | |
| 363 | + | if err != nil { | |
| 364 | + | return report, err | |
| 365 | + | } | |
| 366 | + | report.Registry = registryReport.Registry | |
| 355 | 367 | } | |
| 356 | 368 | ||
| 357 | - | report.Registry = registryReport.Registry | |
| 358 | 369 | return report, nil | |
| 359 | 370 | } | |
| 360 | 371 | ||
| @@ -4,6 +4,7 @@ package images | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | + | "fmt" | |||
| 7 | "sort" | 8 | "sort" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | "time" | 10 | "time" | ||
| @@ -331,30 +332,40 @@ func (s *Service) collectKeptTagDigests( | |||||
| 331 | return nil | 332 | return nil | ||
| 332 | } | 333 | } | ||
| 333 | 334 | ||||
| 334 | - | // Prune runs runtime prune and registry prune and aggregates reports. | 335 | + | // 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) { | 336 | + | func (s *Service) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { |
| 336 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 337 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 337 | zerowrap.FieldLayer: "usecase", | 338 | zerowrap.FieldLayer: "usecase", | ||
| 338 | zerowrap.FieldUseCase: "Prune", | 339 | zerowrap.FieldUseCase: "Prune", | ||
| 339 | - | "keepLast": keepLast, | 340 | + | "keepLast": opts.KeepLast, |
| 341 | + | "pruneDangling": opts.PruneDangling, | |||
| 342 | + | "pruneRegistry": opts.PruneRegistry, | |||
| 340 | }) | 343 | }) | ||
| 341 | log := zerowrap.FromCtx(ctx) | 344 | log := zerowrap.FromCtx(ctx) | ||
| 342 | 345 | ||||
| 343 | var report domain.ImagePruneReport | 346 | var report domain.ImagePruneReport | ||
| 344 | 347 | ||||
| 345 | - | runtimeReport, err := s.PruneRuntime(ctx) | 348 | + | if opts.PruneDangling { |
| 346 | - | if err != nil { | 349 | + | runtimeReport, err := s.PruneRuntime(ctx) |
| 347 | - | log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune") | 350 | + | if err != nil { |
| 348 | - | } else { | 351 | + | if !opts.PruneRegistry { |
| 349 | - | report.Runtime = runtimeReport.Runtime | 352 | + | // Dangling-only: surface the error to the caller. |
| 353 | + | return report, fmt.Errorf("runtime prune failed: %w", err) | |||
| 354 | + | } | |||
| 355 | + | log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune") | |||
| 356 | + | } else { | |||
| 357 | + | report.Runtime = runtimeReport.Runtime | |||
| 358 | + | } | |||
| 350 | } | 359 | } | ||
| 351 | 360 | ||||
| 352 | - | registryReport, err := s.PruneRegistry(ctx, keepLast) | 361 | + | if opts.PruneRegistry { |
| 353 | - | if err != nil { | 362 | + | registryReport, err := s.PruneRegistry(ctx, opts.KeepLast) |
| 354 | - | return report, err | 363 | + | if err != nil { |
| 364 | + | return report, err | |||
| 365 | + | } | |||
| 366 | + | report.Registry = registryReport.Registry | |||
| 355 | } | 367 | } | ||
| 356 | 368 | ||||
| 357 | - | report.Registry = registryReport.Registry | |||
| 358 | return report, nil | 369 | return report, nil | ||
| 359 | } | 370 | } | ||
| 360 | 371 | ||||
M internal/usecase/images/service_test.go +64 -2
| @@ -469,7 +469,9 @@ func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | |||
| 469 | 469 | ||
| 470 | 470 | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 471 | 471 | ||
| 472 | - | report, err := svc.Prune(context.Background(), 1) | |
| 472 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 473 | + | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |
| 474 | + | }) | |
| 473 | 475 | ||
| 474 | 476 | require.NoError(t, err) | |
| 475 | 477 | assert.Equal(t, 1, report.Runtime.DeletedCount) | |
| @@ -494,7 +496,9 @@ func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) { | |||
| 494 | 496 | ||
| 495 | 497 | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 496 | 498 | ||
| 497 | - | report, err := svc.Prune(context.Background(), 1) | |
| 499 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 500 | + | KeepLast: 1, PruneDangling: true, PruneRegistry: true, | |
| 501 | + | }) | |
| 498 | 502 | ||
| 499 | 503 | require.NoError(t, err) | |
| 500 | 504 | assert.Equal(t, 0, report.Runtime.DeletedCount) | |
| @@ -505,6 +509,64 @@ func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) { | |||
| 505 | 509 | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | |
| 506 | 510 | } | |
| 507 | 511 | ||
| 512 | + | func TestService_Prune_DanglingOnlySkipsRegistry(t *testing.T) { | |
| 513 | + | manifestStorage := newFakeManifestStorage() | |
| 514 | + | manifestStorage.repositories = []string{"gordon/api"} | |
| 515 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | |
| 516 | + | blobStorage := &fakeBlobStorage{} | |
| 517 | + | rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 512}} | |
| 518 | + | ||
| 519 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 520 | + | ||
| 521 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 522 | + | KeepLast: 3, PruneDangling: true, PruneRegistry: false, | |
| 523 | + | }) | |
| 524 | + | ||
| 525 | + | require.NoError(t, err) | |
| 526 | + | assert.True(t, rt.pruneCalled, "runtime prune should have been called") | |
| 527 | + | assert.Equal(t, 1, report.Runtime.DeletedCount) | |
| 528 | + | assert.Equal(t, 0, report.Registry.TagsRemoved, "registry should not be pruned") | |
| 529 | + | assert.Equal(t, 0, manifestStorage.listRepositoriesCalls, "should not list repositories when registry disabled") | |
| 530 | + | } | |
| 531 | + | ||
| 532 | + | func TestService_Prune_DanglingOnlyRuntimeFailureReturnsError(t *testing.T) { | |
| 533 | + | rt := &fakeRuntime{pruneErr: errors.New("docker daemon unavailable")} | |
| 534 | + | svc := NewService(rt, newFakeManifestStorage(), &fakeBlobStorage{}, zerowrap.Default()) | |
| 535 | + | ||
| 536 | + | _, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 537 | + | KeepLast: 3, PruneDangling: true, PruneRegistry: false, | |
| 538 | + | }) | |
| 539 | + | ||
| 540 | + | require.Error(t, err) | |
| 541 | + | assert.Contains(t, err.Error(), "runtime prune failed") | |
| 542 | + | assert.Contains(t, err.Error(), "docker daemon unavailable") | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) { | |
| 546 | + | manifestStorage := newFakeManifestStorage() | |
| 547 | + | manifestStorage.repositories = []string{"gordon/api"} | |
| 548 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |
| 549 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 550 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 551 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 552 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 553 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |
| 554 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |
| 555 | + | rt := &fakeRuntime{} | |
| 556 | + | ||
| 557 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |
| 558 | + | ||
| 559 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |
| 560 | + | KeepLast: 1, PruneDangling: false, PruneRegistry: true, | |
| 561 | + | }) | |
| 562 | + | ||
| 563 | + | require.NoError(t, err) | |
| 564 | + | assert.False(t, rt.pruneCalled, "runtime prune should not have been called") | |
| 565 | + | assert.Equal(t, 0, report.Runtime.DeletedCount) | |
| 566 | + | assert.Equal(t, 1, report.Registry.TagsRemoved) | |
| 567 | + | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |
| 568 | + | } | |
| 569 | + | ||
| 508 | 570 | type fakeRuntime struct { | |
| 509 | 571 | pkgruntime.Runtime | |
| 510 | 572 | ||
| @@ -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,64 @@ 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_DanglingOnlyRuntimeFailureReturnsError(t *testing.T) { | |||
| 533 | + | rt := &fakeRuntime{pruneErr: errors.New("docker daemon unavailable")} | |||
| 534 | + | svc := NewService(rt, newFakeManifestStorage(), &fakeBlobStorage{}, zerowrap.Default()) | |||
| 535 | + | ||||
| 536 | + | _, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |||
| 537 | + | KeepLast: 3, PruneDangling: true, PruneRegistry: false, | |||
| 538 | + | }) | |||
| 539 | + | ||||
| 540 | + | require.Error(t, err) | |||
| 541 | + | assert.Contains(t, err.Error(), "runtime prune failed") | |||
| 542 | + | assert.Contains(t, err.Error(), "docker daemon unavailable") | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) { | |||
| 546 | + | manifestStorage := newFakeManifestStorage() | |||
| 547 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 548 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |||
| 549 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 550 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |||
| 551 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |||
| 552 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |||
| 553 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |||
| 554 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |||
| 555 | + | rt := &fakeRuntime{} | |||
| 556 | + | ||||
| 557 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 558 | + | ||||
| 559 | + | report, err := svc.Prune(context.Background(), domain.ImagePruneOptions{ | |||
| 560 | + | KeepLast: 1, PruneDangling: false, PruneRegistry: true, | |||
| 561 | + | }) | |||
| 562 | + | ||||
| 563 | + | require.NoError(t, err) | |||
| 564 | + | assert.False(t, rt.pruneCalled, "runtime prune should not have been called") | |||
| 565 | + | assert.Equal(t, 0, report.Runtime.DeletedCount) | |||
| 566 | + | assert.Equal(t, 1, report.Registry.TagsRemoved) | |||
| 567 | + | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |||
| 568 | + | } | |||
| 569 | + | ||||
| 508 | type fakeRuntime struct { | 570 | type fakeRuntime struct { | ||
| 509 | pkgruntime.Runtime | 571 | pkgruntime.Runtime | ||
| 510 | 572 | ||||