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}