v3.4.1 de8f68e

images.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close