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}