Skip to content

Commit 2014e1e

Browse files
authored
feat: add backup management commands (#1)
1 parent c87da45 commit 2014e1e

15 files changed

Lines changed: 812 additions & 1 deletion

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ lint:
1212
golangci-lint run
1313

1414
format:
15-
$(GOLANGCI_LINT) run --fix
15+
golangci-lint run --fix
1616

1717
clean:
1818
rm -rf build/

internal/cli/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/spf13/cobra"
77

8+
"github.com/qdrant/qcloud-cli/internal/cmd/backup"
89
"github.com/qdrant/qcloud-cli/internal/cmd/cluster"
910
cmdcontext "github.com/qdrant/qcloud-cli/internal/cmd/context"
1011
"github.com/qdrant/qcloud-cli/internal/cmd/version"
@@ -55,6 +56,7 @@ func NewRootCommand(s *state.State) *cobra.Command {
5556
cmd.AddCommand(version.NewCommand(s))
5657
cmd.AddCommand(cluster.NewCommand(s))
5758
cmd.AddCommand(cmdcontext.NewCommand(s))
59+
cmd.AddCommand(backup.NewCommand(s))
5860

5961
return cmd
6062
}

internal/cmd/backup/backup.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package backup
2+
3+
import (
4+
"strings"
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/state"
11+
)
12+
13+
// NewCommand creates the "backup" parent command and registers all subcommands.
14+
func NewCommand(s *state.State) *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "backup",
17+
Short: "Manage Qdrant Cloud backups",
18+
Args: cobra.NoArgs,
19+
}
20+
cmd.AddCommand(
21+
newListCommand(s),
22+
newDescribeCommand(s),
23+
newCreateCommand(s),
24+
newDeleteCommand(s),
25+
)
26+
return cmd
27+
}
28+
29+
// backupStatusString returns a concise status label for a BackupStatus.
30+
func backupStatusString(s backupv1.BackupStatus) string {
31+
return strings.TrimPrefix(s.String(), "BACKUP_STATUS_")
32+
}

internal/cmd/backup/completion.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package backup
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1"
7+
8+
"github.com/qdrant/qcloud-cli/internal/state"
9+
)
10+
11+
// backupIDCompletion returns a ValidArgsFunction that completes backup IDs.
12+
func backupIDCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
13+
return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
14+
if len(args) > 0 {
15+
return nil, cobra.ShellCompDirectiveNoFileComp
16+
}
17+
18+
ctx := cmd.Context()
19+
client, err := s.Client(ctx)
20+
if err != nil {
21+
return nil, cobra.ShellCompDirectiveError
22+
}
23+
24+
accountID, err := s.AccountID()
25+
if err != nil {
26+
return nil, cobra.ShellCompDirectiveError
27+
}
28+
29+
req := &backupv1.ListBackupsRequest{AccountId: accountID}
30+
if cmd.Flags().Changed("cluster-id") {
31+
clusterID, _ := cmd.Flags().GetString("cluster-id")
32+
req.ClusterId = &clusterID
33+
}
34+
35+
resp, err := client.Backup().ListBackups(ctx, req)
36+
if err != nil {
37+
return nil, cobra.ShellCompDirectiveError
38+
}
39+
40+
completions := make([]string, 0, len(resp.GetItems()))
41+
for _, b := range resp.GetItems() {
42+
completions = append(completions, b.GetId()+"\t"+b.GetName())
43+
}
44+
return completions, cobra.ShellCompDirectiveNoFileComp
45+
}
46+
}

