diff --git a/go.mod b/go.mod index 4b16647..80390a9 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/jedib0t/go-pretty/v6 v6.7.8 github.com/qdrant/qdrant-cloud-public-api v0.110.0 + github.com/robfig/cron/v3 v3.0.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 diff --git a/go.sum b/go.sum index d0a3c6a..d643342 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/qdrant/qdrant-cloud-public-api v0.110.0/go.mod h1:XqgxNTOYlCx1d6UKbPj github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/cmd/backup/backup.go b/internal/cmd/backup/backup.go index b1f318c..1b34154 100644 --- a/internal/cmd/backup/backup.go +++ b/internal/cmd/backup/backup.go @@ -22,6 +22,8 @@ func NewCommand(s *state.State) *cobra.Command { newDescribeCommand(s), newCreateCommand(s), newDeleteCommand(s), + newRestoreCommand(s), + newScheduleCommand(s), ) return cmd } @@ -30,3 +32,13 @@ func NewCommand(s *state.State) *cobra.Command { func backupStatusString(s backupv1.BackupStatus) string { return strings.TrimPrefix(s.String(), "BACKUP_STATUS_") } + +// scheduleStatusString returns a concise status label for a BackupScheduleStatus. +func scheduleStatusString(s backupv1.BackupScheduleStatus) string { + return strings.TrimPrefix(s.String(), "BACKUP_SCHEDULE_STATUS_") +} + +// restoreStatusString returns a concise status label for a BackupRestoreStatus. +func restoreStatusString(s backupv1.BackupRestoreStatus) string { + return strings.TrimPrefix(s.String(), "BACKUP_RESTORE_STATUS_") +} diff --git a/internal/cmd/backup/delete.go b/internal/cmd/backup/delete.go index 633e5e0..2a21cd9 100644 --- a/internal/cmd/backup/delete.go +++ b/internal/cmd/backup/delete.go @@ -8,6 +8,7 @@ import ( 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/util" "github.com/qdrant/qcloud-cli/internal/state" ) @@ -23,7 +24,7 @@ func newDeleteCommand(s *state.State) *cobra.Command { cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") return cmd }, - ValidArgsFunction: backupIDCompletion(s), + ValidArgsFunction: completion.BackupIDCompletion(s), Run: func(s *state.State, cmd *cobra.Command, args []string) error { backupID := args[0] diff --git a/internal/cmd/backup/describe.go b/internal/cmd/backup/describe.go index 75afd47..e74df73 100644 --- a/internal/cmd/backup/describe.go +++ b/internal/cmd/backup/describe.go @@ -9,6 +9,7 @@ import ( 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/cmd/util" "github.com/qdrant/qcloud-cli/internal/state" @@ -19,7 +20,7 @@ func newDescribeCommand(s *state.State) *cobra.Command { Use: "describe ", Short: "Describe a backup", Args: util.ExactArgs(1, "a backup ID"), - ValidArgsFunction: backupIDCompletion(s), + ValidArgsFunction: completion.BackupIDCompletion(s), Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*backupv1.Backup, error) { ctx := cmd.Context() client, err := s.Client(ctx) diff --git a/internal/cmd/backup/restore.go b/internal/cmd/backup/restore.go new file mode 100644 index 0000000..c9ce24d --- /dev/null +++ b/internal/cmd/backup/restore.go @@ -0,0 +1,20 @@ +package backup + +import ( + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newRestoreCommand(s *state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "restore", + Short: "Manage backup restores", + Args: cobra.NoArgs, + } + cmd.AddCommand( + newRestoreListCommand(s), + newRestoreTriggerCommand(s), + ) + return cmd +} diff --git a/internal/cmd/backup/restore_list.go b/internal/cmd/backup/restore_list.go new file mode 100644 index 0000000..f0076eb --- /dev/null +++ b/internal/cmd/backup/restore_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 newRestoreListCommand(s *state.State) *cobra.Command { + cmd := base.ListCmd[*backupv1.ListBackupRestoresResponse]{ + Use: "list", + Short: "List backup restores", + Fetch: func(s *state.State, cmd *cobra.Command) (*backupv1.ListBackupRestoresResponse, 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.ListBackupRestoresRequest{AccountId: accountID} + if cmd.Flags().Changed("cluster-id") { + clusterID, _ := cmd.Flags().GetString("cluster-id") + req.ClusterId = &clusterID + } + + resp, err := client.Backup().ListBackupRestores(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to list backup restores: %w", err) + } + return resp, nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, resp *backupv1.ListBackupRestoresResponse) error { + t := output.NewTable[*backupv1.BackupRestore](w) + t.AddField("ID", func(v *backupv1.BackupRestore) string { + return v.GetId() + }) + t.AddField("BACKUP", func(v *backupv1.BackupRestore) string { + return v.GetBackupId() + }) + t.AddField("CLUSTER", func(v *backupv1.BackupRestore) string { + return v.GetClusterId() + }) + t.AddField("STATUS", func(v *backupv1.BackupRestore) string { + return restoreStatusString(v.GetStatus()) + }) + t.AddField("CREATED", func(v *backupv1.BackupRestore) 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/restore_list_test.go b/internal/cmd/backup/restore_list_test.go new file mode 100644 index 0000000..5f6214a --- /dev/null +++ b/internal/cmd/backup/restore_list_test.go @@ -0,0 +1,103 @@ +package backup_test + +import ( + "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 TestRestoreList_TableOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.ListBackupRestoresCalls.Returns( + &backupv1.ListBackupRestoresResponse{ + Items: []*backupv1.BackupRestore{ + { + Id: "restore-1", + BackupId: "backup-abc", + ClusterId: "cluster-123", + Status: backupv1.BackupRestoreStatus_BACKUP_RESTORE_STATUS_SUCCEEDED, + CreatedAt: timestamppb.Now(), + }, + }, + }, + nil, + ) + + stdout, _, err := testutil.Exec(t, env, "backup", "restore", "list") + require.NoError(t, err) + req, _ := env.BackupServer.ListBackupRestoresCalls.Last() + assert.Equal(t, "test-account-id", req.GetAccountId()) + assert.Contains(t, stdout, "ID") + assert.Contains(t, stdout, "BACKUP") + assert.Contains(t, stdout, "CLUSTER") + assert.Contains(t, stdout, "STATUS") + assert.Contains(t, stdout, "restore-1") + assert.Contains(t, stdout, "backup-abc") + assert.Contains(t, stdout, "SUCCEEDED") +} + +func TestRestoreList_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.ListBackupRestoresCalls.Returns( + &backupv1.ListBackupRestoresResponse{ + Items: []*backupv1.BackupRestore{ + {Id: "restore-json", BackupId: "backup-123"}, + }, + }, + nil, + ) + + stdout, _, err := testutil.Exec(t, env, "backup", "restore", "list", "--json") + require.NoError(t, err) + + var result struct { + Items []struct { + ID string `json:"id"` + BackupID string `json:"backupId"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + require.Len(t, result.Items, 1) + assert.Equal(t, "restore-json", result.Items[0].ID) + assert.Equal(t, "backup-123", result.Items[0].BackupID) +} + +func TestRestoreList_EmptyResponse(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.ListBackupRestoresCalls.Returns(&backupv1.ListBackupRestoresResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "backup", "restore", "list") + require.NoError(t, err) + assert.Contains(t, stdout, "ID") + assert.Contains(t, stdout, "STATUS") +} + +func TestRestoreList_APIError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.ListBackupRestoresCalls.Returns(nil, assert.AnError) + + _, _, err := testutil.Exec(t, env, "backup", "restore", "list") + require.Error(t, err) +} + +func TestRestoreList_ClusterIDFilter(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.ListBackupRestoresCalls.Returns(&backupv1.ListBackupRestoresResponse{}, nil) + + _, _, err := testutil.Exec(t, env, "backup", "restore", "list", "--cluster-id=my-cluster") + require.NoError(t, err) + req, _ := env.BackupServer.ListBackupRestoresCalls.Last() + assert.Equal(t, "my-cluster", req.GetClusterId()) +} diff --git a/internal/cmd/backup/restore_trigger.go b/internal/cmd/backup/restore_trigger.go new file mode 100644 index 0000000..ce18c52 --- /dev/null +++ b/internal/cmd/backup/restore_trigger.go @@ -0,0 +1,61 @@ +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/completion" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newRestoreTriggerCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "trigger ", + Short: "Trigger a restore from a backup", + Args: util.ExactArgs(1, "a backup ID"), + } + cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + return cmd + }, + ValidArgsFunction: completion.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 restore 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().RestoreBackup(ctx, &backupv1.RestoreBackupRequest{ + AccountId: accountID, + BackupId: backupID, + }) + if err != nil { + return fmt.Errorf("failed to restore backup: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Restore of backup %s started.\n", backupID) + fmt.Fprintln(cmd.OutOrStdout(), "Run 'qcloud backup restore list' to track progress.") + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/backup/restore_trigger_test.go b/internal/cmd/backup/restore_trigger_test.go new file mode 100644 index 0000000..377c745 --- /dev/null +++ b/internal/cmd/backup/restore_trigger_test.go @@ -0,0 +1,44 @@ +package backup_test + +import ( + "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 TestRestoreTrigger_WithForce(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.RestoreBackupCalls.Returns(&backupv1.RestoreBackupResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "backup", "restore", "trigger", "backup-abc", "--force") + require.NoError(t, err) + req, _ := env.BackupServer.RestoreBackupCalls.Last() + assert.Equal(t, "test-account-id", req.GetAccountId()) + assert.Equal(t, "backup-abc", req.GetBackupId()) + assert.Contains(t, stdout, "Restore of backup backup-abc started.") + assert.Contains(t, stdout, "Run 'qcloud backup restore list' to track progress.") +} + +func TestRestoreTrigger_Aborted(t *testing.T) { + env := testutil.NewTestEnv(t) + + stdout, _, err := testutil.Exec(t, env, "backup", "restore", "trigger", "backup-abc") + require.NoError(t, err) + assert.Contains(t, stdout, "Aborted.") + assert.Equal(t, 0, env.BackupServer.RestoreBackupCalls.Count()) +} + +func TestRestoreTrigger_APIError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.RestoreBackupCalls.Returns(nil, assert.AnError) + + _, _, err := testutil.Exec(t, env, "backup", "restore", "trigger", "backup-abc", "--force") + require.Error(t, err) +} diff --git a/internal/cmd/backup/schedule.go b/internal/cmd/backup/schedule.go new file mode 100644 index 0000000..4cc8c03 --- /dev/null +++ b/internal/cmd/backup/schedule.go @@ -0,0 +1,23 @@ +package backup + +import ( + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newScheduleCommand(s *state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "schedule", + Short: "Manage backup schedules", + Args: cobra.NoArgs, + } + cmd.AddCommand( + newScheduleListCommand(s), + newScheduleDescribeCommand(s), + newScheduleCreateCommand(s), + newScheduleUpdateCommand(s), + newScheduleDeleteCommand(s), + ) + return cmd +} diff --git a/internal/cmd/backup/completion.go b/internal/cmd/backup/schedule_completion.go similarity index 59% rename from internal/cmd/backup/completion.go rename to internal/cmd/backup/schedule_completion.go index 7176f29..2f4502f 100644 --- a/internal/cmd/backup/completion.go +++ b/internal/cmd/backup/schedule_completion.go @@ -8,8 +8,8 @@ import ( "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) { +// scheduleIDCompletion returns a ValidArgsFunction that completes backup schedule IDs. +func scheduleIDCompletion(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 @@ -26,20 +26,16 @@ func backupIDCompletion(s *state.State) func(*cobra.Command, []string, string) ( 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) + resp, err := client.Backup().ListBackupSchedules(ctx, &backupv1.ListBackupSchedulesRequest{ + AccountId: accountID, + }) 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()) + for _, sched := range resp.GetItems() { + completions = append(completions, sched.GetId()+"\tcluster:"+sched.GetClusterId()+" | "+sched.GetSchedule()) } return completions, cobra.ShellCompDirectiveNoFileComp } diff --git a/internal/cmd/backup/schedule_create.go b/internal/cmd/backup/schedule_create.go new file mode 100644 index 0000000..d9c0ada --- /dev/null +++ b/internal/cmd/backup/schedule_create.go @@ -0,0 +1,76 @@ +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 newScheduleCreateCommand(s *state.State) *cobra.Command { + cmd := base.CreateCmd[*backupv1.BackupSchedule]{ + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a backup schedule for a cluster", + Args: cobra.NoArgs, + } + cmd.Flags().String("cluster-id", "", "Cluster ID (required)") + cmd.Flags().String("schedule", "", "Cron schedule expression in UTC (required), e.g. '0 2 * * *'") + cmd.Flags().Uint32("retention-days", 0, "Retention period in days (1-365) (required)") + _ = cmd.MarkFlagRequired("cluster-id") + _ = cmd.MarkFlagRequired("schedule") + _ = cmd.MarkFlagRequired("retention-days") + return cmd + }, + Run: func(s *state.State, cmd *cobra.Command, args []string) (*backupv1.BackupSchedule, 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") + schedule, _ := cmd.Flags().GetString("schedule") + 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 + sched := &backupv1.BackupSchedule{ + AccountId: accountID, + ClusterId: clusterID, + Schedule: schedule, + RetentionPeriod: durationpb.New(d), + } + + resp, err := client.Backup().CreateBackupSchedule(ctx, &backupv1.CreateBackupScheduleRequest{ + BackupSchedule: sched, + }) + if err != nil { + return nil, fmt.Errorf("failed to create backup schedule: %w", err) + } + return resp.GetBackupSchedule(), nil + }, + PrintResource: func(_ *cobra.Command, out io.Writer, sched *backupv1.BackupSchedule) { + fmt.Fprintf(out, "Backup schedule %s created for cluster %s.\n", sched.GetId(), sched.GetClusterId()) + }, + }.CobraCommand(s) + _ = cmd.RegisterFlagCompletionFunc("cluster-id", completion.ClusterIDCompletion(s)) + return cmd +} diff --git a/internal/cmd/backup/schedule_create_test.go b/internal/cmd/backup/schedule_create_test.go new file mode 100644 index 0000000..29845be --- /dev/null +++ b/internal/cmd/backup/schedule_create_test.go @@ -0,0 +1,108 @@ +package backup_test + +import ( + "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 TestScheduleCreate_Success(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.CreateBackupScheduleCalls.Returns( + &backupv1.CreateBackupScheduleResponse{ + BackupSchedule: &backupv1.BackupSchedule{Id: "schedule-new", ClusterId: "cluster-abc"}, + }, + nil, + ) + + stdout, _, err := testutil.Exec(t, env, "backup", "schedule", "create", + "--cluster-id=cluster-abc", "--schedule=0 2 * * *", "--retention-days=30") + require.NoError(t, err) + req, _ := env.BackupServer.CreateBackupScheduleCalls.Last() + assert.Equal(t, "test-account-id", req.GetBackupSchedule().GetAccountId()) + assert.Equal(t, "cluster-abc", req.GetBackupSchedule().GetClusterId()) + assert.Equal(t, "0 2 * * *", req.GetBackupSchedule().GetSchedule()) + assert.Contains(t, stdout, "schedule-new") + assert.Contains(t, stdout, "cluster-abc") +} + +func TestScheduleCreate_WithRetention(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.CreateBackupScheduleCalls.Returns( + &backupv1.CreateBackupScheduleResponse{ + BackupSchedule: &backupv1.BackupSchedule{Id: "schedule-ret", ClusterId: "cluster-abc"}, + }, + nil, + ) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "create", + "--cluster-id=cluster-abc", "--schedule=0 2 * * *", "--retention-days=30") + require.NoError(t, err) + req, _ := env.BackupServer.CreateBackupScheduleCalls.Last() + var retentionDays int64 + if req.GetBackupSchedule().GetRetentionPeriod() != nil { + retentionDays = int64(req.GetBackupSchedule().GetRetentionPeriod().AsDuration().Hours()) / 24 + } + assert.Equal(t, int64(30), retentionDays) +} + +func TestScheduleCreate_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.CreateBackupScheduleCalls.Returns( + &backupv1.CreateBackupScheduleResponse{ + BackupSchedule: &backupv1.BackupSchedule{Id: "schedule-json", Schedule: "0 5 * * *"}, + }, + nil, + ) + + stdout, _, err := testutil.Exec(t, env, "backup", "schedule", "create", + "--cluster-id=cluster-abc", "--schedule=0 5 * * *", "--retention-days=30", "--json") + require.NoError(t, err) + + var result struct { + ID string `json:"id"` + Schedule string `json:"schedule"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.Equal(t, "schedule-json", result.ID) +} + +func TestScheduleCreate_InvalidRetention(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "create", + "--cluster-id=cluster-abc", "--schedule=0 2 * * *", "--retention-days=0") + require.Error(t, err) +} + +func TestScheduleCreate_MissingFlags(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "create", "--cluster-id=cluster-abc") + require.Error(t, err) +} + +func TestScheduleCreate_MissingClusterID(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "create", + "--schedule=0 2 * * *", "--retention-days=30") + require.Error(t, err) +} + +func TestScheduleCreate_MissingRetentionDays(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "create", + "--cluster-id=cluster-abc", "--schedule=0 2 * * *") + require.Error(t, err) +} diff --git a/internal/cmd/backup/schedule_delete.go b/internal/cmd/backup/schedule_delete.go new file mode 100644 index 0000000..9f2666f --- /dev/null +++ b/internal/cmd/backup/schedule_delete.go @@ -0,0 +1,65 @@ +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 newScheduleDeleteCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a backup schedule", + Args: util.ExactArgs(1, "a schedule ID"), + } + cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + cmd.Flags().Bool("delete-backups", false, "Also delete all backups created by this schedule") + return cmd + }, + ValidArgsFunction: scheduleIDCompletion(s), + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + scheduleID := args[0] + + force, _ := cmd.Flags().GetBool("force") + if !util.ConfirmAction(force, fmt.Sprintf("Are you sure you want to delete backup schedule %s?", scheduleID)) { + 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 + } + + deleteBackups, _ := cmd.Flags().GetBool("delete-backups") + + req := &backupv1.DeleteBackupScheduleRequest{ + AccountId: accountID, + BackupScheduleId: scheduleID, + DeleteBackups: &deleteBackups, + } + + _, err = client.Backup().DeleteBackupSchedule(ctx, req) + if err != nil { + return fmt.Errorf("failed to delete backup schedule: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Backup schedule %s deleted.\n", scheduleID) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/backup/schedule_delete_test.go b/internal/cmd/backup/schedule_delete_test.go new file mode 100644 index 0000000..417703f --- /dev/null +++ b/internal/cmd/backup/schedule_delete_test.go @@ -0,0 +1,47 @@ +package backup_test + +import ( + "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 TestScheduleDelete_WithForce(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.DeleteBackupScheduleCalls.Returns(&backupv1.DeleteBackupScheduleResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "backup", "schedule", "delete", "schedule-abc", "--force") + require.NoError(t, err) + req, _ := env.BackupServer.DeleteBackupScheduleCalls.Last() + assert.Equal(t, "test-account-id", req.GetAccountId()) + assert.Equal(t, "schedule-abc", req.GetBackupScheduleId()) + assert.False(t, req.GetDeleteBackups()) + assert.Contains(t, stdout, "schedule-abc") + assert.Contains(t, stdout, "deleted") +} + +func TestScheduleDelete_WithDeleteBackups(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.DeleteBackupScheduleCalls.Returns(&backupv1.DeleteBackupScheduleResponse{}, nil) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "delete", "schedule-abc", "--force", "--delete-backups") + require.NoError(t, err) + req, _ := env.BackupServer.DeleteBackupScheduleCalls.Last() + assert.True(t, req.GetDeleteBackups()) +} + +func TestScheduleDelete_APIError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.DeleteBackupScheduleCalls.Returns(nil, assert.AnError) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "delete", "schedule-abc", "--force") + require.Error(t, err) +} diff --git a/internal/cmd/backup/schedule_describe.go b/internal/cmd/backup/schedule_describe.go new file mode 100644 index 0000000..1459dcb --- /dev/null +++ b/internal/cmd/backup/schedule_describe.go @@ -0,0 +1,85 @@ +package backup + +import ( + "fmt" + "io" + "time" + + "github.com/robfig/cron/v3" + "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/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func nextScheduleRun(cronExpr string) (time.Time, bool) { + s, err := cron.ParseStandard(cronExpr) + if err != nil { + return time.Time{}, false + } + return s.Next(time.Now().UTC()), true +} + +func newScheduleDescribeCommand(s *state.State) *cobra.Command { + cmd := base.DescribeCmd[*backupv1.BackupSchedule]{ + Use: "describe ", + Short: "Describe a backup schedule", + Long: `Describe a backup schedule. + +The --cluster-id flag is required because the API requires the cluster ID to look up a schedule by ID.`, + Args: util.ExactArgs(1, "a schedule ID"), + ValidArgsFunction: scheduleIDCompletion(s), + Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*backupv1.BackupSchedule, 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") + + resp, err := client.Backup().GetBackupSchedule(ctx, &backupv1.GetBackupScheduleRequest{ + AccountId: accountID, + ClusterId: clusterID, + BackupScheduleId: args[0], + }) + if err != nil { + return nil, fmt.Errorf("failed to get backup schedule: %w", err) + } + return resp.GetBackupSchedule(), nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, sched *backupv1.BackupSchedule) error { + fmt.Fprintf(w, "ID: %s\n", sched.GetId()) + fmt.Fprintf(w, "Cluster: %s\n", sched.GetClusterId()) + fmt.Fprintf(w, "Schedule: %s\n", sched.GetSchedule()) + if next, ok := nextScheduleRun(sched.GetSchedule()); ok { + fmt.Fprintf(w, "Next Run: %s (%s)\n", output.HumanTime(next), output.FullDateTime(next)) + } + fmt.Fprintf(w, "Status: %s\n", scheduleStatusString(sched.GetStatus())) + if sched.GetCreatedAt() != nil { + t := sched.GetCreatedAt().AsTime() + fmt.Fprintf(w, "Created: %s (%s)\n", output.HumanTime(t), output.FullDateTime(t)) + } + if sched.GetRetentionPeriod() != nil { + days := int64(sched.GetRetentionPeriod().AsDuration().Hours()) / 24 + fmt.Fprintf(w, "Retention: %d days\n", days) + } + return nil + }, + }.CobraCommand(s) + + cmd.Flags().String("cluster-id", "", "Cluster ID (required)") + _ = cmd.MarkFlagRequired("cluster-id") + _ = cmd.RegisterFlagCompletionFunc("cluster-id", completion.ClusterIDCompletion(s)) + return cmd +} diff --git a/internal/cmd/backup/schedule_describe_test.go b/internal/cmd/backup/schedule_describe_test.go new file mode 100644 index 0000000..13be937 --- /dev/null +++ b/internal/cmd/backup/schedule_describe_test.go @@ -0,0 +1,81 @@ +package backup_test + +import ( + "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 TestScheduleDescribe_TextOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.GetBackupScheduleCalls.Returns( + &backupv1.GetBackupScheduleResponse{ + BackupSchedule: &backupv1.BackupSchedule{ + Id: "schedule-1", + ClusterId: "cluster-abc", + Schedule: "0 2 * * *", + Status: backupv1.BackupScheduleStatus_BACKUP_SCHEDULE_STATUS_ACTIVE, + CreatedAt: timestamppb.Now(), + }, + }, + nil, + ) + + stdout, _, err := testutil.Exec(t, env, "backup", "schedule", "describe", "schedule-1", "--cluster-id=cluster-abc") + require.NoError(t, err) + req, _ := env.BackupServer.GetBackupScheduleCalls.Last() + assert.Equal(t, "test-account-id", req.GetAccountId()) + assert.Equal(t, "cluster-abc", req.GetClusterId()) + assert.Equal(t, "schedule-1", req.GetBackupScheduleId()) + assert.Contains(t, stdout, "schedule-1") + assert.Contains(t, stdout, "cluster-abc") + assert.Contains(t, stdout, "0 2 * * *") + assert.Contains(t, stdout, "Next Run:") + assert.Contains(t, stdout, "ACTIVE") +} + +func TestScheduleDescribe_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.GetBackupScheduleCalls.Returns( + &backupv1.GetBackupScheduleResponse{ + BackupSchedule: &backupv1.BackupSchedule{Id: "schedule-json", Schedule: "0 4 * * *"}, + }, + nil, + ) + + stdout, _, err := testutil.Exec(t, env, "backup", "schedule", "describe", "schedule-json", "--cluster-id=cluster-abc", "--json") + require.NoError(t, err) + + var result struct { + ID string `json:"id"` + Schedule string `json:"schedule"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.Equal(t, "schedule-json", result.ID) + assert.Equal(t, "0 4 * * *", result.Schedule) +} + +func TestScheduleDescribe_MissingClusterID(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "describe", "schedule-1") + require.Error(t, err) +} + +func TestScheduleDescribe_APIError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.GetBackupScheduleCalls.Returns(nil, assert.AnError) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "describe", "schedule-1", "--cluster-id=cluster-abc") + require.Error(t, err) +} diff --git a/internal/cmd/backup/schedule_list.go b/internal/cmd/backup/schedule_list.go new file mode 100644 index 0000000..062a2a7 --- /dev/null +++ b/internal/cmd/backup/schedule_list.go @@ -0,0 +1,79 @@ +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 newScheduleListCommand(s *state.State) *cobra.Command { + cmd := base.ListCmd[*backupv1.ListBackupSchedulesResponse]{ + Use: "list", + Short: "List backup schedules", + Fetch: func(s *state.State, cmd *cobra.Command) (*backupv1.ListBackupSchedulesResponse, 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.ListBackupSchedulesRequest{AccountId: accountID} + if cmd.Flags().Changed("cluster-id") { + clusterID, _ := cmd.Flags().GetString("cluster-id") + req.ClusterId = &clusterID + } + + resp, err := client.Backup().ListBackupSchedules(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to list backup schedules: %w", err) + } + return resp, nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, resp *backupv1.ListBackupSchedulesResponse) error { + t := output.NewTable[*backupv1.BackupSchedule](w) + t.AddField("ID", func(v *backupv1.BackupSchedule) string { + return v.GetId() + }) + t.AddField("CLUSTER", func(v *backupv1.BackupSchedule) string { + return v.GetClusterId() + }) + t.AddField("SCHEDULE", func(v *backupv1.BackupSchedule) string { + return v.GetSchedule() + }) + t.AddField("STATUS", func(v *backupv1.BackupSchedule) string { + return scheduleStatusString(v.GetStatus()) + }) + t.AddField("NEXT RUN", func(v *backupv1.BackupSchedule) string { + if next, ok := nextScheduleRun(v.GetSchedule()); ok { + return output.HumanTime(next) + } + return "" + }) + t.AddField("CREATED", func(v *backupv1.BackupSchedule) 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/schedule_list_test.go b/internal/cmd/backup/schedule_list_test.go new file mode 100644 index 0000000..a5174af --- /dev/null +++ b/internal/cmd/backup/schedule_list_test.go @@ -0,0 +1,96 @@ +package backup_test + +import ( + "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 TestScheduleList_TableOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.ListBackupSchedulesCalls.Returns( + &backupv1.ListBackupSchedulesResponse{ + Items: []*backupv1.BackupSchedule{ + { + Id: "schedule-1", + ClusterId: "cluster-abc", + Schedule: "0 2 * * *", + Status: backupv1.BackupScheduleStatus_BACKUP_SCHEDULE_STATUS_ACTIVE, + CreatedAt: timestamppb.Now(), + }, + }, + }, + nil, + ) + + stdout, _, err := testutil.Exec(t, env, "backup", "schedule", "list") + require.NoError(t, err) + req, _ := env.BackupServer.ListBackupSchedulesCalls.Last() + assert.Equal(t, "test-account-id", req.GetAccountId()) + assert.Contains(t, stdout, "ID") + assert.Contains(t, stdout, "CLUSTER") + assert.Contains(t, stdout, "SCHEDULE") + assert.Contains(t, stdout, "STATUS") + assert.Contains(t, stdout, "NEXT RUN") + assert.Contains(t, stdout, "schedule-1") + assert.Contains(t, stdout, "cluster-abc") + assert.Contains(t, stdout, "0 2 * * *") + assert.Contains(t, stdout, "ACTIVE") +} + +func TestScheduleList_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.ListBackupSchedulesCalls.Returns( + &backupv1.ListBackupSchedulesResponse{ + Items: []*backupv1.BackupSchedule{ + {Id: "schedule-json", Schedule: "0 3 * * *"}, + }, + }, + nil, + ) + + stdout, _, err := testutil.Exec(t, env, "backup", "schedule", "list", "--json") + require.NoError(t, err) + + var result struct { + Items []struct { + ID string `json:"id"` + Schedule string `json:"schedule"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + require.Len(t, result.Items, 1) + assert.Equal(t, "schedule-json", result.Items[0].ID) + assert.Equal(t, "0 3 * * *", result.Items[0].Schedule) +} + +func TestScheduleList_EmptyResponse(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.ListBackupSchedulesCalls.Returns(&backupv1.ListBackupSchedulesResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "backup", "schedule", "list") + require.NoError(t, err) + assert.Contains(t, stdout, "ID") + assert.Contains(t, stdout, "SCHEDULE") +} + +func TestScheduleList_ClusterIDFilter(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.ListBackupSchedulesCalls.Returns(&backupv1.ListBackupSchedulesResponse{}, nil) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "list", "--cluster-id=my-cluster") + require.NoError(t, err) + req, _ := env.BackupServer.ListBackupSchedulesCalls.Last() + assert.Equal(t, "my-cluster", req.GetClusterId()) +} diff --git a/internal/cmd/backup/schedule_update.go b/internal/cmd/backup/schedule_update.go new file mode 100644 index 0000000..161a497 --- /dev/null +++ b/internal/cmd/backup/schedule_update.go @@ -0,0 +1,96 @@ +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/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newScheduleUpdateCommand(s *state.State) *cobra.Command { + cmd := base.UpdateCmd[*backupv1.BackupSchedule]{ + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a backup schedule", + Long: `Update a backup schedule. + +The --cluster-id flag is required because the API requires the cluster ID to look up a schedule by ID.`, + Args: util.ExactArgs(1, "a schedule ID"), + } + cmd.Flags().String("cluster-id", "", "Cluster ID (required)") + cmd.Flags().String("schedule", "", "New cron schedule expression in UTC, e.g. '0 2 * * *'") + cmd.Flags().Uint32("retention-days", 0, "New retention period in days (1-365)") + _ = cmd.MarkFlagRequired("cluster-id") + return cmd + }, + ValidArgsFunction: scheduleIDCompletion(s), + Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*backupv1.BackupSchedule, 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") + + resp, err := client.Backup().GetBackupSchedule(ctx, &backupv1.GetBackupScheduleRequest{ + AccountId: accountID, + ClusterId: clusterID, + BackupScheduleId: args[0], + }) + if err != nil { + return nil, fmt.Errorf("failed to get backup schedule: %w", err) + } + return resp.GetBackupSchedule(), nil + }, + Update: func(s *state.State, cmd *cobra.Command, sched *backupv1.BackupSchedule) (*backupv1.BackupSchedule, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + + if cmd.Flags().Changed("schedule") { + schedule, _ := cmd.Flags().GetString("schedule") + sched.Schedule = schedule + } + + if cmd.Flags().Changed("retention-days") { + 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 + sched.RetentionPeriod = durationpb.New(d) + } + + resp, err := client.Backup().UpdateBackupSchedule(ctx, &backupv1.UpdateBackupScheduleRequest{ + BackupSchedule: sched, + }) + if err != nil { + return nil, fmt.Errorf("failed to update backup schedule: %w", err) + } + return resp.GetBackupSchedule(), nil + }, + PrintResource: func(_ *cobra.Command, out io.Writer, sched *backupv1.BackupSchedule) { + fmt.Fprintf(out, "Backup schedule %s updated.\n", sched.GetId()) + }, + }.CobraCommand(s) + _ = cmd.RegisterFlagCompletionFunc("cluster-id", completion.ClusterIDCompletion(s)) + return cmd +} diff --git a/internal/cmd/backup/schedule_update_test.go b/internal/cmd/backup/schedule_update_test.go new file mode 100644 index 0000000..42c9f31 --- /dev/null +++ b/internal/cmd/backup/schedule_update_test.go @@ -0,0 +1,133 @@ +package backup_test + +import ( + "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 TestScheduleUpdate_Success(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.GetBackupScheduleCalls.Returns( + &backupv1.GetBackupScheduleResponse{ + BackupSchedule: &backupv1.BackupSchedule{ + Id: "schedule-1", + ClusterId: "cluster-abc", + Schedule: "0 2 * * *", + }, + }, + nil, + ) + env.BackupServer.UpdateBackupScheduleCalls.Returns( + &backupv1.UpdateBackupScheduleResponse{ + BackupSchedule: &backupv1.BackupSchedule{Id: "schedule-1", Schedule: "0 3 * * *"}, + }, + nil, + ) + + stdout, _, err := testutil.Exec(t, env, "backup", "schedule", "update", "schedule-1", + "--cluster-id=cluster-abc", "--schedule=0 3 * * *") + require.NoError(t, err) + getReq, _ := env.BackupServer.GetBackupScheduleCalls.Last() + assert.Equal(t, "cluster-abc", getReq.GetClusterId()) + assert.Equal(t, "schedule-1", getReq.GetBackupScheduleId()) + updateReq, _ := env.BackupServer.UpdateBackupScheduleCalls.Last() + assert.Equal(t, "schedule-1", updateReq.GetBackupSchedule().GetId()) + assert.Equal(t, "0 3 * * *", updateReq.GetBackupSchedule().GetSchedule()) + assert.Contains(t, stdout, "schedule-1") + assert.Contains(t, stdout, "updated") +} + +func TestScheduleUpdate_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.GetBackupScheduleCalls.Returns( + &backupv1.GetBackupScheduleResponse{ + BackupSchedule: &backupv1.BackupSchedule{Id: "schedule-1", Schedule: "0 2 * * *"}, + }, + nil, + ) + env.BackupServer.UpdateBackupScheduleCalls.Returns( + &backupv1.UpdateBackupScheduleResponse{ + BackupSchedule: &backupv1.BackupSchedule{Id: "schedule-1", Schedule: "0 4 * * *"}, + }, + nil, + ) + + stdout, _, err := testutil.Exec(t, env, "backup", "schedule", "update", "schedule-1", + "--cluster-id=cluster-abc", "--schedule=0 4 * * *", "--json") + require.NoError(t, err) + + var result struct { + ID string `json:"id"` + Schedule string `json:"schedule"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.Equal(t, "schedule-1", result.ID) +} + +func TestScheduleUpdate_InvalidRetention(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.GetBackupScheduleCalls.Returns( + &backupv1.GetBackupScheduleResponse{ + BackupSchedule: &backupv1.BackupSchedule{Id: "schedule-1", Schedule: "0 2 * * *"}, + }, + nil, + ) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "update", "schedule-1", + "--cluster-id=cluster-abc", "--retention-days=0") + require.Error(t, err) +} + +func TestScheduleUpdate_WithRetention(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.GetBackupScheduleCalls.Returns( + &backupv1.GetBackupScheduleResponse{ + BackupSchedule: &backupv1.BackupSchedule{Id: "schedule-1", Schedule: "0 2 * * *"}, + }, + nil, + ) + env.BackupServer.UpdateBackupScheduleCalls.Returns( + &backupv1.UpdateBackupScheduleResponse{ + BackupSchedule: &backupv1.BackupSchedule{Id: "schedule-1"}, + }, + nil, + ) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "update", "schedule-1", + "--cluster-id=cluster-abc", "--retention-days=14") + require.NoError(t, err) + req, _ := env.BackupServer.UpdateBackupScheduleCalls.Last() + var retentionDays int64 + if req.GetBackupSchedule().GetRetentionPeriod() != nil { + retentionDays = int64(req.GetBackupSchedule().GetRetentionPeriod().AsDuration().Hours()) / 24 + } + assert.Equal(t, int64(14), retentionDays) +} + +func TestScheduleUpdate_MissingClusterID(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "update", "schedule-1") + require.Error(t, err) +} + +func TestScheduleUpdate_APIError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.BackupServer.GetBackupScheduleCalls.Returns(nil, assert.AnError) + + _, _, err := testutil.Exec(t, env, "backup", "schedule", "update", "schedule-1", + "--cluster-id=cluster-abc", "--schedule=0 3 * * *") + require.Error(t, err) +} diff --git a/internal/cmd/base/describe.go b/internal/cmd/base/describe.go index 7ca33ca..e50d74d 100644 --- a/internal/cmd/base/describe.go +++ b/internal/cmd/base/describe.go @@ -13,6 +13,7 @@ import ( type DescribeCmd[T any] struct { Use string Short string + Long string Args cobra.PositionalArgs Fetch func(s *state.State, cmd *cobra.Command, args []string) (T, error) PrintText func(cmd *cobra.Command, out io.Writer, resource T) error @@ -24,6 +25,7 @@ func (dc DescribeCmd[T]) CobraCommand(s *state.State) *cobra.Command { cmd := &cobra.Command{ Use: dc.Use, Short: dc.Short, + Long: dc.Long, Args: dc.Args, RunE: func(cmd *cobra.Command, args []string) error { resource, err := dc.Fetch(s, cmd, args) diff --git a/internal/cmd/completion/completion.go b/internal/cmd/completion/completion.go index 2d2c941..f3cb773 100644 --- a/internal/cmd/completion/completion.go +++ b/internal/cmd/completion/completion.go @@ -3,11 +3,49 @@ package completion import ( "github.com/spf13/cobra" + 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" "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 + } +} + // ClusterIDCompletion returns a ValidArgsFunction that completes cluster IDs. func ClusterIDCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { diff --git a/internal/testutil/method_spy.go b/internal/testutil/method_spy.go index f3b0310..f32b10d 100644 --- a/internal/testutil/method_spy.go +++ b/internal/testutil/method_spy.go @@ -28,6 +28,13 @@ func (m *MethodSpy[Req, Resp]) OnCall(i int, fn func(context.Context, Req) (Resp return m } +// Returns sets a fixed response returned for every call (shorthand for Always with a constant return). +func (m *MethodSpy[Req, Resp]) Returns(resp Resp, err error) *MethodSpy[Req, Resp] { + return m.Always(func(_ context.Context, _ Req) (Resp, error) { + return resp, err + }) +} + // Always sets a fallback handler invoked for every call that has no matching OnCall entry. // If neither OnCall nor Always is set the method falls back to the gRPC Unimplemented default. func (m *MethodSpy[Req, Resp]) Always(fn func(context.Context, Req) (Resp, error)) *MethodSpy[Req, Resp] {