detached 352a4ea

feat(cli): add remote image list and prune commands

commit
352a4ea8f241608c8476da7033afe38d36b2c097
author
brice · · 2026-02-08 06:48 UTC
committer
brice · · 2026-02-08 09:40 UTC
parent
4677f2d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +441 -0
A internal/adapters/in/cli/images.go +200 -0
@@ -0,0 +1,200 @@
1+package cli
2+
3+import (
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+
15+type imagesClient interface {
16+ ListImages(ctx context.Context) ([]dto.Image, error)
17+ PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error)
18+}
19+
20+type imagesPruneOptions struct {
21+ DryRun bool
22+ KeepLast int
23+ RuntimeOnly bool
24+}
25+
26+func 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+
32+These 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+
41+func 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+
56+func 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+
79+func 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+
119+func 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+
182+func formatImageCreatedAt(t time.Time) string {
183+ if t.IsZero() {
184+ return "-"
185+ }
186+ return t.UTC().Format("2006-01-02T15:04:05Z")
187+}
188+
189+func 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+}
@@ -0,0 +1,200 @@
1+package cli
2+
3+import (
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+
15+type imagesClient interface {
16+ ListImages(ctx context.Context) ([]dto.Image, error)
17+ PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error)
18+}
19+
20+type imagesPruneOptions struct {
21+ DryRun bool
22+ KeepLast int
23+ RuntimeOnly bool
24+}
25+
26+func 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+
32+These 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+
41+func 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+
56+func 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+
79+func 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+
119+func 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+
182+func formatImageCreatedAt(t time.Time) string {
183+ if t.IsZero() {
184+ return "-"
185+ }
186+ return t.UTC().Format("2006-01-02T15:04:05Z")
187+}
188+
189+func 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+}
A internal/adapters/in/cli/images_test.go +158 -0
@@ -0,0 +1,158 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/dto"
14+)
15+
16+type fakeImagesClient struct {
17+ listImagesResp []dto.Image
18+ listImagesErr error
19+ pruneResp *dto.ImagePruneResponse
20+ pruneErr error
21+
22+ listImagesCalls int
23+ pruneCalls int
24+ lastPruneKeep int
25+}
26+
27+func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) {
28+ f.listImagesCalls++
29+ if f.listImagesErr != nil {
30+ return nil, f.listImagesErr
31+ }
32+ return f.listImagesResp, nil
33+}
34+
35+func (f *fakeImagesClient) PruneImages(_ context.Context, keepLast int) (*dto.ImagePruneResponse, error) {
36+ f.pruneCalls++
37+ f.lastPruneKeep = keepLast
38+ if f.pruneErr != nil {
39+ return nil, f.pruneErr
40+ }
41+ return f.pruneResp, nil
42+}
43+
44+func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
45+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
46+ client := &fakeImagesClient{
47+ listImagesResp: []dto.Image{
48+ {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
49+ {Repository: "<none>", Tag: "<none>", Size: 512_000, Created: createdAt, ID: "sha256:2222", Dangling: true},
50+ },
51+ }
52+
53+ var out bytes.Buffer
54+ err := runImagesList(context.Background(), client, &out)
55+ require.NoError(t, err)
56+
57+ text := out.String()
58+ assert.Contains(t, text, "REPOSITORY")
59+ assert.Contains(t, text, "registry.example.com/app")
60+ assert.Contains(t, text, "latest")
61+ assert.Contains(t, text, "Total images: 2 (dangling: 1)")
62+ assert.Equal(t, 1, client.listImagesCalls)
63+}
64+
65+func TestRunImagesList_EmptyOutput(t *testing.T) {
66+ client := &fakeImagesClient{listImagesResp: []dto.Image{}}
67+
68+ var out bytes.Buffer
69+ err := runImagesList(context.Background(), client, &out)
70+ require.NoError(t, err)
71+
72+ text := out.String()
73+ assert.Contains(t, text, "No images found")
74+ assert.Contains(t, text, "Total images: 0")
75+ assert.Equal(t, 1, client.listImagesCalls)
76+}
77+
78+func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) {
79+ client := &fakeImagesClient{
80+ listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}},
81+ }
82+
83+ var out bytes.Buffer
84+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 3}, &out)
85+ require.NoError(t, err)
86+
87+ text := out.String()
88+ assert.Contains(t, text, "Dry run")
89+ assert.Contains(t, text, "would prune 2 dangling runtime images")
90+ assert.Contains(t, text, "would keep last 3 tags")
91+ assert.Equal(t, 1, client.listImagesCalls)
92+ assert.Equal(t, 0, client.pruneCalls)
93+}
94+
95+func TestRunImagesPrune_DryRunKeepZeroSkipsRegistry(t *testing.T) {
96+ client := &fakeImagesClient{listImagesResp: []dto.Image{{Dangling: true}}}
97+
98+ var out bytes.Buffer
99+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 0}, &out)
100+ require.NoError(t, err)
101+
102+ text := out.String()
103+ assert.Contains(t, text, "Registry cleanup skipped (--keep=0)")
104+ assert.Equal(t, 1, client.listImagesCalls)
105+ assert.Equal(t, 0, client.pruneCalls)
106+}
107+
108+func TestRunImagesPrune_RuntimeOnlyForcesKeepZero(t *testing.T) {
109+ client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}}
110+
111+ var out bytes.Buffer
112+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 9, RuntimeOnly: true}, &out)
113+ require.NoError(t, err)
114+
115+ assert.Equal(t, 1, client.pruneCalls)
116+ assert.Equal(t, 0, client.lastPruneKeep)
117+ assert.Contains(t, out.String(), "Registry cleanup skipped")
118+}
119+
120+func TestRunImagesPrune_UsesKeepFlagWhenNotRuntimeOnly(t *testing.T) {
121+ client := &fakeImagesClient{
122+ pruneResp: &dto.ImagePruneResponse{
123+ Runtime: dto.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
124+ Registry: dto.RegistryPruneResult{TagsRemoved: 3, BlobsRemoved: 1, SpaceReclaimed: 8192},
125+ },
126+ }
127+
128+ var out bytes.Buffer
129+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 5}, &out)
130+ require.NoError(t, err)
131+
132+ text := out.String()
133+ assert.Equal(t, 1, client.pruneCalls)
134+ assert.Equal(t, 5, client.lastPruneKeep)
135+ assert.Contains(t, text, "Runtime: deleted=2")
136+ assert.Contains(t, text, "Registry: tags_removed=3")
137+}
138+
139+func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) {
140+ client := &fakeImagesClient{pruneErr: errors.New("request failed")}
141+
142+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 2}, &bytes.Buffer{})
143+ require.Error(t, err)
144+ assert.Contains(t, err.Error(), "failed to prune images")
145+}
146+
147+func TestRunImagesPrune_RejectsNegativeKeep(t *testing.T) {
148+ err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepLast: -1}, &bytes.Buffer{})
149+ require.Error(t, err)
150+ assert.Contains(t, err.Error(), "--keep must be >= 0")
151+}
152+
153+func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) {
154+ client := &fakeImagesClient{pruneResp: nil}
155+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 1}, &bytes.Buffer{})
156+ require.Error(t, err)
157+ assert.Contains(t, err.Error(), "empty response")
158+}
@@ -0,0 +1,158 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/dto"
14+)
15+
16+type fakeImagesClient struct {
17+ listImagesResp []dto.Image
18+ listImagesErr error
19+ pruneResp *dto.ImagePruneResponse
20+ pruneErr error
21+
22+ listImagesCalls int
23+ pruneCalls int
24+ lastPruneKeep int
25+}
26+
27+func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) {
28+ f.listImagesCalls++
29+ if f.listImagesErr != nil {
30+ return nil, f.listImagesErr
31+ }
32+ return f.listImagesResp, nil
33+}
34+
35+func (f *fakeImagesClient) PruneImages(_ context.Context, keepLast int) (*dto.ImagePruneResponse, error) {
36+ f.pruneCalls++
37+ f.lastPruneKeep = keepLast
38+ if f.pruneErr != nil {
39+ return nil, f.pruneErr
40+ }
41+ return f.pruneResp, nil
42+}
43+
44+func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
45+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
46+ client := &fakeImagesClient{
47+ listImagesResp: []dto.Image{
48+ {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
49+ {Repository: "<none>", Tag: "<none>", Size: 512_000, Created: createdAt, ID: "sha256:2222", Dangling: true},
50+ },
51+ }
52+
53+ var out bytes.Buffer
54+ err := runImagesList(context.Background(), client, &out)
55+ require.NoError(t, err)
56+
57+ text := out.String()
58+ assert.Contains(t, text, "REPOSITORY")
59+ assert.Contains(t, text, "registry.example.com/app")
60+ assert.Contains(t, text, "latest")
61+ assert.Contains(t, text, "Total images: 2 (dangling: 1)")
62+ assert.Equal(t, 1, client.listImagesCalls)
63+}
64+
65+func TestRunImagesList_EmptyOutput(t *testing.T) {
66+ client := &fakeImagesClient{listImagesResp: []dto.Image{}}
67+
68+ var out bytes.Buffer
69+ err := runImagesList(context.Background(), client, &out)
70+ require.NoError(t, err)
71+
72+ text := out.String()
73+ assert.Contains(t, text, "No images found")
74+ assert.Contains(t, text, "Total images: 0")
75+ assert.Equal(t, 1, client.listImagesCalls)
76+}
77+
78+func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) {
79+ client := &fakeImagesClient{
80+ listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}},
81+ }
82+
83+ var out bytes.Buffer
84+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 3}, &out)
85+ require.NoError(t, err)
86+
87+ text := out.String()
88+ assert.Contains(t, text, "Dry run")
89+ assert.Contains(t, text, "would prune 2 dangling runtime images")
90+ assert.Contains(t, text, "would keep last 3 tags")
91+ assert.Equal(t, 1, client.listImagesCalls)
92+ assert.Equal(t, 0, client.pruneCalls)
93+}
94+
95+func TestRunImagesPrune_DryRunKeepZeroSkipsRegistry(t *testing.T) {
96+ client := &fakeImagesClient{listImagesResp: []dto.Image{{Dangling: true}}}
97+
98+ var out bytes.Buffer
99+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 0}, &out)
100+ require.NoError(t, err)
101+
102+ text := out.String()
103+ assert.Contains(t, text, "Registry cleanup skipped (--keep=0)")
104+ assert.Equal(t, 1, client.listImagesCalls)
105+ assert.Equal(t, 0, client.pruneCalls)
106+}
107+
108+func TestRunImagesPrune_RuntimeOnlyForcesKeepZero(t *testing.T) {
109+ client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}}
110+
111+ var out bytes.Buffer
112+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 9, RuntimeOnly: true}, &out)
113+ require.NoError(t, err)
114+
115+ assert.Equal(t, 1, client.pruneCalls)
116+ assert.Equal(t, 0, client.lastPruneKeep)
117+ assert.Contains(t, out.String(), "Registry cleanup skipped")
118+}
119+
120+func TestRunImagesPrune_UsesKeepFlagWhenNotRuntimeOnly(t *testing.T) {
121+ client := &fakeImagesClient{
122+ pruneResp: &dto.ImagePruneResponse{
123+ Runtime: dto.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
124+ Registry: dto.RegistryPruneResult{TagsRemoved: 3, BlobsRemoved: 1, SpaceReclaimed: 8192},
125+ },
126+ }
127+
128+ var out bytes.Buffer
129+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 5}, &out)
130+ require.NoError(t, err)
131+
132+ text := out.String()
133+ assert.Equal(t, 1, client.pruneCalls)
134+ assert.Equal(t, 5, client.lastPruneKeep)
135+ assert.Contains(t, text, "Runtime: deleted=2")
136+ assert.Contains(t, text, "Registry: tags_removed=3")
137+}
138+
139+func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) {
140+ client := &fakeImagesClient{pruneErr: errors.New("request failed")}
141+
142+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 2}, &bytes.Buffer{})
143+ require.Error(t, err)
144+ assert.Contains(t, err.Error(), "failed to prune images")
145+}
146+
147+func TestRunImagesPrune_RejectsNegativeKeep(t *testing.T) {
148+ err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepLast: -1}, &bytes.Buffer{})
149+ require.Error(t, err)
150+ assert.Contains(t, err.Error(), "--keep must be >= 0")
151+}
152+
153+func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) {
154+ client := &fakeImagesClient{pruneResp: nil}
155+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 1}, &bytes.Buffer{})
156+ require.Error(t, err)
157+ assert.Contains(t, err.Error(), "empty response")
158+}
M internal/adapters/in/cli/remote/client.go +36 -0
@@ -465,6 +465,42 @@ type RouteHealth struct {
465465
466466 // Backups API
467467
468+// Images API
469+
470+// ListImages returns runtime images from the admin API.
471+func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
472+ resp, err := c.request(ctx, http.MethodGet, "/images", nil)
473+ if err != nil {
474+ return nil, err
475+ }
476+
477+ var result dto.ImagesResponse
478+ if err := parseResponse(resp, &result); err != nil {
479+ return nil, err
480+ }
481+
482+ return result.Images, nil
483+}
484+
485+// PruneImages prunes runtime and registry images.
486+func (c *Client) PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) {
487+ if keepLast < 0 {
488+ return nil, fmt.Errorf("keepLast must be >= 0")
489+ }
490+
491+ resp, err := c.request(ctx, http.MethodPost, "/images/prune", dto.ImagePruneRequest{KeepLast: &keepLast})
492+ if err != nil {
493+ return nil, err
494+ }
495+
496+ var result dto.ImagePruneResponse
497+ if err := parseResponse(resp, &result); err != nil {
498+ return nil, err
499+ }
500+
501+ return &result, nil
502+}
503+
468504 // ListBackups returns backups globally or for a domain.
469505 func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
470506 path := "/backups"
@@ -465,6 +465,42 @@ type RouteHealth struct {
465 465
466 // Backups API466 // Backups API
467 467
468+// Images API
469+
470+// ListImages returns runtime images from the admin API.
471+func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
472+ resp, err := c.request(ctx, http.MethodGet, "/images", nil)
473+ if err != nil {
474+ return nil, err
475+ }
476+
477+ var result dto.ImagesResponse
478+ if err := parseResponse(resp, &result); err != nil {
479+ return nil, err
480+ }
481+
482+ return result.Images, nil
483+}
484+
485+// PruneImages prunes runtime and registry images.
486+func (c *Client) PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) {
487+ if keepLast < 0 {
488+ return nil, fmt.Errorf("keepLast must be >= 0")
489+ }
490+
491+ resp, err := c.request(ctx, http.MethodPost, "/images/prune", dto.ImagePruneRequest{KeepLast: &keepLast})
492+ if err != nil {
493+ return nil, err
494+ }
495+
496+ var result dto.ImagePruneResponse
497+ if err := parseResponse(resp, &result); err != nil {
498+ return nil, err
499+ }
500+
501+ return &result, nil
502+}
503+
468 // ListBackups returns backups globally or for a domain.504 // ListBackups returns backups globally or for a domain.
469 func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {505 func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
470 path := "/backups"506 path := "/backups"
M internal/adapters/in/cli/remote/client_test.go +43 -0
@@ -2,6 +2,7 @@ package remote
22
33 import (
44 "context"
5+ "encoding/json"
56 "net/http"
67 "net/http/httptest"
78 "sync/atomic"
@@ -10,6 +11,8 @@ import (
1011
1112 "github.com/stretchr/testify/assert"
1213 "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/adapters/dto"
1316 )
1417
1518 func withFastRetry(t *testing.T) {
@@ -108,3 +111,43 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
108111 _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background())
109112 require.NoError(t, err)
110113 }
114+
115+func TestClientListImages(t *testing.T) {
116+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117+ require.Equal(t, "/admin/images", r.URL.Path)
118+ require.Equal(t, http.MethodGet, r.Method)
119+ w.Header().Set("Content-Type", "application/json")
120+ _, _ = w.Write([]byte(`{"images":[{"repository":"registry.example.com/app","tag":"latest","size":1234,"created":"2026-02-08T12:00:00Z","id":"sha256:abc","dangling":false}]}`))
121+ }))
122+ defer srv.Close()
123+
124+ client := NewClient(srv.URL)
125+ images, err := client.ListImages(context.Background())
126+ require.NoError(t, err)
127+ require.Len(t, images, 1)
128+ assert.Equal(t, "registry.example.com/app", images[0].Repository)
129+ assert.Equal(t, "latest", images[0].Tag)
130+}
131+
132+func TestClientPruneImages(t *testing.T) {
133+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134+ require.Equal(t, "/admin/images/prune", r.URL.Path)
135+ require.Equal(t, http.MethodPost, r.Method)
136+
137+ var req dto.ImagePruneRequest
138+ require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
139+ require.NotNil(t, req.KeepLast)
140+ assert.Equal(t, 4, *req.KeepLast)
141+
142+ w.Header().Set("Content-Type", "application/json")
143+ _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`))
144+ }))
145+ defer srv.Close()
146+
147+ client := NewClient(srv.URL)
148+ resp, err := client.PruneImages(context.Background(), 4)
149+ require.NoError(t, err)
150+ require.NotNil(t, resp)
151+ assert.Equal(t, 2, resp.Runtime.DeletedCount)
152+ assert.Equal(t, 3, resp.Registry.TagsRemoved)
153+}
@@ -2,6 +2,7 @@ package remote
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "net/http"6 "net/http"
6 "net/http/httptest"7 "net/http/httptest"
7 "sync/atomic"8 "sync/atomic"
@@ -10,6 +11,8 @@ import (
10 11
11 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/adapters/dto"
13 )16 )
14 17
15 func withFastRetry(t *testing.T) {18 func withFastRetry(t *testing.T) {
@@ -108,3 +111,43 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
108 _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background())111 _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background())
109 require.NoError(t, err)112 require.NoError(t, err)
110 }113 }
114+
115+func TestClientListImages(t *testing.T) {
116+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117+ require.Equal(t, "/admin/images", r.URL.Path)
118+ require.Equal(t, http.MethodGet, r.Method)
119+ w.Header().Set("Content-Type", "application/json")
120+ _, _ = w.Write([]byte(`{"images":[{"repository":"registry.example.com/app","tag":"latest","size":1234,"created":"2026-02-08T12:00:00Z","id":"sha256:abc","dangling":false}]}`))
121+ }))
122+ defer srv.Close()
123+
124+ client := NewClient(srv.URL)
125+ images, err := client.ListImages(context.Background())
126+ require.NoError(t, err)
127+ require.Len(t, images, 1)
128+ assert.Equal(t, "registry.example.com/app", images[0].Repository)
129+ assert.Equal(t, "latest", images[0].Tag)
130+}
131+
132+func TestClientPruneImages(t *testing.T) {
133+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134+ require.Equal(t, "/admin/images/prune", r.URL.Path)
135+ require.Equal(t, http.MethodPost, r.Method)
136+
137+ var req dto.ImagePruneRequest
138+ require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
139+ require.NotNil(t, req.KeepLast)
140+ assert.Equal(t, 4, *req.KeepLast)
141+
142+ w.Header().Set("Content-Type", "application/json")
143+ _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`))
144+ }))
145+ defer srv.Close()
146+
147+ client := NewClient(srv.URL)
148+ resp, err := client.PruneImages(context.Background(), 4)
149+ require.NoError(t, err)
150+ require.NotNil(t, resp)
151+ assert.Equal(t, 2, resp.Runtime.DeletedCount)
152+ assert.Equal(t, 3, resp.Registry.TagsRemoved)
153+}
M internal/adapters/in/cli/root.go +4 -0
@@ -121,6 +121,10 @@ Commands are organized by where they run:
121121 backupCmd.GroupID = groupManage
122122 rootCmd.AddCommand(backupCmd)
123123
124+ imagesCmd := newImagesCmd()
125+ imagesCmd.GroupID = groupManage
126+ rootCmd.AddCommand(imagesCmd)
127+
124128 // Client-only commands (no server needed)
125129 remotesCmd := newRemotesCmd()
126130 remotesCmd.GroupID = groupClient
@@ -121,6 +121,10 @@ Commands are organized by where they run:
121 backupCmd.GroupID = groupManage121 backupCmd.GroupID = groupManage
122 rootCmd.AddCommand(backupCmd)122 rootCmd.AddCommand(backupCmd)
123 123
124+ imagesCmd := newImagesCmd()
125+ imagesCmd.GroupID = groupManage
126+ rootCmd.AddCommand(imagesCmd)
127+
124 // Client-only commands (no server needed)128 // Client-only commands (no server needed)
125 remotesCmd := newRemotesCmd()129 remotesCmd := newRemotesCmd()
126 remotesCmd.GroupID = groupClient130 remotesCmd.GroupID = groupClient

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