From 64668f6382ce5e97a492ebf0aacbc43ee5a72d14 Mon Sep 17 00:00:00 2001 From: Thomas Kooi Date: Wed, 15 Jul 2026 16:04:59 +0200 Subject: [PATCH 1/2] fix(dbaas): preserve security groups on cluster update Carry existing security group attachments into update requests and add a --security-groups flag to set or replace them explicitly. --- cmd/dbaas/update.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cmd/dbaas/update.go b/cmd/dbaas/update.go index 8ea8b08..bdc46c7 100644 --- a/cmd/dbaas/update.go +++ b/cmd/dbaas/update.go @@ -23,6 +23,7 @@ var ( updateClusterAnnotations []string updateClusterDeleteProtection bool updateClusterAutoUpgradePolicy string + updateClusterSecurityGroups []string ) // updateCmd represents the update command @@ -64,6 +65,11 @@ var updateCmd = &cobra.Command{ if current.Replicas > 0 { req.Replicas = current.Replicas } + if len(current.SecurityGroups) > 0 { + for _, sg := range current.SecurityGroups { + req.SecurityGroupAttachments = append(req.SecurityGroupAttachments, sg.Identity) + } + } // Update name if provided if cmd.Flags().Changed("name") { @@ -119,6 +125,11 @@ var updateCmd = &cobra.Command{ req.DeleteProtection = updateClusterDeleteProtection } + // Update security groups if provided + if cmd.Flags().Changed("security-groups") { + req.SecurityGroupAttachments = updateClusterSecurityGroups + } + // Update auto-upgrade policy if provided if cmd.Flags().Changed("auto-upgrade-policy") { policy := dbaas.DbClusterAutoUpgradePolicy(updateClusterAutoUpgradePolicy) @@ -188,9 +199,11 @@ func init() { updateCmd.Flags().StringSliceVar(&updateClusterAnnotations, "annotations", []string{}, "Annotations in key=value format (can be specified multiple times)") updateCmd.Flags().BoolVar(&updateClusterDeleteProtection, "delete-protection", false, "Enable or disable delete protection") updateCmd.Flags().StringVar(&updateClusterAutoUpgradePolicy, "auto-upgrade-policy", "", "Auto-upgrade policy: none, latest-version, latest-stable, latest-patch, latest-minor, latest-major") + updateCmd.Flags().StringSliceVar(&updateClusterSecurityGroups, "security-groups", []string{}, "Security group identities to attach") // Register completions _ = updateCmd.RegisterFlagCompletionFunc("instance-type", completion.CompleteDbInstanceType) + _ = updateCmd.RegisterFlagCompletionFunc("security-groups", completion.CompleteSecurityGroupID) _ = updateCmd.RegisterFlagCompletionFunc("auto-upgrade-policy", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return []string{ string(dbaas.DbClusterAutoUpgradePolicyNone), From 0dfaf93a17b543e739117377702e1354a85d97fe Mon Sep 17 00:00:00 2001 From: Thomas Kooi Date: Wed, 15 Jul 2026 16:10:10 +0200 Subject: [PATCH 2/2] feat(dbaas): add restore-from-backup on cluster create Support creating clusters from backups with optional PITR targets, including barman-formatted restore timestamps. --- cmd/dbaas/create.go | 43 +++++++++++++++++++++++++++++- cmd/dbaas/restore.go | 27 +++++++++++++++++++ cmd/dbaas/restore_test.go | 56 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 cmd/dbaas/restore.go create mode 100644 cmd/dbaas/restore_test.go diff --git a/cmd/dbaas/create.go b/cmd/dbaas/create.go index 0127901..4adbbf8 100644 --- a/cmd/dbaas/create.go +++ b/cmd/dbaas/create.go @@ -15,6 +15,7 @@ import ( "github.com/thalassa-cloud/cli/internal/thalassaclient" "github.com/thalassa-cloud/client-go/dbaas" "github.com/thalassa-cloud/client-go/iaas" + tcclient "github.com/thalassa-cloud/client-go/pkg/client" ) var ( @@ -35,6 +36,9 @@ var ( createClusterWaitTimeout time.Duration createClusterProvisionDbBackupObjectStorageBucket bool createClusterDbBackupObjectStorageId string + createClusterRestoreFromBackup string + createClusterRestoreTargetTime string + createClusterRestoreTargetLSN string ) // createCmd represents the create command @@ -42,7 +46,7 @@ var createCmd = &cobra.Command{ Use: "create", Aliases: []string{"create-cluster", "cluster-create"}, Short: "Create a database cluster", - Long: "Create a new database cluster in the Thalassa Cloud Platform.", + Long: "Create a new database cluster in the Thalassa Cloud Platform. Use --restore-from-backup to create a cluster from an existing backup.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { client, err := thalassaclient.GetThalassaClient() @@ -68,6 +72,12 @@ var createCmd = &cobra.Command{ if createClusterReplicas < 0 { return fmt.Errorf("replicas must be 0 or greater") } + if createClusterRestoreTargetTime != "" && createClusterRestoreTargetLSN != "" { + return fmt.Errorf("--restore-target-time and --restore-target-lsn are mutually exclusive") + } + if (createClusterRestoreTargetTime != "" || createClusterRestoreTargetLSN != "") && createClusterRestoreFromBackup == "" { + return fmt.Errorf("--restore-from-backup is required when using a restore recovery target") + } // Resolve volume type volumeTypes, err := client.IaaS().ListVolumeTypes(cmd.Context(), &iaas.ListVolumeTypesRequest{}) @@ -147,6 +157,33 @@ var createCmd = &cobra.Command{ req.Replicas = createClusterReplicas } + if createClusterRestoreFromBackup != "" { + _, err := client.DBaaS().GetDbBackup(cmd.Context(), createClusterRestoreFromBackup) + if err != nil { + if tcclient.IsNotFound(err) { + return fmt.Errorf("backup not found: %s", createClusterRestoreFromBackup) + } + return fmt.Errorf("failed to get backup: %w", err) + } + req.RestoreFromBackupIdentity = &createClusterRestoreFromBackup + + if createClusterRestoreTargetTime != "" || createClusterRestoreTargetLSN != "" { + recoveryTarget := &dbaas.RestoreRecoveryTarget{} + if createClusterRestoreTargetTime != "" { + targetTime, err := parseBarmanRestoreTargetTime(createClusterRestoreTargetTime) + if err != nil { + return fmt.Errorf("invalid restore target time: %w", err) + } + recoveryTarget.TargetTime = &targetTime + } + if createClusterRestoreTargetLSN != "" { + lsn := strings.TrimSpace(createClusterRestoreTargetLSN) + recoveryTarget.TargetLSN = &lsn + } + req.RestoreRecoveryTarget = recoveryTarget + } + } + cluster, err := client.DBaaS().CreateDbCluster(cmd.Context(), req) if err != nil { return fmt.Errorf("failed to create database cluster: %w", err) @@ -239,11 +276,15 @@ func init() { createCmd.Flags().DurationVar(&createClusterWaitTimeout, "wait-timeout", 20*time.Minute, "Maximum time to wait for the database cluster to be available") 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)") + createCmd.Flags().StringVar(&createClusterRestoreFromBackup, "restore-from-backup", "", "Backup identity to restore the cluster from") + createCmd.Flags().StringVar(&createClusterRestoreTargetTime, "restore-target-time", "", barmanTargetTimeDescription+" (requires --restore-from-backup)") + createCmd.Flags().StringVar(&createClusterRestoreTargetLSN, "restore-target-lsn", "", "Point-in-time recovery target LSN (requires --restore-from-backup)") // Register completions _ = createCmd.RegisterFlagCompletionFunc("vpc", completion.CompleteVPCID) _ = createCmd.RegisterFlagCompletionFunc("subnet", completion.CompleteSubnetEnhanced) _ = createCmd.RegisterFlagCompletionFunc("engine-version", completion.CompleteDbEngineVersion) + _ = createCmd.RegisterFlagCompletionFunc("restore-from-backup", completion.CompleteDbBackupID) _ = createCmd.MarkFlagRequired("name") _ = createCmd.MarkFlagRequired("engine") diff --git a/cmd/dbaas/restore.go b/cmd/dbaas/restore.go new file mode 100644 index 0000000..319354c --- /dev/null +++ b/cmd/dbaas/restore.go @@ -0,0 +1,27 @@ +package dbaas + +import ( + "fmt" + "strings" + "time" +) + +const ( + barmanTargetTimeExample = "2023-08-11 11:14:21.00000+02" + barmanTargetTimeDescription = "Timestamp to restore to in barman format (YYYY-MM-DD HH:MM:SS.00000±TZ). Example: '" + barmanTargetTimeExample + "'" + barmanTargetTimeLayout = "2006-01-02 15:04:05.00000-07" +) + +func parseBarmanRestoreTargetTime(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("restore target time is empty") + } + + targetTime, err := time.Parse(barmanTargetTimeLayout, value) + if err != nil { + return "", fmt.Errorf("parse barman target time: %w", err) + } + + return targetTime.Format(barmanTargetTimeLayout), nil +} diff --git a/cmd/dbaas/restore_test.go b/cmd/dbaas/restore_test.go new file mode 100644 index 0000000..57e0908 --- /dev/null +++ b/cmd/dbaas/restore_test.go @@ -0,0 +1,56 @@ +package dbaas + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseBarmanRestoreTargetTime(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + { + name: "valid positive offset", + input: "2023-08-11 11:14:21.00000+02", + want: "2023-08-11 11:14:21.00000+02", + }, + { + name: "valid negative offset", + input: "2023-08-11 11:14:21.00000-05", + want: "2023-08-11 11:14:21.00000-05", + }, + { + name: "trims whitespace", + input: " 2023-08-11 11:14:21.00000+02 ", + want: "2023-08-11 11:14:21.00000+02", + }, + { + name: "invalid format", + input: "2026-07-15T10:00:00Z", + wantErr: true, + }, + { + name: "empty", + input: " ", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseBarmanRestoreTargetTime(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +}