Skip to content

Commit

Permalink
feat: Add support for generic oidc accounts (#455)
Browse files Browse the repository at this point in the history
* feat: Add support for generic oidc accounts

* fix: missing audience from automation command

* chore: update go client version
  • Loading branch information
grace-rehn authored Dec 4, 2024
1 parent 20c2f9f commit 87745a9
Show file tree
Hide file tree
Showing 7 changed files with 378 additions and 3 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions pkg/cmd/account/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down
213 changes: 213 additions & 0 deletions pkg/cmd/account/generic-oidc/create/create.go
Original file line number Diff line number Diff line change
@@ -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
}
58 changes: 58 additions & 0 deletions pkg/cmd/account/generic-oidc/create/create_test.go
Original file line number Diff line number Diff line change
@@ -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()
}
24 changes: 24 additions & 0 deletions pkg/cmd/account/generic-oidc/generic-oidc.go
Original file line number Diff line number Diff line change
@@ -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 <command>",
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
}
Loading

0 comments on commit 87745a9

Please sign in to comment.