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}