v2.16.0 ebd2ab3

images.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"fmt"
6	"io"
7	"strings"
8	"text/tabwriter"
9	"time"
10
11	"github.com/spf13/cobra"
12
13	"github.com/bnema/gordon/internal/adapters/dto"
14)
15
16type imagesClient interface {
17	ListImages(ctx context.Context) ([]dto.Image, error)
18	PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error)
19}
20
21type imagesPruneOptions struct {
22	DryRun      bool
23	KeepLast    int
24	RuntimeOnly bool
25}
26
27func newImagesCmd() *cobra.Command {
28	cmd := &cobra.Command{
29		Use:   "images",
30		Short: "List and prune images",
31		Long: `Inspect and clean up runtime and registry images.
32
33These commands currently require remote mode with a configured target.`,
34	}
35
36	cmd.AddCommand(newImagesListCmd())
37	cmd.AddCommand(newImagesPruneCmd())
38
39	return cmd
40}
41
42func newImagesListCmd() *cobra.Command {
43	return &cobra.Command{
44		Use:   "list",
45		Short: "List runtime images and registry tags",
46		RunE: func(cmd *cobra.Command, _ []string) error {
47			client, isRemote := GetRemoteClient()
48			if !isRemote {
49				return fmt.Errorf("images commands require a configured remote target")
50			}
51
52			return runImagesList(cmd.Context(), client, cmd.OutOrStdout())
53		},
54	}
55}
56
57func newImagesPruneCmd() *cobra.Command {
58	var opts imagesPruneOptions
59
60	cmd := &cobra.Command{
61		Use:   "prune",
62		Short: "Prune unused images",
63		RunE: func(cmd *cobra.Command, _ []string) error {
64			client, isRemote := GetRemoteClient()
65			if !isRemote {
66				return fmt.Errorf("images commands require a configured remote target")
67			}
68
69			return runImagesPrune(cmd.Context(), client, opts, cmd.OutOrStdout())
70		},
71	}
72
73	cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes")
74	cmd.Flags().IntVar(&opts.KeepLast, "keep", 0, "Number of previous version tags to keep per repository (latest is always kept when present; 0 disables registry cleanup)")
75	cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)")
76
77	return cmd
78}
79
80func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error {
81	images, err := client.ListImages(ctx)
82	if err != nil {
83		return fmt.Errorf("failed to list images: %w", err)
84	}
85
86	if len(images) == 0 {
87		if _, err := fmt.Fprintln(out, "No images found"); err != nil {
88			return err
89		}
90		_, err := fmt.Fprintln(out, "Total images: 0 (dangling: 0)")
91		return err
92	}
93
94	w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
95	if _, err := fmt.Fprintln(w, "REPOSITORY\tTAG\tSIZE\tCREATED\tIMAGE_ID\tDANGLING"); err != nil {
96		return err
97	}
98
99	danglingCount := 0
100	for _, img := range images {
101		dangling := "no"
102		if img.Dangling {
103			dangling = "yes"
104			danglingCount++
105		}
106
107		if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), formatImageID(img.ID), dangling); err != nil {
108			return err
109		}
110	}
111
112	if err := w.Flush(); err != nil {
113		return err
114	}
115
116	_, err = fmt.Fprintf(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount)
117	return err
118}
119
120func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOptions, out io.Writer) error {
121	if opts.KeepLast < 0 {
122		return fmt.Errorf("--keep must be >= 0")
123	}
124
125	keepLast := opts.KeepLast
126	if opts.RuntimeOnly {
127		keepLast = 0
128	}
129
130	if opts.DryRun {
131		images, err := client.ListImages(ctx)
132		if err != nil {
133			return fmt.Errorf("failed to list images: %w", err)
134		}
135
136		danglingCount := 0
137		for _, img := range images {
138			if img.Dangling {
139				danglingCount++
140			}
141		}
142
143		if _, err := fmt.Fprintln(out, "Dry run: no changes applied"); err != nil {
144			return err
145		}
146		if _, err := fmt.Fprintf(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {
147			return err
148		}
149		if opts.RuntimeOnly {
150			_, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)")
151			return err
152		}
153		if keepLast == 0 {
154			_, err = fmt.Fprintln(out, "Registry cleanup skipped (--keep=0)")
155			return err
156		}
157
158		_, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast)
159		return err
160	}
161
162	resp, err := client.PruneImages(ctx, keepLast)
163	if err != nil {
164		return fmt.Errorf("failed to prune images: %w", err)
165	}
166	if resp == nil {
167		return fmt.Errorf("failed to prune images: empty response")
168	}
169
170	if _, err := fmt.Fprintf(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {
171		return err
172	}
173
174	if opts.RuntimeOnly {
175		_, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)")
176		return err
177	}
178
179	_, err = fmt.Fprintf(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)
180	return err
181}
182
183func formatImageCreatedAt(t time.Time) string {
184	if t.IsZero() {
185		return "-"
186	}
187	return t.UTC().Format("2006-01-02T15:04:05Z")
188}
189
190func formatImageSize(size int64) string {
191	if size <= 0 {
192		return "-"
193	}
194
195	const unit = 1024
196	if size < unit {
197		return fmt.Sprintf("%d B", size)
198	}
199	div, exp := int64(unit), 0
200	for n := size / unit; n >= unit; n /= unit {
201		div *= unit
202		exp++
203	}
204	return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
205}
206
207func formatImageID(id string) string {
208	if strings.TrimSpace(id) == "" {
209		return "-"
210	}
211
212	return id
213}

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