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