v3.3.0 b164fe9

backup.go RawHistoryFolder

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	"github.com/bnema/gordon/pkg/bytesize"
14)
15
16// newBackupCmd creates the backup command group. Backups are identified by
17// app, service, and the declared database or volume: a domain is never an
18// identity.
19func newBackupCmd() *cobra.Command {
20	cmd := &cobra.Command{
21		Use:     "backups",
22		Aliases: []string{"backup"},
23		Short:   "Manage app backups",
24		Long: `Manage app backups.
25
26Backup targets come from the app manifest: a service declares its databases
27and volumes, and which of them are backed up. Runs locally through the daemon's
28authenticated Unix socket, or against a remote Gordon instance when --remote
29targeting is configured.`,
30	}
31
32	cmd.AddCommand(newBackupListCmd())
33	cmd.AddCommand(newBackupRunCmd())
34	cmd.AddCommand(newBackupStatusCmd())
35	cmd.AddCommand(newBackupVolumeCmd())
36
37	return cmd
38}
39
40// newBackupVolumeCmd creates the `backup volume` group.
41func newBackupVolumeCmd() *cobra.Command {
42	cmd := &cobra.Command{
43		Use:   "volume",
44		Short: "Manage app volume backups",
45	}
46	cmd.AddCommand(newVolumeBackupListCmd())
47	cmd.AddCommand(newVolumeBackupRunCmd())
48	cmd.AddCommand(newVolumeBackupStatusCmd())
49	return cmd
50}
51
52func newBackupListCmd() *cobra.Command {
53	var jsonOut bool
54
55	cmd := &cobra.Command{
56		Use:   "list [APP]",
57		Short: "List database backups",
58		Long:  cliRenderMuted("List stored backups for all apps, or for one app."),
59		Args:  cobra.MaximumNArgs(1),
60		RunE: func(cmd *cobra.Command, args []string) error {
61			app := ""
62			if len(args) == 1 {
63				app = args[0]
64			}
65			handle, err := resolveControlPlane(cliConfigPath)
66			if err != nil {
67				return err
68			}
69			defer handle.close()
70			return runBackupList(cmd.Context(), handle.plane, app, cmd.OutOrStdout(), jsonOut)
71		},
72	}
73
74	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
75	return cmd
76}
77
78func runBackupList(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
79	jobs, err := plane.ListBackups(ctx, app)
80	if err != nil {
81		return fmt.Errorf("failed to list backups: %w", err)
82	}
83	return printBackupJobs(out, jobs, jsonOut)
84}
85
86func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
87	if len(jobs) == 0 {
88		if jsonOut {
89			return writeJSON(out, []dto.BackupJob{})
90		}
91		return cliWriteLine(out, cliRenderMuted("No backups found"))
92	}
93
94	if jsonOut {
95		return writeJSON(out, jobs)
96	}
97
98	if err := cliWriteLine(out, cliRenderTitle("Backups")); err != nil {
99		return err
100	}
101	w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
102	if _, err := fmt.Fprintln(w, "APP\tSERVICE\tDATABASE\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
103		return err
104	}
105	for _, job := range jobs {
106		if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
107			job.App, job.Service, job.Database, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {
108			return err
109		}
110	}
111	return w.Flush()
112}
113
114func newBackupRunCmd() *cobra.Command {
115	var service string
116	var database string
117	var jsonOut bool
118
119	cmd := &cobra.Command{
120		Use:   "run APP",
121		Short: "Run a database backup now",
122		Long: cliRenderMuted(`Run the declared database backup of one app service.
123
124--service and --database select the target. Omitting a selector is only
125allowed when exactly one compatible target exists.`),
126		Args: cobra.ExactArgs(1),
127		RunE: func(cmd *cobra.Command, args []string) error {
128			handle, err := resolveControlPlane(cliConfigPath)
129			if err != nil {
130				return err
131			}
132			defer handle.close()
133			return runBackupRun(cmd.Context(), handle.plane, args[0], service, database, cmd.OutOrStdout(), jsonOut)
134		},
135	}
136
137	cmd.Flags().StringVar(&service, "service", "", "Service that declares the database")
138	cmd.Flags().StringVar(&database, "database", "", "Declared database name (optional)")
139	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
140	return cmd
141}
142
143func runBackupRun(ctx context.Context, plane ControlPlane, app, service, database string, out io.Writer, jsonOut bool) error {
144	result, err := plane.RunBackup(ctx, app, service, database)
145	if err != nil {
146		return fmt.Errorf("failed to run backup: %w", err)
147	}
148	if result.Backup == nil {
149		return fmt.Errorf("backup run completed without a backup payload")
150	}
151	if jsonOut {
152		return writeJSON(out, result)
153	}
154	return printBackupJobs(out, []dto.BackupJob{*result.Backup}, false)
155}
156
157func newBackupStatusCmd() *cobra.Command {
158	var jsonOut bool
159	cmd := &cobra.Command{
160		Use:   "status",
161		Short: "Show database backup status",
162		Long:  cliRenderMuted("Show stored backups plus declared targets that have no completed backup yet."),
163		RunE: func(cmd *cobra.Command, args []string) error {
164			handle, err := resolveControlPlane(cliConfigPath)
165			if err != nil {
166				return err
167			}
168			defer handle.close()
169			return runBackupStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
170		},
171	}
172	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
173	return cmd
174}
175
176func runBackupStatus(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error {
177	jobs, err := plane.BackupStatus(ctx)
178	if err != nil {
179		return fmt.Errorf("failed to get backup status: %w", err)
180	}
181	return printBackupJobs(out, jobs, jsonOut)
182}
183
184func newVolumeBackupListCmd() *cobra.Command {
185	var jsonOut bool
186
187	cmd := &cobra.Command{
188		Use:   "list [APP]",
189		Short: "List volume backups",
190		Args:  cobra.MaximumNArgs(1),
191		RunE: func(cmd *cobra.Command, args []string) error {
192			app := ""
193			if len(args) == 1 {
194				app = args[0]
195			}
196			handle, err := resolveControlPlane(cliConfigPath)
197			if err != nil {
198				return err
199			}
200			defer handle.close()
201			return runVolumeBackupList(cmd.Context(), handle.plane, app, cmd.OutOrStdout(), jsonOut)
202		},
203	}
204	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
205	return cmd
206}
207
208func runVolumeBackupList(ctx context.Context, plane ControlPlane, app string, out io.Writer, jsonOut bool) error {
209	jobs, err := plane.ListVolumeBackups(ctx, app)
210	if err != nil {
211		return fmt.Errorf("failed to list volume backups: %w", err)
212	}
213	return printVolumeBackupJobs(out, jobs, jsonOut)
214}
215
216func newVolumeBackupRunCmd() *cobra.Command {
217	var service string
218	var volume string
219	var jsonOut bool
220
221	cmd := &cobra.Command{
222		Use:   "run APP",
223		Short: "Run a volume backup now",
224		Long: cliRenderMuted(`Run the declared volume backup of one app service.
225
226--service and --volume select the target. Omitting a selector is only
227allowed when exactly one compatible target exists.`),
228		Args: cobra.ExactArgs(1),
229		RunE: func(cmd *cobra.Command, args []string) error {
230			handle, err := resolveControlPlane(cliConfigPath)
231			if err != nil {
232				return err
233			}
234			defer handle.close()
235			return runVolumeBackupRun(cmd.Context(), handle.plane, args[0], service, volume, cmd.OutOrStdout(), jsonOut)
236		},
237	}
238	cmd.Flags().StringVar(&service, "service", "", "Service that declares the volume")
239	cmd.Flags().StringVar(&volume, "volume", "", "Declared volume name (optional)")
240	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
241	return cmd
242}
243
244func runVolumeBackupRun(ctx context.Context, plane ControlPlane, app, service, volume string, out io.Writer, jsonOut bool) error {
245	result, err := plane.RunVolumeBackups(ctx, app, service, volume)
246	if err != nil {
247		// Partial results are reported before the error: an operator must
248		// see which volumes completed.
249		if result != nil && len(result.Backups) > 0 {
250			if printErr := printVolumeBackupJobs(out, result.Backups, jsonOut); printErr != nil {
251				return printErr
252			}
253		}
254		return fmt.Errorf("failed to run volume backup: %w", err)
255	}
256	if jsonOut {
257		return writeJSON(out, result)
258	}
259	return printVolumeBackupJobs(out, result.Backups, false)
260}
261
262func newVolumeBackupStatusCmd() *cobra.Command {
263	var jsonOut bool
264	cmd := &cobra.Command{
265		Use:   "status",
266		Short: "Show volume backup status",
267		RunE: func(cmd *cobra.Command, args []string) error {
268			handle, err := resolveControlPlane(cliConfigPath)
269			if err != nil {
270				return err
271			}
272			defer handle.close()
273			return runVolumeBackupStatus(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
274		},
275	}
276	cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
277	return cmd
278}
279
280func runVolumeBackupStatus(ctx context.Context, plane ControlPlane, out io.Writer, jsonOut bool) error {
281	jobs, err := plane.VolumeBackupStatus(ctx)
282	if err != nil {
283		return fmt.Errorf("failed to get volume backup status: %w", err)
284	}
285	return printVolumeBackupJobs(out, jobs, jsonOut)
286}
287
288func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {
289	if len(jobs) == 0 {
290		if jsonOut {
291			return writeJSON(out, []dto.VolumeBackupJob{})
292		}
293		return cliWriteLine(out, cliRenderMuted("No volume backups found"))
294	}
295	if jsonOut {
296		return writeJSON(out, jobs)
297	}
298	if err := cliWriteLine(out, cliRenderTitle("Volume Backups")); err != nil {
299		return err
300	}
301	w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
302	if _, err := fmt.Fprintln(w, "APP\tSERVICE\tVOLUME\tCOMPRESSION\tSTATUS\tSTARTED_AT\tSIZE\tARTIFACT"); err != nil {
303		return err
304	}
305	for _, job := range jobs {
306		if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
307			job.App, job.Service, job.VolumeName, job.Compression, job.Status,
308			formatBackupTime(job.StartedAt), bytesize.Format(job.SizeBytes), job.ArtifactRef); err != nil {
309			return err
310		}
311	}
312	return w.Flush()
313}
314
315func formatBackupTime(t *time.Time) string {
316	if t == nil {
317		return ""
318	}
319	return t.UTC().Format("2006-01-02T15:04:05Z")
320}

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