diff --git a/docs/stackit_beta_sqlserverflex.md b/docs/stackit_beta_sqlserverflex.md index dc6ec99d7..79a07e351 100644 --- a/docs/stackit_beta_sqlserverflex.md +++ b/docs/stackit_beta_sqlserverflex.md @@ -31,4 +31,5 @@ stackit beta sqlserverflex [flags] * [stackit beta](./stackit_beta.md) - Contains beta STACKIT CLI commands * [stackit beta sqlserverflex instance](./stackit_beta_sqlserverflex_instance.md) - Provides functionality for SQLServer Flex instances * [stackit beta sqlserverflex options](./stackit_beta_sqlserverflex_options.md) - Lists SQL Server Flex options +* [stackit beta sqlserverflex user](./stackit_beta_sqlserverflex_user.md) - Provides functionality for SQLServer Flex users diff --git a/docs/stackit_beta_sqlserverflex_options.md b/docs/stackit_beta_sqlserverflex_options.md index 30d76d30c..dd4a7063b 100644 --- a/docs/stackit_beta_sqlserverflex_options.md +++ b/docs/stackit_beta_sqlserverflex_options.md @@ -20,10 +20,10 @@ stackit beta sqlserverflex options [flags] List SQL Server Flex available versions $ stackit beta sqlserverflex options --versions - List SQL Server Flex storage options for a given flavor. The flavor ID can be retrieved by running "$ stackit sqlserverflex options --flavors" + List SQL Server Flex storage options for a given flavor. The flavor ID can be retrieved by running "$ stackit beta sqlserverflex options --flavors" $ stackit beta sqlserverflex options --storages --flavor-id - List SQL Server Flex user roles and database compatibilities for a given instance. The IDs of existing instances can be obtained by running "$ stackit sqlserverflex instance list" + List SQL Server Flex user roles and database compatibilities for a given instance. The IDs of existing instances can be obtained by running "$ stackit beta sqlserverflex instance list" $ stackit beta sqlserverflex options --user-roles --db-compatibilities --instance-id ``` diff --git a/docs/stackit_beta_sqlserverflex_user.md b/docs/stackit_beta_sqlserverflex_user.md new file mode 100644 index 000000000..2b02cbb4c --- /dev/null +++ b/docs/stackit_beta_sqlserverflex_user.md @@ -0,0 +1,34 @@ +## stackit beta sqlserverflex user + +Provides functionality for SQLServer Flex users + +### Synopsis + +Provides functionality for SQLServer Flex users. + +``` +stackit beta sqlserverflex user [flags] +``` + +### Options + +``` + -h, --help Help for "stackit beta sqlserverflex user" +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sqlserverflex](./stackit_beta_sqlserverflex.md) - Provides functionality for SQLServer Flex +* [stackit beta sqlserverflex user create](./stackit_beta_sqlserverflex_user_create.md) - Creates an SQLServer Flex user +* [stackit beta sqlserverflex user reset-password](./stackit_beta_sqlserverflex_user_reset-password.md) - Resets the password of an SQLServer Flex user + diff --git a/docs/stackit_beta_sqlserverflex_user_create.md b/docs/stackit_beta_sqlserverflex_user_create.md new file mode 100644 index 000000000..aa9faa987 --- /dev/null +++ b/docs/stackit_beta_sqlserverflex_user_create.md @@ -0,0 +1,50 @@ +## stackit beta sqlserverflex user create + +Creates an SQLServer Flex user + +### Synopsis + +Creates an SQLServer Flex user for an instance. +The password is only visible upon creation and cannot be retrieved later. +Alternatively, you can reset the password and access the new one by running: + $ stackit beta sqlserverflex user reset-password USER_ID --instance-id INSTANCE_ID +Please refer to https://docs.stackit.cloud/stackit/en/creating-logins-and-users-in-sqlserver-flex-instances-210862358.html for additional information. + +``` +stackit beta sqlserverflex user create [flags] +``` + +### Examples + +``` + Create an SQLServer Flex user for instance with ID "xxx" and specify the username, role and database + $ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles my-role --database my-database + + Create an SQLServer Flex user for instance with ID "xxx", specifying multiple roles + $ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles "my-role-1,my-role-2 +``` + +### Options + +``` + --database string Default database for the user + -h, --help Help for "stackit beta sqlserverflex user create" + --instance-id string ID of the instance + --roles strings Roles of the user + --username string Username of the user +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sqlserverflex user](./stackit_beta_sqlserverflex_user.md) - Provides functionality for SQLServer Flex users + diff --git a/docs/stackit_beta_sqlserverflex_user_reset-password.md b/docs/stackit_beta_sqlserverflex_user_reset-password.md new file mode 100644 index 000000000..757438141 --- /dev/null +++ b/docs/stackit_beta_sqlserverflex_user_reset-password.md @@ -0,0 +1,41 @@ +## stackit beta sqlserverflex user reset-password + +Resets the password of an SQLServer Flex user + +### Synopsis + +Resets the password of an SQLServer Flex user. +sThe new password is visible after resetting and cannot be retrieved later. + +``` +stackit beta sqlserverflex user reset-password USER_ID [flags] +``` + +### Examples + +``` + Reset the password of an SQLServer Flex user with ID "xxx" of instance with ID "yyy" + $ stackit beta sqlserverflex user reset-password xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit beta sqlserverflex user reset-password" + --instance-id string ID of the instance +``` + +### Options inherited from parent commands + +``` + -y, --assume-yes If set, skips all confirmation prompts + --async If set, runs the command asynchronously + -o, --output-format string Output format, one of ["json" "pretty" "none" "yaml"] + -p, --project-id string Project ID + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit beta sqlserverflex user](./stackit_beta_sqlserverflex_user.md) - Provides functionality for SQLServer Flex users + diff --git a/docs/stackit_mongodbflex_user_describe.md b/docs/stackit_mongodbflex_user_describe.md index 2295b1c84..1a0b65443 100644 --- a/docs/stackit_mongodbflex_user_describe.md +++ b/docs/stackit_mongodbflex_user_describe.md @@ -16,10 +16,10 @@ stackit mongodbflex user describe USER_ID [flags] ``` Get details of a MongoDB Flex user with ID "xxx" of instance with ID "yyy" - $ stackit mongodbflex user list xxx --instance-id yyy + $ stackit mongodbflex user describe xxx --instance-id yyy Get details of a MongoDB Flex user with ID "xxx" of instance with ID "yyy" in JSON format - $ stackit mongodbflex user list xxx --instance-id yyy --output-format json + $ stackit mongodbflex user describe xxx --instance-id yyy --output-format json ``` ### Options diff --git a/docs/stackit_postgresflex_user_describe.md b/docs/stackit_postgresflex_user_describe.md index dd1d74e8e..40e5a5bd9 100644 --- a/docs/stackit_postgresflex_user_describe.md +++ b/docs/stackit_postgresflex_user_describe.md @@ -16,10 +16,10 @@ stackit postgresflex user describe USER_ID [flags] ``` Get details of a PostgreSQL Flex user with ID "xxx" of instance with ID "yyy" - $ stackit postgresflex user list xxx --instance-id yyy + $ stackit postgresflex user describe xxx --instance-id yyy Get details of a PostgreSQL Flex user with ID "xxx" of instance with ID "yyy" in JSON format - $ stackit postgresflex user list xxx --instance-id yyy --output-format json + $ stackit postgresflex user describe xxx --instance-id yyy --output-format json ``` ### Options diff --git a/internal/cmd/beta/sqlserverflex/options/options.go b/internal/cmd/beta/sqlserverflex/options/options.go index 789af6036..1e585bb55 100644 --- a/internal/cmd/beta/sqlserverflex/options/options.go +++ b/internal/cmd/beta/sqlserverflex/options/options.go @@ -87,10 +87,10 @@ func NewCmd(p *print.Printer) *cobra.Command { `List SQL Server Flex available versions`, "$ stackit beta sqlserverflex options --versions"), examples.NewExample( - `List SQL Server Flex storage options for a given flavor. The flavor ID can be retrieved by running "$ stackit sqlserverflex options --flavors"`, + `List SQL Server Flex storage options for a given flavor. The flavor ID can be retrieved by running "$ stackit beta sqlserverflex options --flavors"`, "$ stackit beta sqlserverflex options --storages --flavor-id "), examples.NewExample( - `List SQL Server Flex user roles and database compatibilities for a given instance. The IDs of existing instances can be obtained by running "$ stackit sqlserverflex instance list"`, + `List SQL Server Flex user roles and database compatibilities for a given instance. The IDs of existing instances can be obtained by running "$ stackit beta sqlserverflex instance list"`, "$ stackit beta sqlserverflex options --user-roles --db-compatibilities --instance-id "), ), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/beta/sqlserverflex/sqlserverflex.go b/internal/cmd/beta/sqlserverflex/sqlserverflex.go index 2cd98a979..196d7a6e1 100644 --- a/internal/cmd/beta/sqlserverflex/sqlserverflex.go +++ b/internal/cmd/beta/sqlserverflex/sqlserverflex.go @@ -3,6 +3,7 @@ package sqlserverflex import ( "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/instance" "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/options" + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/user" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -25,4 +26,5 @@ func NewCmd(p *print.Printer) *cobra.Command { func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(instance.NewCmd(p)) cmd.AddCommand(options.NewCmd(p)) + cmd.AddCommand(user.NewCmd(p)) } diff --git a/internal/cmd/beta/sqlserverflex/user/create/create.go b/internal/cmd/beta/sqlserverflex/user/create/create.go new file mode 100644 index 000000000..88d352a54 --- /dev/null +++ b/internal/cmd/beta/sqlserverflex/user/create/create.go @@ -0,0 +1,194 @@ +package create + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sqlserverflex/client" + sqlserverflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sqlserverflex/utils" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" +) + +const ( + instanceIdFlag = "instance-id" + usernameFlag = "username" + databaseFlag = "database" + rolesFlag = "roles" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + Username *string + Database *string + Roles *[]string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Creates an SQLServer Flex user", + Long: fmt.Sprintf("%s\n%s\n%s\n%s\n%s", + "Creates an SQLServer Flex user for an instance.", + "The password is only visible upon creation and cannot be retrieved later.", + "Alternatively, you can reset the password and access the new one by running:", + " $ stackit beta sqlserverflex user reset-password USER_ID --instance-id INSTANCE_ID", + "Please refer to https://docs.stackit.cloud/stackit/en/creating-logins-and-users-in-sqlserver-flex-instances-210862358.html for additional information.", + ), + Example: examples.Build( + examples.NewExample( + `Create an SQLServer Flex user for instance with ID "xxx" and specify the username, role and database`, + "$ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles my-role --database my-database"), + examples.NewExample( + `Create an SQLServer Flex user for instance with ID "xxx", specifying multiple roles`, + `$ stackit beta sqlserverflex user create --instance-id xxx --username johndoe --roles "my-role-1,my-role-2`), + ), + Args: args.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + instanceLabel, err := sqlserverflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + p.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to create a user for instance %q?", instanceLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("create SQLServer Flex user: %w", err) + } + user := resp.Item + + return outputResult(p, model, instanceLabel, user) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the instance") + cmd.Flags().String(usernameFlag, "", "Username of the user") + cmd.Flags().String(databaseFlag, "", "Default database for the user") + cmd.Flags().StringSlice(rolesFlag, []string{}, "Roles of the user") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag, usernameFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command) (*inputModel, error) { + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + Username: flags.FlagToStringPointer(p, cmd, usernameFlag), + Database: flags.FlagToStringPointer(p, cmd, databaseFlag), + Roles: flags.FlagToStringSlicePointer(p, cmd, rolesFlag), + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sqlserverflex.APIClient) sqlserverflex.ApiCreateUserRequest { + req := apiClient.CreateUser(ctx, model.ProjectId, model.InstanceId) + + var roles []sqlserverflex.Role + if model.Roles != nil { + for _, r := range *model.Roles { + roles = append(roles, sqlserverflex.Role(r)) + } + } + + req = req.CreateUserPayload(sqlserverflex.CreateUserPayload{ + Username: model.Username, + Database: model.Database, + Roles: &roles, + }) + return req +} + +func outputResult(p *print.Printer, model *inputModel, instanceLabel string, user *sqlserverflex.User) error { + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(user, "", " ") + if err != nil { + return fmt.Errorf("marshal SQLServer Flex user: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(user, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal SQLServer Flex user: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + p.Outputf("Created user for instance %q. User ID: %s\n\n", instanceLabel, *user.Id) + p.Outputf("Username: %s\n", *user.Username) + p.Outputf("Password: %s\n", *user.Password) + if user.Roles != nil && len(*user.Roles) != 0 { + p.Outputf("Roles: %v\n", *user.Roles) + } + if user.Database != nil && *user.Database != "" { + p.Outputf("Database: %s\n", *user.Database) + } + if user.Host != nil && *user.Host != "" { + p.Outputf("Host: %s\n", *user.Host) + } + if user.Port != nil { + p.Outputf("Port: %d\n", *user.Port) + } + if user.Uri != nil && *user.Uri != "" { + p.Outputf("URI: %s\n", *user.Uri) + } + + return nil + } +} diff --git a/internal/cmd/beta/sqlserverflex/user/create/create_test.go b/internal/cmd/beta/sqlserverflex/user/create/create_test.go new file mode 100644 index 000000000..2f4cad3b0 --- /dev/null +++ b/internal/cmd/beta/sqlserverflex/user/create/create_test.go @@ -0,0 +1,239 @@ +package create + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sqlserverflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +func fixtureFlagValues(mods ...func(flagValues map[string]string)) map[string]string { + flagValues := map[string]string{ + projectIdFlag: testProjectId, + instanceIdFlag: testInstanceId, + usernameFlag: "johndoe", + databaseFlag: "default", + rolesFlag: "read", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: testInstanceId, + Username: utils.Ptr("johndoe"), + Database: utils.Ptr("default"), + Roles: utils.Ptr([]string{"read"}), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sqlserverflex.ApiCreateUserRequest)) sqlserverflex.ApiCreateUserRequest { + request := testClient.CreateUser(testCtx, testProjectId, testInstanceId) + request = request.CreateUserPayload(sqlserverflex.CreateUserPayload{ + Username: utils.Ptr("johndoe"), + Database: utils.Ptr("default"), + Roles: utils.Ptr([]sqlserverflex.Role{"read"}), + }) + + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no username specified", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, usernameFlag) + }), + isValid: false, + }, + { + description: "no database specified", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, databaseFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Database = nil + }), + }, + { + description: "no roles specified", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, rolesFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Roles = nil + }), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, projectIdFlag) + }), + isValid: false, + }, + { + description: "project id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "" + }), + isValid: false, + }, + { + description: "project id invalid 2", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[projectIdFlag] = "invalid-uuid" + }), + isValid: false, + }, + { + description: "instance id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, instanceIdFlag) + }), + isValid: false, + }, + { + description: "instance id invalid 1", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[instanceIdFlag] = "" + }), + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := &cobra.Command{} + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + configureFlags(cmd) + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + p := print.NewPrinter() + model, err := parseInput(p, cmd) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error parsing flags: %v", err) + } + + if !tt.isValid { + t.Fatalf("did not fail on invalid input") + } + diff := cmp.Diff(model, tt.expectedModel) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} + +func TestBuildRequest(t *testing.T) { + tests := []struct { + description string + model *inputModel + expectedRequest sqlserverflex.ApiCreateUserRequest + }{ + { + description: "base", + model: fixtureInputModel(), + expectedRequest: fixtureRequest(), + }, + { + description: "no username specified", + model: fixtureInputModel(func(model *inputModel) { + model.Username = nil + }), + expectedRequest: fixtureRequest().CreateUserPayload(sqlserverflex.CreateUserPayload{ + Database: utils.Ptr("default"), + Roles: utils.Ptr([]sqlserverflex.Role{"read"}), + }), + }, + } + + 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/beta/sqlserverflex/user/reset-password/reset_password.go b/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password.go new file mode 100644 index 000000000..009ec914c --- /dev/null +++ b/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password.go @@ -0,0 +1,162 @@ +package resetpassword + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/goccy/go-yaml" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/errors" + "github.com/stackitcloud/stackit-cli/internal/pkg/examples" + "github.com/stackitcloud/stackit-cli/internal/pkg/flags" + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/sqlserverflex/client" + sqlserverflexUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/sqlserverflex/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" +) + +const ( + userIdArg = "USER_ID" + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + UserId string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("reset-password %s", userIdArg), + Short: "Resets the password of an SQLServer Flex user", + Long: fmt.Sprintf("%s\ns%s", + "Resets the password of an SQLServer Flex user.", + "The new password is visible after resetting and cannot be retrieved later.", + ), + Example: examples.Build( + examples.NewExample( + `Reset the password of an SQLServer Flex user with ID "xxx" of instance with ID "yyy"`, + "$ stackit beta sqlserverflex user reset-password xxx --instance-id yyy"), + ), + Args: args.SingleArg(userIdArg, nil), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(p, cmd, args) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + instanceLabel, err := sqlserverflexUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + p.Debug(print.ErrorLevel, "get instance name: %v", err) + instanceLabel = model.InstanceId + } + + userLabel, err := sqlserverflexUtils.GetUserName(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) + if err != nil { + p.Debug(print.ErrorLevel, "get user name: %v", err) + userLabel = model.UserId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to reset the password of user %q of instance %q? (This cannot be undone)", userLabel, instanceLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req := buildRequest(ctx, model, apiClient) + user, err := req.Execute() + if err != nil { + return fmt.Errorf("reset SQLServer Flex user password: %w", err) + } + + return outputResult(p, model, userLabel, instanceLabel, user.Item) + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the instance") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + cobra.CheckErr(err) +} + +func parseInput(p *print.Printer, cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(p, cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + model := inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(p, cmd, instanceIdFlag), + UserId: userId, + } + + if p.IsVerbosityDebug() { + modelStr, err := print.BuildDebugStrFromInputModel(model) + if err != nil { + p.Debug(print.ErrorLevel, "convert model to string for debugging: %v", err) + } else { + p.Debug(print.DebugLevel, "parsed input values: %s", modelStr) + } + } + + return &model, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *sqlserverflex.APIClient) sqlserverflex.ApiResetUserRequest { + req := apiClient.ResetUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + return req +} + +func outputResult(p *print.Printer, model *inputModel, userLabel, instanceLabel string, user *sqlserverflex.User) error { + switch model.OutputFormat { + case print.JSONOutputFormat: + details, err := json.MarshalIndent(user, "", " ") + if err != nil { + return fmt.Errorf("marshal SQLServer Flex reset password: %w", err) + } + p.Outputln(string(details)) + + return nil + case print.YAMLOutputFormat: + details, err := yaml.MarshalWithOptions(user, yaml.IndentSequence(true)) + if err != nil { + return fmt.Errorf("marshal SQLServer Flex reset password: %w", err) + } + p.Outputln(string(details)) + + return nil + default: + p.Outputf("Reset password for user %q of instance %q\n\n", userLabel, instanceLabel) + p.Outputf("Username: %s\n", *user.Username) + p.Outputf("New password: %s\n", *user.Password) + if user.Uri != nil && *user.Uri != "" { + p.Outputf("New URI: %s\n", *user.Uri) + } + return nil + } +} diff --git a/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password_test.go b/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password_test.go new file mode 100644 index 000000000..b60011db4 --- /dev/null +++ b/internal/cmd/beta/sqlserverflex/user/reset-password/reset_password_test.go @@ -0,0 +1,233 @@ +package resetpassword + +import ( + "context" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stackitcloud/stackit-sdk-go/services/sqlserverflex" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &sqlserverflex.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() +var testUserId = uuid.NewString() + +func fixtureArgValues(mods ...func(argValues []string)) []string { + argValues := []string{ + testUserId, + } + 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, + Verbosity: globalflags.VerbosityDefault, + }, + InstanceId: testInstanceId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *sqlserverflex.ApiResetUserRequest)) sqlserverflex.ApiResetUserRequest { + request := testClient.ResetUser(testCtx, testProjectId, testInstanceId, testUserId) + 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: fixtureArgValues(), + 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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + p := print.NewPrinter() + cmd := NewCmd(p) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateArgs(tt.argValues) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating args: %v", err) + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(p, 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 sqlserverflex.ApiResetUserRequest + }{ + { + 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/beta/sqlserverflex/user/user.go b/internal/cmd/beta/sqlserverflex/user/user.go new file mode 100644 index 000000000..7b01d76fd --- /dev/null +++ b/internal/cmd/beta/sqlserverflex/user/user.go @@ -0,0 +1,28 @@ +package user + +import ( + "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/user/create" + resetpassword "github.com/stackitcloud/stackit-cli/internal/cmd/beta/sqlserverflex/user/reset-password" + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" +) + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "user", + Short: "Provides functionality for SQLServer Flex users", + Long: "Provides functionality for SQLServer Flex users.", + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(create.NewCmd(p)) + cmd.AddCommand(resetpassword.NewCmd(p)) +} diff --git a/internal/pkg/services/mongodbflex/utils/utils.go b/internal/pkg/services/mongodbflex/utils/utils.go index c582c8de8..ad2e07f9e 100644 --- a/internal/pkg/services/mongodbflex/utils/utils.go +++ b/internal/pkg/services/mongodbflex/utils/utils.go @@ -20,6 +20,13 @@ var instanceTypeToReplicas = map[string]int64{ "Sharded": 9, } +type MongoDBFlexClient interface { + ListVersionsExecute(ctx context.Context, projectId string) (*mongodbflex.ListVersionsResponse, error) + GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*mongodbflex.GetInstanceResponse, error) + GetUserExecute(ctx context.Context, projectId, instanceId, userId string) (*mongodbflex.GetUserResponse, error) + ListRestoreJobsExecute(ctx context.Context, projectId string, instanceId string) (*mongodbflex.ListRestoreJobsResponse, error) +} + func AvailableInstanceTypes() []string { instanceTypes := make([]string, len(instanceTypeToReplicas)) i := 0 @@ -115,13 +122,6 @@ func LoadFlavorId(cpu, ram int64, flavors *[]mongodbflex.HandlersInfraFlavor) (* } } -type MongoDBFlexClient interface { - ListVersionsExecute(ctx context.Context, projectId string) (*mongodbflex.ListVersionsResponse, error) - GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*mongodbflex.GetInstanceResponse, error) - GetUserExecute(ctx context.Context, projectId, instanceId, userId string) (*mongodbflex.GetUserResponse, error) - ListRestoreJobsExecute(ctx context.Context, projectId string, instanceId string) (*mongodbflex.ListRestoreJobsResponse, error) -} - func GetLatestMongoDBVersion(ctx context.Context, apiClient MongoDBFlexClient, projectId string) (string, error) { resp, err := apiClient.ListVersionsExecute(ctx, projectId) if err != nil { @@ -147,7 +147,7 @@ func GetLatestMongoDBVersion(ctx context.Context, apiClient MongoDBFlexClient, p func GetInstanceName(ctx context.Context, apiClient MongoDBFlexClient, projectId, instanceId string) (string, error) { resp, err := apiClient.GetInstanceExecute(ctx, projectId, instanceId) if err != nil { - return "", fmt.Errorf("get MongoDBFlex instance: %w", err) + return "", fmt.Errorf("get MongoDB Flex instance: %w", err) } return *resp.Item.Name, nil } @@ -155,7 +155,7 @@ func GetInstanceName(ctx context.Context, apiClient MongoDBFlexClient, projectId func GetUserName(ctx context.Context, apiClient MongoDBFlexClient, projectId, instanceId, userId string) (string, error) { resp, err := apiClient.GetUserExecute(ctx, projectId, instanceId, userId) if err != nil { - return "", fmt.Errorf("get MongoDBFlex user: %w", err) + return "", fmt.Errorf("get MongoDB Flex user: %w", err) } return *resp.Item.Username, nil } diff --git a/internal/pkg/services/postgresflex/utils/utils.go b/internal/pkg/services/postgresflex/utils/utils.go index 298df752e..66bc48474 100644 --- a/internal/pkg/services/postgresflex/utils/utils.go +++ b/internal/pkg/services/postgresflex/utils/utils.go @@ -18,6 +18,12 @@ var instanceTypeToReplicas = map[string]int64{ "Replica": 3, } +type PostgresFlexClient interface { + ListVersionsExecute(ctx context.Context, projectId string) (*postgresflex.ListVersionsResponse, error) + GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*postgresflex.InstanceResponse, error) + GetUserExecute(ctx context.Context, projectId, instanceId, userId string) (*postgresflex.GetUserResponse, error) +} + func AvailableInstanceTypes() []string { instanceTypes := make([]string, len(instanceTypeToReplicas)) i := 0 @@ -113,12 +119,6 @@ func LoadFlavorId(cpu, ram int64, flavors *[]postgresflex.Flavor) (*string, erro } } -type PostgresFlexClient interface { - ListVersionsExecute(ctx context.Context, projectId string) (*postgresflex.ListVersionsResponse, error) - GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*postgresflex.InstanceResponse, error) - GetUserExecute(ctx context.Context, projectId, instanceId, userId string) (*postgresflex.GetUserResponse, error) -} - func GetLatestPostgreSQLVersion(ctx context.Context, apiClient PostgresFlexClient, projectId string) (string, error) { resp, err := apiClient.ListVersionsExecute(ctx, projectId) if err != nil { diff --git a/internal/pkg/services/sqlserverflex/utils/utils.go b/internal/pkg/services/sqlserverflex/utils/utils.go index 2e74c879e..11a88fc77 100644 --- a/internal/pkg/services/sqlserverflex/utils/utils.go +++ b/internal/pkg/services/sqlserverflex/utils/utils.go @@ -17,6 +17,7 @@ const ( type SQLServerFlexClient interface { ListVersionsExecute(ctx context.Context, projectId string) (*sqlserverflex.ListVersionsResponse, error) GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*sqlserverflex.GetInstanceResponse, error) + GetUserExecute(ctx context.Context, projectId, instanceId, userId string) (*sqlserverflex.GetUserResponse, error) } func ValidateFlavorId(flavorId string, flavors *[]sqlserverflex.InstanceFlavorEntry) error { @@ -91,3 +92,11 @@ func GetInstanceName(ctx context.Context, apiClient SQLServerFlexClient, project } return *resp.Item.Name, nil } + +func GetUserName(ctx context.Context, apiClient SQLServerFlexClient, projectId, instanceId, userId string) (string, error) { + resp, err := apiClient.GetUserExecute(ctx, projectId, instanceId, userId) + if err != nil { + return "", fmt.Errorf("get SQLServer Flex user: %w", err) + } + return *resp.Item.Username, nil +} diff --git a/internal/pkg/services/sqlserverflex/utils/utils_test.go b/internal/pkg/services/sqlserverflex/utils/utils_test.go index c944fc810..8776baf4a 100644 --- a/internal/pkg/services/sqlserverflex/utils/utils_test.go +++ b/internal/pkg/services/sqlserverflex/utils/utils_test.go @@ -15,6 +15,7 @@ import ( var ( testProjectId = uuid.NewString() testInstanceId = uuid.NewString() + testUserId = uuid.NewString() ) const ( @@ -421,3 +422,53 @@ func TestGetInstanceName(t *testing.T) { }) } } + +func TestGetUserName(t *testing.T) { + tests := []struct { + description string + getUserFails bool + getUserResp *sqlserverflex.GetUserResponse + isValid bool + expectedOutput string + }{ + { + description: "base", + getUserResp: &sqlserverflex.GetUserResponse{ + Item: &sqlserverflex.InstanceResponseUser{ + Username: utils.Ptr(testUserName), + }, + }, + isValid: true, + expectedOutput: testUserName, + }, + { + description: "get user fails", + getUserFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &sqlServerFlexClientMocked{ + getUserFails: tt.getUserFails, + getUserResp: tt.getUserResp, + } + + output, err := GetUserName(context.Background(), client, testProjectId, testInstanceId, testUserId) + + if tt.isValid && err != nil { + t.Errorf("failed on valid input") + } + if !tt.isValid && err == nil { + t.Errorf("did not fail on invalid input") + } + if !tt.isValid { + return + } + if output != tt.expectedOutput { + t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + } + }) + } +}