v2.31.0 e02f654

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: "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}

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