From cda0fc9fe4e538c44fb6b2cc5a95bc664ba4d9bb Mon Sep 17 00:00:00 2001 From: Thomas Kooi Date: Mon, 26 Jan 2026 13:44:32 +0100 Subject: [PATCH] feat(dbaas): add backup and backup schedule commands Signed-off-by: Thomas Kooi --- .../backup-schedules/backup-schedules.go | 15 + cmd/dbaas/backup-schedules/create.go | 148 ++++++++++ cmd/dbaas/backup-schedules/delete.go | 76 +++++ cmd/dbaas/backup-schedules/list.go | 143 ++++++++++ cmd/dbaas/backup-schedules/update.go | 157 +++++++++++ cmd/dbaas/backup-schedules/view.go | 117 ++++++++ cmd/dbaas/backup/backup.go | 15 + cmd/dbaas/backup/cancel-deletion.go | 42 +++ cmd/dbaas/backup/create.go | 153 ++++++++++ cmd/dbaas/backup/delete.go | 154 ++++++++++ cmd/dbaas/backup/list.go | 265 ++++++++++++++++++ cmd/dbaas/backup/view.go | 131 +++++++++ cmd/dbaas/create.go | 49 ++-- cmd/dbaas/dbaas.go | 4 + go.mod | 2 +- go.sum | 4 +- internal/completion/completion.go | 30 ++ 17 files changed, 1484 insertions(+), 21 deletions(-) create mode 100644 cmd/dbaas/backup-schedules/backup-schedules.go create mode 100644 cmd/dbaas/backup-schedules/create.go create mode 100644 cmd/dbaas/backup-schedules/delete.go create mode 100644 cmd/dbaas/backup-schedules/list.go create mode 100644 cmd/dbaas/backup-schedules/update.go create mode 100644 cmd/dbaas/backup-schedules/view.go create mode 100644 cmd/dbaas/backup/backup.go create mode 100644 cmd/dbaas/backup/cancel-deletion.go create mode 100644 cmd/dbaas/backup/create.go create mode 100644 cmd/dbaas/backup/delete.go create mode 100644 cmd/dbaas/backup/list.go create mode 100644 cmd/dbaas/backup/view.go diff --git a/cmd/dbaas/backup-schedules/backup-schedules.go b/cmd/dbaas/backup-schedules/backup-schedules.go new file mode 100644 index 0000000..8927a51 --- /dev/null +++ b/cmd/dbaas/backup-schedules/backup-schedules.go @@ -0,0 +1,15 @@ +package backupschedules + +import ( + "github.com/spf13/cobra" +) + +const NoHeaderKey = "no-header" + +// BackupSchedulesCmd represents the backup-schedules command +var BackupSchedulesCmd = &cobra.Command{ + Use: "backup-schedules", + Aliases: []string{"backup-schedule", "schedules", "schedule"}, + Short: "Manage database backup schedules", + Long: "Manage database backup schedules for database clusters", +} diff --git a/cmd/dbaas/backup-schedules/create.go b/cmd/dbaas/backup-schedules/create.go new file mode 100644 index 0000000..c03bf88 --- /dev/null +++ b/cmd/dbaas/backup-schedules/create.go @@ -0,0 +1,148 @@ +package backupschedules + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/thalassa-cloud/cli/internal/completion" + "github.com/thalassa-cloud/cli/internal/table" + "github.com/thalassa-cloud/cli/internal/thalassaclient" + "github.com/thalassa-cloud/client-go/dbaas" + tcclient "github.com/thalassa-cloud/client-go/pkg/client" +) + +var ( + backupScheduleCreateName string + backupScheduleCreateDescription string + backupScheduleCreateSchedule string + backupScheduleCreateRetentionPolicy string + backupScheduleCreateMethod string + backupScheduleCreateLabels []string + backupScheduleCreateAnnotations []string +) + +// backupScheduleCreateCmd represents the backup-schedules create command +var backupScheduleCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a database backup schedule", + Long: "Create a new backup schedule for a database cluster", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.CompleteDbClusterID, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := thalassaclient.GetThalassaClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + clusterIdentity := args[0] + + if backupScheduleCreateName == "" { + return fmt.Errorf("name is required") + } + if backupScheduleCreateSchedule == "" { + return fmt.Errorf("schedule is required") + } + if backupScheduleCreateRetentionPolicy == "" { + return fmt.Errorf("retention-policy is required") + } + if backupScheduleCreateMethod == "" { + return fmt.Errorf("method is required") + } + + // Parse labels from key=value format + labels := make(map[string]string) + for _, label := range backupScheduleCreateLabels { + parts := strings.SplitN(label, "=", 2) + if len(parts) == 2 { + labels[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + + // Parse annotations from key=value format + annotations := make(map[string]string) + for _, annotation := range backupScheduleCreateAnnotations { + parts := strings.SplitN(annotation, "=", 2) + if len(parts) == 2 { + annotations[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + + method := dbaas.DbClusterBackupScheduleMethod(backupScheduleCreateMethod) + if method != dbaas.DbClusterBackupScheduleMethodSnapshot && method != dbaas.DbClusterBackupScheduleMethodBarman { + return fmt.Errorf("method must be either 'snapshot' or 'barman'") + } + + createReq := dbaas.CreateDbBackupScheduleRequest{ + Name: backupScheduleCreateName, + Schedule: backupScheduleCreateSchedule, + RetentionPolicy: backupScheduleCreateRetentionPolicy, + Method: method, + Labels: labels, + Annotations: annotations, + } + + if backupScheduleCreateDescription != "" { + createReq.Description = &backupScheduleCreateDescription + } + + schedule, err := client.DBaaS().CreateDbBackupSchedule(cmd.Context(), clusterIdentity, createReq) + if err != nil { + if tcclient.IsNotFound(err) { + return fmt.Errorf("database cluster not found: %s", clusterIdentity) + } + return fmt.Errorf("failed to create backup schedule: %w", err) + } + + // Output in table format + clusterName := "" + if schedule.DbCluster != nil { + clusterName = schedule.DbCluster.Name + } + + nextBackup := "-" + if schedule.NextBackupAt != nil { + nextBackup = schedule.NextBackupAt.Format("2006-01-02 15:04:05") + } + + body := [][]string{ + { + schedule.Identity, + schedule.Name, + clusterName, + string(schedule.Method), + schedule.Schedule, + schedule.RetentionPolicy, + nextBackup, + string(schedule.Status), + }, + } + + backupScheduleCreateNoHeader, _ := cmd.Flags().GetBool(NoHeaderKey) + if backupScheduleCreateNoHeader { + table.Print(nil, body) + } else { + table.Print([]string{"ID", "Name", "Cluster", "Method", "Schedule", "Retention", "Next Backup", "Status"}, body) + } + + return nil + }, +} + +func init() { + BackupSchedulesCmd.AddCommand(backupScheduleCreateCmd) + + backupScheduleCreateCmd.Flags().Bool(NoHeaderKey, false, "Do not print the header") + backupScheduleCreateCmd.Flags().StringVarP(&backupScheduleCreateName, "name", "n", "", "Name of the backup schedule (required)") + backupScheduleCreateCmd.Flags().StringVar(&backupScheduleCreateDescription, "description", "", "Description of the backup schedule") + backupScheduleCreateCmd.Flags().StringVar(&backupScheduleCreateSchedule, "schedule", "", "Cron expression for the backup schedule (required)") + backupScheduleCreateCmd.Flags().StringVar(&backupScheduleCreateRetentionPolicy, "retention-policy", "", "Retention policy for the backup schedule (required)") + backupScheduleCreateCmd.Flags().StringVar(&backupScheduleCreateMethod, "method", "barman", "Backup method: 'barman' (default)") + backupScheduleCreateCmd.Flags().StringSliceVar(&backupScheduleCreateLabels, "labels", []string{}, "Labels in key=value format (can be specified multiple times)") + backupScheduleCreateCmd.Flags().StringSliceVar(&backupScheduleCreateAnnotations, "annotations", []string{}, "Annotations in key=value format (can be specified multiple times)") + + _ = backupScheduleCreateCmd.MarkFlagRequired("name") + _ = backupScheduleCreateCmd.MarkFlagRequired("schedule") + _ = backupScheduleCreateCmd.MarkFlagRequired("retention-policy") +} diff --git a/cmd/dbaas/backup-schedules/delete.go b/cmd/dbaas/backup-schedules/delete.go new file mode 100644 index 0000000..911dfeb --- /dev/null +++ b/cmd/dbaas/backup-schedules/delete.go @@ -0,0 +1,76 @@ +package backupschedules + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/thalassa-cloud/cli/internal/completion" + "github.com/thalassa-cloud/cli/internal/thalassaclient" + tcclient "github.com/thalassa-cloud/client-go/pkg/client" +) + +var ( + backupScheduleDeleteForce bool +) + +// backupScheduleDeleteCmd represents the backup-schedules delete command +var backupScheduleDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete a database backup schedule", + Long: "Delete a database backup schedule", + Aliases: []string{"d", "rm", "del", "remove"}, + Args: cobra.ExactArgs(2), + ValidArgsFunction: completion.CompleteDbClusterID, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := thalassaclient.GetThalassaClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + clusterIdentity := args[0] + scheduleIdentity := args[1] + + // Get schedule to show name in confirmation + var scheduleName string + if !backupScheduleDeleteForce { + schedule, err := client.DBaaS().GetDbBackupSchedule(cmd.Context(), clusterIdentity, scheduleIdentity) + if err != nil { + if tcclient.IsNotFound(err) { + return fmt.Errorf("backup schedule not found: %s", scheduleIdentity) + } + return fmt.Errorf("failed to get backup schedule: %w", err) + } + scheduleName = schedule.Name + } + + // Ask for confirmation unless --force is provided + if !backupScheduleDeleteForce { + fmt.Printf("Are you sure you want to delete backup schedule %s (%s)?\n", scheduleName, scheduleIdentity) + var confirm string + fmt.Printf("Enter 'yes' to confirm: ") + fmt.Scanln(&confirm) + if confirm != "yes" { + fmt.Println("Aborted") + return nil + } + } + + err = client.DBaaS().DeleteDbBackupSchedule(cmd.Context(), clusterIdentity, scheduleIdentity) + if err != nil { + if tcclient.IsNotFound(err) { + return fmt.Errorf("backup schedule not found: %s", scheduleIdentity) + } + return fmt.Errorf("failed to delete backup schedule: %w", err) + } + + fmt.Printf("Backup schedule %s deleted successfully\n", scheduleIdentity) + return nil + }, +} + +func init() { + BackupSchedulesCmd.AddCommand(backupScheduleDeleteCmd) + + backupScheduleDeleteCmd.Flags().BoolVar(&backupScheduleDeleteForce, "force", false, "Force the deletion and skip the confirmation") +} diff --git a/cmd/dbaas/backup-schedules/list.go b/cmd/dbaas/backup-schedules/list.go new file mode 100644 index 0000000..b4f3d3d --- /dev/null +++ b/cmd/dbaas/backup-schedules/list.go @@ -0,0 +1,143 @@ +package backupschedules + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/thalassa-cloud/cli/internal/completion" + "github.com/thalassa-cloud/cli/internal/formattime" + "github.com/thalassa-cloud/cli/internal/table" + "github.com/thalassa-cloud/cli/internal/thalassaclient" + "github.com/thalassa-cloud/client-go/dbaas" +) + +var ( + backupScheduleListClusterFilter string + backupScheduleListNoHeader bool + backupScheduleListShowExactTime bool + backupScheduleListShowLabels bool +) + +// backupScheduleListCmd represents the backup-schedules list command +var backupScheduleListCmd = &cobra.Command{ + Use: "list", + Short: "List database backup schedules", + Long: "List database backup schedules for a specific cluster or all schedules in the organisation", + Aliases: []string{"ls", "get", "clusters", "cluster"}, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completion.CompleteDbClusterID(cmd, args, toComplete) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := thalassaclient.GetThalassaClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + var schedules []dbaas.DbClusterBackupSchedule + + // If cluster identity is provided as argument, list schedules for that cluster + if len(args) > 0 { + clusterIdentity := args[0] + schedules, err = client.DBaaS().ListDbBackupSchedules(cmd.Context(), clusterIdentity) + if err != nil { + return fmt.Errorf("failed to list backup schedules for cluster: %w", err) + } + } else { + // Otherwise list all schedules for the organisation + schedules, err = client.DBaaS().ListDbBackupSchedulesForOrganisation(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to list backup schedules: %w", err) + } + } + + if len(schedules) == 0 { + fmt.Println("No backup schedules found") + return nil + } + + body := make([][]string, 0, len(schedules)) + for _, schedule := range schedules { + clusterName := "-" + if schedule.DbCluster != nil { + clusterName = schedule.DbCluster.Name + } + + status := string(schedule.Status) + if schedule.Suspended { + status = fmt.Sprintf("%s (suspended)", status) + } + if schedule.DeleteScheduledAt != nil { + status = fmt.Sprintf("%s (deletion scheduled)", status) + } + + nextBackup := "-" + if schedule.NextBackupAt != nil { + nextBackup = formattime.FormatTime(schedule.NextBackupAt.Local(), backupScheduleListShowExactTime) + } + + lastBackup := "-" + if schedule.LastBackupAt != nil { + lastBackup = formattime.FormatTime(schedule.LastBackupAt.Local(), backupScheduleListShowExactTime) + } + + row := []string{ + schedule.Identity, + schedule.Name, + clusterName, + string(schedule.Method), + schedule.Schedule, + schedule.RetentionPolicy, + fmt.Sprintf("%d", schedule.BackupCount), + nextBackup, + lastBackup, + status, + formattime.FormatTime(schedule.CreatedAt.Local(), backupScheduleListShowExactTime), + } + + if backupScheduleListShowLabels { + labelStrs := []string{} + for k, v := range schedule.Labels { + labelStrs = append(labelStrs, k+"="+v) + } + sort.Strings(labelStrs) + if len(labelStrs) == 0 { + labelStrs = []string{"-"} + } + row = append(row, strings.Join(labelStrs, ",")) + } + + body = append(body, row) + } + + if backupScheduleListNoHeader { + table.Print(nil, body) + } else { + headers := []string{"ID", "Name", "Cluster", "Method", "Schedule", "Retention", "Backups", "Next Backup", "Last Backup", "Status", "Created"} + if backupScheduleListShowLabels { + headers = append(headers, "Labels") + } + table.Print(headers, body) + } + + return nil + }, +} + +func init() { + BackupSchedulesCmd.AddCommand(backupScheduleListCmd) + + backupScheduleListCmd.Flags().BoolVar(&backupScheduleListNoHeader, NoHeaderKey, false, "Do not print the header") + backupScheduleListCmd.Flags().BoolVar(&backupScheduleListShowExactTime, "exact-time", false, "Show exact time instead of relative time") + backupScheduleListCmd.Flags().BoolVar(&backupScheduleListShowLabels, "show-labels", false, "Show labels") + backupScheduleListCmd.Flags().StringVar(&backupScheduleListClusterFilter, "cluster", "", "Filter by database cluster identity, slug, or name") + + // Register completions + backupScheduleListCmd.RegisterFlagCompletionFunc("cluster", completion.CompleteDbClusterID) +} diff --git a/cmd/dbaas/backup-schedules/update.go b/cmd/dbaas/backup-schedules/update.go new file mode 100644 index 0000000..49c4a70 --- /dev/null +++ b/cmd/dbaas/backup-schedules/update.go @@ -0,0 +1,157 @@ +package backupschedules + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/thalassa-cloud/cli/internal/completion" + "github.com/thalassa-cloud/cli/internal/table" + "github.com/thalassa-cloud/cli/internal/thalassaclient" + "github.com/thalassa-cloud/client-go/dbaas" + tcclient "github.com/thalassa-cloud/client-go/pkg/client" +) + +var ( + backupScheduleUpdateName string + backupScheduleUpdateDescription string + backupScheduleUpdateSchedule string + backupScheduleUpdateRetentionPolicy string + backupScheduleUpdateLabels []string + backupScheduleUpdateAnnotations []string +) + +// backupScheduleUpdateCmd represents the backup-schedules update command +var backupScheduleUpdateCmd = &cobra.Command{ + Use: "update", + Short: "Update a database backup schedule", + Long: "Update properties of an existing backup schedule", + Args: cobra.ExactArgs(2), + ValidArgsFunction: completion.CompleteDbClusterID, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := thalassaclient.GetThalassaClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + clusterIdentity := args[0] + scheduleIdentity := args[1] + + // Get current schedule + current, err := client.DBaaS().GetDbBackupSchedule(cmd.Context(), clusterIdentity, scheduleIdentity) + if err != nil { + if tcclient.IsNotFound(err) { + return fmt.Errorf("backup schedule not found: %s", scheduleIdentity) + } + return fmt.Errorf("failed to get backup schedule: %w", err) + } + + req := dbaas.UpdateDbBackupScheduleRequest{ + Name: current.Name, + Description: "", + Schedule: current.Schedule, + RetentionPolicy: current.RetentionPolicy, + Labels: current.Labels, + Annotations: current.Annotations, + } + + if current.Description != nil { + req.Description = *current.Description + } + + // Update name if provided + if cmd.Flags().Changed("name") { + req.Name = backupScheduleUpdateName + } + + // Update description if provided + if cmd.Flags().Changed("description") { + req.Description = backupScheduleUpdateDescription + } + + // Update schedule if provided + if cmd.Flags().Changed("schedule") { + req.Schedule = backupScheduleUpdateSchedule + } + + // Update retention policy if provided + if cmd.Flags().Changed("retention-policy") { + req.RetentionPolicy = backupScheduleUpdateRetentionPolicy + } + + // Parse labels from key=value format + if cmd.Flags().Changed("labels") { + labels := make(map[string]string) + for _, label := range backupScheduleUpdateLabels { + parts := strings.SplitN(label, "=", 2) + if len(parts) == 2 { + labels[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + req.Labels = labels + } + + // Parse annotations from key=value format + if cmd.Flags().Changed("annotations") { + annotations := make(map[string]string) + for _, annotation := range backupScheduleUpdateAnnotations { + parts := strings.SplitN(annotation, "=", 2) + if len(parts) == 2 { + annotations[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + req.Annotations = annotations + } + + schedule, err := client.DBaaS().UpdateDbBackupSchedule(cmd.Context(), clusterIdentity, scheduleIdentity, req) + if err != nil { + return fmt.Errorf("failed to update backup schedule: %w", err) + } + + // Output in table format + clusterName := "" + if schedule.DbCluster != nil { + clusterName = schedule.DbCluster.Name + } + + nextBackup := "-" + if schedule.NextBackupAt != nil { + nextBackup = schedule.NextBackupAt.Format("2006-01-02 15:04:05") + } + + body := [][]string{ + { + schedule.Identity, + schedule.Name, + clusterName, + string(schedule.Method), + schedule.Schedule, + schedule.RetentionPolicy, + nextBackup, + string(schedule.Status), + }, + } + + backupScheduleUpdateNoHeader, _ := cmd.Flags().GetBool(NoHeaderKey) + if backupScheduleUpdateNoHeader { + table.Print(nil, body) + } else { + table.Print([]string{"ID", "Name", "Cluster", "Method", "Schedule", "Retention", "Next Backup", "Status"}, body) + } + + return nil + }, +} + +func init() { + BackupSchedulesCmd.AddCommand(backupScheduleUpdateCmd) + + backupScheduleUpdateCmd.Flags().Bool(NoHeaderKey, false, "Do not print the header") + backupScheduleUpdateCmd.Flags().StringVar(&backupScheduleUpdateName, "name", "", "Name of the backup schedule") + backupScheduleUpdateCmd.Flags().StringVar(&backupScheduleUpdateDescription, "description", "", "Description of the backup schedule") + backupScheduleUpdateCmd.Flags().StringVar(&backupScheduleUpdateSchedule, "schedule", "", "Cron expression for the backup schedule") + backupScheduleUpdateCmd.Flags().StringVar(&backupScheduleUpdateRetentionPolicy, "retention-policy", "", "Retention policy for the backup schedule") + backupScheduleUpdateCmd.Flags().StringSliceVar(&backupScheduleUpdateLabels, "labels", []string{}, "Labels in key=value format (can be specified multiple times)") + backupScheduleUpdateCmd.Flags().StringSliceVar(&backupScheduleUpdateAnnotations, "annotations", []string{}, "Annotations in key=value format (can be specified multiple times)") +} diff --git a/cmd/dbaas/backup-schedules/view.go b/cmd/dbaas/backup-schedules/view.go new file mode 100644 index 0000000..076c77e --- /dev/null +++ b/cmd/dbaas/backup-schedules/view.go @@ -0,0 +1,117 @@ +package backupschedules + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/thalassa-cloud/cli/internal/completion" + "github.com/thalassa-cloud/cli/internal/formattime" + "github.com/thalassa-cloud/cli/internal/table" + "github.com/thalassa-cloud/cli/internal/thalassaclient" + tcclient "github.com/thalassa-cloud/client-go/pkg/client" +) + +var ( + backupScheduleViewShowExactTime bool + backupScheduleViewNoHeader bool +) + +// backupScheduleViewCmd represents the backup-schedules view command +var backupScheduleViewCmd = &cobra.Command{ + Use: "view", + Short: "View backup schedule details", + Long: "View detailed information about a database backup schedule", + Args: cobra.ExactArgs(2), + ValidArgsFunction: completion.CompleteDbClusterID, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := thalassaclient.GetThalassaClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + clusterIdentity := args[0] + scheduleIdentity := args[1] + + schedule, err := client.DBaaS().GetDbBackupSchedule(cmd.Context(), clusterIdentity, scheduleIdentity) + if err != nil { + if tcclient.IsNotFound(err) { + return fmt.Errorf("backup schedule not found: %s", scheduleIdentity) + } + return fmt.Errorf("failed to get backup schedule: %w", err) + } + + clusterName := "" + if schedule.DbCluster != nil { + clusterName = schedule.DbCluster.Name + } + + body := [][]string{ + {"ID", schedule.Identity}, + {"Name", schedule.Name}, + {"Cluster", clusterName}, + {"Status", string(schedule.Status)}, + {"Method", string(schedule.Method)}, + {"Schedule", schedule.Schedule}, + {"Retention Policy", schedule.RetentionPolicy}, + {"Backup Count", fmt.Sprintf("%d", schedule.BackupCount)}, + {"Suspended", fmt.Sprintf("%v", schedule.Suspended)}, + {"Created", formattime.FormatTime(schedule.CreatedAt.Local(), backupScheduleViewShowExactTime)}, + } + + if schedule.Description != nil && *schedule.Description != "" { + body = append(body, []string{"Description", *schedule.Description}) + } + + if schedule.NextBackupAt != nil { + body = append(body, []string{"Next Backup", formattime.FormatTime(schedule.NextBackupAt.Local(), backupScheduleViewShowExactTime)}) + } + + if schedule.LastBackupAt != nil { + body = append(body, []string{"Last Backup", formattime.FormatTime(schedule.LastBackupAt.Local(), backupScheduleViewShowExactTime)}) + } + + if schedule.StatusMessage != "" { + body = append(body, []string{"Status Message", schedule.StatusMessage}) + } + + if schedule.DeleteScheduledAt != nil { + body = append(body, []string{"Delete Scheduled At", formattime.FormatTime(schedule.DeleteScheduledAt.Local(), backupScheduleViewShowExactTime)}) + } + + if len(schedule.Labels) > 0 { + labelStrs := []string{} + for k, v := range schedule.Labels { + labelStrs = append(labelStrs, k+"="+v) + } + sort.Strings(labelStrs) + body = append(body, []string{"Labels", strings.Join(labelStrs, ", ")}) + } + + if len(schedule.Annotations) > 0 { + annotationStrs := []string{} + for k, v := range schedule.Annotations { + annotationStrs = append(annotationStrs, k+"="+v) + } + sort.Strings(annotationStrs) + body = append(body, []string{"Annotations", strings.Join(annotationStrs, ", ")}) + } + + if backupScheduleViewNoHeader { + table.Print(nil, body) + } else { + table.Print([]string{"Field", "Value"}, body) + } + + return nil + }, +} + +func init() { + BackupSchedulesCmd.AddCommand(backupScheduleViewCmd) + + backupScheduleViewCmd.Flags().BoolVar(&backupScheduleViewNoHeader, NoHeaderKey, false, "Do not print the header") + backupScheduleViewCmd.Flags().BoolVar(&backupScheduleViewShowExactTime, "exact-time", false, "Show exact time instead of relative time") +} diff --git a/cmd/dbaas/backup/backup.go b/cmd/dbaas/backup/backup.go new file mode 100644 index 0000000..bbc7106 --- /dev/null +++ b/cmd/dbaas/backup/backup.go @@ -0,0 +1,15 @@ +package backup + +import ( + "github.com/spf13/cobra" +) + +const NoHeaderKey = "no-header" + +// BackupCmd represents the backup command +var BackupCmd = &cobra.Command{ + Use: "backup", + Aliases: []string{"backups"}, + Short: "Manage database backups", + Long: "Manage database backups for database clusters", +} diff --git a/cmd/dbaas/backup/cancel-deletion.go b/cmd/dbaas/backup/cancel-deletion.go new file mode 100644 index 0000000..1ac70a2 --- /dev/null +++ b/cmd/dbaas/backup/cancel-deletion.go @@ -0,0 +1,42 @@ +package backup + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/thalassa-cloud/cli/internal/thalassaclient" + tcclient "github.com/thalassa-cloud/client-go/pkg/client" +) + +// backupCancelDeletionCmd represents the backup cancel-deletion command +var backupCancelDeletionCmd = &cobra.Command{ + Use: "cancel-deletion", + Short: "Cancel scheduled deletion of a backup", + Long: "Cancel the scheduled deletion of a database backup", + Aliases: []string{"cancel", "undelete"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := thalassaclient.GetThalassaClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + backupIdentity := args[0] + + err = client.DBaaS().CancelDeleteDbBackup(cmd.Context(), backupIdentity) + if err != nil { + if tcclient.IsNotFound(err) { + return fmt.Errorf("backup not found: %s", backupIdentity) + } + return fmt.Errorf("failed to cancel backup deletion: %w", err) + } + + fmt.Printf("Backup deletion cancelled for %s\n", backupIdentity) + return nil + }, +} + +func init() { + BackupCmd.AddCommand(backupCancelDeletionCmd) +} diff --git a/cmd/dbaas/backup/create.go b/cmd/dbaas/backup/create.go new file mode 100644 index 0000000..7e0c516 --- /dev/null +++ b/cmd/dbaas/backup/create.go @@ -0,0 +1,153 @@ +package backup + +import ( + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/thalassa-cloud/cli/internal/completion" + "github.com/thalassa-cloud/cli/internal/table" + "github.com/thalassa-cloud/cli/internal/thalassaclient" + "github.com/thalassa-cloud/client-go/dbaas" + tcclient "github.com/thalassa-cloud/client-go/pkg/client" +) + +var ( + backupCreateName string + backupCreateDescription string + backupCreateLabels []string + backupCreateAnnotations []string + backupCreateRetentionPolicy string + backupCreateWait bool +) + +// backupCreateCmd represents the backup create command +var backupCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a database backup", + Long: "Create a new backup for a database cluster", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.CompleteDbClusterID, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := thalassaclient.GetThalassaClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + clusterIdentity := args[0] + + if backupCreateName == "" { + return fmt.Errorf("name is required") + } + + // Parse labels from key=value format + labels := make(map[string]string) + for _, label := range backupCreateLabels { + parts := strings.SplitN(label, "=", 2) + if len(parts) == 2 { + labels[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + + // Parse annotations from key=value format + annotations := make(map[string]string) + for _, annotation := range backupCreateAnnotations { + parts := strings.SplitN(annotation, "=", 2) + if len(parts) == 2 { + annotations[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + } + + createReq := dbaas.CreateDbClusterBackupRequest{ + Name: backupCreateName, + Labels: labels, + Annotations: annotations, + } + + if backupCreateDescription != "" { + createReq.Description = &backupCreateDescription + } + + if backupCreateRetentionPolicy != "" { + createReq.RetentionPolicy = &backupCreateRetentionPolicy + } + + backup, err := client.DBaaS().CreateDbBackup(cmd.Context(), clusterIdentity, createReq) + if err != nil { + if tcclient.IsNotFound(err) { + return fmt.Errorf("database cluster not found: %s", clusterIdentity) + } + return fmt.Errorf("failed to create backup: %w", err) + } + + if backupCreateWait { + // Poll until backup is completed + for { + backup, err = client.DBaaS().GetDbBackup(cmd.Context(), backup.Identity) + if err != nil { + return fmt.Errorf("failed to get backup: %w", err) + } + // Check if backup is completed (has StoppedAt timestamp) + if backup.StoppedAt != nil { + break + } + // Check for failed status + if backup.Status == dbaas.ObjectStatusFailed { + return fmt.Errorf("backup creation failed: %s", backup.StatusMessage) + } + // Simple polling with sleep + select { + case <-cmd.Context().Done(): + return cmd.Context().Err() + case <-time.After(5 * time.Second): + // Continue polling + } + } + } + + // Output in table format + clusterName := "" + if backup.DbCluster != nil { + clusterName = backup.DbCluster.Name + } + + body := [][]string{ + { + backup.Identity, + clusterName, + string(backup.EngineType), + backup.EngineVersion, + backup.BackupType, + string(backup.Status), + }, + } + + var backupCreateNoHeader bool + if cmd.Flags().Changed(NoHeaderKey) { + backupCreateNoHeader, _ = cmd.Flags().GetBool(NoHeaderKey) + } + if backupCreateNoHeader { + table.Print(nil, body) + } else { + table.Print([]string{"ID", "Cluster", "Engine", "Version", "Type", "Status"}, body) + } + + return nil + }, +} + +func init() { + BackupCmd.AddCommand(backupCreateCmd) + + backupCreateCmd.Flags().Bool(NoHeaderKey, false, "Do not print the header") + backupCreateCmd.Flags().StringVar(&backupCreateName, "name", "", "Name of the backup (required)") + backupCreateCmd.Flags().StringVar(&backupCreateDescription, "description", "", "Description of the backup") + backupCreateCmd.Flags().StringSliceVar(&backupCreateLabels, "labels", []string{}, "Labels in key=value format (can be specified multiple times)") + backupCreateCmd.Flags().StringSliceVar(&backupCreateAnnotations, "annotations", []string{}, "Annotations in key=value format (can be specified multiple times)") + backupCreateCmd.Flags().StringVar(&backupCreateRetentionPolicy, "retention-policy", "", "Retention policy for the backup") + backupCreateCmd.Flags().BoolVar(&backupCreateWait, "wait", false, "Wait for the backup to be completed before returning") + + _ = backupCreateCmd.MarkFlagRequired("name") +} diff --git a/cmd/dbaas/backup/delete.go b/cmd/dbaas/backup/delete.go new file mode 100644 index 0000000..341c338 --- /dev/null +++ b/cmd/dbaas/backup/delete.go @@ -0,0 +1,154 @@ +package backup + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/thalassa-cloud/cli/internal/completion" + "github.com/thalassa-cloud/cli/internal/labels" + "github.com/thalassa-cloud/cli/internal/thalassaclient" + "github.com/thalassa-cloud/client-go/dbaas" + "github.com/thalassa-cloud/client-go/filters" + tcclient "github.com/thalassa-cloud/client-go/pkg/client" +) + +var ( + backupDeleteForce bool + backupDeleteAllFailed bool + backupDeleteLabelSelector string +) + +// backupDeleteCmd represents the backup delete command +var backupDeleteCmd = &cobra.Command{ + Use: "delete", + Short: "Delete database backup(s)", + Long: "Delete database backup(s) by identity, label selector, or all failed backups", + Aliases: []string{"d", "rm", "del", "remove"}, + Args: cobra.MinimumNArgs(0), + ValidArgsFunction: completion.CompleteDbBackupID, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 && !backupDeleteAllFailed && backupDeleteLabelSelector == "" { + return fmt.Errorf("either backup identity(ies), --all-failed, or --selector must be provided") + } + + client, err := thalassaclient.GetThalassaClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + // Collect backups to delete + backupsToDelete := []dbaas.DbClusterBackup{} + + // Build filters for listing backups + f := []filters.Filter{} + + // Add label selector filter if provided + if backupDeleteLabelSelector != "" { + f = append(f, &filters.LabelFilter{ + MatchLabels: labels.ParseLabelSelector(backupDeleteLabelSelector), + }) + } + + // Add status filter for --all-failed + if backupDeleteAllFailed { + f = append(f, &filters.FilterKeyValue{ + Key: "status", + Value: "failed", + }) + } + + // If using --all-failed or --selector, list backups with filters + if backupDeleteAllFailed || backupDeleteLabelSelector != "" { + listRequest := &dbaas.ListDbBackupsRequest{ + Filters: f, + } + allBackups, err := client.DBaaS().ListDbBackupsForOrganisation(cmd.Context(), listRequest) + if err != nil { + return fmt.Errorf("failed to list backups: %w", err) + } + if len(allBackups) == 0 { + if backupDeleteAllFailed && backupDeleteLabelSelector != "" { + fmt.Println("No failed backups found matching the label selector") + } else if backupDeleteAllFailed { + fmt.Println("No failed backups found") + } else { + fmt.Println("No backups found matching the label selector") + } + return nil + } + backupsToDelete = append(backupsToDelete, allBackups...) + } + + // Get backups by identity (if provided) + for _, backupIdentity := range args { + backup, err := client.DBaaS().GetDbBackup(cmd.Context(), backupIdentity) + if err != nil { + if tcclient.IsNotFound(err) { + fmt.Printf("Backup %s not found\n", backupIdentity) + continue + } + return fmt.Errorf("failed to get backup: %w", err) + } + // Check if already added (avoid duplicates when combining with filters) + alreadyAdded := false + for _, existing := range backupsToDelete { + if existing.Identity == backup.Identity { + alreadyAdded = true + break + } + } + if !alreadyAdded { + backupsToDelete = append(backupsToDelete, *backup) + } + } + + if len(backupsToDelete) == 0 { + fmt.Println("No backups to delete") + return nil + } + + // Ask for confirmation unless --force is provided + if !backupDeleteForce { + if len(backupsToDelete) == 1 { + fmt.Printf("Are you sure you want to delete backup %s?\n", backupsToDelete[0].Identity) + } else { + fmt.Printf("Are you sure you want to delete the following backup(s)?\n") + for _, backup := range backupsToDelete { + fmt.Printf(" %s\n", backup.Identity) + } + } + var confirm string + fmt.Printf("Enter 'yes' to confirm: ") + fmt.Scanln(&confirm) + if confirm != "yes" { + fmt.Println("Aborted") + return nil + } + } + + // Delete each backup + for _, backup := range backupsToDelete { + fmt.Printf("Deleting backup: %s\n", backup.Identity) + err := client.DBaaS().DeleteDbBackup(cmd.Context(), backup.Identity) + if err != nil { + if tcclient.IsNotFound(err) { + fmt.Printf("Backup %s not found\n", backup.Identity) + continue + } + return fmt.Errorf("failed to delete backup: %w", err) + } + fmt.Printf("Backup %s deleted successfully\n", backup.Identity) + } + + return nil + }, +} + +func init() { + BackupCmd.AddCommand(backupDeleteCmd) + + backupDeleteCmd.Flags().BoolVar(&backupDeleteForce, "force", false, "Force the deletion and skip the confirmation") + backupDeleteCmd.Flags().BoolVar(&backupDeleteAllFailed, "all-failed", false, "Delete all failed backups") + backupDeleteCmd.Flags().StringVarP(&backupDeleteLabelSelector, "selector", "l", "", "Label selector to filter backups (format: key1=value1,key2=value2)") +} diff --git a/cmd/dbaas/backup/list.go b/cmd/dbaas/backup/list.go new file mode 100644 index 0000000..5747b92 --- /dev/null +++ b/cmd/dbaas/backup/list.go @@ -0,0 +1,265 @@ +package backup + +import ( + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/thalassa-cloud/cli/internal/completion" + "github.com/thalassa-cloud/cli/internal/formattime" + "github.com/thalassa-cloud/cli/internal/labels" + "github.com/thalassa-cloud/cli/internal/table" + "github.com/thalassa-cloud/cli/internal/thalassaclient" + "github.com/thalassa-cloud/client-go/dbaas" + "github.com/thalassa-cloud/client-go/filters" +) + +var ( + backupListLabelSelector string + backupListClusterFilter string + backupListOlderThan string + backupListNewerThan string + backupListStatusFilter []string + backupListNoHeader bool + backupListShowExactTime bool + backupListShowLabels bool +) + +// parseDuration parses a duration string supporting days (d), weeks (w), months (mo), years (y) +// in addition to standard Go duration units (h, m, s, etc.) +func parseDuration(s string) (time.Duration, error) { + // First try standard time.ParseDuration + if d, err := time.ParseDuration(s); err == nil { + return d, nil + } + + // Handle custom units: d, w, mo, y + var total time.Duration + re := regexp.MustCompile(`(\d+)([dwy]|mo)`) + matches := re.FindAllStringSubmatch(s, -1) + + if len(matches) == 0 { + return 0, fmt.Errorf("invalid duration format: %s", s) + } + + for _, match := range matches { + if len(match) != 3 { + continue + } + value, err := strconv.Atoi(match[1]) + if err != nil { + return 0, fmt.Errorf("invalid number in duration: %s", match[1]) + } + + unit := match[2] + switch unit { + case "d": + total += time.Duration(value) * 24 * time.Hour + case "w": + total += time.Duration(value) * 7 * 24 * time.Hour + case "mo": + // Approximate month as 30 days + total += time.Duration(value) * 30 * 24 * time.Hour + case "y": + // Approximate year as 365 days + total += time.Duration(value) * 365 * 24 * time.Hour + } + } + + // Check if there are remaining characters not matched + remaining := re.ReplaceAllString(s, "") + if strings.TrimSpace(remaining) != "" { + return 0, fmt.Errorf("invalid duration format: %s (unrecognized: %s)", s, remaining) + } + + return total, nil +} + +// backupListCmd represents the backup list command +var backupListCmd = &cobra.Command{ + Use: "list", + Short: "List database backups", + Long: "List database backups for a specific cluster or all backups in the organisation", + Aliases: []string{"ls", "get"}, + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completion.CompleteDbClusterID(cmd, args, toComplete) + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := thalassaclient.GetThalassaClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + var backups []dbaas.DbClusterBackup + + // Build filters + f := []filters.Filter{} + + // Add label selector filter if provided + if backupListLabelSelector != "" { + f = append(f, &filters.LabelFilter{ + MatchLabels: labels.ParseLabelSelector(backupListLabelSelector), + }) + } + + // Add cluster filter if provided via flag + if backupListClusterFilter != "" { + f = append(f, &filters.FilterKeyValue{ + Key: "dbCluster", + Value: backupListClusterFilter, + }) + } + + if len(backupListStatusFilter) > 0 { + f = append(f, &filters.FilterKeyValue{ + Key: "status", + Value: strings.Join(backupListStatusFilter, ","), + }) + } + + listRequest := &dbaas.ListDbBackupsRequest{ + Filters: f, + } + + // If cluster identity is provided as argument, list backups for that cluster + if len(args) > 0 { + clusterIdentity := args[0] + backups, err = client.DBaaS().ListDbBackupsForDbCluster(cmd.Context(), clusterIdentity, listRequest) + if err != nil { + return fmt.Errorf("failed to list backups for cluster: %w", err) + } + } else { + // Otherwise list all backups for the organisation + backups, err = client.DBaaS().ListDbBackupsForOrganisation(cmd.Context(), listRequest) + if err != nil { + return fmt.Errorf("failed to list backups: %w", err) + } + } + + // Filter by age and status if specified + now := time.Now() + if backupListOlderThan != "" || backupListNewerThan != "" { + filteredBackups := []dbaas.DbClusterBackup{} + for _, backup := range backups { + // Filter by age + backupAge := now.Sub(backup.CreatedAt) + + // Filter by older-than + if backupListOlderThan != "" { + duration, err := parseDuration(backupListOlderThan) + if err != nil { + return fmt.Errorf("invalid --older-than duration: %w", err) + } + if backupAge < duration { + continue // Skip backups that are not old enough + } + } + + // Filter by newer-than + if backupListNewerThan != "" { + duration, err := parseDuration(backupListNewerThan) + if err != nil { + return fmt.Errorf("invalid --newer-than duration: %w", err) + } + if backupAge > duration { + continue // Skip backups that are too old + } + } + + filteredBackups = append(filteredBackups, backup) + } + backups = filteredBackups + } + + if len(backups) == 0 { + fmt.Println("No backups found") + return nil + } + + body := make([][]string, 0, len(backups)) + for _, backup := range backups { + clusterName := "" + if backup.DbCluster != nil { + clusterName = backup.DbCluster.Name + } + + status := string(backup.Status) + if backup.DeleteScheduledAt != nil { + status = fmt.Sprintf("%s (deletion scheduled)", status) + } + + trigger := string(backup.BackupTrigger) + if backup.BackupSchedule != nil { + trigger = fmt.Sprintf("%s (%s)", trigger, backup.BackupSchedule.Name) + } + + row := []string{ + backup.Identity, + clusterName, + string(backup.EngineType), + backup.EngineVersion, + backup.BackupType, + trigger, + status, + formattime.FormatTime(backup.CreatedAt.Local(), backupListShowExactTime), + } + + if backup.StoppedAt != nil { + row = append(row, formattime.FormatTime(backup.StoppedAt.Local(), backupListShowExactTime)) + } else { + row = append(row, "-") + } + + if backupListShowLabels { + labelStrs := []string{} + for k, v := range backup.Labels { + labelStrs = append(labelStrs, k+"="+v) + } + sort.Strings(labelStrs) + if len(labelStrs) == 0 { + labelStrs = []string{"-"} + } + row = append(row, strings.Join(labelStrs, ",")) + } + + body = append(body, row) + } + + if backupListNoHeader { + table.Print(nil, body) + } else { + headers := []string{"ID", "Cluster", "Engine", "Version", "Type", "Trigger", "Status", "Created", "Completed"} + if backupListShowLabels { + headers = append(headers, "Labels") + } + table.Print(headers, body) + } + + return nil + }, +} + +func init() { + BackupCmd.AddCommand(backupListCmd) + + backupListCmd.Flags().BoolVar(&backupListNoHeader, NoHeaderKey, false, "Do not print the header") + backupListCmd.Flags().BoolVar(&backupListShowExactTime, "exact-time", false, "Show exact time instead of relative time") + backupListCmd.Flags().BoolVar(&backupListShowLabels, "show-labels", false, "Show labels") + backupListCmd.Flags().StringVarP(&backupListLabelSelector, "selector", "l", "", "Label selector to filter backups (format: key1=value1,key2=value2)") + backupListCmd.Flags().StringVar(&backupListClusterFilter, "cluster", "", "Filter by database cluster identity, slug, or name") + backupListCmd.Flags().StringVar(&backupListOlderThan, "older-than", "", "Filter backups older than the specified duration (e.g., 30d, 1w, 1mo, 1y, 24h)") + backupListCmd.Flags().StringVar(&backupListNewerThan, "newer-than", "", "Filter backups newer than the specified duration (e.g., 7d, 1w, 1mo, 1y, 24h)") + backupListCmd.Flags().StringSliceVar(&backupListStatusFilter, "status", []string{}, "Filter by backup status (can be specified multiple times, e.g., --status ready --status failed)") + + // Register completions + backupListCmd.RegisterFlagCompletionFunc("cluster", completion.CompleteDbClusterID) +} diff --git a/cmd/dbaas/backup/view.go b/cmd/dbaas/backup/view.go new file mode 100644 index 0000000..e4b719c --- /dev/null +++ b/cmd/dbaas/backup/view.go @@ -0,0 +1,131 @@ +package backup + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/thalassa-cloud/cli/internal/formattime" + "github.com/thalassa-cloud/cli/internal/table" + "github.com/thalassa-cloud/cli/internal/thalassaclient" + tcclient "github.com/thalassa-cloud/client-go/pkg/client" +) + +var ( + backupViewShowExactTime bool + backupViewNoHeader bool +) + +// backupViewCmd represents the backup view command +var backupViewCmd = &cobra.Command{ + Use: "view", + Short: "View backup details", + Long: "View detailed information about a database backup", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := thalassaclient.GetThalassaClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + backupIdentity := args[0] + + backup, err := client.DBaaS().GetDbBackup(cmd.Context(), backupIdentity) + if err != nil { + if tcclient.IsNotFound(err) { + return fmt.Errorf("backup not found: %s", backupIdentity) + } + return fmt.Errorf("failed to get backup: %w", err) + } + + clusterName := "" + if backup.DbCluster != nil { + clusterName = backup.DbCluster.Name + } + + trigger := string(backup.BackupTrigger) + if backup.BackupSchedule != nil { + trigger = fmt.Sprintf("%s (%s)", trigger, backup.BackupSchedule.Name) + } + + body := [][]string{ + {"ID", backup.Identity}, + {"Cluster", clusterName}, + {"Status", string(backup.Status)}, + {"Engine", string(backup.EngineType)}, + {"Engine Version", backup.EngineVersion}, + {"Backup Type", backup.BackupType}, + {"Trigger", trigger}, + {"Online", fmt.Sprintf("%v", backup.Online)}, + {"Delete Protection", fmt.Sprintf("%v", backup.DeleteProtection)}, + {"Created", formattime.FormatTime(backup.CreatedAt.Local(), backupViewShowExactTime)}, + } + + if backup.StartedAt != nil { + body = append(body, []string{"Started", formattime.FormatTime(backup.StartedAt.Local(), backupViewShowExactTime)}) + } + + if backup.StoppedAt != nil { + body = append(body, []string{"Stopped", formattime.FormatTime(backup.StoppedAt.Local(), backupViewShowExactTime)}) + } + + if backup.StatusMessage != "" { + body = append(body, []string{"Status Message", backup.StatusMessage}) + } + + if backup.BeginLSN != "" { + body = append(body, []string{"Begin LSN", backup.BeginLSN}) + } + + if backup.EndLSN != "" { + body = append(body, []string{"End LSN", backup.EndLSN}) + } + + if backup.BeginWAL != "" { + body = append(body, []string{"Begin WAL", backup.BeginWAL}) + } + + if backup.EndWAL != "" { + body = append(body, []string{"End WAL", backup.EndWAL}) + } + + if backup.DeleteScheduledAt != nil { + body = append(body, []string{"Delete Scheduled At", formattime.FormatTime(backup.DeleteScheduledAt.Local(), backupViewShowExactTime)}) + } + + if len(backup.Labels) > 0 { + labelStrs := []string{} + for k, v := range backup.Labels { + labelStrs = append(labelStrs, k+"="+v) + } + sort.Strings(labelStrs) + body = append(body, []string{"Labels", strings.Join(labelStrs, ", ")}) + } + + if len(backup.Annotations) > 0 { + annotationStrs := []string{} + for k, v := range backup.Annotations { + annotationStrs = append(annotationStrs, k+"="+v) + } + sort.Strings(annotationStrs) + body = append(body, []string{"Annotations", strings.Join(annotationStrs, ", ")}) + } + + if backupViewNoHeader { + table.Print(nil, body) + } else { + table.Print([]string{"Field", "Value"}, body) + } + + return nil + }, +} + +func init() { + BackupCmd.AddCommand(backupViewCmd) + + backupViewCmd.Flags().BoolVar(&backupViewNoHeader, NoHeaderKey, false, "Do not print the header") + backupViewCmd.Flags().BoolVar(&backupViewShowExactTime, "exact-time", false, "Show exact time instead of relative time") +} diff --git a/cmd/dbaas/create.go b/cmd/dbaas/create.go index 50e61a0..ba9a832 100644 --- a/cmd/dbaas/create.go +++ b/cmd/dbaas/create.go @@ -17,28 +17,31 @@ import ( ) var ( - createClusterName string - createClusterDescription string - createClusterEngine string - createClusterEngineVersion string - createClusterInstanceType string - createClusterVpc string - createClusterSubnet string - createClusterStorage int - createclusterVolumeType string - createClusterReplicas int - createClusterLabels []string - createClusterAnnotations []string - createClusterDeleteProtection bool - createClusterWait bool + createClusterName string + createClusterDescription string + createClusterEngine string + createClusterEngineVersion string + createClusterInstanceType string + createClusterVpc string + createClusterSubnet string + createClusterStorage int + createclusterVolumeType string + createClusterReplicas int + createClusterLabels []string + createClusterAnnotations []string + createClusterDeleteProtection bool + createClusterWait bool + createClusterProvisionDbBackupObjectStorageBucket bool + createClusterDbBackupObjectStorageId string ) // createCmd represents the create command var createCmd = &cobra.Command{ - Use: "create", - Short: "Create a database cluster", - Long: "Create a new database cluster in the Thalassa Cloud Platform.", - Args: cobra.NoArgs, + Use: "create", + Aliases: []string{"create-cluster", "cluster-create"}, + Short: "Create a database cluster", + Long: "Create a new database cluster in the Thalassa Cloud Platform.", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { client, err := thalassaclient.GetThalassaClient() if err != nil { @@ -104,6 +107,8 @@ var createCmd = &cobra.Command{ parts := strings.SplitN(label, "=", 2) if len(parts) == 2 { labels[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } else { + labels[label] = "" } } @@ -113,6 +118,8 @@ var createCmd = &cobra.Command{ parts := strings.SplitN(annotation, "=", 2) if len(parts) == 2 { annotations[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } else { + annotations[annotation] = "" } } @@ -128,6 +135,10 @@ var createCmd = &cobra.Command{ Labels: labels, Annotations: annotations, DeleteProtection: createClusterDeleteProtection, + ProvisionDbObjectStore: createClusterProvisionDbBackupObjectStorageBucket, + } + if createClusterDbBackupObjectStorageId != "" { // todo; ensure this exists + req.DbObjectStoreIdentity = &createClusterDbBackupObjectStorageId } if createClusterReplicas > 0 { @@ -222,6 +233,8 @@ func init() { createCmd.Flags().StringSliceVar(&createClusterAnnotations, "annotations", []string{}, "Annotations in key=value format (can be specified multiple times)") createCmd.Flags().BoolVar(&createClusterDeleteProtection, "delete-protection", false, "Enable delete protection") createCmd.Flags().BoolVar(&createClusterWait, "wait", false, "Wait for the database cluster to be available before returning") + createCmd.Flags().BoolVar(&createClusterProvisionDbBackupObjectStorageBucket, "with-backup-bucket", false, "Provision a backup object storage bucket for the database cluster") + createCmd.Flags().StringVar(&createClusterDbBackupObjectStorageId, "backup-object-storage-id", "", "Backup object storage ID (enables backup storage, requires --with-backup-bucket=false)") // Register completions createCmd.RegisterFlagCompletionFunc("vpc", completion.CompleteVPCID) diff --git a/cmd/dbaas/dbaas.go b/cmd/dbaas/dbaas.go index c6a9e56..9da56cd 100644 --- a/cmd/dbaas/dbaas.go +++ b/cmd/dbaas/dbaas.go @@ -2,6 +2,8 @@ package dbaas import ( "github.com/spf13/cobra" + "github.com/thalassa-cloud/cli/cmd/dbaas/backup" + backupschedules "github.com/thalassa-cloud/cli/cmd/dbaas/backup-schedules" ) var DbaasCmd = &cobra.Command{ @@ -13,4 +15,6 @@ var DbaasCmd = &cobra.Command{ } func init() { + DbaasCmd.AddCommand(backup.BackupCmd) + DbaasCmd.AddCommand(backupschedules.BackupSchedulesCmd) } diff --git a/go.mod b/go.mod index f29677f..a1b5d6f 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 - github.com/thalassa-cloud/client-go v0.28.1 + github.com/thalassa-cloud/client-go v0.28.2 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.35.0 k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 diff --git a/go.sum b/go.sum index 44c7f07..ef2f041 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/thalassa-cloud/client-go v0.28.1 h1:yOiDFG60GiWIug8cCm8iPHnOvBs3av6zOmMl+B/IdjM= -github.com/thalassa-cloud/client-go v0.28.1/go.mod h1:CPY800FtJifCr1rJP4giXaXMoq7ierOyMl6KjkAvzm4= +github.com/thalassa-cloud/client-go v0.28.2 h1:UHaR0BMYgVjXbdKkiRQlAVwIblC7vXaGXrbQwELtdN8= +github.com/thalassa-cloud/client-go v0.28.2/go.mod h1:CPY800FtJifCr1rJP4giXaXMoq7ierOyMl6KjkAvzm4= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= diff --git a/internal/completion/completion.go b/internal/completion/completion.go index 49d0d78..72416b0 100644 --- a/internal/completion/completion.go +++ b/internal/completion/completion.go @@ -233,6 +233,36 @@ func CompleteDbClusterID(cmd *cobra.Command, args []string, toComplete string) ( return completions, cobra.ShellCompDirectiveNoFileComp } +// CompleteDbBackupID provides completion for DBaaS backup IDs +func CompleteDbBackupID(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + client, err := thalassaclient.GetThalassaClient() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + backups, err := client.DBaaS().ListDbBackupsForOrganisation(cmd.Context(), &dbaas.ListDbBackupsRequest{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var completions []string + for _, backup := range backups { + // Skip backups that are already in args to avoid duplicates + alreadyAdded := false + for _, arg := range args { + if arg == backup.Identity { + alreadyAdded = true + break + } + } + if !alreadyAdded { + completions = append(completions, backup.Identity) + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp +} + // CompleteOutputFormat provides completion for output format options func CompleteOutputFormat(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{"yaml"}, cobra.ShellCompDirectiveNoFileComp