internal/cmd/backup/create.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package backup
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"time"
7+
8+
"github.com/spf13/cobra"
9+
"google.golang.org/protobuf/types/known/durationpb"
10+
11+
backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1"
12+
13+
"github.com/qdrant/qcloud-cli/internal/cmd/base"
14+
"github.com/qdrant/qcloud-cli/internal/cmd/completion"
15+
"github.com/qdrant/qcloud-cli/internal/state"
16+
)
17+
18+
func newCreateCommand(s *state.State) *cobra.Command {
19+
cmd := base.CreateCmd[*backupv1.Backup]{
20+
BaseCobraCommand: func() *cobra.Command {
21+
cmd := &cobra.Command{
22+
Use: "create",
23+
Short: "Create a backup for a cluster",
24+
Args: cobra.NoArgs,
25+
}
26+
cmd.Flags().String("cluster-id", "", "Cluster ID to back up (required)")
27+
cmd.Flags().Uint32("retention-days", 0, "Retention period in days (1-365) (required)")
28+
_ = cmd.MarkFlagRequired("cluster-id")
29+
_ = cmd.MarkFlagRequired("retention-days")
30+
return cmd
31+
},
32+
Run: func(s *state.State, cmd *cobra.Command, args []string) (*backupv1.Backup, error) {
33+
ctx := cmd.Context()
34+
client, err := s.Client(ctx)
35+
if err != nil {
36+
return nil, err
37+
}
38+
39+
accountID, err := s.AccountID()
40+
if err != nil {
41+
return nil, err
42+
}
43+
44+
clusterID, _ := cmd.Flags().GetString("cluster-id")
45+
retentionDays, _ := cmd.Flags().GetUint32("retention-days")
46+
47+
if retentionDays < 1 {
48+
return nil, fmt.Errorf("--retention-days must be at least 1")
49+
}
50+
51+
d := time.Duration(retentionDays) * 24 * time.Hour
52+
b := &backupv1.Backup{
53+
AccountId: accountID,
54+
ClusterId: clusterID,
55+
RetentionPeriod: durationpb.New(d),
56+
}
57+
58+
resp, err := client.Backup().CreateBackup(ctx, &backupv1.CreateBackupRequest{
59+
Backup: b,
60+
})
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to create backup: %w", err)
63+
}
64+
return resp.GetBackup(), nil
65+
},
66+
PrintResource: func(_ *cobra.Command, out io.Writer, b *backupv1.Backup) {
67+
fmt.Fprintf(out, "Backup %s created for cluster %s.\n", b.GetId(), b.GetClusterId())
68+
},
69+
}.CobraCommand(s)
70+
_ = cmd.RegisterFlagCompletionFunc("cluster-id", completion.ClusterIDCompletion(s))
71+
return cmd
72+
}

internal/cmd/backup/create_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
11+
backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1"
12+
13+
"github.com/qdrant/qcloud-cli/internal/testutil"
14+
)
15+
16+
func TestBackupCreate_Success(t *testing.T) {
17+
env := testutil.NewTestEnv(t)
18+
t.Cleanup(env.Cleanup)
19+
20+
env.BackupServer.CreateBackupFunc = func(_ context.Context, req *backupv1.CreateBackupRequest) (*backupv1.CreateBackupResponse, error) {
21+
assert.Equal(t, "test-account-id", req.GetBackup().GetAccountId())
22+
assert.Equal(t, "cluster-abc", req.GetBackup().GetClusterId())
23+
return &backupv1.CreateBackupResponse{
24+
Backup: &backupv1.Backup{Id: "backup-new", ClusterId: "cluster-abc"},
25+
}, nil
26+
}
27+
28+
stdout, _, err := testutil.Exec(t, env, "backup", "create", "--cluster-id=cluster-abc", "--retention-days=7")
29+
require.NoError(t, err)
30+
assert.Contains(t, stdout, "backup-new")
31+
assert.Contains(t, stdout, "cluster-abc")
32+
}
33+
34+
func TestBackupCreate_WithRetention(t *testing.T) {
35+
env := testutil.NewTestEnv(t)
36+
t.Cleanup(env.Cleanup)
37+
38+
var capturedRetention int64
39+
env.BackupServer.CreateBackupFunc = func(_ context.Context, req *backupv1.CreateBackupRequest) (*backupv1.CreateBackupResponse, error) {
40+
if req.GetBackup().GetRetentionPeriod() != nil {
41+
capturedRetention = int64(req.GetBackup().GetRetentionPeriod().AsDuration().Hours()) / 24
42+
}
43+
return &backupv1.CreateBackupResponse{
44+
Backup: &backupv1.Backup{Id: "backup-ret", ClusterId: "cluster-abc"},
45+
}, nil
46+
}
47+
48+
_, _, err := testutil.Exec(t, env, "backup", "create", "--cluster-id=cluster-abc", "--retention-days=7")
49+
require.NoError(t, err)
50+
assert.Equal(t, int64(7), capturedRetention)
51+
}
52+
53+
func TestBackupCreate_JSONOutput(t *testing.T) {
54+
env := testutil.NewTestEnv(t)
55+
t.Cleanup(env.Cleanup)
56+
57+
env.BackupServer.CreateBackupFunc = func(_ context.Context, _ *backupv1.CreateBackupRequest) (*backupv1.CreateBackupResponse, error) {
58+
return &backupv1.CreateBackupResponse{
59+
Backup: &backupv1.Backup{Id: "backup-json", ClusterId: "cluster-123"},
60+
}, nil
61+
}
62+
63+
stdout, _, err := testutil.Exec(t, env, "backup", "create", "--cluster-id=cluster-123", "--retention-days=7", "--json")
64+
require.NoError(t, err)
65+
66+
var result struct {
67+
ID string `json:"id"`
68+
ClusterID string `json:"clusterId"`
69+
}
70+
require.NoError(t, json.Unmarshal([]byte(stdout), &result))
71+
assert.Equal(t, "backup-json", result.ID)
72+
}
73+
74+
func TestBackupCreate_InvalidRetention(t *testing.T) {
75+
env := testutil.NewTestEnv(t)
76+
t.Cleanup(env.Cleanup)
77+
78+
_, _, err := testutil.Exec(t, env, "backup", "create", "--cluster-id=cluster-abc", "--retention-days=0")
79+
require.Error(t, err)
80+
}
81+
82+
func TestBackupCreate_MissingClusterID(t *testing.T) {
83+
env := testutil.NewTestEnv(t)
84+
t.Cleanup(env.Cleanup)
85+
86+
_, _, err := testutil.Exec(t, env, "backup", "create")
87+
require.Error(t, err)
88+
}
89+
90+
func TestBackupCreate_APIError(t *testing.T) {
91+
env := testutil.NewTestEnv(t)
92+
t.Cleanup(env.Cleanup)
93+
94+
env.BackupServer.CreateBackupFunc = func(_ context.Context, _ *backupv1.CreateBackupRequest) (*backupv1.CreateBackupResponse, error) {
95+
return nil, assert.AnError
96+
}
97+
98+
_, _, err := testutil.Exec(t, env, "backup", "create", "--cluster-id=cluster-abc", "--retention-days=7")
99+
require.Error(t, err)
100+
}

