diff --git a/docs/stackit_argus_grafana.md b/docs/stackit_argus_grafana.md index 9d6714075..c1ce6b5b5 100644 --- a/docs/stackit_argus_grafana.md +++ b/docs/stackit_argus_grafana.md @@ -30,4 +30,5 @@ stackit argus grafana [flags] * [stackit argus](./stackit_argus.md) - Provides functionality for Argus * [stackit argus grafana describe](./stackit_argus_grafana_describe.md) - Shows details of the Grafana configuration of an Argus instance +* [stackit argus grafana single-sign-on](./stackit_argus_grafana_single-sign-on.md) - Enable or disable single sign-on for Grafana in Argus instances diff --git a/docs/stackit_argus_grafana_single-sign-on.md b/docs/stackit_argus_grafana_single-sign-on.md new file mode 100644 index 000000000..48f0960a2 --- /dev/null +++ b/docs/stackit_argus_grafana_single-sign-on.md @@ -0,0 +1,35 @@ +## stackit argus grafana single-sign-on + +Enable or disable single sign-on for Grafana in Argus instances + +### Synopsis + +Enable or disable single sign-on for Grafana in Argus instances. +When enabled for an instance, overwrites the generic OAuth2 authentication and configures STACKIT single sign-on for that instance. + +``` +stackit argus grafana single-sign-on [flags] +``` + +### Options + +``` + -h, --help Help for "stackit argus grafana single-sign-on" +``` + +### 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 + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit argus grafana](./stackit_argus_grafana.md) - Provides functionality for the Grafana configuration of Argus instances +* [stackit argus grafana single-sign-on disable](./stackit_argus_grafana_single-sign-on_disable.md) - Disables single sign-on for Grafana on Argus instances +* [stackit argus grafana single-sign-on enable](./stackit_argus_grafana_single-sign-on_enable.md) - Enables single sign-on for Grafana on Argus instances + diff --git a/docs/stackit_argus_grafana_single-sign-on_disable.md b/docs/stackit_argus_grafana_single-sign-on_disable.md new file mode 100644 index 000000000..143273e20 --- /dev/null +++ b/docs/stackit_argus_grafana_single-sign-on_disable.md @@ -0,0 +1,41 @@ +## stackit argus grafana single-sign-on disable + +Disables single sign-on for Grafana on Argus instances + +### Synopsis + +Disables single sign-on for Grafana on Argus instances. +When disabled for an instance, the generic OAuth2 authentication is used for that instance. + +``` +stackit argus grafana single-sign-on disable [flags] +``` + +### Examples + +``` + Disable single sign-on for Grafana on an Argus instance with ID "xxx" + $ stackit argus grafana single-sign-on disable --instance-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit argus grafana single-sign-on disable" + --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 + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit argus grafana single-sign-on](./stackit_argus_grafana_single-sign-on.md) - Enable or disable single sign-on for Grafana in Argus instances + diff --git a/docs/stackit_argus_grafana_single-sign-on_enable.md b/docs/stackit_argus_grafana_single-sign-on_enable.md new file mode 100644 index 000000000..ad83abb99 --- /dev/null +++ b/docs/stackit_argus_grafana_single-sign-on_enable.md @@ -0,0 +1,41 @@ +## stackit argus grafana single-sign-on enable + +Enables single sign-on for Grafana on Argus instances + +### Synopsis + +Enables single sign-on for Grafana on Argus instances. +When enabled for an instance, overwrites the generic OAuth2 authentication and configures STACKIT single sign-on for that instance. + +``` +stackit argus grafana single-sign-on enable [flags] +``` + +### Examples + +``` + Enable single sign-on for Grafana on an Argus instance with ID "xxx" + $ stackit argus grafana single-sign-on enable --instance-id xxx +``` + +### Options + +``` + -h, --help Help for "stackit argus grafana single-sign-on enable" + --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 + --verbosity string Verbosity of the CLI, one of ["debug" "info" "warning" "error"] (default "info") +``` + +### SEE ALSO + +* [stackit argus grafana single-sign-on](./stackit_argus_grafana_single-sign-on.md) - Enable or disable single sign-on for Grafana in Argus instances + diff --git a/internal/cmd/argus/grafana/grafana.go b/internal/cmd/argus/grafana/grafana.go index e1a77a9ce..0ce98f4c8 100644 --- a/internal/cmd/argus/grafana/grafana.go +++ b/internal/cmd/argus/grafana/grafana.go @@ -2,6 +2,7 @@ package grafana import ( "github.com/stackitcloud/stackit-cli/internal/cmd/argus/grafana/describe" + singlesignon "github.com/stackitcloud/stackit-cli/internal/cmd/argus/grafana/single-sign-on" "github.com/stackitcloud/stackit-cli/internal/pkg/args" "github.com/stackitcloud/stackit-cli/internal/pkg/print" "github.com/stackitcloud/stackit-cli/internal/pkg/utils" @@ -23,4 +24,5 @@ func NewCmd(p *print.Printer) *cobra.Command { func addSubcommands(cmd *cobra.Command, p *print.Printer) { cmd.AddCommand(describe.NewCmd(p)) + cmd.AddCommand(singlesignon.NewCmd(p)) } diff --git a/internal/cmd/argus/grafana/single-sign-on/disable/disable.go b/internal/cmd/argus/grafana/single-sign-on/disable/disable.go new file mode 100644 index 000000000..090320e4c --- /dev/null +++ b/internal/cmd/argus/grafana/single-sign-on/disable/disable.go @@ -0,0 +1,115 @@ +package disable + +import ( + "context" + "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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client" + argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +const ( + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "disable", + Short: "Disables single sign-on for Grafana on Argus instances", + Long: fmt.Sprintf("%s\n%s", + "Disables single sign-on for Grafana on Argus instances.", + "When disabled for an instance, the generic OAuth2 authentication is used for that instance.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Disable single sign-on for Grafana on an Argus instance with ID "xxx"`, + "$ stackit argus grafana single-sign-on disable --instance-id xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) + if err != nil || instanceLabel == "" { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to disable single sign-on for instance %q?", instanceLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + _, err = req.Execute() + if err != nil { + return fmt.Errorf("disable single sign-on: %w", err) + } + + p.Info("Disabled single sign-on for instance %q\n", 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) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient argusUtils.ArgusClient) (argus.ApiUpdateGrafanaConfigsRequest, error) { + req := apiClient.UpdateGrafanaConfigs(ctx, model.InstanceId, model.ProjectId) + payload, err := argusUtils.GetPartialUpdateGrafanaConfigsPayload(ctx, apiClient, model.InstanceId, model.ProjectId, utils.Ptr(false), nil) + if err != nil { + return req, fmt.Errorf("build request payload: %w", err) + } + req = req.UpdateGrafanaConfigsPayload(*payload) + return req, nil +} diff --git a/internal/cmd/argus/grafana/single-sign-on/disable/disable_test.go b/internal/cmd/argus/grafana/single-sign-on/disable/disable_test.go new file mode 100644 index 000000000..b654bbb32 --- /dev/null +++ b/internal/cmd/argus/grafana/single-sign-on/disable/disable_test.go @@ -0,0 +1,269 @@ +package disable + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils" + "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/argus" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &argus.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +type argusClientMocked struct { + getGrafanaConfigsFails bool + getGrafanaConfigsResp *argus.GrafanaConfigs +} + +func (c *argusClientMocked) GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*argus.GetInstanceResponse, error) { + return testClient.GetInstanceExecute(ctx, instanceId, projectId) +} + +func (c *argusClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) argus.ApiUpdateGrafanaConfigsRequest { + return testClient.UpdateGrafanaConfigs(ctx, instanceId, projectId) +} + +func (c *argusClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ string) (*argus.GrafanaConfigs, error) { + if c.getGrafanaConfigsFails { + return nil, fmt.Errorf("get payload failed") + } + return c.getGrafanaConfigsResp, nil +} + +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, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureGrafanaConfigs(mods ...func(gc *argus.GrafanaConfigs)) *argus.GrafanaConfigs { + gc := argus.GrafanaConfigs{ + GenericOauth: &argus.GrafanaOauth{ + ApiUrl: utils.Ptr("apiUrl"), + AuthUrl: utils.Ptr("authUrl"), + Enabled: utils.Ptr(true), + Name: utils.Ptr("name"), + OauthClientId: utils.Ptr("oauthClientId"), + OauthClientSecret: utils.Ptr("oauthClientSecret"), + RoleAttributePath: utils.Ptr("roleAttributePath"), + RoleAttributeStrict: utils.Ptr(true), + Scopes: utils.Ptr("scopes"), + TokenUrl: utils.Ptr("tokenUrl"), + UsePkce: utils.Ptr(true), + }, + PublicReadAccess: utils.Ptr(false), + UseStackitSso: utils.Ptr(false), + } + for _, mod := range mods { + mod(&gc) + } + return &gc +} + +func fixturePayload(mods ...func(payload *argus.UpdateGrafanaConfigsPayload)) *argus.UpdateGrafanaConfigsPayload { + payload := &argus.UpdateGrafanaConfigsPayload{ + GenericOauth: argusUtils.ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), + PublicReadAccess: fixtureGrafanaConfigs().PublicReadAccess, + UseStackitSso: utils.Ptr(false), + } + for _, mod := range mods { + mod(payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *argus.ApiUpdateGrafanaConfigsRequest)) argus.ApiUpdateGrafanaConfigsRequest { + request := testClient.UpdateGrafanaConfigs(testCtx, testInstanceId, testProjectId) + request = request.UpdateGrafanaConfigsPayload(*fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, 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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd(nil) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(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 + getGrafanaConfigsFails bool + getGrafanaConfigsResp *argus.GrafanaConfigs + isValid bool + expectedRequest argus.ApiUpdateGrafanaConfigsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + getGrafanaConfigsResp: fixtureGrafanaConfigs(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + { + description: "nil generic oauth", + model: fixtureInputModel(), + getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *argus.GrafanaConfigs) { + gc.GenericOauth = nil + }), + isValid: true, + expectedRequest: fixtureRequest(func(request *argus.ApiUpdateGrafanaConfigsRequest) { + *request = request.UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *argus.UpdateGrafanaConfigsPayload) { + payload.GenericOauth = nil + })) + }), + }, + { + description: "get grafana configs fails", + model: fixtureInputModel(), + getGrafanaConfigsFails: true, + isValid: false, + }, + { + description: "no grafana configs", + model: fixtureInputModel(), + getGrafanaConfigsResp: nil, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &argusClientMocked{ + getGrafanaConfigsFails: tt.getGrafanaConfigsFails, + getGrafanaConfigsResp: tt.getGrafanaConfigsResp, + } + request, err := buildRequest(testCtx, tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/argus/grafana/single-sign-on/enable/enable.go b/internal/cmd/argus/grafana/single-sign-on/enable/enable.go new file mode 100644 index 000000000..711e68fb1 --- /dev/null +++ b/internal/cmd/argus/grafana/single-sign-on/enable/enable.go @@ -0,0 +1,115 @@ +package enable + +import ( + "context" + "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/print" + "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/client" + argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils" + "github.com/stackitcloud/stackit-cli/internal/pkg/utils" + + "github.com/spf13/cobra" + "github.com/stackitcloud/stackit-sdk-go/services/argus" +) + +const ( + instanceIdFlag = "instance-id" +) + +type inputModel struct { + *globalflags.GlobalFlagModel + InstanceId string +} + +func NewCmd(p *print.Printer) *cobra.Command { + cmd := &cobra.Command{ + Use: "enable", + Short: "Enables single sign-on for Grafana on Argus instances", + Long: fmt.Sprintf("%s\n%s", + "Enables single sign-on for Grafana on Argus instances.", + "When enabled for an instance, overwrites the generic OAuth2 authentication and configures STACKIT single sign-on for that instance.", + ), + Args: args.NoArgs, + Example: examples.Build( + examples.NewExample( + `Enable single sign-on for Grafana on an Argus instance with ID "xxx"`, + "$ stackit argus grafana single-sign-on enable --instance-id xxx"), + ), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + model, err := parseInput(cmd) + if err != nil { + return err + } + + // Configure API client + apiClient, err := client.ConfigureClient(p) + if err != nil { + return err + } + + instanceLabel, err := argusUtils.GetInstanceName(ctx, apiClient, model.InstanceId, model.ProjectId) + if err != nil || instanceLabel == "" { + instanceLabel = model.InstanceId + } + + if !model.AssumeYes { + prompt := fmt.Sprintf("Are you sure you want to enable single sign-on for instance %q?", instanceLabel) + err = p.PromptForConfirmation(prompt) + if err != nil { + return err + } + } + + // Call API + req, err := buildRequest(ctx, model, apiClient) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + _, err = req.Execute() + if err != nil { + return fmt.Errorf("enable single sign-on: %w", err) + } + + p.Info("Enabled single sign-on for instance %q\n", 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) (*inputModel, error) { + globalFlags := globalflags.Parse(cmd) + if globalFlags.ProjectId == "" { + return nil, &errors.ProjectIdError{} + } + + return &inputModel{ + GlobalFlagModel: globalFlags, + InstanceId: flags.FlagToStringValue(cmd, instanceIdFlag), + }, nil +} + +func buildRequest(ctx context.Context, model *inputModel, apiClient argusUtils.ArgusClient) (argus.ApiUpdateGrafanaConfigsRequest, error) { + req := apiClient.UpdateGrafanaConfigs(ctx, model.InstanceId, model.ProjectId) + payload, err := argusUtils.GetPartialUpdateGrafanaConfigsPayload(ctx, apiClient, model.InstanceId, model.ProjectId, utils.Ptr(true), nil) + if err != nil { + return req, fmt.Errorf("build request payload: %w", err) + } + req = req.UpdateGrafanaConfigsPayload(*payload) + return req, nil +} diff --git a/internal/cmd/argus/grafana/single-sign-on/enable/enable_test.go b/internal/cmd/argus/grafana/single-sign-on/enable/enable_test.go new file mode 100644 index 000000000..7544b81b4 --- /dev/null +++ b/internal/cmd/argus/grafana/single-sign-on/enable/enable_test.go @@ -0,0 +1,269 @@ +package enable + +import ( + "context" + "fmt" + "testing" + + "github.com/stackitcloud/stackit-cli/internal/pkg/globalflags" + argusUtils "github.com/stackitcloud/stackit-cli/internal/pkg/services/argus/utils" + "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/argus" +) + +var projectIdFlag = globalflags.ProjectIdFlag + +type testCtxKey struct{} + +var testCtx = context.WithValue(context.Background(), testCtxKey{}, "foo") +var testClient = &argus.APIClient{} +var testProjectId = uuid.NewString() +var testInstanceId = uuid.NewString() + +type argusClientMocked struct { + getGrafanaConfigsFails bool + getGrafanaConfigsResp *argus.GrafanaConfigs +} + +func (c *argusClientMocked) GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*argus.GetInstanceResponse, error) { + return testClient.GetInstanceExecute(ctx, instanceId, projectId) +} + +func (c *argusClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) argus.ApiUpdateGrafanaConfigsRequest { + return testClient.UpdateGrafanaConfigs(ctx, instanceId, projectId) +} + +func (c *argusClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ string) (*argus.GrafanaConfigs, error) { + if c.getGrafanaConfigsFails { + return nil, fmt.Errorf("get payload failed") + } + return c.getGrafanaConfigsResp, nil +} + +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, + } + for _, mod := range mods { + mod(model) + } + return model +} + +func fixtureGrafanaConfigs(mods ...func(gc *argus.GrafanaConfigs)) *argus.GrafanaConfigs { + gc := argus.GrafanaConfigs{ + GenericOauth: &argus.GrafanaOauth{ + ApiUrl: utils.Ptr("apiUrl"), + AuthUrl: utils.Ptr("authUrl"), + Enabled: utils.Ptr(true), + Name: utils.Ptr("name"), + OauthClientId: utils.Ptr("oauthClientId"), + OauthClientSecret: utils.Ptr("oauthClientSecret"), + RoleAttributePath: utils.Ptr("roleAttributePath"), + RoleAttributeStrict: utils.Ptr(true), + Scopes: utils.Ptr("scopes"), + TokenUrl: utils.Ptr("tokenUrl"), + UsePkce: utils.Ptr(true), + }, + PublicReadAccess: utils.Ptr(false), + UseStackitSso: utils.Ptr(false), + } + for _, mod := range mods { + mod(&gc) + } + return &gc +} + +func fixturePayload(mods ...func(payload *argus.UpdateGrafanaConfigsPayload)) *argus.UpdateGrafanaConfigsPayload { + payload := &argus.UpdateGrafanaConfigsPayload{ + GenericOauth: argusUtils.ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), + PublicReadAccess: fixtureGrafanaConfigs().PublicReadAccess, + UseStackitSso: utils.Ptr(true), + } + for _, mod := range mods { + mod(payload) + } + return payload +} + +func fixtureRequest(mods ...func(request *argus.ApiUpdateGrafanaConfigsRequest)) argus.ApiUpdateGrafanaConfigsRequest { + request := testClient.UpdateGrafanaConfigs(testCtx, testInstanceId, testProjectId) + request = request.UpdateGrafanaConfigsPayload(*fixturePayload()) + for _, mod := range mods { + mod(&request) + } + return request +} + +func TestParseInput(t *testing.T) { + tests := []struct { + description string + flagValues map[string]string + isValid bool + expectedModel *inputModel + }{ + { + description: "base", + flagValues: fixtureFlagValues(), + isValid: true, + expectedModel: fixtureInputModel(), + }, + { + description: "no values", + flagValues: map[string]string{}, + isValid: false, + }, + { + description: "project id missing", + flagValues: fixtureFlagValues(func(flagValues map[string]string) { + delete(flagValues, 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, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + cmd := NewCmd(nil) + err := globalflags.Configure(cmd.Flags()) + if err != nil { + t.Fatalf("configure global flags: %v", err) + } + + for flag, value := range tt.flagValues { + err := cmd.Flags().Set(flag, value) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("setting flag --%s=%s: %v", flag, value, err) + } + } + + err = cmd.ValidateRequiredFlags() + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error validating flags: %v", err) + } + + model, err := parseInput(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 + getGrafanaConfigsFails bool + getGrafanaConfigsResp *argus.GrafanaConfigs + isValid bool + expectedRequest argus.ApiUpdateGrafanaConfigsRequest + }{ + { + description: "base", + model: fixtureInputModel(), + getGrafanaConfigsResp: fixtureGrafanaConfigs(), + isValid: true, + expectedRequest: fixtureRequest(), + }, + { + description: "nil generic oauth", + model: fixtureInputModel(), + getGrafanaConfigsResp: fixtureGrafanaConfigs(func(gc *argus.GrafanaConfigs) { + gc.GenericOauth = nil + }), + isValid: true, + expectedRequest: fixtureRequest(func(request *argus.ApiUpdateGrafanaConfigsRequest) { + *request = request.UpdateGrafanaConfigsPayload(*fixturePayload(func(payload *argus.UpdateGrafanaConfigsPayload) { + payload.GenericOauth = nil + })) + }), + }, + { + description: "get grafana configs fails", + model: fixtureInputModel(), + getGrafanaConfigsFails: true, + isValid: false, + }, + { + description: "no grafana configs", + model: fixtureInputModel(), + getGrafanaConfigsResp: nil, + isValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + client := &argusClientMocked{ + getGrafanaConfigsFails: tt.getGrafanaConfigsFails, + getGrafanaConfigsResp: tt.getGrafanaConfigsResp, + } + request, err := buildRequest(testCtx, tt.model, client) + if err != nil { + if !tt.isValid { + return + } + t.Fatalf("error building request: %v", err) + } + + diff := cmp.Diff(request, tt.expectedRequest, + cmp.AllowUnexported(tt.expectedRequest), + cmpopts.EquateComparable(testCtx), + ) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + }) + } +} diff --git a/internal/cmd/argus/grafana/single-sign-on/single_sign_on.go b/internal/cmd/argus/grafana/single-sign-on/single_sign_on.go new file mode 100644 index 000000000..6b2cf37cb --- /dev/null +++ b/internal/cmd/argus/grafana/single-sign-on/single_sign_on.go @@ -0,0 +1,33 @@ +package singlesignon + +import ( + "fmt" + + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/grafana/single-sign-on/disable" + "github.com/stackitcloud/stackit-cli/internal/cmd/argus/grafana/single-sign-on/enable" + "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: "single-sign-on", + Short: "Enable or disable single sign-on for Grafana in Argus instances", + Long: fmt.Sprintf("%s\n%s", + "Enable or disable single sign-on for Grafana in Argus instances.", + "When enabled for an instance, overwrites the generic OAuth2 authentication and configures STACKIT single sign-on for that instance.", + ), + Args: args.NoArgs, + Run: utils.CmdHelp, + } + addSubcommands(cmd, p) + return cmd +} + +func addSubcommands(cmd *cobra.Command, p *print.Printer) { + cmd.AddCommand(enable.NewCmd(p)) + cmd.AddCommand(disable.NewCmd(p)) +} diff --git a/internal/pkg/services/argus/utils/utils.go b/internal/pkg/services/argus/utils/utils.go index 396aa84fb..9024817d4 100644 --- a/internal/pkg/services/argus/utils/utils.go +++ b/internal/pkg/services/argus/utils/utils.go @@ -17,6 +17,7 @@ const ( type ArgusClient interface { GetInstanceExecute(ctx context.Context, instanceId, projectId string) (*argus.GetInstanceResponse, error) GetGrafanaConfigsExecute(ctx context.Context, instanceId, projectId string) (*argus.GrafanaConfigs, error) + UpdateGrafanaConfigs(ctx context.Context, instanceId string, projectId string) argus.ApiUpdateGrafanaConfigsRequest } func ValidatePlanId(planId string, resp *argus.PlansResponse) error { @@ -69,41 +70,22 @@ func GetInstanceName(ctx context.Context, apiClient ArgusClient, instanceId, pro return *resp.Name, nil } -func toPayloadGenericOAuth(response *argus.GrafanaOauth) *argus.UpdateGrafanaConfigsPayloadGenericOauth { - if response == nil { +func ToPayloadGenericOAuth(respOAuth *argus.GrafanaOauth) *argus.UpdateGrafanaConfigsPayloadGenericOauth { + if respOAuth == nil { return nil } return &argus.UpdateGrafanaConfigsPayloadGenericOauth{ - ApiUrl: response.ApiUrl, - AuthUrl: response.AuthUrl, - Enabled: response.Enabled, - Name: response.Name, - OauthClientId: response.OauthClientId, - OauthClientSecret: response.OauthClientSecret, - RoleAttributePath: response.RoleAttributePath, - RoleAttributeStrict: response.RoleAttributeStrict, - Scopes: response.Scopes, - TokenUrl: response.TokenUrl, - UsePkce: response.UsePkce, - } -} - -func toRespGenericOAuth(payloadModel *argus.UpdateGrafanaConfigsPayloadGenericOauth) *argus.GrafanaOauth { - if payloadModel == nil { - return nil - } - return &argus.GrafanaOauth{ - ApiUrl: payloadModel.ApiUrl, - AuthUrl: payloadModel.AuthUrl, - Enabled: payloadModel.Enabled, - Name: payloadModel.Name, - OauthClientId: payloadModel.OauthClientId, - OauthClientSecret: payloadModel.OauthClientSecret, - RoleAttributePath: payloadModel.RoleAttributePath, - RoleAttributeStrict: payloadModel.RoleAttributeStrict, - Scopes: payloadModel.Scopes, - TokenUrl: payloadModel.TokenUrl, - UsePkce: payloadModel.UsePkce, + ApiUrl: respOAuth.ApiUrl, + AuthUrl: respOAuth.AuthUrl, + Enabled: respOAuth.Enabled, + Name: respOAuth.Name, + OauthClientId: respOAuth.OauthClientId, + OauthClientSecret: respOAuth.OauthClientSecret, + RoleAttributePath: respOAuth.RoleAttributePath, + RoleAttributeStrict: respOAuth.RoleAttributeStrict, + Scopes: respOAuth.Scopes, + TokenUrl: respOAuth.TokenUrl, + UsePkce: respOAuth.UsePkce, } } @@ -112,13 +94,12 @@ func GetPartialUpdateGrafanaConfigsPayload(ctx context.Context, apiClient ArgusC if err != nil { return nil, fmt.Errorf("get current Grafana configs: %w", err) } - - if currentConfigs == nil || currentConfigs.GenericOauth == nil { + if currentConfigs == nil { return nil, fmt.Errorf("no Grafana configs found for instance %q", instanceId) } payload := &argus.UpdateGrafanaConfigsPayload{ - GenericOauth: toPayloadGenericOAuth(currentConfigs.GenericOauth), + GenericOauth: ToPayloadGenericOAuth(currentConfigs.GenericOauth), PublicReadAccess: currentConfigs.PublicReadAccess, UseStackitSso: currentConfigs.UseStackitSso, } diff --git a/internal/pkg/services/argus/utils/utils_test.go b/internal/pkg/services/argus/utils/utils_test.go index 911eb061c..66ca7fa2d 100644 --- a/internal/pkg/services/argus/utils/utils_test.go +++ b/internal/pkg/services/argus/utils/utils_test.go @@ -14,6 +14,7 @@ import ( ) var ( + testClient = &argus.APIClient{} testProjectId = uuid.NewString() testInstanceId = uuid.NewString() testPlanId = uuid.NewString() @@ -54,6 +55,10 @@ func (m *argusClientMocked) GetGrafanaConfigsExecute(_ context.Context, _, _ str return m.getGrafanaConfigsResp, nil } +func (c *argusClientMocked) UpdateGrafanaConfigs(ctx context.Context, instanceId, projectId string) argus.ApiUpdateGrafanaConfigsRequest { + return testClient.UpdateGrafanaConfigs(ctx, instanceId, projectId) +} + func fixtureGrafanaConfigs(mods ...func(gc *argus.GrafanaConfigs)) *argus.GrafanaConfigs { gc := argus.GrafanaConfigs{ GenericOauth: &argus.GrafanaOauth{ @@ -292,7 +297,7 @@ func TestToPayloadGenericOAuth(t *testing.T) { }, }, { - description: "nil response", + description: "nil response oauth", response: nil, expected: nil, }, @@ -300,61 +305,7 @@ func TestToPayloadGenericOAuth(t *testing.T) { for _, tt := range tests { t.Run(tt.description, func(t *testing.T) { - output := toPayloadGenericOAuth(tt.response) - - diff := cmp.Diff(output, tt.expected) - if diff != "" { - t.Errorf("expected output to be %+v, got %+v", tt.expected, output) - } - }) - } -} - -func TestToRespGenericOAuth(t *testing.T) { - tests := []struct { - description string - payload *argus.UpdateGrafanaConfigsPayloadGenericOauth - expected *argus.GrafanaOauth - }{ - { - description: "base", - payload: &argus.UpdateGrafanaConfigsPayloadGenericOauth{ - ApiUrl: utils.Ptr("apiUrl"), - AuthUrl: utils.Ptr("authUrl"), - Enabled: utils.Ptr(true), - Name: utils.Ptr("name"), - OauthClientId: utils.Ptr("oauthClientId"), - OauthClientSecret: utils.Ptr("oauthClientSecret"), - RoleAttributePath: utils.Ptr("roleAttributePath"), - RoleAttributeStrict: utils.Ptr(true), - Scopes: utils.Ptr("scopes"), - TokenUrl: utils.Ptr("tokenUrl"), - UsePkce: utils.Ptr(true), - }, - expected: &argus.GrafanaOauth{ - ApiUrl: utils.Ptr("apiUrl"), - AuthUrl: utils.Ptr("authUrl"), - Enabled: utils.Ptr(true), - Name: utils.Ptr("name"), - OauthClientId: utils.Ptr("oauthClientId"), - OauthClientSecret: utils.Ptr("oauthClientSecret"), - RoleAttributePath: utils.Ptr("roleAttributePath"), - RoleAttributeStrict: utils.Ptr(true), - Scopes: utils.Ptr("scopes"), - TokenUrl: utils.Ptr("tokenUrl"), - UsePkce: utils.Ptr(true), - }, - }, - { - description: "nil payload", - payload: nil, - expected: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.description, func(t *testing.T) { - output := toRespGenericOAuth(tt.payload) + output := ToPayloadGenericOAuth(tt.response) diff := cmp.Diff(output, tt.expected) if diff != "" { @@ -381,7 +332,7 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) { getGrafanaConfigsResp: fixtureGrafanaConfigs(), isValid: true, expectedPayload: &argus.UpdateGrafanaConfigsPayload{ - GenericOauth: toPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), + GenericOauth: ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), UseStackitSso: utils.Ptr(true), PublicReadAccess: utils.Ptr(true), }, @@ -393,7 +344,7 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) { getGrafanaConfigsResp: fixtureGrafanaConfigs(), isValid: true, expectedPayload: &argus.UpdateGrafanaConfigsPayload{ - GenericOauth: toPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), + GenericOauth: ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), UseStackitSso: utils.Ptr(false), PublicReadAccess: utils.Ptr(false), }, @@ -405,7 +356,7 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) { getGrafanaConfigsResp: fixtureGrafanaConfigs(), isValid: true, expectedPayload: &argus.UpdateGrafanaConfigsPayload{ - GenericOauth: toPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), + GenericOauth: ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), UseStackitSso: utils.Ptr(true), PublicReadAccess: fixtureGrafanaConfigs().PublicReadAccess, }, @@ -417,7 +368,7 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) { getGrafanaConfigsResp: fixtureGrafanaConfigs(), isValid: true, expectedPayload: &argus.UpdateGrafanaConfigsPayload{ - GenericOauth: toPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), + GenericOauth: ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), UseStackitSso: fixtureGrafanaConfigs().UseStackitSso, PublicReadAccess: utils.Ptr(true), }, @@ -431,7 +382,7 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) { }), isValid: true, expectedPayload: &argus.UpdateGrafanaConfigsPayload{ - GenericOauth: toPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), + GenericOauth: ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), UseStackitSso: utils.Ptr(false), PublicReadAccess: fixtureGrafanaConfigs().PublicReadAccess, }, @@ -445,11 +396,23 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) { }), isValid: true, expectedPayload: &argus.UpdateGrafanaConfigsPayload{ - GenericOauth: toPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), + GenericOauth: ToPayloadGenericOAuth(fixtureGrafanaConfigs().GenericOauth), UseStackitSso: fixtureGrafanaConfigs().UseStackitSso, PublicReadAccess: utils.Ptr(false), }, }, + { + description: "nil generic oauth", + singleSignOn: utils.Ptr(true), + publicReadAccess: utils.Ptr(true), + getGrafanaConfigsResp: &argus.GrafanaConfigs{}, + isValid: true, + expectedPayload: &argus.UpdateGrafanaConfigsPayload{ + GenericOauth: nil, + UseStackitSso: utils.Ptr(true), + PublicReadAccess: utils.Ptr(true), + }, + }, { description: "get grafana configs fails", singleSignOn: utils.Ptr(true), @@ -461,7 +424,7 @@ func TestGetPartialUpdateGrafanaConfigsPayload(t *testing.T) { description: "no grafana configs", singleSignOn: utils.Ptr(true), publicReadAccess: utils.Ptr(true), - getGrafanaConfigsResp: &argus.GrafanaConfigs{}, + getGrafanaConfigsResp: nil, isValid: false, }, }