v2.31.0-dev3 cbca441

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 := 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 := resolveControlPlane(configPath)
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}

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