Skip to content

Commit 848f5e7

Browse files
authored
feat: add cluster create-from-backup command (#96)
1 parent c2f4561 commit 848f5e7

4 files changed

Lines changed: 251 additions & 10 deletions

File tree

internal/cmd/cluster/cluster.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ func NewCommand(s *state.State) *cobra.Command {
1717
newListCommand(s),
1818
newDescribeCommand(s),
1919
newCreateCommand(s),
20+
newCreateFromBackupCommand(s),
2021
newUpdateCommand(s),
2122
newDeleteCommand(s),
2223
newRestartCommand(s),
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package cluster
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"time"
7+
8+
"github.com/spf13/cobra"
9+
10+
clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1"
11+
12+
"github.com/qdrant/qcloud-cli/internal/cmd/base"
13+
"github.com/qdrant/qcloud-cli/internal/cmd/clusterutil"
14+
"github.com/qdrant/qcloud-cli/internal/cmd/completion"
15+
"github.com/qdrant/qcloud-cli/internal/state"
16+
)
17+
18+
func newCreateFromBackupCommand(s *state.State) *cobra.Command {
19+
return base.CreateCmd[*clusterv1.Cluster]{
20+
Example: `# Create a cluster from a backup
21+
qcloud cluster create-from-backup --backup-id <backup-id> --name my-restored-cluster
22+
23+
# Create a cluster from a backup and wait until it is healthy
24+
qcloud cluster create-from-backup --backup-id <backup-id> --name my-restored-cluster --wait
25+
26+
# Create with a custom wait timeout
27+
qcloud cluster create-from-backup --backup-id <backup-id> --name my-restored-cluster --wait --wait-timeout 20m`,
28+
BaseCobraCommand: func() *cobra.Command {
29+
cmd := &cobra.Command{
30+
Use: "create-from-backup",
31+
Short: "Create a new cluster from a backup",
32+
Long: `Create a new Qdrant Cloud cluster seeded with the data from an existing backup.
33+
34+
The new cluster is provisioned using the same configuration as the original cluster
35+
at the time the backup was taken. The backup must belong to the current account.`,
36+
Args: cobra.NoArgs,
37+
}
38+
cmd.Flags().String("backup-id", "", "ID of the backup to restore from (required)")
39+
cmd.Flags().String("name", "", "Name for the new cluster (required)")
40+
cmd.Flags().Bool("wait", false, "Wait for the cluster to become healthy")
41+
cmd.Flags().Duration("wait-timeout", 10*time.Minute, "Maximum time to wait for cluster health")
42+
cmd.Flags().Duration("wait-poll-interval", 5*time.Second, "How often to poll for cluster health")
43+
_ = cmd.Flags().MarkHidden("wait-poll-interval")
44+
_ = cmd.MarkFlagRequired("backup-id")
45+
_ = cmd.MarkFlagRequired("name")
46+
_ = cmd.RegisterFlagCompletionFunc("backup-id", completion.BackupIDCompletion(s))
47+
return cmd
48+
},
49+
Run: func(s *state.State, cmd *cobra.Command, args []string) (*clusterv1.Cluster, error) {
50+
ctx := cmd.Context()
51+
client, err := s.Client(ctx)
52+
if err != nil {
53+
return nil, err
54+
}
55+
56+
accountID, err := s.AccountID()
57+
if err != nil {
58+
return nil, err
59+
}
60+
61+
backupID, _ := cmd.Flags().GetString("backup-id")
62+
name, _ := cmd.Flags().GetString("name")
63+
64+
resp, err := client.Cluster().CreateClusterFromBackup(ctx, &clusterv1.CreateClusterFromBackupRequest{
65+
AccountId: accountID,
66+
BackupId: backupID,
67+
ClusterName: name,
68+
})
69+
if err != nil {
70+
return nil, fmt.Errorf("failed to create cluster from backup: %w", err)
71+
}
72+
73+
created := resp.GetCluster()
74+
75+
wait, _ := cmd.Flags().GetBool("wait")
76+
if !wait {
77+
return created, nil
78+
}
79+
80+
waitTimeout, _ := cmd.Flags().GetDuration("wait-timeout")
81+
pollInterval, _ := cmd.Flags().GetDuration("wait-poll-interval")
82+
fmt.Fprintf(cmd.ErrOrStderr(), "Cluster %s created, waiting for it to become healthy...\n", created.GetId())
83+
return clusterutil.WaitForClusterHealthy(ctx, client.Cluster(), cmd.ErrOrStderr(), accountID, created.GetId(), waitTimeout, pollInterval)
84+
},
85+
PrintResource: func(_ *cobra.Command, out io.Writer, created *clusterv1.Cluster) {
86+
if ep := created.GetState().GetEndpoint(); ep != nil && ep.GetUrl() != "" {
87+
fmt.Fprintf(out, "Cluster %s (%s) is ready. Endpoint: %s\n", created.GetId(), created.GetName(), ep.GetUrl())
88+
} else {
89+
fmt.Fprintf(out, "Cluster %s (%s) created from backup.\n", created.GetId(), created.GetName())
90+
}
91+
},
92+
}.CobraCommand(s)
93+
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package cluster_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1"
11+
12+
"github.com/qdrant/qcloud-cli/internal/testutil"
13+
)
14+
15+
func TestCreateFromBackup_Success(t *testing.T) {
16+
env := testutil.NewTestEnv(t)
17+
18+
env.Server.CreateClusterFromBackupCalls.Returns(&clusterv1.CreateClusterFromBackupResponse{
19+
Cluster: &clusterv1.Cluster{Id: "cluster-restored", Name: "my-restored-cluster"},
20+
}, nil)
21+
22+
stdout, _, err := testutil.Exec(t, env,
23+
"cluster", "create-from-backup",
24+
"--backup-id", "backup-abc",
25+
"--name", "my-restored-cluster",
26+
)
27+
require.NoError(t, err)
28+
29+
req, ok := env.Server.CreateClusterFromBackupCalls.Last()
30+
require.True(t, ok)
31+
assert.Equal(t, "test-account-id", req.GetAccountId())
32+
assert.Equal(t, "backup-abc", req.GetBackupId())
33+
assert.Equal(t, "my-restored-cluster", req.GetClusterName())
34+
assert.Contains(t, stdout, "cluster-restored")
35+
assert.Contains(t, stdout, "my-restored-cluster")
36+
}
37+
38+
func TestCreateFromBackup_MissingBackupID(t *testing.T) {
39+
env := testutil.NewTestEnv(t)
40+
41+
_, _, err := testutil.Exec(t, env,
42+
"cluster", "create-from-backup",
43+
"--name", "my-restored-cluster",
44+
)
45+
require.Error(t, err)
46+
assert.Equal(t, 0, env.Server.CreateClusterFromBackupCalls.Count())
47+
}
48+
49+
func TestCreateFromBackup_MissingName(t *testing.T) {
50+
env := testutil.NewTestEnv(t)
51+
52+
_, _, err := testutil.Exec(t, env,
53+
"cluster", "create-from-backup",
54+
"--backup-id", "backup-abc",
55+
)
56+
require.Error(t, err)
57+
assert.Equal(t, 0, env.Server.CreateClusterFromBackupCalls.Count())
58+
}
59+
60+
func TestCreateFromBackup_APIError(t *testing.T) {
61+
env := testutil.NewTestEnv(t)
62+
63+
env.Server.CreateClusterFromBackupCalls.Returns(nil, assert.AnError)
64+
65+
_, _, err := testutil.Exec(t, env,
66+
"cluster", "create-from-backup",
67+
"--backup-id", "backup-abc",
68+
"--name", "my-restored-cluster",
69+
)
70+
require.Error(t, err)
71+
}
72+
73+
func TestCreateFromBackup_NoWait(t *testing.T) {
74+
env := testutil.NewTestEnv(t)
75+
76+
env.Server.CreateClusterFromBackupCalls.Always(func(_ context.Context, req *clusterv1.CreateClusterFromBackupRequest) (*clusterv1.CreateClusterFromBackupResponse, error) {
77+
return &clusterv1.CreateClusterFromBackupResponse{
78+
Cluster: &clusterv1.Cluster{
79+
Id: "cluster-restored",
80+
Name: req.GetClusterName(),
81+
},
82+
}, nil
83+
})
84+
85+
stdout, _, err := testutil.Exec(t, env,
86+
"cluster", "create-from-backup",
87+
"--backup-id", "backup-abc",
88+
"--name", "my-restored-cluster",
89+
)
90+
require.NoError(t, err)
91+
assert.Contains(t, stdout, "cluster-restored")
92+
assert.Equal(t, 0, env.Server.GetClusterCalls.Count(), "GetCluster should not be called without --wait")
93+
}
94+
95+
func TestCreateFromBackup_WaitSuccess(t *testing.T) {
96+
env := testutil.NewTestEnv(t)
97+
98+
env.Server.CreateClusterFromBackupCalls.Always(func(_ context.Context, req *clusterv1.CreateClusterFromBackupRequest) (*clusterv1.CreateClusterFromBackupResponse, error) {
99+
return &clusterv1.CreateClusterFromBackupResponse{
100+
Cluster: &clusterv1.Cluster{
101+
Id: "cluster-restored",
102+
Name: req.GetClusterName(),
103+
},
104+
}, nil
105+
})
106+
env.Server.GetClusterCalls.
107+
OnCall(0, func(_ context.Context, _ *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) {
108+
return &clusterv1.GetClusterResponse{
109+
Cluster: &clusterv1.Cluster{
110+
Id: "cluster-restored",
111+
State: &clusterv1.ClusterState{Phase: clusterv1.ClusterPhase_CLUSTER_PHASE_CREATING},
112+
},
113+
}, nil
114+
}).
115+
Always(func(_ context.Context, _ *clusterv1.GetClusterRequest) (*clusterv1.GetClusterResponse, error) {
116+
return &clusterv1.GetClusterResponse{
117+
Cluster: &clusterv1.Cluster{
118+
Id: "cluster-restored",
119+
State: &clusterv1.ClusterState{
120+
Phase: clusterv1.ClusterPhase_CLUSTER_PHASE_HEALTHY,
121+
Endpoint: &clusterv1.ClusterEndpoint{Url: "https://restored.aws.cloud.qdrant.io"},
122+
},
123+
},
124+
}, nil
125+
})
126+
127+
stdout, stderr, err := testutil.Exec(t, env,
128+
"cluster", "create-from-backup",
129+
"--backup-id", "backup-abc",
130+
"--name", "my-restored-cluster",
131+
"--wait",
132+
"--wait-timeout", "30s",
133+
"--wait-poll-interval", "10ms",
134+
)
135+
require.NoError(t, err)
136+
assert.Contains(t, stderr, "cluster-restored")
137+
assert.Contains(t, stdout, "cluster-restored")
138+
assert.Contains(t, stdout, "https://restored.aws.cloud.qdrant.io")
139+
assert.Positive(t, env.Server.GetClusterCalls.Count())
140+
}

internal/testutil/fake_cluster.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,17 @@ import (
1111
type FakeClusterService struct {
1212
clusterv1.UnimplementedClusterServiceServer
1313

14-
ListClustersCalls MethodSpy[*clusterv1.ListClustersRequest, *clusterv1.ListClustersResponse]
15-
GetClusterCalls MethodSpy[*clusterv1.GetClusterRequest, *clusterv1.GetClusterResponse]
16-
CreateClusterCalls MethodSpy[*clusterv1.CreateClusterRequest, *clusterv1.CreateClusterResponse]
17-
UpdateClusterCalls MethodSpy[*clusterv1.UpdateClusterRequest, *clusterv1.UpdateClusterResponse]
18-
DeleteClusterCalls MethodSpy[*clusterv1.DeleteClusterRequest, *clusterv1.DeleteClusterResponse]
19-
RestartClusterCalls MethodSpy[*clusterv1.RestartClusterRequest, *clusterv1.RestartClusterResponse]
20-
SuspendClusterCalls MethodSpy[*clusterv1.SuspendClusterRequest, *clusterv1.SuspendClusterResponse]
21-
UnsuspendClusterCalls MethodSpy[*clusterv1.UnsuspendClusterRequest, *clusterv1.UnsuspendClusterResponse]
22-
SuggestClusterNameCalls MethodSpy[*clusterv1.SuggestClusterNameRequest, *clusterv1.SuggestClusterNameResponse]
23-
ListQdrantReleasesCalls MethodSpy[*clusterv1.ListQdrantReleasesRequest, *clusterv1.ListQdrantReleasesResponse]
14+
ListClustersCalls MethodSpy[*clusterv1.ListClustersRequest, *clusterv1.ListClustersResponse]
15+
GetClusterCalls MethodSpy[*clusterv1.GetClusterRequest, *clusterv1.GetClusterResponse]
16+
CreateClusterCalls MethodSpy[*clusterv1.CreateClusterRequest, *clusterv1.CreateClusterResponse]
17+
UpdateClusterCalls MethodSpy[*clusterv1.UpdateClusterRequest, *clusterv1.UpdateClusterResponse]
18+
DeleteClusterCalls MethodSpy[*clusterv1.DeleteClusterRequest, *clusterv1.DeleteClusterResponse]
19+
RestartClusterCalls MethodSpy[*clusterv1.RestartClusterRequest, *clusterv1.RestartClusterResponse]
20+
SuspendClusterCalls MethodSpy[*clusterv1.SuspendClusterRequest, *clusterv1.SuspendClusterResponse]
21+
UnsuspendClusterCalls MethodSpy[*clusterv1.UnsuspendClusterRequest, *clusterv1.UnsuspendClusterResponse]
22+
SuggestClusterNameCalls MethodSpy[*clusterv1.SuggestClusterNameRequest, *clusterv1.SuggestClusterNameResponse]
23+
ListQdrantReleasesCalls MethodSpy[*clusterv1.ListQdrantReleasesRequest, *clusterv1.ListQdrantReleasesResponse]
24+
CreateClusterFromBackupCalls MethodSpy[*clusterv1.CreateClusterFromBackupRequest, *clusterv1.CreateClusterFromBackupResponse]
2425
}
2526

2627
// ListClusters records the call and dispatches via ListClustersCalls.
@@ -82,3 +83,9 @@ func (f *FakeClusterService) ListQdrantReleases(ctx context.Context, req *cluste
8283
f.ListQdrantReleasesCalls.record(req)
8384
return f.ListQdrantReleasesCalls.dispatch(ctx, req, f.UnimplementedClusterServiceServer.ListQdrantReleases)
8485
}
86+
87+
// CreateClusterFromBackup records the call and dispatches via CreateClusterFromBackupCalls.
88+
func (f *FakeClusterService) CreateClusterFromBackup(ctx context.Context, req *clusterv1.CreateClusterFromBackupRequest) (*clusterv1.CreateClusterFromBackupResponse, error) {
89+
f.CreateClusterFromBackupCalls.record(req)
90+
return f.CreateClusterFromBackupCalls.dispatch(ctx, req, f.UnimplementedClusterServiceServer.CreateClusterFromBackup)
91+
}

0 commit comments

Comments
 (0)