Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ lint:
golangci-lint run

format:
$(GOLANGCI_LINT) run --fix
golangci-lint run --fix

clean:
rm -rf build/
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
32 changes: 32 additions & 0 deletions internal/cmd/backup/backup.go
Original file line number Diff line number Diff line change
@@ -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_")
}
46 changes: 46 additions & 0 deletions internal/cmd/backup/completion.go
Original file line number Diff line number Diff line change
@@ -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
}
}
72 changes: 72 additions & 0 deletions internal/cmd/backup/create.go
Original file line number Diff line number Diff line change
@@ -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
}
100 changes: 100 additions & 0 deletions internal/cmd/backup/create_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
59 changes: 59 additions & 0 deletions internal/cmd/backup/delete.go
Original file line number Diff line number Diff line change
@@ -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 <backup-id>",
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)
}
43 changes: 43 additions & 0 deletions internal/cmd/backup/delete_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading