v2.15.0 cf76057

images.go RawHistoryFolder

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

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