From 37d7f24abf1e6dabf6fbab2d8c00df4decadd08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Wed, 6 Mar 2024 14:23:29 +0000 Subject: [PATCH 1/9] Onboard Secrets Manager (user) : add delete command --- .../cmd/secrets-manager/user/delete/delete.go | 119 ++++++++++++++++++ internal/cmd/secrets-manager/user/user.go | 2 + .../services/secrets-manager/utils/utils.go | 9 ++ .../secrets-manager/utils/utils_test.go | 62 +++++++++ 4 files changed, 192 insertions(+) create mode 100644 internal/cmd/secrets-manager/user/delete/delete.go diff --git a/internal/cmd/secrets-manager/user/delete/delete.go b/internal/cmd/secrets-manager/user/delete/delete.go new file mode 100644 index 000000000..a0224a7bc --- /dev/null +++ b/internal/cmd/secrets-manager/user/delete/delete.go @@ -0,0 +1,119 @@ +package delete + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "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/secrets-manager/client" + secretsManagerUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/secrets-manager/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" +) + +const ( + userIdArg = "USER_ID" + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + UserId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("delete %s", userIdArg), + Short: "Deletes a Secrets Manager user", + Long: fmt.Sprintf("%s\n%s", + "Deletes a Secrets Manager user by ID. You can get the IDs of users for an instance by running:", + " $ stackit mongodbflex user list --instance-id ", + ), + Example: examples.Build( + examples.NewExample( + `Delete a Secrets Manager user with ID "xxx" for instance with ID "yyy"`, + "$ stackit mongodbflex user delete xxx --instance-id yyy"), + ), + Args: args.SingleArg(userIdArg, 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 := secretsManagerUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + userLabel, userDescription, err := secretsManagerUtils.GetUserDetails(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) + if err != nil { + userLabel = model.UserId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to delete user %q (%s) of instance %q? (This cannot be undone)", userLabel, userDescription, 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("delete Secrets Manager user: %w", err) + } + + cmd.Printf("Deleted user %q of instance %q\n", userLabel, instanceLabel) + return nil + }, + } + 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) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + UserId: userId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) secretsmanager.ApiDeleteUserRequest { + req := apiClient.DeleteUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + return req +} diff --git a/internal/cmd/secrets-manager/user/user.go b/internal/cmd/secrets-manager/user/user.go index 6c4d661bb..a1b868dfa 100644 --- a/internal/cmd/secrets-manager/user/user.go +++ b/internal/cmd/secrets-manager/user/user.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/create" + "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/list" ) @@ -24,4 +25,5 @@ func NewCmd() *cobra.Command { func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(list.NewCmd()) cmd.AddCommand(create.NewCmd()) + cmd.AddCommand(delete.NewCmd()) } diff --git a/internal/pkg/services/secrets-manager/utils/utils.go b/internal/pkg/services/secrets-manager/utils/utils.go index 97aa1f6c1..7927de71c 100644 --- a/internal/pkg/services/secrets-manager/utils/utils.go +++ b/internal/pkg/services/secrets-manager/utils/utils.go @@ -9,6 +9,7 @@ import ( type SecretsManagerClient interface { GetInstanceExecute(ctx context.Context, projectId, instanceId string) (*secretsmanager.Instance, error) + GetUserExecute(ctx context.Context, projectId string, instanceId string, userId string) (*secretsmanager.User, error) } func GetInstanceName(ctx context.Context, apiClient SecretsManagerClient, projectId, instanceId string) (string, error) { @@ -18,3 +19,11 @@ func GetInstanceName(ctx context.Context, apiClient SecretsManagerClient, projec } return *resp.Name, nil } + +func GetUserDetails(ctx context.Context, apiClient SecretsManagerClient, projectId, instanceId, userId string) (username, description string, err error) { + resp, err := apiClient.GetUserExecute(ctx, projectId, instanceId, userId) + if err != nil { + return "", "", fmt.Errorf("get Secrets Manager user: %w", err) + } + return *resp.Username, *resp.Description, nil +} diff --git a/internal/pkg/services/secrets-manager/utils/utils_test.go b/internal/pkg/services/secrets-manager/utils/utils_test.go index 6ae75fbdc..497331d14 100644 --- a/internal/pkg/services/secrets-manager/utils/utils_test.go +++ b/internal/pkg/services/secrets-manager/utils/utils_test.go @@ -13,16 +13,20 @@ import ( var ( testProjectId = uuid.NewString() testInstanceId = uuid.NewString() + testUserId = uuid.NewString() ) const ( testInstanceName = "instance" testUserName = "user" + testDescription = "sample description" ) type secretsManagerClientMocked struct { getInstanceFails bool getInstanceResp *secretsmanager.Instance + getUserFails bool + getUserResp *secretsmanager.User } func (s *secretsManagerClientMocked) GetInstanceExecute(_ context.Context, _, _ string) (*secretsmanager.Instance, error) { @@ -32,6 +36,13 @@ func (s *secretsManagerClientMocked) GetInstanceExecute(_ context.Context, _, _ return s.getInstanceResp, nil } +func (s *secretsManagerClientMocked) GetUserExecute(_ context.Context, _, _, _ string) (*secretsmanager.User, error) { + if s.getUserFails { + return nil, fmt.Errorf("could not get instance") + } + return s.getUserResp, nil +} + func TestGetInstanceName(t *testing.T) { tests := []struct { description string @@ -79,3 +90,54 @@ func TestGetInstanceName(t *testing.T) { }) } } + +func TestGetUserDetails(t *testing.T) { + tests := []struct { + description string + getUserFails bool + GetUserResp *secretsmanager.User + isValid bool + expectedOutput [2]string + }{ + { + description: "base", + GetUserResp: &secretsmanager.User{ + Username: utils.Ptr(testUserName), + Description: utils.Ptr(testDescription), + }, + isValid: true, + expectedOutput: [2]string{testUserName, testDescription}, + }, + { + description: "get instance fails", + getUserFails: true, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &secretsManagerClientMocked{ + getUserFails: tt.getUserFails, + getUserResp: tt.GetUserResp, + } + + username, description, err := GetUserDetails(context.Background(), client, testProjectId, testInstanceId, testUserId) + + output := [2]string{username, description} + + 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) + } + }) + } +} From aa30670ab6262228bf4ae6440ca574a28c5f0046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Wed, 6 Mar 2024 14:42:48 +0000 Subject: [PATCH 2/9] delete command: testing and docs --- docs/stackit_secrets-manager_user.md | 1 + docs/stackit_secrets-manager_user_create.md | 15 +- docs/stackit_secrets-manager_user_delete.md | 40 +++ .../cmd/secrets-manager/user/delete/delete.go | 4 +- .../user/delete/delete_test.go | 230 ++++++++++++++++++ 5 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 docs/stackit_secrets-manager_user_delete.md create mode 100644 internal/cmd/secrets-manager/user/delete/delete_test.go diff --git a/docs/stackit_secrets-manager_user.md b/docs/stackit_secrets-manager_user.md index 33fc70ac7..af3b2159a 100644 --- a/docs/stackit_secrets-manager_user.md +++ b/docs/stackit_secrets-manager_user.md @@ -29,5 +29,6 @@ stackit secrets-manager user [flags] * [stackit secrets-manager](./stackit_secrets-manager.md) - Provides functionality for Secrets Manager * [stackit secrets-manager user create](./stackit_secrets-manager_user_create.md) - Creates a Secrets Manager user +* [stackit secrets-manager user delete](./stackit_secrets-manager_user_delete.md) - Deletes a Secrets Manager user * [stackit secrets-manager user list](./stackit_secrets-manager_user_list.md) - Lists all Secrets Manager users diff --git a/docs/stackit_secrets-manager_user_create.md b/docs/stackit_secrets-manager_user_create.md index aed85ad8d..4a671d95f 100644 --- a/docs/stackit_secrets-manager_user_create.md +++ b/docs/stackit_secrets-manager_user_create.md @@ -4,7 +4,9 @@ Creates a Secrets Manager user ### Synopsis -Creates a user for a Secrets Manager instance with generated username and password +Creates a Secrets Manager user. +The username and password are auto-generated and provided upon creation. +A description can be provided to identify a user. ``` stackit secrets-manager user create [flags] @@ -13,17 +15,14 @@ stackit secrets-manager user create [flags] ### Examples ``` - Create a Secrets Manager user for instance with ID "xxx" - $ stackit mongodbflex user create --instance-id xxx - Create a Secrets Manager user for instance with ID "xxx" and description "yyy" - $ stackit mongodbflex user create --instance-id xxx --description yyy + $ stackit secrets-manager user create --instance-id xxx --description yyy - Create a Secrets Manager user for instance with ID "xxx" and doesn't display the password - $ stackit mongodbflex user create --instance-id xxx --hide-password + Create a Secrets Manager user for instance with ID "xxx" and hides the generated password + $ stackit secrets-manager user create --instance-id xxx --hide-password Create a Secrets Manager user for instance with ID "xxx" with write access to the secrets engine - $ stackit mongodbflex user create --instance-id xxx --write + $ stackit secrets-manager user create --instance-id xxx --write ``` ### Options diff --git a/docs/stackit_secrets-manager_user_delete.md b/docs/stackit_secrets-manager_user_delete.md new file mode 100644 index 000000000..fa28e1690 --- /dev/null +++ b/docs/stackit_secrets-manager_user_delete.md @@ -0,0 +1,40 @@ +## stackit secrets-manager user delete + +Deletes a Secrets Manager user + +### Synopsis + +Deletes a Secrets Manager user by ID. You can get the IDs of users for an instance by running: + $ stackit secrets-manager user list --instance-id + +``` +stackit secrets-manager user delete USER_ID [flags] +``` + +### Examples + +``` + Delete a Secrets Manager user with ID "xxx" for instance with ID "yyy" + $ stackit secrets-manager user delete xxx --instance-id yyy +``` + +### Options + +``` + -h, --help Help for "stackit secrets-manager user delete" + --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 secrets-manager user](./stackit_secrets-manager_user.md) - Provides functionality for Secrets Manager users + diff --git a/internal/cmd/secrets-manager/user/delete/delete.go b/internal/cmd/secrets-manager/user/delete/delete.go index a0224a7bc..c6e8ecdd8 100644 --- a/internal/cmd/secrets-manager/user/delete/delete.go +++ b/internal/cmd/secrets-manager/user/delete/delete.go @@ -37,12 +37,12 @@ func NewCmd() *cobra.Command { Short: "Deletes a Secrets Manager user", Long: fmt.Sprintf("%s\n%s", "Deletes a Secrets Manager user by ID. You can get the IDs of users for an instance by running:", - " $ stackit mongodbflex user list --instance-id ", + " $ stackit secrets-manager user list --instance-id ", ), Example: examples.Build( examples.NewExample( `Delete a Secrets Manager user with ID "xxx" for instance with ID "yyy"`, - "$ stackit mongodbflex user delete xxx --instance-id yyy"), + "$ stackit secrets-manager user delete xxx --instance-id yyy"), ), Args: args.SingleArg(userIdArg, utils.ValidateUUID), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/secrets-manager/user/delete/delete_test.go b/internal/cmd/secrets-manager/user/delete/delete_test.go new file mode 100644 index 000000000..2fec10573 --- /dev/null +++ b/internal/cmd/secrets-manager/user/delete/delete_test.go @@ -0,0 +1,230 @@ +package delete + +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/secretsmanager" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &secretsmanager.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, + }, + InstanceId: testInstanceId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *secretsmanager.ApiDeleteUserRequest)) secretsmanager.ApiDeleteUserRequest { + request := testClient.DeleteUser(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: []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 invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "instance id invalid 2", + argValues: []string{"invalid-uuid"}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "user id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "user id invalid 2", + argValues: []string{"invalid-uuid"}, + 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 secretsmanager.ApiDeleteUserRequest + }{ + { + 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 c47b7854bf068b86ccd7667537a9f3308f34ebb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Wed, 6 Mar 2024 17:01:02 +0000 Subject: [PATCH 3/9] Onboard Secrets Manager (user): add describe command --- .../secrets-manager/user/describe/describe.go | 134 ++++++++++ .../user/describe/describe_test.go | 242 ++++++++++++++++++ internal/cmd/secrets-manager/user/user.go | 2 + 3 files changed, 378 insertions(+) create mode 100644 internal/cmd/secrets-manager/user/describe/describe.go create mode 100644 internal/cmd/secrets-manager/user/describe/describe_test.go diff --git a/internal/cmd/secrets-manager/user/describe/describe.go b/internal/cmd/secrets-manager/user/describe/describe.go new file mode 100644 index 000000000..0dcd70b82 --- /dev/null +++ b/internal/cmd/secrets-manager/user/describe/describe.go @@ -0,0 +1,134 @@ +package describe + +import ( + "context" + "encoding/json" + "fmt" + + "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/secrets-manager/client" + "github.com/stackitcloud/stackit-cli/internal/pkg/tables" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" +) + +const ( + userIdArg = "USER_ID" + + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + UserId string +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("describe %s", userIdArg), + Short: "Shows details of a Secrets Manager user", + Long: "Shows details of a Secrets Manager user.", + Example: examples.Build( + examples.NewExample( + `Get details of a Secrets Manager user with ID "xxx" of instance with ID "yyy"`, + "$ stackit secrets-manager user list xxx --instance-id yyy"), + examples.NewExample( + `Get details of a Secrets Manager user with ID "xxx" of instance with ID "yyy" in table format`, + "$ stackit secrets-manager user list xxx --instance-id yyy --output-format pretty"), + ), + Args: args.SingleArg(userIdArg, 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 + } + + // Call API + req := buildRequest(ctx, model, apiClient) + resp, err := req.Execute() + if err != nil { + return fmt.Errorf("get Secrets Manager user: %w", err) + } + + return outputResult(cmd, model.OutputFormat, *resp) + }, + } + + 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(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + UserId: userId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) secretsmanager.ApiGetUserRequest { + req := apiClient.GetUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + return req +} + +func outputResult(cmd *cobra.Command, outputFormat string, user secretsmanager.User) error { + switch outputFormat { + case globalflags.PrettyOutputFormat: + table := tables.NewTable() + table.AddRow("ID", *user.Id) + table.AddSeparator() + table.AddRow("USERNAME", *user.Username) + table.AddSeparator() + table.AddRow("DESCRIPTION", *user.Description) + if *user.Password != "" { + table.AddSeparator() + table.AddRow("PASSWORD", *user.Password) + } + table.AddSeparator() + table.AddRow("WRITE ACCESS", *user.Write) + + err := table.Display(cmd) + if err != nil { + return fmt.Errorf("render table: %w", err) + } + + return nil + default: + details, err := json.MarshalIndent(user, "", " ") + if err != nil { + return fmt.Errorf("marshal Secrets Manager user: %w", err) + } + cmd.Println(string(details)) + + return nil + } +} diff --git a/internal/cmd/secrets-manager/user/describe/describe_test.go b/internal/cmd/secrets-manager/user/describe/describe_test.go new file mode 100644 index 000000000..226967fe5 --- /dev/null +++ b/internal/cmd/secrets-manager/user/describe/describe_test.go @@ -0,0 +1,242 @@ +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/secretsmanager" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &secretsmanager.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, + }, + InstanceId: testInstanceId, + UserId: testUserId, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *secretsmanager.ApiGetUserRequest)) secretsmanager.ApiGetUserRequest { + request := testClient.GetUser(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, + }, + { + description: "user id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "user id invalid 2", + argValues: []string{"invalid-uuid"}, + 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 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 secretsmanager.ApiGetUserRequest + }{ + { + 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/secrets-manager/user/user.go b/internal/cmd/secrets-manager/user/user.go index a1b868dfa..2eef02e74 100644 --- a/internal/cmd/secrets-manager/user/user.go +++ b/internal/cmd/secrets-manager/user/user.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/create" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/delete" + "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/list" ) @@ -26,4 +27,5 @@ func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(list.NewCmd()) cmd.AddCommand(create.NewCmd()) cmd.AddCommand(delete.NewCmd()) + cmd.AddCommand(describe.NewCmd()) } From 1d6547427ffa3ac80392ce469b214e1db31436eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 7 Mar 2024 11:41:30 +0000 Subject: [PATCH 4/9] Onboard Secrets Manager (user): add update command --- docs/stackit_secrets-manager_user.md | 2 + docs/stackit_secrets-manager_user_describe.md | 42 +++ docs/stackit_secrets-manager_user_update.md | 44 +++ .../cmd/secrets-manager/user/update/update.go | 134 +++++++++ .../user/update/update_test.go | 272 ++++++++++++++++++ internal/cmd/secrets-manager/user/user.go | 2 + 6 files changed, 496 insertions(+) create mode 100644 docs/stackit_secrets-manager_user_describe.md create mode 100644 docs/stackit_secrets-manager_user_update.md create mode 100644 internal/cmd/secrets-manager/user/update/update.go create mode 100644 internal/cmd/secrets-manager/user/update/update_test.go diff --git a/docs/stackit_secrets-manager_user.md b/docs/stackit_secrets-manager_user.md index af3b2159a..606af1500 100644 --- a/docs/stackit_secrets-manager_user.md +++ b/docs/stackit_secrets-manager_user.md @@ -30,5 +30,7 @@ stackit secrets-manager user [flags] * [stackit secrets-manager](./stackit_secrets-manager.md) - Provides functionality for Secrets Manager * [stackit secrets-manager user create](./stackit_secrets-manager_user_create.md) - Creates a Secrets Manager user * [stackit secrets-manager user delete](./stackit_secrets-manager_user_delete.md) - Deletes a Secrets Manager user +* [stackit secrets-manager user describe](./stackit_secrets-manager_user_describe.md) - Shows details of a Secrets Manager user * [stackit secrets-manager user list](./stackit_secrets-manager_user_list.md) - Lists all Secrets Manager users +* [stackit secrets-manager user update](./stackit_secrets-manager_user_update.md) - Updates the write privileges Secrets Manager user diff --git a/docs/stackit_secrets-manager_user_describe.md b/docs/stackit_secrets-manager_user_describe.md new file mode 100644 index 000000000..64e48b87c --- /dev/null +++ b/docs/stackit_secrets-manager_user_describe.md @@ -0,0 +1,42 @@ +## stackit secrets-manager user describe + +Shows details of a Secrets Manager user + +### Synopsis + +Shows details of a Secrets Manager user. + +``` +stackit secrets-manager user describe USER_ID [flags] +``` + +### Examples + +``` + Get details of a Secrets Manager user with ID "xxx" of instance with ID "yyy" + $ stackit secrets-manager user list xxx --instance-id yyy + + Get details of a Secrets Manager user with ID "xxx" of instance with ID "yyy" in table format + $ stackit secrets-manager user list xxx --instance-id yyy --output-format pretty +``` + +### Options + +``` + -h, --help Help for "stackit secrets-manager user describe" + --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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit secrets-manager user](./stackit_secrets-manager_user.md) - Provides functionality for Secrets Manager users + diff --git a/docs/stackit_secrets-manager_user_update.md b/docs/stackit_secrets-manager_user_update.md new file mode 100644 index 000000000..6ff1c2afc --- /dev/null +++ b/docs/stackit_secrets-manager_user_update.md @@ -0,0 +1,44 @@ +## stackit secrets-manager user update + +Updates the write privileges Secrets Manager user + +### Synopsis + +Updates the write privileges Secrets Manager user. + +``` +stackit secrets-manager user update USER_ID [flags] +``` + +### Examples + +``` + Enable write access of a Secrets Manager user with ID "xxx" of instance with ID "yyy" + $ stackit secrets-manager user update xxx --instance-id yyy --enable-write + + Disable write access of a Secrets Manager user with ID "xxx" of instance with ID "yyy" + $ stackit secrets-manager user update xxx --instance-id yyy --disable-write +``` + +### Options + +``` + --disable-write Set the user to have read-only access to the secrets engine. + --enable-write Set the user to have write access to the secrets engine. + -h, --help Help for "stackit secrets-manager user update" + --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"] + -p, --project-id string Project ID +``` + +### SEE ALSO + +* [stackit secrets-manager user](./stackit_secrets-manager_user.md) - Provides functionality for Secrets Manager users + diff --git a/internal/cmd/secrets-manager/user/update/update.go b/internal/cmd/secrets-manager/user/update/update.go new file mode 100644 index 000000000..2208a5cee --- /dev/null +++ b/internal/cmd/secrets-manager/user/update/update.go @@ -0,0 +1,134 @@ +package update + +import ( + "context" + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/pkg/args" + "github.com/stackitcloud/stackit-cli/internal/pkg/confirm" + "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/secrets-manager/client" + secretsManagerUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/secrets-manager/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/secretsmanager" +) + +const ( + userIdArg = "USER_ID" + + instanceIdFlag = "instance-id" + enableWriteFlag = "enable-write" + disableWriteFlag = "disable-write" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + + InstanceId string + UserId string + Write *bool +} + +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: fmt.Sprintf("update %s", userIdArg), + Short: "Updates the write privileges Secrets Manager user", + Long: "Updates the write privileges Secrets Manager user.", + Example: examples.Build( + examples.NewExample( + `Enable write access of a Secrets Manager user with ID "xxx" of instance with ID "yyy"`, + "$ stackit secrets-manager user update xxx --instance-id yyy --enable-write"), + examples.NewExample( + `Disable write access of a Secrets Manager user with ID "xxx" of instance with ID "yyy"`, + "$ stackit secrets-manager user update xxx --instance-id yyy --disable-write"), + ), + Args: args.SingleArg(userIdArg, 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 := secretsManagerUtils.GetInstanceName(ctx, apiClient, model.ProjectId, model.InstanceId) + if err != nil { + instanceLabel = model.InstanceId + } + + userLabel, userDescription, err := secretsManagerUtils.GetUserDetails(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) + if err != nil { + userLabel = model.UserId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to update user %q (%s) of instance %q?", userLabel, userDescription, 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 Secrets Manager user: %w", err) + } + + cmd.Printf("Updated user %q of instance %q\n", userLabel, instanceLabel) + return nil + }, + } + + configureFlags(cmd) + return cmd +} + +func configureFlags(cmd *cobra.Command) { + cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the instance") + cmd.Flags().Bool(enableWriteFlag, false, "Set the user to have write access to the secrets engine.") + cmd.Flags().Bool(disableWriteFlag, false, "Set the user to have read-only access to the secrets engine.") + + err := flags.MarkFlagsRequired(cmd, instanceIdFlag) + + cmd.MarkFlagsMutuallyExclusive(enableWriteFlag, disableWriteFlag) + cmd.MarkFlagsOneRequired(enableWriteFlag, disableWriteFlag) + cobra.CheckErr(err) +} + +func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { + userId := inputArgs[0] + + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + enableWrite := flags.FlagToBoolValue(cmd, enableWriteFlag) + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + Write: utils.Ptr(enableWrite), + UserId: userId, + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) secretsmanager.ApiUpdateUserRequest { + req := apiClient.UpdateUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + req = req.UpdateUserPayload(secretsmanager.UpdateUserPayload{ + Write: model.Write, + }) + return req +} diff --git a/internal/cmd/secrets-manager/user/update/update_test.go b/internal/cmd/secrets-manager/user/update/update_test.go new file mode 100644 index 000000000..91d85e523 --- /dev/null +++ b/internal/cmd/secrets-manager/user/update/update_test.go @@ -0,0 +1,272 @@ +package update + +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/secretsmanager" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &secretsmanager.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, + enableWriteFlag: "true", + } + for _, mod := range mods { + mod(flagValues) + } + return flagValues +} + +func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { + model := &inputModel{ + GlobalFlagModel: &globalflags.GlobalFlagModel{ + ProjectId: testProjectId, + }, + InstanceId: testInstanceId, + UserId: testUserId, + Write: utils.Ptr(true), + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureRequest(mods ...func(request *secretsmanager.ApiUpdateUserRequest)) secretsmanager.ApiUpdateUserRequest { + request := testClient.UpdateUser(testCtx, testProjectId, testInstanceId, testUserId) + request = request.UpdateUserPayload(secretsmanager.UpdateUserPayload{ + Write: utils.Ptr(true), + }) + 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: "disable write access", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[disableWriteFlag] = "true" + delete(flagValues, enableWriteFlag) + }), + isValid: true, + expectedModel: fixtureInputModel(func(model *inputModel) { + model.Write = utils.Ptr(false) + }), + }, + { + description: "neither write flag provided", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, enableWriteFlag) + delete(flagValues, disableWriteFlag) + }), + isValid: false, + }, + { + description: "both flags provided", + argValues: fixtureArgValues(), + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + flagValues[disableWriteFlag] = "true" + }), + 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: "user id invalid 1", + argValues: []string{""}, + flagValues: fixtureFlagValues(), + isValid: false, + }, + { + description: "user id invalid 2", + argValues: []string{"invalid-uuid"}, + 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) + } + + err = cmd.ValidateFlagGroups() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flag groups: %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 secretsmanager.ApiUpdateUserRequest + }{ + { + 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/secrets-manager/user/user.go b/internal/cmd/secrets-manager/user/user.go index 2eef02e74..76379621b 100644 --- a/internal/cmd/secrets-manager/user/user.go +++ b/internal/cmd/secrets-manager/user/user.go @@ -9,6 +9,7 @@ import ( "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/delete" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/describe" "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/list" + "github.com/stackitcloud/stackit-cli/internal/cmd/secrets-manager/user/update" ) func NewCmd() *cobra.Command { @@ -28,4 +29,5 @@ func addSubcommands(cmd *cobra.Command) { cmd.AddCommand(create.NewCmd()) cmd.AddCommand(delete.NewCmd()) cmd.AddCommand(describe.NewCmd()) + cmd.AddCommand(update.NewCmd()) } From 4d0f89687f93152719da8a676d2852ba45bcce8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 7 Mar 2024 12:01:57 +0000 Subject: [PATCH 5/9] update command: update logic --- .../cmd/secrets-manager/user/update/update.go | 15 ++++++++------- .../secrets-manager/user/update/update_test.go | 10 ++++++---- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/internal/cmd/secrets-manager/user/update/update.go b/internal/cmd/secrets-manager/user/update/update.go index 2208a5cee..a1d9ed97a 100644 --- a/internal/cmd/secrets-manager/user/update/update.go +++ b/internal/cmd/secrets-manager/user/update/update.go @@ -29,9 +29,10 @@ const ( type inputModel struct { *globalflags.GlobalFlagModel - InstanceId string - UserId string - Write *bool + InstanceId string + UserId string + EnableWrite *bool + DisableWrite *bool } func NewCmd() *cobra.Command { @@ -115,20 +116,20 @@ func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { return nil, &errors.ProjectIdError{} } - enableWrite := flags.FlagToBoolValue(cmd, enableWriteFlag) - return &inputModel{ GlobalFlagModel: globalFlags, InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), - Write: utils.Ptr(enableWrite), + EnableWrite: utils.Ptr(flags.FlagToBoolValue(cmd, enableWriteFlag)), + DisableWrite: utils.Ptr(flags.FlagToBoolValue(cmd, disableWriteFlag)), UserId: userId, }, nil } func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) secretsmanager.ApiUpdateUserRequest { req := apiClient.UpdateUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + req = req.UpdateUserPayload(secretsmanager.UpdateUserPayload{ - Write: model.Write, + Write: model.EnableWrite, }) return req } diff --git a/internal/cmd/secrets-manager/user/update/update_test.go b/internal/cmd/secrets-manager/user/update/update_test.go index 91d85e523..b9cd9a7fe 100644 --- a/internal/cmd/secrets-manager/user/update/update_test.go +++ b/internal/cmd/secrets-manager/user/update/update_test.go @@ -50,9 +50,10 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { GlobalFlagModel: &globalflags.GlobalFlagModel{ ProjectId: testProjectId, }, - InstanceId: testInstanceId, - UserId: testUserId, - Write: utils.Ptr(true), + InstanceId: testInstanceId, + UserId: testUserId, + EnableWrite: utils.Ptr(true), + DisableWrite: utils.Ptr(false), } for _, mod := range mods { mod(model) @@ -108,7 +109,8 @@ func TestParseInput(t *testing.T) { }), isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { - model.Write = utils.Ptr(false) + model.EnableWrite = utils.Ptr(false) + model.DisableWrite = utils.Ptr(true) }), }, { From 17f9d120ba81fac00ad95c48b94b17be9012f1b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 7 Mar 2024 13:08:49 +0000 Subject: [PATCH 6/9] fix printing formatting --- internal/cmd/secrets-manager/user/delete/delete.go | 2 +- internal/cmd/secrets-manager/user/update/update.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmd/secrets-manager/user/delete/delete.go b/internal/cmd/secrets-manager/user/delete/delete.go index c6e8ecdd8..ff3423726 100644 --- a/internal/cmd/secrets-manager/user/delete/delete.go +++ b/internal/cmd/secrets-manager/user/delete/delete.go @@ -69,7 +69,7 @@ func NewCmd() *cobra.Command { } if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to delete user %q (%s) of instance %q? (This cannot be undone)", userLabel, userDescription, instanceLabel) + prompt := fmt.Sprintf("Are you sure you want to delete user %q (%q) of instance %q? (This cannot be undone)", userLabel, userDescription, instanceLabel) err = confirm.PromptForConfirmation(cmd, prompt) if err != nil { return err diff --git a/internal/cmd/secrets-manager/user/update/update.go b/internal/cmd/secrets-manager/user/update/update.go index a1d9ed97a..2aeaba9d3 100644 --- a/internal/cmd/secrets-manager/user/update/update.go +++ b/internal/cmd/secrets-manager/user/update/update.go @@ -73,7 +73,7 @@ func NewCmd() *cobra.Command { } if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update user %q (%s) of instance %q?", userLabel, userDescription, instanceLabel) + prompt := fmt.Sprintf("Are you sure you want to update user %q (%q) of instance %q?", userLabel, userDescription, instanceLabel) err = confirm.PromptForConfirmation(cmd, prompt) if err != nil { return err From 6c931dcf323030d766ae2b7acdfcaa935314dc7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 7 Mar 2024 15:02:15 +0000 Subject: [PATCH 7/9] Addressed comments --- docs/stackit_secrets-manager_user_update.md | 4 +-- .../secrets-manager/user/describe/describe.go | 2 +- .../cmd/secrets-manager/user/update/update.go | 34 +++++++++++++------ .../user/update/update_test.go | 4 +-- .../secrets-manager/utils/utils_test.go | 29 +++++++++------- 5 files changed, 45 insertions(+), 28 deletions(-) diff --git a/docs/stackit_secrets-manager_user_update.md b/docs/stackit_secrets-manager_user_update.md index 6ff1c2afc..274d6e7c6 100644 --- a/docs/stackit_secrets-manager_user_update.md +++ b/docs/stackit_secrets-manager_user_update.md @@ -23,8 +23,8 @@ stackit secrets-manager user update USER_ID [flags] ### Options ``` - --disable-write Set the user to have read-only access to the secrets engine. - --enable-write Set the user to have write access to the secrets engine. + --disable-write Set the user to have read-only access. + --enable-write Set the user to have write access. -h, --help Help for "stackit secrets-manager user update" --instance-id string ID of the instance ``` diff --git a/internal/cmd/secrets-manager/user/describe/describe.go b/internal/cmd/secrets-manager/user/describe/describe.go index 0dcd70b82..50845ae51 100644 --- a/internal/cmd/secrets-manager/user/describe/describe.go +++ b/internal/cmd/secrets-manager/user/describe/describe.go @@ -109,7 +109,7 @@ func outputResult(cmd *cobra.Command, outputFormat string, user secretsmanager.U table.AddRow("USERNAME", *user.Username) table.AddSeparator() table.AddRow("DESCRIPTION", *user.Description) - if *user.Password != "" { + if user.Password != nil && *user.Password != "" { table.AddSeparator() table.AddRow("PASSWORD", *user.Password) } diff --git a/internal/cmd/secrets-manager/user/update/update.go b/internal/cmd/secrets-manager/user/update/update.go index 2aeaba9d3..724cb7d0e 100644 --- a/internal/cmd/secrets-manager/user/update/update.go +++ b/internal/cmd/secrets-manager/user/update/update.go @@ -67,13 +67,17 @@ func NewCmd() *cobra.Command { instanceLabel = model.InstanceId } - userLabel, userDescription, err := secretsManagerUtils.GetUserDetails(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) + var userLabel string + + userName, userDescription, err := secretsManagerUtils.GetUserDetails(ctx, apiClient, model.ProjectId, model.InstanceId, model.UserId) if err != nil { - userLabel = model.UserId + userLabel = fmt.Sprintf("%q", model.UserId) + } else { + userLabel = fmt.Sprintf("%q (%q)", userName, userDescription) } if !model.AssumeYes { - prompt := fmt.Sprintf("Are you sure you want to update user %q (%q) of instance %q?", userLabel, userDescription, instanceLabel) + prompt := fmt.Sprintf("Are you sure you want to update user %s of instance %q?", userLabel, instanceLabel) err = confirm.PromptForConfirmation(cmd, prompt) if err != nil { return err @@ -87,7 +91,7 @@ func NewCmd() *cobra.Command { return fmt.Errorf("update Secrets Manager user: %w", err) } - cmd.Printf("Updated user %q of instance %q\n", userLabel, instanceLabel) + cmd.Printf("Updated user %s of instance %q\n", userLabel, instanceLabel) return nil }, } @@ -98,13 +102,12 @@ func NewCmd() *cobra.Command { func configureFlags(cmd *cobra.Command) { cmd.Flags().Var(flags.UUIDFlag(), instanceIdFlag, "ID of the instance") - cmd.Flags().Bool(enableWriteFlag, false, "Set the user to have write access to the secrets engine.") - cmd.Flags().Bool(disableWriteFlag, false, "Set the user to have read-only access to the secrets engine.") + cmd.Flags().Bool(enableWriteFlag, false, "Set the user to have write access.") + cmd.Flags().Bool(disableWriteFlag, false, "Set the user to have read-only access.") err := flags.MarkFlagsRequired(cmd, instanceIdFlag) cmd.MarkFlagsMutuallyExclusive(enableWriteFlag, disableWriteFlag) - cmd.MarkFlagsOneRequired(enableWriteFlag, disableWriteFlag) cobra.CheckErr(err) } @@ -116,11 +119,18 @@ func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { return nil, &errors.ProjectIdError{} } + enableWrite := flags.FlagToBoolPointer(cmd, enableWriteFlag) + disableWrite := flags.FlagToBoolPointer(cmd, disableWriteFlag) + + if enableWrite == nil && disableWrite == nil { + return nil, &errors.EmptyUpdateError{} + } + return &inputModel{ GlobalFlagModel: globalFlags, InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), - EnableWrite: utils.Ptr(flags.FlagToBoolValue(cmd, enableWriteFlag)), - DisableWrite: utils.Ptr(flags.FlagToBoolValue(cmd, disableWriteFlag)), + EnableWrite: enableWrite, + DisableWrite: disableWrite, UserId: userId, }, nil } @@ -128,8 +138,12 @@ func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) secretsmanager.ApiUpdateUserRequest { req := apiClient.UpdateUser(ctx, model.ProjectId, model.InstanceId, model.UserId) + // model.EnableWrite and model.DisableWrite are mutually exclusive and can't be both nil + // therefore we can check only one for the value of the write parameter + write := model.EnableWrite != nil + req = req.UpdateUserPayload(secretsmanager.UpdateUserPayload{ - Write: model.EnableWrite, + Write: utils.Ptr(write), }) return req } diff --git a/internal/cmd/secrets-manager/user/update/update_test.go b/internal/cmd/secrets-manager/user/update/update_test.go index b9cd9a7fe..780ec8d4d 100644 --- a/internal/cmd/secrets-manager/user/update/update_test.go +++ b/internal/cmd/secrets-manager/user/update/update_test.go @@ -53,7 +53,7 @@ func fixtureInputModel(mods ...func(model *inputModel)) *inputModel { InstanceId: testInstanceId, UserId: testUserId, EnableWrite: utils.Ptr(true), - DisableWrite: utils.Ptr(false), + DisableWrite: nil, } for _, mod := range mods { mod(model) @@ -109,7 +109,7 @@ func TestParseInput(t *testing.T) { }), isValid: true, expectedModel: fixtureInputModel(func(model *inputModel) { - model.EnableWrite = utils.Ptr(false) + model.EnableWrite = nil model.DisableWrite = utils.Ptr(true) }), }, diff --git a/internal/pkg/services/secrets-manager/utils/utils_test.go b/internal/pkg/services/secrets-manager/utils/utils_test.go index 497331d14..add1773aa 100644 --- a/internal/pkg/services/secrets-manager/utils/utils_test.go +++ b/internal/pkg/services/secrets-manager/utils/utils_test.go @@ -38,7 +38,7 @@ func (s *secretsManagerClientMocked) GetInstanceExecute(_ context.Context, _, _ func (s *secretsManagerClientMocked) GetUserExecute(_ context.Context, _, _, _ string) (*secretsmanager.User, error) { if s.getUserFails { - return nil, fmt.Errorf("could not get instance") + return nil, fmt.Errorf("could not get user") } return s.getUserResp, nil } @@ -93,11 +93,12 @@ func TestGetInstanceName(t *testing.T) { func TestGetUserDetails(t *testing.T) { tests := []struct { - description string - getUserFails bool - GetUserResp *secretsmanager.User - isValid bool - expectedOutput [2]string + description string + getUserFails bool + GetUserResp *secretsmanager.User + isValid bool + expectedUserName string + expectedDescription string }{ { description: "base", @@ -105,11 +106,12 @@ func TestGetUserDetails(t *testing.T) { Username: utils.Ptr(testUserName), Description: utils.Ptr(testDescription), }, - isValid: true, - expectedOutput: [2]string{testUserName, testDescription}, + isValid: true, + expectedUserName: testUserName, + expectedDescription: testDescription, }, { - description: "get instance fails", + description: "get user fails", getUserFails: true, isValid: false, }, @@ -124,8 +126,6 @@ func TestGetUserDetails(t *testing.T) { username, description, err := GetUserDetails(context.Background(), client, testProjectId, testInstanceId, testUserId) - output := [2]string{username, description} - if tt.isValid && err != nil { t.Errorf("failed on valid input") } @@ -135,8 +135,11 @@ func TestGetUserDetails(t *testing.T) { if !tt.isValid { return } - if output != tt.expectedOutput { - t.Errorf("expected output to be %s, got %s", tt.expectedOutput, output) + if username != tt.expectedUserName { + t.Errorf("expected username to be %s, got %s", tt.expectedUserName, username) + } + if description != tt.expectedDescription { + t.Errorf("expected description to be %s, got %s", tt.expectedDescription, description) } }) } From 833fa82f91d360bfdf7be253a35fdd92b85bb4e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 7 Mar 2024 15:36:20 +0000 Subject: [PATCH 8/9] Improve update logic and formating --- .../cmd/secrets-manager/user/update/update.go | 28 ++++++++++++++----- .../user/update/update_test.go | 5 +++- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/internal/cmd/secrets-manager/user/update/update.go b/internal/cmd/secrets-manager/user/update/update.go index 724cb7d0e..89474402e 100644 --- a/internal/cmd/secrets-manager/user/update/update.go +++ b/internal/cmd/secrets-manager/user/update/update.go @@ -73,7 +73,7 @@ func NewCmd() *cobra.Command { if err != nil { userLabel = fmt.Sprintf("%q", model.UserId) } else { - userLabel = fmt.Sprintf("%q (%q)", userName, userDescription) + userLabel = fmt.Sprintf("%q (%s)", userName, userDescription) } if !model.AssumeYes { @@ -85,7 +85,11 @@ func NewCmd() *cobra.Command { } // Call API - req := buildRequest(ctx, model, apiClient) + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return err + } + err = req.Execute() if err != nil { return fmt.Errorf("update Secrets Manager user: %w", err) @@ -135,15 +139,25 @@ func parseInput(cmd *cobra.Command, inputArgs []string) (*inputModel, error) { }, nil } -func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) secretsmanager.ApiUpdateUserRequest { +func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmanager.APIClient) (secretsmanager.ApiUpdateUserRequest, error) { req := apiClient.UpdateUser(ctx, model.ProjectId, model.InstanceId, model.UserId) - // model.EnableWrite and model.DisableWrite are mutually exclusive and can't be both nil - // therefore we can check only one for the value of the write parameter - write := model.EnableWrite != nil + var write bool + + if model.EnableWrite != nil && model.DisableWrite == nil { + write = true + } else if model.DisableWrite != nil && model.EnableWrite == nil { + write = false + } else if model.DisableWrite == nil && model.EnableWrite == nil { + // Should never happen + return req, fmt.Errorf("one of enable-write and disable-write flags needs to be set") + } else if model.DisableWrite != nil && model.EnableWrite != nil { + // Should never happen + return req, fmt.Errorf("enable-write and disable-write flags can't be both set") + } req = req.UpdateUserPayload(secretsmanager.UpdateUserPayload{ Write: utils.Ptr(write), }) - return req + return req, nil } diff --git a/internal/cmd/secrets-manager/user/update/update_test.go b/internal/cmd/secrets-manager/user/update/update_test.go index 780ec8d4d..84e578155 100644 --- a/internal/cmd/secrets-manager/user/update/update_test.go +++ b/internal/cmd/secrets-manager/user/update/update_test.go @@ -260,7 +260,10 @@ func TestBuildRequest(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - request := buildRequest(testCtx, tt.model, testClient) + request, err := buildRequest(testCtx, tt.model, testClient) + if err != nil { + t.Fatalf("error building request: %v", err) + } diff := cmp.Diff(request, tt.expectedRequest, cmp.AllowUnexported(tt.expectedRequest), From 3a44e9cf81cdaa37396776d4c83dd9de94a7e17c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diogo=20Ferr=C3=A3o?= Date: Thu, 7 Mar 2024 15:48:17 +0000 Subject: [PATCH 9/9] update command: add testing for buildRequest logic, improve formatting --- .../cmd/secrets-manager/user/update/update.go | 4 +-- .../user/update/update_test.go | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/internal/cmd/secrets-manager/user/update/update.go b/internal/cmd/secrets-manager/user/update/update.go index 89474402e..3027ad079 100644 --- a/internal/cmd/secrets-manager/user/update/update.go +++ b/internal/cmd/secrets-manager/user/update/update.go @@ -150,10 +150,10 @@ func buildRequest(ctx context.Context, model *inputModel, apiClient *secretsmana write = false } else if model.DisableWrite == nil && model.EnableWrite == nil { // Should never happen - return req, fmt.Errorf("one of enable-write and disable-write flags needs to be set") + return req, fmt.Errorf("one of %s and %s flags needs to be set", enableWriteFlag, disableWriteFlag) } else if model.DisableWrite != nil && model.EnableWrite != nil { // Should never happen - return req, fmt.Errorf("enable-write and disable-write flags can't be both set") + return req, fmt.Errorf("%s and %s flags can't be both set", enableWriteFlag, disableWriteFlag) } req = req.UpdateUserPayload(secretsmanager.UpdateUserPayload{ diff --git a/internal/cmd/secrets-manager/user/update/update_test.go b/internal/cmd/secrets-manager/user/update/update_test.go index 84e578155..3b80192ce 100644 --- a/internal/cmd/secrets-manager/user/update/update_test.go +++ b/internal/cmd/secrets-manager/user/update/update_test.go @@ -250,11 +250,40 @@ func TestBuildRequest(t *testing.T) { description string model *inputModel expectedRequest secretsmanager.ApiUpdateUserRequest + isValid bool }{ { description: "base", model: fixtureInputModel(), expectedRequest: fixtureRequest(), + isValid: true, + }, + { + description: "disable write", + model: fixtureInputModel(func(model *inputModel) { + model.EnableWrite = nil + model.DisableWrite = utils.Ptr(true) + }), + expectedRequest: fixtureRequest().UpdateUserPayload(secretsmanager.UpdateUserPayload{ + Write: utils.Ptr(false), + }), + isValid: true, + }, + { + description: "both write flags set", + model: fixtureInputModel(func(model *inputModel) { + model.EnableWrite = utils.Ptr(true) + model.DisableWrite = utils.Ptr(true) + }), + isValid: false, + }, + { + description: "none of the write flags set", + model: fixtureInputModel(func(model *inputModel) { + model.EnableWrite = nil + model.DisableWrite = nil + }), + isValid: false, }, } @@ -262,6 +291,9 @@ func TestBuildRequest(t *testing.T) { t.Run(tt.description, func(t *testing.T) { request, err := buildRequest(testCtx, tt.model, testClient) if err != nil { + if !tt.isValid { + return + } t.Fatalf("error building request: %v", err) }