From 913495e51d59643ca7da6517f0be12b706c17b8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 12 Mar 2024 15:03:12 +0100 Subject: [PATCH 01/15] fix typo in error message --- internal/pkg/errors/errors.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go index a9753f5e9..f2560266b 100644 --- a/internal/pkg/errors/errors.go +++ b/internal/pkg/errors/errors.go @@ -89,7 +89,7 @@ For more details on the available storages for the configured flavor (%[3]s), ru SINGLE_ARG_EXPECTED = `expected 1 argument %q, %d were provided` - SUBCOMMAND_UNKNOWN = `unkwown subcommand %q` + SUBCOMMAND_UNKNOWN = `unknown subcommand %q` SUBCOMMAND_MISSING = `missing subcommand` From f6ef15a3693799205cb946b5e8e2fc7d37c3c8e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 12 Mar 2024 15:07:02 +0100 Subject: [PATCH 02/15] add bytesize package --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index dfcd4646f..468b4ade4 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/stackitcloud/stackit-cli go 1.21 require ( + github.com/depp/bytesize v1.1.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index a999fcdba..187ab6bb8 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/depp/bytesize v1.1.0 h1:HUJEFG8nW/vrfflMw0TB/5ZrwSuGAw3xrgyzKqzVLf4= +github.com/depp/bytesize v1.1.0/go.mod h1:W5nYZIYKjq8tqfzkVVwMblQwIRI4KcZGJz4DbNT9wwY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= From 4050ddcfa245f6e45e6d5aa1027a077315fccfa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 12 Mar 2024 15:08:04 +0100 Subject: [PATCH 03/15] onboard clone instance command --- .../cmd/postgresflex/instance/clone/clone.go | 154 +++++++++ .../postgresflex/instance/clone/clone_test.go | 325 ++++++++++++++++++ .../cmd/postgresflex/instance/instance.go | 2 + 3 files changed, 481 insertions(+) create mode 100644 internal/cmd/postgresflex/instance/clone/clone.go create mode 100644 internal/cmd/postgresflex/instance/clone/clone_test.go diff --git a/internal/cmd/postgresflex/instance/clone/clone.go b/internal/cmd/postgresflex/instance/clone/clone.go new file mode 100644 index 000000000..ae8ac6259 --- /dev/null +++ b/internal/cmd/postgresflex/instance/clone/clone.go @@ -0,0 +1,154 @@ +package clone + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + cliErr "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/services/postgresflex/client" + postgresflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/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/postgresflex" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex/wait" +) + +const ( + instanceIdArg = "INSTANCE_ID" + + storageClassFlag = "storage-class" + storageSizeFlag = "storage-size" + recoveryTimestampFlag = "recovery-timestamp" + recoveryDateFormat = "2023-04-17T09:28:00+00:00" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + StorageClass *string + StorageSize *int64 + RecoveryDate *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("clone %s", instanceIdArg), + Short: "Clones a PostgreSQL Flex instance", + Long: "Clones a PostgreSQL Flex instance from a selected point in time.", + Example: examples.Build( + examples.NewExample( + `Clone a PostgreSQL Flex instance with ID "xxx" . The recovery timestamp should be specified in UTC time following the format provided in the example.`, + `$ stackit postgresflex instance clone xxx --recovery-timestamp 2023-04-17T09:28:00+00:00`), + examples.NewExample( + `Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp and specify storage class. If not specified, storage class from the existing instance will be used.`, + `$ stackit postgresflex instance clone xxx --recovery-timestamp 2023-04-17T09:28:00+00:00 --storage-class premium-perf6-stackit`), + examples.NewExample( + `Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp and specify storage size. If not specified, storage size from the existing instance will be used.`, + `$ stackit postgresflex instance clone xxx --recovery-timestamp 2023-04-17T09:28:00+00:00 --storage-size 10`), + ), + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to clone instance %q?", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("clone PostgreSQL Flex instance: %w", err) + } + instanceId := *resp.InstanceId + + // Wait for async operation, if async mode not enabled + if !model.Async { + s := spinner.New(cmd) + s.Start("Cloning instance") + _, err = wait.CreateInstanceWaitHandler(ctx, apiClient, model.ProjectId, instanceId).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("wait for PostgreSQL Flex instance cloning: %w", err) + } + s.Stop() + } + + operationState := "Cloned" + if model.Async { + operationState = "Triggered cloning of" + } + + cmd.Printf("%s instance from instance %q. New Instance ID: %s\n", operationState, instanceLabel, instanceId) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(recoveryTimestampFlag, "", "Recovery timestamp for the instance, in a date-time with the layout format, e.g. 2024-03-12T09:28:00+00:00") + cmd.Flags().String(storageClassFlag, "", "Storage class") + cmd.Flags().Int64(storageSizeFlag, 0, "Storage size (in GB)") + + err := flags.MarkFlagsRequired(cmd, recoveryTimestampFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + StorageClass: flags.FlagToStringPointer(cmd, storageClassFlag), + StorageSize: flags.FlagToInt64Pointer(cmd, storageSizeFlag), + RecoveryDate: flags.FlagToStringPointer(cmd, recoveryTimestampFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) (postgresflex.ApiCloneInstanceRequest, error) { + req := apiClient.CloneInstance(ctx, model.ProjectId, model.InstanceId) + req = req.CloneInstancePayload(postgresflex.CloneInstancePayload{ + Class: model.StorageClass, + Size: model.StorageSize, + Timestamp: model.RecoveryDate, + }) + return req, nil +} diff --git a/internal/cmd/postgresflex/instance/clone/clone_test.go b/internal/cmd/postgresflex/instance/clone/clone_test.go new file mode 100644 index 000000000..885307edb --- /dev/null +++ b/internal/cmd/postgresflex/instance/clone/clone_test.go @@ -0,0 +1,325 @@ +package clone + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "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/postgresflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &postgresflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testRecoveryTimestamp = "2024-03-08T09:28:00+00:00" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureRequiredFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + recoveryTimestampFlag: testRecoveryTimestamp, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureStandardFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + recoveryTimestampFlag: testRecoveryTimestamp, + storageClassFlag: "class", + storageSizeFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureRequiredInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + RecoveryDate: utils.Ptr(testRecoveryTimestamp), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + StorageClass: utils.Ptr("premium-perf4-stackit"), + StorageSize: utils.Ptr(int64(10)), + RecoveryDate: utils.Ptr(testRecoveryTimestamp), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *postgresflex.ApiCloneInstanceRequest)) postgresflex.ApiCloneInstanceRequest { + request := testClient.CloneInstance(testCtx, testProjectId, testInstanceId) + request = request.CloneInstancePayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func fixturePayload(mods ...func(payload *postgresflex.CloneInstancePayload)) postgresflex.CloneInstancePayload { + payload := postgresflex.CloneInstancePayload{ + Class: utils.Ptr("premium-perf4-stackit"), + Size: utils.Ptr(int64(10)), + Timestamp: utils.Ptr(testRecoveryTimestamp), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(), + isValid: true, + expectedModel: fixtureRequiredInputModel(), + }, + { + description: "with defaults", + argValues: fixtureArgValues(), + flagValues: fixtureStandardFlagValues(func(flagValues map[string]string) { + delete(flagValues, storageClassFlag) + delete(flagValues, storageSizeFlag) + }), + isValid: true, + expectedModel: fixtureRequiredInputModel(), + }, + { + description: "no values", + argValues: []string{}, + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "no arg values", + argValues: []string{}, + flagValues: fixtureRequiredFlagValues(), + isValid: false, + }, + { + description: "no flag values", + argValues: fixtureArgValues(), + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "all values with storage class", + argValues: fixtureArgValues(), + flagValues: fixtureStandardFlagValues(func(flagValues map[string]string) { + delete(flagValues, storageSizeFlag) + flagValues[storageClassFlag] = "premium-perf4-stackit" + }), + isValid: true, + expectedModel: fixtureStandardInputModel(func(model *inputModel) { + model.StorageSize = nil + model.StorageClass = utils.Ptr("premium-perf4-stackit") + }), + }, + { + description: "all values with storage size", + argValues: fixtureArgValues(), + flagValues: fixtureStandardFlagValues(func(flagValues map[string]string) { + delete(flagValues, storageClassFlag) + flagValues[storageSizeFlag] = "2" + }), + isValid: true, + expectedModel: fixtureStandardInputModel(func(model *inputModel) { + model.StorageClass = nil + model.StorageSize = utils.Ptr(int64(2)) + }), + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureRequiredFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureRequiredFlagValues(), + isValid: false, + }, + { + description: "recovery timestamp is missing", + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + delete(flagValues, recoveryTimestampFlag) + }), + isValid: false, + }, + { + description: "recovery timestamp is empty", + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + flagValues[recoveryTimestampFlag] = "" + }), + isValid: false, + }, + { + description: "recovery timestamp is invalid", + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + flagValues[recoveryTimestampFlag] = "test" + }), + isValid: false, + }, + { + description: "recovery timestamp is invalid 2", + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + flagValues[recoveryTimestampFlag] = "11:00 12/12/2024" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + 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) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + 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 postgresflex.ApiCloneInstanceRequest + }{ + { + description: "base", + model: fixtureStandardInputModel(), + 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/postgresflex/instance/instance.go b/internal/cmd/postgresflex/instance/instance.go index 378bf254b..0d16db1f8 100644 --- a/internal/cmd/postgresflex/instance/instance.go +++ b/internal/cmd/postgresflex/instance/instance.go @@ -1,6 +1,7 @@ package instance import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/instance/clone" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/instance/create" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/instance/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/instance/describe" @@ -30,4 +31,5 @@ func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(describe.NewCmd()) cmd.AddCommand(update.NewCmd()) cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(clone.NewCmd()) } From 31ca3952906838f50d344aa8f782183a4122ebc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 12 Mar 2024 15:20:40 +0100 Subject: [PATCH 04/15] onboard backups list command --- internal/cmd/postgresflex/backups/backups.go | 29 +++ .../cmd/postgresflex/backups/list/list.go | 153 ++++++++++++ .../postgresflex/backups/list/list_test.go | 226 ++++++++++++++++++ internal/cmd/postgresflex/postgresflex.go | 2 + 4 files changed, 410 insertions(+) create mode 100644 internal/cmd/postgresflex/backups/backups.go create mode 100644 internal/cmd/postgresflex/backups/list/list.go create mode 100644 internal/cmd/postgresflex/backups/list/list_test.go diff --git a/internal/cmd/postgresflex/backups/backups.go b/internal/cmd/postgresflex/backups/backups.go new file mode 100644 index 000000000..1c7a1279d --- /dev/null +++ b/internal/cmd/postgresflex/backups/backups.go @@ -0,0 +1,29 @@ +package backups + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/backups/describe" + "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/backups/list" + updateschedule "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/backups/update-schedule" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "backups", + Short: "Provides functionality for PostgreSQL Flex instance backups", + Long: "Provides functionality for PostgreSQL Flex instance backups.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd) + return cmd +} + +func addSubcommands(cmd *cobra.Command) { + cmd.AddCommand(list.NewCmd()) + cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(updateschedule.NewCmd()) +} diff --git a/internal/cmd/postgresflex/backups/list/list.go b/internal/cmd/postgresflex/backups/list/list.go new file mode 100644 index 000000000..782bf1ce5 --- /dev/null +++ b/internal/cmd/postgresflex/backups/list/list.go @@ -0,0 +1,153 @@ +package list + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/depp/bytesize" + postgresflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "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/services/postgresflex/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" +) + +const ( + instanceIdArg = "INSTANCE_ID" + + limitFlag = "limit" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + Limit *int64 +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "Lists all backups which are available for a specific PostgreSQL Flex instance", + Long: "Lists all backups which are available for a specific PostgreSQL Flex instance.", + Example: examples.Build( + examples.NewExample( + `List all backups of instance with ID "xxx"`, + "$ stackit postgresflex backups list xxx"), + examples.NewExample( + `List all backups of instance with ID "xxx" in JSON format`, + "$ stackit postgresflex backups list xxx --output-format json"), + examples.NewExample( + `List up to 10 backups of instance with ID "xxx"`, + "$ stackit postgresflex backups list xxx --limit 10"), + ), + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get backups for PostgreSQL Flex instance %q: %w\n", instanceLabel, err) + } + if resp.Items == nil || len(*resp.Items) == 0 { + cmd.Printf("No backups found for instance %q\n", instanceLabel) + return nil + } + backups := *resp.Items + + // Truncate output + if model.Limit != nil && len(backups) > int(*model.Limit) { + backups = backups[:*model.Limit] + } + + return outputResult(cmd, model.OutputFormat, backups) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + limit := flags.FlagToInt64Pointer(cmd, limitFlag) + if limit != nil && *limit < 1 { + return nil, &errors.FlagValidationError{ + Flag: limitFlag, + Details: "must be greater than 0", + } + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + Limit: flags.FlagToInt64Pointer(cmd, limitFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiListBackupsRequest { + req := apiClient.ListBackups(ctx, model.ProjectId, model.InstanceId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, backups []postgresflex.Backup) error { + switch outputFormat { + case globalflags.JSONOutputFormat: + details, err := json.MarshalIndent(backups, "", " ") + if err != nil { + return fmt.Errorf("marshal PostgreSQL Flex instance list: %w", err) + } + cmd.Println(string(details)) + + return nil + default: + table := tables.NewTable() + table.SetHeader("ID", "NAME", "START TIME", "END TIME", "BACKUP SIZE") + for i := range backups { + backup := backups[i] + table.AddRow(*backup.Id, *backup.Name, *backup.StartTime, *backup.EndTime, bytesize.Format(uint64(*backup.Size))) + } + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + } +} diff --git a/internal/cmd/postgresflex/backups/list/list_test.go b/internal/cmd/postgresflex/backups/list/list_test.go new file mode 100644 index 000000000..2887389f7 --- /dev/null +++ b/internal/cmd/postgresflex/backups/list/list_test.go @@ -0,0 +1,226 @@ +package list + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "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/postgresflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &postgresflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + limitFlag: "10", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + Limit: utils.Ptr(int64(10)), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *postgresflex.ApiListBackupsRequest)) postgresflex.ApiListBackupsRequest { + request := testClient.ListBackups(testCtx, testProjectId, testInstanceId) + 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: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + 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 := NewCmd() + 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) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(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 postgresflex.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) + } + }) + } +} diff --git a/internal/cmd/postgresflex/postgresflex.go b/internal/cmd/postgresflex/postgresflex.go index 3f74e35c8..10206a3f0 100644 --- a/internal/cmd/postgresflex/postgresflex.go +++ b/internal/cmd/postgresflex/postgresflex.go @@ -1,6 +1,7 @@ package postgresflex import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/backups" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/instance" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/options" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/user" @@ -27,4 +28,5 @@ func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(instance.NewCmd()) cmd.AddCommand(user.NewCmd()) cmd.AddCommand(options.NewCmd()) + cmd.AddCommand(backups.NewCmd()) } From 70fda9e29e7a07e3a22741b2527a7e5d80680762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 12 Mar 2024 16:27:20 +0100 Subject: [PATCH 05/15] onboard backups describe command --- .../postgresflex/backups/describe/describe.go | 140 +++++++++++ .../backups/describe/describe_test.go | 236 ++++++++++++++++++ 2 files changed, 376 insertions(+) create mode 100644 internal/cmd/postgresflex/backups/describe/describe.go create mode 100644 internal/cmd/postgresflex/backups/describe/describe_test.go diff --git a/internal/cmd/postgresflex/backups/describe/describe.go b/internal/cmd/postgresflex/backups/describe/describe.go new file mode 100644 index 000000000..ea25b0a28 --- /dev/null +++ b/internal/cmd/postgresflex/backups/describe/describe.go @@ -0,0 +1,140 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/depp/bytesize" + "github.com/spf13/cobra" + "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/services/postgresflex/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" +) + +const ( + backupIdArg = "BACKUP_ID" + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + BackupId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", backupIdArg), + Short: "Shows details of a backup for a specific PostgreSQL Flex instance", + Long: "Shows details of a backup for a specific PostgreSQL Flex instance.", + Example: examples.Build( + examples.NewExample( + `Get details of a backup with ID "xxx" for a PostgreSQL Flex instance with ID "yyy"`, + "$ stackit postgresflex backups describe xxx --instance-id yyy"), + examples.NewExample( + `Get details of a backup with ID "xxx" for a PostgreSQL Flex instance with ID "yyy" in a table format`, + "$ stackit postgresflex backups describe xxx --instance-id yyy --output-format pretty"), + ), + Args: args.SingleArg(backupIdArg, nil), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + + if err != nil { + return fmt.Errorf("describe backup for PostgreSQL Flex instance : %w", err) + } + + return outputResult(cmd, model.OutputFormat, *resp.Item) + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + backupId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + BackupId: backupId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiGetBackupRequest { + req := apiClient.GetBackup(ctx, model.ProjectId, model.InstanceId, model.BackupId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, backup postgresflex.Backup) error { + backupStartTime, err := time.Parse(time.RFC3339, *backup.StartTime) + if err != nil { + return fmt.Errorf("parse backup start time : %w", err) + } + backupExpireDate := backupStartTime.AddDate(0, 0, 30).Format(time.DateOnly) + + switch outputFormat { + case globalflags.PrettyOutputFormat: + table := tables.NewTable() + table.AddRow("ID", *backup.Id) + table.AddSeparator() + table.AddRow("NAME", *backup.Name) + table.AddSeparator() + table.AddRow("START TIME", *backup.StartTime) + table.AddSeparator() + table.AddRow("END TIME", *backup.EndTime) + table.AddSeparator() + table.AddRow("EXPIRES AT", backupExpireDate) + table.AddSeparator() + table.AddRow("BACKUP SIZE", bytesize.Format(uint64(*backup.Size))) + + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(backup, "", " ") + if err != nil { + return fmt.Errorf("marshal backup for PostgreSQL Flex instance : %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/postgresflex/backups/describe/describe_test.go b/internal/cmd/postgresflex/backups/describe/describe_test.go new file mode 100644 index 000000000..f003f9b12 --- /dev/null +++ b/internal/cmd/postgresflex/backups/describe/describe_test.go @@ -0,0 +1,236 @@ +package describe + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &postgresflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testBackupId = "backupID" + +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{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + BackupId: testBackupId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *postgresflex.ApiGetBackupRequest)) postgresflex.ApiGetBackupRequest { + request := testClient.GetBackup(testCtx, testProjectId, testInstanceId, 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, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "backup id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + 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) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(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 postgresflex.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) + } + }) + } +} From 392d8ef537051faf7c0a4fcf68dc009c05274f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 12 Mar 2024 16:29:03 +0100 Subject: [PATCH 06/15] onboard backups update-schedule command --- .../update-schedule/update_schedule.go | 116 ++++++++ .../update-schedule/update_schedule_test.go | 257 ++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 internal/cmd/postgresflex/backups/update-schedule/update_schedule.go create mode 100644 internal/cmd/postgresflex/backups/update-schedule/update_schedule_test.go diff --git a/internal/cmd/postgresflex/backups/update-schedule/update_schedule.go b/internal/cmd/postgresflex/backups/update-schedule/update_schedule.go new file mode 100644 index 000000000..40d289ffd --- /dev/null +++ b/internal/cmd/postgresflex/backups/update-schedule/update_schedule.go @@ -0,0 +1,116 @@ +package updateschedule + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + cliErr "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/services/postgresflex/client" + postgresflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/utils" + "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" +) + +const ( + instanceIdArg = "INSTANCE_ID" + + backupScheduleFlag = "backup-schedule" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + BackupSchedule *string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update-schedule %s", instanceIdArg), + Short: "Updates backup schedule for a specific PostgreSQL Flex instance", + Long: "Updates backup schedule for a specific PostgreSQL Flex instance.", + Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), + Example: examples.Build( + examples.NewExample( + `Update the backup schedule of a PostgreSQL Flex instance with ID "xxx"`, + "$ stackit postgresflex backups update-schedule xxx --backup-schedule '6 6 * * *'"), + ), + + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + model, err := parseInput(cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(cmd) + if err != nil { + return err + } + + instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update backup schedule of instance %q?", instanceLabel) + err = confirm.PromptForConfirmation(cmd, prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + err = req.Execute() + if err != nil { + return fmt.Errorf("update backup schedule of PostgreSQL Flex instance: %w", err) + } + + cmd.Printf("Updated backup schedule of instance %q\n", instanceLabel) + return nil + }, + } + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().String(backupScheduleFlag, "", "Backup schedule") + + err := flags.MarkFlagsRequired(cmd, backupScheduleFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + instanceId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &cliErr.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + BackupSchedule: flags.FlagToStringPointer(cmd, backupScheduleFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiUpdateBackupScheduleRequest { + req := apiClient.UpdateBackupSchedule(ctx, model.ProjectId, model.InstanceId) + req = req.UpdateBackupSchedulePayload(postgresflex.UpdateBackupSchedulePayload{ + BackupSchedule: model.BackupSchedule, + }) + return req +} diff --git a/internal/cmd/postgresflex/backups/update-schedule/update_schedule_test.go b/internal/cmd/postgresflex/backups/update-schedule/update_schedule_test.go new file mode 100644 index 000000000..c64a76a3f --- /dev/null +++ b/internal/cmd/postgresflex/backups/update-schedule/update_schedule_test.go @@ -0,0 +1,257 @@ +package updateschedule + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "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/postgresflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &postgresflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testBackupSchedule = "0 0 * * *" + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testInstanceId, + } + for _, mod := range mods { + mod(argValues) + } + return argValues +} + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + backupScheduleFlag: testBackupSchedule, + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + BackupSchedule: &testBackupSchedule, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixturePayload(mods ...func(payload *postgresflex.UpdateBackupSchedulePayload)) postgresflex.UpdateBackupSchedulePayload { + payload := postgresflex.UpdateBackupSchedulePayload{ + BackupSchedule: utils.Ptr(testBackupSchedule), + } + for _, mod := range mods { + mod(&payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *postgresflex.ApiUpdateBackupScheduleRequest)) postgresflex.ApiUpdateBackupScheduleRequest { + request := testClient.UpdateBackupSchedule(testCtx, testProjectId, testInstanceId) + request = request.UpdateBackupSchedulePayload(fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + argValues []string + flagValues map[string]string + aclValues []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: "with defaults", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "project id missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "backup schedule missing", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, backupScheduleFlag) + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd() + 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) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(cmd, tt.argValues) + 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 postgresflex.ApiUpdateBackupScheduleRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "required fields only", + model: &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + }, + expectedRequest: testClient.UpdateBackupSchedule(testCtx, testProjectId, testInstanceId). + UpdateBackupSchedulePayload(postgresflex.UpdateBackupSchedulePayload{}), + }, + } + + 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) + } + }) + } +} From de3e2c46bcf4810590a75222b3ead1eff2372b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 12 Mar 2024 16:36:22 +0100 Subject: [PATCH 07/15] add docs --- docs/stackit_postgresflex.md | 1 + docs/stackit_postgresflex_backups.md | 34 ++++++++++++++ docs/stackit_postgresflex_backups_describe.md | 42 +++++++++++++++++ docs/stackit_postgresflex_backups_list.md | 45 ++++++++++++++++++ ...it_postgresflex_backups_update-schedule.md | 39 +++++++++++++++ docs/stackit_postgresflex_instance.md | 1 + docs/stackit_postgresflex_instance_clone.md | 47 +++++++++++++++++++ 7 files changed, 209 insertions(+) create mode 100644 docs/stackit_postgresflex_backups.md create mode 100644 docs/stackit_postgresflex_backups_describe.md create mode 100644 docs/stackit_postgresflex_backups_list.md create mode 100644 docs/stackit_postgresflex_backups_update-schedule.md create mode 100644 docs/stackit_postgresflex_instance_clone.md diff --git a/docs/stackit_postgresflex.md b/docs/stackit_postgresflex.md index f97789144..73d642424 100644 --- a/docs/stackit_postgresflex.md +++ b/docs/stackit_postgresflex.md @@ -28,6 +28,7 @@ stackit postgresflex [flags] ### SEE ALSO * [stackit](./stackit.md) - Manage STACKIT resources using the command line +* [stackit postgresflex backups](./stackit_postgresflex_backups.md) - Provides functionality for PostgreSQL Flex instance backups * [stackit postgresflex instance](./stackit_postgresflex_instance.md) - Provides functionality for PostgreSQL Flex instances * [stackit postgresflex options](./stackit_postgresflex_options.md) - Lists PostgreSQL Flex options * [stackit postgresflex user](./stackit_postgresflex_user.md) - Provides functionality for PostgreSQL Flex users diff --git a/docs/stackit_postgresflex_backups.md b/docs/stackit_postgresflex_backups.md new file mode 100644 index 000000000..de0434609 --- /dev/null +++ b/docs/stackit_postgresflex_backups.md @@ -0,0 +1,34 @@ +## stackit postgresflex backups + +Provides functionality for PostgreSQL Flex instance backups + +### Synopsis + +Provides functionality for PostgreSQL Flex instance backups. + +``` +stackit postgresflex backups [flags] +``` + +### Options + +``` + -h, --help Help for "stackit postgresflex backups" +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit postgresflex](./stackit_postgresflex.md) - Provides functionality for PostgreSQL Flex +* [stackit postgresflex backups describe](./stackit_postgresflex_backups_describe.md) - Shows details of a backup for a specific PostgreSQL Flex instance +* [stackit postgresflex backups list](./stackit_postgresflex_backups_list.md) - Lists all backups which are available for a specific PostgreSQL Flex instance +* [stackit postgresflex backups update-schedule](./stackit_postgresflex_backups_update-schedule.md) - Updates backup schedule for a specific PostgreSQL Flex instance + diff --git a/docs/stackit_postgresflex_backups_describe.md b/docs/stackit_postgresflex_backups_describe.md new file mode 100644 index 000000000..c8670f050 --- /dev/null +++ b/docs/stackit_postgresflex_backups_describe.md @@ -0,0 +1,42 @@ +## stackit postgresflex backups describe + +Shows details of a backup for a specific PostgreSQL Flex instance + +### Synopsis + +Shows details of a backup for a specific PostgreSQL Flex instance. + +``` +stackit postgresflex backups describe BACKUP_ID [flags] +``` + +### Examples + +``` + Get details of a backup with ID "xxx" for a PostgreSQL Flex instance with ID "yyy" + $ stackit postgresflex backups describe xxx --instance-id yyy + + Get details of a backup with ID "xxx" for a PostgreSQL Flex instance with ID "yyy" in a table format + $ stackit postgresflex backups describe xxx --instance-id yyy --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit postgresflex backups describe" + --instance-id string Instance ID +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit postgresflex backups](./stackit_postgresflex_backups.md) - Provides functionality for PostgreSQL Flex instance backups + diff --git a/docs/stackit_postgresflex_backups_list.md b/docs/stackit_postgresflex_backups_list.md new file mode 100644 index 000000000..fca2fa64f --- /dev/null +++ b/docs/stackit_postgresflex_backups_list.md @@ -0,0 +1,45 @@ +## stackit postgresflex backups list + +Lists all backups which are available for a specific PostgreSQL Flex instance + +### Synopsis + +Lists all backups which are available for a specific PostgreSQL Flex instance. + +``` +stackit postgresflex backups list [flags] +``` + +### Examples + +``` + List all backups of instance with ID "xxx" + $ stackit postgresflex backups list xxx + + List all backups of instance with ID "xxx" in JSON format + $ stackit postgresflex backups list xxx --output-format json + + List up to 10 backups of instance with ID "xxx" + $ stackit postgresflex backups list xxx --limit 10 +``` + +### Options + +``` + -h, --help Help for "stackit postgresflex backups list" + --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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit postgresflex backups](./stackit_postgresflex_backups.md) - Provides functionality for PostgreSQL Flex instance backups + diff --git a/docs/stackit_postgresflex_backups_update-schedule.md b/docs/stackit_postgresflex_backups_update-schedule.md new file mode 100644 index 000000000..0d6890028 --- /dev/null +++ b/docs/stackit_postgresflex_backups_update-schedule.md @@ -0,0 +1,39 @@ +## stackit postgresflex backups update-schedule + +Updates backup schedule for a specific PostgreSQL Flex instance + +### Synopsis + +Updates backup schedule for a specific PostgreSQL Flex instance. + +``` +stackit postgresflex backups update-schedule INSTANCE_ID [flags] +``` + +### Examples + +``` + Update the backup schedule of a PostgreSQL Flex instance with ID "xxx" + $ stackit postgresflex backups update-schedule xxx --backup-schedule '6 6 * * *' +``` + +### Options + +``` + --backup-schedule string Backup schedule + -h, --help Help for "stackit postgresflex backups update-schedule" +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit postgresflex backups](./stackit_postgresflex_backups.md) - Provides functionality for PostgreSQL Flex instance backups + diff --git a/docs/stackit_postgresflex_instance.md b/docs/stackit_postgresflex_instance.md index b5471c5e9..0d6fa26ea 100644 --- a/docs/stackit_postgresflex_instance.md +++ b/docs/stackit_postgresflex_instance.md @@ -28,6 +28,7 @@ stackit postgresflex instance [flags] ### SEE ALSO * [stackit postgresflex](./stackit_postgresflex.md) - Provides functionality for PostgreSQL Flex +* [stackit postgresflex instance clone](./stackit_postgresflex_instance_clone.md) - Clones a PostgreSQL Flex instance * [stackit postgresflex instance create](./stackit_postgresflex_instance_create.md) - Creates a PostgreSQL Flex instance * [stackit postgresflex instance delete](./stackit_postgresflex_instance_delete.md) - Deletes a PostgreSQL Flex instance * [stackit postgresflex instance describe](./stackit_postgresflex_instance_describe.md) - Shows details of a PostgreSQL Flex instance diff --git a/docs/stackit_postgresflex_instance_clone.md b/docs/stackit_postgresflex_instance_clone.md new file mode 100644 index 000000000..215ef48ce --- /dev/null +++ b/docs/stackit_postgresflex_instance_clone.md @@ -0,0 +1,47 @@ +## stackit postgresflex instance clone + +Clones a PostgreSQL Flex instance + +### Synopsis + +Clones a PostgreSQL Flex instance from a selected point in time. + +``` +stackit postgresflex instance clone INSTANCE_ID [flags] +``` + +### Examples + +``` + Clone a PostgreSQL Flex instance with ID "xxx" . The recovery timestamp should be specified in UTC time following the format provided in the example. + $ stackit postgresflex instance clone xxx --recovery-timestamp 2023-04-17T09:28:00+00:00 + + Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp and specify storage class. If not specified, storage class from the existing instance will be used. + $ stackit postgresflex instance clone xxx --recovery-timestamp 2023-04-17T09:28:00+00:00 --storage-class premium-perf6-stackit + + Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp and specify storage size. If not specified, storage size from the existing instance will be used. + $ stackit postgresflex instance clone xxx --recovery-timestamp 2023-04-17T09:28:00+00:00 --storage-size 10 +``` + +### Options + +``` + -h, --help Help for "stackit postgresflex instance clone" + --recovery-timestamp string Recovery timestamp for the instance, in a date-time with the layout format, e.g. 2024-03-12T09:28:00+00:00 + --storage-class string Storage class + --storage-size int Storage size (in GB) +``` + +### 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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit postgresflex instance](./stackit_postgresflex_instance.md) - Provides functionality for PostgreSQL Flex instances + From e3279862c7c0a8046538b19468a3d12ba7b79714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Mon, 18 Mar 2024 11:02:35 +0100 Subject: [PATCH 08/15] edit example and flag descriptions // add date validation for recovery date --- .../cmd/postgresflex/instance/clone/clone.go | 29 +++++++++++++------ .../postgresflex/instance/clone/clone_test.go | 29 +++++++++++++++++-- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/internal/cmd/postgresflex/instance/clone/clone.go b/internal/cmd/postgresflex/instance/clone/clone.go index ae8ac6259..935f22f33 100644 --- a/internal/cmd/postgresflex/instance/clone/clone.go +++ b/internal/cmd/postgresflex/instance/clone/clone.go @@ -3,6 +3,7 @@ package clone import ( "context" "fmt" + "time" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" @@ -26,7 +27,7 @@ const ( storageClassFlag = "storage-class" storageSizeFlag = "storage-size" recoveryTimestampFlag = "recovery-timestamp" - recoveryDateFormat = "2023-04-17T09:28:00+00:00" + recoveryDateFormat = time.RFC3339 ) type inputModel struct { @@ -42,16 +43,17 @@ func NewCmd() *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("clone %s", instanceIdArg), Short: "Clones a PostgreSQL Flex instance", - Long: "Clones a PostgreSQL Flex instance from a selected point in time.", + Long: "Clones a PostgreSQL Flex instance from a selected point in time. " + + "The new cloned instance will be an independent instance with the same settings as the original instance unless the flags are specified.", Example: examples.Build( examples.NewExample( - `Clone a PostgreSQL Flex instance with ID "xxx" . The recovery timestamp should be specified in UTC time following the format provided in the example.`, + `Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp.`, `$ stackit postgresflex instance clone xxx --recovery-timestamp 2023-04-17T09:28:00+00:00`), examples.NewExample( - `Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp and specify storage class. If not specified, storage class from the existing instance will be used.`, + `Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp and specify storage class.`, `$ stackit postgresflex instance clone xxx --recovery-timestamp 2023-04-17T09:28:00+00:00 --storage-class premium-perf6-stackit`), examples.NewExample( - `Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp and specify storage size. If not specified, storage size from the existing instance will be used.`, + `Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp and specify storage size.`, `$ stackit postgresflex instance clone xxx --recovery-timestamp 2023-04-17T09:28:00+00:00 --storage-size 10`), ), Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), @@ -118,9 +120,9 @@ func NewCmd() *cobra.Command { } func configureFlags(cmd *cobra.Command) { - cmd.Flags().String(recoveryTimestampFlag, "", "Recovery timestamp for the instance, in a date-time with the layout format, e.g. 2024-03-12T09:28:00+00:00") - cmd.Flags().String(storageClassFlag, "", "Storage class") - cmd.Flags().Int64(storageSizeFlag, 0, "Storage size (in GB)") + cmd.Flags().String(recoveryTimestampFlag, "", "Recovery timestamp for the instance, specified in UTC time following the format, e.g. 2024-03-12T09:28:00+00:00 ") + cmd.Flags().String(storageClassFlag, "", "Storage class. If not specified, storage class from the existing instance will be used.") + cmd.Flags().Int64(storageSizeFlag, 0, "Storage size (in GB). If not specified, storage size from the existing instance will be used.") err := flags.MarkFlagsRequired(cmd, recoveryTimestampFlag) cobra.CheckErr(err) @@ -134,12 +136,21 @@ func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { return nil, &cliErr.ProjectIdError{} } + recoveryTimestamp, err := flags.FlagToDateTimePointer(cmd, recoveryTimestampFlag, recoveryDateFormat) + if err != nil { + return nil, &cliErr.FlagValidationError{ + Flag: recoveryTimestampFlag, + Details: err.Error(), + } + } + recoveryTimestampString := recoveryTimestamp.String() + return &inputModel{ GlobalFlagModel: globalFlags, InstanceId: instanceId, StorageClass: flags.FlagToStringPointer(cmd, storageClassFlag), StorageSize: flags.FlagToInt64Pointer(cmd, storageSizeFlag), - RecoveryDate: flags.FlagToStringPointer(cmd, recoveryTimestampFlag), + RecoveryDate: utils.Ptr(recoveryTimestampString), }, nil } diff --git a/internal/cmd/postgresflex/instance/clone/clone_test.go b/internal/cmd/postgresflex/instance/clone/clone_test.go index 885307edb..d508385fa 100644 --- a/internal/cmd/postgresflex/instance/clone/clone_test.go +++ b/internal/cmd/postgresflex/instance/clone/clone_test.go @@ -3,6 +3,7 @@ package clone import ( "context" "testing" + "time" "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -58,12 +59,18 @@ func fixtureStandardFlagValues(mods ...func(flagValues map[string]string)) map[s } func fixtureRequiredInputModel(mods ...func(model *inputModel)) *inputModel { + testRecoveryTimestamp, err := time.Parse(recoveryDateFormat, testRecoveryTimestamp) + if err != nil { + return &inputModel{} + } + recoveryTimestampString := testRecoveryTimestamp.String() + model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, }, InstanceId: testInstanceId, - RecoveryDate: utils.Ptr(testRecoveryTimestamp), + RecoveryDate: utils.Ptr(recoveryTimestampString), } for _, mod := range mods { mod(model) @@ -72,6 +79,12 @@ func fixtureRequiredInputModel(mods ...func(model *inputModel)) *inputModel { } func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel { + testRecoveryTimestamp, err := time.Parse(recoveryDateFormat, testRecoveryTimestamp) + if err != nil { + return &inputModel{} + } + recoveryTimestampString := testRecoveryTimestamp.String() + model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, @@ -79,7 +92,7 @@ func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel { InstanceId: testInstanceId, StorageClass: utils.Ptr("premium-perf4-stackit"), StorageSize: utils.Ptr(int64(10)), - RecoveryDate: utils.Ptr(testRecoveryTimestamp), + RecoveryDate: utils.Ptr(recoveryTimestampString), } for _, mod := range mods { mod(model) @@ -97,10 +110,16 @@ func fixtureRequest(mods ...func(request *postgresflex.ApiCloneInstanceRequest)) } func fixturePayload(mods ...func(payload *postgresflex.CloneInstancePayload)) postgresflex.CloneInstancePayload { + testRecoveryTimestamp, err := time.Parse(recoveryDateFormat, testRecoveryTimestamp) + if err != nil { + return postgresflex.CloneInstancePayload{} + } + recoveryTimestampString := testRecoveryTimestamp.String() + payload := postgresflex.CloneInstancePayload{ Class: utils.Ptr("premium-perf4-stackit"), Size: utils.Ptr(int64(10)), - Timestamp: utils.Ptr(testRecoveryTimestamp), + Timestamp: utils.Ptr(recoveryTimestampString), } for _, mod := range mods { mod(&payload) @@ -215,6 +234,7 @@ func TestParseInput(t *testing.T) { }, { description: "recovery timestamp is missing", + argValues: fixtureArgValues(), flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { delete(flagValues, recoveryTimestampFlag) }), @@ -222,6 +242,7 @@ func TestParseInput(t *testing.T) { }, { description: "recovery timestamp is empty", + argValues: fixtureArgValues(), flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { flagValues[recoveryTimestampFlag] = "" }), @@ -229,6 +250,7 @@ func TestParseInput(t *testing.T) { }, { description: "recovery timestamp is invalid", + argValues: fixtureArgValues(), flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { flagValues[recoveryTimestampFlag] = "test" }), @@ -236,6 +258,7 @@ func TestParseInput(t *testing.T) { }, { description: "recovery timestamp is invalid 2", + argValues: fixtureArgValues(), flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { flagValues[recoveryTimestampFlag] = "11:00 12/12/2024" }), From f364936b312aea2a28e85b7601c04f9b009a60e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Mon, 18 Mar 2024 13:54:43 +0100 Subject: [PATCH 09/15] add storage validation // add unit tests for build request --- .../cmd/postgresflex/instance/clone/clone.go | 27 ++- .../postgresflex/instance/clone/clone_test.go | 175 +++++++++++++++++- 2 files changed, 194 insertions(+), 8 deletions(-) diff --git a/internal/cmd/postgresflex/instance/clone/clone.go b/internal/cmd/postgresflex/instance/clone/clone.go index 935f22f33..b05617b2f 100644 --- a/internal/cmd/postgresflex/instance/clone/clone.go +++ b/internal/cmd/postgresflex/instance/clone/clone.go @@ -154,8 +154,33 @@ func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { }, nil } -func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) (postgresflex.ApiCloneInstanceRequest, error) { +type PostgreSQLFlexClient interface { + CloneInstance(ctx context.Context, projectId, instanceId string) postgresflex.ApiCloneInstanceRequest + GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*postgresflex.InstanceResponse, error) + ListStoragesExecute(ctx context.Context, projectId, flavorId string) (*postgresflex.ListStoragesResponse, error) +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFlexClient) (postgresflex.ApiCloneInstanceRequest, error) { req := apiClient.CloneInstance(ctx, model.ProjectId, model.InstanceId) + + var storages *postgresflex.ListStoragesResponse + if model.StorageClass != nil || model.StorageSize != nil { + currentInstance, err := apiClient.GetInstanceExecute(ctx, model.ProjectId, model.InstanceId) + if err != nil { + return req, fmt.Errorf("get PostgreSQL Flex instance: %w", err) + } + validationFlavorId := currentInstance.Item.Flavor.Id + + storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, *validationFlavorId) + if err != nil { + return req, fmt.Errorf("get PostgreSQL Flex storages: %w", err) + } + err = postgresflexUtils.ValidateStorage(model.StorageClass, model.StorageSize, storages, *validationFlavorId) + if err != nil { + return req, err + } + } + req = req.CloneInstancePayload(postgresflex.CloneInstancePayload{ Class: model.StorageClass, Size: model.StorageSize, diff --git a/internal/cmd/postgresflex/instance/clone/clone_test.go b/internal/cmd/postgresflex/instance/clone/clone_test.go index d508385fa..c61e50e44 100644 --- a/internal/cmd/postgresflex/instance/clone/clone_test.go +++ b/internal/cmd/postgresflex/instance/clone/clone_test.go @@ -2,6 +2,7 @@ package clone import ( "context" + "fmt" "testing" "time" @@ -20,9 +21,36 @@ type testCtxKey struct{} var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") var testClient = &postgresflex.APIClient{} + +type postgresFlexClientMocked struct { + listStoragesFails bool + listStoragesResp *postgresflex.ListStoragesResponse + getInstanceFails bool + getInstanceResp *postgresflex.InstanceResponse +} + +func (c *postgresFlexClientMocked) CloneInstance(ctx context.Context, projectId, instanceId string) postgresflex.ApiCloneInstanceRequest { + return testClient.CloneInstance(ctx, projectId, instanceId) +} + +func (c *postgresFlexClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*postgresflex.InstanceResponse, error) { + if c.getInstanceFails { + return nil, fmt.Errorf("get instance failed") + } + return c.getInstanceResp, nil +} + +func (c *postgresFlexClientMocked) ListStoragesExecute(_ context.Context, _, _ string) (*postgresflex.ListStoragesResponse, error) { + if c.listStoragesFails { + return nil, fmt.Errorf("list storages failed") + } + return c.listStoragesResp, nil +} + var testProjectId = uuid.NewString() var testInstanceId = uuid.NewString() var testRecoveryTimestamp = "2024-03-08T09:28:00+00:00" +var testFlavorId = uuid.NewString() func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ @@ -117,8 +145,6 @@ func fixturePayload(mods ...func(payload *postgresflex.CloneInstancePayload)) po recoveryTimestampString := testRecoveryTimestamp.String() payload := postgresflex.CloneInstancePayload{ - Class: utils.Ptr("premium-perf4-stackit"), - Size: utils.Ptr(int64(10)), Timestamp: utils.Ptr(recoveryTimestampString), } for _, mod := range mods { @@ -320,21 +346,156 @@ func TestParseInput(t *testing.T) { } func TestBuildRequest(t *testing.T) { + testRecoveryTimestamp, err := time.Parse(recoveryDateFormat, testRecoveryTimestamp) + if err != nil { + return + } + recoveryTimestampString := testRecoveryTimestamp.String() + tests := []struct { - description string - model *inputModel - expectedRequest postgresflex.ApiCloneInstanceRequest + description string + model *inputModel + expectedRequest postgresflex.ApiCloneInstanceRequest + getInstanceFails bool + getInstanceResp *postgresflex.InstanceResponse + listStoragesFails bool + listStoragesResp *postgresflex.ListStoragesResponse + isValid bool }{ { description: "base", - model: fixtureStandardInputModel(), + model: fixtureRequiredInputModel(), + isValid: true, expectedRequest: fixtureRequest(), }, + { + description: "specify storage class only", + model: fixtureRequiredInputModel(func(model *inputModel) { + model.StorageClass = utils.Ptr("class") + }), + isValid: true, + getInstanceResp: &postgresflex.InstanceResponse{ + Item: &postgresflex.Instance{ + Flavor: &postgresflex.Flavor{ + Id: utils.Ptr(testFlavorId), + }, + }, + }, + listStoragesResp: &postgresflex.ListStoragesResponse{ + StorageClasses: &[]string{"class"}, + StorageRange: &postgresflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + expectedRequest: testClient.CloneInstance(testCtx, testProjectId, testInstanceId). + CloneInstancePayload(postgresflex.CloneInstancePayload{ + Class: utils.Ptr("class"), + Timestamp: utils.Ptr(recoveryTimestampString), + }), + }, + { + description: "specify storage class and size", + model: fixtureRequiredInputModel(func(model *inputModel) { + model.StorageClass = utils.Ptr("class") + model.StorageSize = utils.Ptr(int64(10)) + }), + isValid: true, + getInstanceResp: &postgresflex.InstanceResponse{ + Item: &postgresflex.Instance{ + Flavor: &postgresflex.Flavor{ + Id: utils.Ptr(testFlavorId), + }, + }, + }, + listStoragesResp: &postgresflex.ListStoragesResponse{ + StorageClasses: &[]string{"class"}, + StorageRange: &postgresflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + expectedRequest: testClient.CloneInstance(testCtx, testProjectId, testInstanceId). + CloneInstancePayload(postgresflex.CloneInstancePayload{ + Class: utils.Ptr("class"), + Size: utils.Ptr(int64(10)), + Timestamp: utils.Ptr(recoveryTimestampString), + }), + }, + { + description: "get instance fails", + model: fixtureRequiredInputModel( + func(model *inputModel) { + model.StorageClass = utils.Ptr("class") + model.RecoveryDate = utils.Ptr(recoveryTimestampString) + }, + ), + getInstanceFails: true, + isValid: false, + }, + { + description: "invalid storage class", + model: fixtureRequiredInputModel( + func(model *inputModel) { + model.StorageClass = utils.Ptr("non-existing-class") + }, + ), + getInstanceResp: &postgresflex.InstanceResponse{ + Item: &postgresflex.Instance{ + Flavor: &postgresflex.Flavor{ + Id: utils.Ptr(testFlavorId), + }, + }, + }, + listStoragesResp: &postgresflex.ListStoragesResponse{ + StorageClasses: &[]string{"class"}, + StorageRange: &postgresflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + isValid: false, + }, + { + description: "invalid storage size", + model: fixtureRequiredInputModel( + func(model *inputModel) { + model.StorageSize = utils.Ptr(int64(9)) + }, + ), + getInstanceResp: &postgresflex.InstanceResponse{ + Item: &postgresflex.Instance{ + Flavor: &postgresflex.Flavor{ + Id: utils.Ptr(testFlavorId), + }, + }, + }, + listStoragesResp: &postgresflex.ListStoragesResponse{ + StorageClasses: &[]string{"class"}, + StorageRange: &postgresflex.StorageRange{ + Min: utils.Ptr(int64(10)), + Max: utils.Ptr(int64(100)), + }, + }, + isValid: false, + }, } for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - request, _ := buildRequest(testCtx, tt.model, testClient) + client := &postgresFlexClientMocked{ + getInstanceFails: tt.getInstanceFails, + getInstanceResp: tt.getInstanceResp, + listStoragesFails: tt.listStoragesFails, + listStoragesResp: tt.listStoragesResp, + } + request, err := buildRequest(testCtx, tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), From 9beb9fefca4c67ab38414db6ab66d76c90754444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Mon, 18 Mar 2024 14:36:50 +0100 Subject: [PATCH 10/15] revert backups changes --- docs/stackit_postgresflex.md | 1 - docs/stackit_postgresflex_backups.md | 34 --- docs/stackit_postgresflex_backups_describe.md | 42 --- docs/stackit_postgresflex_backups_list.md | 45 --- ...it_postgresflex_backups_update-schedule.md | 39 --- docs/stackit_postgresflex_instance_clone.md | 14 +- internal/cmd/postgresflex/backups/backups.go | 29 -- .../postgresflex/backups/describe/describe.go | 140 ---------- .../backups/describe/describe_test.go | 236 ---------------- .../cmd/postgresflex/backups/list/list.go | 153 ----------- .../postgresflex/backups/list/list_test.go | 226 --------------- .../update-schedule/update_schedule.go | 116 -------- .../update-schedule/update_schedule_test.go | 257 ------------------ internal/cmd/postgresflex/postgresflex.go | 2 - 14 files changed, 7 insertions(+), 1327 deletions(-) delete mode 100644 docs/stackit_postgresflex_backups.md delete mode 100644 docs/stackit_postgresflex_backups_describe.md delete mode 100644 docs/stackit_postgresflex_backups_list.md delete mode 100644 docs/stackit_postgresflex_backups_update-schedule.md delete mode 100644 internal/cmd/postgresflex/backups/backups.go delete mode 100644 internal/cmd/postgresflex/backups/describe/describe.go delete mode 100644 internal/cmd/postgresflex/backups/describe/describe_test.go delete mode 100644 internal/cmd/postgresflex/backups/list/list.go delete mode 100644 internal/cmd/postgresflex/backups/list/list_test.go delete mode 100644 internal/cmd/postgresflex/backups/update-schedule/update_schedule.go delete mode 100644 internal/cmd/postgresflex/backups/update-schedule/update_schedule_test.go diff --git a/docs/stackit_postgresflex.md b/docs/stackit_postgresflex.md index 73d642424..f97789144 100644 --- a/docs/stackit_postgresflex.md +++ b/docs/stackit_postgresflex.md @@ -28,7 +28,6 @@ stackit postgresflex [flags] ### SEE ALSO * [stackit](./stackit.md) - Manage STACKIT resources using the command line -* [stackit postgresflex backups](./stackit_postgresflex_backups.md) - Provides functionality for PostgreSQL Flex instance backups * [stackit postgresflex instance](./stackit_postgresflex_instance.md) - Provides functionality for PostgreSQL Flex instances * [stackit postgresflex options](./stackit_postgresflex_options.md) - Lists PostgreSQL Flex options * [stackit postgresflex user](./stackit_postgresflex_user.md) - Provides functionality for PostgreSQL Flex users diff --git a/docs/stackit_postgresflex_backups.md b/docs/stackit_postgresflex_backups.md deleted file mode 100644 index de0434609..000000000 --- a/docs/stackit_postgresflex_backups.md +++ /dev/null @@ -1,34 +0,0 @@ -## stackit postgresflex backups - -Provides functionality for PostgreSQL Flex instance backups - -### Synopsis - -Provides functionality for PostgreSQL Flex instance backups. - -``` -stackit postgresflex backups [flags] -``` - -### Options - -``` - -h, --help Help for "stackit postgresflex backups" -``` - -### 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"] - -p, --project-id string Project ID -``` - -### SEE ALSO - -* [stackit postgresflex](./stackit_postgresflex.md) - Provides functionality for PostgreSQL Flex -* [stackit postgresflex backups describe](./stackit_postgresflex_backups_describe.md) - Shows details of a backup for a specific PostgreSQL Flex instance -* [stackit postgresflex backups list](./stackit_postgresflex_backups_list.md) - Lists all backups which are available for a specific PostgreSQL Flex instance -* [stackit postgresflex backups update-schedule](./stackit_postgresflex_backups_update-schedule.md) - Updates backup schedule for a specific PostgreSQL Flex instance - diff --git a/docs/stackit_postgresflex_backups_describe.md b/docs/stackit_postgresflex_backups_describe.md deleted file mode 100644 index c8670f050..000000000 --- a/docs/stackit_postgresflex_backups_describe.md +++ /dev/null @@ -1,42 +0,0 @@ -## stackit postgresflex backups describe - -Shows details of a backup for a specific PostgreSQL Flex instance - -### Synopsis - -Shows details of a backup for a specific PostgreSQL Flex instance. - -``` -stackit postgresflex backups describe BACKUP_ID [flags] -``` - -### Examples - -``` - Get details of a backup with ID "xxx" for a PostgreSQL Flex instance with ID "yyy" - $ stackit postgresflex backups describe xxx --instance-id yyy - - Get details of a backup with ID "xxx" for a PostgreSQL Flex instance with ID "yyy" in a table format - $ stackit postgresflex backups describe xxx --instance-id yyy --output-format pretty -``` - -### Options - -``` - -h, --help Help for "stackit postgresflex backups describe" - --instance-id string Instance ID -``` - -### 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"] - -p, --project-id string Project ID -``` - -### SEE ALSO - -* [stackit postgresflex backups](./stackit_postgresflex_backups.md) - Provides functionality for PostgreSQL Flex instance backups - diff --git a/docs/stackit_postgresflex_backups_list.md b/docs/stackit_postgresflex_backups_list.md deleted file mode 100644 index fca2fa64f..000000000 --- a/docs/stackit_postgresflex_backups_list.md +++ /dev/null @@ -1,45 +0,0 @@ -## stackit postgresflex backups list - -Lists all backups which are available for a specific PostgreSQL Flex instance - -### Synopsis - -Lists all backups which are available for a specific PostgreSQL Flex instance. - -``` -stackit postgresflex backups list [flags] -``` - -### Examples - -``` - List all backups of instance with ID "xxx" - $ stackit postgresflex backups list xxx - - List all backups of instance with ID "xxx" in JSON format - $ stackit postgresflex backups list xxx --output-format json - - List up to 10 backups of instance with ID "xxx" - $ stackit postgresflex backups list xxx --limit 10 -``` - -### Options - -``` - -h, --help Help for "stackit postgresflex backups list" - --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"] - -p, --project-id string Project ID -``` - -### SEE ALSO - -* [stackit postgresflex backups](./stackit_postgresflex_backups.md) - Provides functionality for PostgreSQL Flex instance backups - diff --git a/docs/stackit_postgresflex_backups_update-schedule.md b/docs/stackit_postgresflex_backups_update-schedule.md deleted file mode 100644 index 0d6890028..000000000 --- a/docs/stackit_postgresflex_backups_update-schedule.md +++ /dev/null @@ -1,39 +0,0 @@ -## stackit postgresflex backups update-schedule - -Updates backup schedule for a specific PostgreSQL Flex instance - -### Synopsis - -Updates backup schedule for a specific PostgreSQL Flex instance. - -``` -stackit postgresflex backups update-schedule INSTANCE_ID [flags] -``` - -### Examples - -``` - Update the backup schedule of a PostgreSQL Flex instance with ID "xxx" - $ stackit postgresflex backups update-schedule xxx --backup-schedule '6 6 * * *' -``` - -### Options - -``` - --backup-schedule string Backup schedule - -h, --help Help for "stackit postgresflex backups update-schedule" -``` - -### 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"] - -p, --project-id string Project ID -``` - -### SEE ALSO - -* [stackit postgresflex backups](./stackit_postgresflex_backups.md) - Provides functionality for PostgreSQL Flex instance backups - diff --git a/docs/stackit_postgresflex_instance_clone.md b/docs/stackit_postgresflex_instance_clone.md index 215ef48ce..64acbc209 100644 --- a/docs/stackit_postgresflex_instance_clone.md +++ b/docs/stackit_postgresflex_instance_clone.md @@ -4,7 +4,7 @@ Clones a PostgreSQL Flex instance ### Synopsis -Clones a PostgreSQL Flex instance from a selected point in time. +Clones a PostgreSQL Flex instance from a selected point in time. The new cloned instance will be an independent instance with the same settings as the original instance unless the flags are specified. ``` stackit postgresflex instance clone INSTANCE_ID [flags] @@ -13,13 +13,13 @@ stackit postgresflex instance clone INSTANCE_ID [flags] ### Examples ``` - Clone a PostgreSQL Flex instance with ID "xxx" . The recovery timestamp should be specified in UTC time following the format provided in the example. + Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp. $ stackit postgresflex instance clone xxx --recovery-timestamp 2023-04-17T09:28:00+00:00 - Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp and specify storage class. If not specified, storage class from the existing instance will be used. + Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp and specify storage class. $ stackit postgresflex instance clone xxx --recovery-timestamp 2023-04-17T09:28:00+00:00 --storage-class premium-perf6-stackit - Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp and specify storage size. If not specified, storage size from the existing instance will be used. + Clone a PostgreSQL Flex instance with ID "xxx" from a selected recovery timestamp and specify storage size. $ stackit postgresflex instance clone xxx --recovery-timestamp 2023-04-17T09:28:00+00:00 --storage-size 10 ``` @@ -27,9 +27,9 @@ stackit postgresflex instance clone INSTANCE_ID [flags] ``` -h, --help Help for "stackit postgresflex instance clone" - --recovery-timestamp string Recovery timestamp for the instance, in a date-time with the layout format, e.g. 2024-03-12T09:28:00+00:00 - --storage-class string Storage class - --storage-size int Storage size (in GB) + --recovery-timestamp string Recovery timestamp for the instance, specified in UTC time following the format, e.g. 2024-03-12T09:28:00+00:00 + --storage-class string Storage class. If not specified, storage class from the existing instance will be used. + --storage-size int Storage size (in GB). If not specified, storage size from the existing instance will be used. ``` ### Options inherited from parent commands diff --git a/internal/cmd/postgresflex/backups/backups.go b/internal/cmd/postgresflex/backups/backups.go deleted file mode 100644 index 1c7a1279d..000000000 --- a/internal/cmd/postgresflex/backups/backups.go +++ /dev/null @@ -1,29 +0,0 @@ -package backups - -import ( - "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/backups/describe" - "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/backups/list" - updateschedule "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/backups/update-schedule" - "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - - "github.com/spf13/cobra" -) - -func NewCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "backups", - Short: "Provides functionality for PostgreSQL Flex instance backups", - Long: "Provides functionality for PostgreSQL Flex instance backups.", - Args: args.NoArgs, - Run: utils.CmdHelp, - } - addSubcommands(cmd) - return cmd -} - -func addSubcommands(cmd *cobra.Command) { - cmd.AddCommand(list.NewCmd()) - cmd.AddCommand(describe.NewCmd()) - cmd.AddCommand(updateschedule.NewCmd()) -} diff --git a/internal/cmd/postgresflex/backups/describe/describe.go b/internal/cmd/postgresflex/backups/describe/describe.go deleted file mode 100644 index ea25b0a28..000000000 --- a/internal/cmd/postgresflex/backups/describe/describe.go +++ /dev/null @@ -1,140 +0,0 @@ -package describe - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/depp/bytesize" - "github.com/spf13/cobra" - "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/services/postgresflex/client" - "github.com/stackitcloud/stackit-cli/internal/pkg/tables" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" -) - -const ( - backupIdArg = "BACKUP_ID" - - instanceIdFlag = "instance-id" -) - -type inputModel struct { - *globalflags.GlobalFlagModel - - InstanceId string - BackupId string -} - -func NewCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: fmt.Sprintf("describe %s", backupIdArg), - Short: "Shows details of a backup for a specific PostgreSQL Flex instance", - Long: "Shows details of a backup for a specific PostgreSQL Flex instance.", - Example: examples.Build( - examples.NewExample( - `Get details of a backup with ID "xxx" for a PostgreSQL Flex instance with ID "yyy"`, - "$ stackit postgresflex backups describe xxx --instance-id yyy"), - examples.NewExample( - `Get details of a backup with ID "xxx" for a PostgreSQL Flex instance with ID "yyy" in a table format`, - "$ stackit postgresflex backups describe xxx --instance-id yyy --output-format pretty"), - ), - Args: args.SingleArg(backupIdArg, nil), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := context.Background() - model, err := parseInput(cmd, args) - if err != nil { - return err - } - - // Configure API client - apiClient, err := client.ConfigureClient(cmd) - if err != nil { - return err - } - - // Call API - req := buildRequest(ctx, model, apiClient) - resp, err := req.Execute() - - if err != nil { - return fmt.Errorf("describe backup for PostgreSQL Flex instance : %w", err) - } - - return outputResult(cmd, model.OutputFormat, *resp.Item) - }, - } - configureFlags(cmd) - return cmd -} - -func configureFlags(cmd *cobra.Command) { - cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "Instance ID") - - err := flags.MarkFlagsRequired(cmd, instanceIdFlag) - cobra.CheckErr(err) -} - -func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { - backupId := inputArgs[0] - - globalFlags := globalflags.Parse(cmd) - if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} - } - - return &inputModel{ - GlobalFlagModel: globalFlags, - InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), - BackupId: backupId, - }, nil -} - -func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiGetBackupRequest { - req := apiClient.GetBackup(ctx, model.ProjectId, model.InstanceId, model.BackupId) - return req -} - -func outputResult(cmd *cobra.Command, outputFormat string, backup postgresflex.Backup) error { - backupStartTime, err := time.Parse(time.RFC3339, *backup.StartTime) - if err != nil { - return fmt.Errorf("parse backup start time : %w", err) - } - backupExpireDate := backupStartTime.AddDate(0, 0, 30).Format(time.DateOnly) - - switch outputFormat { - case globalflags.PrettyOutputFormat: - table := tables.NewTable() - table.AddRow("ID", *backup.Id) - table.AddSeparator() - table.AddRow("NAME", *backup.Name) - table.AddSeparator() - table.AddRow("START TIME", *backup.StartTime) - table.AddSeparator() - table.AddRow("END TIME", *backup.EndTime) - table.AddSeparator() - table.AddRow("EXPIRES AT", backupExpireDate) - table.AddSeparator() - table.AddRow("BACKUP SIZE", bytesize.Format(uint64(*backup.Size))) - - err := table.Display(cmd) - if err != nil { - return fmt.Errorf("render table: %w", err) - } - - return nil - default: - details, err := json.MarshalIndent(backup, "", " ") - if err != nil { - return fmt.Errorf("marshal backup for PostgreSQL Flex instance : %w", err) - } - cmd.Println(string(details)) - - return nil - } -} diff --git a/internal/cmd/postgresflex/backups/describe/describe_test.go b/internal/cmd/postgresflex/backups/describe/describe_test.go deleted file mode 100644 index f003f9b12..000000000 --- a/internal/cmd/postgresflex/backups/describe/describe_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package describe - -import ( - "context" - "testing" - - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/google/uuid" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" -) - -var projectIdFlag = globalflags.ProjectIdFlag - -type testCtxKey struct{} - -var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &postgresflex.APIClient{} -var testProjectId = uuid.NewString() -var testInstanceId = uuid.NewString() -var testBackupId = "backupID" - -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{ - projectIdFlag: testProjectId, - instanceIdFlag: testInstanceId, - } - for _, mod := range mods { - mod(flagValues) - } - return flagValues -} - -func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { - model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ - ProjectId: testProjectId, - }, - InstanceId: testInstanceId, - BackupId: testBackupId, - } - for _, mod := range mods { - mod(model) - } - return model -} - -func fixtureRequest(mods ...func(request *postgresflex.ApiGetBackupRequest)) postgresflex.ApiGetBackupRequest { - request := testClient.GetBackup(testCtx, testProjectId, testInstanceId, 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, projectIdFlag) - }), - isValid: false, - }, - { - description: "project id invalid 1", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" - }), - isValid: false, - }, - { - description: "project id invalid 2", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" - }), - isValid: false, - }, - { - description: "instance id missing", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, instanceIdFlag) - }), - isValid: false, - }, - { - description: "instance id invalid 1", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[instanceIdFlag] = "" - }), - isValid: false, - }, - { - description: "instance id invalid 2", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[instanceIdFlag] = "invalid-uuid" - }), - isValid: false, - }, - { - description: "backup id invalid 1", - argValues: []string{""}, - flagValues: fixtureFlagValues(), - isValid: false, - }, - } - - for _, tt := range tests { - t.Run(tt.description, func(t *testing.T) { - cmd := NewCmd() - 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) - } - - err = cmd.ValidateRequiredFlags() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(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 postgresflex.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) - } - }) - } -} diff --git a/internal/cmd/postgresflex/backups/list/list.go b/internal/cmd/postgresflex/backups/list/list.go deleted file mode 100644 index 782bf1ce5..000000000 --- a/internal/cmd/postgresflex/backups/list/list.go +++ /dev/null @@ -1,153 +0,0 @@ -package list - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/depp/bytesize" - postgresflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/utils" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - - "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/services/postgresflex/client" - "github.com/stackitcloud/stackit-cli/internal/pkg/tables" - - "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" -) - -const ( - instanceIdArg = "INSTANCE_ID" - - limitFlag = "limit" -) - -type inputModel struct { - *globalflags.GlobalFlagModel - - InstanceId string - Limit *int64 -} - -func NewCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "list", - Short: "Lists all backups which are available for a specific PostgreSQL Flex instance", - Long: "Lists all backups which are available for a specific PostgreSQL Flex instance.", - Example: examples.Build( - examples.NewExample( - `List all backups of instance with ID "xxx"`, - "$ stackit postgresflex backups list xxx"), - examples.NewExample( - `List all backups of instance with ID "xxx" in JSON format`, - "$ stackit postgresflex backups list xxx --output-format json"), - examples.NewExample( - `List up to 10 backups of instance with ID "xxx"`, - "$ stackit postgresflex backups list xxx --limit 10"), - ), - Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := context.Background() - model, err := parseInput(cmd, args) - if err != nil { - return err - } - - // Configure API client - apiClient, err := client.ConfigureClient(cmd) - if err != nil { - return err - } - - instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) - if err != nil { - instanceLabel = model.InstanceId - } - - // Call API - req := buildRequest(ctx, model, apiClient) - resp, err := req.Execute() - if err != nil { - return fmt.Errorf("get backups for PostgreSQL Flex instance %q: %w\n", instanceLabel, err) - } - if resp.Items == nil || len(*resp.Items) == 0 { - cmd.Printf("No backups found for instance %q\n", instanceLabel) - return nil - } - backups := *resp.Items - - // Truncate output - if model.Limit != nil && len(backups) > int(*model.Limit) { - backups = backups[:*model.Limit] - } - - return outputResult(cmd, model.OutputFormat, backups) - }, - } - - configureFlags(cmd) - return cmd -} - -func configureFlags(cmd *cobra.Command) { - cmd.Flags().Int64(limitFlag, 0, "Maximum number of entries to list") -} - -func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { - instanceId := inputArgs[0] - - globalFlags := globalflags.Parse(cmd) - if globalFlags.ProjectId == "" { - return nil, &errors.ProjectIdError{} - } - - limit := flags.FlagToInt64Pointer(cmd, limitFlag) - if limit != nil && *limit < 1 { - return nil, &errors.FlagValidationError{ - Flag: limitFlag, - Details: "must be greater than 0", - } - } - - return &inputModel{ - GlobalFlagModel: globalFlags, - InstanceId: instanceId, - Limit: flags.FlagToInt64Pointer(cmd, limitFlag), - }, nil -} - -func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiListBackupsRequest { - req := apiClient.ListBackups(ctx, model.ProjectId, model.InstanceId) - return req -} - -func outputResult(cmd *cobra.Command, outputFormat string, backups []postgresflex.Backup) error { - switch outputFormat { - case globalflags.JSONOutputFormat: - details, err := json.MarshalIndent(backups, "", " ") - if err != nil { - return fmt.Errorf("marshal PostgreSQL Flex instance list: %w", err) - } - cmd.Println(string(details)) - - return nil - default: - table := tables.NewTable() - table.SetHeader("ID", "NAME", "START TIME", "END TIME", "BACKUP SIZE") - for i := range backups { - backup := backups[i] - table.AddRow(*backup.Id, *backup.Name, *backup.StartTime, *backup.EndTime, bytesize.Format(uint64(*backup.Size))) - } - err := table.Display(cmd) - if err != nil { - return fmt.Errorf("render table: %w", err) - } - - return nil - } -} diff --git a/internal/cmd/postgresflex/backups/list/list_test.go b/internal/cmd/postgresflex/backups/list/list_test.go deleted file mode 100644 index 2887389f7..000000000 --- a/internal/cmd/postgresflex/backups/list/list_test.go +++ /dev/null @@ -1,226 +0,0 @@ -package list - -import ( - "context" - "testing" - - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "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/postgresflex" -) - -var projectIdFlag = globalflags.ProjectIdFlag - -type testCtxKey struct{} - -var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &postgresflex.APIClient{} -var testProjectId = uuid.NewString() -var testInstanceId = uuid.NewString() - -func fixtureArgValues(mods ...func(argValues []string)) []string { - argValues := []string{ - testInstanceId, - } - for _, mod := range mods { - mod(argValues) - } - return argValues -} - -func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { - flagValues := map[string]string{ - projectIdFlag: testProjectId, - limitFlag: "10", - } - for _, mod := range mods { - mod(flagValues) - } - return flagValues -} - -func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { - model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ - ProjectId: testProjectId, - }, - InstanceId: testInstanceId, - Limit: utils.Ptr(int64(10)), - } - for _, mod := range mods { - mod(model) - } - return model -} - -func fixtureRequest(mods ...func(request *postgresflex.ApiListBackupsRequest)) postgresflex.ApiListBackupsRequest { - request := testClient.ListBackups(testCtx, testProjectId, testInstanceId) - 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: "project id missing", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) - }), - isValid: false, - }, - { - description: "project id invalid 1", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" - }), - isValid: false, - }, - { - description: "project id invalid 2", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" - }), - isValid: false, - }, - { - description: "instance id invalid 1", - argValues: []string{""}, - flagValues: fixtureFlagValues(), - isValid: false, - }, - { - description: "instance id invalid 2", - argValues: []string{"invalid-uuid"}, - flagValues: fixtureFlagValues(), - 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 := NewCmd() - 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) - } - - err = cmd.ValidateRequiredFlags() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(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 postgresflex.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) - } - }) - } -} diff --git a/internal/cmd/postgresflex/backups/update-schedule/update_schedule.go b/internal/cmd/postgresflex/backups/update-schedule/update_schedule.go deleted file mode 100644 index 40d289ffd..000000000 --- a/internal/cmd/postgresflex/backups/update-schedule/update_schedule.go +++ /dev/null @@ -1,116 +0,0 @@ -package updateschedule - -import ( - "context" - "fmt" - - "github.com/stackitcloud/stackit-cli/internal/pkg/args" - "github.com/stackitcloud/stackit-cli/internal/pkg/utils" - - "github.com/spf13/cobra" - "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" - cliErr "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/services/postgresflex/client" - postgresflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/postgresflex/utils" - "github.com/stackitcloud/stackit-sdk-go/services/postgresflex" -) - -const ( - instanceIdArg = "INSTANCE_ID" - - backupScheduleFlag = "backup-schedule" -) - -type inputModel struct { - *globalflags.GlobalFlagModel - - InstanceId string - BackupSchedule *string -} - -func NewCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: fmt.Sprintf("update-schedule %s", instanceIdArg), - Short: "Updates backup schedule for a specific PostgreSQL Flex instance", - Long: "Updates backup schedule for a specific PostgreSQL Flex instance.", - Args: args.SingleArg(instanceIdArg, utils.ValidateUUID), - Example: examples.Build( - examples.NewExample( - `Update the backup schedule of a PostgreSQL Flex instance with ID "xxx"`, - "$ stackit postgresflex backups update-schedule xxx --backup-schedule '6 6 * * *'"), - ), - - RunE: func(cmd *cobra.Command, args []string) error { - ctx := context.Background() - - model, err := parseInput(cmd, args) - if err != nil { - return err - } - - // Configure API client - apiClient, err := client.ConfigureClient(cmd) - if err != nil { - return err - } - - instanceLabel, err := postgresflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) - if err != nil { - instanceLabel = model.InstanceId - } - - if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update backup schedule of instance %q?", instanceLabel) - err = confirm.PromptForConfirmation(cmd, prompt) - if err != nil { - return err - } - } - - // Call API - req := buildRequest(ctx, model, apiClient) - err = req.Execute() - if err != nil { - return fmt.Errorf("update backup schedule of PostgreSQL Flex instance: %w", err) - } - - cmd.Printf("Updated backup schedule of instance %q\n", instanceLabel) - return nil - }, - } - configureFlags(cmd) - return cmd -} - -func configureFlags(cmd *cobra.Command) { - cmd.Flags().String(backupScheduleFlag, "", "Backup schedule") - - err := flags.MarkFlagsRequired(cmd, backupScheduleFlag) - cobra.CheckErr(err) -} - -func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { - instanceId := inputArgs[0] - - globalFlags := globalflags.Parse(cmd) - if globalFlags.ProjectId == "" { - return nil, &cliErr.ProjectIdError{} - } - - return &inputModel{ - GlobalFlagModel: globalFlags, - InstanceId: instanceId, - BackupSchedule: flags.FlagToStringPointer(cmd, backupScheduleFlag), - }, nil -} - -func buildRequest(ctx context.Context, model *inputModel, apiClient *postgresflex.APIClient) postgresflex.ApiUpdateBackupScheduleRequest { - req := apiClient.UpdateBackupSchedule(ctx, model.ProjectId, model.InstanceId) - req = req.UpdateBackupSchedulePayload(postgresflex.UpdateBackupSchedulePayload{ - BackupSchedule: model.BackupSchedule, - }) - return req -} diff --git a/internal/cmd/postgresflex/backups/update-schedule/update_schedule_test.go b/internal/cmd/postgresflex/backups/update-schedule/update_schedule_test.go deleted file mode 100644 index c64a76a3f..000000000 --- a/internal/cmd/postgresflex/backups/update-schedule/update_schedule_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package updateschedule - -import ( - "context" - "testing" - - "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" - "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/postgresflex" -) - -var projectIdFlag = globalflags.ProjectIdFlag - -type testCtxKey struct{} - -var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") -var testClient = &postgresflex.APIClient{} -var testProjectId = uuid.NewString() -var testInstanceId = uuid.NewString() -var testBackupSchedule = "0 0 * * *" - -func fixtureArgValues(mods ...func(argValues []string)) []string { - argValues := []string{ - testInstanceId, - } - for _, mod := range mods { - mod(argValues) - } - return argValues -} - -func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { - flagValues := map[string]string{ - projectIdFlag: testProjectId, - backupScheduleFlag: testBackupSchedule, - } - for _, mod := range mods { - mod(flagValues) - } - return flagValues -} - -func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { - model := &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ - ProjectId: testProjectId, - }, - InstanceId: testInstanceId, - BackupSchedule: &testBackupSchedule, - } - for _, mod := range mods { - mod(model) - } - return model -} - -func fixturePayload(mods ...func(payload *postgresflex.UpdateBackupSchedulePayload)) postgresflex.UpdateBackupSchedulePayload { - payload := postgresflex.UpdateBackupSchedulePayload{ - BackupSchedule: utils.Ptr(testBackupSchedule), - } - for _, mod := range mods { - mod(&payload) - } - return payload -} - -func fixtureRequest(mods ...func(request *postgresflex.ApiUpdateBackupScheduleRequest)) postgresflex.ApiUpdateBackupScheduleRequest { - request := testClient.UpdateBackupSchedule(testCtx, testProjectId, testInstanceId) - request = request.UpdateBackupSchedulePayload(fixturePayload()) - for _, mod := range mods { - mod(&request) - } - return request -} - -func TestParseInput(t *testing.T) { - tests := []struct { - description string - argValues []string - flagValues map[string]string - aclValues []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: "with defaults", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(), - isValid: true, - expectedModel: fixtureInputModel(), - }, - { - description: "project id missing", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, projectIdFlag) - }), - isValid: false, - }, - { - description: "project id invalid 1", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "" - }), - isValid: false, - }, - { - description: "project id invalid 2", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - flagValues[projectIdFlag] = "invalid-uuid" - }), - isValid: false, - }, - { - description: "instance id invalid 1", - argValues: []string{""}, - flagValues: fixtureFlagValues(), - isValid: false, - }, - { - description: "instance id invalid 2", - argValues: []string{"invalid-uuid"}, - flagValues: fixtureFlagValues(), - isValid: false, - }, - { - description: "backup schedule missing", - argValues: fixtureArgValues(), - flagValues: fixtureFlagValues(func(flagValues map[string]string) { - delete(flagValues, backupScheduleFlag) - }), - isValid: false, - }, - } - - for _, tt := range tests { - t.Run(tt.description, func(t *testing.T) { - cmd := NewCmd() - 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) - } - - err = cmd.ValidateRequiredFlags() - if err != nil { - if !tt.isValid { - return - } - t.Fatalf("error validating flags: %v", err) - } - - model, err := parseInput(cmd, tt.argValues) - 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 postgresflex.ApiUpdateBackupScheduleRequest - }{ - { - description: "base", - model: fixtureInputModel(), - expectedRequest: fixtureRequest(), - }, - { - description: "required fields only", - model: &inputModel{ - GlobalFlagModel: &globalflags.GlobalFlagModel{ - ProjectId: testProjectId, - }, - InstanceId: testInstanceId, - }, - expectedRequest: testClient.UpdateBackupSchedule(testCtx, testProjectId, testInstanceId). - UpdateBackupSchedulePayload(postgresflex.UpdateBackupSchedulePayload{}), - }, - } - - 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/postgresflex/postgresflex.go b/internal/cmd/postgresflex/postgresflex.go index 10206a3f0..3f74e35c8 100644 --- a/internal/cmd/postgresflex/postgresflex.go +++ b/internal/cmd/postgresflex/postgresflex.go @@ -1,7 +1,6 @@ package postgresflex import ( - "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/backups" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/instance" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/options" "github.com/stackitcloud/stackit-cli/internal/cmd/postgresflex/user" @@ -28,5 +27,4 @@ func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(instance.NewCmd()) cmd.AddCommand(user.NewCmd()) cmd.AddCommand(options.NewCmd()) - cmd.AddCommand(backups.NewCmd()) } From c32ac591506577299e79d1492aa2f9d4d77c3c90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Mon, 18 Mar 2024 16:31:50 +0100 Subject: [PATCH 11/15] change string formatting for recovery timestamp --- internal/cmd/postgresflex/instance/clone/clone.go | 2 +- internal/cmd/postgresflex/instance/clone/clone_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/cmd/postgresflex/instance/clone/clone.go b/internal/cmd/postgresflex/instance/clone/clone.go index b05617b2f..e20baa762 100644 --- a/internal/cmd/postgresflex/instance/clone/clone.go +++ b/internal/cmd/postgresflex/instance/clone/clone.go @@ -143,7 +143,7 @@ func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { Details: err.Error(), } } - recoveryTimestampString := recoveryTimestamp.String() + recoveryTimestampString := recoveryTimestamp.Format(time.RFC3339) return &inputModel{ GlobalFlagModel: globalFlags, diff --git a/internal/cmd/postgresflex/instance/clone/clone_test.go b/internal/cmd/postgresflex/instance/clone/clone_test.go index c61e50e44..04d0e2b6e 100644 --- a/internal/cmd/postgresflex/instance/clone/clone_test.go +++ b/internal/cmd/postgresflex/instance/clone/clone_test.go @@ -91,7 +91,7 @@ func fixtureRequiredInputModel(mods ...func(model *inputModel)) *inputModel { if err != nil { return &inputModel{} } - recoveryTimestampString := testRecoveryTimestamp.String() + recoveryTimestampString := testRecoveryTimestamp.Format(time.RFC3339) model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ @@ -111,7 +111,7 @@ func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel { if err != nil { return &inputModel{} } - recoveryTimestampString := testRecoveryTimestamp.String() + recoveryTimestampString := testRecoveryTimestamp.Format(time.RFC3339) model := &inputModel{ GlobalFlagModel: &globalflags.GlobalFlagModel{ @@ -142,7 +142,7 @@ func fixturePayload(mods ...func(payload *postgresflex.CloneInstancePayload)) po if err != nil { return postgresflex.CloneInstancePayload{} } - recoveryTimestampString := testRecoveryTimestamp.String() + recoveryTimestampString := testRecoveryTimestamp.Format(time.RFC3339) payload := postgresflex.CloneInstancePayload{ Timestamp: utils.Ptr(recoveryTimestampString), @@ -350,7 +350,7 @@ func TestBuildRequest(t *testing.T) { if err != nil { return } - recoveryTimestampString := testRecoveryTimestamp.String() + recoveryTimestampString := testRecoveryTimestamp.Format(time.RFC3339) tests := []struct { description string From a4477d1b839d38de5b17d2be4590bb51a4658584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 19 Mar 2024 13:46:51 +0100 Subject: [PATCH 12/15] remove bytesize --- go.mod | 1 - go.sum | 2 -- 2 files changed, 3 deletions(-) diff --git a/go.mod b/go.mod index 468b4ade4..dfcd4646f 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/stackitcloud/stackit-cli go 1.21 require ( - github.com/depp/bytesize v1.1.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/go-cmp v0.6.0 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 187ab6bb8..a999fcdba 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/depp/bytesize v1.1.0 h1:HUJEFG8nW/vrfflMw0TB/5ZrwSuGAw3xrgyzKqzVLf4= -github.com/depp/bytesize v1.1.0/go.mod h1:W5nYZIYKjq8tqfzkVVwMblQwIRI4KcZGJz4DbNT9wwY= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= From 23164bba87b55edeae25f49240429b86bc8d064f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 19 Mar 2024 13:53:28 +0100 Subject: [PATCH 13/15] edit recovery timestamp flag description --- internal/cmd/postgresflex/instance/clone/clone.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/postgresflex/instance/clone/clone.go b/internal/cmd/postgresflex/instance/clone/clone.go index e20baa762..41d9155be 100644 --- a/internal/cmd/postgresflex/instance/clone/clone.go +++ b/internal/cmd/postgresflex/instance/clone/clone.go @@ -120,7 +120,7 @@ func NewCmd() *cobra.Command { } func configureFlags(cmd *cobra.Command) { - cmd.Flags().String(recoveryTimestampFlag, "", "Recovery timestamp for the instance, specified in UTC time following the format, e.g. 2024-03-12T09:28:00+00:00 ") + cmd.Flags().String(recoveryTimestampFlag, "", "Recovery timestamp for the instance, in a date-time with the RFC3339 layout format, e.g. 2024-01-01T00:00:00Z") cmd.Flags().String(storageClassFlag, "", "Storage class. If not specified, storage class from the existing instance will be used.") cmd.Flags().Int64(storageSizeFlag, 0, "Storage size (in GB). If not specified, storage size from the existing instance will be used.") From 94de4cdf9200b1eb0f76b67f47de8f84f7e565ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Go=CC=88kc=CC=A7e=20Go=CC=88k=20Klingel?= Date: Tue, 19 Mar 2024 14:16:19 +0100 Subject: [PATCH 14/15] extend storage validation for request --- .../cmd/postgresflex/instance/clone/clone.go | 11 +++++++++- .../postgresflex/instance/clone/clone_test.go | 22 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/internal/cmd/postgresflex/instance/clone/clone.go b/internal/cmd/postgresflex/instance/clone/clone.go index 41d9155be..23c727fa0 100644 --- a/internal/cmd/postgresflex/instance/clone/clone.go +++ b/internal/cmd/postgresflex/instance/clone/clone.go @@ -170,12 +170,21 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient PostgreSQLFl return req, fmt.Errorf("get PostgreSQL Flex instance: %w", err) } validationFlavorId := currentInstance.Item.Flavor.Id + currentInstanceStorageClass := currentInstance.Item.Storage.Class + currentInstanceStorageSize := currentInstance.Item.Storage.Size storages, err = apiClient.ListStoragesExecute(ctx, model.ProjectId, *validationFlavorId) if err != nil { return req, fmt.Errorf("get PostgreSQL Flex storages: %w", err) } - err = postgresflexUtils.ValidateStorage(model.StorageClass, model.StorageSize, storages, *validationFlavorId) + + if model.StorageClass == nil { + err = postgresflexUtils.ValidateStorage(currentInstanceStorageClass, model.StorageSize, storages, *validationFlavorId) + } else if model.StorageSize == nil { + err = postgresflexUtils.ValidateStorage(model.StorageClass, currentInstanceStorageSize, storages, *validationFlavorId) + } else { + err = postgresflexUtils.ValidateStorage(model.StorageClass, model.StorageSize, storages, *validationFlavorId) + } if err != nil { return req, err } diff --git a/internal/cmd/postgresflex/instance/clone/clone_test.go b/internal/cmd/postgresflex/instance/clone/clone_test.go index 04d0e2b6e..4eb2c11b3 100644 --- a/internal/cmd/postgresflex/instance/clone/clone_test.go +++ b/internal/cmd/postgresflex/instance/clone/clone_test.go @@ -51,6 +51,8 @@ var testProjectId = uuid.NewString() var testInstanceId = uuid.NewString() var testRecoveryTimestamp = "2024-03-08T09:28:00+00:00" var testFlavorId = uuid.NewString() +var testStorageClass = "premium-perf4-stackit" +var testStorageSize = int64(10) func fixtureArgValues(mods ...func(argValues []string)) []string { argValues := []string{ @@ -118,8 +120,8 @@ func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel { ProjectId: testProjectId, }, InstanceId: testInstanceId, - StorageClass: utils.Ptr("premium-perf4-stackit"), - StorageSize: utils.Ptr(int64(10)), + StorageClass: utils.Ptr(testStorageClass), + StorageSize: utils.Ptr(testStorageSize), RecoveryDate: utils.Ptr(recoveryTimestampString), } for _, mod := range mods { @@ -379,6 +381,10 @@ func TestBuildRequest(t *testing.T) { Flavor: &postgresflex.Flavor{ Id: utils.Ptr(testFlavorId), }, + Storage: &postgresflex.Storage{ + Class: utils.Ptr(testStorageClass), + Size: utils.Ptr(testStorageSize), + }, }, }, listStoragesResp: &postgresflex.ListStoragesResponse{ @@ -406,6 +412,10 @@ func TestBuildRequest(t *testing.T) { Flavor: &postgresflex.Flavor{ Id: utils.Ptr(testFlavorId), }, + Storage: &postgresflex.Storage{ + Class: utils.Ptr(testStorageClass), + Size: utils.Ptr(testStorageSize), + }, }, }, listStoragesResp: &postgresflex.ListStoragesResponse{ @@ -445,6 +455,10 @@ func TestBuildRequest(t *testing.T) { Flavor: &postgresflex.Flavor{ Id: utils.Ptr(testFlavorId), }, + Storage: &postgresflex.Storage{ + Class: utils.Ptr(testStorageClass), + Size: utils.Ptr(testStorageSize), + }, }, }, listStoragesResp: &postgresflex.ListStoragesResponse{ @@ -468,6 +482,10 @@ func TestBuildRequest(t *testing.T) { Flavor: &postgresflex.Flavor{ Id: utils.Ptr(testFlavorId), }, + Storage: &postgresflex.Storage{ + Class: utils.Ptr(testStorageClass), + Size: utils.Ptr(testStorageSize), + }, }, }, listStoragesResp: &postgresflex.ListStoragesResponse{ From b9c8490dfb1f7d9ca54331e2f0e8c1f86e9f9570 Mon Sep 17 00:00:00 2001 From: GokceGK <161626272+GokceGK@users.noreply.github.com> Date: Tue, 19 Mar 2024 14:18:21 +0100 Subject: [PATCH 15/15] use variable in format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: João Palet --- internal/cmd/postgresflex/instance/clone/clone.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/postgresflex/instance/clone/clone.go b/internal/cmd/postgresflex/instance/clone/clone.go index 23c727fa0..3bea17ed3 100644 --- a/internal/cmd/postgresflex/instance/clone/clone.go +++ b/internal/cmd/postgresflex/instance/clone/clone.go @@ -143,7 +143,7 @@ func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { Details: err.Error(), } } - recoveryTimestampString := recoveryTimestamp.Format(time.RFC3339) + recoveryTimestampString := recoveryTimestamp.Format(recoveryDateFormat) return &inputModel{ GlobalFlagModel: globalFlags,