From faa3b4c46dad0167859d3e053dec9229832b9159 Mon Sep 17 00:00:00 2001 From: Ben Pearce Date: Fri, 6 Jan 2023 12:12:01 +1000 Subject: [PATCH] feat: project variables create (#198) feat: project variables update feat: project variables list feat: project variables view feat: project variables delete --- examples.md | 22 + pkg/cmd/project/convert/convert.go | 4 +- pkg/cmd/project/project.go | 2 + pkg/cmd/project/variables/create/create.go | 423 ++++++++++++++++++ .../project/variables/create/create_test.go | 177 ++++++++ pkg/cmd/project/variables/delete/delete.go | 149 ++++++ pkg/cmd/project/variables/list/list.go | 93 ++++ pkg/cmd/project/variables/shared/input.go | 233 ++++++++++ .../project/variables/shared/input_test.go | 108 +++++ pkg/cmd/project/variables/shared/scopes.go | 220 +++++++++ pkg/cmd/project/variables/update/update.go | 309 +++++++++++++ .../project/variables/update/update_test.go | 203 +++++++++ pkg/cmd/project/variables/variables.go | 39 ++ pkg/cmd/project/variables/view/view.go | 180 ++++++++ pkg/cmd/target/shared/tenant.go | 6 +- pkg/cmd/tenant/clone/clone.go | 4 +- pkg/cmd/tenant/connect/connect.go | 6 +- pkg/cmd/tenant/disconnect/disconnect.go | 8 +- pkg/cmd/tenant/shared/shared.go | 8 +- pkg/util/util.go | 16 +- pkg/util/util_test.go | 20 + 21 files changed, 2208 insertions(+), 22 deletions(-) create mode 100644 pkg/cmd/project/variables/create/create.go create mode 100644 pkg/cmd/project/variables/create/create_test.go create mode 100644 pkg/cmd/project/variables/delete/delete.go create mode 100644 pkg/cmd/project/variables/list/list.go create mode 100644 pkg/cmd/project/variables/shared/input.go create mode 100644 pkg/cmd/project/variables/shared/input_test.go create mode 100644 pkg/cmd/project/variables/shared/scopes.go create mode 100644 pkg/cmd/project/variables/update/update.go create mode 100644 pkg/cmd/project/variables/update/update_test.go create mode 100644 pkg/cmd/project/variables/variables.go create mode 100644 pkg/cmd/project/variables/view/view.go diff --git a/examples.md b/examples.md index 99c53b36..7e373aad 100644 --- a/examples.md +++ b/examples.md @@ -116,3 +116,25 @@ octopus project convert --project 'Project 54' \ ``` An existing project can be converted to Config As Code using the `convert` command + +# View all values for a project variable + +``` +octopus project variables view BlueGreenTarget +``` + +# Set project variable prior to creating a release + +In this example the `Id` represents the specific value for the variable `BlueGreenTarget` that has been scoped to the production environment. +The Id can be obtained with the `project variables view` command. + +``` +value=`octopus project variables view BlueGreenTarget --project "Random Quotes" --id d8527596-6fa2-4394-94e1-07942d3d0202 | grep Value` +if [[ $value =~ 'Blue' ]]; then + value="Green" +else + value="Blue" +fi +octopus project variables update BlueGreenTarget --project "Random Quotes" --id d8527596-6fa2-4394-94e1-07942d3d0202 --name "" --value $value --no-prompt +octopus release create --version 1.0.1 --project "Random Quotes" --no-prompt +``` \ No newline at end of file diff --git a/pkg/cmd/project/convert/convert.go b/pkg/cmd/project/convert/convert.go index f80a5027..4aac3f83 100644 --- a/pkg/cmd/project/convert/convert.go +++ b/pkg/cmd/project/convert/convert.go @@ -95,9 +95,9 @@ func NewConvertOptions(flags *ConvertFlags, dependencies *cmd.Dependencies) *Con return createGetAllGitCredentialsCallback(*dependencies.Client) }, GetProjectCallback: func(identifier string) (*projects.Project, error) { - return shared.GetProject(*dependencies.Client, identifier) + return shared.GetProject(dependencies.Client, identifier) }, - GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(*dependencies.Client) }, + GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(dependencies.Client) }, } } diff --git a/pkg/cmd/project/project.go b/pkg/cmd/project/project.go index 6e6230a3..97098f36 100644 --- a/pkg/cmd/project/project.go +++ b/pkg/cmd/project/project.go @@ -8,6 +8,7 @@ import ( cmdDelete "github.com/OctopusDeploy/cli/pkg/cmd/project/delete" cmdDisconnect "github.com/OctopusDeploy/cli/pkg/cmd/project/disconnect" cmdList "github.com/OctopusDeploy/cli/pkg/cmd/project/list" + cmdVariables "github.com/OctopusDeploy/cli/pkg/cmd/project/variables" cmdView "github.com/OctopusDeploy/cli/pkg/cmd/project/view" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/constants/annotations" @@ -37,6 +38,7 @@ func NewCmdProject(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdConnect.NewCmdConnect(f)) cmd.AddCommand(cmdDisconnect.NewCmdDisconnect(f)) cmd.AddCommand(cmdConvert.NewCmdConvert(f)) + cmd.AddCommand(cmdVariables.NewCmdVariables(f)) return cmd } diff --git a/pkg/cmd/project/variables/create/create.go b/pkg/cmd/project/variables/create/create.go new file mode 100644 index 00000000..e64b1899 --- /dev/null +++ b/pkg/cmd/project/variables/create/create.go @@ -0,0 +1,423 @@ +package create + +import ( + "fmt" + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + sharedVariable "github.com/OctopusDeploy/cli/pkg/cmd/project/variables/shared" + "github.com/OctopusDeploy/cli/pkg/cmd/tenant/shared" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/spf13/cobra" + "strings" +) + +const ( + FlagProject = "project" + FlagName = "name" + FlagValue = "value" + FlagType = "type" + FlagDescription = "description" + + FlagPrompt = "prompted" + FlagPromptLabel = "prompt-label" + FlagPromptDescription = "prompt-description" + FlagPromptType = "prompt-type" + FlagPromptRequired = "prompt-required" + FlagPromptSelectOptions = "prompt-dropdown-option" + + TypeText = "text" + TypeSensitive = "sensitive" + TypeAwsAccount = "awsaccount" + TypeWorkerPool = "workerpool" + TypeAzureAccount = "azureaccount" + TypeCertificate = "certificate" + TypeGoogleAccount = "googleaccount" + + PromptTypeText = "text" + PromptTypeMultiText = "multiline-text" + PromptTypeCheckbox = "checkbox" + PromptTypeDropdown = "dropdown" +) + +type CreateFlags struct { + Project *flag.Flag[string] + Name *flag.Flag[string] + Description *flag.Flag[string] + Value *flag.Flag[string] + Type *flag.Flag[string] + + *sharedVariable.ScopeFlags + + IsPrompted *flag.Flag[bool] + PromptLabel *flag.Flag[string] + PromptDescription *flag.Flag[string] + PromptType *flag.Flag[string] + PromptRequired *flag.Flag[bool] + PromptSelectOptions *flag.Flag[[]string] +} + +type CreateOptions struct { + *CreateFlags + *cmd.Dependencies + shared.GetProjectCallback + shared.GetAllProjectsCallback + *sharedVariable.VariableCallbacks +} + +func NewCreateFlags() *CreateFlags { + return &CreateFlags{ + Project: flag.New[string](FlagProject, false), + Name: flag.New[string](FlagName, false), + Value: flag.New[string](FlagValue, false), + Description: flag.New[string](FlagDescription, false), + Type: flag.New[string](FlagType, false), + ScopeFlags: sharedVariable.NewScopeFlags(), + IsPrompted: flag.New[bool](FlagPrompt, false), + PromptLabel: flag.New[string](FlagPromptLabel, false), + PromptDescription: flag.New[string](FlagPromptDescription, false), + PromptType: flag.New[string](FlagPromptType, false), + PromptRequired: flag.New[bool](FlagPromptRequired, false), + PromptSelectOptions: flag.New[[]string](FlagPromptSelectOptions, false), + } +} + +func NewCreateOptions(flags *CreateFlags, dependencies *cmd.Dependencies) *CreateOptions { + return &CreateOptions{ + CreateFlags: flags, + Dependencies: dependencies, + GetProjectCallback: func(identifier string) (*projects.Project, error) { + return shared.GetProject(dependencies.Client, identifier) + }, + GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(dependencies.Client) }, + VariableCallbacks: sharedVariable.NewVariableCallbacks(dependencies), + } +} + +func NewCreateCmd(f factory.Factory) *cobra.Command { + createFlags := NewCreateFlags() + cmd := &cobra.Command{ + Use: "create", + Short: "Create a variable for a project", + Long: "Create a variable for a project in Octopus Deploy", + Aliases: []string{"add"}, + Example: heredoc.Docf(` + $ %[1]s project variable create + $ %[1]s project variable create --name varname --value "abc" + $ %[1]s project variable create --name varname --value "passwordABC" --type sensitive + $ %[1]s project variable create --name varname --value "abc" --scope environment='test' + `, constants.ExecutableName), + RunE: func(c *cobra.Command, args []string) error { + opts := NewCreateOptions(createFlags, cmd.NewDependencies(f, c)) + if opts.Type.Value == TypeSensitive { + opts.Value.Secure = true + } + + return createRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&createFlags.Project.Value, createFlags.Project.Name, "p", "", "The project") + flags.StringVarP(&createFlags.Name.Value, createFlags.Name.Name, "n", "", "The name of the variable") + flags.StringVarP(&createFlags.Type.Value, createFlags.Type.Name, "t", "", fmt.Sprintf("The type of variable. Valid values are %s. Default is %s", strings.Join([]string{TypeText, TypeSensitive, TypeWorkerPool, TypeAwsAccount, TypeAzureAccount, TypeGoogleAccount, TypeCertificate}, ", "), TypeText)) + flags.StringVar(&createFlags.Value.Value, createFlags.Value.Name, "", "The value to set on the variable") + + sharedVariable.RegisterScopeFlags(cmd, createFlags.ScopeFlags) + flags.BoolVar(&createFlags.IsPrompted.Value, createFlags.IsPrompted.Name, false, "Make a prompted variable") + flags.StringVar(&createFlags.PromptLabel.Value, createFlags.PromptLabel.Name, "", "The label for the prompted variable") + flags.StringVar(&createFlags.PromptDescription.Value, createFlags.PromptDescription.Name, "", "Description for the prompted variable") + flags.StringVar(&createFlags.PromptType.Value, createFlags.PromptType.Name, "", fmt.Sprintf("The input type for the prompted variable. Valid values are '%s', '%s', '%s' and '%s'", PromptTypeText, PromptTypeMultiText, PromptTypeCheckbox, PromptTypeDropdown)) + flags.BoolVar(&createFlags.PromptRequired.Value, createFlags.PromptRequired.Name, false, "Prompt will require a value for deployment") + flags.StringSliceVar(&createFlags.PromptSelectOptions.Value, createFlags.PromptSelectOptions.Name, []string{}, "Options for a dropdown prompt. May be specified multiple times. Must be in format 'value|description'") + return cmd +} + +func createRun(opts *CreateOptions) error { + if !opts.NoPrompt { + err := PromptMissing(opts) + if err != nil { + return err + } + } + + project, err := opts.GetProjectCallback(opts.Project.Value) + if err != nil { + return err + } + + projectVariables, err := opts.GetProjectVariables(project.GetID()) + if err != nil { + return err + } + + scope, err := sharedVariable.ToVariableScope(projectVariables, opts.ScopeFlags, project) + if err != nil { + return err + } + + newVariable := variables.NewVariable(opts.Name.Value) + varType, err := mapVariableType(opts.Type.Value) + if err != nil { + return err + } + + newVariable.Type = varType + newVariable.Value = opts.Value.Value + newVariable.Scope = *scope + + if opts.IsPrompted.Value { + promptControlType, err := mapControlType(opts.PromptType.Value) + if err != nil { + return err + } + newVariable.Prompt = &variables.VariablePromptOptions{ + Description: opts.PromptDescription.Value, + Label: opts.PromptLabel.Value, + IsRequired: opts.PromptRequired.Value, + } + + selectOptions := parseSelectOptions(opts, promptControlType) + newVariable.Prompt.DisplaySettings = variables.NewDisplaySettings(promptControlType, selectOptions) + } + + _, err = opts.Client.Variables.AddSingle(project.GetID(), newVariable) + if err != nil { + return err + } + + _, err = fmt.Fprintf(opts.Out, "Successfully created variable '%s' in project '%s'\n", opts.Name.Value, project.GetName()) + + if !opts.NoPrompt { + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Project, opts.Name, opts.Value, opts.Description, opts.Type, opts.EnvironmentsScopes, opts.ChannelScopes, opts.StepScopes, opts.TargetScopes, opts.TagScopes, opts.RoleScopes, opts.ProcessScopes, opts.IsPrompted, opts.PromptType, opts.PromptLabel, opts.PromptDescription, opts.PromptSelectOptions, opts.PromptRequired) + fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) + } + + return nil +} + +func PromptMissing(opts *CreateOptions) error { + var project *projects.Project + var err error + if opts.Project.Value == "" { + project, err = projectSelector("You have not specified a Project. Please select one:", opts.GetAllProjectsCallback, opts.Ask) + if err != nil { + return nil + } + opts.Project.Value = project.GetName() + } else { + project, err = opts.GetProjectCallback(opts.Project.Value) + if err != nil { + return err + } + } + + if opts.Name.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Name", + Help: fmt.Sprintf("A name for this variable."), + }, &opts.Name.Value, survey.WithValidator(survey.ComposeValidators( + survey.MaxLength(200), + survey.MinLength(1), + survey.Required, + ))); err != nil { + return err + } + } + + question.AskDescription(opts.Ask, "", "Variable", &opts.Description.Value) + + if opts.Type.Value == "" { + selectedType, err := selectors.SelectOptions(opts.Ask, "Select the type of the variable", getVariableTypeOptions) + if err != nil { + return err + } + opts.Type.Value = selectedType.Value + } + + if !opts.IsPrompted.Value { + opts.Ask(&survey.Confirm{ + Message: "Is this a prompted variable?", + Default: false, + }, &opts.IsPrompted.Value) + } + + if opts.IsPrompted.Value { + if opts.PromptLabel.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Prompt Label", + }, &opts.PromptLabel.Value); err != nil { + return err + } + } + + question.AskDescription(opts.Ask, "Prompt ", "Prompted Variable", &opts.PromptDescription.Value) + + if opts.PromptType.Value == "" { + selectedPromptType, err := selectors.SelectOptions(opts.Ask, "Select the control type of the prompted variable", getControlTypeOptions) + if err != nil { + return err + } + opts.PromptType.Value = selectedPromptType.Value + } + + if opts.PromptType.Value == PromptTypeDropdown && util.Empty(opts.PromptSelectOptions.Value) { + for { + var value string + + if err := opts.Ask(&survey.Input{ + Message: "Enter a selection option value (enter blank to end)", + }, &value, survey.WithValidator(survey.MaxLength(200))); err != nil { + return err + } + + if strings.TrimSpace(value) == "" { + break + } + + var description string + if err := opts.Ask(&survey.Input{ + Message: "Enter a selection option description", + }, &description, survey.WithValidator(survey.ComposeValidators(survey.Required))); err != nil { + return err + } + + opts.PromptSelectOptions.Value = append(opts.PromptSelectOptions.Value, fmt.Sprintf("%s|%s", value, description)) + } + } + + if !opts.PromptRequired.Value { + if err := opts.Ask(&survey.Confirm{ + Message: "Is this the prompted variable required to have a value supplied?", + Default: false, + }, &opts.PromptRequired.Value); err != nil { + return err + } + } + + } + + if opts.Value.Value == "" { + variableType, err := mapVariableType(opts.Type.Value) + if err != nil { + return err + } + opts.Value.Value, err = sharedVariable.PromptValue(opts.Ask, variableType, opts.VariableCallbacks) + if err != nil { + return err + } + } + + projectVariables, err := opts.GetProjectVariables(project.GetID()) + if err != nil { + return err + } + + scope, err := sharedVariable.ToVariableScope(projectVariables, opts.ScopeFlags, project) + if err != nil { + return err + } + + if scope.IsEmpty() { + err = sharedVariable.PromptScopes(opts.Ask, projectVariables, opts.ScopeFlags, opts.IsPrompted.Value) + if err != nil { + return err + } + } + + return nil +} + +func getVariableTypeOptions() []*selectors.SelectOption[string] { + return []*selectors.SelectOption[string]{ + {Display: "Text", Value: TypeText}, + {Display: "Sensitive", Value: TypeSensitive}, + {Display: "Certificate", Value: TypeCertificate}, + {Display: "Worker Pool", Value: TypeWorkerPool}, + {Display: "Azure Account", Value: TypeAzureAccount}, + {Display: "Aws Account", Value: TypeAwsAccount}, + {Display: "Google Account", Value: TypeGoogleAccount}, + } +} + +func getControlTypeOptions() []*selectors.SelectOption[string] { + return []*selectors.SelectOption[string]{ + {Display: "Single line text", Value: PromptTypeText}, + {Display: "Multi line text", Value: PromptTypeMultiText}, + {Display: "Checkbox", Value: PromptTypeCheckbox}, + {Display: "Drop down", Value: PromptTypeDropdown}, + } +} + +func projectSelector(questionText string, getAllProjectsCallback shared.GetAllProjectsCallback, ask question.Asker) (*projects.Project, error) { + existingProjects, err := getAllProjectsCallback() + if err != nil { + return nil, err + } + + return question.SelectMap(ask, questionText, existingProjects, func(p *projects.Project) string { return p.GetName() }) +} + +func parseSelectOptions(opts *CreateOptions, controlType variables.ControlType) []*variables.SelectOption { + options := []*variables.SelectOption{} + if controlType != variables.ControlTypeSelect { + return options + } + + for _, selectOption := range opts.PromptSelectOptions.Value { + o := strings.Split(selectOption, "|") + options = append(options, &variables.SelectOption{ + Value: o[0], + DisplayName: o[1], + }) + } + + return options +} + +func mapVariableType(varType string) (string, error) { + if varType == "" { + varType = TypeText + } + + switch varType { + case TypeText: + return "String", nil + case TypeSensitive: + return "Sensitive", nil + case TypeAwsAccount: + return "AmazonWebServicesAccount", nil + case TypeWorkerPool: + return "WorkerPool", nil + case TypeAzureAccount: + return "AzureAccount", nil + case TypeCertificate: + return "Certificate", nil + case TypeGoogleAccount: + return "GoogleCloudAccount", nil + default: + return "", fmt.Errorf("unknown variable type '%s', valid values are '%s','%s','%s', '%s', '%s', '%s', '%s'", varType, TypeText, TypeSensitive, TypeAzureAccount, TypeAwsAccount, TypeGoogleAccount, TypeWorkerPool, TypeCertificate) + } +} + +func mapControlType(promptType string) (variables.ControlType, error) { + switch promptType { + case PromptTypeText: + return variables.ControlTypeSingleLineText, nil + case PromptTypeMultiText: + return variables.ControlTypeMultiLineText, nil + case PromptTypeCheckbox: + return variables.ControlTypeCheckbox, nil + case PromptTypeDropdown: + return variables.ControlTypeSelect, nil + default: + return "", fmt.Errorf("unknown prompt type '%s', valid values are '%s','%s','%s', '%s'", promptType, PromptTypeText, PromptTypeMultiText, PromptTypeCheckbox, PromptTypeDropdown) + } +} diff --git a/pkg/cmd/project/variables/create/create_test.go b/pkg/cmd/project/variables/create/create_test.go new file mode 100644 index 00000000..c039c0bf --- /dev/null +++ b/pkg/cmd/project/variables/create/create_test.go @@ -0,0 +1,177 @@ +package create_test + +import ( + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/project/variables/create" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestPromptMissing_AllFlagsProvided(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewConfirmPromptWithDefault("Is this a prompted variable?", "", false, false), + } + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + flags := create.NewCreateFlags() + flags.Name.Value = "What is a name" + flags.Description.Value = "The un-describable" + flags.Value.Value = "new value" + flags.Type.Value = "Text" + flags.IsPrompted.Value = false + flags.EnvironmentsScopes.Value = []string{"test"} + flags.Project.Value = "Project" + opts := create.NewCreateOptions(flags, &cmd.Dependencies{Ask: asker}) + opts.GetProjectCallback = func(identifier string) (*projects.Project, error) { + return projects.NewProject("Project", "Lifecycles-1", "ProjectGroups-1"), nil + } + opts.GetProjectVariables = func(projectId string) (*variables.VariableSet, error) { + return &variables.VariableSet{ + OwnerID: "Projects-1", + ScopeValues: &variables.VariableScopeValues{ + Environments: []*resources.ReferenceDataItem{{ID: "Environments-1", Name: "test"}}, + }, + SpaceID: "Spaces-1", + Variables: []*variables.Variable{}, + }, nil + } + + err := create.PromptMissing(opts) + + assert.NoError(t, err) + checkRemainingPrompts() +} + +func TestPromptMissing_AllFlagsProvided_PromptedVariable(t *testing.T) { + pa := []*testutil.PA{} + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + flags := create.NewCreateFlags() + flags.Name.Value = "What is a name" + flags.Description.Value = "The un-describable" + flags.Value.Value = "new value" + flags.Type.Value = "Text" + flags.IsPrompted.Value = true + flags.PromptRequired.Value = true + flags.PromptDescription.Value = "prompted description" + flags.PromptType.Value = "String" + flags.PromptLabel.Value = "prompt?" + flags.EnvironmentsScopes.Value = []string{"test"} + flags.Project.Value = "Project" + opts := create.NewCreateOptions(flags, &cmd.Dependencies{Ask: asker}) + opts.GetProjectCallback = func(identifier string) (*projects.Project, error) { + return projects.NewProject("Project", "Lifecycles-1", "ProjectGroups-1"), nil + } + opts.GetProjectVariables = func(projectId string) (*variables.VariableSet, error) { + return &variables.VariableSet{ + OwnerID: "Projects-1", + ScopeValues: &variables.VariableScopeValues{ + Environments: []*resources.ReferenceDataItem{{ID: "Environments-1", Name: "test"}}, + }, + SpaceID: "Spaces-1", + Variables: []*variables.Variable{}, + }, nil + } + + err := create.PromptMissing(opts) + + assert.NoError(t, err) + checkRemainingPrompts() +} + +func TestPromptMissing_NoFlags(t *testing.T) { + project1 := projects.NewProject("Project", "Lifecycles-1", "ProjectGroups-1") + project2 := projects.NewProject("Project 2", "Lifecycles-1", "ProjectGroups-1") + + pa := []*testutil.PA{ + testutil.NewSelectPrompt("You have not specified a Project. Please select one:", "", []string{project1.Name, project2.Name}, project1.Name), + testutil.NewInputPrompt("Name", "A name for this variable.", "Ship name"), + testutil.NewInputPrompt("Description", "A short, memorable, description for this Variable.", "the ship will need a valid name to be able to travel in interstellar space"), + testutil.NewSelectPrompt("Select the type of the variable", "", []string{"Text", "Sensitive", "Certificate", "Worker Pool", "Azure Account", "Aws Account", "Google Account"}, "Text"), + testutil.NewConfirmPromptWithDefault("Is this a prompted variable?", "", true, false), + testutil.NewInputPrompt("Prompt Label", "", "prompt label"), + testutil.NewInputPrompt("Prompt Description", "A short, memorable, description for this Prompted Variable.", "prompt description"), + testutil.NewSelectPrompt("Select the control type of the prompted variable", "", []string{"Single line text", "Multi line text", "Checkbox", "Drop down"}, "Single line text"), + testutil.NewConfirmPromptWithDefault("Is this the prompted variable required to have a value supplied?", "", true, false), + testutil.NewInputPrompt("Value", "", "Spaceball 1"), + } + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + flags := create.NewCreateFlags() + + opts := create.NewCreateOptions(flags, &cmd.Dependencies{Ask: asker}) + + opts.GetProjectCallback = func(identifier string) (*projects.Project, error) { + return project1, nil + } + opts.GetAllProjectsCallback = func() ([]*projects.Project, error) { + return []*projects.Project{project1, project2}, nil + } + opts.GetProjectVariables = func(projectId string) (*variables.VariableSet, error) { + return &variables.VariableSet{ + OwnerID: "Projects-1", + ScopeValues: &variables.VariableScopeValues{}, + SpaceID: "Spaces-1", + Variables: []*variables.Variable{}, + }, nil + } + + err := create.PromptMissing(opts) + assert.NoError(t, err) + checkRemainingPrompts() + assert.Equal(t, "Ship name", opts.Name.Value) + assert.Equal(t, "the ship will need a valid name to be able to travel in interstellar space", opts.Description.Value) + assert.Equal(t, create.TypeText, opts.Type.Value) + assert.Equal(t, "Spaceball 1", opts.Value.Value) + assert.Equal(t, true, opts.IsPrompted.Value) + assert.Equal(t, "prompt label", opts.PromptLabel.Value) + assert.Equal(t, "prompt description", opts.PromptDescription.Value) + assert.Equal(t, create.PromptTypeText, opts.PromptType.Value) + assert.Equal(t, true, opts.PromptRequired.Value) + +} + +func TestPromptMissing_PromptedVariableForSelectOptions(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewInputPrompt("Enter a selection option value (enter blank to end)", "", "value1"), + testutil.NewInputPrompt("Enter a selection option description", "", "display 1"), + testutil.NewInputPrompt("Enter a selection option value (enter blank to end)", "", "value2"), + testutil.NewInputPrompt("Enter a selection option description", "", "display 2"), + testutil.NewInputPrompt("Enter a selection option value (enter blank to end)", "", ""), + } + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + flags := create.NewCreateFlags() + flags.Name.Value = "What is a name" + flags.Description.Value = "The un-describable" + flags.Value.Value = "new value" + flags.Type.Value = "Text" + flags.IsPrompted.Value = true + flags.PromptRequired.Value = true + flags.PromptDescription.Value = "prompted description" + flags.PromptType.Value = create.PromptTypeDropdown + flags.PromptLabel.Value = "prompt?" + flags.EnvironmentsScopes.Value = []string{"test"} + flags.Project.Value = "Project" + opts := create.NewCreateOptions(flags, &cmd.Dependencies{Ask: asker}) + opts.GetProjectCallback = func(identifier string) (*projects.Project, error) { + return projects.NewProject("Project", "Lifecycles-1", "ProjectGroups-1"), nil + } + opts.GetProjectVariables = func(projectId string) (*variables.VariableSet, error) { + return &variables.VariableSet{ + OwnerID: "Projects-1", + ScopeValues: &variables.VariableScopeValues{ + Environments: []*resources.ReferenceDataItem{{ID: "Environments-1", Name: "test"}}, + }, + SpaceID: "Spaces-1", + Variables: []*variables.Variable{}, + }, nil + } + + err := create.PromptMissing(opts) + + assert.NoError(t, err) + checkRemainingPrompts() + assert.Equal(t, []string{"value1|display 1", "value2|display 2"}, opts.PromptSelectOptions.Value) +} diff --git a/pkg/cmd/project/variables/delete/delete.go b/pkg/cmd/project/variables/delete/delete.go new file mode 100644 index 00000000..d387c4a6 --- /dev/null +++ b/pkg/cmd/project/variables/delete/delete.go @@ -0,0 +1,149 @@ +package delete + +import ( + "fmt" + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/tenant/shared" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/spf13/cobra" + "strings" +) + +const ( + FlagId = "id" + FlagName = "name" + FlagProject = "project" +) + +type DeleteFlags struct { + Id *flag.Flag[string] + Name *flag.Flag[string] + Project *flag.Flag[string] + *question.ConfirmFlags +} + +type DeleteOptions struct { + *DeleteFlags + *cmd.Dependencies + shared.GetProjectCallback +} + +func NewDeleteFlags() *DeleteFlags { + return &DeleteFlags{ + Id: flag.New[string](FlagId, false), + Name: flag.New[string](FlagName, false), + Project: flag.New[string](FlagProject, false), + ConfirmFlags: question.NewConfirmFlags(), + } +} + +func NewDeleteOptions(flags *DeleteFlags, dependencies *cmd.Dependencies) *DeleteOptions { + return &DeleteOptions{ + DeleteFlags: flags, + Dependencies: dependencies, + GetProjectCallback: func(identifier string) (*projects.Project, error) { + return shared.GetProject(dependencies.Client, identifier) + }, + } +} + +func NewDeleteCmd(f factory.Factory) *cobra.Command { + deleteFlags := NewDeleteFlags() + cmd := &cobra.Command{ + Use: "delete {}", + Aliases: []string{"del", "rm", "remove"}, + Short: "Delete a project variable", + Long: "Delete a project variable in Octopus Deploy", + Example: heredoc.Docf(` + $ %[1]s project variable delete "Database Name" --project "Deploy Site" + $ %[1]s project variable delete "Database Name" --id 26a58596-4cd9-e072-7215-7e15cb796dd2 --project "Deploy Site" --confirm + `, constants.ExecutableName), + RunE: func(c *cobra.Command, args []string) error { + opts := NewDeleteOptions(deleteFlags, cmd.NewDependencies(f, c)) + + return deleteRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVar(&deleteFlags.Id.Value, deleteFlags.Id.Name, "", "The id of the specific variable value to delete") + flags.StringVarP(&deleteFlags.Name.Value, deleteFlags.Name.Name, "n", "", "The name of the variable") + flags.StringVarP(&deleteFlags.Project.Value, deleteFlags.Project.Name, "p", "", "The project") + question.RegisterConfirmDeletionFlag(cmd, &deleteFlags.Confirm.Value, "project variable") + + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + if opts.Name.Value == "" { + return fmt.Errorf("variable name is required but was not provided") + } + + project, err := opts.Client.Projects.GetByIdentifier(opts.Project.Value) + if err != nil { + return err + } + + allVars, err := opts.Client.Variables.GetAll(project.GetID()) + if err != nil { + return err + } + + filteredVars := util.SliceFilter( + allVars.Variables, + func(variable *variables.Variable) bool { + return strings.EqualFold(variable.Name, opts.Name.Value) + }) + + if !util.Any(filteredVars) { + return fmt.Errorf("cannot find variable '%s'", opts.Name.Value) + } + + if len(filteredVars) == 0 { + return fmt.Errorf("cannot find variable named '%s'", opts.Name.Value) + } + + if len(filteredVars) > 1 { + if opts.Id.Value == "" { + return fmt.Errorf("'%s' has multiple values, supply '%s' flag", filteredVars[0].Name, FlagId) + } + + filteredVars = util.SliceFilter(filteredVars, func(variable *variables.Variable) bool { + return variable.ID == opts.Id.Value + }) + } + + if len(filteredVars) == 1 { + targetVar := filteredVars[0] + targetIndex := -1 + for i, v := range allVars.Variables { + if v.ID == targetVar.ID { + targetIndex = i + } + } + + allVars.Variables = util.RemoveIndex(allVars.Variables, targetIndex) + if opts.ConfirmFlags.Confirm.Value { + delete(opts, project, allVars) + } else { + return question.DeleteWithConfirmation(opts.Ask, "variable", targetVar.Name, targetVar.ID, func() error { + return delete(opts, project, allVars) + }) + } + + } + + return nil +} + +func delete(opts *DeleteOptions, project *projects.Project, allVars variables.VariableSet) error { + _, err := opts.Client.Variables.Update(project.GetID(), allVars) + return err +} diff --git a/pkg/cmd/project/variables/list/list.go b/pkg/cmd/project/variables/list/list.go new file mode 100644 index 00000000..e4a7fbd2 --- /dev/null +++ b/pkg/cmd/project/variables/list/list.go @@ -0,0 +1,93 @@ +package list + +import ( + "fmt" + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/apiclient" + variableShared "github.com/OctopusDeploy/cli/pkg/cmd/project/variables/shared" + "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/variables" + "github.com/spf13/cobra" + "sort" + "strconv" +) + +func NewCmdList(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List project variables", + Long: "List project variables in Octopus Deploy", + Example: heredoc.Docf(` + $ %[1]s project variable list + $ %[1]s project variable ls + `, constants.ExecutableName), + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("must supply project identifier") + } + return listRun(cmd, f, args[0]) + }, + } + + return cmd +} + +type VariableAsJson struct { + *variables.Variable + Scope variables.VariableScopeValues +} + +func listRun(cmd *cobra.Command, f factory.Factory, id string) error { + client, err := f.GetSpacedClient(apiclient.NewRequester(cmd)) + if err != nil { + return err + } + + project, err := client.Projects.GetByIdentifier(id) + if err != nil { + return err + } + + vars, err := client.Variables.GetAll(project.GetID()) + if err != nil { + return err + } + + allVariables := vars.Variables + sort.SliceStable(allVariables, func(i, j int) bool { + return allVariables[i].Name < allVariables[j].Name + }) + + return output.PrintArray(vars.Variables, cmd, output.Mappers[*variables.Variable]{ + Json: func(v *variables.Variable) any { + enhancedScope, err := variableShared.ToScopeValues(v, vars.ScopeValues) + if err != nil { + return err + } + return VariableAsJson{ + Variable: v, + Scope: *enhancedScope} + }, + Table: output.TableDefinition[*variables.Variable]{ + Header: []string{"NAME", "DESCRIPTION", "VALUE", "IS PROMPTED", "ID"}, + Row: func(v *variables.Variable) []string { + return []string{output.Bold(v.Name), v.Description, getValue(v), strconv.FormatBool(v.Prompt != nil), output.Dim(v.GetID())} + }, + }, + Basic: func(v *variables.Variable) string { + return v.Name + }, + }) + +} + +func getValue(v *variables.Variable) string { + if v.IsSensitive { + return "***" + } + + return v.Value +} diff --git a/pkg/cmd/project/variables/shared/input.go b/pkg/cmd/project/variables/shared/input.go new file mode 100644 index 00000000..dd9f9bc0 --- /dev/null +++ b/pkg/cmd/project/variables/shared/input.go @@ -0,0 +1,233 @@ +package shared + +import ( + "fmt" + "github.com/AlecAivazis/survey/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/accounts" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/certificates" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/workerpools" +) + +type GetAccountsByTypeCallback func(accountType accounts.AccountType) ([]accounts.IAccount, error) +type GetAllWorkerPoolsCallback func() ([]*workerpools.WorkerPoolListResult, error) +type GetAllCertificatesCallback func() ([]*certificates.CertificateResource, error) +type GetProjectVariablesCallback func(projectId string) (*variables.VariableSet, error) +type GetVariableByIdCallback func(ownerId, variableId string) (*variables.Variable, error) + +type VariableCallbacks struct { + GetAccountsByType GetAccountsByTypeCallback + GetAllWorkerPools GetAllWorkerPoolsCallback + GetAllCertificates GetAllCertificatesCallback + GetProjectVariables GetProjectVariablesCallback + GetVariableById GetVariableByIdCallback +} + +func NewVariableCallbacks(dependencies *cmd.Dependencies) *VariableCallbacks { + return &VariableCallbacks{ + GetAccountsByType: func(accountType accounts.AccountType) ([]accounts.IAccount, error) { + return getAccountsByType(dependencies.Client, accountType) + }, + GetAllWorkerPools: func() ([]*workerpools.WorkerPoolListResult, error) { + return getAllWorkerPools(dependencies.Client) + }, + GetAllCertificates: func() ([]*certificates.CertificateResource, error) { + return getAllCertificates(dependencies.Client) + }, + GetProjectVariables: func(projectId string) (*variables.VariableSet, error) { + return getProjectVariables(dependencies.Client, projectId) + }, + GetVariableById: func(ownerId, variableId string) (*variables.Variable, error) { + return getVariableById(dependencies.Client, ownerId, variableId) + }, + } +} + +func PromptValue(ask question.Asker, variableType string, callbacks *VariableCallbacks) (string, error) { + var value string + switch variableType { + case "String": + if err := ask(&survey.Input{ + Message: "Value", + }, &value); err != nil { + return "", err + } + return value, nil + case "Sensitive": + if err := ask(&survey.Password{ + Message: "Value", + }, &value); err != nil { + return "", err + } + return value, nil + case "AmazonWebServicesAccount", "AzureAccount", "GoogleCloudAccount": + accountType, err := mapVariableTypeToAccountType(variableType) + if err != nil { + return "", err + } + accountsByType, err := callbacks.GetAccountsByType(accountType) + if err != nil { + return "", err + } + + selectedValue, err := selectors.ByName(ask, accountsByType, "Value") + if err != nil { + return "", err + } + return selectedValue.GetName(), nil + case "WorkerPool": + workerPools, err := callbacks.GetAllWorkerPools() + if err != nil { + return "", err + } + selectedValue, err := selectors.Select( + ask, + "Value", + func() ([]*workerpools.WorkerPoolListResult, error) { return workerPools, nil }, + func(item *workerpools.WorkerPoolListResult) string { return item.Name }) + if err != nil { + return "", err + } + return selectedValue.Name, nil + case "Certificate": + allCerts, err := callbacks.GetAllCertificates() + if err != nil { + return "", err + } + selectedValue, err := selectors.Select( + ask, + "Value", + func() ([]*certificates.CertificateResource, error) { return allCerts, nil }, + func(item *certificates.CertificateResource) string { return item.Name }) + if err != nil { + return "", err + } + return selectedValue.Name, nil + } + + return "", fmt.Errorf("error getting value") +} + +func PromptScopes(asker question.Asker, projectVariables *variables.VariableSet, flags *ScopeFlags, isPrompted bool) error { + var err error + if util.Empty(flags.EnvironmentsScopes.Value) { + flags.EnvironmentsScopes.Value, err = PromptScope(asker, "Environment", projectVariables.ScopeValues.Environments, nil) + if err != nil { + return err + } + } + + flags.ProcessScopes.Value, err = PromptScope(asker, "Process", ConvertProcessScopesToReference(projectVariables.ScopeValues.Processes), nil) + if err != nil { + return err + } + + if !isPrompted { + flags.ChannelScopes.Value, err = PromptScope(asker, "Channel", projectVariables.ScopeValues.Channels, nil) + if err != nil { + return err + } + + flags.TargetScopes.Value, err = PromptScope(asker, "Target", projectVariables.ScopeValues.Machines, nil) + if err != nil { + return err + } + + flags.RoleScopes.Value, err = PromptScope(asker, "Role", projectVariables.ScopeValues.Roles, nil) + if err != nil { + return err + } + + flags.TagScopes.Value, err = PromptScope(asker, "Tag", projectVariables.ScopeValues.TenantTags, func(i *resources.ReferenceDataItem) string { return i.ID }) + if err != nil { + return err + } + + flags.StepScopes.Value, err = PromptScope(asker, "Step", projectVariables.ScopeValues.Actions, nil) + if err != nil { + return err + } + } + + return nil +} + +func PromptScope(ask question.Asker, scopeDescription string, items []*resources.ReferenceDataItem, displaySelector func(i *resources.ReferenceDataItem) string) ([]string, error) { + if displaySelector == nil { + displaySelector = func(i *resources.ReferenceDataItem) string { return i.Name } + } + if util.Empty(items) { + return nil, nil + } + var selectedItems []string + err := ask(&survey.MultiSelect{ + Message: fmt.Sprintf("%s scope", scopeDescription), + Options: util.SliceTransform(items, displaySelector), + }, &selectedItems) + + if err != nil { + return nil, err + } + + return selectedItems, nil +} + +func mapVariableTypeToAccountType(variableType string) (accounts.AccountType, error) { + switch variableType { + case "AmazonWebServicesAccount": + return accounts.AccountTypeAmazonWebServicesAccount, nil + case "AzureAccount": + return accounts.AccountTypeAzureServicePrincipal, nil + case "GoogleCloudAccount": + return accounts.AccountTypeGoogleCloudPlatformAccount, nil + default: + return accounts.AccountTypeNone, fmt.Errorf("variable type '%s' is not a valid account variable type", variableType) + + } +} + +func getAccountsByType(client *client.Client, accountType accounts.AccountType) ([]accounts.IAccount, error) { + accountResources, err := client.Accounts.Get(accounts.AccountsQuery{ + AccountType: accountType, + }) + if err != nil { + return nil, err + } + items, err := accountResources.GetAllPages(client.Accounts.GetClient()) + if err != nil { + return nil, err + } + return items, nil +} + +func getAllCertificates(client *client.Client) ([]*certificates.CertificateResource, error) { + certs, err := client.Certificates.Get(certificates.CertificatesQuery{}) + if err != nil { + return nil, err + } + return certs.GetAllPages(client.Sling()) +} + +func getAllWorkerPools(client *client.Client) ([]*workerpools.WorkerPoolListResult, error) { + res, err := client.WorkerPools.GetAll() + if err != nil { + return nil, err + } + + return res, nil +} + +func getProjectVariables(client *client.Client, id string) (*variables.VariableSet, error) { + variableSet, err := client.Variables.GetAll(id) + return &variableSet, err +} + +func getVariableById(client *client.Client, ownerId string, variableId string) (*variables.Variable, error) { + return client.Variables.GetByID(ownerId, variableId) +} diff --git a/pkg/cmd/project/variables/shared/input_test.go b/pkg/cmd/project/variables/shared/input_test.go new file mode 100644 index 00000000..5e924450 --- /dev/null +++ b/pkg/cmd/project/variables/shared/input_test.go @@ -0,0 +1,108 @@ +package shared_test + +import ( + "github.com/OctopusDeploy/cli/pkg/cmd/project/variables/shared" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestPromptScopes(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewMultiSelectPrompt("Environment scope", "", []string{"test"}, []string{"test"}), + testutil.NewMultiSelectPrompt("Process scope", "", []string{"Run, book, run"}, []string{"Run, book, run"}), + testutil.NewMultiSelectPrompt("Channel scope", "", []string{"Default channel"}, []string{"Default channel"}), + testutil.NewMultiSelectPrompt("Target scope", "", []string{"Deployment target"}, []string{"Deployment target"}), + testutil.NewMultiSelectPrompt("Role scope", "", []string{"Role 1"}, []string{"Role 1"}), + testutil.NewMultiSelectPrompt("Tag scope", "", []string{"tag set/tag 1"}, []string{"tag set/tag 1"}), + testutil.NewMultiSelectPrompt("Step scope", "", []string{"Step name"}, []string{"Step name"}), + } + + variable := variables.NewVariable("") + variable.ID = "123abc" + variableSet := &variables.VariableSet{ + OwnerID: "Projects-1", + ScopeValues: &variables.VariableScopeValues{ + Environments: []*resources.ReferenceDataItem{{ID: "Environments-1", Name: "test"}}, + Actions: []*resources.ReferenceDataItem{{ID: "actionId", Name: "Step name"}}, + Channels: []*resources.ReferenceDataItem{{ID: "Channels-1", Name: "Default channel"}}, + Machines: []*resources.ReferenceDataItem{{ID: "Machines-1", Name: "Deployment target"}}, + TenantTags: []*resources.ReferenceDataItem{{ID: "tag set/tag 1", Name: "tag 1"}}, + Roles: []*resources.ReferenceDataItem{{ID: "Role 1", Name: "Role 1"}}, + Processes: []*resources.ProcessReferenceDataItem{{ID: "Runbooks-1", Name: "Run, book, run"}}, + }, + SpaceID: "Spaces-1", + Variables: []*variables.Variable{variable}, + } + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + + flags := shared.NewScopeFlags() + err := shared.PromptScopes(asker, variableSet, flags, false) + + assert.NoError(t, err) + checkRemainingPrompts() +} + +func TestPromptScopes_Prompted(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewMultiSelectPrompt("Environment scope", "", []string{"test"}, []string{"test"}), + testutil.NewMultiSelectPrompt("Process scope", "", []string{"Run, book, run"}, []string{"Run, book, run"}), + } + + variable := variables.NewVariable("") + variable.ID = "123abc" + variableSet := &variables.VariableSet{ + OwnerID: "Projects-1", + ScopeValues: &variables.VariableScopeValues{ + Environments: []*resources.ReferenceDataItem{{ID: "Environments-1", Name: "test"}}, + Actions: []*resources.ReferenceDataItem{{ID: "actionId", Name: "Step name"}}, + Channels: []*resources.ReferenceDataItem{{ID: "Channels-1", Name: "Default channel"}}, + Machines: []*resources.ReferenceDataItem{{ID: "Machines-1", Name: "Deployment target"}}, + TenantTags: []*resources.ReferenceDataItem{{ID: "TenantTags-1", Name: "tag set/tag 1"}}, + Roles: []*resources.ReferenceDataItem{{ID: "Role 1", Name: "Role 1"}}, + Processes: []*resources.ProcessReferenceDataItem{{ID: "Runbooks-1", Name: "Run, book, run"}}, + }, + SpaceID: "Spaces-1", + Variables: []*variables.Variable{variable}, + } + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + + flags := shared.NewScopeFlags() + err := shared.PromptScopes(asker, variableSet, flags, true) + + assert.NoError(t, err) + checkRemainingPrompts() +} + +func TestPromptScope_NoItems(t *testing.T) { + pa := []*testutil.PA{} + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + + selectedValue, err := shared.PromptScope(asker, "Test", nil, nil) + + assert.NoError(t, err) + checkRemainingPrompts() + assert.Nil(t, selectedValue) +} + +func TestPromptScope_HasItems(t *testing.T) { + pa := []*testutil.PA{ + testutil.NewMultiSelectPrompt("Test scope", "", []string{"test", "not test"}, []string{"test", "not test"}), + } + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + + selectedValue, err := shared.PromptScope(asker, "Test", []*resources.ReferenceDataItem{ + {ID: "item-1", Name: "test"}, + {ID: "item-2", Name: "not test"}, + }, nil) + + assert.NoError(t, err) + checkRemainingPrompts() + assert.Equal(t, []string{"test", "not test"}, selectedValue) +} diff --git a/pkg/cmd/project/variables/shared/scopes.go b/pkg/cmd/project/variables/shared/scopes.go new file mode 100644 index 00000000..acec89ec --- /dev/null +++ b/pkg/cmd/project/variables/shared/scopes.go @@ -0,0 +1,220 @@ +package shared + +import ( + "fmt" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/spf13/cobra" + "strings" +) + +const ( + FlagEnvironmentScope = "environment-scope" + FlagTargetScope = "target-scope" + FlagStepScope = "step-scope" + FlagRoleScope = "role-scope" + FlagChannelScope = "channel-scope" + FlagTagScope = "tag-scope" + FlagProcessScope = "process-scope" +) + +type ScopeFlags struct { + EnvironmentsScopes *flag.Flag[[]string] + ChannelScopes *flag.Flag[[]string] + TargetScopes *flag.Flag[[]string] + StepScopes *flag.Flag[[]string] + RoleScopes *flag.Flag[[]string] + TagScopes *flag.Flag[[]string] + ProcessScopes *flag.Flag[[]string] +} + +func NewScopeFlags() *ScopeFlags { + return &ScopeFlags{ + EnvironmentsScopes: flag.New[[]string](FlagEnvironmentScope, false), + ChannelScopes: flag.New[[]string](FlagChannelScope, false), + TargetScopes: flag.New[[]string](FlagTargetScope, false), + StepScopes: flag.New[[]string](FlagStepScope, false), + RoleScopes: flag.New[[]string](FlagRoleScope, false), + TagScopes: flag.New[[]string](FlagTagScope, false), + ProcessScopes: flag.New[[]string](FlagProcessScope, false), + } +} + +func RegisterScopeFlags(cmd *cobra.Command, scopeFlags *ScopeFlags) { + flags := cmd.Flags() + flags.StringSliceVar(&scopeFlags.EnvironmentsScopes.Value, scopeFlags.EnvironmentsScopes.Name, []string{}, "Assign environment scopes to the variable. Multiple scopes can be supplied.") + flags.StringSliceVar(&scopeFlags.ChannelScopes.Value, scopeFlags.ChannelScopes.Name, []string{}, "Assign channel scopes to the variable. Multiple scopes can be supplied.") + flags.StringSliceVar(&scopeFlags.TargetScopes.Value, scopeFlags.TargetScopes.Name, []string{}, "Assign deployment target scopes to the variable. Multiple scopes can be supplied.") + flags.StringSliceVar(&scopeFlags.StepScopes.Value, scopeFlags.StepScopes.Name, []string{}, "Assign process step scopes to the variable. Multiple scopes can be supplied.") + flags.StringSliceVar(&scopeFlags.RoleScopes.Value, scopeFlags.RoleScopes.Name, []string{}, "Assign role scopes to the variable. Multiple scopes can be supplied.") + flags.StringSliceVar(&scopeFlags.TagScopes.Value, scopeFlags.TagScopes.Name, []string{}, "Assign tag scopes to the variable. Multiple scopes can be supplied.") + flags.StringSliceVar(&scopeFlags.ProcessScopes.Value, scopeFlags.ProcessScopes.Name, []string{}, "Assign process scopes to the variable. Valid scopes are 'deployment' or a runbook name. Multiple scopes can be supplied.") +} + +func ToScopeValues(variable *variables.Variable, variableScopeValues *variables.VariableScopeValues) (*variables.VariableScopeValues, error) { + scopeValues := &variables.VariableScopeValues{} + + var err error + scopeValues.Environments, err = getSingleScope(variable.Scope.Environments, variableScopeValues.Environments) + if err != nil { + return nil, err + } + + scopeValues.Channels, err = getSingleScope(variable.Scope.Channels, variableScopeValues.Channels) + if err != nil { + return nil, err + } + + scopeValues.Actions, err = getSingleScope(variable.Scope.Actions, variableScopeValues.Actions) + if err != nil { + return nil, err + } + + scopeValues.TenantTags, err = getSingleScope(variable.Scope.TenantTags, variableScopeValues.TenantTags) + if err != nil { + return nil, err + } + + scopeValues.Roles, err = getSingleScope(variable.Scope.Roles, variableScopeValues.Roles) + if err != nil { + return nil, err + } + + scopeValues.Machines, err = getSingleScope(variable.Scope.Machines, variableScopeValues.Machines) + if err != nil { + return nil, err + } + + scopeValues.Processes, err = getSingleProcessScope(variable.Scope.ProcessOwners, variableScopeValues.Processes) + if err != nil { + return nil, err + } + + return scopeValues, nil +} + +func getSingleScope(scopes []string, lookupScopes []*resources.ReferenceDataItem) ([]*resources.ReferenceDataItem, error) { + var referenceScopes []*resources.ReferenceDataItem + for _, s := range scopes { + scope, err := findSingleScope(s, lookupScopes) + if err != nil { + return nil, err + } + referenceScopes = append(referenceScopes, scope) + } + + return referenceScopes, nil +} + +func findSingleScope(scope string, scopes []*resources.ReferenceDataItem) (*resources.ReferenceDataItem, error) { + for _, s := range scopes { + if strings.EqualFold(scope, s.ID) { + return s, nil + } + } + + return nil, fmt.Errorf("cannot find scope value for '%s'", scope) +} + +func getSingleProcessScope(scopes []string, lookupScopes []*resources.ProcessReferenceDataItem) ([]*resources.ProcessReferenceDataItem, error) { + var referenceScopes []*resources.ProcessReferenceDataItem + for _, s := range scopes { + scope, err := findSingleProcessScope(s, lookupScopes) + if err != nil { + return nil, err + } + referenceScopes = append(referenceScopes, scope) + } + + return referenceScopes, nil +} + +func findSingleProcessScope(scope string, scopes []*resources.ProcessReferenceDataItem) (*resources.ProcessReferenceDataItem, error) { + for _, s := range scopes { + if strings.EqualFold(scope, s.ID) { + return s, nil + } + } + + return nil, fmt.Errorf("cannot find scope value for '%s'", scope) +} + +func ToVariableScope(projectVariables *variables.VariableSet, opts *ScopeFlags, project *projects.Project) (*variables.VariableScope, error) { + scope := &variables.VariableScope{} + var err error + scope.Environments, err = buildSingleScope(opts.EnvironmentsScopes.Value, projectVariables.ScopeValues.Environments) + if err != nil { + return nil, err + } + + scope.Roles, err = buildSingleScope(opts.RoleScopes.Value, projectVariables.ScopeValues.Roles) + if err != nil { + return nil, err + } + + scope.Machines, err = buildSingleScope(opts.TargetScopes.Value, projectVariables.ScopeValues.Machines) + if err != nil { + return nil, err + } + + scope.TenantTags, err = buildSingleScope(opts.TagScopes.Value, projectVariables.ScopeValues.TenantTags) + if err != nil { + return nil, err + } + + scope.Actions, err = buildSingleScope(opts.StepScopes.Value, projectVariables.ScopeValues.Actions) + if err != nil { + return nil, err + } + + scope.Channels, err = buildSingleScope(opts.ChannelScopes.Value, projectVariables.ScopeValues.Channels) + if err != nil { + return nil, err + } + + processScopeReference := ConvertProcessScopesToReference(projectVariables.ScopeValues.Processes) + processScopeReference = append(processScopeReference, &resources.ReferenceDataItem{ID: project.GetID(), Name: "deployment"}) + scope.ProcessOwners, err = buildSingleScope(opts.ProcessScopes.Value, processScopeReference) + if err != nil { + return nil, err + } + + return scope, nil +} + +func ConvertProcessScopesToReference(processes []*resources.ProcessReferenceDataItem) []*resources.ReferenceDataItem { + refs := []*resources.ReferenceDataItem{} + for _, p := range processes { + refs = append(refs, &resources.ReferenceDataItem{ + ID: p.ID, + Name: p.Name, + }) + } + + return refs +} + +func buildSingleScope(inputScopes []string, references []*resources.ReferenceDataItem) ([]string, error) { + scopes := []string{} + for _, e := range inputScopes { + ref, err := findReference(e, references) + if err != nil { + return nil, err + } + scopes = append(scopes, ref) + } + + return scopes, nil +} + +func findReference(value string, items []*resources.ReferenceDataItem) (string, error) { + for _, i := range items { + if strings.EqualFold(value, i.ID) || strings.EqualFold(value, i.Name) { + return i.ID, nil + } + } + + return "", fmt.Errorf("cannot find scope value '%s'", value) +} diff --git a/pkg/cmd/project/variables/update/update.go b/pkg/cmd/project/variables/update/update.go new file mode 100644 index 00000000..8e6c01c0 --- /dev/null +++ b/pkg/cmd/project/variables/update/update.go @@ -0,0 +1,309 @@ +package update + +import ( + "fmt" + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + sharedVariable "github.com/OctopusDeploy/cli/pkg/cmd/project/variables/shared" + "github.com/OctopusDeploy/cli/pkg/cmd/tenant/shared" + "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/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/spf13/cobra" + "strings" +) + +const ( + FlagId = "id" + FlagProject = "project" + FlagName = "name" + FlagValue = "value" + FlagUnscoped = "unscoped" +) + +type UpdateFlags struct { + Id *flag.Flag[string] + Project *flag.Flag[string] + Name *flag.Flag[string] + Value *flag.Flag[string] + Unscoped *flag.Flag[bool] + + *sharedVariable.ScopeFlags +} + +type UpdateOptions struct { + *UpdateFlags + *cmd.Dependencies + shared.GetProjectCallback + shared.GetAllProjectsCallback + *sharedVariable.VariableCallbacks +} + +func NewUpdateFlags() *UpdateFlags { + return &UpdateFlags{ + Id: flag.New[string](FlagId, false), + Project: flag.New[string](FlagProject, false), + Name: flag.New[string](FlagName, false), + Value: flag.New[string](FlagValue, false), + Unscoped: flag.New[bool](FlagUnscoped, false), + ScopeFlags: sharedVariable.NewScopeFlags(), + } +} + +func NewUpdateOptions(flags *UpdateFlags, dependencies *cmd.Dependencies) *UpdateOptions { + return &UpdateOptions{ + UpdateFlags: flags, + Dependencies: dependencies, + GetProjectCallback: func(identifier string) (*projects.Project, error) { + return shared.GetProject(dependencies.Client, identifier) + }, + GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(dependencies.Client) }, + VariableCallbacks: sharedVariable.NewVariableCallbacks(dependencies), + } +} + +func NewUpdateCmd(f factory.Factory) *cobra.Command { + updateFlags := NewUpdateFlags() + cmd := &cobra.Command{ + Use: "update", + Short: "Update the value of a project variable", + Long: "Update the value of a project variable in Octopus Deploy", + Example: heredoc.Docf(` + $ %[1]s project variable update + $ %[1]s project variable update --name varname --value "abc" + $ %[1]s project variable update --name varname --value "password" + $ %[1]s project variable update --name varname --unscoped + $ %[1]s project variable update --name varname --environment-scope test + `, constants.ExecutableName), + RunE: func(c *cobra.Command, args []string) error { + opts := NewUpdateOptions(updateFlags, cmd.NewDependencies(f, c)) + + return updateRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVar(&updateFlags.Id.Value, updateFlags.Id.Name, "", "The variable id to update") + flags.StringVarP(&updateFlags.Project.Value, updateFlags.Project.Name, "p", "", "The project") + flags.StringVarP(&updateFlags.Name.Value, updateFlags.Name.Name, "n", "", "The name of the variable") + flags.StringVar(&updateFlags.Value.Value, updateFlags.Value.Name, "", "The value to set on the variable") + flags.BoolVar(&updateFlags.Unscoped.Value, updateFlags.Unscoped.Name, false, "Remove all shared from the variable, cannot be used with shared") + sharedVariable.RegisterScopeFlags(cmd, updateFlags.ScopeFlags) + + return cmd +} + +func updateRun(opts *UpdateOptions) error { + if opts.Unscoped.Value && scopesProvided(opts) { + return fmt.Errorf("cannot provide '%s' and scope flags together", opts.Unscoped.Name) + } + + if !opts.NoPrompt { + err := PromptMissing(opts) + if err != nil { + return err + } + } + + project, err := opts.GetProjectCallback(opts.Project.Value) + if err != nil { + return err + } + + projectVariables, err := opts.GetProjectVariables(project.GetID()) + if err != nil { + return err + } + + variable, err := getVariable(opts, project, projectVariables) + if err != nil { + return err + } + + if variable.IsSensitive { + opts.Value.Secure = true + } + + updatedScope, err := sharedVariable.ToVariableScope(projectVariables, opts.ScopeFlags, project) + if err != nil { + return err + } + + if opts.Value.Value != "" { + variable.Value = opts.Value.Value + } + + if opts.Unscoped.Value { + variable.Scope = variables.VariableScope{} + } else { + if !updatedScope.IsEmpty() { + variable.Scope = *updatedScope + } + } + + _, err = opts.Client.Variables.UpdateSingle(project.GetID(), variable) + if err != nil { + return err + } + _, err = fmt.Fprintf(opts.Out, "Successfully updated variable '%s' in project '%s'\n", opts.Name.Value, project.GetName()) + + if !opts.NoPrompt { + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Id, opts.Name, opts.Value, opts.Project, opts.EnvironmentsScopes, opts.ChannelScopes, opts.StepScopes, opts.TargetScopes, opts.TagScopes, opts.RoleScopes, opts.ProcessScopes, opts.Unscoped) + fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) + } + + return nil +} + +func getVariable(opts *UpdateOptions, project *projects.Project, projectVariables *variables.VariableSet) (*variables.Variable, error) { + var variable *variables.Variable + var err error + if opts.Id.Value != "" { + variable, err = opts.GetVariableById(project.GetID(), opts.Id.Value) + if err != nil { + return nil, err + } + + if variable == nil { + return nil, fmt.Errorf("cannot find variable with id '%s'", opts.Id.Value) + } + } else { + variables := util.SliceFilter(projectVariables.Variables, func(v *variables.Variable) bool { + return strings.EqualFold(v.Name, opts.Name.Value) + }) + + if len(variables) == 0 { + return nil, fmt.Errorf("cannot find variable with name '%s'", opts.Name.Value) + } else if len(variables) > 1 { + return nil, fmt.Errorf("'%s' has multiple values, supply '%s' flag", variables[0].Name, FlagId) + } else { + variable = variables[0] + } + } + + return variable, err +} + +func PromptMissing(opts *UpdateOptions) error { + var project *projects.Project + var err error + if opts.Project.Value == "" { + project, err = projectSelector("You have not specified a Project. Please select one:", opts.GetAllProjectsCallback, opts.Ask) + if err != nil { + return nil + } + opts.Project.Value = project.GetName() + } else { + project, err = opts.GetProjectCallback(opts.Project.Value) + if err != nil { + return err + } + } + + projectVariables, err := opts.GetProjectVariables(project.GetID()) + if err != nil { + return err + } + + var variable *variables.Variable + if opts.Id.Value != "" || opts.Name.Value != "" { + variable, err = getVariable(opts, project, projectVariables) + if err != nil { + variable, err = promptForVariable(opts, projectVariables) + if err != nil { + return err + } + } + opts.Id.Value = variable.GetID() + opts.Name.Value = variable.Name + } else { + variable, err = promptForVariable(opts, projectVariables) + opts.Id.Value = variable.GetID() + opts.Name.Value = variable.Name + } + + if opts.Value.Value == "" { + var updateValue bool + opts.Ask(&survey.Confirm{ + Message: "Do you want to update the variable value?", + Default: false, + }, &updateValue) + + if updateValue { + opts.Value.Value, err = sharedVariable.PromptValue(opts.Ask, variable.Type, opts.VariableCallbacks) + if err != nil { + return err + } + } + } + + if !scopesProvided(opts) { + selectedOption, err := selectors.SelectOptions(opts.Ask, "Do you want to change the variable scoping?", getScopeUpdateOptions) + if err != nil { + return err + } + switch selectedOption.Value { + case "unscope": + opts.Unscoped.Value = true + case "replace": + sharedVariable.PromptScopes(opts.Ask, projectVariables, opts.ScopeFlags, variable.Prompt != nil) + } + } + + return nil +} + +func promptForVariable(opts *UpdateOptions, projectVariables *variables.VariableSet) (*variables.Variable, error) { + selectedOption, err := selectors.Select(opts.Ask, "Select the variable you wish to update", func() ([]*variables.Variable, error) { return projectVariables.Variables, nil }, func(v *variables.Variable) string { return formatVariableSelection(v) }) + + if err != nil { + return nil, err + } + return selectedOption, nil +} + +func formatVariableSelection(v *variables.Variable) string { + value := v.Value + if v.IsSensitive { + value = "***" + } + if value == "" { + value = output.Dim("(no value)") + } + + return fmt.Sprintf("%s (%s) = %s", v.Name, output.Dim(v.GetID()), value) +} + +func projectSelector(questionText string, getAllProjectsCallback shared.GetAllProjectsCallback, ask question.Asker) (*projects.Project, error) { + existingProjects, err := getAllProjectsCallback() + if err != nil { + return nil, err + } + + return question.SelectMap(ask, questionText, existingProjects, func(p *projects.Project) string { return p.GetName() }) +} + +func getScopeUpdateOptions() []*selectors.SelectOption[string] { + return []*selectors.SelectOption[string]{ + {Display: "Leave", Value: "leave"}, + {Display: "Replace", Value: "replace"}, + {Display: "Unscope", Value: "unscope"}, + } +} + +func scopesProvided(opts *UpdateOptions) bool { + return !util.Empty(opts.EnvironmentsScopes.Value) || + !util.Empty(opts.ChannelScopes.Value) || + !util.Empty(opts.TagScopes.Value) || + !util.Empty(opts.RoleScopes.Value) || + !util.Empty(opts.StepScopes.Value) || + !util.Empty(opts.ProcessScopes.Value) || + !util.Empty(opts.TargetScopes.Value) +} diff --git a/pkg/cmd/project/variables/update/update_test.go b/pkg/cmd/project/variables/update/update_test.go new file mode 100644 index 00000000..7c379bc2 --- /dev/null +++ b/pkg/cmd/project/variables/update/update_test.go @@ -0,0 +1,203 @@ +package update_test + +import ( + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/project/variables/update" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestPromptMissing_AllFlagsProvided(t *testing.T) { + pa := []*testutil.PA{} + + variable := variables.NewVariable("") + variable.ID = "123abc" + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + flags := update.NewUpdateFlags() + flags.Id.Value = "123" + flags.Value.Value = "new value" + flags.EnvironmentsScopes.Value = []string{"test"} + flags.Project.Value = "make all the things great again" + opts := update.NewUpdateOptions(flags, &cmd.Dependencies{Ask: asker}) + opts.GetProjectVariables = func(projectId string) (*variables.VariableSet, error) { + return &variables.VariableSet{ + OwnerID: "Projects-1", + ScopeValues: &variables.VariableScopeValues{ + Environments: []*resources.ReferenceDataItem{{ID: "Environments-1", Name: "test"}}, + }, + SpaceID: "Spaces-1", + Variables: []*variables.Variable{variable}, + }, nil + } + + opts.GetProjectCallback = func(identifier string) (*projects.Project, error) { + return projects.NewProject("Project", "Lifecycles-1", "ProjectGroups-1"), nil + } + + opts.GetVariableById = func(ownerId, variableId string) (*variables.Variable, error) { + return variable, nil + } + + err := update.PromptMissing(opts) + checkRemainingPrompts() + assert.NoError(t, err) +} + +func TestPromptMissing_NoFlags_LeaveScope(t *testing.T) { + project1 := projects.NewProject("Project 1", "Lifecycles-1", "ProjectGroups-1") + project2 := projects.NewProject("Project 2", "Lifecycles-1", "ProjectGroups-1") + + variable := variables.NewVariable("var1") + variable.ID = "123abc" + variable.Type = "String" + + pa := []*testutil.PA{ + testutil.NewSelectPrompt("You have not specified a Project. Please select one:", "", []string{project1.Name, project2.Name}, project1.Name), + testutil.NewConfirmPromptWithDefault("Do you want to update the variable value?", "", true, false), + testutil.NewInputPrompt("Value", "", "updated value"), + testutil.NewSelectPrompt("Do you want to change the variable scoping?", "", []string{"Leave", "Replace", "Unscope"}, "Leave"), + } + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + flags := update.NewUpdateFlags() + opts := update.NewUpdateOptions(flags, &cmd.Dependencies{Ask: asker}) + + opts.GetProjectVariables = func(projectId string) (*variables.VariableSet, error) { + return &variables.VariableSet{ + OwnerID: "Projects-1", + ScopeValues: &variables.VariableScopeValues{ + Environments: []*resources.ReferenceDataItem{{ID: "Environments-1", Name: "test"}}, + }, + SpaceID: "Spaces-1", + Variables: []*variables.Variable{variable}, + }, nil + } + + opts.GetProjectCallback = func(identifier string) (*projects.Project, error) { return project1, nil } + opts.GetAllProjectsCallback = func() ([]*projects.Project, error) { + return []*projects.Project{project1, project2}, nil + } + opts.GetVariableById = func(ownerId, variableId string) (*variables.Variable, error) { + return variable, nil + } + + err := update.PromptMissing(opts) + checkRemainingPrompts() + assert.NoError(t, err) + assert.Equal(t, "updated value", opts.Value.Value) + assert.Equal(t, "123abc", opts.Id.Value) +} + +func TestPromptMissing_NoFlags_ReplaceScope(t *testing.T) { + project := projects.NewProject("Project 1", "Lifecycles-1", "ProjectGroups-1") + + pa := []*testutil.PA{ + testutil.NewConfirmPromptWithDefault("Do you want to update the variable value?", "", false, false), + testutil.NewSelectPrompt("Do you want to change the variable scoping?", "", []string{"Leave", "Replace", "Unscope"}, "Replace"), + testutil.NewMultiSelectPrompt("Environment scope", "", []string{"test"}, []string{"test"}), + testutil.NewMultiSelectPrompt("Process scope", "", []string{"Run, book, run"}, []string{"Run, book, run"}), + testutil.NewMultiSelectPrompt("Channel scope", "", []string{"Default channel"}, []string{"Default channel"}), + testutil.NewMultiSelectPrompt("Target scope", "", []string{"Deployment target"}, []string{"Deployment target"}), + testutil.NewMultiSelectPrompt("Role scope", "", []string{"Role 1"}, []string{"Role 1"}), + testutil.NewMultiSelectPrompt("Tag scope", "", []string{"tag set/tag 1"}, []string{"tag set/tag 1"}), + testutil.NewMultiSelectPrompt("Step scope", "", []string{"Step name"}, []string{"Step name"}), + } + + variable := variables.NewVariable("") + variable.ID = "123abc" + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + flags := update.NewUpdateFlags() + flags.Project.Value = project.GetName() + opts := update.NewUpdateOptions(flags, &cmd.Dependencies{Ask: asker}) + opts.GetProjectVariables = func(projectId string) (*variables.VariableSet, error) { + return &variables.VariableSet{ + OwnerID: "Projects-1", + ScopeValues: &variables.VariableScopeValues{ + Environments: []*resources.ReferenceDataItem{{ID: "Environments-1", Name: "test"}}, + Actions: []*resources.ReferenceDataItem{{ID: "actionId", Name: "Step name"}}, + Channels: []*resources.ReferenceDataItem{{ID: "Channels-1", Name: "Default channel"}}, + Machines: []*resources.ReferenceDataItem{{ID: "Machines-1", Name: "Deployment target"}}, + TenantTags: []*resources.ReferenceDataItem{{ID: "tag set/tag 1", Name: "tag 1"}}, + Roles: []*resources.ReferenceDataItem{{ID: "Role 1", Name: "Role 1"}}, + Processes: []*resources.ProcessReferenceDataItem{{ID: "Runbooks-1", Name: "Run, book, run"}}, + }, + SpaceID: "Spaces-1", + Variables: []*variables.Variable{variable}, + }, nil + } + + opts.GetProjectCallback = func(identifier string) (*projects.Project, error) { return project, nil } + opts.GetVariableById = func(ownerId, variableId string) (*variables.Variable, error) { + return variable, nil + } + + err := update.PromptMissing(opts) + checkRemainingPrompts() + assert.NoError(t, err) + assert.Equal(t, []string{"test"}, opts.EnvironmentsScopes.Value) + assert.Equal(t, []string{"Step name"}, opts.StepScopes.Value) + assert.Equal(t, []string{"Default channel"}, opts.ChannelScopes.Value) + assert.Equal(t, []string{"Deployment target"}, opts.TargetScopes.Value) + assert.Equal(t, []string{"tag set/tag 1"}, opts.TagScopes.Value) + assert.Equal(t, []string{"Role 1"}, opts.RoleScopes.Value) + assert.Equal(t, []string{"Run, book, run"}, opts.ProcessScopes.Value) + assert.Equal(t, "123abc", opts.Id.Value) +} + +func TestPromptMissing_Unscope(t *testing.T) { + project := projects.NewProject("Project 1", "Lifecycles-1", "ProjectGroups-1") + + pa := []*testutil.PA{ + testutil.NewConfirmPromptWithDefault("Do you want to update the variable value?", "", false, false), + testutil.NewSelectPrompt("Do you want to change the variable scoping?", "", []string{"Leave", "Replace", "Unscope"}, "Unscope"), + } + + variable := variables.NewVariable("") + variable.ID = "123abc" + + asker, checkRemainingPrompts := testutil.NewMockAsker(t, pa) + flags := update.NewUpdateFlags() + flags.Project.Value = project.GetName() + opts := update.NewUpdateOptions(flags, &cmd.Dependencies{Ask: asker}) + opts.GetProjectVariables = func(projectId string) (*variables.VariableSet, error) { + return &variables.VariableSet{ + OwnerID: "Projects-1", + ScopeValues: &variables.VariableScopeValues{ + Environments: []*resources.ReferenceDataItem{{ID: "Environments-1", Name: "test"}}, + Actions: []*resources.ReferenceDataItem{{ID: "actionId", Name: "Step name"}}, + Channels: []*resources.ReferenceDataItem{{ID: "Channels-1", Name: "Default channel"}}, + Machines: []*resources.ReferenceDataItem{{ID: "Machines-1", Name: "Deployment target"}}, + TenantTags: []*resources.ReferenceDataItem{{ID: "TenantTags-1", Name: "tag set/tag 1"}}, + Roles: []*resources.ReferenceDataItem{{ID: "Role 1", Name: "Role 1"}}, + Processes: []*resources.ProcessReferenceDataItem{{ID: "Runbooks-1", Name: "Run, book, run"}}, + }, + SpaceID: "Spaces-1", + Variables: []*variables.Variable{variable}, + }, nil + } + + opts.GetProjectCallback = func(identifier string) (*projects.Project, error) { return project, nil } + opts.GetVariableById = func(ownerId, variableId string) (*variables.Variable, error) { + return variable, nil + } + + err := update.PromptMissing(opts) + checkRemainingPrompts() + assert.NoError(t, err) + assert.Empty(t, opts.EnvironmentsScopes.Value) + assert.Empty(t, opts.StepScopes.Value) + assert.Empty(t, opts.ChannelScopes.Value) + assert.Empty(t, opts.TargetScopes.Value) + assert.Empty(t, opts.TagScopes.Value) + assert.Empty(t, opts.RoleScopes.Value) + assert.Empty(t, opts.ProcessScopes.Value) + assert.True(t, opts.Unscoped.Value) + assert.Equal(t, "123abc", opts.Id.Value) + +} diff --git a/pkg/cmd/project/variables/variables.go b/pkg/cmd/project/variables/variables.go new file mode 100644 index 00000000..a00f460b --- /dev/null +++ b/pkg/cmd/project/variables/variables.go @@ -0,0 +1,39 @@ +package variables + +import ( + "github.com/MakeNowJust/heredoc/v2" + cmdCreate "github.com/OctopusDeploy/cli/pkg/cmd/project/variables/create" + cmdDelete "github.com/OctopusDeploy/cli/pkg/cmd/project/variables/delete" + cmdList "github.com/OctopusDeploy/cli/pkg/cmd/project/variables/list" + cmdUpdate "github.com/OctopusDeploy/cli/pkg/cmd/project/variables/update" + cmdView "github.com/OctopusDeploy/cli/pkg/cmd/project/variables/view" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/constants/annotations" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/spf13/cobra" +) + +func NewCmdVariables(f factory.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "variables ", + Aliases: []string{"variable"}, + Short: "Manage project variables", + Long: "Manage project variables in Octopus Deploy", + Example: heredoc.Docf(` + $ %[1]s project variable list --project Deploy + $ %[1]s project variable view --name "DatabaseName" --project Deploy + $ %[1]s project variable update + `, constants.ExecutableName), + Annotations: map[string]string{ + annotations.IsCore: "true", + }, + } + + cmd.AddCommand(cmdUpdate.NewUpdateCmd(f)) + cmd.AddCommand(cmdCreate.NewCreateCmd(f)) + cmd.AddCommand(cmdList.NewCmdList(f)) + cmd.AddCommand(cmdView.NewCmdView(f)) + cmd.AddCommand(cmdDelete.NewDeleteCmd(f)) + + return cmd +} diff --git a/pkg/cmd/project/variables/view/view.go b/pkg/cmd/project/variables/view/view.go new file mode 100644 index 00000000..28facde4 --- /dev/null +++ b/pkg/cmd/project/variables/view/view.go @@ -0,0 +1,180 @@ +package view + +import ( + "fmt" + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/apiclient" + variableShared "github.com/OctopusDeploy/cli/pkg/cmd/project/variables/shared" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" + "github.com/spf13/cobra" + "io" + "strconv" + "strings" +) + +const ( + FlagProject = "project" + FlagWeb = "web" + FlagId = "id" +) + +type ViewFlags struct { + Id *flag.Flag[string] + Project *flag.Flag[string] + Web *flag.Flag[bool] +} + +type ViewOptions struct { + Client *client.Client + Host string + out io.Writer + name string + *ViewFlags +} + +func NewViewFlags() *ViewFlags { + return &ViewFlags{ + Project: flag.New[string](FlagProject, false), + Id: flag.New[string](FlagId, false), + Web: flag.New[bool](FlagWeb, false), + } +} + +func NewCmdView(f factory.Factory) *cobra.Command { + viewFlags := NewViewFlags() + cmd := &cobra.Command{ + Use: "view", + Short: "View all values of a project variable", + Long: "View all values of a project variable in Octopus Deploy", + Example: heredoc.Docf(` + $ %[1]s project variable view + $ %[1]s project variable view DatabaseName --project "Vet Clinic" + `, constants.ExecutableName), + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("must supply variable name") + } + + client, err := f.GetSpacedClient(apiclient.NewRequester(cmd)) + if err != nil { + return err + } + + opts := &ViewOptions{ + client, + f.GetCurrentHost(), + cmd.OutOrStdout(), + args[0], + viewFlags, + } + + return viewRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&viewFlags.Project.Value, viewFlags.Project.Name, "p", "", "The project containing the variable") + flags.StringVar(&viewFlags.Id.Value, viewFlags.Id.Name, "", "The Id of the specifically scoped variable") + flags.BoolVarP(&viewFlags.Web.Value, viewFlags.Web.Name, "w", false, "Open in web browser") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + project, err := opts.Client.Projects.GetByIdentifier(opts.Project.Value) + if err != nil { + return err + } + + allVars, err := opts.Client.Variables.GetAll(project.GetID()) + if err != nil { + return err + } + + filteredVars := util.SliceFilter( + allVars.Variables, + func(variable *variables.Variable) bool { + if opts.Id.Value != "" { + return strings.EqualFold(variable.Name, opts.name) && strings.EqualFold(variable.ID, opts.Id.Value) + } + + return strings.EqualFold(variable.Name, opts.name) + }) + + if !util.Any(filteredVars) { + return fmt.Errorf("cannot find variable '%s'", opts.name) + } + + fmt.Fprintln(opts.out, output.Bold(filteredVars[0].Name)) + + for _, v := range filteredVars { + data := []*output.DataRow{} + + data = append(data, output.NewDataRow("Id", output.Dim(v.GetID()))) + if v.IsSensitive { + data = append(data, output.NewDataRow("Value", output.Bold("*** (sensitive)"))) + } else { + data = append(data, output.NewDataRow("Value", output.Bold(v.Value))) + } + + if v.Description == "" { + v.Description = constants.NoDescription + } + data = append(data, output.NewDataRow("Description", output.Dim(v.Description))) + + scopeValues, err := variableShared.ToScopeValues(v, allVars.ScopeValues) + if err != nil { + return err + } + data = addScope(scopeValues.Environments, "Environment scope", data, nil) + data = addScope(scopeValues.Roles, "Role scope", data, nil) + data = addScope(scopeValues.Channels, "Channel scope", data, nil) + data = addScope(scopeValues.Machines, "Machine scope", data, nil) + data = addScope(scopeValues.TenantTags, "Tenant tag scope", data, func(item *resources.ReferenceDataItem) string { + return item.ID + }) + data = addScope(scopeValues.Actions, "Step scope", data, nil) + data = addScope( + util.SliceTransform(scopeValues.Processes, func(item *resources.ProcessReferenceDataItem) *resources.ReferenceDataItem { + return &resources.ReferenceDataItem{ + ID: item.ID, + Name: item.Name, + } + }), + "Process scope", + data, + nil) + + if v.Prompt != nil { + data = append(data, output.NewDataRow("Prompted", "true")) + data = append(data, output.NewDataRow("Prompt Label", v.Prompt.Label)) + data = append(data, output.NewDataRow("Prompt Description", output.Dim(v.Prompt.Description))) + data = append(data, output.NewDataRow("Prompt Required", strconv.FormatBool(v.Prompt.IsRequired))) + } + + fmt.Fprintln(opts.out) + output.PrintRows(data, opts.out) + } + + return nil +} + +func addScope(values []*resources.ReferenceDataItem, scopeDescription string, data []*output.DataRow, displaySelector func(item *resources.ReferenceDataItem) string) []*output.DataRow { + if displaySelector == nil { + displaySelector = func(item *resources.ReferenceDataItem) string { return item.Name } + } + + if util.Any(values) { + data = append(data, output.NewDataRow(scopeDescription, output.FormatAsList(util.SliceTransform(values, displaySelector)))) + } + + return data +} diff --git a/pkg/cmd/target/shared/tenant.go b/pkg/cmd/target/shared/tenant.go index f93b1fab..640bed27 100644 --- a/pkg/cmd/target/shared/tenant.go +++ b/pkg/cmd/target/shared/tenant.go @@ -56,10 +56,10 @@ func NewCreateTargetTenantOptions(dependencies *cmd.Dependencies) *CreateTargetT return &CreateTargetTenantOptions{ Dependencies: dependencies, GetAllTenantsCallback: func() ([]*tenants.Tenant, error) { - return sharedTenants.GetAllTenants(*dependencies.Client) + return sharedTenants.GetAllTenants(dependencies.Client) }, GetAllTagsCallback: func() ([]*tagsets.Tag, error) { - return getAllTags(*dependencies.Client) + return getAllTags(dependencies.Client) }, } } @@ -152,7 +152,7 @@ func getTenantDeploymentOptions() []*selectors.SelectOption[string] { } } -func getAllTags(client client.Client) ([]*tagsets.Tag, error) { +func getAllTags(client *client.Client) ([]*tagsets.Tag, error) { tagSets, err := client.TagSets.GetAll() if err != nil { return nil, err diff --git a/pkg/cmd/tenant/clone/clone.go b/pkg/cmd/tenant/clone/clone.go index 2bdb70c6..65044d16 100644 --- a/pkg/cmd/tenant/clone/clone.go +++ b/pkg/cmd/tenant/clone/clone.go @@ -49,10 +49,10 @@ func NewCloneOptions(flags *CloneFlags, dependencies *cmd.Dependencies) *CloneOp CloneFlags: flags, Dependencies: dependencies, GetTenantCallback: func(id string) (*tenants.Tenant, error) { - return shared.GetTenant(*dependencies.Client, id) + return shared.GetTenant(dependencies.Client, id) }, GetAllTenantsCallback: func() ([]*tenants.Tenant, error) { - return shared.GetAllTenants(*dependencies.Client) + return shared.GetAllTenants(dependencies.Client) }, } } diff --git a/pkg/cmd/tenant/connect/connect.go b/pkg/cmd/tenant/connect/connect.go index daec0eb5..578d18fe 100644 --- a/pkg/cmd/tenant/connect/connect.go +++ b/pkg/cmd/tenant/connect/connect.go @@ -51,10 +51,10 @@ func NewConnectOptions(connectFlags *ConnectFlags, dependencies *cmd.Dependencie return &ConnectOptions{ Dependencies: dependencies, ConnectFlags: connectFlags, - GetAllTenantsCallback: func() ([]*tenants.Tenant, error) { return shared.GetAllTenants(*dependencies.Client) }, - GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(*dependencies.Client) }, + GetAllTenantsCallback: func() ([]*tenants.Tenant, error) { return shared.GetAllTenants(dependencies.Client) }, + GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(dependencies.Client) }, GetProjectCallback: func(identifier string) (*projects.Project, error) { - return shared.GetProject(*dependencies.Client, identifier) + return shared.GetProject(dependencies.Client, identifier) }, GetProjectProgressionCallback: func(project *projects.Project) (*projects.Progression, error) { return getProjectProgression(*dependencies.Client, project) diff --git a/pkg/cmd/tenant/disconnect/disconnect.go b/pkg/cmd/tenant/disconnect/disconnect.go index 49d3bc71..4cc292d1 100644 --- a/pkg/cmd/tenant/disconnect/disconnect.go +++ b/pkg/cmd/tenant/disconnect/disconnect.go @@ -52,13 +52,13 @@ func NewDisconnectOptions(disconnectFlags *DisconnectFlags, dependencies *cmd.De return &DisconnectOptions{ Dependencies: dependencies, DisconnectFlags: disconnectFlags, - GetAllTenantsCallback: func() ([]*tenants.Tenant, error) { return shared.GetAllTenants(*dependencies.Client) }, - GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(*dependencies.Client) }, + GetAllTenantsCallback: func() ([]*tenants.Tenant, error) { return shared.GetAllTenants(dependencies.Client) }, + GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(dependencies.Client) }, GetProjectCallback: func(identifier string) (*projects.Project, error) { - return shared.GetProject(*dependencies.Client, identifier) + return shared.GetProject(dependencies.Client, identifier) }, GetTenantCallback: func(identifier string) (*tenants.Tenant, error) { - return shared.GetTenant(*dependencies.Client, identifier) + return shared.GetTenant(dependencies.Client, identifier) }, } } diff --git a/pkg/cmd/tenant/shared/shared.go b/pkg/cmd/tenant/shared/shared.go index a19340cc..8cc8661b 100644 --- a/pkg/cmd/tenant/shared/shared.go +++ b/pkg/cmd/tenant/shared/shared.go @@ -44,7 +44,7 @@ func GetAllSpaces(client client.Client) ([]*spaces.Space, error) { return res, nil } -func GetAllTenants(client client.Client) ([]*tenants.Tenant, error) { +func GetAllTenants(client *client.Client) ([]*tenants.Tenant, error) { res, err := client.Tenants.GetAll() if err != nil { return nil, err @@ -53,7 +53,7 @@ func GetAllTenants(client client.Client) ([]*tenants.Tenant, error) { return res, nil } -func GetTenant(client client.Client, identifier string) (*tenants.Tenant, error) { +func GetTenant(client *client.Client, identifier string) (*tenants.Tenant, error) { res, err := client.Tenants.GetByIdentifier(identifier) if err != nil { return nil, err @@ -62,7 +62,7 @@ func GetTenant(client client.Client, identifier string) (*tenants.Tenant, error) return res, nil } -func GetAllProjects(client client.Client) ([]*projects.Project, error) { +func GetAllProjects(client *client.Client) ([]*projects.Project, error) { res, err := client.Projects.GetAll() if err != nil { return nil, err @@ -71,7 +71,7 @@ func GetAllProjects(client client.Client) ([]*projects.Project, error) { return res, nil } -func GetProject(client client.Client, identifier string) (*projects.Project, error) { +func GetProject(client *client.Client, identifier string) (*projects.Project, error) { res, err := client.Projects.GetByIdentifier(identifier) if err != nil { return nil, err diff --git a/pkg/util/util.go b/pkg/util/util.go index 96d3c40a..2e828924 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -82,10 +82,10 @@ type MapCollectionCacheContainer struct { // items as it iterates the collection, and call out to lambdas to look those values up. // See the unit tests for examples which should clarify the use-cases for this. func MapCollectionWithLookups[T any, TResult any]( - cacheContainer *MapCollectionCacheContainer, // cache for keys (typically this will store a mapping of ID->[Name, Name]). - collection []T, // input (e.g. list of Releases) - keySelector func(T) []string, // fetches the keys (e.g given a Release, returns the [ChannelID, ProjectID] - mapper func(T, []string) TResult, // fetches the value to lookup (e.g given a Release and the [ChannelName,ProjectName], does the mapping to return the output struct) + cacheContainer *MapCollectionCacheContainer, // cache for keys (typically this will store a mapping of ID->[Name, Name]). + collection []T, // input (e.g. list of Releases) + keySelector func(T) []string, // fetches the keys (e.g given a Release, returns the [ChannelID, ProjectID] + mapper func(T, []string) TResult, // fetches the value to lookup (e.g given a Release and the [ChannelName,ProjectName], does the mapping to return the output struct) runLookups ...func([]string) ([]string, error), // callbacks to go fetch values for the keys (given a list of Channel IDs, it should return the list of associated Channel Names) ) ([]TResult, error) { // if the caller didn't specify an external cache, create an internal one. @@ -176,3 +176,11 @@ func SliceDistinct[T comparable](slice []T) []T { } return result } + +func RemoveIndex[T any](s []T, index int) []T { + if index < 0 || index >= len(s) { + return s + } + + return append(s[:index], s[index+1:]...) +} diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go index 5a33ae29..95b8fcfb 100644 --- a/pkg/util/util_test.go +++ b/pkg/util/util_test.go @@ -403,3 +403,23 @@ func TestDistinct_WithDuplicateItems(t *testing.T) { items := []string{"foo", "bar", "foo", "baz", "bar"} assert.Equal(t, []string{"foo", "bar", "baz"}, util.SliceDistinct(items)) } + +func TestRemoveIndex_Empty(t *testing.T) { + result := util.RemoveIndex([]string{}, 0) + assert.Empty(t, result) +} + +func TestRemoveIndex(t *testing.T) { + result := util.RemoveIndex([]string{"a", "b", "c"}, 1) + assert.Equal(t, []string{"a", "c"}, result) +} + +func TestRemoveIndex_IndexOutOfBounds_TooHigh(t *testing.T) { + result := util.RemoveIndex([]string{"a", "b", "c"}, 10) + assert.Equal(t, []string{"a", "b", "c"}, result) +} + +func TestRemoveIndex_IndexOutOfBounds_TooLow(t *testing.T) { + result := util.RemoveIndex([]string{"a", "b", "c"}, -1) + assert.Equal(t, []string{"a", "b", "c"}, result) +}