v3.3.0 b164fe9

volumes.go RawHistoryFolder

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}

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