From 87745a9aa4ae5046ed848bd89a3f64e7d922ddf2 Mon Sep 17 00:00:00 2001 From: grace-rehn Date: Wed, 4 Dec 2024 13:34:47 +1000 Subject: [PATCH] feat: Add support for generic oidc accounts (#455) * feat: Add support for generic oidc accounts * fix: missing audience from automation command * chore: update go client version --- go.mod | 2 +- go.sum | 4 +- pkg/cmd/account/account.go | 2 + pkg/cmd/account/generic-oidc/create/create.go | 213 ++++++++++++++++++ .../generic-oidc/create/create_test.go | 58 +++++ pkg/cmd/account/generic-oidc/generic-oidc.go | 24 ++ pkg/cmd/account/generic-oidc/list/list.go | 78 +++++++ 7 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 pkg/cmd/account/generic-oidc/create/create.go create mode 100644 pkg/cmd/account/generic-oidc/create/create_test.go create mode 100644 pkg/cmd/account/generic-oidc/generic-oidc.go create mode 100644 pkg/cmd/account/generic-oidc/list/list.go diff --git a/go.mod b/go.mod index 9e74fd8b..ef654ff8 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.62.0 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.63.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 5d0d46da..6e1889f6 100644 --- a/go.sum +++ b/go.sum @@ -48,8 +48,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/OctopusDeploy/go-octodiff v1.0.0 h1:U+ORg6azniwwYo+O44giOw6TiD5USk8S4VDhOQ0Ven0= github.com/OctopusDeploy/go-octodiff v1.0.0/go.mod h1:Mze0+EkOWTgTmi8++fyUc6r0aLZT7qD9gX+31t8MmIU= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.62.0 h1:p2qapGMs+BGZfRmKa+1K6j7J9g+n6bxTWPZFeHZ/W0o= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.62.0/go.mod h1:ggvOXzMnq+w0pLg6C9zdjz6YBaHfO3B3tqmmB7JQdaw= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.63.0 h1:TshwN+IqKt21uY9aXzj0ou0Ew92uIi3+ZGTccVd9Z8g= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.63.0/go.mod h1:ggvOXzMnq+w0pLg6C9zdjz6YBaHfO3B3tqmmB7JQdaw= 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 9586df97..76e04ec9 100644 --- a/pkg/cmd/account/account.go +++ b/pkg/cmd/account/account.go @@ -8,6 +8,7 @@ import ( 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" + cmdGenericOidc "github.com/OctopusDeploy/cli/pkg/cmd/account/generic-oidc" cmdList "github.com/OctopusDeploy/cli/pkg/cmd/account/list" cmdSSH "github.com/OctopusDeploy/cli/pkg/cmd/account/ssh" cmdToken "github.com/OctopusDeploy/cli/pkg/cmd/account/token" @@ -35,6 +36,7 @@ func NewCmdAccount(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdAWS.NewCmdAws(f)) cmd.AddCommand(cmdAzure.NewCmdAzure(f)) cmd.AddCommand(cmdAzureOidc.NewCmdAzureOidc(f)) + cmd.AddCommand(cmdGenericOidc.NewCmdGenericOidc(f)) cmd.AddCommand(cmdGCP.NewCmdGcp(f)) cmd.AddCommand(cmdSSH.NewCmdSsh(f)) cmd.AddCommand(cmdUsr.NewCmdUsername(f)) diff --git a/pkg/cmd/account/generic-oidc/create/create.go b/pkg/cmd/account/generic-oidc/create/create.go new file mode 100644 index 00000000..b3d511d1 --- /dev/null +++ b/pkg/cmd/account/generic-oidc/create/create.go @@ -0,0 +1,213 @@ +package create + +import ( + "fmt" + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + "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" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/surveyext" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/cli/pkg/validation" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/accounts" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/spf13/cobra" + "os" +) + +type CreateFlags struct { + Name *flag.Flag[string] + Description *flag.Flag[string] + Environments *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), + 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 Generic OpenID Connect account", + Long: "Create an Generic OpenID Connect account in Octopus Deploy", + Example: heredoc.Docf("$ %s account generic-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.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.StringArrayVarP(&createFlags.Environments.Value, createFlags.Environments.Name, "e", nil, "The environments that are allowed to use this account") + 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://default") + 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 + oidcAccount, err := accounts.NewGenericOIDCAccount( + opts.Name.Value, + ) + if err != nil { + return err + } + oidcAccount.DeploymentSubjectKeys = opts.ExecutionSubjectKeys.Value + oidcAccount.Audience = opts.Audience.Value + oidcAccount.Description = opts.Description.Value + + createdAccount, err = opts.Client.Accounts.Add(oidcAccount) + if err != nil { + return err + } + + _, err = fmt.Fprintf(opts.Out, "Successfully created Generic 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.ExecutionSubjectKeys, + opts.Audience, + ) + 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 + } + } + + 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 opts.Audience.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Audience", + Default: "api://default", + Help: "Set this if you need to override the default Audience 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/generic-oidc/create/create_test.go b/pkg/cmd/account/generic-oidc/create/create_test.go new file mode 100644 index 00000000..c13f4e0c --- /dev/null +++ b/pkg/cmd/account/generic-oidc/create/create_test.go @@ -0,0 +1,58 @@ +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/generic-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.ExecutionSubjectKeys.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.NewMultiSelectPrompt("Deployment and Runbook subject keys", "", []string{"space", "environment", "project", "tenant", "runbook", "account", "type"}, []string{"space", "type"}), + testutil.NewInputPromptWithDefault("Audience", "Set this if you need to override the default Audience value.", "api://default", "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, "custom audience", flags.Audience.Value) + assert.Equal(t, []string{"Environments-1"}, flags.Environments.Value) + checkRemainingPrompts() +} diff --git a/pkg/cmd/account/generic-oidc/generic-oidc.go b/pkg/cmd/account/generic-oidc/generic-oidc.go new file mode 100644 index 00000000..05e1dfc7 --- /dev/null +++ b/pkg/cmd/account/generic-oidc/generic-oidc.go @@ -0,0 +1,24 @@ +package generic_oidc + +import ( + "github.com/MakeNowJust/heredoc/v2" + cmdCreate "github.com/OctopusDeploy/cli/pkg/cmd/account/generic-oidc/create" + cmdList "github.com/OctopusDeploy/cli/pkg/cmd/account/generic-oidc/list" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/spf13/cobra" +) + +func NewCmdGenericOidc(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "generic-oidc ", + Short: "Manage Generic OpenID Connect accounts", + Long: "Manage Generic OpenID Connect accounts in Octopus Deploy", + Example: heredoc.Docf("$ %s account generic-oidc list", constants.ExecutableName), + } + + cmd.AddCommand(cmdList.NewCmdList(f)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f)) + + return cmd +} diff --git a/pkg/cmd/account/generic-oidc/list/list.go b/pkg/cmd/account/generic-oidc/list/list.go new file mode 100644 index 00000000..111d0452 --- /dev/null +++ b/pkg/cmd/account/generic-oidc/list/list.go @@ -0,0 +1,78 @@ +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 Generic OpenID Connect accounts", + Long: "List Generic OpenID Connect accounts in Octopus Deploy", + Example: heredoc.Docf("$ %s account generic-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 listGenericOidcAccounts(client, cmd) + }, + } + + return cmd +} + +func listGenericOidcAccounts(client *client.Client, cmd *cobra.Command) error { + accountResources, err := client.Accounts.Get(accounts.AccountsQuery{ + AccountType: accounts.AccountTypeGenericOIDCAccount, + }) + if err != nil { + return err + } + items, err := accountResources.GetAllPages(client.Accounts.GetClient()) + if err != nil { + return err + } + + output.PrintArray(items, cmd, output.Mappers[accounts.IAccount]{ + Json: func(item accounts.IAccount) any { + acc := item.(*accounts.GenericOIDCAccount) + return &struct { + Id string + Name string + Slug string + AccountType string + ExecutionSubjectKeys []string + Audience string + }{ + Id: acc.GetID(), + Name: acc.GetName(), + Slug: acc.GetSlug(), + AccountType: string(acc.AccountType), + ExecutionSubjectKeys: acc.DeploymentSubjectKeys, + Audience: acc.Audience, + } + }, + Table: output.TableDefinition[accounts.IAccount]{ + Header: []string{"NAME", "SLUG", "AUDIENCE"}, + Row: func(item accounts.IAccount) []string { + acc := item.(*accounts.GenericOIDCAccount) + return []string{ + output.Bold(acc.GetName()), + acc.GetSlug(), + acc.Audience} + }}, + Basic: func(item accounts.IAccount) string { + return item.GetName() + }, + }) + return nil +}