From 554d17154c980caae90f52f047696321dd6dd6d3 Mon Sep 17 00:00:00 2001 From: Ben Pearce Date: Tue, 12 Dec 2023 15:06:59 +1000 Subject: [PATCH] feat: account azure-oidc create (#296) * feat: account azure-oidc list --- .github/workflows/integration-test.yml | 2 +- go.mod | 2 +- go.sum | 4 +- pkg/cmd/account/account.go | 2 + pkg/cmd/account/azure-oidc/azure-oidc.go | 24 ++ pkg/cmd/account/azure-oidc/create/create.go | 398 ++++++++++++++++++ .../account/azure-oidc/create/create_test.go | 84 ++++ pkg/cmd/account/azure-oidc/list/list.go | 99 +++++ pkg/cmd/account/azure/create/create.go | 46 +- pkg/cmd/account/create/create.go | 8 + pkg/cmd/account/list/list.go | 1 + pkg/cmd/account/shared/shared.go | 20 + test/testutil/fakesurvey.go | 12 + 13 files changed, 666 insertions(+), 36 deletions(-) create mode 100644 pkg/cmd/account/azure-oidc/azure-oidc.go create mode 100644 pkg/cmd/account/azure-oidc/create/create.go create mode 100644 pkg/cmd/account/azure-oidc/create/create_test.go create mode 100644 pkg/cmd/account/azure-oidc/list/list.go create mode 100644 pkg/cmd/account/shared/shared.go diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index f54bcc75..cbdc4b62 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -31,7 +31,7 @@ jobs: --health-retries 10 --health-start-period 10s octopusserver: - image: docker.packages.octopushq.com/octopusdeploy/octopusdeploy:2022.4.3812-linux + image: docker.packages.octopushq.com/octopusdeploy/octopusdeploy:2023.4.8126-linux env: ACCEPT_EULA: Y DB_CONNECTION_STRING: "Server=sqlserver;Database=OctopusDeploy;User Id=sa;Password=${{ env.SA_PASSWORD }};" diff --git a/go.mod b/go.mod index fb8b5adf..2e37fe63 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.19 require ( github.com/AlecAivazis/survey/v2 v2.3.6 github.com/MakeNowJust/heredoc/v2 v2.0.1 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.32.0 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.34.0 github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index 583c5b2a..0d1f9554 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,8 @@ github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZ github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.32.0 h1:UWNSsntIp4J+F7JOw4k6Df4gAOg8fBwCyoeBizy1ff4= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.32.0/go.mod h1:GZmFu6LmN8Yg0tEoZx3ytk9FnaH+84cWm7u5TdWZC6E= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.34.0 h1:S8h21VdVSHC8yfAk0Te8eHU/gkJ+3w2pv7Q/0kooK8I= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.34.0/go.mod h1:GZmFu6LmN8Yg0tEoZx3ytk9FnaH+84cWm7u5TdWZC6E= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= diff --git a/pkg/cmd/account/account.go b/pkg/cmd/account/account.go index dcb429ac..9586df97 100644 --- a/pkg/cmd/account/account.go +++ b/pkg/cmd/account/account.go @@ -4,6 +4,7 @@ import ( "github.com/MakeNowJust/heredoc/v2" cmdAWS "github.com/OctopusDeploy/cli/pkg/cmd/account/aws" cmdAzure "github.com/OctopusDeploy/cli/pkg/cmd/account/azure" + cmdAzureOidc "github.com/OctopusDeploy/cli/pkg/cmd/account/azure-oidc" cmdCreate "github.com/OctopusDeploy/cli/pkg/cmd/account/create" cmdDelete "github.com/OctopusDeploy/cli/pkg/cmd/account/delete" cmdGCP "github.com/OctopusDeploy/cli/pkg/cmd/account/gcp" @@ -33,6 +34,7 @@ func NewCmdAccount(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdList.NewCmdList(f)) cmd.AddCommand(cmdAWS.NewCmdAws(f)) cmd.AddCommand(cmdAzure.NewCmdAzure(f)) + cmd.AddCommand(cmdAzureOidc.NewCmdAzureOidc(f)) cmd.AddCommand(cmdGCP.NewCmdGcp(f)) cmd.AddCommand(cmdSSH.NewCmdSsh(f)) cmd.AddCommand(cmdUsr.NewCmdUsername(f)) diff --git a/pkg/cmd/account/azure-oidc/azure-oidc.go b/pkg/cmd/account/azure-oidc/azure-oidc.go new file mode 100644 index 00000000..d9f85a53 --- /dev/null +++ b/pkg/cmd/account/azure-oidc/azure-oidc.go @@ -0,0 +1,24 @@ +package azure + +import ( + "github.com/MakeNowJust/heredoc/v2" + cmdCreate "github.com/OctopusDeploy/cli/pkg/cmd/account/azure-oidc/create" + cmdList "github.com/OctopusDeploy/cli/pkg/cmd/account/azure-oidc/list" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/spf13/cobra" +) + +func NewCmdAzureOidc(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "azure-oidc ", + Short: "Manage Azure OpenID Connect accounts", + Long: "Manage Azure OpenID Connect accounts in Octopus Deploy", + Example: heredoc.Docf("$ %s account azure-oidc list", constants.ExecutableName), + } + + cmd.AddCommand(cmdList.NewCmdList(f)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f)) + + return cmd +} diff --git a/pkg/cmd/account/azure-oidc/create/create.go b/pkg/cmd/account/azure-oidc/create/create.go new file mode 100644 index 00000000..abfbac40 --- /dev/null +++ b/pkg/cmd/account/azure-oidc/create/create.go @@ -0,0 +1,398 @@ +package create + +import ( + "fmt" + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/account/shared" + "github.com/OctopusDeploy/cli/pkg/question" + "os" + "strings" + + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd/account/helper" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/surveyext" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/cli/pkg/validation" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/accounts" + "github.com/google/uuid" + "github.com/spf13/cobra" +) + +type CreateFlags struct { + Name *flag.Flag[string] + Description *flag.Flag[string] + Environments *flag.Flag[[]string] + SubscriptionID *flag.Flag[string] + TenantID *flag.Flag[string] + ApplicationID *flag.Flag[string] + AzureEnvironment *flag.Flag[string] + ADEndpointBaseUrl *flag.Flag[string] + RMBaseUri *flag.Flag[string] + HealthSubjectKeys *flag.Flag[[]string] + AccountTestSubjectKeys *flag.Flag[[]string] + ExecutionSubjectKeys *flag.Flag[[]string] + Audience *flag.Flag[string] +} + +type CreateOptions struct { + *CreateFlags + *cmd.Dependencies + selectors.GetAllEnvironmentsCallback +} + +func NewCreateFlags() *CreateFlags { + return &CreateFlags{ + Name: flag.New[string]("name", false), + Description: flag.New[string]("description", false), + Environments: flag.New[[]string]("environment", false), + SubscriptionID: flag.New[string]("subscription-id", false), + TenantID: flag.New[string]("tenant-id", false), + ApplicationID: flag.New[string]("application-id", false), + AzureEnvironment: flag.New[string]("azure-environment", false), + ADEndpointBaseUrl: flag.New[string]("ad-endpoint-base-uri", false), + RMBaseUri: flag.New[string]("resource-management-base-uri", false), + HealthSubjectKeys: flag.New[[]string]("health-subject-keys", false), + AccountTestSubjectKeys: flag.New[[]string]("accounttest-subject-keys", false), + ExecutionSubjectKeys: flag.New[[]string]("execution-subject-keys", false), + Audience: flag.New[string]("audience", false), + } +} + +func NewCreateOptions(flags *CreateFlags, dependencies *cmd.Dependencies) *CreateOptions { + return &CreateOptions{ + CreateFlags: flags, + Dependencies: dependencies, + GetAllEnvironmentsCallback: func() ([]*environments.Environment, error) { + return selectors.GetAllEnvironments(dependencies.Client) + }, + } +} + +func NewCmdCreate(f factory.Factory) *cobra.Command { + createFlags := NewCreateFlags() + descriptionFilePath := "" + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an Azure OpenID Connect account", + Long: "Create an Azure OpenID Connect account in Octopus Deploy", + Example: heredoc.Docf("$ %s account azure-oidc create", constants.ExecutableName), + Aliases: []string{"new"}, + RunE: func(c *cobra.Command, _ []string) error { + opts := NewCreateOptions(createFlags, cmd.NewDependencies(f, c)) + if descriptionFilePath != "" { + if err := validation.IsExistingFile(descriptionFilePath); err != nil { + return err + } + data, err := os.ReadFile(descriptionFilePath) + if err != nil { + return err + } + opts.Description.Value = string(data) + } + opts.NoPrompt = !f.IsPromptEnabled() + + if opts.SubscriptionID.Value != "" { + if err := validation.IsUuid(opts.SubscriptionID.Value); err != nil { + return err + } + } + if opts.TenantID.Value != "" { + if err := validation.IsUuid(opts.TenantID.Value); err != nil { + return err + } + } + if opts.ApplicationID.Value != "" { + if err := validation.IsUuid(opts.ApplicationID.Value); err != nil { + return err + } + } + if opts.AzureEnvironment.Value != "" { + isAzureEnvCorrect := false + for _, value := range shared.AzureEnvMap { + if strings.EqualFold(value, opts.AzureEnvironment.Value) { + opts.AzureEnvironment.Value = value + isAzureEnvCorrect = true + break + } + } + if !isAzureEnvCorrect { + return fmt.Errorf("the Azure environment %s is not correct, please use AzureChinaCloud, AzureChinaCloud, AzureGermanCloud or AzureUSGovernment", opts.AzureEnvironment.Value) + } + if opts.RMBaseUri.Value == "" && opts.NoPrompt { + opts.RMBaseUri.Value = shared.AzureResourceManagementBaseUri[opts.AzureEnvironment.Value] + } + if opts.ADEndpointBaseUrl.Value == "" && opts.NoPrompt { + opts.ADEndpointBaseUrl.Value = shared.AzureADEndpointBaseUri[opts.AzureEnvironment.Value] + } + } + if opts.Environments.Value != nil { + env, err := helper.ResolveEnvironmentNames(opts.Environments.Value, opts.Client) + if err != nil { + return err + } + opts.Environments.Value = env + } + return CreateRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&createFlags.Name.Value, createFlags.Name.Name, "n", "", "A short, memorable, unique name for this account.") + flags.StringVarP(&createFlags.Description.Value, createFlags.Description.Value, "d", "", "A summary explaining the use of the account to other users.") + flags.StringVar(&createFlags.SubscriptionID.Value, createFlags.SubscriptionID.Name, "", "Your Azure subscription ID.") + flags.StringVar(&createFlags.TenantID.Value, createFlags.TenantID.Name, "", "Your Azure Active Directory Tenant ID.") + flags.StringVar(&createFlags.ApplicationID.Value, createFlags.ApplicationID.Name, "", "Your Azure Active Directory Application ID.") + flags.StringArrayVarP(&createFlags.Environments.Value, createFlags.Environments.Name, "e", nil, "The environments that are allowed to use this account") + flags.StringVar(&createFlags.AzureEnvironment.Value, createFlags.AzureEnvironment.Name, "", "Set only if you are using an isolated Azure Environment. Configure isolated Azure Environment. Valid option are AzureChinaCloud, AzureChinaCloud, AzureGermanCloud or AzureUSGovernment") + flags.StringVar(&createFlags.ADEndpointBaseUrl.Value, createFlags.ADEndpointBaseUrl.Name, "", "Set this only if you need to override the default Active Directory Endpoint.") + flags.StringVar(&createFlags.RMBaseUri.Value, createFlags.RMBaseUri.Name, "", "Set this only if you need to override the default Resource Management Endpoint.") + flags.StringArrayVarP(&createFlags.HealthSubjectKeys.Value, createFlags.HealthSubjectKeys.Name, "H", nil, "The subject keys used for a health check") + flags.StringArrayVarP(&createFlags.AccountTestSubjectKeys.Value, createFlags.AccountTestSubjectKeys.Name, "T", nil, "The subject keys used for an account test") + flags.StringArrayVarP(&createFlags.ExecutionSubjectKeys.Value, createFlags.ExecutionSubjectKeys.Name, "E", nil, "The subject keys used for a deployment or runbook") + flags.StringVar(&createFlags.Audience.Value, createFlags.Audience.Name, "", "The audience claim for the federated credentials. Defaults to api://AzureADTokenExchange") + flags.StringVarP(&descriptionFilePath, "description-file", "D", "", "Read the description from `file`") + + return cmd +} + +func CreateRun(opts *CreateOptions) error { + if !opts.NoPrompt { + if err := PromptMissing(opts); err != nil { + return err + } + } + var createdAccount accounts.IAccount + subId, err := uuid.Parse(opts.SubscriptionID.Value) + if err != nil { + return err + } + tenantID, err := uuid.Parse(opts.TenantID.Value) + if err != nil { + return err + } + appID, err := uuid.Parse(opts.ApplicationID.Value) + if err != nil { + return err + } + oidcAccount, err := accounts.NewAzureOIDCAccount( + opts.Name.Value, + subId, + tenantID, + appID, + ) + if err != nil { + return err + } + oidcAccount.HealthCheckSubjectKeys = opts.HealthSubjectKeys.Value + oidcAccount.DeploymentSubjectKeys = opts.ExecutionSubjectKeys.Value + oidcAccount.AccountTestSubjectKeys = opts.AccountTestSubjectKeys.Value + oidcAccount.Audience = opts.Audience.Value + oidcAccount.Description = opts.Description.Value + oidcAccount.AzureEnvironment = opts.AzureEnvironment.Value + oidcAccount.ResourceManagerEndpoint = opts.RMBaseUri.Value + oidcAccount.AuthenticationEndpoint = opts.ADEndpointBaseUrl.Value + + createdAccount, err = opts.Client.Accounts.Add(oidcAccount) + if err != nil { + return err + } + + _, err = fmt.Fprintf(opts.Out, "Successfully created Azure account %s %s.\n", createdAccount.GetName(), output.Dimf("(%s)", createdAccount.GetSlug())) + if err != nil { + return err + } + link := output.Bluef("%s/app#/%s/infrastructure/accounts/%s", opts.Host, opts.Space.GetID(), createdAccount.GetID()) + fmt.Fprintf(opts.Out, "\nView this account on Octopus Deploy: %s\n", link) + if !opts.NoPrompt { + autoCmd := flag.GenerateAutomationCmd( + opts.CmdPath, + opts.Name, + opts.Description, + opts.Environments, + opts.SubscriptionID, + opts.TenantID, + opts.ApplicationID, + opts.AzureEnvironment, + opts.ADEndpointBaseUrl, + opts.RMBaseUri, + opts.HealthSubjectKeys, + opts.AccountTestSubjectKeys, + opts.ExecutionSubjectKeys, + ) + fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) + } + return nil +} + +func PromptMissing(opts *CreateOptions) error { + if opts.Name.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Name", + Help: "A short, memorable, unique name for this account.", + }, &opts.Name.Value, survey.WithValidator(survey.ComposeValidators( + survey.MaxLength(200), + survey.MinLength(1), + survey.Required, + ))); err != nil { + return err + } + } + + if opts.Description.Value == "" { + if err := opts.Ask(&surveyext.OctoEditor{ + Editor: &survey.Editor{ + Message: "Description", + Help: "A summary explaining the use of the account to other users.", + FileName: "*.md", + }, + Optional: true, + }, &opts.Description.Value); err != nil { + return err + } + } + + if opts.SubscriptionID.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Subscription ID", + Help: "Your Azure Subscription ID. This is a GUID in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.", + }, &opts.SubscriptionID.Value, survey.WithValidator(survey.ComposeValidators( + survey.Required, + validation.IsUuid, + ))); err != nil { + return err + } + } + + if opts.TenantID.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Tenant ID", + Help: "Your Azure Active Directory Tenant ID. This is a GUID in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.", + }, &opts.TenantID.Value, survey.WithValidator(survey.ComposeValidators( + survey.Required, + validation.IsUuid, + ))); err != nil { + return err + } + } + + if opts.ApplicationID.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Application ID", + Help: "Your Azure Active Directory Application ID. This is a GUID in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.", + }, &opts.ApplicationID.Value, survey.WithValidator(survey.ComposeValidators( + survey.Required, + validation.IsUuid, + ))); err != nil { + return err + } + } + + if opts.AzureEnvironment.Value == "" { + var shouldConfigureAzureEnvironment bool + if err := opts.Ask(&survey.Confirm{ + Message: "Configure isolated Azure Environment connection.", + Default: false, + }, &shouldConfigureAzureEnvironment); err != nil { + return err + } + if shouldConfigureAzureEnvironment { + envMapKeys := make([]string, 0, len(shared.AzureEnvMap)) + for keys := range shared.AzureEnvMap { + envMapKeys = append(envMapKeys, keys) + } + if err := opts.Ask(&survey.Select{ + Message: "Azure Environment", + Options: envMapKeys, + Default: "Global Cloud (Default)", + }, &opts.AzureEnvironment.Value); err != nil { + return err + } + opts.AzureEnvironment.Value = shared.AzureEnvMap[opts.AzureEnvironment.Value] + } + } + + if opts.AzureEnvironment.Value != "" { + if opts.ADEndpointBaseUrl.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Active Directory endpoint base URI", + Default: shared.AzureADEndpointBaseUri[opts.AzureEnvironment.Value], + Help: "Set this only if you need to override the default Active Directory Endpoint. In most cases you should leave the pre-populated value as is.", + }, &opts.ADEndpointBaseUrl.Value); err != nil { + return err + } + } + if opts.RMBaseUri.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Resource Management Base URI", + Default: shared.AzureResourceManagementBaseUri[opts.AzureEnvironment.Value], + Help: "Set this only if you need to override the default Resource Management Endpoint. In most cases you should leave the pre-populated value as is.", + }, &opts.RMBaseUri.Value); err != nil { + return err + } + } + } + + var err error + if len(opts.ExecutionSubjectKeys.Value) == 0 { + opts.ExecutionSubjectKeys.Value, err = promptSubjectKeys(opts.Ask, "Deployment and Runbook subject keys", []string{"space", "environment", "project", "tenant", "runbook", "account", "type"}) + if err != nil { + return err + } + } + + if len(opts.HealthSubjectKeys.Value) == 0 { + opts.HealthSubjectKeys.Value, err = promptSubjectKeys(opts.Ask, "Health check subject keys", []string{"space", "target", "account", "type"}) + if err != nil { + return err + } + } + + if len(opts.AccountTestSubjectKeys.Value) == 0 { + opts.AccountTestSubjectKeys.Value, err = promptSubjectKeys(opts.Ask, "Account test subject keys", []string{"space", "account", "type"}) + if err != nil { + return err + } + } + + if opts.Audience.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Audience", + Default: "api://AzureADTokenExchange", + Help: "Set this only if you need to override the default Audience value. In most cases you should leave it at the default value.", + }, &opts.Audience.Value); err != nil { + return err + } + } + + if opts.Environments.Value == nil { + envs, err := selectors.EnvironmentsMultiSelect(opts.Ask, opts.GetAllEnvironmentsCallback, + "Choose the environments that are allowed to use this account.\n"+ + output.Dim("If nothing is selected, the account can be used for deployments to any environment."), false) + if err != nil { + return err + } + opts.Environments.Value = util.SliceTransform(envs, func(e *environments.Environment) string { return e.ID }) + } + return nil +} + +func promptSubjectKeys(ask question.Asker, message string, opts []string) ([]string, error) { + keys, err := question.MultiSelectMap(ask, message, opts, func(item string) string { return item }, false) + if err != nil { + return nil, err + } + if len(keys) > 0 { + return keys, nil + } + + return nil, nil +} diff --git a/pkg/cmd/account/azure-oidc/create/create_test.go b/pkg/cmd/account/azure-oidc/create/create_test.go new file mode 100644 index 00000000..6bc54f1c --- /dev/null +++ b/pkg/cmd/account/azure-oidc/create/create_test.go @@ -0,0 +1,84 @@ +package create_test + +import ( + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/stretchr/testify/assert" + "testing" + + "github.com/OctopusDeploy/cli/pkg/cmd/account/azure-oidc/create" + "github.com/OctopusDeploy/cli/test/testutil" +) + +func TestPromptMissing_AllOptionsSupplied(t *testing.T) { + pa := []*testutil.PA{} + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + flags := create.NewCreateFlags() + flags.Name.Value = "The Final Frontier" + flags.Description.Value = "Where no person has gone before" + flags.SubscriptionID.Value = "fec7f106-0e8d-4f27-ac37-530add5e4557" + flags.TenantID.Value = "fec7f106-0e8d-4f27-ac37-530add5e4557" + flags.ApplicationID.Value = "fec7f106-0e8d-4f27-ac37-530add5e4557" + flags.AzureEnvironment.Value = "GlobalCloud" + flags.ADEndpointBaseUrl.Value = "https://something.windows.net" + flags.RMBaseUri.Value = "https://rm.microsoft.net" + flags.ExecutionSubjectKeys.Value = []string{"space"} + flags.HealthSubjectKeys.Value = []string{"space"} + flags.AccountTestSubjectKeys.Value = []string{"space"} + flags.Audience.Value = "custom audience" + flags.Environments.Value = []string{"dev"} + + opts := &create.CreateOptions{ + CreateFlags: flags, + Dependencies: &cmd.Dependencies{Ask: asker}, + } + _ = create.PromptMissing(opts) + checkRemainingPrompts() +} + +func TestPromptMissing_NoOptionsSupplied(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewInputPrompt("Name", "A short, memorable, unique name for this account.", "oidc account"), + testutil.NewInputPrompt("Subscription ID", "Your Azure Subscription ID. This is a GUID in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.", "9843a42e-4fe9-4902-8b38-94257ee2b8d7"), + testutil.NewInputPrompt("Tenant ID", "Your Azure Active Directory Tenant ID. This is a GUID in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.", "c0441a23-3450-41f0-acf2-75c7209b57cc"), + testutil.NewInputPrompt("Application ID", "Your Azure Active Directory Application ID. This is a GUID in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.", "ffe0aca0-91a4-4b2e-a754-26d766b24bec"), + testutil.NewConfirmPromptWithDefault("Configure isolated Azure Environment connection.", "", true, false), + testutil.NewSelectPromptWithDefault("Azure Environment", "", []string{"Global Cloud (Default)", "China Cloud", "German Cloud", "US Government"}, "Global Cloud (Default)", "Global Cloud (Default)"), + testutil.NewInputPromptWithDefault("Active Directory endpoint base URI", "Set this only if you need to override the default Active Directory Endpoint. In most cases you should leave the pre-populated value as is.", "https://login.microsoftonline.com/", ""), + testutil.NewInputPromptWithDefault("Resource Management Base URI", "Set this only if you need to override the default Resource Management Endpoint. In most cases you should leave the pre-populated value as is.", "https://management.azure.com/", ""), + testutil.NewMultiSelectPrompt("Deployment and Runbook subject keys", "", []string{"space", "environment", "project", "tenant", "runbook", "account", "type"}, []string{"space", "type"}), + testutil.NewMultiSelectPrompt("Health check subject keys", "", []string{"space", "target", "account", "type"}, []string{"space", "target"}), + testutil.NewMultiSelectPrompt("Account test subject keys", "", []string{"space", "account", "type"}, []string{"space", "account"}), + testutil.NewInputPromptWithDefault("Audience", "Set this only if you need to override the default Audience value. In most cases you should leave it at the default value.", "api://AzureADTokenExchange", "custom audience"), + testutil.NewMultiSelectPrompt("Choose the environments that are allowed to use this account.\nIf nothing is selected, the account can be used for deployments to any environment.", "", []string{"testenv"}, []string{"testenv"}), + } + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + flags := create.NewCreateFlags() + flags.Description.Value = "the description" // this is due the input mocking not support OctoEditor + + opts := &create.CreateOptions{ + CreateFlags: flags, + Dependencies: &cmd.Dependencies{Ask: asker}, + GetAllEnvironmentsCallback: func() ([]*environments.Environment, error) { + return []*environments.Environment{fixtures.NewEnvironment("Spaces-1", "Environments-1", "testenv")}, nil + }, + } + _ = create.PromptMissing(opts) + + assert.Equal(t, "oidc account", flags.Name.Value) + assert.Equal(t, "9843a42e-4fe9-4902-8b38-94257ee2b8d7", flags.SubscriptionID.Value) + assert.Equal(t, "c0441a23-3450-41f0-acf2-75c7209b57cc", flags.TenantID.Value) + assert.Equal(t, "ffe0aca0-91a4-4b2e-a754-26d766b24bec", flags.ApplicationID.Value) + assert.Equal(t, "AzureCloud", flags.AzureEnvironment.Value) + assert.Equal(t, "", flags.ADEndpointBaseUrl.Value) + assert.Equal(t, "", flags.RMBaseUri.Value) + assert.Equal(t, []string{"space", "type"}, flags.ExecutionSubjectKeys.Value) + assert.Equal(t, []string{"space", "target"}, flags.HealthSubjectKeys.Value) + assert.Equal(t, []string{"space", "account"}, flags.AccountTestSubjectKeys.Value) + assert.Equal(t, "custom audience", flags.Audience.Value) + assert.Equal(t, []string{"Environments-1"}, flags.Environments.Value) + checkRemainingPrompts() +} diff --git a/pkg/cmd/account/azure-oidc/list/list.go b/pkg/cmd/account/azure-oidc/list/list.go new file mode 100644 index 00000000..1e1f08c0 --- /dev/null +++ b/pkg/cmd/account/azure-oidc/list/list.go @@ -0,0 +1,99 @@ +package list + +import ( + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/apiclient" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/accounts" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/spf13/cobra" +) + +func NewCmdList(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List Azure OpenID Connect accounts", + Long: "List Azure OpenID Connect accounts in Octopus Deploy", + Example: heredoc.Docf("$ %s account azure-oidc list", constants.ExecutableName), + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := f.GetSpacedClient(apiclient.NewRequester(cmd)) + if err != nil { + return err + } + return listAzureOidcAccounts(client, cmd) + }, + } + + return cmd +} + +func listAzureOidcAccounts(client *client.Client, cmd *cobra.Command) error { + accountResources, err := client.Accounts.Get(accounts.AccountsQuery{ + AccountType: accounts.AccountTypeAzureOIDC, + }) + if err != nil { + return err + } + items, err := accountResources.GetAllPages(client.Accounts.GetClient()) + if err != nil { + return err + } + + azureEnvMap := map[string]string{ + "": "Global Cloud", + "AzureCloud": "Global Cloud", + "AzureChinaCloud": "China Cloud", + "AzureGermanCloud": "German Cloud", + "AzureUSGovernment": "US Government", + } + + output.PrintArray(items, cmd, output.Mappers[accounts.IAccount]{ + Json: func(item accounts.IAccount) any { + acc := item.(*accounts.AzureOIDCAccount) + return &struct { + Id string + Name string + Slug string + SubscriptionNumber string + TenantID string + ApplicationID string + AccountType string + AzureEnvironment string + HealthSubjectKeys []string + TestSubjectKeys []string + ExecutionSubjectKeys []string + Audience string + }{ + Id: acc.GetID(), + Name: acc.GetName(), + Slug: acc.GetSlug(), + SubscriptionNumber: acc.SubscriptionID.String(), + TenantID: acc.TenantID.String(), + ApplicationID: acc.ApplicationID.String(), + AccountType: string(acc.AccountType), + AzureEnvironment: acc.AzureEnvironment, + HealthSubjectKeys: acc.HealthCheckSubjectKeys, + TestSubjectKeys: acc.AccountTestSubjectKeys, + ExecutionSubjectKeys: acc.DeploymentSubjectKeys, + Audience: acc.Audience, + } + }, + Table: output.TableDefinition[accounts.IAccount]{ + Header: []string{"NAME", "SLUG", "SUBSCRIPTION ID", "AZURE ENVIRONMENT"}, + Row: func(item accounts.IAccount) []string { + acc := item.(*accounts.AzureOIDCAccount) + return []string{ + output.Bold(acc.GetName()), + acc.GetSlug(), + acc.SubscriptionID.String(), + azureEnvMap[acc.AzureEnvironment]} + }}, + Basic: func(item accounts.IAccount) string { + return item.GetName() + }, + }) + return nil +} diff --git a/pkg/cmd/account/azure/create/create.go b/pkg/cmd/account/azure/create/create.go index 96e98f37..6bd5d3c4 100644 --- a/pkg/cmd/account/azure/create/create.go +++ b/pkg/cmd/account/azure/create/create.go @@ -3,6 +3,7 @@ package create import ( "fmt" "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/account/shared" "os" "strings" @@ -69,25 +70,6 @@ func NewCreateOptions(flags *CreateFlags, dependencies *cmd.Dependencies) *Creat } } -var azureEnvMap = map[string]string{ - "Global Cloud (Default)": "AzureCloud", - "China Cloud": "AzureChinaCloud", - "German Cloud": "AzureGermanCloud", - "US Government": "AzureUSGovernment", -} -var azureADEndpointBaseUri = map[string]string{ - "AzureCloud": "https://login.microsoftonline.com/", - "AzureChinaCloud": "https://login.chinacloudapi.cn/", - "AzureGermanCloud": "https://login.microsoftonline.de/", - "AzureUSGovernment": "https://login.microsoftonline.us/", -} -var azureResourceManagementBaseUri = map[string]string{ - "AzureCloud": "https://management.azure.com/", - "AzureChinaCloud": "https://management.chinacloudapi.cn/", - "AzureGermanCloud": "https://management.microsoftazure.de/", - "AzureUSGovernment": "https://management.usgovcloudapi.net/", -} - func NewCmdCreate(f factory.Factory) *cobra.Command { createFlags := NewCreateFlags() descriptionFilePath := "" @@ -113,23 +95,23 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { opts.NoPrompt = !f.IsPromptEnabled() if opts.SubscriptionID.Value != "" { - if err := validation.IsUuid(opts.SubscriptionID); err != nil { + if err := validation.IsUuid(opts.SubscriptionID.Value); err != nil { return err } } if opts.TenantID.Value != "" { - if err := validation.IsUuid(opts.TenantID); err != nil { + if err := validation.IsUuid(opts.TenantID.Value); err != nil { return err } } if opts.ApplicationID.Value != "" { - if err := validation.IsUuid(opts.ApplicationID); err != nil { + if err := validation.IsUuid(opts.ApplicationID.Value); err != nil { return err } } if opts.AzureEnvironment.Value != "" { isAzureEnvCorrect := false - for _, value := range azureEnvMap { + for _, value := range shared.AzureEnvMap { if strings.EqualFold(value, opts.AzureEnvironment.Value) { opts.AzureEnvironment.Value = value isAzureEnvCorrect = true @@ -140,10 +122,10 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { return fmt.Errorf("the Azure environment %s is not correct, please use AzureChinaCloud, AzureChinaCloud, AzureGermanCloud or AzureUSGovernment", opts.AzureEnvironment.Value) } if opts.RMBaseUri.Value == "" && opts.NoPrompt { - opts.RMBaseUri.Value = azureResourceManagementBaseUri[opts.AzureEnvironment.Value] + opts.RMBaseUri.Value = shared.AzureResourceManagementBaseUri[opts.AzureEnvironment.Value] } if opts.ADEndpointBaseUrl.Value == "" && opts.NoPrompt { - opts.ADEndpointBaseUrl.Value = azureADEndpointBaseUri[opts.AzureEnvironment.Value] + opts.ADEndpointBaseUrl.Value = shared.AzureADEndpointBaseUri[opts.AzureEnvironment.Value] } } if opts.Environments.Value != nil { @@ -267,7 +249,7 @@ func PromptMissing(opts *CreateOptions) error { if opts.SubscriptionID.Value == "" { if err := opts.Ask(&survey.Input{ Message: "Subscription ID", - Help: "Your Azure subscription ID. This is a GUID in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.", + Help: "Your Azure Subscription ID. This is a GUID in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.", }, &opts.SubscriptionID.Value, survey.WithValidator(survey.ComposeValidators( survey.Required, validation.IsUuid, @@ -291,7 +273,7 @@ func PromptMissing(opts *CreateOptions) error { if opts.ApplicationID.Value == "" { if err := opts.Ask(&survey.Input{ Message: "Application ID", - Help: "Your Azure Active Directory Tenant ID. This is a GUID in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.", + Help: "Your Azure Active Directory Application ID. This is a GUID in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.", }, &opts.ApplicationID.Value, survey.WithValidator(survey.ComposeValidators( survey.Required, validation.IsUuid, @@ -320,8 +302,8 @@ func PromptMissing(opts *CreateOptions) error { return err } if shouldConfigureAzureEnvironment { - envMapKeys := make([]string, 0, len(azureEnvMap)) - for keys := range azureEnvMap { + envMapKeys := make([]string, 0, len(shared.AzureEnvMap)) + for keys := range shared.AzureEnvMap { envMapKeys = append(envMapKeys, keys) } if err := opts.Ask(&survey.Select{ @@ -331,7 +313,7 @@ func PromptMissing(opts *CreateOptions) error { }, &opts.AzureEnvironment.Value); err != nil { return err } - opts.AzureEnvironment.Value = azureEnvMap[opts.AzureEnvironment.Value] + opts.AzureEnvironment.Value = shared.AzureEnvMap[opts.AzureEnvironment.Value] } } @@ -339,7 +321,7 @@ func PromptMissing(opts *CreateOptions) error { if opts.ADEndpointBaseUrl.Value == "" { if err := opts.Ask(&survey.Input{ Message: "Active Directory endpoint base URI", - Default: azureADEndpointBaseUri[opts.AzureEnvironment.Value], + Default: shared.AzureADEndpointBaseUri[opts.AzureEnvironment.Value], Help: "Set this only if you need to override the default Active Directory Endpoint. In most cases you should leave the pre-populated value as is.", }, &opts.ADEndpointBaseUrl.Value); err != nil { return err @@ -348,7 +330,7 @@ func PromptMissing(opts *CreateOptions) error { if opts.RMBaseUri.Value == "" { if err := opts.Ask(&survey.Input{ Message: "Resource Management Base URI", - Default: azureResourceManagementBaseUri[opts.AzureEnvironment.Value], + Default: shared.AzureResourceManagementBaseUri[opts.AzureEnvironment.Value], Help: "Set this only if you need to override the default Resource Management Endpoint. In most cases you should leave the pre-populated value as is.", }, &opts.RMBaseUri.Value); err != nil { return err diff --git a/pkg/cmd/account/create/create.go b/pkg/cmd/account/create/create.go index eaa26f87..92df18d1 100644 --- a/pkg/cmd/account/create/create.go +++ b/pkg/cmd/account/create/create.go @@ -6,6 +6,7 @@ import ( "github.com/MakeNowJust/heredoc/v2" "github.com/OctopusDeploy/cli/pkg/cmd" awsCreate "github.com/OctopusDeploy/cli/pkg/cmd/account/aws/create" + azureOidcCreate "github.com/OctopusDeploy/cli/pkg/cmd/account/azure-oidc/create" azureCreate "github.com/OctopusDeploy/cli/pkg/cmd/account/azure/create" gcpCreate "github.com/OctopusDeploy/cli/pkg/cmd/account/gcp/create" sshCreate "github.com/OctopusDeploy/cli/pkg/cmd/account/ssh/create" @@ -19,6 +20,7 @@ import ( const ( AwsAccount = "AWS Account" AzureAccount = "Azure Account" + AzureOidcAccount = "Azure OIDC Account" GcpAccount = "Google Cloud Account" SshAccount = "SSH Key Pair" UsernamePasswordAccount = "Username/Password" @@ -46,6 +48,7 @@ func createRun(f factory.Factory, c *cobra.Command) error { accountTypes := []string{ AwsAccount, AzureAccount, + AzureOidcAccount, GcpAccount, SshAccount, UsernamePasswordAccount, @@ -73,6 +76,11 @@ func createRun(f factory.Factory, c *cobra.Command) error { if err := azureCreate.CreateRun(opts); err != nil { return err } + case AzureOidcAccount: + opts := azureOidcCreate.NewCreateOptions(azureOidcCreate.NewCreateFlags(), cmd.NewDependenciesFromExisting(dependencies, fmt.Sprintf("%s account azure-oidc create", constants.ExecutableName))) + if err := azureOidcCreate.CreateRun(opts); err != nil { + return err + } case GcpAccount: opts := gcpCreate.NewCreateOptions(gcpCreate.NewCreateFlags(), cmd.NewDependenciesFromExisting(dependencies, fmt.Sprintf("%s account gcp create", constants.ExecutableName))) if err := gcpCreate.CreateRun(opts); err != nil { diff --git a/pkg/cmd/account/list/list.go b/pkg/cmd/account/list/list.go index 4585166a..92c40bf3 100644 --- a/pkg/cmd/account/list/list.go +++ b/pkg/cmd/account/list/list.go @@ -44,6 +44,7 @@ func NewCmdList(f factory.Factory) *cobra.Command { accounts.AccountTypeAmazonWebServicesAccount: "AWS Account", accounts.AccountTypeAzureSubscription: "Azure Subscription", accounts.AccountTypeAzureServicePrincipal: "Azure Service Principal", + accounts.AccountTypeAzureOIDC: "Azure OpenID Connect", accounts.AccountTypeGoogleCloudPlatformAccount: "Google Cloud Account", accounts.AccountTypeSSHKeyPair: "SSH Key Pair", accounts.AccountTypeUsernamePassword: "Username/Password", diff --git a/pkg/cmd/account/shared/shared.go b/pkg/cmd/account/shared/shared.go new file mode 100644 index 00000000..2b059042 --- /dev/null +++ b/pkg/cmd/account/shared/shared.go @@ -0,0 +1,20 @@ +package shared + +var AzureEnvMap = map[string]string{ + "Global Cloud (Default)": "AzureCloud", + "China Cloud": "AzureChinaCloud", + "German Cloud": "AzureGermanCloud", + "US Government": "AzureUSGovernment", +} +var AzureADEndpointBaseUri = map[string]string{ + "AzureCloud": "https://login.microsoftonline.com/", + "AzureChinaCloud": "https://login.chinacloudapi.cn/", + "AzureGermanCloud": "https://login.microsoftonline.de/", + "AzureUSGovernment": "https://login.microsoftonline.us/", +} +var AzureResourceManagementBaseUri = map[string]string{ + "AzureCloud": "https://management.azure.com/", + "AzureChinaCloud": "https://management.chinacloudapi.cn/", + "AzureGermanCloud": "https://management.microsoftazure.de/", + "AzureUSGovernment": "https://management.usgovcloudapi.net/", +} diff --git a/test/testutil/fakesurvey.go b/test/testutil/fakesurvey.go index 59c748a2..21540ee9 100644 --- a/test/testutil/fakesurvey.go +++ b/test/testutil/fakesurvey.go @@ -62,6 +62,18 @@ func NewSelectPrompt(prompt string, help string, options []string, response stri } } +func NewSelectPromptWithDefault(prompt string, help string, options []string, def string, response string) *PA { + return &PA{ + Prompt: &survey.Select{ + Message: prompt, + Options: options, + Default: def, + Help: help, + }, + Answer: response, + } +} + func NewMultiSelectPrompt(prompt string, help string, options []string, responses []string) *PA { return &PA{ Prompt: &survey.MultiSelect{