internal/cmd/backup/delete.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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/util"
12+
"github.com/qdrant/qcloud-cli/internal/state"
13+
)
14+
15+
func newDeleteCommand(s *state.State) *cobra.Command {
16+
return base.Cmd{
17+
BaseCobraCommand: func() *cobra.Command {
18+
cmd := &cobra.Command{
19+
Use: "delete <backup-id>",
20+
Short: "Delete a backup",
21+
Args: util.ExactArgs(1, "a backup ID"),
22+
}
23+
cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
24+
return cmd
25+
},
26+
ValidArgsFunction: backupIDCompletion(s),
27+
Run: func(s *state.State, cmd *cobra.Command, args []string) error {
28+
backupID := args[0]
29+
30+
force, _ := cmd.Flags().GetBool("force")
31+
if !util.ConfirmAction(force, fmt.Sprintf("Are you sure you want to delete backup %s?", backupID)) {
32+
fmt.Fprintln(cmd.OutOrStdout(), "Aborted.")
33+
return nil
34+
}
35+
36+
ctx := cmd.Context()
37+
client, err := s.Client(ctx)
38+
if err != nil {
39+
return err
40+
}
41+
42+
accountID, err := s.AccountID()
43+
if err != nil {
44+
return err
45+
}
46+
47+
_, err = client.Backup().DeleteBackup(ctx, &backupv1.DeleteBackupRequest{
48+
AccountId: accountID,
49+
BackupId: backupID,
50+
})
51+
if err != nil {
52+
return fmt.Errorf("failed to delete backup: %w", err)
53+
}
54+
55+
fmt.Fprintf(cmd.OutOrStdout(), "Backup %s deleted.\n", backupID)
56+
return nil
57+
},
58+
}.CobraCommand(s)
59+
}

internal/cmd/backup/delete_test.go

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 TestBackupDelete_WithForce(t *testing.T) {
16+
env := testutil.NewTestEnv(t)
17+
t.Cleanup(env.Cleanup)
18+
19+
var capturedBackupID string
20+
env.BackupServer.DeleteBackupFunc = func(_ context.Context, req *backupv1.DeleteBackupRequest) (*backupv1.DeleteBackupResponse, error) {
21+
assert.Equal(t, "test-account-id", req.GetAccountId())
22+
capturedBackupID = req.GetBackupId()
23+
return &backupv1.DeleteBackupResponse{}, nil
24+
}
25+
26+
stdout, _, err := testutil.Exec(t, env, "backup", "delete", "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, "deleted")
31+
}
32+
33+
func TestBackupDelete_APIError(t *testing.T) {
34+
env := testutil.NewTestEnv(t)
35+
t.Cleanup(env.Cleanup)
36+
37+
env.BackupServer.DeleteBackupFunc = func(_ context.Context, _ *backupv1.DeleteBackupRequest) (*backupv1.DeleteBackupResponse, error) {
38+
return nil, assert.AnError
39+
}
40+
41+
_, _, err := testutil.Exec(t, env, "backup", "delete", "backup-abc", "--force")
42+
require.Error(t, err)
43+
}

0 commit comments

Comments
 (0)