v2.31.0-dev22 8d342c4

backup.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"fmt"
6	"io"
7	"os"
8	"text/tabwriter"
9	"time"
10
11	"github.com/spf13/cobra"
12
13	"github.com/bnema/gordon/internal/adapters/dto"
14	"github.com/bnema/gordon/pkg/bytesize"
15)
16
17var backupResolveControlPlane = func(ctx context.Context, configPath, domainName string) (*controlPlaneHandle, error) {
18	if domainName != "" {
19		return resolveControlPlaneForRouteDomain(ctx, domainName)
20	}
21	return resolveControlPlane(configPath)
22}
23
24// newBackupCmd creates the backup command group.
25func newBackupCmd() *cobra.Command {
26	cmd := &cobra.Command{
27		Use:     "backups",
28		Aliases: []string{"backup"},
29		Short:   "Manage backups",
30		Long: `Manage backups.
31
32Runs locally via in-process services by default, or against a remote Gordon
33instance when --remote targeting is configured.`,
34	}
35
36	cmd.AddCommand(newBackupDatabasesCmd())
37	cmd.AddCommand(newBackupVolumesCmd())
38
39	// Compatibility aliases for the original database backup command shape.
40	cmd.AddCommand(newBackupListCmd())
41	cmd.AddCommand(newBackupRunCmd())
42	cmd.AddCommand(newBackupDetectCmd())
43	cmd.AddCommand(newBackupStatusCmd())
44
45	return cmd
46}
47
48func newBackupDatabasesCmd() *cobra.Command {
49	cmd := &cobra.Command{
50		Use:     "databases",
51		Aliases: []string{"database", "db"},
52		Short:   "Manage database backups",
53	}
54	cmd.AddCommand(newBackupListCmd())
55	cmd.AddCommand(newBackupRunCmd())
56	cmd.AddCommand(newBackupDetectCmd())
57	cmd.AddCommand(newBackupStatusCmd())
58	return cmd
59}
60
61func newBackupVolumesCmd() *cobra.Command {
62	cmd := &cobra.Command{
63		Use:   "volumes",
64		Short: "Manage volume backups",
65	}
66	cmd.AddCommand(newVolumeBackupListCmd())
67	cmd.AddCommand(newVolumeBackupRunCmd())
68	cmd.AddCommand(newVolumeBackupStatusCmd())
69	return cmd
70}
71
72func newBackupListCmd() *cobra.Command {
73	var jsonOut bool
74
75	cmd := &cobra.Command{
76		Use:   "list [domain]",
77		Short: "List backups",
78		Long:  cliRenderMuted("List backups for all domains or a specific domain."),
79		Args:  cobra.MaximumNArgs(1),
80		RunE: func(cmd *cobra.Command, args []string) error {
81			domainName := ""
82			if len(args) == 1 {
83				domainName = args[0]
84			}
85
86			var (
87				handle *controlPlaneHandle
88				err    error
89			)
90			if domainName != "" {
91				handle, err = resolveControlPlaneForRouteDomain(cmd.Context(), domainName)
92			} else {
93				handle, err = resolveControlPlane(configPath)
94			}
95			if err != nil {
96				return err
97			}
98			defer handle.close()
99
100			jobs, err := handle.plane.ListBackups(cmd.Context(), domainName)
101			if err != nil {
102				return fmt.Errorf("failed to list backups: %w", err)
103			}
104
105			return printBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
106		},
107	}
108
109	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
110
111	return cmd
112}
113
114func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
115	if len(jobs) == 0 {
116		if jsonOut {
117			return writeJSON(out, []dto.BackupJob{})
118		}
119		return cliWriteLine(out, cliRenderMuted("No backups found"))
120	}
121
122	if jsonOut {
123		return writeJSON(out, jobs)
124	}
125
126	if err := cliWriteLine(out, cliRenderTitle("Backups")); err != nil {
127		return err
128	}
129	w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
130	if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
131		return err
132	}
133
134	for _, job := range jobs {
135		if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {
136			return err
137		}
138	}
139	return w.Flush()
140}
141
142func newVolumeBackupListCmd() *cobra.Command {
143	var jsonOut bool
144
145	cmd := &cobra.Command{
146		Use:   "list [domain]",
147		Short: "List volume backups",
148		Args:  cobra.MaximumNArgs(1),
149		RunE: func(cmd *cobra.Command, args []string) error {
150			domainName := ""
151			if len(args) == 1 {
152				domainName = args[0]
153			}
154			handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
155			if err != nil {
156				return err
157			}
158			defer handle.close()
159
160			jobs, err := handle.plane.ListVolumeBackups(cmd.Context(), domainName)
161			if err != nil {
162				return fmt.Errorf("failed to list volume backups: %w", err)
163			}
164			return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
165		},
166	}
167	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
168	return cmd
169}
170
171func newVolumeBackupRunCmd() *cobra.Command {
172	var volumeName string
173	var jsonOut bool
174
175	cmd := &cobra.Command{
176		Use:   "run [domain]",
177		Short: "Run volume backups now",
178		Args:  cobra.MaximumNArgs(1),
179		RunE: func(cmd *cobra.Command, args []string) error {
180			domainName := ""
181			if len(args) == 1 {
182				domainName = args[0]
183			}
184			handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
185			if err != nil {
186				return err
187			}
188			defer handle.close()
189
190			result, err := handle.plane.RunVolumeBackups(cmd.Context(), domainName, volumeName)
191			if err != nil {
192				if result != nil && len(result.Backups) > 0 {
193					if printErr := printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut); printErr != nil {
194						return printErr
195					}
196				}
197				return fmt.Errorf("failed to run volume backups: %w", err)
198			}
199			return printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut)
200		},
201	}
202	cmd.Flags().StringVar(&volumeName, "volume", "", "Volume name (optional)")
203	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
204	return cmd
205}
206
207func newVolumeBackupStatusCmd() *cobra.Command {
208	var jsonOut bool
209	cmd := &cobra.Command{
210		Use:   "status",
211		Short: "Show volume backup status",
212		RunE: func(cmd *cobra.Command, args []string) error {
213			handle, err := backupResolveControlPlane(cmd.Context(), configPath, "")
214			if err != nil {
215				return err
216			}
217			defer handle.close()
218
219			jobs, err := handle.plane.VolumeBackupStatus(cmd.Context())
220			if err != nil {
221				return fmt.Errorf("failed to get volume backup status: %w", err)
222			}
223			return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
224		},
225	}
226	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
227	return cmd
228}
229
230func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {
231	if len(jobs) == 0 {
232		if jsonOut {
233			return writeJSON(out, []dto.VolumeBackupJob{})
234		}
235		return cliWriteLine(out, cliRenderMuted("No volume backups found"))
236	}
237	if jsonOut {
238		return writeJSON(out, jobs)
239	}
240	if err := cliWriteLine(out, cliRenderTitle("Volume Backups")); err != nil {
241		return err
242	}
243	w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
244	if _, err := fmt.Fprintln(w, "DOMAIN\tVOLUME\tCONTAINER\tCOMPRESSION\tSTATUS\tSTARTED_AT\tSIZE\tARTIFACT"); err != nil {
245		return err
246	}
247	for _, job := range jobs {
248		if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", job.Domain, job.VolumeName, job.ContainerName, job.Compression, job.Status, formatBackupTime(job.StartedAt), bytesize.Format(job.SizeBytes), job.ArtifactRef); err != nil {
249			return err
250		}
251	}
252	return w.Flush()
253}
254
255func newBackupRunCmd() *cobra.Command {
256	var dbName string
257
258	cmd := &cobra.Command{
259		Use:   "run <domain>",
260		Short: "Run backup now",
261		Args:  cobra.ExactArgs(1),
262		RunE: func(cmd *cobra.Command, args []string) error {
263			handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
264			if err != nil {
265				return err
266			}
267			defer handle.close()
268
269			result, err := handle.plane.RunBackup(cmd.Context(), args[0], dbName)
270			if err != nil {
271				return fmt.Errorf("failed to run backup: %w", err)
272			}
273
274			if result.Backup == nil {
275				return fmt.Errorf("backup run completed without backup payload")
276			}
277			if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Result")); err != nil {
278				return err
279			}
280
281			w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
282			if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE"); err != nil {
283				return err
284			}
285			if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, bytesize.Format(result.Backup.SizeBytes)); err != nil {
286				return err
287			}
288			if err := w.Flush(); err != nil {
289				return err
290			}
291			return nil
292		},
293	}
294
295	cmd.Flags().StringVar(&dbName, "db", "", "Database attachment name (optional)")
296	return cmd
297}
298
299func newBackupDetectCmd() *cobra.Command {
300	return &cobra.Command{
301		Use:   "detect <domain>",
302		Short: "Detect databases for domain",
303		Args:  cobra.ExactArgs(1),
304		RunE: func(cmd *cobra.Command, args []string) error {
305			handle, err := resolveControlPlaneForRouteDomain(cmd.Context(), args[0])
306			if err != nil {
307				return err
308			}
309			defer handle.close()
310
311			dbs, err := handle.plane.DetectDatabases(cmd.Context(), args[0])
312			if err != nil {
313				return fmt.Errorf("failed to detect databases: %w", err)
314			}
315
316			if len(dbs) == 0 {
317				if err := cliWriteLine(os.Stdout, cliRenderMuted("No supported databases detected")); err != nil {
318					return err
319				}
320				return nil
321			}
322			if err := cliWriteLine(os.Stdout, cliRenderTitle("Detected Databases")); err != nil {
323				return err
324			}
325			w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
326			if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil {
327				return err
328			}
329
330			for _, db := range dbs {
331				if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", db.Name, db.Type, db.Host, db.Port, db.ImageName); err != nil {
332					return err
333				}
334			}
335			if err := w.Flush(); err != nil {
336				return err
337			}
338
339			return nil
340		},
341	}
342}
343
344func newBackupStatusCmd() *cobra.Command {
345	return &cobra.Command{
346		Use:   "status",
347		Short: "Show backup status",
348		RunE: func(cmd *cobra.Command, args []string) error {
349			handle, err := resolveControlPlane(configPath)
350			if err != nil {
351				return err
352			}
353			defer handle.close()
354
355			jobs, err := handle.plane.BackupStatus(cmd.Context())
356			if err != nil {
357				return fmt.Errorf("failed to get backup status: %w", err)
358			}
359
360			if len(jobs) == 0 {
361				if err := cliWriteLine(os.Stdout, cliRenderMuted("No backup status available")); err != nil {
362					return err
363				}
364				return nil
365			}
366			if err := cliWriteLine(os.Stdout, cliRenderTitle("Backup Status")); err != nil {
367				return err
368			}
369			w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
370			if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil {
371				return err
372			}
373
374			for _, job := range jobs {
375				if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt)); err != nil {
376					return err
377				}
378			}
379			if err := w.Flush(); err != nil {
380				return err
381			}
382
383			return nil
384		},
385	}
386}
387
388func formatBackupTime(t *time.Time) string {
389	if t == nil {
390		return ""
391	}
392	return t.UTC().Format("2006-01-02T15:04:05Z")
393}

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