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