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}