From a18093ead16123341b6e535b27ca0f321e56543e Mon Sep 17 00:00:00 2001 From: dhernando Date: Fri, 13 Mar 2026 17:26:17 +0100 Subject: [PATCH] feat: add backup management commands --- Makefile | 2 +- internal/cli/root.go | 2 + internal/cmd/backup/backup.go | 32 ++++++++ internal/cmd/backup/completion.go | 46 +++++++++++ internal/cmd/backup/create.go | 72 +++++++++++++++++ internal/cmd/backup/create_test.go | 100 +++++++++++++++++++++++ internal/cmd/backup/delete.go | 59 ++++++++++++++ internal/cmd/backup/delete_test.go | 43 ++++++++++ internal/cmd/backup/describe.go | 68 ++++++++++++++++ internal/cmd/backup/describe_test.go | 75 +++++++++++++++++ internal/cmd/backup/list.go | 73 +++++++++++++++++ internal/cmd/backup/list_test.go | 115 +++++++++++++++++++++++++++ internal/qcloudapi/client.go | 8 ++ internal/testutil/fake_backup.go | 113 ++++++++++++++++++++++++++ internal/testutil/server.go | 5 ++ 15 files changed, 812 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/backup/backup.go create mode 100644 internal/cmd/backup/completion.go create mode 100644 internal/cmd/backup/create.go create mode 100644 internal/cmd/backup/create_test.go create mode 100644 internal/cmd/backup/delete.go create mode 100644 internal/cmd/backup/delete_test.go create mode 100644 internal/cmd/backup/describe.go create mode 100644 internal/cmd/backup/describe_test.go create mode 100644 internal/cmd/backup/list.go create mode 100644 internal/cmd/backup/list_test.go create mode 100644 internal/testutil/fake_backup.go diff --git a/Makefile b/Makefile index 0877bf7..8ff09d5 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ lint: golangci-lint run format: - $(GOLANGCI_LINT) run --fix + golangci-lint run --fix clean: rm -rf build/ diff --git a/internal/cli/root.go b/internal/cli/root.go index 4fa49f7..5996f61 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/cobra" + "github.com/qdrant/qcloud-cli/internal/cmd/backup" "github.com/qdrant/qcloud-cli/internal/cmd/cluster" cmdcontext "github.com/qdrant/qcloud-cli/internal/cmd/context" "github.com/qdrant/qcloud-cli/internal/cmd/version" @@ -55,6 +56,7 @@ func NewRootCommand(s *state.State) *cobra.Command { cmd.AddCommand(version.NewCommand(s)) cmd.AddCommand(cluster.NewCommand(s)) cmd.AddCommand(cmdcontext.NewCommand(s)) + cmd.AddCommand(backup.NewCommand(s)) return cmd } diff --git a/internal/cmd/backup/backup.go b/internal/cmd/backup/backup.go new file mode 100644 index 0000000..b1f318c --- /dev/null +++ b/internal/cmd/backup/backup.go @@ -0,0 +1,32 @@ +package backup + +import ( + "strings" + + "github.com/spf13/cobra" + + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +// NewCommand creates the "backup" parent command and registers all subcommands. +func NewCommand(s *state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "backup", + Short: "Manage Qdrant Cloud backups", + Args: cobra.NoArgs, + } + cmd.AddCommand( + newListCommand(s), + newDescribeCommand(s), + newCreateCommand(s), + newDeleteCommand(s), + ) + return cmd +} + +// backupStatusString returns a concise status label for a BackupStatus. +func backupStatusString(s backupv1.BackupStatus) string { + return strings.TrimPrefix(s.String(), "BACKUP_STATUS_") +} diff --git a/internal/cmd/backup/completion.go b/internal/cmd/backup/completion.go new file mode 100644 index 0000000..7176f29 --- /dev/null +++ b/internal/cmd/backup/completion.go @@ -0,0 +1,46 @@ +package backup + +import ( + "github.com/spf13/cobra" + + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +// backupIDCompletion returns a ValidArgsFunction that completes backup IDs. +func backupIDCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + accountID, err := s.AccountID() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + req := &backupv1.ListBackupsRequest{AccountId: accountID} + if cmd.Flags().Changed("cluster-id") { + clusterID, _ := cmd.Flags().GetString("cluster-id") + req.ClusterId = &clusterID + } + + resp, err := client.Backup().ListBackups(ctx, req) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + completions := make([]string, 0, len(resp.GetItems())) + for _, b := range resp.GetItems() { + completions = append(completions, b.GetId()+"\t"+b.GetName()) + } + return completions, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/internal/cmd/backup/create.go b/internal/cmd/backup/create.go new file mode 100644 index 0000000..e2831fa --- /dev/null +++ b/internal/cmd/backup/create.go @@ -0,0 +1,72 @@ +package backup + +import ( + "fmt" + "io" + "time" + + "github.com/spf13/cobra" + "google.golang.org/protobuf/types/known/durationpb" + + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/completion" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newCreateCommand(s *state.State) *cobra.Command { + cmd := base.CreateCmd[*backupv1.Backup]{ + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a backup for a cluster", + Args: cobra.NoArgs, + } + cmd.Flags().String("cluster-id", "", "Cluster ID to back up (required)") + cmd.Flags().Uint32("retention-days", 0, "Retention period in days (1-365) (required)") + _ = cmd.MarkFlagRequired("cluster-id") + _ = cmd.MarkFlagRequired("retention-days") + return cmd + }, + Run: func(s *state.State, cmd *cobra.Command, args []string) (*backupv1.Backup, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + + clusterID, _ := cmd.Flags().GetString("cluster-id") + retentionDays, _ := cmd.Flags().GetUint32("retention-days") + + if retentionDays < 1 { + return nil, fmt.Errorf("--retention-days must be at least 1") + } + + d := time.Duration(retentionDays) * 24 * time.Hour + b := &backupv1.Backup{ + AccountId: accountID, + ClusterId: clusterID, + RetentionPeriod: durationpb.New(d), + } + + resp, err := client.Backup().CreateBackup(ctx, &backupv1.CreateBackupRequest{ + Backup: b, + }) + if err != nil { + return nil, fmt.Errorf("failed to create backup: %w", err) + } + return resp.GetBackup(), nil + }, + PrintResource: func(_ *cobra.Command, out io.Writer, b *backupv1.Backup) { + fmt.Fprintf(out, "Backup %s created for cluster %s.\n", b.GetId(), b.GetClusterId()) + }, + }.CobraCommand(s) + _ = cmd.RegisterFlagCompletionFunc("cluster-id", completion.ClusterIDCompletion(s)) + return cmd +} diff --git a/internal/cmd/backup/create_test.go b/internal/cmd/backup/create_test.go new file mode 100644 index 0000000..55ac0c6 --- /dev/null +++ b/internal/cmd/backup/create_test.go @@ -0,0 +1,100 @@ +package backup_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestBackupCreate_Success(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + env.BackupServer.CreateBackupFunc = func(_ context.Context, req *backupv1.CreateBackupRequest) (*backupv1.CreateBackupResponse, error) { + assert.Equal(t, "test-account-id", req.GetBackup().GetAccountId()) + assert.Equal(t, "cluster-abc", req.GetBackup().GetClusterId()) + return &backupv1.CreateBackupResponse{ + Backup: &backupv1.Backup{Id: "backup-new", ClusterId: "cluster-abc"}, + }, nil + } + + stdout, _, err := testutil.Exec(t, env, "backup", "create", "--cluster-id=cluster-abc", "--retention-days=7") + require.NoError(t, err) + assert.Contains(t, stdout, "backup-new") + assert.Contains(t, stdout, "cluster-abc") +} + +func TestBackupCreate_WithRetention(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + var capturedRetention int64 + env.BackupServer.CreateBackupFunc = func(_ context.Context, req *backupv1.CreateBackupRequest) (*backupv1.CreateBackupResponse, error) { + if req.GetBackup().GetRetentionPeriod() != nil { + capturedRetention = int64(req.GetBackup().GetRetentionPeriod().AsDuration().Hours()) / 24 + } + return &backupv1.CreateBackupResponse{ + Backup: &backupv1.Backup{Id: "backup-ret", ClusterId: "cluster-abc"}, + }, nil + } + + _, _, err := testutil.Exec(t, env, "backup", "create", "--cluster-id=cluster-abc", "--retention-days=7") + require.NoError(t, err) + assert.Equal(t, int64(7), capturedRetention) +} + +func TestBackupCreate_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + env.BackupServer.CreateBackupFunc = func(_ context.Context, _ *backupv1.CreateBackupRequest) (*backupv1.CreateBackupResponse, error) { + return &backupv1.CreateBackupResponse{ + Backup: &backupv1.Backup{Id: "backup-json", ClusterId: "cluster-123"}, + }, nil + } + + stdout, _, err := testutil.Exec(t, env, "backup", "create", "--cluster-id=cluster-123", "--retention-days=7", "--json") + require.NoError(t, err) + + var result struct { + ID string `json:"id"` + ClusterID string `json:"clusterId"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.Equal(t, "backup-json", result.ID) +} + +func TestBackupCreate_InvalidRetention(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + _, _, err := testutil.Exec(t, env, "backup", "create", "--cluster-id=cluster-abc", "--retention-days=0") + require.Error(t, err) +} + +func TestBackupCreate_MissingClusterID(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + _, _, err := testutil.Exec(t, env, "backup", "create") + require.Error(t, err) +} + +func TestBackupCreate_APIError(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + env.BackupServer.CreateBackupFunc = func(_ context.Context, _ *backupv1.CreateBackupRequest) (*backupv1.CreateBackupResponse, error) { + return nil, assert.AnError + } + + _, _, err := testutil.Exec(t, env, "backup", "create", "--cluster-id=cluster-abc", "--retention-days=7") + require.Error(t, err) +} diff --git a/internal/cmd/backup/delete.go b/internal/cmd/backup/delete.go new file mode 100644 index 0000000..633e5e0 --- /dev/null +++ b/internal/cmd/backup/delete.go @@ -0,0 +1,59 @@ +package backup + +import ( + "fmt" + + "github.com/spf13/cobra" + + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newDeleteCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a backup", + Args: util.ExactArgs(1, "a backup ID"), + } + cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + return cmd + }, + ValidArgsFunction: backupIDCompletion(s), + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + backupID := args[0] + + force, _ := cmd.Flags().GetBool("force") + if !util.ConfirmAction(force, fmt.Sprintf("Are you sure you want to delete backup %s?", backupID)) { + fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") + return nil + } + + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return err + } + + accountID, err := s.AccountID() + if err != nil { + return err + } + + _, err = client.Backup().DeleteBackup(ctx, &backupv1.DeleteBackupRequest{ + AccountId: accountID, + BackupId: backupID, + }) + if err != nil { + return fmt.Errorf("failed to delete backup: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Backup %s deleted.\n", backupID) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/backup/delete_test.go b/internal/cmd/backup/delete_test.go new file mode 100644 index 0000000..dc24bcb --- /dev/null +++ b/internal/cmd/backup/delete_test.go @@ -0,0 +1,43 @@ +package backup_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestBackupDelete_WithForce(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + var capturedBackupID string + env.BackupServer.DeleteBackupFunc = func(_ context.Context, req *backupv1.DeleteBackupRequest) (*backupv1.DeleteBackupResponse, error) { + assert.Equal(t, "test-account-id", req.GetAccountId()) + capturedBackupID = req.GetBackupId() + return &backupv1.DeleteBackupResponse{}, nil + } + + stdout, _, err := testutil.Exec(t, env, "backup", "delete", "backup-abc", "--force") + require.NoError(t, err) + assert.Equal(t, "backup-abc", capturedBackupID) + assert.Contains(t, stdout, "backup-abc") + assert.Contains(t, stdout, "deleted") +} + +func TestBackupDelete_APIError(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + env.BackupServer.DeleteBackupFunc = func(_ context.Context, _ *backupv1.DeleteBackupRequest) (*backupv1.DeleteBackupResponse, error) { + return nil, assert.AnError + } + + _, _, err := testutil.Exec(t, env, "backup", "delete", "backup-abc", "--force") + require.Error(t, err) +} diff --git a/internal/cmd/backup/describe.go b/internal/cmd/backup/describe.go new file mode 100644 index 0000000..75afd47 --- /dev/null +++ b/internal/cmd/backup/describe.go @@ -0,0 +1,68 @@ +package backup + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newDescribeCommand(s *state.State) *cobra.Command { + return base.DescribeCmd[*backupv1.Backup]{ + Use: "describe ", + Short: "Describe a backup", + Args: util.ExactArgs(1, "a backup ID"), + ValidArgsFunction: backupIDCompletion(s), + Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*backupv1.Backup, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + + resp, err := client.Backup().GetBackup(ctx, &backupv1.GetBackupRequest{ + AccountId: accountID, + BackupId: args[0], + }) + if err != nil { + return nil, fmt.Errorf("failed to get backup: %w", err) + } + return resp.GetBackup(), nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, b *backupv1.Backup) error { + fmt.Fprintf(w, "ID: %s\n", b.GetId()) + fmt.Fprintf(w, "Name: %s\n", b.GetName()) + fmt.Fprintf(w, "Cluster: %s\n", b.GetClusterId()) + fmt.Fprintf(w, "Status: %s\n", backupStatusString(b.GetStatus())) + if b.GetCreatedAt() != nil { + t := b.GetCreatedAt().AsTime() + fmt.Fprintf(w, "Created: %s (%s)\n", output.HumanTime(t), output.FullDateTime(t)) + } + if b.GetBackupScheduleId() != "" { + fmt.Fprintf(w, "Schedule: %s\n", b.GetBackupScheduleId()) + } + if b.GetRetentionPeriod() != nil { + days := int64(b.GetRetentionPeriod().AsDuration().Hours()) / 24 + fmt.Fprintf(w, "Retention: %d days\n", days) + } + if b.GetClusterInfo() != nil { + ci := b.GetClusterInfo() + fmt.Fprintf(w, "Cloud: %s\n", ci.GetCloudProviderId()) + fmt.Fprintf(w, "Region: %s\n", ci.GetCloudProviderRegionId()) + } + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/backup/describe_test.go b/internal/cmd/backup/describe_test.go new file mode 100644 index 0000000..5982981 --- /dev/null +++ b/internal/cmd/backup/describe_test.go @@ -0,0 +1,75 @@ +package backup_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestBackupDescribe_TextOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + env.BackupServer.GetBackupFunc = func(_ context.Context, req *backupv1.GetBackupRequest) (*backupv1.GetBackupResponse, error) { + assert.Equal(t, "test-account-id", req.GetAccountId()) + assert.Equal(t, "backup-abc", req.GetBackupId()) + return &backupv1.GetBackupResponse{ + Backup: &backupv1.Backup{ + Id: "backup-abc", + Name: "my-backup", + ClusterId: "cluster-123", + Status: backupv1.BackupStatus_BACKUP_STATUS_SUCCEEDED, + CreatedAt: timestamppb.Now(), + }, + }, nil + } + + stdout, _, err := testutil.Exec(t, env, "backup", "describe", "backup-abc") + require.NoError(t, err) + assert.Contains(t, stdout, "backup-abc") + assert.Contains(t, stdout, "my-backup") + assert.Contains(t, stdout, "cluster-123") + assert.Contains(t, stdout, "SUCCEEDED") +} + +func TestBackupDescribe_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + env.BackupServer.GetBackupFunc = func(_ context.Context, _ *backupv1.GetBackupRequest) (*backupv1.GetBackupResponse, error) { + return &backupv1.GetBackupResponse{ + Backup: &backupv1.Backup{Id: "backup-json", ClusterId: "cluster-xyz"}, + }, nil + } + + stdout, _, err := testutil.Exec(t, env, "backup", "describe", "backup-json", "--json") + require.NoError(t, err) + + var result struct { + ID string `json:"id"` + ClusterID string `json:"clusterId"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.Equal(t, "backup-json", result.ID) + assert.Equal(t, "cluster-xyz", result.ClusterID) +} + +func TestBackupDescribe_APIError(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + env.BackupServer.GetBackupFunc = func(_ context.Context, _ *backupv1.GetBackupRequest) (*backupv1.GetBackupResponse, error) { + return nil, assert.AnError + } + + _, _, err := testutil.Exec(t, env, "backup", "describe", "backup-abc") + require.Error(t, err) +} diff --git a/internal/cmd/backup/list.go b/internal/cmd/backup/list.go new file mode 100644 index 0000000..765cce1 --- /dev/null +++ b/internal/cmd/backup/list.go @@ -0,0 +1,73 @@ +package backup + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/completion" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newListCommand(s *state.State) *cobra.Command { + cmd := base.ListCmd[*backupv1.ListBackupsResponse]{ + Use: "list", + Short: "List backups", + Fetch: func(s *state.State, cmd *cobra.Command) (*backupv1.ListBackupsResponse, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + + req := &backupv1.ListBackupsRequest{AccountId: accountID} + if cmd.Flags().Changed("cluster-id") { + clusterID, _ := cmd.Flags().GetString("cluster-id") + req.ClusterId = &clusterID + } + + resp, err := client.Backup().ListBackups(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to list backups: %w", err) + } + return resp, nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, resp *backupv1.ListBackupsResponse) error { + t := output.NewTable[*backupv1.Backup](w) + t.AddField("ID", func(v *backupv1.Backup) string { + return v.GetId() + }) + t.AddField("NAME", func(v *backupv1.Backup) string { + return v.GetName() + }) + t.AddField("CLUSTER", func(v *backupv1.Backup) string { + return v.GetClusterId() + }) + t.AddField("STATUS", func(v *backupv1.Backup) string { + return backupStatusString(v.GetStatus()) + }) + t.AddField("CREATED", func(v *backupv1.Backup) string { + if v.GetCreatedAt() != nil { + return output.HumanTime(v.GetCreatedAt().AsTime()) + } + return "" + }) + t.Write(resp.GetItems()) + return nil + }, + }.CobraCommand(s) + + cmd.Flags().String("cluster-id", "", "Filter by cluster ID") + _ = cmd.RegisterFlagCompletionFunc("cluster-id", completion.ClusterIDCompletion(s)) + return cmd +} diff --git a/internal/cmd/backup/list_test.go b/internal/cmd/backup/list_test.go new file mode 100644 index 0000000..b608777 --- /dev/null +++ b/internal/cmd/backup/list_test.go @@ -0,0 +1,115 @@ +package backup_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestBackupList_TableOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + env.BackupServer.ListBackupsFunc = func(_ context.Context, req *backupv1.ListBackupsRequest) (*backupv1.ListBackupsResponse, error) { + assert.Equal(t, "test-account-id", req.GetAccountId()) + return &backupv1.ListBackupsResponse{ + Items: []*backupv1.Backup{ + { + Id: "backup-1", + Name: "my-backup", + ClusterId: "cluster-abc", + Status: backupv1.BackupStatus_BACKUP_STATUS_SUCCEEDED, + CreatedAt: timestamppb.Now(), + }, + }, + }, nil + } + + stdout, _, err := testutil.Exec(t, env, "backup", "list") + require.NoError(t, err) + assert.Contains(t, stdout, "ID") + assert.Contains(t, stdout, "NAME") + assert.Contains(t, stdout, "CLUSTER") + assert.Contains(t, stdout, "STATUS") + assert.Contains(t, stdout, "CREATED") + assert.Contains(t, stdout, "backup-1") + assert.Contains(t, stdout, "my-backup") + assert.Contains(t, stdout, "cluster-abc") + assert.Contains(t, stdout, "SUCCEEDED") +} + +func TestBackupList_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + env.BackupServer.ListBackupsFunc = func(_ context.Context, _ *backupv1.ListBackupsRequest) (*backupv1.ListBackupsResponse, error) { + return &backupv1.ListBackupsResponse{ + Items: []*backupv1.Backup{ + {Id: "backup-json", ClusterId: "cluster-123"}, + }, + }, nil + } + + stdout, _, err := testutil.Exec(t, env, "backup", "list", "--json") + require.NoError(t, err) + + var result struct { + Items []struct { + ID string `json:"id"` + ClusterID string `json:"clusterId"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + require.Len(t, result.Items, 1) + assert.Equal(t, "backup-json", result.Items[0].ID) + assert.Equal(t, "cluster-123", result.Items[0].ClusterID) +} + +func TestBackupList_EmptyResponse(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + env.BackupServer.ListBackupsFunc = func(_ context.Context, _ *backupv1.ListBackupsRequest) (*backupv1.ListBackupsResponse, error) { + return &backupv1.ListBackupsResponse{}, nil + } + + stdout, _, err := testutil.Exec(t, env, "backup", "list") + require.NoError(t, err) + assert.Contains(t, stdout, "ID") + assert.Contains(t, stdout, "STATUS") +} + +func TestBackupList_ClusterIDFilter(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + var capturedClusterID string + env.BackupServer.ListBackupsFunc = func(_ context.Context, req *backupv1.ListBackupsRequest) (*backupv1.ListBackupsResponse, error) { + capturedClusterID = req.GetClusterId() + return &backupv1.ListBackupsResponse{}, nil + } + + _, _, err := testutil.Exec(t, env, "backup", "list", "--cluster-id=my-cluster") + require.NoError(t, err) + assert.Equal(t, "my-cluster", capturedClusterID) +} + +func TestBackupList_APIError(t *testing.T) { + env := testutil.NewTestEnv(t) + t.Cleanup(env.Cleanup) + + env.BackupServer.ListBackupsFunc = func(_ context.Context, _ *backupv1.ListBackupsRequest) (*backupv1.ListBackupsResponse, error) { + return nil, assert.AnError + } + + _, _, err := testutil.Exec(t, env, "backup", "list") + require.Error(t, err) +} diff --git a/internal/qcloudapi/client.go b/internal/qcloudapi/client.go index 5ab72f1..c8f14a2 100644 --- a/internal/qcloudapi/client.go +++ b/internal/qcloudapi/client.go @@ -9,6 +9,7 @@ import ( bookingv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/booking/v1" clusterauthv2 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/auth/v2" + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" platformv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/platform/v1" ) @@ -20,6 +21,7 @@ type Client struct { booking bookingv1.BookingServiceClient platform platformv1.PlatformServiceClient databaseApiKey clusterauthv2.DatabaseApiKeyServiceClient + backup backupv1.BackupServiceClient } // New creates a new gRPC client connected to the given endpoint with the given API key. @@ -48,6 +50,7 @@ func newFromConn(conn *grpc.ClientConn) *Client { booking: bookingv1.NewBookingServiceClient(conn), platform: platformv1.NewPlatformServiceClient(conn), databaseApiKey: clusterauthv2.NewDatabaseApiKeyServiceClient(conn), + backup: backupv1.NewBackupServiceClient(conn), } } @@ -71,6 +74,11 @@ func (c *Client) DatabaseApiKey() clusterauthv2.DatabaseApiKeyServiceClient { return c.databaseApiKey } +// Backup returns the BackupService gRPC client. +func (c *Client) Backup() backupv1.BackupServiceClient { + return c.backup +} + // Close closes the underlying gRPC connection. func (c *Client) Close() error { return c.conn.Close() diff --git a/internal/testutil/fake_backup.go b/internal/testutil/fake_backup.go new file mode 100644 index 0000000..c60b660 --- /dev/null +++ b/internal/testutil/fake_backup.go @@ -0,0 +1,113 @@ +package testutil + +import ( + "context" + + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" +) + +// FakeBackupService is a test fake that implements BackupServiceServer. +// Set the function fields to control responses per test. +type FakeBackupService struct { + backupv1.UnimplementedBackupServiceServer + + ListBackupsFunc func(context.Context, *backupv1.ListBackupsRequest) (*backupv1.ListBackupsResponse, error) + GetBackupFunc func(context.Context, *backupv1.GetBackupRequest) (*backupv1.GetBackupResponse, error) + CreateBackupFunc func(context.Context, *backupv1.CreateBackupRequest) (*backupv1.CreateBackupResponse, error) + DeleteBackupFunc func(context.Context, *backupv1.DeleteBackupRequest) (*backupv1.DeleteBackupResponse, error) + ListBackupRestoresFunc func(context.Context, *backupv1.ListBackupRestoresRequest) (*backupv1.ListBackupRestoresResponse, error) + RestoreBackupFunc func(context.Context, *backupv1.RestoreBackupRequest) (*backupv1.RestoreBackupResponse, error) + ListBackupSchedulesFunc func(context.Context, *backupv1.ListBackupSchedulesRequest) (*backupv1.ListBackupSchedulesResponse, error) + GetBackupScheduleFunc func(context.Context, *backupv1.GetBackupScheduleRequest) (*backupv1.GetBackupScheduleResponse, error) + CreateBackupScheduleFunc func(context.Context, *backupv1.CreateBackupScheduleRequest) (*backupv1.CreateBackupScheduleResponse, error) + UpdateBackupScheduleFunc func(context.Context, *backupv1.UpdateBackupScheduleRequest) (*backupv1.UpdateBackupScheduleResponse, error) + DeleteBackupScheduleFunc func(context.Context, *backupv1.DeleteBackupScheduleRequest) (*backupv1.DeleteBackupScheduleResponse, error) +} + +// ListBackups delegates to ListBackupsFunc if set. +func (f *FakeBackupService) ListBackups(ctx context.Context, req *backupv1.ListBackupsRequest) (*backupv1.ListBackupsResponse, error) { + if f.ListBackupsFunc != nil { + return f.ListBackupsFunc(ctx, req) + } + return f.UnimplementedBackupServiceServer.ListBackups(ctx, req) +} + +// GetBackup delegates to GetBackupFunc if set. +func (f *FakeBackupService) GetBackup(ctx context.Context, req *backupv1.GetBackupRequest) (*backupv1.GetBackupResponse, error) { + if f.GetBackupFunc != nil { + return f.GetBackupFunc(ctx, req) + } + return f.UnimplementedBackupServiceServer.GetBackup(ctx, req) +} + +// CreateBackup delegates to CreateBackupFunc if set. +func (f *FakeBackupService) CreateBackup(ctx context.Context, req *backupv1.CreateBackupRequest) (*backupv1.CreateBackupResponse, error) { + if f.CreateBackupFunc != nil { + return f.CreateBackupFunc(ctx, req) + } + return f.UnimplementedBackupServiceServer.CreateBackup(ctx, req) +} + +// DeleteBackup delegates to DeleteBackupFunc if set. +func (f *FakeBackupService) DeleteBackup(ctx context.Context, req *backupv1.DeleteBackupRequest) (*backupv1.DeleteBackupResponse, error) { + if f.DeleteBackupFunc != nil { + return f.DeleteBackupFunc(ctx, req) + } + return f.UnimplementedBackupServiceServer.DeleteBackup(ctx, req) +} + +// ListBackupRestores delegates to ListBackupRestoresFunc if set. +func (f *FakeBackupService) ListBackupRestores(ctx context.Context, req *backupv1.ListBackupRestoresRequest) (*backupv1.ListBackupRestoresResponse, error) { + if f.ListBackupRestoresFunc != nil { + return f.ListBackupRestoresFunc(ctx, req) + } + return f.UnimplementedBackupServiceServer.ListBackupRestores(ctx, req) +} + +// RestoreBackup delegates to RestoreBackupFunc if set. +func (f *FakeBackupService) RestoreBackup(ctx context.Context, req *backupv1.RestoreBackupRequest) (*backupv1.RestoreBackupResponse, error) { + if f.RestoreBackupFunc != nil { + return f.RestoreBackupFunc(ctx, req) + } + return f.UnimplementedBackupServiceServer.RestoreBackup(ctx, req) +} + +// ListBackupSchedules delegates to ListBackupSchedulesFunc if set. +func (f *FakeBackupService) ListBackupSchedules(ctx context.Context, req *backupv1.ListBackupSchedulesRequest) (*backupv1.ListBackupSchedulesResponse, error) { + if f.ListBackupSchedulesFunc != nil { + return f.ListBackupSchedulesFunc(ctx, req) + } + return f.UnimplementedBackupServiceServer.ListBackupSchedules(ctx, req) +} + +// GetBackupSchedule delegates to GetBackupScheduleFunc if set. +func (f *FakeBackupService) GetBackupSchedule(ctx context.Context, req *backupv1.GetBackupScheduleRequest) (*backupv1.GetBackupScheduleResponse, error) { + if f.GetBackupScheduleFunc != nil { + return f.GetBackupScheduleFunc(ctx, req) + } + return f.UnimplementedBackupServiceServer.GetBackupSchedule(ctx, req) +} + +// CreateBackupSchedule delegates to CreateBackupScheduleFunc if set. +func (f *FakeBackupService) CreateBackupSchedule(ctx context.Context, req *backupv1.CreateBackupScheduleRequest) (*backupv1.CreateBackupScheduleResponse, error) { + if f.CreateBackupScheduleFunc != nil { + return f.CreateBackupScheduleFunc(ctx, req) + } + return f.UnimplementedBackupServiceServer.CreateBackupSchedule(ctx, req) +} + +// UpdateBackupSchedule delegates to UpdateBackupScheduleFunc if set. +func (f *FakeBackupService) UpdateBackupSchedule(ctx context.Context, req *backupv1.UpdateBackupScheduleRequest) (*backupv1.UpdateBackupScheduleResponse, error) { + if f.UpdateBackupScheduleFunc != nil { + return f.UpdateBackupScheduleFunc(ctx, req) + } + return f.UnimplementedBackupServiceServer.UpdateBackupSchedule(ctx, req) +} + +// DeleteBackupSchedule delegates to DeleteBackupScheduleFunc if set. +func (f *FakeBackupService) DeleteBackupSchedule(ctx context.Context, req *backupv1.DeleteBackupScheduleRequest) (*backupv1.DeleteBackupScheduleResponse, error) { + if f.DeleteBackupScheduleFunc != nil { + return f.DeleteBackupScheduleFunc(ctx, req) + } + return f.UnimplementedBackupServiceServer.DeleteBackupSchedule(ctx, req) +} diff --git a/internal/testutil/server.go b/internal/testutil/server.go index 9f58de5..62caabf 100644 --- a/internal/testutil/server.go +++ b/internal/testutil/server.go @@ -13,6 +13,7 @@ import ( bookingv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/booking/v1" clusterauthv2 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/auth/v2" + backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" platformv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/platform/v1" @@ -55,6 +56,7 @@ type TestEnv struct { BookingServer *FakeBookingService PlatformServer *FakePlatformService DatabaseApiKeyServer *FakeDatabaseApiKeyService + BackupServer *FakeBackupService Capture *RequestCapture Cleanup func() } @@ -96,6 +98,7 @@ func newBaseTestEnv(t *testing.T, cfg *envConfig) *TestEnv { fakeBooking := &FakeBookingService{} fakePlatform := &FakePlatformService{} fakeDatabaseApiKey := &FakeDatabaseApiKeyService{} + fakeBackup := &FakeBackupService{} capture := &RequestCapture{} // Start gRPC server on bufconn. @@ -105,6 +108,7 @@ func newBaseTestEnv(t *testing.T, cfg *envConfig) *TestEnv { bookingv1.RegisterBookingServiceServer(srv, fakeBooking) platformv1.RegisterPlatformServiceServer(srv, fakePlatform) clusterauthv2.RegisterDatabaseApiKeyServiceServer(srv, fakeDatabaseApiKey) + backupv1.RegisterBackupServiceServer(srv, fakeBackup) go func() { _ = srv.Serve(lis) @@ -143,6 +147,7 @@ func newBaseTestEnv(t *testing.T, cfg *envConfig) *TestEnv { BookingServer: fakeBooking, PlatformServer: fakePlatform, DatabaseApiKeyServer: fakeDatabaseApiKey, + BackupServer: fakeBackup, Capture: capture, Cleanup: func() { _ = client.Close()