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..64acbc209 --- /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. 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] +``` + +### Examples + +``` + 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. + $ 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. + $ 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, 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 + +``` + -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 + diff --git a/internal/cmd/postgresflex/instance/clone/clone.go b/internal/cmd/postgresflex/instance/clone/clone.go new file mode 100644 index 000000000..3bea17ed3 --- /dev/null +++ b/internal/cmd/postgresflex/instance/clone/clone.go @@ -0,0 +1,199 @@ +package clone + +import ( + "context" + "fmt" + "time" + + "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 = time.RFC3339 +) + +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. " + + "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" 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.`, + `$ 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.`, + `$ 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 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.") + + 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{} + } + + recoveryTimestamp, err := flags.FlagToDateTimePointer(cmd, recoveryTimestampFlag, recoveryDateFormat) + if err != nil { + return nil, &cliErr.FlagValidationError{ + Flag: recoveryTimestampFlag, + Details: err.Error(), + } + } + recoveryTimestampString := recoveryTimestamp.Format(recoveryDateFormat) + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: instanceId, + StorageClass: flags.FlagToStringPointer(cmd, storageClassFlag), + StorageSize: flags.FlagToInt64Pointer(cmd, storageSizeFlag), + RecoveryDate: utils.Ptr(recoveryTimestampString), + }, nil +} + +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 + 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) + } + + 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 + } + } + + 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..4eb2c11b3 --- /dev/null +++ b/internal/cmd/postgresflex/instance/clone/clone_test.go @@ -0,0 +1,527 @@ +package clone + +import ( + "context" + "fmt" + "testing" + "time" + + "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{} + +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() +var testStorageClass = "premium-perf4-stackit" +var testStorageSize = int64(10) + +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 { + testRecoveryTimestamp, err := time.Parse(recoveryDateFormat, testRecoveryTimestamp) + if err != nil { + return &inputModel{} + } + recoveryTimestampString := testRecoveryTimestamp.Format(time.RFC3339) + + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + RecoveryDate: utils.Ptr(recoveryTimestampString), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureStandardInputModel(mods ...func(model *inputModel)) *inputModel { + testRecoveryTimestamp, err := time.Parse(recoveryDateFormat, testRecoveryTimestamp) + if err != nil { + return &inputModel{} + } + recoveryTimestampString := testRecoveryTimestamp.Format(time.RFC3339) + + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + StorageClass: utils.Ptr(testStorageClass), + StorageSize: utils.Ptr(testStorageSize), + RecoveryDate: utils.Ptr(recoveryTimestampString), + } + 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 { + testRecoveryTimestamp, err := time.Parse(recoveryDateFormat, testRecoveryTimestamp) + if err != nil { + return postgresflex.CloneInstancePayload{} + } + recoveryTimestampString := testRecoveryTimestamp.Format(time.RFC3339) + + payload := postgresflex.CloneInstancePayload{ + Timestamp: utils.Ptr(recoveryTimestampString), + } + 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", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + delete(flagValues, recoveryTimestampFlag) + }), + isValid: false, + }, + { + description: "recovery timestamp is empty", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + flagValues[recoveryTimestampFlag] = "" + }), + isValid: false, + }, + { + description: "recovery timestamp is invalid", + argValues: fixtureArgValues(), + flagValues: fixtureRequiredFlagValues(func(flagValues map[string]string) { + flagValues[recoveryTimestampFlag] = "test" + }), + isValid: false, + }, + { + description: "recovery timestamp is invalid 2", + argValues: fixtureArgValues(), + 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) { + testRecoveryTimestamp, err := time.Parse(recoveryDateFormat, testRecoveryTimestamp) + if err != nil { + return + } + recoveryTimestampString := testRecoveryTimestamp.Format(time.RFC3339) + + tests := []struct { + description string + model *inputModel + expectedRequest postgresflex.ApiCloneInstanceRequest + getInstanceFails bool + getInstanceResp *postgresflex.InstanceResponse + listStoragesFails bool + listStoragesResp *postgresflex.ListStoragesResponse + isValid bool + }{ + { + description: "base", + 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), + }, + Storage: &postgresflex.Storage{ + Class: utils.Ptr(testStorageClass), + Size: utils.Ptr(testStorageSize), + }, + }, + }, + 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), + }, + Storage: &postgresflex.Storage{ + Class: utils.Ptr(testStorageClass), + Size: utils.Ptr(testStorageSize), + }, + }, + }, + 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), + }, + Storage: &postgresflex.Storage{ + Class: utils.Ptr(testStorageClass), + Size: utils.Ptr(testStorageSize), + }, + }, + }, + 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), + }, + Storage: &postgresflex.Storage{ + Class: utils.Ptr(testStorageClass), + Size: utils.Ptr(testStorageSize), + }, + }, + }, + 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) { + 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), + 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()) } 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`