1package cli
2
3import (
4 "context"
5 "fmt"
6 "io"
7
8 "github.com/spf13/cobra"
9
10 "github.com/bnema/gordon/internal/adapters/dto"
11 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
12 "github.com/bnema/gordon/pkg/bytesize"
13)
14
15type volumesClient interface {
16 ListVolumes(ctx context.Context) ([]dto.Volume, error)
17 PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error)
18}
19
20type volumesPruneOptions struct {
21 DryRun bool
22 NoConfirm bool
23 Json bool
24}
25
26func newVolumesCmd() *cobra.Command {
27 cmd := &cobra.Command{
28 Use: "volumes",
29 Short: "Manage volumes",
30 GroupID: groupManage,
31 }
32 cmd.AddCommand(newVolumesListCmd())
33 cmd.AddCommand(newVolumesPruneCmd())
34 return cmd
35}
36
37func newVolumesListCmd() *cobra.Command {
38 var jsonOut bool
39 cmd := &cobra.Command{
40 Use: "list",
41 Short: "List all volumes",
42 RunE: func(cmd *cobra.Command, _ []string) error {
43 handle, err := resolveControlPlane(cliConfigPath)
44 if err != nil {
45 return err
46 }
47 defer handle.close()
48 return runVolumesList(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
49 },
50 }
51 cmd.Flags().BoolVar(&jsonOut, "json", false, "output as JSON")
52 return cmd
53}
54
55func newVolumesPruneCmd() *cobra.Command {
56 var opts volumesPruneOptions
57 cmd := &cobra.Command{
58 Use: "prune",
59 Short: "Remove released app-owned volumes",
60 Long: `Remove app-owned volumes that were explicitly released and are unused.
61
62Only a volume whose durable ownership record marks it released, whose runtime
63labels agree with that record, and which no container mounts is removed.
64Attached, retained, legacy-managed, unmanaged, and unknown volumes survive.`,
65 RunE: func(cmd *cobra.Command, _ []string) error {
66 handle, err := resolveControlPlane(cliConfigPath)
67 if err != nil {
68 return err
69 }
70 defer handle.close()
71 return runVolumesPrune(cmd.Context(), handle.plane, opts, cmd.OutOrStdout())
72 },
73 }
74 cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "report the plan without deleting")
75 cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "skip confirmation prompt")
76 cmd.Flags().BoolVar(&opts.Json, "json", false, "output as JSON")
77 return cmd
78}
79
80func runVolumesList(ctx context.Context, client volumesClient, out io.Writer, jsonOut bool) error {
81 volumes, err := client.ListVolumes(ctx)
82 if err != nil {
83 return err
84 }
85
86 if jsonOut {
87 return writeJSON(out, volumes)
88 }
89
90 if len(volumes) == 0 {
91 return cliWriteLine(out, "No volumes found.")
92 }
93
94 if err := cliWriteLine(out, cliRenderTitle("Volumes")); err != nil {
95 return err
96 }
97
98 for _, v := range volumes {
99 status := "orphaned"
100 if v.InUse {
101 status = "in-use"
102 }
103 containers := "-"
104 if len(v.Containers) > 0 {
105 containers = fmt.Sprintf("%v", v.Containers)
106 }
107 if err := cliWritef(out, " %-30s %-10s %-10s %s\n", v.Name, status, bytesize.Format(v.Size), containers); err != nil {
108 return err
109 }
110 }
111
112 var inUse, orphaned int
113 for _, v := range volumes {
114 if v.InUse {
115 inUse++
116 } else {
117 orphaned++
118 }
119 }
120 return cliWritef(out, "\n%d volumes (%d in-use, %d orphaned)\n", len(volumes), inUse, orphaned)
121}
122
123func renderPrunePreview(out io.Writer, resp *dto.VolumePruneResponse) error {
124 if err := cliWriteLine(out, cliRenderTitle("Volumes to remove")); err != nil {
125 return err
126 }
127 for _, v := range resp.Volumes {
128 if err := cliWritef(out, " %s (%s)\n", v.Name, bytesize.Format(v.Size)); err != nil {
129 return err
130 }
131 }
132 return cliWritef(out, "\n%d volumes, %s total\n", resp.VolumesRemoved, bytesize.Format(resp.SpaceReclaimed))
133}
134
135func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPruneOptions, out io.Writer) error {
136 preview, err := client.PruneVolumes(ctx, dto.VolumePruneRequest{DryRun: true})
137 if err != nil {
138 return err
139 }
140
141 if opts.DryRun {
142 return renderVolumePrunePlan(out, opts.Json, preview)
143 }
144 if preview.Plan.Eligible == 0 {
145 // A zero-eligibility plan is a normal outcome: the report explains
146 // why each volume survived.
147 return renderVolumePrunePlan(out, opts.Json, preview)
148 }
149
150 if !opts.Json {
151 if err := renderPrunePreview(out, preview); err != nil {
152 return err
153 }
154 if !opts.NoConfirm {
155 confirmed, err := components.RunConfirm("Remove these volumes?")
156 if err != nil {
157 return err
158 }
159 if !confirmed {
160 return cliWriteLine(out, "Cancelled.")
161 }
162 }
163 }
164
165 result, err := client.PruneVolumes(ctx, dto.VolumePruneRequest{DryRun: false})
166 if err != nil {
167 return err
168 }
169 if opts.Json {
170 return writeJSON(out, result)
171 }
172
173 if err := cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, bytesize.Format(result.SpaceReclaimed)))); err != nil {
174 return err
175 }
176 return cliRenderPruneSummary(out, result.Plan)
177}
178
179// renderVolumePrunePlan writes one plan without deleting anything.
180func renderVolumePrunePlan(out io.Writer, asJSON bool, resp *dto.VolumePruneResponse) error {
181 if asJSON {
182 return writeJSON(out, resp)
183 }
184 if resp.Plan.Eligible == 0 {
185 if err := cliWriteLine(out, "No released volumes to remove."); err != nil {
186 return err
187 }
188 }
189 return cliRenderPruneSummary(out, resp.Plan)
190}