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