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..5a322f34a --- /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, one of ["volume" "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/go.mod b/go.mod index 29ef475e5..c3bb00c1f 100644 --- a/go.mod +++ b/go.mod @@ -18,11 +18,11 @@ require ( github.com/stackitcloud/stackit-sdk-go/core v0.17.2 github.com/stackitcloud/stackit-sdk-go/services/alb v0.5.0 github.com/stackitcloud/stackit-sdk-go/services/authorization v0.7.0 - github.com/stackitcloud/stackit-sdk-go/services/dns v0.15.1 - github.com/stackitcloud/stackit-sdk-go/services/git v0.5.1 + github.com/stackitcloud/stackit-sdk-go/services/dns v0.15.0 + github.com/stackitcloud/stackit-sdk-go/services/git v0.5.0 github.com/stackitcloud/stackit-sdk-go/services/iaas v0.24.0 - github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.2.1 - github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.23.1 + github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.2.0 + github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.23.0 github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.1.0 github.com/stackitcloud/stackit-sdk-go/services/resourcemanager v0.16.0 github.com/stackitcloud/stackit-sdk-go/services/runcommand v1.2.1 diff --git a/go.sum b/go.sum index 3560b9d7b..6bd663516 100644 --- a/go.sum +++ b/go.sum @@ -568,10 +568,10 @@ github.com/stackitcloud/stackit-sdk-go/services/alb v0.5.0 h1:7UKUi7Od7KpuVjV4I/ github.com/stackitcloud/stackit-sdk-go/services/alb v0.5.0/go.mod h1:RBLBx00zF9MoA/mcLoWwYaACFE0xrWp/EHlzo5S7nhA= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.7.0 h1:VpONplkdlEh7Pf22+cNnnHH4bx+S9QI+z55XYRE74JY= github.com/stackitcloud/stackit-sdk-go/services/authorization v0.7.0/go.mod h1:dJ19ZwFjp2bfC5ZobXV3vUdSpE3quUw3GuoFSKLpHIo= -github.com/stackitcloud/stackit-sdk-go/services/dns v0.15.1 h1:+7iIR5r2epGNkAcuERX4kEg1H8hz2yR9+/zLYqZR7Xk= -github.com/stackitcloud/stackit-sdk-go/services/dns v0.15.1/go.mod h1:xhAdw016dY/hVsLerlExSMocqCc872+S0y1CdV3jAjU= -github.com/stackitcloud/stackit-sdk-go/services/git v0.5.1 h1:xs8CMY7t8nULQvZr5+XZRs8yWw8YMVw+HfjcuMhieR4= -github.com/stackitcloud/stackit-sdk-go/services/git v0.5.1/go.mod h1:agI7SONeLR/IZL3TOgn1tDzfS63O2rWKQE8+huRjEzU= +github.com/stackitcloud/stackit-sdk-go/services/dns v0.15.0 h1:GQAA9gqhKN0ZRc1vRYURHeVjSghh+iF+5DK0HdeuakI= +github.com/stackitcloud/stackit-sdk-go/services/dns v0.15.0/go.mod h1:PMHoavoIaRZpkI9BA0nsnRjGoHASVSBon45XB3QyhMA= +github.com/stackitcloud/stackit-sdk-go/services/git v0.5.0 h1:s0A2EPBrnBxfKStKA/B1izbyYHw/0m2RdqN3Inkv9hI= +github.com/stackitcloud/stackit-sdk-go/services/git v0.5.0/go.mod h1:XhXHJpOVC9Rpwyf1G+EpMbprBafH9aZb8vWBdR+z0WM= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.24.0 h1:aLlZmcsDHqqc7KPsevvs+W6EPZFT51u/dx5TcVQsE6g= github.com/stackitcloud/stackit-sdk-go/services/iaas v0.24.0/go.mod h1:TaMx7kukGpRm0BkNCmS7u2x12q1pgfbD55DAnLIjOIQ= github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.4.0 h1:Ef4SyTBjIkfwaws4mssa6AoK+OokHFtr7ZIflUpoXVE= @@ -580,14 +580,14 @@ github.com/stackitcloud/stackit-sdk-go/services/logme v0.24.1 h1:hfnILDJGBwwqUIs github.com/stackitcloud/stackit-sdk-go/services/logme v0.24.1/go.mod h1:XjDMHhAQogFXsVR+o138CPYG1FOe0/Nl2Vm+fAgzx2A= github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.24.1 h1:7nN7ZCuWSbJMy5KqoOqSbp5JKIOvyuDqVRtxVvT1iyE= github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.24.1/go.mod h1:Pb8IEV5/jP8k75dVcN5cn3kP7PHTy/4KXXKpG76oj4U= -github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.2.1 h1:TWz7qJ4Mg5pquDXODSZ1dzhS95ZYn3w1aKjuRU2VqCg= -github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.2.1/go.mod h1:U1Zf/S9IuDvRJq1tRKFT/bsJd4qxYzwtukqX3TL++Mw= +github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.2.0 h1:U/IhjLOz0vG6zuxTqGhBd8f609s6JB+X9PaL6x/VM58= +github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v1.2.0/go.mod h1:+JSnz5/AvGN5ek/iH008frRc/NgjSr1EVOTIbyLwAuQ= github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.2.0 h1:+dKIPVz9ydKbX3x6+1NvYk++OA378w74p+N6SjDmzBQ= github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.2.0/go.mod h1:iCOYS9yICXQPyMAIdUGMqJDLY8HXKiVAdiMzO/mPvtA= github.com/stackitcloud/stackit-sdk-go/services/observability v0.7.1 h1:6OObzh2zk7wg75zYstcj0kjOjaxWc4joqA6qdeo8DP4= github.com/stackitcloud/stackit-sdk-go/services/observability v0.7.1/go.mod h1:+eNo7SEeVRuW7hgujSabSketScSUKGuC88UznPS+UTE= -github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.23.1 h1:E6vCqPn1NiPHnbnvqLNQNz6a/cmeyRb5iA9cDUPtP58= -github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.23.1/go.mod h1:ifKKKCWL1U435fXGQ375SPX+burtfg1I7EGZ58COzRA= +github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.23.0 h1:/OaZCyrD8LFa4W6a2Vu2QSVMJwLLBr8ZdBKzX00MV1Q= +github.com/stackitcloud/stackit-sdk-go/services/opensearch v0.23.0/go.mod h1:c30J6f/fXtbzcHkH3ZcabZUek3wfy5CRnEkcW5e5yXg= github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.1.0 h1:r29a9GoBLVw2VZSzdPftlIsE5t7shdxobwoT6NVUIjU= github.com/stackitcloud/stackit-sdk-go/services/postgresflex v1.1.0/go.mod h1:4g/L5PHfz1xk3udEhvPy2nXiH4UgRO5Cj6iwUa7k5VQ= github.com/stackitcloud/stackit-sdk-go/services/rabbitmq v0.24.1 h1:2rDFwJtZOFYFUiJqJ9uIwM+mu+BbtuVaUHARRJtrZPU= 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..447a3ea16 --- /dev/null +++ b/internal/cmd/volume/backup/create/create.go @@ -0,0 +1,226 @@ +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" + iaasutils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "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" { + name, err := iaasutils.GetVolumeName(ctx, apiClient, model.ProjectId, model.SourceID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get volume name: %v", err) + } else if name != "" { + sourceLabel = name + } + } else if model.SourceType == "snapshot" { + name, err := iaasutils.GetSnapshotName(ctx, apiClient, model.ProjectId, model.SourceID) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get snapshot name: %v", err) + } else if name != "" { + sourceLabel = 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) + + payload := iaas.CreateBackupPayload{ + Name: model.Name, + Labels: utils.ConvertStringMapToInterfaceMap(utils.Ptr(model.Labels)), + Source: &iaas.BackupSource{ + Id: &model.SourceID, + Type: &model.SourceType, + }, + } + + return req.CreateBackupPayload(payload) +} + +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..b40d93ea7 --- /dev/null +++ b/internal/cmd/volume/backup/delete/delete.go @@ -0,0 +1,125 @@ +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" + + iaasutils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/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 + } + + backupLabel, err := iaasutils.GetBackupName(ctx, apiClient, model.ProjectId, model.BackupId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get backup name: %v", err) + } + + 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.Outputf("Triggered deletion of backup %q\n", backupLabel) + } else { + params.Printer.Outputf("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..3249f0560 --- /dev/null +++ b/internal/cmd/volume/backup/restore/restore.go @@ -0,0 +1,138 @@ +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" + + iaasutils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/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 + } + + backupLabel, err := iaasutils.GetBackupName(ctx, apiClient, model.ProjectId, model.BackupId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get backup details: %v", err) + } + + // Get source details for labels + var sourceLabel string + backup, err := apiClient.GetBackup(ctx, model.ProjectId, model.BackupId).Execute() + if err == nil && backup != nil && backup.VolumeId != nil { + sourceLabel = *backup.VolumeId + name, err := iaasutils.GetVolumeName(ctx, apiClient, model.ProjectId, *backup.VolumeId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get volume details: %v", err) + } else if name != "" { + sourceLabel = 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.Outputf("Triggered restore of %q with %q in %q\n", sourceLabel, backupLabel, model.ProjectId) + } else { + params.Printer.Outputf("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..f23bb5108 --- /dev/null +++ b/internal/cmd/volume/backup/update/update.go @@ -0,0 +1,168 @@ +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" + iaasutils "github.com/stackitcloud/stackit-cli/internal/pkg/services/iaas/utils" + "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 + } + + backupLabel, err := iaasutils.GetBackupName(ctx, apiClient, model.ProjectId, model.BackupId) + if err != nil { + params.Printer.Debug(print.ErrorLevel, "get backup name: %v", err) + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update backup %q? (This cannot be undone)", model.BackupId) + 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("update backup: %w", err) + } + + 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) + + payload := iaas.UpdateBackupPayload{ + Name: model.Name, + Labels: utils.ConvertStringMapToInterfaceMap(utils.Ptr(model.Labels)), + } + + req = req.UpdateBackupPayload(payload) + 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/create/create.go b/internal/cmd/volume/create/create.go index 258e0752a..5519bd5d8 100644 --- a/internal/cmd/volume/create/create.go +++ b/internal/cmd/volume/create/create.go @@ -174,20 +174,11 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APICli Type: model.SourceType, } - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } - payload := iaas.CreateVolumePayload{ AvailabilityZone: model.AvailabilityZone, Name: model.Name, Description: model.Description, - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), PerformanceClass: model.PerformanceClass, Size: model.Size, } diff --git a/internal/cmd/volume/update/update.go b/internal/cmd/volume/update/update.go index 80e137987..263f333af 100644 --- a/internal/cmd/volume/update/update.go +++ b/internal/cmd/volume/update/update.go @@ -135,19 +135,10 @@ func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inpu func buildRequest(ctx context.Context, model *inputModel, apiClient *iaas.APIClient) iaas.ApiUpdateVolumeRequest { req := apiClient.UpdateVolume(ctx, model.ProjectId, model.VolumeId) - var labelsMap *map[string]interface{} - if model.Labels != nil && len(*model.Labels) > 0 { - // convert map[string]string to map[string]interface{} - labelsMap = utils.Ptr(map[string]interface{}{}) - for k, v := range *model.Labels { - (*labelsMap)[k] = v - } - } - payload := iaas.UpdateVolumePayload{ Name: model.Name, Description: model.Description, - Labels: labelsMap, + Labels: utils.ConvertStringMapToInterfaceMap(model.Labels), } return req.UpdateVolumePayload(payload) 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)) } diff --git a/internal/pkg/services/iaas/utils/utils.go b/internal/pkg/services/iaas/utils/utils.go index f2616a01a..f3d3571d7 100644 --- a/internal/pkg/services/iaas/utils/utils.go +++ b/internal/pkg/services/iaas/utils/utils.go @@ -26,6 +26,8 @@ type IaaSClient interface { GetNetworkAreaRangeExecute(ctx context.Context, organizationId, areaId, networkRangeId string) (*iaas.NetworkRange, error) GetImageExecute(ctx context.Context, projectId string, imageId string) (*iaas.Image, error) GetAffinityGroupExecute(ctx context.Context, projectId string, affinityGroupId string) (*iaas.AffinityGroup, error) + GetSnapshotExecute(ctx context.Context, projectId, snapshotId string) (*iaas.Snapshot, error) + GetBackupExecute(ctx context.Context, projectId, backupId string) (*iaas.Backup, error) } func GetSecurityGroupRuleName(ctx context.Context, apiClient IaaSClient, projectId, securityGroupRuleId, securityGroupId string) (string, error) { @@ -170,3 +172,22 @@ func GetAffinityGroupName(ctx context.Context, apiClient IaaSClient, projectId, } return *resp.Name, nil } + +func GetSnapshotName(ctx context.Context, apiClient IaaSClient, projectId, snapshotId string) (string, error) { + resp, err := apiClient.GetSnapshotExecute(ctx, projectId, snapshotId) + if err != nil { + return "", fmt.Errorf("get snapshot: %w", err) + } + return *resp.Name, nil +} + +func GetBackupName(ctx context.Context, apiClient IaaSClient, projectId, backupId string) (string, error) { + resp, err := apiClient.GetBackupExecute(ctx, projectId, backupId) + if err != nil { + return backupId, fmt.Errorf("get backup: %w", err) + } + if resp != nil && resp.Name != nil { + return *resp.Name, nil + } + return backupId, nil +} diff --git a/internal/pkg/services/iaas/utils/utils_test.go b/internal/pkg/services/iaas/utils/utils_test.go index f4375251d..e2ccdc469 100644 --- a/internal/pkg/services/iaas/utils/utils_test.go +++ b/internal/pkg/services/iaas/utils/utils_test.go @@ -33,6 +33,10 @@ type IaaSClientMocked struct { GetImageResp *iaas.Image GetAffinityGroupsFails bool GetAffinityGroupResp *iaas.AffinityGroup + GetBackupFails bool + GetBackupResp *iaas.Backup + GetSnapshotFails bool + GetSnapshotResp *iaas.Snapshot } func (m *IaaSClientMocked) GetAffinityGroupExecute(_ context.Context, _, _ string) (*iaas.AffinityGroup, error) { @@ -112,6 +116,19 @@ func (m *IaaSClientMocked) GetImageExecute(_ context.Context, _, _ string) (*iaa return m.GetImageResp, nil } +func (m *IaaSClientMocked) GetBackupExecute(_ context.Context, _, _ string) (*iaas.Backup, error) { + if m.GetBackupFails { + return nil, fmt.Errorf("could not get backup") + } + return m.GetBackupResp, nil +} + +func (m *IaaSClientMocked) GetSnapshotExecute(_ context.Context, _, _ string) (*iaas.Snapshot, error) { + if m.GetSnapshotFails { + return nil, fmt.Errorf("could not get snapshot") + } + return m.GetSnapshotResp, nil +} func TestGetSecurityGroupRuleName(t *testing.T) { type args struct { getInstanceFails bool