diff --git a/docs/stackit_volume.md b/docs/stackit_volume.md index c83878554..4955b6299 100644 --- a/docs/stackit_volume.md +++ b/docs/stackit_volume.md @@ -30,6 +30,7 @@ stackit volume [flags] ### SEE ALSO * [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups * [stackit volume create](./stackit_volume_create.md) - Creates a volume * [stackit volume delete](./stackit_volume_delete.md) - Deletes a volume * [stackit volume describe](./stackit_volume_describe.md) - Shows details of a volume diff --git a/docs/stackit_volume_backup.md b/docs/stackit_volume_backup.md new file mode 100644 index 000000000..f6390f385 --- /dev/null +++ b/docs/stackit_volume_backup.md @@ -0,0 +1,39 @@ +## stackit volume backup + +Provides functionality for volume backups + +### Synopsis + +Provides functionality for volume backups. + +``` +stackit volume backup [flags] +``` + +### Options + +``` + -h, --help Help for "stackit volume backup" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume](./stackit_volume.md) - Provides functionality for volumes +* [stackit volume backup create](./stackit_volume_backup_create.md) - Creates a backup from a specific source +* [stackit volume backup delete](./stackit_volume_backup_delete.md) - Deletes a backup +* [stackit volume backup describe](./stackit_volume_backup_describe.md) - Describes a backup +* [stackit volume backup list](./stackit_volume_backup_list.md) - Lists all backups +* [stackit volume backup restore](./stackit_volume_backup_restore.md) - Restores a backup +* [stackit volume backup update](./stackit_volume_backup_update.md) - Updates a backup + diff --git a/docs/stackit_volume_backup_create.md b/docs/stackit_volume_backup_create.md new file mode 100644 index 000000000..ef5013918 --- /dev/null +++ b/docs/stackit_volume_backup_create.md @@ -0,0 +1,50 @@ +## stackit volume backup create + +Creates a backup from a specific source + +### Synopsis + +Creates a backup from a specific source (volume or snapshot). + +``` +stackit volume backup create [flags] +``` + +### Examples + +``` + Create a backup from a volume + $ stackit volume backup create --source-id xxx --source-type volume + + Create a backup from a snapshot with a name + $ stackit volume backup create --source-id xxx --source-type snapshot --name my-backup + + Create a backup with labels + $ stackit volume backup create --source-id xxx --source-type volume --labels key1=value1,key2=value2 +``` + +### Options + +``` + -h, --help Help for "stackit volume backup create" + --labels stringToString Key-value string pairs as labels (default []) + --name string Name of the backup + --source-id string ID of the source from which a backup should be created + --source-type string Source type of the backup (volume or snapshot) +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups + diff --git a/docs/stackit_volume_backup_delete.md b/docs/stackit_volume_backup_delete.md new file mode 100644 index 000000000..5300f7854 --- /dev/null +++ b/docs/stackit_volume_backup_delete.md @@ -0,0 +1,40 @@ +## stackit volume backup delete + +Deletes a backup + +### Synopsis + +Deletes a backup by its ID. + +``` +stackit volume backup delete BACKUP_ID [flags] +``` + +### Examples + +``` + Delete a backup with ID "xxx" + $ stackit volume backup delete xxx +``` + +### Options + +``` + -h, --help Help for "stackit volume backup delete" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups + diff --git a/docs/stackit_volume_backup_describe.md b/docs/stackit_volume_backup_describe.md new file mode 100644 index 000000000..dbff5e4dc --- /dev/null +++ b/docs/stackit_volume_backup_describe.md @@ -0,0 +1,43 @@ +## stackit volume backup describe + +Describes a backup + +### Synopsis + +Describes a backup by its ID. + +``` +stackit volume backup describe BACKUP_ID [flags] +``` + +### Examples + +``` + Get details of a backup with ID "xxx" + $ stackit volume backup describe xxx + + Get details of a backup with ID "xxx" in JSON format + $ stackit volume backup describe xxx --output-format json +``` + +### Options + +``` + -h, --help Help for "stackit volume backup describe" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups + diff --git a/docs/stackit_volume_backup_list.md b/docs/stackit_volume_backup_list.md new file mode 100644 index 000000000..91f3ca99a --- /dev/null +++ b/docs/stackit_volume_backup_list.md @@ -0,0 +1,51 @@ +## stackit volume backup list + +Lists all backups + +### Synopsis + +Lists all backups in a project. + +``` +stackit volume backup list [flags] +``` + +### Examples + +``` + List all backups + $ stackit volume backup list + + List all backups in JSON format + $ stackit volume backup list --output-format json + + List up to 10 backups + $ stackit volume backup list --limit 10 + + List backups with specific labels + $ stackit volume backup list --label-selector key1=value1,key2=value2 +``` + +### Options + +``` + -h, --help Help for "stackit volume backup list" + --label-selector string Filter backups by labels + --limit int Maximum number of entries to list +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups + diff --git a/docs/stackit_volume_backup_restore.md b/docs/stackit_volume_backup_restore.md new file mode 100644 index 000000000..80dc563db --- /dev/null +++ b/docs/stackit_volume_backup_restore.md @@ -0,0 +1,40 @@ +## stackit volume backup restore + +Restores a backup + +### Synopsis + +Restores a backup by its ID. + +``` +stackit volume backup restore BACKUP_ID [flags] +``` + +### Examples + +``` + Restore a backup with ID "xxx" + $ stackit volume backup restore xxx +``` + +### Options + +``` + -h, --help Help for "stackit volume backup restore" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups + diff --git a/docs/stackit_volume_backup_update.md b/docs/stackit_volume_backup_update.md new file mode 100644 index 000000000..02f86f4e8 --- /dev/null +++ b/docs/stackit_volume_backup_update.md @@ -0,0 +1,45 @@ +## stackit volume backup update + +Updates a backup + +### Synopsis + +Updates a backup by its ID. + +``` +stackit volume backup update BACKUP_ID [flags] +``` + +### Examples + +``` + Update the name of a backup with ID "xxx" + $ stackit volume backup update xxx --name new-name + + Update the labels of a backup with ID "xxx" + $ stackit volume backup update xxx --labels key1=value1,key2=value2 +``` + +### Options + +``` + -h, --help Help for "stackit volume backup update" + --labels stringToString Key-value string pairs as labels (default []) + --name string Name of the backup +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --region string Target region for region-specific requests + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit volume backup](./stackit_volume_backup.md) - Provides functionality for volume backups + diff --git a/internal/cmd/volume/backup/backup.go b/internal/cmd/volume/backup/backup.go new file mode 100644 index 000000000..b7cf8b37b --- /dev/null +++ b/internal/cmd/volume/backup/backup.go @@ -0,0 +1,36 @@ +package backup + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup/restore" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup/update" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "backup", + Short: "Provides functionality for volume backups", + Long: "Provides functionality for volume backups.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, params) + return cmd +} + +func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { + cmd.AddCommand(create.NewCmd(params)) + cmd.AddCommand(list.NewCmd(params)) + cmd.AddCommand(update.NewCmd(params)) + cmd.AddCommand(delete.NewCmd(params)) + cmd.AddCommand(describe.NewCmd(params)) + cmd.AddCommand(restore.NewCmd(params)) +} diff --git a/internal/cmd/volume/backup/create/create.go b/internal/cmd/volume/backup/create/create.go new file mode 100644 index 000000000..5848a972a --- /dev/null +++ b/internal/cmd/volume/backup/create/create.go @@ -0,0 +1,233 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" +) + +const ( + sourceIdFlag = "source-id" + sourceTypeFlag = "source-type" + nameFlag = "name" + labelsFlag = "labels" +) + +var sourceTypeFlagOptions = []string{"volume", "snapshot"} + +type inputModel struct { + *globalflags.GlobalFlagModel + SourceID string + SourceType string + Name *string + Labels map[string]string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates a backup from a specific source", + Long: "Creates a backup from a specific source (volume or snapshot).", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Create a backup from a volume`, + "$ stackit volume backup create --source-id xxx --source-type volume"), + examples.NewExample( + `Create a backup from a snapshot with a name`, + "$ stackit volume backup create --source-id xxx --source-type snapshot --name my-backup"), + examples.NewExample( + `Create a backup with labels`, + "$ stackit volume backup create --source-id xxx --source-type volume --labels key1=value1,key2=value2"), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + + // Get source name for label (use ID if name not available) + sourceLabel := model.SourceID + if model.SourceType == "volume" { + volume, err := apiClient.GetVolume(ctx, model.ProjectId, model.SourceID).Execute() + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) + } else if volume != nil && volume.Name != nil { + sourceLabel = *volume.Name + } + } else if model.SourceType == "snapshot" { + snapshot, err := apiClient.GetSnapshot(ctx, model.ProjectId, model.SourceID).Execute() + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get snapshot name: %v", err) + } else if snapshot != nil && snapshot.Name != nil { + sourceLabel = *snapshot.Name + } + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create backup from %s? (This cannot be undone)", sourceLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create volume backup: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Creating backup") + resp, err = wait.CreateBackupWaitHandler(ctx, apiClient, model.ProjectId, *resp.Id).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for backup creation: %w", err) + } + s.Stop() + } + + return outputResult(params.Printer, model.OutputFormat, model.Async, sourceLabel, projectLabel, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(sourceIdFlag, "", "ID of the source from which a backup should be created") + cmd.Flags().Var(flags.EnumFlag(false, "", sourceTypeFlagOptions...), sourceTypeFlag, fmt.Sprintf("Source type of the backup, one of %q", sourceTypeFlagOptions)) + cmd.Flags().String(nameFlag, "", "Name of the backup") + cmd.Flags().StringToString(labelsFlag, nil, "Key-value string pairs as labels") + + err := flags.MarkFlagsRequired(cmd, sourceIdFlag, sourceTypeFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + sourceID := flags.FlagToStringValue(p, cmd, sourceIdFlag) + if sourceID == "" { + return nil, fmt.Errorf("source-id is required") + } + + sourceType := flags.FlagToStringValue(p, cmd, sourceTypeFlag) + + name := flags.FlagToStringPointer(p, cmd, nameFlag) + labels := flags.FlagToStringToStringPointer(p, cmd, labelsFlag) + if labels == nil { + labels = &map[string]string{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + SourceID: sourceID, + SourceType: sourceType, + Name: name, + Labels: *labels, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiCreateBackupRequest { + req := apiClient.CreateBackup(ctx, model.ProjectId) + + // Convert map[string]string to map[string]interface{} + var labelsMap *map[string]interface{} + if len(model.Labels) > 0 { + labelsMap = utils.Ptr(map[string]interface{}{}) + for k, v := range model.Labels { + (*labelsMap)[k] = v + } + } + + createPayload := iaas.NewCreateBackupPayloadWithDefaults() + createPayload.Name = model.Name + createPayload.Labels = labelsMap + createPayload.Source = &iaas.BackupSource{ + Id: &model.SourceID, + Type: &model.SourceType, + } + + return req.CreateBackupPayload(*createPayload) +} + +func outputResult(p *print.Printer, outputFormat string, async bool, sourceLabel, projectLabel string, resp *iaas.Backup) error { + if resp == nil { + return fmt.Errorf("create backup response is empty") + } + + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("marshal backup: %w", err) + } + p.Outputln(string(details)) + return nil + + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(resp, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal backup: %w", err) + } + p.Outputln(string(details)) + return nil + + default: + if async { + p.Outputf("Triggered backup of %s in %s. Backup ID: %s\n", sourceLabel, projectLabel, utils.PtrString(resp.Id)) + } else { + p.Outputf("Created backup of %s in %s. Backup ID: %s\n", sourceLabel, projectLabel, utils.PtrString(resp.Id)) + } + return nil + } +} diff --git a/internal/cmd/volume/backup/create/create_test.go b/internal/cmd/volume/backup/create/create_test.go new file mode 100644 index 000000000..3c4980cc6 --- /dev/null +++ b/internal/cmd/volume/backup/create/create_test.go @@ -0,0 +1,309 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type testCtxKey struct{} + +const ( + testName = "my-backup" + testSourceType = "volume" +) + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testSourceId = uuid.NewString() + testLabels = map[string]string{"key1": "value1"} +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + sourceIdFlag: testSourceId, + sourceTypeFlag: testSourceType, + nameFlag: testName, + labelsFlag: "key1=value1", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + SourceID: testSourceId, + SourceType: testSourceType, + Name: utils.Ptr(testName), + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiCreateBackupRequest)) iaas.ApiCreateBackupRequest { + request := testClient.CreateBackup(testCtx, testProjectId) + + createPayload := iaas.NewCreateBackupPayloadWithDefaults() + createPayload.Name = utils.Ptr(testName) + createPayload.Labels = &map[string]interface{}{ + "key1": "value1", + } + createPayload.Source = &iaas.BackupSource{ + Id: &testSourceId, + Type: utils.Ptr(testSourceType), + } + + request = request.CreateBackupPayload(*createPayload) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no source id", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, sourceIdFlag) + }), + isValid: false, + }, + { + description: "no source type", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, sourceTypeFlag) + }), + isValid: false, + }, + { + description: "invalid source type", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[sourceTypeFlag] = "invalid" + }), + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "only required flags", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, nameFlag) + delete(flagValues, labelsFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Name = nil + model.Labels = make(map[string]string) + }), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiCreateBackupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + backupId := "test-backup-id" + + type args struct { + outputFormat string + async bool + sourceLabel string + projectLabel string + backup *iaas.Backup + } + + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty backup", + args: args{}, + wantErr: true, + }, + { + name: "backup is nil", + args: args{ + backup: nil, + }, + wantErr: true, + }, + { + name: "minimal backup", + args: args{ + backup: &iaas.Backup{ + Id: &backupId, + }, + sourceLabel: "test-source", + projectLabel: "test-project", + }, + wantErr: false, + }, + { + name: "async mode", + args: args{ + backup: &iaas.Backup{ + Id: &backupId, + }, + sourceLabel: "test-source", + projectLabel: "test-project", + async: true, + }, + wantErr: false, + }, + { + name: "json output", + args: args{ + backup: &iaas.Backup{ + Id: &backupId, + }, + outputFormat: print.JSONOutputFormat, + }, + wantErr: false, + }, + { + name: "yaml output", + args: args{ + backup: &iaas.Backup{ + Id: &backupId, + }, + outputFormat: print.YAMLOutputFormat, + }, + wantErr: false, + }, + } + + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + p.Cmd = cmd + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.async, tt.args.sourceLabel, tt.args.projectLabel, tt.args.backup); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/volume/backup/delete/delete.go b/internal/cmd/volume/backup/delete/delete.go new file mode 100644 index 000000000..3a9bcfc7b --- /dev/null +++ b/internal/cmd/volume/backup/delete/delete.go @@ -0,0 +1,128 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" +) + +const ( + backupIdArg = "BACKUP_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + BackupId string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", backupIdArg), + Short: "Deletes a backup", + Long: "Deletes a backup by its ID.", + Args: args.SingleArg(backupIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Delete a backup with ID "xxx"`, "$ stackit volume backup delete xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + backup, err := apiClient.GetBackup(ctx, model.ProjectId, model.BackupId).Execute() + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get backup name: %v", err) + } + backupLabel := model.BackupId + if backup != nil && backup.Name != nil { + backupLabel = *backup.Name + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete backup %q? (This cannot be undone)", backupLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("delete backup: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Deleting backup") + _, err = wait.DeleteBackupWaitHandler(ctx, apiClient, model.ProjectId, model.BackupId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for backup deletion: %w", err) + } + s.Stop() + } + + if model.Async { + params.Printer.Info("Triggered deletion of backup %q\n", backupLabel) + } else { + params.Printer.Info("Deleted backup %q\n", backupLabel) + } + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + backupId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + BackupId: backupId, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiDeleteBackupRequest { + req := apiClient.DeleteBackup(ctx, model.ProjectId, model.BackupId) + return req +} diff --git a/internal/cmd/volume/backup/delete/delete_test.go b/internal/cmd/volume/backup/delete/delete_test.go new file mode 100644 index 000000000..8425e9c98 --- /dev/null +++ b/internal/cmd/volume/backup/delete/delete_test.go @@ -0,0 +1,183 @@ +package delete + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testBackupId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testBackupId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + BackupId: testBackupId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiDeleteBackupRequest)) iaas.ApiDeleteBackupRequest { + request := testClient.DeleteBackup(testCtx, testProjectId, testBackupId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiDeleteBackupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/volume/backup/describe/describe.go b/internal/cmd/volume/backup/describe/describe.go new file mode 100644 index 000000000..f181195d9 --- /dev/null +++ b/internal/cmd/volume/backup/describe/describe.go @@ -0,0 +1,162 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + backupIdArg = "BACKUP_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + BackupId string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", backupIdArg), + Short: "Describes a backup", + Long: "Describes a backup by its ID.", + Args: args.SingleArg(backupIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Get details of a backup with ID "xxx"`, + "$ stackit volume backup describe xxx"), + examples.NewExample( + `Get details of a backup with ID "xxx" in JSON format`, + "$ stackit volume backup describe xxx --output-format json"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + backup, err := req.Execute() + if err != nil { + return fmt.Errorf("get backup details: %w", err) + } + + return outputResult(params.Printer, model.OutputFormat, backup) + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + backupId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + BackupId: backupId, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiGetBackupRequest { + req := apiClient.GetBackup(ctx, model.ProjectId, model.BackupId) + return req +} + +func outputResult(p *print.Printer, outputFormat string, backup *iaas.Backup) error { + if backup == nil { + return fmt.Errorf("backup response is empty") + } + + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(backup, "", " ") + if err != nil { + return fmt.Errorf("marshal backup: %w", err) + } + p.Outputln(string(details)) + return nil + + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(backup, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal backup: %w", err) + } + p.Outputln(string(details)) + return nil + + default: + table := tables.NewTable() + table.AddRow("ID", utils.PtrString(backup.Id)) + table.AddSeparator() + table.AddRow("NAME", utils.PtrString(backup.Name)) + table.AddSeparator() + table.AddRow("SIZE", utils.PtrByteSizeDefault(backup.Size, "")) + table.AddSeparator() + table.AddRow("STATUS", utils.PtrString(backup.Status)) + table.AddSeparator() + table.AddRow("SNAPSHOT ID", utils.PtrString(backup.SnapshotId)) + table.AddSeparator() + table.AddRow("VOLUME ID", utils.PtrString(backup.VolumeId)) + table.AddSeparator() + table.AddRow("AVAILABILITY ZONE", utils.PtrString(backup.AvailabilityZone)) + table.AddSeparator() + + if backup.Labels != nil && len(*backup.Labels) > 0 { + var labels []string + for key, value := range *backup.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + table.AddRow("LABELS", strings.Join(labels, "\n")) + table.AddSeparator() + } + + table.AddRow("CREATED AT", utils.ConvertTimePToDateTimeString(backup.CreatedAt)) + table.AddSeparator() + table.AddRow("UPDATED AT", utils.ConvertTimePToDateTimeString(backup.UpdatedAt)) + + err := table.Display(p) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/volume/backup/describe/describe_test.go b/internal/cmd/volume/backup/describe/describe_test.go new file mode 100644 index 000000000..3dcac1e56 --- /dev/null +++ b/internal/cmd/volume/backup/describe/describe_test.go @@ -0,0 +1,217 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testBackupId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testBackupId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + BackupId: testBackupId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiGetBackupRequest)) iaas.ApiGetBackupRequest { + request := testClient.GetBackup(testCtx, testProjectId, testBackupId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiGetBackupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + backup *iaas.Backup + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "backup as argument", + args: args{ + backup: &iaas.Backup{}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.backup); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/volume/backup/list/list.go b/internal/cmd/volume/backup/list/list.go new file mode 100644 index 000000000..540c00480 --- /dev/null +++ b/internal/cmd/volume/backup/list/list.go @@ -0,0 +1,202 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/projectname" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + limitFlag = "limit" + labelSelectorFlag = "label-selector" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + Limit *int64 + LabelSelector *string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all backups", + Long: "Lists all backups in a project.", + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `List all backups`, + "$ stackit volume backup list"), + examples.NewExample( + `List all backups in JSON format`, + "$ stackit volume backup list --output-format json"), + examples.NewExample( + `List up to 10 backups`, + "$ stackit volume backup list --limit 10"), + examples.NewExample( + `List backups with specific labels`, + "$ stackit volume backup list --label-selector key1=value1,key2=value2"), + ), + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get backups: %w", err) + } + if resp.Items == nil || len(*resp.Items) == 0 { + projectLabel, err := projectname.GetProjectName(ctx, params.Printer, params.CliVersion, cmd) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get project name: %v", err) + projectLabel = model.ProjectId + } + params.Printer.Info("No backups found for project %s\n", projectLabel) + return nil + } + backups := *resp.Items + + // Truncate output + if model.Limit != nil && len(backups) > int(*model.Limit) { + backups = backups[:*model.Limit] + } + + return outputResult(params.Printer, model.OutputFormat, backups) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") + cmd.Flags().String(labelSelectorFlag, "", "Filter backups by labels") +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(p, cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + labelSelector := flags.FlagToStringPointer(p, cmd, labelSelectorFlag) + + model := inputModel{ + GlobalFlagModel: globalFlags, + Limit: limit, + LabelSelector: labelSelector, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiListBackupsRequest { + req := apiClient.ListBackups(ctx, model.ProjectId) + + if model.LabelSelector != nil { + req = req.LabelSelector(*model.LabelSelector) + } + + return req +} + +func outputResult(p *print.Printer, outputFormat string, backups []iaas.Backup) error { + if backups == nil { + return fmt.Errorf("backups is empty") + } + + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(backups, "", " ") + if err != nil { + return fmt.Errorf("marshal backup list: %w", err) + } + p.Outputln(string(details)) + return nil + + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(backups, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal backup list: %w", err) + } + p.Outputln(string(details)) + return nil + + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "SIZE", "STATUS", "SNAPSHOT ID", "VOLUME ID", "AVAILABILITY ZONE", "LABELS", "CREATED AT", "UPDATED AT") + + for _, backup := range backups { + var labelsString string + if backup.Labels != nil { + var labels []string + for key, value := range *backup.Labels { + labels = append(labels, fmt.Sprintf("%s: %s", key, value)) + } + labelsString = strings.Join(labels, ", ") + } + + table.AddRow( + utils.PtrString(backup.Id), + utils.PtrString(backup.Name), + utils.PtrByteSizeDefault(backup.Size, ""), + utils.PtrString(backup.Status), + utils.PtrString(backup.SnapshotId), + utils.PtrString(backup.VolumeId), + utils.PtrString(backup.AvailabilityZone), + labelsString, + utils.ConvertTimePToDateTimeString(backup.CreatedAt), + utils.ConvertTimePToDateTimeString(backup.UpdatedAt), + ) + table.AddSeparator() + } + + p.Outputln(table.Render()) + return nil + } +} diff --git a/internal/cmd/volume/backup/list/list_test.go b/internal/cmd/volume/backup/list/list_test.go new file mode 100644 index 000000000..7a24783c2 --- /dev/null +++ b/internal/cmd/volume/backup/list/list_test.go @@ -0,0 +1,233 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() +) + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + limitFlag: "10", + labelSelectorFlag: "key1=value1", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + Limit: utils.Ptr(int64(10)), + LabelSelector: utils.Ptr("key1=value1"), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiListBackupsRequest)) iaas.ApiListBackupsRequest { + request := testClient.ListBackups(testCtx, testProjectId) + request = request.LabelSelector("key1=value1") + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[globalflags.ProjectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "limit invalid", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "invalid" + }), + isValid: false, + }, + { + description: "limit invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[limitFlag] = "0" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + p := print.NewPrinter() + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiListBackupsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestOutputResult(t *testing.T) { + type args struct { + outputFormat string + backups []iaas.Backup + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{}, + wantErr: true, + }, + { + name: "empty backup in slice", + args: args{ + backups: []iaas.Backup{{}}, + }, + wantErr: false, + }, + { + name: "empty slice", + args: args{ + backups: []iaas.Backup{}, + }, + wantErr: false, + }, + } + p := print.NewPrinter() + p.Cmd = NewCmd(¶ms.CmdParams{Printer: p}) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := outputResult(p, tt.args.outputFormat, tt.args.backups); (err != nil) != tt.wantErr { + t.Errorf("outputResult() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/cmd/volume/backup/restore/restore.go b/internal/cmd/volume/backup/restore/restore.go new file mode 100644 index 000000000..6cf0b2962 --- /dev/null +++ b/internal/cmd/volume/backup/restore/restore.go @@ -0,0 +1,141 @@ +package restore + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/spinner" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" + "github.com/stackitcloud/stackit-sdk-go/services/iaas/wait" +) + +const ( + backupIdArg = "BACKUP_ID" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + BackupId string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("restore %s", backupIdArg), + Short: "Restores a backup", + Long: "Restores a backup by its ID.", + Args: args.SingleArg(backupIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Restore a backup with ID "xxx"`, "$ stackit volume backup restore xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Get backup details for labels + backup, err := apiClient.GetBackup(ctx, model.ProjectId, model.BackupId).Execute() + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get backup details: %v", err) + } + backupLabel := model.BackupId + if backup != nil && backup.Name != nil { + backupLabel = *backup.Name + } + + // Get source details for labels + var sourceLabel string + if backup != nil && backup.VolumeId != nil { + sourceLabel = *backup.VolumeId + volume, err := apiClient.GetVolume(ctx, model.ProjectId, *backup.VolumeId).Execute() + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get volume details: %v", err) + } else if volume.Name != nil { + sourceLabel = *volume.Name + } + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to restore %q with backup %q? (This cannot be undone)", sourceLabel, backupLabel) + err = params.Printer.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("restore backup: %w", err) + } + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(params.Printer) + s.Start("Restoring backup") + _, err = wait.RestoreBackupWaitHandler(ctx, apiClient, model.ProjectId, model.BackupId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for backup restore: %w", err) + } + s.Stop() + } + + if model.Async { + params.Printer.Info("Triggered restore of %q with %q in %q\n", sourceLabel, backupLabel, model.ProjectId) + } else { + params.Printer.Info("Restored %q with %q in %q\n", sourceLabel, backupLabel, model.ProjectId) + } + return nil + }, + } + return cmd +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + backupId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + BackupId: backupId, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiRestoreBackupRequest { + req := apiClient.RestoreBackup(ctx, model.ProjectId, model.BackupId) + return req +} diff --git a/internal/cmd/volume/backup/restore/restore_test.go b/internal/cmd/volume/backup/restore/restore_test.go new file mode 100644 index 000000000..217300720 --- /dev/null +++ b/internal/cmd/volume/backup/restore/restore_test.go @@ -0,0 +1,183 @@ +package restore + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testBackupId = uuid.NewString() +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testBackupId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + BackupId: testBackupId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiRestoreBackupRequest)) iaas.ApiRestoreBackupRequest { + request := testClient.RestoreBackup(testCtx, testProjectId, testBackupId) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, globalflags.ProjectIdFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiRestoreBackupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/volume/backup/update/update.go b/internal/cmd/volume/backup/update/update.go new file mode 100644 index 000000000..181218f9e --- /dev/null +++ b/internal/cmd/volume/backup/update/update.go @@ -0,0 +1,170 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +const ( + backupIdArg = "BACKUP_ID" + nameFlag = "name" + labelsFlag = "labels" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + BackupId string + Name *string + Labels map[string]string +} + +func NewCmd(params *params.CmdParams) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", backupIdArg), + Short: "Updates a backup", + Long: "Updates a backup by its ID.", + Args: args.SingleArg(backupIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the name of a backup with ID "xxx"`, + "$ stackit volume backup update xxx --name new-name"), + examples.NewExample( + `Update the labels of a backup with ID "xxx"`, + "$ stackit volume backup update xxx --labels key1=value1,key2=value2"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(params.Printer, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(params.Printer, params.CliVersion) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("update backup: %w", err) + } + + // Get backup label (use ID if name not available) + backupLabel := model.BackupId + if resp.Name != nil { + backupLabel = *resp.Name + } + + return outputResult(params.Printer, model.OutputFormat, backupLabel, resp) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(nameFlag, "", "Name of the backup") + cmd.Flags().StringToString(labelsFlag, nil, "Key-value string pairs as labels") +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + backupId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + name := flags.FlagToStringPointer(p, cmd, nameFlag) + labels := flags.FlagToStringToStringPointer(p, cmd, labelsFlag) + if labels == nil { + labels = &map[string]string{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + BackupId: backupId, + Name: name, + Labels: *labels, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateBackupRequest { + req := apiClient.UpdateBackup(ctx, model.ProjectId, model.BackupId) + + updatePayload := iaas.NewUpdateBackupPayloadWithDefaults() + if model.Name != nil { + updatePayload.Name = model.Name + } + + // Convert map[string]string to map[string]interface{} + var labelsMap *map[string]interface{} + if len(model.Labels) > 0 { + labelsMap = utils.Ptr(map[string]interface{}{}) + for k, v := range model.Labels { + (*labelsMap)[k] = v + } + } + updatePayload.Labels = labelsMap + + req = req.UpdateBackupPayload(*updatePayload) + return req +} + +func outputResult(p *print.Printer, outputFormat, backupLabel string, backup *iaas.Backup) error { + if backup == nil { + return fmt.Errorf("backup response is empty") + } + + switch outputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(backup, "", " ") + if err != nil { + return fmt.Errorf("marshal backup: %w", err) + } + p.Outputln(string(details)) + return nil + + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(backup, yaml.IndentSequence(true), yaml.UseJSONMarshaler()) + if err != nil { + return fmt.Errorf("marshal backup: %w", err) + } + p.Outputln(string(details)) + return nil + + default: + p.Outputf("Updated backup %q\n", backupLabel) + return nil + } +} diff --git a/internal/cmd/volume/backup/update/update_test.go b/internal/cmd/volume/backup/update/update_test.go new file mode 100644 index 000000000..b0826ae65 --- /dev/null +++ b/internal/cmd/volume/backup/update/update_test.go @@ -0,0 +1,192 @@ +package update + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/iaas" +) + +type testCtxKey struct{} + +var ( + testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") + testClient = &iaas.APIClient{} + testProjectId = uuid.NewString() + testBackupId = uuid.NewString() + testName = "test-backup" + testLabels = map[string]string{"key1": "value1"} +) + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testBackupId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + globalflags.ProjectIdFlag: testProjectId, + nameFlag: testName, + labelsFlag: "key1=value1", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + BackupId: testBackupId, + Name: &testName, + Labels: testLabels, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *iaas.ApiUpdateBackupRequest)) iaas.ApiUpdateBackupRequest { + request := testClient.UpdateBackup(testCtx, testProjectId, testBackupId) + payload := iaas.NewUpdateBackupPayloadWithDefaults() + payload.Name = &testName + + // Convert test labels to map[string]interface{} + labelsMap := map[string]interface{}{} + for k, v := range testLabels { + labelsMap[k] = v + } + payload.Labels = &labelsMap + + request = request.UpdateBackupPayload(*payload) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(¶ms.CmdParams{Printer: p}) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + model, err := parseInput(p, cmd, tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing input: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest iaas.ApiUpdateBackupRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + request := buildRequest(testCtx, tt.model, testClient) + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/volume/volume.go b/internal/cmd/volume/volume.go index 1e876e85b..d5cad1614 100644 --- a/internal/cmd/volume/volume.go +++ b/internal/cmd/volume/volume.go @@ -2,6 +2,7 @@ package volume import ( "github.com/stackitcloud/stackit-cli/internal/cmd/params" + "github.com/stackitcloud/stackit-cli/internal/cmd/volume/backup" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/create" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/volume/describe" @@ -35,4 +36,5 @@ func addSubcommands(cmd *cobra.Command, params *params.CmdParams) { cmd.AddCommand(update.NewCmd(params)) cmd.AddCommand(resize.NewCmd(params)) cmd.AddCommand(performanceclass.NewCmd(params)) + cmd.AddCommand(backup.NewCmd(params)) }