1package cli
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "sort"
8 "strings"
9 "time"
10
11 "github.com/charmbracelet/bubbles/spinner"
12 "github.com/charmbracelet/lipgloss"
13 "github.com/spf13/cobra"
14
15 "github.com/bnema/gordon/internal/adapters/dto"
16 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20type imagesClient interface {
21 ListImages(ctx context.Context) ([]dto.Image, error)
22 PruneImages(ctx context.Context, req dto.ImagePruneRequest) (*dto.ImagePruneResponse, error)
23}
24
25type imagesPruneOptions struct {
26 DryRun bool
27 KeepReleases int
28 Dangling bool // scope flag: prune dangling runtime images
29 Registry bool // scope flag: prune registry tags
30 NoConfirm bool
31}
32
33type prunePreview struct {
34 danglingRuntimeCount int
35 registryTagsToPrune int
36}
37
38// pruneConfirmFunc is the confirmation callback used before destructive prune.
39// It is a package-level variable so tests can replace it without spawning a
40// real Bubble Tea program.
41var pruneConfirmFunc = defaultPruneConfirm
42
43func defaultPruneConfirm(prompt string) (bool, error) {
44 return components.RunConfirm(prompt, components.WithDefaultYes())
45}
46
47// Column widths are fixed to keep table output predictable across terminals.
48// Values are chosen for typical repository names and SHA-prefixed IDs;
49// the table component truncates with ellipsis when content overflows.
50const (
51 imagesListRepositoryColumnWidth = 44
52 imagesListTagColumnWidth = 14
53 imagesListSizeColumnWidth = 10
54 imagesListCreatedColumnWidth = 22
55 imagesListImageIDColumnWidth = 22
56 imagesListDanglingColumnWidth = 10
57)
58
59var imagesListTableColumns = []components.TableColumn{
60 {Title: "REPOSITORY", Width: imagesListRepositoryColumnWidth},
61 {Title: "TAG", Width: imagesListTagColumnWidth},
62 {Title: "SIZE", Width: imagesListSizeColumnWidth},
63 {Title: "CREATED", Width: imagesListCreatedColumnWidth},
64 {Title: "IMAGE_ID", Width: imagesListImageIDColumnWidth},
65 {Title: "DANGLING", Width: imagesListDanglingColumnWidth},
66}
67
68func newImagesCmd() *cobra.Command {
69 cmd := &cobra.Command{
70 Use: "images",
71 Short: "List and prune images",
72 Long: `Inspect and clean up runtime and registry images.
73
74These commands currently require remote mode with a configured target.`,
75 }
76
77 cmd.AddCommand(newImagesListCmd())
78 cmd.AddCommand(newImagesPruneCmd())
79 cmd.AddCommand(newImagesTagsCmd())
80
81 return cmd
82}
83
84func newImagesListCmd() *cobra.Command {
85 var jsonOut bool
86
87 cmd := &cobra.Command{
88 Use: "list",
89 Short: "List runtime images and registry tags",
90 RunE: func(cmd *cobra.Command, _ []string) error {
91 client, isRemote := GetRemoteClient()
92 if !isRemote {
93 return fmt.Errorf("images commands require a configured remote target")
94 }
95
96 return runImagesList(cmd.Context(), client, cmd.OutOrStdout(), jsonOut)
97 },
98 }
99
100 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
101
102 return cmd
103}
104
105func newImagesPruneCmd() *cobra.Command {
106 var opts imagesPruneOptions
107
108 cmd := &cobra.Command{
109 Use: "prune",
110 Short: "Prune unused images",
111 Long: `Remove dangling runtime images and/or old registry tags.
112
113By default both runtime and registry cleanup run, keeping latest + 3 previous
114release tags per repository. Use --dangling or --registry to restrict scope.`,
115 RunE: func(cmd *cobra.Command, _ []string) error {
116 client, isRemote := GetRemoteClient()
117 if !isRemote {
118 return fmt.Errorf("images commands require a configured remote target")
119 }
120
121 return runImagesPrune(cmd.Context(), client, opts, cmd.OutOrStdout())
122 },
123 }
124
125 cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes")
126 cmd.Flags().IntVar(&opts.KeepReleases, "keep-releases", domain.DefaultImagePruneKeepLast,
127 "Number of previous non-latest tags to keep per repository (latest is always kept)")
128 cmd.Flags().BoolVar(&opts.Dangling, "dangling", false, "Include dangling runtime images (default: both scopes)")
129 cmd.Flags().BoolVar(&opts.Registry, "registry", false, "Include old registry tags (default: both scopes)")
130 cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "Skip confirmation prompt")
131
132 return cmd
133}
134
135func newImagesTagsCmd() *cobra.Command {
136 var jsonOut bool
137
138 cmd := &cobra.Command{
139 Use: "tags <repository>",
140 Short: "List registry tags for a repository",
141 Long: `Display all tags in the Gordon registry for a given repository.
142
143Examples:
144 gordon images tags myapp
145 gordon images tags myapp --json
146 gordon images tags myapp --remote https://gordon.mydomain.com --token $TOKEN`,
147 Args: cobra.ExactArgs(1),
148 RunE: func(cmd *cobra.Command, args []string) error {
149 ctx := cmd.Context()
150 handle, err := resolveControlPlane(configPath)
151 if err != nil {
152 return err
153 }
154 defer handle.close()
155 return runImagesTags(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
156 },
157 }
158
159 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
160
161 return cmd
162}
163
164func runImagesTags(ctx context.Context, cp ControlPlane, out io.Writer, repository string, jsonOut bool) error {
165 tags, err := cp.ListTags(ctx, repository)
166 if err != nil {
167 return fmt.Errorf("failed to list tags: %w", err)
168 }
169
170 if jsonOut {
171 if tags == nil {
172 tags = []string{}
173 }
174 payload := map[string]any{
175 "repository": repository,
176 "tags": tags,
177 }
178 return writeJSON(out, payload)
179 }
180
181 if len(tags) == 0 {
182 return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No tags found for %s", repository)))
183 }
184
185 if err := cliWriteLine(out, cliRenderTitle(fmt.Sprintf("Tags for %s", repository))); err != nil {
186 return err
187 }
188 if err := cliWriteLine(out, ""); err != nil {
189 return err
190 }
191
192 for _, tag := range tags {
193 if err := cliWriteLine(out, cliRenderListItem(tag)); err != nil {
194 return err
195 }
196 }
197
198 if err := cliWriteLine(out, ""); err != nil {
199 return err
200 }
201 return cliWritef(out, "Total: %d tags\n", len(tags))
202}
203
204// resolvePruneScopes determines which prune subsystems are active.
205// No scope flags → both true. Any scope flag present → only selected scopes.
206func resolvePruneScopes(opts imagesPruneOptions) (pruneDangling, pruneRegistry bool) {
207 if !opts.Dangling && !opts.Registry {
208 return true, true
209 }
210 return opts.Dangling, opts.Registry
211}
212
213func pruneWithSpinner(ctx context.Context, client imagesClient, req dto.ImagePruneRequest, pruneDangling, pruneRegistry bool) (*dto.ImagePruneResponse, error) {
214 if !isInteractiveTerminal() {
215 return client.PruneImages(ctx, req)
216 }
217
218 type pruneResult struct {
219 resp *dto.ImagePruneResponse
220 err error
221 }
222
223 done := make(chan pruneResult, 1)
224 go func() {
225 resp, err := client.PruneImages(ctx, req)
226 done <- pruneResult{resp: resp, err: err}
227 }()
228
229 // Build spinner message based on scopes
230 msg := "Pruning images"
231 if pruneDangling && pruneRegistry {
232 msg = "Pruning runtime and registry images"
233 } else if pruneDangling {
234 msg = "Pruning runtime images"
235 } else if pruneRegistry {
236 msg = "Pruning registry images"
237 }
238
239 model := components.NewSpinner(
240 components.WithMessage(msg),
241 components.WithSpinnerType(components.SpinnerMiniDot),
242 )
243
244 for {
245 select {
246 case result := <-done:
247 if result.err != nil {
248 return nil, result.err
249 }
250 if result.resp == nil {
251 return nil, fmt.Errorf("prune operation completed but no response received")
252 }
253 // Success - set final message and render
254 model.SetMessage(cliRenderSuccess("Pruning complete"))
255 updatedModel, _ := model.Update(spinner.TickMsg{})
256 if m, ok := updatedModel.(components.SpinnerModel); ok {
257 fmt.Print("\r" + m.View() + "\n")
258 }
259 return result.resp, nil
260 case <-time.After(100 * time.Millisecond):
261 updatedModel, _ := model.Update(spinner.TickMsg{})
262 if m, ok := updatedModel.(components.SpinnerModel); ok {
263 model = m
264 fmt.Print("\r" + m.View())
265 }
266 }
267 }
268}
269
270func runImagesList(ctx context.Context, client imagesClient, out io.Writer, jsonOut bool) error {
271 images, err := client.ListImages(ctx)
272 if err != nil {
273 return fmt.Errorf("failed to list images: %w", err)
274 }
275
276 if jsonOut {
277 return writeJSON(out, images)
278 }
279
280 if len(images) == 0 {
281 if err := cliWriteLine(out, cliRenderMuted("No images found")); err != nil {
282 return err
283 }
284 err := cliWriteLine(out, cliRenderInfo("Total images: 0 (dangling: 0)"))
285 return err
286 }
287
288 if err := cliWriteLine(out, cliRenderTitle("Images")); err != nil {
289 return err
290 }
291
292 danglingCount := 0
293 rows := make([][]string, 0, len(images))
294 for _, img := range images {
295 dangling := "no"
296 if img.Dangling {
297 dangling = "yes"
298 danglingCount++
299 }
300
301 rows = append(rows, []string{
302 img.Repository,
303 img.Tag,
304 formatImageSize(img.Size),
305 formatImageCreatedAt(img.Created),
306 formatImageID(img.ID),
307 dangling,
308 })
309 }
310
311 table := components.NewTable(
312 components.WithColumns(imagesListTableColumns),
313 components.WithRows(rows),
314 components.WithHeaderStyle(lipgloss.NewStyle().Bold(true)),
315 components.WithCellStyle(lipgloss.NewStyle()),
316 )
317
318 if err := cliWriteLine(out, table.View()); err != nil {
319 return err
320 }
321
322 err = cliWritef(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount)
323 return err
324}
325
326func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOptions, out io.Writer) error {
327 if opts.KeepReleases < 0 {
328 return fmt.Errorf("--keep-releases must be >= 0")
329 }
330
331 pruneDangling, pruneRegistry := resolvePruneScopes(opts)
332
333 if opts.DryRun {
334 return runImagesPruneDryRun(ctx, client, opts, pruneDangling, pruneRegistry, out)
335 }
336
337 // Confirmation prompt for destructive operations.
338 if !opts.NoConfirm {
339 preview, err := loadPrunePreview(ctx, client, opts.KeepReleases, pruneDangling, pruneRegistry)
340 if err != nil {
341 return fmt.Errorf("failed to load prune preview: %w", err)
342 }
343
344 confirmed, err := pruneConfirmFunc(renderPruneConfirmPrompt(preview, opts.KeepReleases, pruneDangling, pruneRegistry))
345 if err != nil {
346 return fmt.Errorf("confirmation failed: %w", err)
347 }
348 if !confirmed {
349 return cliWriteLine(out, cliRenderMuted("Prune cancelled"))
350 }
351 }
352
353 req := buildPruneRequest(opts.KeepReleases, pruneDangling, pruneRegistry)
354
355 resp, err := pruneWithSpinner(ctx, client, req, pruneDangling, pruneRegistry)
356 if err != nil {
357 return fmt.Errorf("failed to prune images: %w", err)
358 }
359 if resp == nil {
360 return fmt.Errorf("failed to prune images: empty response")
361 }
362
363 if pruneDangling {
364 if err := cliWritef(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {
365 return err
366 }
367 } else {
368 if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
369 return err
370 }
371 }
372
373 if pruneRegistry {
374 err = cliWritef(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)
375 return err
376 }
377
378 err = cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))
379 return err
380}
381
382func runImagesPruneDryRun(ctx context.Context, client imagesClient, opts imagesPruneOptions, pruneDangling, pruneRegistry bool, out io.Writer) error {
383 if err := cliWriteLine(out, cliRenderWarning("Dry run: no changes applied")); err != nil {
384 return err
385 }
386
387 if pruneDangling {
388 images, err := client.ListImages(ctx)
389 if err != nil {
390 return fmt.Errorf("failed to list images: %w", err)
391 }
392
393 danglingCount := 0
394 for _, img := range images {
395 if img.Dangling {
396 danglingCount++
397 }
398 }
399
400 if err := cliWritef(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {
401 return err
402 }
403 } else {
404 if err := cliWriteLine(out, cliRenderMuted("Runtime cleanup skipped (--registry)")); err != nil {
405 return err
406 }
407 }
408
409 if pruneRegistry {
410 return cliWritef(out, "Registry: would keep latest + %d previous tags per repository\n", opts.KeepReleases)
411 }
412
413 return cliWriteLine(out, cliRenderMuted("Registry cleanup skipped (--dangling)"))
414}
415
416func loadPrunePreview(ctx context.Context, client imagesClient, keepReleases int, pruneDangling, pruneRegistry bool) (prunePreview, error) {
417 images, err := client.ListImages(ctx)
418 if err != nil {
419 return prunePreview{}, fmt.Errorf("failed to list images: %w", err)
420 }
421
422 preview := prunePreview{}
423 if pruneDangling {
424 for _, img := range images {
425 if img.Dangling {
426 preview.danglingRuntimeCount++
427 }
428 }
429 }
430
431 if pruneRegistry {
432 preview.registryTagsToPrune = estimateRegistryTagsToPrune(images, keepReleases)
433 }
434
435 return preview, nil
436}
437
438func renderPruneConfirmPrompt(preview prunePreview, keepReleases int, pruneDangling, pruneRegistry bool) string {
439 lines := []string{"Prune images? This cannot be undone.", ""}
440
441 if pruneDangling {
442 lines = append(lines, fmt.Sprintf("Runtime: would prune %d dangling runtime images", preview.danglingRuntimeCount))
443 } else {
444 lines = append(lines, "Runtime: cleanup skipped (--registry)")
445 }
446
447 if pruneRegistry {
448 lines = append(lines, fmt.Sprintf(
449 "Registry: would prune %d tags (keep latest + %d previous tags per repository)",
450 preview.registryTagsToPrune,
451 keepReleases,
452 ))
453 } else {
454 lines = append(lines, "Registry: cleanup skipped (--dangling)")
455 }
456
457 return strings.Join(lines, "\n")
458}
459
460type previewRegistryTag struct {
461 name string
462 created time.Time
463}
464
465func estimateRegistryTagsToPrune(images []dto.Image, keepReleases int) int {
466 tagsByRepo := collectTagsByRepository(images)
467 removed := 0
468
469 for _, repoTags := range tagsByRepo {
470 removed += countTagsToRemove(repoTags, keepReleases)
471 }
472
473 return removed
474}
475
476// collectTagsByRepository groups images by repository and returns
477// map of repo -> tag name -> creation time
478func collectTagsByRepository(images []dto.Image) map[string]map[string]time.Time {
479 tagsByRepo := make(map[string]map[string]time.Time)
480
481 for _, img := range images {
482 repo := strings.TrimSpace(img.Repository)
483 tag := strings.TrimSpace(img.Tag)
484 if repo == "" || tag == "" || repo == "<none>" || tag == "<none>" {
485 continue
486 }
487
488 if _, ok := tagsByRepo[repo]; !ok {
489 tagsByRepo[repo] = make(map[string]time.Time)
490 }
491
492 repoTags := tagsByRepo[repo]
493 if current, exists := repoTags[tag]; !exists || img.Created.After(current) {
494 repoTags[tag] = img.Created
495 }
496 }
497
498 return tagsByRepo
499}
500
501// countTagsToRemove counts how many tags in a repository would be removed
502// based on keepReleases retention policy
503func countTagsToRemove(repoTags map[string]time.Time, keepReleases int) int {
504 tagInfos := toSortedTagInfos(repoTags)
505
506 kept := selectKeptTags(tagInfos, keepReleases)
507
508 return len(tagInfos) - len(kept)
509}
510
511// toSortedTagInfos converts repo tags map to sorted slice
512func toSortedTagInfos(repoTags map[string]time.Time) []previewRegistryTag {
513 tagInfos := make([]previewRegistryTag, 0, len(repoTags))
514 for name, created := range repoTags {
515 tagInfos = append(tagInfos, previewRegistryTag{name: name, created: created})
516 }
517
518 sort.Slice(tagInfos, func(i, j int) bool {
519 if tagInfos[i].created.Equal(tagInfos[j].created) {
520 return tagInfos[i].name > tagInfos[j].name
521 }
522 return tagInfos[i].created.After(tagInfos[j].created)
523 })
524
525 return tagInfos
526}
527
528// selectKeptTags returns which tags would be kept based on
529// retention policy (latest + keepReleases non-latest)
530func selectKeptTags(tagInfos []previewRegistryTag, keepReleases int) map[string]struct{} {
531 kept := make(map[string]struct{})
532
533 // Always keep latest if present
534 for _, info := range tagInfos {
535 if info.name == "latest" {
536 kept["latest"] = struct{}{}
537 break
538 }
539 }
540
541 // Keep most recent non-latest tags
542 keptCount := 0
543 for _, info := range tagInfos {
544 if info.name == "latest" {
545 continue
546 }
547 if keptCount >= keepReleases {
548 break
549 }
550 kept[info.name] = struct{}{}
551 keptCount++
552 }
553
554 return kept
555}
556
557func buildPruneRequest(keepReleases int, pruneDangling, pruneRegistry bool) dto.ImagePruneRequest {
558 return dto.ImagePruneRequest{
559 KeepLast: &keepReleases,
560 PruneDangling: &pruneDangling,
561 PruneRegistry: &pruneRegistry,
562 }
563}
564
565func formatImageCreatedAt(t time.Time) string {
566 if t.IsZero() {
567 return "-"
568 }
569 return t.UTC().Format("2006-01-02T15:04:05Z")
570}
571
572func formatImageSize(size int64) string {
573 if size <= 0 {
574 return "-"
575 }
576
577 const unit = 1024
578 if size < unit {
579 return fmt.Sprintf("%d B", size)
580 }
581 div, exp := int64(unit), 0
582 for n := size / unit; n >= unit; n /= unit {
583 div *= unit
584 exp++
585 }
586 return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
587}
588
589func formatImageID(id string) string {
590 if strings.TrimSpace(id) == "" {
591 return "-"
592 }
593
594 return id
595}