v2.30.0-pre4 15d9fe3

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

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