Skip to content

Commit 3428e0a

Browse files
committed
feat: add backup schedules and restore actions
1 parent 5c3ba2e commit 3428e0a

23 files changed

Lines changed: 1240 additions & 13 deletions

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/dustin/go-humanize v1.0.1
77
github.com/jedib0t/go-pretty/v6 v6.7.8
88
github.com/qdrant/qdrant-cloud-public-api v0.110.0
9+
github.com/robfig/cron/v3 v3.0.1
910
github.com/spf13/cobra v1.10.2
1011
github.com/spf13/pflag v1.0.10
1112
github.com/spf13/viper v1.21.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ github.com/qdrant/qdrant-cloud-public-api v0.110.0/go.mod h1:XqgxNTOYlCx1d6UKbPj
4242
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
4343
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
4444
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
45+
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
46+
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
4547
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
4648
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
4749
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=

internal/cmd/backup/backup.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ func NewCommand(s *state.State) *cobra.Command {
2222
newDescribeCommand(s),
2323
newCreateCommand(s),
2424
newDeleteCommand(s),
25+
newRestoreCommand(s),
26+
newScheduleCommand(s),
2527
)
2628
return cmd
2729
}
@@ -30,3 +32,13 @@ func NewCommand(s *state.State) *cobra.Command {
3032
func backupStatusString(s backupv1.BackupStatus) string {
3133
return strings.TrimPrefix(s.String(), "BACKUP_STATUS_")
3234
}
35+
36+
// scheduleStatusString returns a concise status label for a BackupScheduleStatus.
37+
func scheduleStatusString(s backupv1.BackupScheduleStatus) string {
38+
return strings.TrimPrefix(s.String(), "BACKUP_SCHEDULE_STATUS_")
39+
}
40+
41+
// restoreStatusString returns a concise status label for a BackupRestoreStatus.
42+
func restoreStatusString(s backupv1.BackupRestoreStatus) string {
43+
return strings.TrimPrefix(s.String(), "BACKUP_RESTORE_STATUS_")
44+
}

internal/cmd/backup/delete.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1"
99

1010
"github.com/qdrant/qcloud-cli/internal/cmd/base"
11+
"github.com/qdrant/qcloud-cli/internal/cmd/completion"
1112
"github.com/qdrant/qcloud-cli/internal/cmd/util"
1213
"github.com/qdrant/qcloud-cli/internal/state"
1314
)
@@ -23,7 +24,7 @@ func newDeleteCommand(s *state.State) *cobra.Command {
2324
cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
2425
return cmd
2526
},
26-
ValidArgsFunction: backupIDCompletion(s),
27+
ValidArgsFunction: completion.BackupIDCompletion(s),
2728
Run: func(s *state.State, cmd *cobra.Command, args []string) error {
2829
backupID := args[0]
2930

internal/cmd/backup/describe.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1"
1010

1111
"github.com/qdrant/qcloud-cli/internal/cmd/base"
12+
"github.com/qdrant/qcloud-cli/internal/cmd/completion"
1213
"github.com/qdrant/qcloud-cli/internal/cmd/output"
1314
"github.com/qdrant/qcloud-cli/internal/cmd/util"
1415
"github.com/qdrant/qcloud-cli/internal/state"
@@ -19,7 +20,7 @@ func newDescribeCommand(s *state.State) *cobra.Command {
1920
Use: "describe <backup-id>",
2021
Short: "Describe a backup",
2122
Args: util.ExactArgs(1, "a backup ID"),
22-
ValidArgsFunction: backupIDCompletion(s),
23+
ValidArgsFunction: completion.BackupIDCompletion(s),
2324
Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*backupv1.Backup, error) {
2425
ctx := cmd.Context()
2526
client, err := s.Client(ctx)

internal/cmd/backup/restore.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package backup
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/qdrant/qcloud-cli/internal/state"
7+
)
8+
9+
func newRestoreCommand(s *state.State) *cobra.Command {
10+
cmd := &cobra.Command{
11+
Use: "restore",
12+
Short: "Manage backup restores",
13+
Args: cobra.NoArgs,
14+
}
15+
cmd.AddCommand(
16+
newRestoreListCommand(s),
17+
newRestoreTriggerCommand(s),
18+
)
19+
return cmd
20+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package backup
2+
3+
import (
4+
"fmt"
5+
"io"
6+
7+
"github.com/spf13/cobra"
8+
9+
backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1"
10+
11+
"github.com/qdrant/qcloud-cli/internal/cmd/base"
12+
"github.com/qdrant/qcloud-cli/internal/cmd/completion"
13+
"github.com/qdrant/qcloud-cli/internal/cmd/output"
14+
"github.com/qdrant/qcloud-cli/internal/state"
15+
)
16+
17+
func newRestoreListCommand(s *state.State) *cobra.Command {
18+
cmd := base.ListCmd[*backupv1.ListBackupRestoresResponse]{
19+
Use: "list",
20+
Short: "List backup restores",
21+
Fetch: func(s *state.State, cmd *cobra.Command) (*backupv1.ListBackupRestoresResponse, error) {
22+
ctx := cmd.Context()
23+
client, err := s.Client(ctx)
24+
if err != nil {
25+
return nil, err
26+
}
27+
28+
accountID, err := s.AccountID()
29+
if err != nil {
30+
return nil, err
31+
}
32+
33+
req := &backupv1.ListBackupRestoresRequest{AccountId: accountID}
34+
if cmd.Flags().Changed("cluster-id") {
35+
clusterID, _ := cmd.Flags().GetString("cluster-id")
36+
req.ClusterId = &clusterID
37+
}
38+
39+
resp, err := client.Backup().ListBackupRestores(ctx, req)
40+
if err != nil {
41+
return nil, fmt.Errorf("failed to list backup restores: %w", err)
42+
}
43+
return resp, nil
44+
},
45+
PrintText: func(_ *cobra.Command, w io.Writer, resp *backupv1.ListBackupRestoresResponse) error {
46+
t := output.NewTable[*backupv1.BackupRestore](w)
47+
t.AddField("ID", func(v *backupv1.BackupRestore) string {
48+
return v.GetId()
49+
})
50+
t.AddField("BACKUP", func(v *backupv1.BackupRestore) string {
51+
return v.GetBackupId()
52+
})
53+
t.AddField("CLUSTER", func(v *backupv1.BackupRestore) string {
54+
return v.GetClusterId()
55+
})
56+
t.AddField("STATUS", func(v *backupv1.BackupRestore) string {
57+
return restoreStatusString(v.GetStatus())
58+
})
59+
t.AddField("CREATED", func(v *backupv1.BackupRestore) string {
60+
if v.GetCreatedAt() != nil {
61+
return output.HumanTime(v.GetCreatedAt().AsTime())
62+
}
63+
return ""
64+
})
65+
t.Write(resp.GetItems())
66+
return nil
67+
},
68+
}.CobraCommand(s)
69+
70+
cmd.Flags().String("cluster-id", "", "Filter by cluster ID")
71+
_ = cmd.RegisterFlagCompletionFunc("cluster-id", completion.ClusterIDCompletion(s))
72+
return cmd
73+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package backup_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
"google.golang.org/protobuf/types/known/timestamppb"
11+
12+
backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1"
13+
14+
"github.com/qdrant/qcloud-cli/internal/testutil"
15+
)
16+
17+
func TestRestoreList_TableOutput(t *testing.T) {
18+
env := testutil.NewTestEnv(t)
19+
t.Cleanup(env.Cleanup)
20+
21+
env.BackupServer.ListBackupRestoresFunc = func(_ context.Context, req *backupv1.ListBackupRestoresRequest) (*backupv1.ListBackupRestoresResponse, error) {
22+
assert.Equal(t, "test-account-id", req.GetAccountId())
23+
return &backupv1.ListBackupRestoresResponse{
24+
Items: []*backupv1.BackupRestore{
25+
{
26+
Id: "restore-1",
27+
BackupId: "backup-abc",
28+
ClusterId: "cluster-123",
29+
Status: backupv1.BackupRestoreStatus_BACKUP_RESTORE_STATUS_SUCCEEDED,
30+
CreatedAt: timestamppb.Now(),
31+
},
32+
},
33+
}, nil
34+
}
35+
36+
stdout, _, err := testutil.Exec(t, env, "backup", "restore", "list")
37+
require.NoError(t, err)
38+
assert.Contains(t, stdout, "ID")
39+
assert.Contains(t, stdout, "BACKUP")
40+
assert.Contains(t, stdout, "CLUSTER")
41+
assert.Contains(t, stdout, "STATUS")
42+
assert.Contains(t, stdout, "restore-1")
43+
assert.Contains(t, stdout, "backup-abc")
44+
assert.Contains(t, stdout, "SUCCEEDED")
45+
}
46+
47+
func TestRestoreList_JSONOutput(t *testing.T) {
48+
env := testutil.NewTestEnv(t)
49+
t.Cleanup(env.Cleanup)
50+
51+
env.BackupServer.ListBackupRestoresFunc = func(_ context.Context, _ *backupv1.ListBackupRestoresRequest) (*backupv1.ListBackupRestoresResponse, error) {
52+
return &backupv1.ListBackupRestoresResponse{
53+
Items: []*backupv1.BackupRestore{
54+
{Id: "restore-json", BackupId: "backup-123"},
55+
},
56+
}, nil
57+
}
58+
59+
stdout, _, err := testutil.Exec(t, env, "backup", "restore", "list", "--json")
60+
require.NoError(t, err)
61+
62+
var result struct {
63+
Items []struct {
64+
ID string `json:"id"`
65+
BackupID string `json:"backupId"`
66+
} `json:"items"`
67+
}
68+
require.NoError(t, json.Unmarshal([]byte(stdout), &result))
69+
require.Len(t, result.Items, 1)
70+
assert.Equal(t, "restore-json", result.Items[0].ID)
71+
assert.Equal(t, "backup-123", result.Items[0].BackupID)
72+
}
73+
74+
func TestRestoreList_EmptyResponse(t *testing.T) {
75+
env := testutil.NewTestEnv(t)
76+
t.Cleanup(env.Cleanup)
77+
78+
env.BackupServer.ListBackupRestoresFunc = func(_ context.Context, _ *backupv1.ListBackupRestoresRequest) (*backupv1.ListBackupRestoresResponse, error) {
79+
return &backupv1.ListBackupRestoresResponse{}, nil
80+
}
81+
82+
stdout, _, err := testutil.Exec(t, env, "backup", "restore", "list")
83+
require.NoError(t, err)
84+
assert.Contains(t, stdout, "ID")
85+
assert.Contains(t, stdout, "STATUS")
86+
}
87+
88+
func TestRestoreList_APIError(t *testing.T) {
89+
env := testutil.NewTestEnv(t)
90+
t.Cleanup(env.Cleanup)
91+
92+
env.BackupServer.ListBackupRestoresFunc = func(_ context.Context, _ *backupv1.ListBackupRestoresRequest) (*backupv1.ListBackupRestoresResponse, error) {
93+
return nil, assert.AnError
94+
}
95+
96+
_, _, err := testutil.Exec(t, env, "backup", "restore", "list")
97+
require.Error(t, err)
98+
}
99+
100+
func TestRestoreList_ClusterIDFilter(t *testing.T) {
101+
env := testutil.NewTestEnv(t)
102+
t.Cleanup(env.Cleanup)
103+
104+
var capturedClusterID string
105+
env.BackupServer.ListBackupRestoresFunc = func(_ context.Context, req *backupv1.ListBackupRestoresRequest) (*backupv1.ListBackupRestoresResponse, error) {
106+
capturedClusterID = req.GetClusterId()
107+
return &backupv1.ListBackupRestoresResponse{}, nil
108+
}
109+
110+
_, _, err := testutil.Exec(t, env, "backup", "restore", "list", "--cluster-id=my-cluster")
111+
require.NoError(t, err)
112+
assert.Equal(t, "my-cluster", capturedClusterID)
113+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package backup
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
8+
backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1"
9+
10+
"github.com/qdrant/qcloud-cli/internal/cmd/base"
11+
"github.com/qdrant/qcloud-cli/internal/cmd/completion"
12+
"github.com/qdrant/qcloud-cli/internal/cmd/util"
13+
"github.com/qdrant/qcloud-cli/internal/state"
14+
)
15+
16+
func newRestoreTriggerCommand(s *state.State) *cobra.Command {
17+
return base.Cmd{
18+
BaseCobraCommand: func() *cobra.Command {
19+
cmd := &cobra.Command{
20+
Use: "trigger <backup-id>",
21+
Short: "Trigger a restore from a backup",
22+
Args: util.ExactArgs(1, "a backup ID"),
23+
}
24+
cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
25+
return cmd
26+
},
27+
ValidArgsFunction: completion.BackupIDCompletion(s),
28+
Run: func(s *state.State, cmd *cobra.Command, args []string) error {
29+
backupID := args[0]
30+
31+
force, _ := cmd.Flags().GetBool("force")
32+
if !util.ConfirmAction(force, fmt.Sprintf("Are you sure you want to restore backup %s?", backupID)) {
33+
fmt.Fprintln(cmd.OutOrStdout(), "Aborted.")
34+
return nil
35+
}
36+
37+
ctx := cmd.Context()
38+
client, err := s.Client(ctx)
39+
if err != nil {
40+
return err
41+
}
42+
43+
accountID, err := s.AccountID()
44+
if err != nil {
45+
return err
46+
}
47+
48+
_, err = client.Backup().RestoreBackup(ctx, &backupv1.RestoreBackupRequest{
49+
AccountId: accountID,
50+
BackupId: backupID,
51+
})
52+
if err != nil {
53+
return fmt.Errorf("failed to restore backup: %w", err)
54+
}
55+
56+
fmt.Fprintf(cmd.OutOrStdout(), "Restore of backup %s started.\n", backupID)
57+
return nil
58+
},
59+
}.CobraCommand(s)
60+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package backup_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1"
11+
12+
"github.com/qdrant/qcloud-cli/internal/testutil"
13+
)
14+
15+
func TestRestoreTrigger_WithForce(t *testing.T) {
16+
env := testutil.NewTestEnv(t)
17+
t.Cleanup(env.Cleanup)
18+
19+
var capturedBackupID string
20+
env.BackupServer.RestoreBackupFunc = func(_ context.Context, req *backupv1.RestoreBackupRequest) (*backupv1.RestoreBackupResponse, error) {
21+
assert.Equal(t, "test-account-id", req.GetAccountId())
22+
capturedBackupID = req.GetBackupId()
23+
return &backupv1.RestoreBackupResponse{}, nil
24+
}
25+
26+
stdout, _, err := testutil.Exec(t, env, "backup", "restore", "trigger", "backup-abc", "--force")
27+
require.NoError(t, err)
28+
assert.Equal(t, "backup-abc", capturedBackupID)
29+
assert.Contains(t, stdout, "backup-abc")
30+
assert.Contains(t, stdout, "started")
31+
}
32+
33+
func TestRestoreTrigger_APIError(t *testing.T) {
34+
env := testutil.NewTestEnv(t)
35+
t.Cleanup(env.Cleanup)
36+
37+
env.BackupServer.RestoreBackupFunc = func(_ context.Context, _ *backupv1.RestoreBackupRequest) (*backupv1.RestoreBackupResponse, error) {
38+
return nil, assert.AnError
39+
}
40+
41+
_, _, err := testutil.Exec(t, env, "backup", "restore", "trigger", "backup-abc", "--force")
42+
require.Error(t, err)
43+
}

0 commit comments

Comments
 (0)