diff --git a/go.mod b/go.mod index e11b84b0..575c6de1 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.116.0 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.117.0 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/briandowns/spinner v1.23.2 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 275e45a7..e204fba1 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,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.116.0 h1:kW1H9qngKgI34OkfYN6/PFpjBEQRK/tZm70MCElHOME= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.116.0/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.117.0 h1:Sh668C2qqIgNUWEvD9bda5j5IvMLYEeqGUD39i9AC50= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.117.0/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 12c2a25d..835c523a 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -83,6 +83,9 @@ const ( FlagAliasExcludeTarget = "exclude-target" FlagAliasExcludeMachines = "excludeMachines" // octo wants a comma separated list. We prefer specifying --exclude-target multiple times, but CSV also works because pflag does it for free + FlagSpecificTargetTagName = "specific-target-tag" + FlagExcludedTargetTagName = "excluded-target-tag" + FlagVariable = "variable" FlagUpdateVariables = "update-variables" @@ -113,6 +116,8 @@ type DeployFlags struct { ForcePackageDownload *flag.Flag[bool] DeploymentTargets *flag.Flag[[]string] ExcludeTargets *flag.Flag[[]string] + SpecificTargetTagNames *flag.Flag[[]string] + ExcludedTargetTagNames *flag.Flag[[]string] DeploymentFreezeNames *flag.Flag[[]string] DeploymentFreezeOverrideReason *flag.Flag[string] } @@ -134,6 +139,8 @@ func NewDeployFlags() *DeployFlags { ForcePackageDownload: flag.New[bool](FlagForcePackageDownload, false), DeploymentTargets: flag.New[[]string](FlagDeploymentTarget, false), ExcludeTargets: flag.New[[]string](FlagExcludeDeploymentTarget, false), + SpecificTargetTagNames: flag.New[[]string](FlagSpecificTargetTagName, false), + ExcludedTargetTagNames: flag.New[[]string](FlagExcludedTargetTagName, false), DeploymentFreezeNames: flag.New[[]string](FlagDeploymentFreezeName, false), DeploymentFreezeOverrideReason: flag.New[string](FlagDeploymentFreezeOverrideReason, false), } @@ -177,6 +184,8 @@ func NewCmdDeploy(f factory.Factory) *cobra.Command { flags.BoolVarP(&deployFlags.ForcePackageDownload.Value, deployFlags.ForcePackageDownload.Name, "", false, "Force re-download of packages") flags.StringArrayVarP(&deployFlags.DeploymentTargets.Value, deployFlags.DeploymentTargets.Name, "", nil, "Deploy to this target (can be specified multiple times)") flags.StringArrayVarP(&deployFlags.ExcludeTargets.Value, deployFlags.ExcludeTargets.Name, "", nil, "Deploy to targets except for this (can be specified multiple times)") + flags.StringArrayVarP(&deployFlags.SpecificTargetTagNames.Value, deployFlags.SpecificTargetTagNames.Name, "", nil, "Deploy to targets matching this tag (can be specified multiple times)") + flags.StringArrayVarP(&deployFlags.ExcludedTargetTagNames.Value, deployFlags.ExcludedTargetTagNames.Name, "", nil, "Deploy to targets except for those matching this tag (can be specified multiple times)") flags.StringArrayVarP(&deployFlags.DeploymentFreezeNames.Value, deployFlags.DeploymentFreezeNames.Name, "", nil, "Override this deployment freeze (can be specified multiple times)") flags.StringVarP(&deployFlags.DeploymentFreezeOverrideReason.Value, deployFlags.DeploymentFreezeOverrideReason.Name, "", "", "Reason for overriding a deployment freeze") @@ -236,6 +245,8 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error ForcePackageDownload: flags.ForcePackageDownload.Value, DeploymentTargets: flags.DeploymentTargets.Value, ExcludeTargets: flags.ExcludeTargets.Value, + SpecificTargetTagNames: flags.SpecificTargetTagNames.Value, + ExcludedTargetTagNames: flags.ExcludedTargetTagNames.Value, DeploymentFreezeNames: flags.DeploymentFreezeNames.Value, DeploymentFreezeOverrideReason: flags.DeploymentFreezeOverrideReason.Value, Variables: parsedVariables, @@ -275,6 +286,8 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error resolvedFlags.Priority.Value = options.Priority resolvedFlags.DeploymentTargets.Value = options.DeploymentTargets resolvedFlags.ExcludeTargets.Value = options.ExcludeTargets + resolvedFlags.SpecificTargetTagNames.Value = options.SpecificTargetTagNames + resolvedFlags.ExcludedTargetTagNames.Value = options.ExcludedTargetTagNames resolvedFlags.DeploymentFreezeNames.Value = options.DeploymentFreezeNames resolvedFlags.DeploymentFreezeOverrideReason.Value = options.DeploymentFreezeOverrideReason @@ -312,6 +325,8 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error resolvedFlags.ForcePackageDownload, resolvedFlags.DeploymentTargets, resolvedFlags.ExcludeTargets, + resolvedFlags.SpecificTargetTagNames, + resolvedFlags.ExcludedTargetTagNames, resolvedFlags.Variables, resolvedFlags.DeploymentFreezeNames, resolvedFlags.DeploymentFreezeOverrideReason, @@ -529,8 +544,9 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques isGuidedFailureModeSpecified := options.GuidedFailureMode != "" isForcePackageDownloadSpecified := options.ForcePackageDownloadWasSpecified isDeploymentTargetsSpecified := len(options.DeploymentTargets) > 0 || len(options.ExcludeTargets) > 0 + isDeploymentTargetTagsSpecified := len(options.SpecificTargetTagNames) > 0 || len(options.ExcludedTargetTagNames) > 0 - allAdvancedOptionsSpecified := isDeployAtSpecified && isExcludedStepsSpecified && isGuidedFailureModeSpecified && isForcePackageDownloadSpecified && isDeploymentTargetsSpecified + allAdvancedOptionsSpecified := isDeployAtSpecified && isExcludedStepsSpecified && isGuidedFailureModeSpecified && isForcePackageDownloadSpecified && isDeploymentTargetsSpecified && isDeploymentTargetTagsSpecified shouldAskAdvancedQuestions := false if !allAdvancedOptionsSpecified { @@ -629,6 +645,20 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques return err } } + + if !isDeploymentTargetTagsSpecified { + if len(deploymentEnvironmentIDs) == 0 { // if the Q&A process earlier hasn't loaded environments already, we need to load them now + selectedEnvironments, err := executionscommon.FindEnvironments(octopus, options.Environments) + if err != nil { + return err + } + deploymentEnvironmentIDs = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.ID }) + } + options.SpecificTargetTagNames, options.ExcludedTargetTagNames, err = askTargetTags(octopus, asker, space.ID, selectedRelease.ID, deploymentEnvironmentIDs) + if err != nil { + return err + } + } } // DONE return nil @@ -822,6 +852,56 @@ func askDeploymentTargets(octopus *octopusApiClient.Client, asker question.Asker return nil, nil } +func askTargetTags(octopus *octopusApiClient.Client, asker question.Asker, spaceID string, releaseID string, deploymentEnvironmentIDs []string) ([]string, []string, error) { + var results []string + + // collect all available target tags from deployment previews across all environments + for _, envID := range deploymentEnvironmentIDs { + preview, err := deployments.GetReleaseDeploymentPreview(octopus, spaceID, releaseID, envID, true) + if err != nil { + return nil, nil, err + } + for _, step := range preview.StepsToExecute { + for _, tagSet := range step.AvailableTagSets { + for _, tag := range tagSet.AvailableTags { + canonicalName := tagSet.TagSetName + "/" + tag.TagName + if !util.SliceContains(results, canonicalName) { + results = append(results, canonicalName) + } + } + } + } + } + + if len(results) == 0 { + return nil, nil, nil + } + + sort.Strings(results) + + var selectedSpecificTags []string + err := asker(&survey.MultiSelect{ + Message: "Specific target tags to include (If none selected, include all)", + Options: results, + }, &selectedSpecificTags) + if err != nil { + return nil, nil, err + } + + var selectedExcludedTags []string + if len(selectedSpecificTags) == 0 { + err = asker(&survey.MultiSelect{ + Message: "Target tags to exclude (If none selected, exclude none)", + Options: results, + }, &selectedExcludedTags) + if err != nil { + return nil, nil, err + } + } + + return selectedSpecificTags, selectedExcludedTags, nil +} + func askDeploymentPreviewVariables(octopus *octopusApiClient.Client, variablesFromCmd map[string]string, asker question.Asker, spaceID string, releaseID string, deploymentPreviewsReqests []deployments.DeploymentPreviewRequest) (map[string]string, error) { previews, err := deployments.GetReleaseDeploymentPreviews(octopus, spaceID, releaseID, deploymentPreviewsReqests, true) if err != nil { @@ -1077,6 +1157,34 @@ func PrintAdvancedSummary(stdout io.Writer, options *executor.TaskOptionsDeployR depTargetsStr = sb.String() } + targetTagsStr := "All included" + if len(options.SpecificTargetTagNames) != 0 || len(options.ExcludedTargetTagNames) != 0 { + sb := strings.Builder{} + if len(options.SpecificTargetTagNames) > 0 { + sb.WriteString("Include ") + for idx, name := range options.SpecificTargetTagNames { + if idx > 0 { + sb.WriteString(",") + } + sb.WriteString(name) + } + } + if len(options.ExcludedTargetTagNames) > 0 { + if sb.Len() > 0 { + sb.WriteString("; ") + } + + sb.WriteString("Exclude ") + for idx, name := range options.ExcludedTargetTagNames { + if idx > 0 { + sb.WriteString(",") + } + sb.WriteString(name) + } + } + targetTagsStr = sb.String() + } + _, _ = fmt.Fprintf(stdout, output.FormatDoc(heredoc.Doc(` bold(Additional Options): Deploy Time: cyan(%s) @@ -1085,7 +1193,8 @@ func PrintAdvancedSummary(stdout io.Writer, options *executor.TaskOptionsDeployR Priority: cyan(%s) Package Download: cyan(%s) Deployment Targets: cyan(%s) - `)), deployAtStr, skipStepsStr, gfmStr, priorityStr, pkgDownloadStr, depTargetsStr) + Target Tags: cyan(%s) + `)), deployAtStr, skipStepsStr, gfmStr, priorityStr, pkgDownloadStr, depTargetsStr, targetTagsStr) } func selectRelease(octopus *octopusApiClient.Client, ask question.Asker, questionText string, space *spaces.Space, project *projects.Project, channel *channels.Channel) (*releases.Release, error) { diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index 65a91514..9f6c6b7f 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -1173,6 +1173,14 @@ func TestDeployCreate_AskQuestions(t *testing.T) { Options: []string{"vm-1", "vm-2", "vm-4", "vm-5"}, }).AnswerWith([]string{"vm-1", "vm-2"}) + // the previews don't declare any tag sets, so the target tag questions are skipped + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/Spaces-1/releases/%s/deployments/preview/%s?includeDisabledSteps=true", release19.ID, devEnvironment.ID)).RespondWith(&deployments.DeploymentPreview{ + StepsToExecute: []*deployments.DeploymentTemplateStep{}, + }) + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/Spaces-1/releases/%s/deployments/preview/%s?includeDisabledSteps=true", release19.ID, scratchEnvironment.ID)).RespondWith(&deployments.DeploymentPreview{ + StepsToExecute: []*deployments.DeploymentTemplateStep{}, + }) + err := <-errReceiver assert.Nil(t, err) @@ -1277,6 +1285,11 @@ func TestDeployCreate_AskQuestions(t *testing.T) { Options: []string{"vm-1", "vm-2", "vm-4"}, }).AnswerWith([]string{"vm-1"}) + // the preview doesn't declare any tag sets, so the target tag questions are skipped + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/Spaces-1/releases/%s/deployments/preview/%s?includeDisabledSteps=true", release19.ID, devEnvironment.ID)).RespondWith(&deployments.DeploymentPreview{ + StepsToExecute: []*deployments.DeploymentTemplateStep{}, + }) + err := <-errReceiver assert.Nil(t, err) @@ -1307,6 +1320,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { ForcePackageDownload: true, ForcePackageDownloadWasSpecified: true, // need this as well ExcludeTargets: []string{"vm-99"}, + ExcludedTargetTagNames: []string{"Role/Legacy"}, ScheduledStartTime: "some-sort-of-garbage(passthru to server)", } @@ -1333,6 +1347,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { Variables: make(map[string]string, 0), ExcludedSteps: []string{"Cleanup"}, ExcludeTargets: []string{"vm-99"}, + ExcludedTargetTagNames: []string{"Role/Legacy"}, ReleaseID: release19.ID, ScheduledStartTime: "some-sort-of-garbage(passthru to server)", }, options) @@ -1347,7 +1362,8 @@ func TestDeployCreate_AskQuestions(t *testing.T) { GuidedFailureMode: "default", ForcePackageDownload: false, ForcePackageDownloadWasSpecified: true, - ExcludeTargets: []string{"vm-99"}, // just to skip the question + ExcludeTargets: []string{"vm-99"}, // just to skip the question + ExcludedTargetTagNames: []string{"Role/Legacy"}, // just to skip the question ScheduledStartTime: now().String(), } @@ -1374,6 +1390,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { Variables: make(map[string]string, 0), ExcludedSteps: []string{"Cleanup"}, ExcludeTargets: []string{"vm-99"}, + ExcludedTargetTagNames: []string{"Role/Legacy"}, ReleaseID: release19.ID, ScheduledStartTime: "2022-09-08 13:25:02 +0800 Malaysia", }, options) @@ -1423,6 +1440,11 @@ func TestDeployCreate_AskQuestions(t *testing.T) { _ = q.AnswerWith(plus59s) // note it doesn't ask for a scheduled end time + // the preview doesn't declare any tag sets, so the target tag questions are skipped + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/Spaces-1/releases/%s/deployments/preview/%s?includeDisabledSteps=true", release19.ID, devEnvironment.ID)).RespondWith(&deployments.DeploymentPreview{ + StepsToExecute: []*deployments.DeploymentTemplateStep{}, + }) + err := <-errReceiver assert.Nil(t, err) @@ -1495,6 +1517,11 @@ func TestDeployCreate_AskQuestions(t *testing.T) { OverrideNow: refNow, }).AnswerWith(plus61s5min) + // the preview doesn't declare any tag sets, so the target tag questions are skipped + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/Spaces-1/releases/%s/deployments/preview/%s?includeDisabledSteps=true", release19.ID, devEnvironment.ID)).RespondWith(&deployments.DeploymentPreview{ + StepsToExecute: []*deployments.DeploymentTemplateStep{}, + }) + err := <-errReceiver assert.Nil(t, err) @@ -1514,6 +1541,165 @@ func TestDeployCreate_AskQuestions(t *testing.T) { ScheduledExpiryTime: "2022-09-08T13:31:03+08:00", }, options) }}, + + {"target tags with specific tags selected", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, stdout *bytes.Buffer) { + options := &executor.TaskOptionsDeployRelease{ + ProjectName: "fire project", + ReleaseVersion: "1.9", + Environments: []string{"dev"}, + ExcludedSteps: []string{"Cleanup"}, + GuidedFailureMode: "false", + ForcePackageDownloadWasSpecified: true, + DeploymentTargets: []string{"vm-1"}, + ScheduledStartTime: "now", + } + + errReceiver := testutil.GoBegin(func() error { + defer testutil.Close(api, qa) + octopus, _ := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, "") + return deploy.AskQuestions(octopus, stdout, qa.AsAsker(), space1, options, now) + }) + + doStandardApiResponses(options, api, release19, variableSnapshotNoVars) + stdout.Reset() + + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Change additional options?", + Options: []string{"Proceed to deploy", "Change"}, + }).AnswerWith("Change") + stdout.Reset() + + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/Spaces-1/releases/%s/deployments/preview/%s?includeDisabledSteps=true", release19.ID, devEnvironment.ID)).RespondWith(&deployments.DeploymentPreview{ + StepsToExecute: []*deployments.DeploymentTemplateStep{ + { + AvailableTagSets: []*deployments.TagSetPreview{ + { + TagSetName: "Role", + AvailableTags: []*deployments.TargetTagPreview{ + {TagName: "WebServer"}, + {TagName: "Database"}, + {TagName: "Legacy"}, + }, + }, + { + TagSetName: "Environment", + AvailableTags: []*deployments.TargetTagPreview{ + {TagName: "Production"}, + {TagName: "Staging"}, + }, + }, + }, + }, + }, + }) + + _ = qa.ExpectQuestion(t, &survey.MultiSelect{ + Message: "Specific target tags to include (If none selected, include all)", + Options: []string{"Environment/Production", "Environment/Staging", "Role/Database", "Role/Legacy", "Role/WebServer"}, + }).AnswerWith([]string{"Role/WebServer", "Environment/Production"}) + + err := <-errReceiver + assert.Nil(t, err) + + // check that the question-asking process has filled out the things we told it to + assert.Equal(t, &executor.TaskOptionsDeployRelease{ + ProjectName: "Fire Project", + ReleaseVersion: "1.9", + Environments: []string{"dev"}, + GuidedFailureMode: "false", + ForcePackageDownload: false, + ForcePackageDownloadWasSpecified: true, + Variables: make(map[string]string, 0), + ExcludedSteps: []string{"Cleanup"}, + DeploymentTargets: []string{"vm-1"}, + SpecificTargetTagNames: []string{"Role/WebServer", "Environment/Production"}, + ExcludedTargetTagNames: nil, + ReleaseID: release19.ID, + ScheduledStartTime: "now", + }, options) + }}, + + {"target tags with excluded tags selected", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, stdout *bytes.Buffer) { + options := &executor.TaskOptionsDeployRelease{ + ProjectName: "fire project", + ReleaseVersion: "1.9", + Environments: []string{"dev"}, + ExcludedSteps: []string{"Cleanup"}, + GuidedFailureMode: "false", + ForcePackageDownloadWasSpecified: true, + DeploymentTargets: []string{"vm-1"}, + ScheduledStartTime: "now", + } + + errReceiver := testutil.GoBegin(func() error { + defer testutil.Close(api, qa) + octopus, _ := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, "") + return deploy.AskQuestions(octopus, stdout, qa.AsAsker(), space1, options, now) + }) + + doStandardApiResponses(options, api, release19, variableSnapshotNoVars) + stdout.Reset() + + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Change additional options?", + Options: []string{"Proceed to deploy", "Change"}, + }).AnswerWith("Change") + stdout.Reset() + + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/Spaces-1/releases/%s/deployments/preview/%s?includeDisabledSteps=true", release19.ID, devEnvironment.ID)).RespondWith(&deployments.DeploymentPreview{ + StepsToExecute: []*deployments.DeploymentTemplateStep{ + { + AvailableTagSets: []*deployments.TagSetPreview{ + { + TagSetName: "Role", + AvailableTags: []*deployments.TargetTagPreview{ + {TagName: "WebServer"}, + {TagName: "Database"}, + {TagName: "Legacy"}, + }, + }, + { + TagSetName: "Environment", + AvailableTags: []*deployments.TargetTagPreview{ + {TagName: "Production"}, + {TagName: "Staging"}, + }, + }, + }, + }, + }, + }) + + _ = qa.ExpectQuestion(t, &survey.MultiSelect{ + Message: "Specific target tags to include (If none selected, include all)", + Options: []string{"Environment/Production", "Environment/Staging", "Role/Database", "Role/Legacy", "Role/WebServer"}, + }).AnswerWith([]string{}) // Selecting no specific tags to allow testing excluded tags + + _ = qa.ExpectQuestion(t, &survey.MultiSelect{ + Message: "Target tags to exclude (If none selected, exclude none)", + Options: []string{"Environment/Production", "Environment/Staging", "Role/Database", "Role/Legacy", "Role/WebServer"}, + }).AnswerWith([]string{"Role/Legacy"}) + + err := <-errReceiver + assert.Nil(t, err) + + // check that the question-asking process has filled out the things we told it to + assert.Equal(t, &executor.TaskOptionsDeployRelease{ + ProjectName: "Fire Project", + ReleaseVersion: "1.9", + Environments: []string{"dev"}, + GuidedFailureMode: "false", + ForcePackageDownload: false, + ForcePackageDownloadWasSpecified: true, + Variables: make(map[string]string, 0), + ExcludedSteps: []string{"Cleanup"}, + DeploymentTargets: []string{"vm-1"}, + SpecificTargetTagNames: nil, + ExcludedTargetTagNames: []string{"Role/Legacy"}, + ReleaseID: release19.ID, + ScheduledStartTime: "now", + }, options) + }}, } for _, test := range tests { @@ -1967,6 +2153,8 @@ func TestDeployCreate_AutomationMode(t *testing.T) { "--update-variables", "--target", "firstMachine", "--target", "secondMachine", "--exclude-target", "thirdMachine", + "--specific-target-tag", "Role/AppServer", "--specific-target-tag", "Region/US-West", + "--excluded-target-tag", "Maintenance/True", "--deployment-freeze-name", "freeze 1", "--deployment-freeze-name", "freeze 2", "--deployment-freeze-override-reason", "Testing", "--variable", "Approver:John", "--variable", "Signoff:Jane", @@ -1991,16 +2179,18 @@ func TestDeployCreate_AutomationMode(t *testing.T) { ForcePackageRedeployment: true, UpdateVariableSnapshot: true, CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ - SpaceID: "Spaces-1", - ProjectIDOrName: fireProject.Name, - ForcePackageDownload: true, - SpecificMachineNames: []string{"firstMachine", "secondMachine"}, - ExcludedMachineNames: []string{"thirdMachine"}, - SkipStepNames: []string{"Install", "Cleanup"}, - UseGuidedFailure: &trueVal, - Priority: "On", - RunAt: "2022-09-10 13:32:03 +10:00", - NoRunAfter: "2022-09-10 13:37:03 +10:00", + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + ForcePackageDownload: true, + SpecificMachineNames: []string{"firstMachine", "secondMachine"}, + ExcludedMachineNames: []string{"thirdMachine"}, + SpecificTargetTagNames: []string{"Role/AppServer", "Region/US-West"}, + ExcludedTargetTagNames: []string{"Maintenance/True"}, + SkipStepNames: []string{"Install", "Cleanup"}, + UseGuidedFailure: &trueVal, + Priority: "On", + RunAt: "2022-09-10 13:32:03 +10:00", + NoRunAfter: "2022-09-10 13:37:03 +10:00", Variables: map[string]string{ "Approver": "John", "Signoff": "Jane", @@ -2043,6 +2233,8 @@ func TestDeployCreate_AutomationMode(t *testing.T) { "--update-variables", "--target", "firstMachine", "--target", "secondMachine", "--exclude-target", "thirdMachine", + "--specific-target-tag", "Role/WebServer", "--specific-target-tag", "Environment/Production", + "--excluded-target-tag", "Role/Database", "--deployment-freeze-name", "freeze 1", "--deployment-freeze-override-reason", "Testing", "--variable", "Approver:John", "--variable", "Signoff:Jane", @@ -2068,16 +2260,18 @@ func TestDeployCreate_AutomationMode(t *testing.T) { Tenants: []string{"Coke", "Pepsi"}, TenantTags: []string{"Region/us-east"}, CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ - SpaceID: "Spaces-1", - ProjectIDOrName: fireProject.Name, - ForcePackageDownload: true, - SpecificMachineNames: []string{"firstMachine", "secondMachine"}, - ExcludedMachineNames: []string{"thirdMachine"}, - SkipStepNames: []string{"Install", "Cleanup"}, - UseGuidedFailure: &trueVal, - Priority: "On", - RunAt: "2022-09-10 13:32:03 +10:00", - NoRunAfter: "2022-09-10 13:37:03 +10:00", + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + ForcePackageDownload: true, + SpecificMachineNames: []string{"firstMachine", "secondMachine"}, + ExcludedMachineNames: []string{"thirdMachine"}, + SpecificTargetTagNames: []string{"Role/WebServer", "Environment/Production"}, + ExcludedTargetTagNames: []string{"Role/Database"}, + SkipStepNames: []string{"Install", "Cleanup"}, + UseGuidedFailure: &trueVal, + Priority: "On", + RunAt: "2022-09-10 13:32:03 +10:00", + NoRunAfter: "2022-09-10 13:37:03 +10:00", Variables: map[string]string{ "Approver": "John", "Signoff": "Jane", @@ -2296,6 +2490,7 @@ func TestDeployCreate_GenerationOfAutomationCommand_MasksSensitiveVariables(t *t Priority: Jump the task queue Package Download: Use cached packages (if available) Deployment Targets: All included + Target Tags: All included Automation Command: octopus release deploy --space 'Default Space' --project 'Fire Project' --version '2.0' --environment 'dev' --priority 'true' --variable 'Boring Variable:BORING' --variable 'Nuclear Launch Codes:*****' --variable 'Secret Password:*****' --no-prompt Warning: Command includes some sensitive variable values which have been replaced with placeholders. @@ -2323,6 +2518,7 @@ func TestDeployCreate_PrintAdvancedSummary(t *testing.T) { Priority: Use default setting from the lifecycle phase Package Download: Use cached packages (if available) Deployment Targets: All included + Target Tags: All included `), stdout.String()) }}, @@ -2346,6 +2542,7 @@ func TestDeployCreate_PrintAdvancedSummary(t *testing.T) { Priority: Jump the task queue Package Download: Re-download packages from feed Deployment Targets: Include vm-1,vm-2; Exclude vm-3,vm-4 + Target Tags: All included `), stdout.String()) }}, @@ -2363,6 +2560,7 @@ func TestDeployCreate_PrintAdvancedSummary(t *testing.T) { Priority: Use default setting from the lifecycle phase Package Download: Use cached packages (if available) Deployment Targets: Include vm-2 + Target Tags: All included `), stdout.String()) }}, @@ -2380,6 +2578,7 @@ func TestDeployCreate_PrintAdvancedSummary(t *testing.T) { Priority: Use default setting from the lifecycle phase Package Download: Use cached packages (if available) Deployment Targets: Exclude vm-4 + Target Tags: All included `), stdout.String()) }}, } diff --git a/pkg/cmd/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index c1ac4afb..b0a5fa40 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -87,6 +87,9 @@ const ( FlagAliasExcludeTarget = "exclude-target" FlagAliasExcludeMachines = "excludeMachines" // octo wants a comma separated list. We prefer specifying --exclude-target multiple times, but CSV also works because pflag does it for free + FlagSpecificTargetTag = "specific-target-tag" + FlagExcludedTargetTag = "excluded-target-tag" + FlagVariable = "variable" FlagGitRef = "git-ref" @@ -96,50 +99,54 @@ const ( ) type RunFlags struct { - Project *flag.Flag[string] - RunbookName *flag.Flag[string] // the runbook to run - RunbookTags *flag.Flag[[]string] - Environments *flag.Flag[[]string] - Tenants *flag.Flag[[]string] - TenantTags *flag.Flag[[]string] - RunAt *flag.Flag[string] - MaxQueueTime *flag.Flag[string] - Variables *flag.Flag[[]string] - Snapshot *flag.Flag[string] - ExcludedSteps *flag.Flag[[]string] - GuidedFailureMode *flag.Flag[string] // tri-state: true, false, or "use default". Can we model it with an optional bool? - Priority *flag.Flag[string] // tri-state: true, false, or "use default" - ForcePackageDownload *flag.Flag[bool] - RunTargets *flag.Flag[[]string] - ExcludeTargets *flag.Flag[[]string] - GitRef *flag.Flag[string] - PackageVersion *flag.Flag[string] - PackageVersionSpec *flag.Flag[[]string] - GitResourceRefsSpec *flag.Flag[[]string] + Project *flag.Flag[string] + RunbookName *flag.Flag[string] // the runbook to run + RunbookTags *flag.Flag[[]string] + Environments *flag.Flag[[]string] + Tenants *flag.Flag[[]string] + TenantTags *flag.Flag[[]string] + RunAt *flag.Flag[string] + MaxQueueTime *flag.Flag[string] + Variables *flag.Flag[[]string] + Snapshot *flag.Flag[string] + ExcludedSteps *flag.Flag[[]string] + GuidedFailureMode *flag.Flag[string] // tri-state: true, false, or "use default". Can we model it with an optional bool? + Priority *flag.Flag[string] // tri-state: true, false, or "use default" + ForcePackageDownload *flag.Flag[bool] + RunTargets *flag.Flag[[]string] + ExcludeTargets *flag.Flag[[]string] + SpecificTargetTagNames *flag.Flag[[]string] + ExcludedTargetTagNames *flag.Flag[[]string] + GitRef *flag.Flag[string] + PackageVersion *flag.Flag[string] + PackageVersionSpec *flag.Flag[[]string] + GitResourceRefsSpec *flag.Flag[[]string] } func NewRunFlags() *RunFlags { return &RunFlags{ - Project: flag.New[string](FlagProject, false), - RunbookName: flag.New[string](FlagRunbookName, false), - RunbookTags: flag.New[[]string](FlagRunbookTag, false), - Environments: flag.New[[]string](FlagEnvironment, false), - Tenants: flag.New[[]string](FlagTenant, false), - TenantTags: flag.New[[]string](FlagTenantTag, false), - MaxQueueTime: flag.New[string](FlagRunAtExpiry, false), - RunAt: flag.New[string](FlagRunAt, false), - Variables: flag.New[[]string](FlagVariable, false), - Snapshot: flag.New[string](FlagSnapshot, false), - ExcludedSteps: flag.New[[]string](FlagSkip, false), - GuidedFailureMode: flag.New[string](FlagGuidedFailure, false), - Priority: flag.New[string](FlagPriority, false), - ForcePackageDownload: flag.New[bool](FlagForcePackageDownload, false), - RunTargets: flag.New[[]string](FlagRunTarget, false), - ExcludeTargets: flag.New[[]string](FlagExcludeRunTarget, false), - GitRef: flag.New[string](FlagGitRef, false), - PackageVersion: flag.New[string](FlagPackageVersion, false), - PackageVersionSpec: flag.New[[]string](FlagPackageVersionSpec, false), - GitResourceRefsSpec: flag.New[[]string](FlagGitResourceRefSpec, false), + Project: flag.New[string](FlagProject, false), + RunbookName: flag.New[string](FlagRunbookName, false), + RunbookTags: flag.New[[]string](FlagRunbookTag, false), + Environments: flag.New[[]string](FlagEnvironment, false), + Tenants: flag.New[[]string](FlagTenant, false), + TenantTags: flag.New[[]string](FlagTenantTag, false), + MaxQueueTime: flag.New[string](FlagRunAtExpiry, false), + RunAt: flag.New[string](FlagRunAt, false), + Variables: flag.New[[]string](FlagVariable, false), + Snapshot: flag.New[string](FlagSnapshot, false), + ExcludedSteps: flag.New[[]string](FlagSkip, false), + GuidedFailureMode: flag.New[string](FlagGuidedFailure, false), + Priority: flag.New[string](FlagPriority, false), + ForcePackageDownload: flag.New[bool](FlagForcePackageDownload, false), + RunTargets: flag.New[[]string](FlagRunTarget, false), + ExcludeTargets: flag.New[[]string](FlagExcludeRunTarget, false), + SpecificTargetTagNames: flag.New[[]string](FlagSpecificTargetTag, false), + ExcludedTargetTagNames: flag.New[[]string](FlagExcludedTargetTag, false), + GitRef: flag.New[string](FlagGitRef, false), + PackageVersion: flag.New[string](FlagPackageVersion, false), + PackageVersionSpec: flag.New[[]string](FlagPackageVersionSpec, false), + GitResourceRefsSpec: flag.New[[]string](FlagGitResourceRefSpec, false), } } @@ -179,6 +186,8 @@ func NewCmdRun(f factory.Factory) *cobra.Command { flags.BoolVarP(&runFlags.ForcePackageDownload.Value, runFlags.ForcePackageDownload.Name, "", false, "Force re-download of packages") flags.StringArrayVarP(&runFlags.RunTargets.Value, runFlags.RunTargets.Name, "", nil, "Run on this target (can be specified multiple times)") flags.StringArrayVarP(&runFlags.ExcludeTargets.Value, runFlags.ExcludeTargets.Name, "", nil, "Run on targets except for this (can be specified multiple times)") + flags.StringArrayVarP(&runFlags.SpecificTargetTagNames.Value, runFlags.SpecificTargetTagNames.Name, "", nil, "Run on targets matching this tag (can be specified multiple times)") + flags.StringArrayVarP(&runFlags.ExcludedTargetTagNames.Value, runFlags.ExcludedTargetTagNames.Name, "", nil, "Run on targets except for those matching this tag (can be specified multiple times)") flags.StringVarP(&runFlags.GitRef.Value, runFlags.GitRef.Name, "", "", "Git Reference e.g. refs/heads/main. Only relevant for config-as-code projects where runbooks are stored in Git.") flags.StringVarP(&runFlags.PackageVersion.Value, runFlags.PackageVersion.Name, "", "", "Default version to use for all packages. Only relevant for config-as-code projects where runbooks are stored in Git.") flags.StringArrayVarP(&runFlags.PackageVersionSpec.Value, runFlags.PackageVersionSpec.Name, "", nil, "Version specification for a specific package.\nFormat as {package}:{version}, {step}:{version} or {package-ref-name}:{packageOrStep}:{version}\nYou may specify this multiple times.\nOnly relevant for config-as-code projects where runbooks are stored in Git.") @@ -289,20 +298,22 @@ func runbookRun(cmd *cobra.Command, f factory.Factory, flags *RunFlags) error { func runDbRunbook(cmd *cobra.Command, f factory.Factory, flags *RunFlags, octopus *octopusApiClient.Client, project *projects.Project, parsedVariables map[string]string, outputFormat string) error { commonOptions := &executor.TaskOptionsRunbookRunBase{ - ProjectName: project.Name, - RunbookName: flags.RunbookName.Value, - Environments: flags.Environments.Value, - Tenants: flags.Tenants.Value, - TenantTags: flags.TenantTags.Value, - ScheduledStartTime: flags.RunAt.Value, - ScheduledExpiryTime: flags.MaxQueueTime.Value, - ExcludedSteps: flags.ExcludedSteps.Value, - GuidedFailureMode: flags.GuidedFailureMode.Value, - Priority: flags.Priority.Value, - ForcePackageDownload: flags.ForcePackageDownload.Value, - RunTargets: flags.RunTargets.Value, - ExcludeTargets: flags.ExcludeTargets.Value, - Variables: parsedVariables, + ProjectName: project.Name, + RunbookName: flags.RunbookName.Value, + Environments: flags.Environments.Value, + Tenants: flags.Tenants.Value, + TenantTags: flags.TenantTags.Value, + ScheduledStartTime: flags.RunAt.Value, + ScheduledExpiryTime: flags.MaxQueueTime.Value, + ExcludedSteps: flags.ExcludedSteps.Value, + GuidedFailureMode: flags.GuidedFailureMode.Value, + Priority: flags.Priority.Value, + ForcePackageDownload: flags.ForcePackageDownload.Value, + RunTargets: flags.RunTargets.Value, + ExcludeTargets: flags.ExcludeTargets.Value, + SpecificTargetTagNames: flags.SpecificTargetTagNames.Value, + ExcludedTargetTagNames: flags.ExcludedTargetTagNames.Value, + Variables: parsedVariables, } options := &executor.TaskOptionsRunbookRun{ Snapshot: flags.Snapshot.Value, @@ -343,6 +354,8 @@ func runDbRunbook(cmd *cobra.Command, f factory.Factory, flags *RunFlags, octopu resolvedFlags.Priority.Value = options.Priority resolvedFlags.RunTargets.Value = options.RunTargets resolvedFlags.ExcludeTargets.Value = options.ExcludeTargets + resolvedFlags.SpecificTargetTagNames.Value = options.SpecificTargetTagNames + resolvedFlags.ExcludedTargetTagNames.Value = options.ExcludedTargetTagNames didMaskSensitiveVariable := false automationVariables := make(map[string]string, len(options.Variables)) @@ -379,6 +392,8 @@ func runDbRunbook(cmd *cobra.Command, f factory.Factory, flags *RunFlags, octopu resolvedFlags.ForcePackageDownload, resolvedFlags.RunTargets, resolvedFlags.ExcludeTargets, + resolvedFlags.SpecificTargetTagNames, + resolvedFlags.ExcludedTargetTagNames, resolvedFlags.Variables, ) cmd.Printf("\nAutomation Command: %s\n", autoCmd) @@ -423,20 +438,22 @@ func runDbRunbook(cmd *cobra.Command, f factory.Factory, flags *RunFlags, octopu func runGitRunbook(cmd *cobra.Command, f factory.Factory, flags *RunFlags, octopus *octopusApiClient.Client, project *projects.Project, parsedVariables map[string]string, outputFormat string) error { commonOptions := &executor.TaskOptionsRunbookRunBase{ - ProjectName: project.Name, - RunbookName: flags.RunbookName.Value, - Environments: flags.Environments.Value, - Tenants: flags.Tenants.Value, - TenantTags: flags.TenantTags.Value, - ScheduledStartTime: flags.RunAt.Value, - ScheduledExpiryTime: flags.MaxQueueTime.Value, - ExcludedSteps: flags.ExcludedSteps.Value, - GuidedFailureMode: flags.GuidedFailureMode.Value, - Priority: flags.Priority.Value, - ForcePackageDownload: flags.ForcePackageDownload.Value, - RunTargets: flags.RunTargets.Value, - ExcludeTargets: flags.ExcludeTargets.Value, - Variables: parsedVariables, + ProjectName: project.Name, + RunbookName: flags.RunbookName.Value, + Environments: flags.Environments.Value, + Tenants: flags.Tenants.Value, + TenantTags: flags.TenantTags.Value, + ScheduledStartTime: flags.RunAt.Value, + ScheduledExpiryTime: flags.MaxQueueTime.Value, + ExcludedSteps: flags.ExcludedSteps.Value, + GuidedFailureMode: flags.GuidedFailureMode.Value, + Priority: flags.Priority.Value, + ForcePackageDownload: flags.ForcePackageDownload.Value, + RunTargets: flags.RunTargets.Value, + ExcludeTargets: flags.ExcludeTargets.Value, + SpecificTargetTagNames: flags.SpecificTargetTagNames.Value, + ExcludedTargetTagNames: flags.ExcludedTargetTagNames.Value, + Variables: parsedVariables, } options := &executor.TaskOptionsGitRunbookRun{ GitReference: flags.GitRef.Value, @@ -480,6 +497,8 @@ func runGitRunbook(cmd *cobra.Command, f factory.Factory, flags *RunFlags, octop resolvedFlags.Priority.Value = options.Priority resolvedFlags.RunTargets.Value = options.RunTargets resolvedFlags.ExcludeTargets.Value = options.ExcludeTargets + resolvedFlags.SpecificTargetTagNames.Value = options.SpecificTargetTagNames + resolvedFlags.ExcludedTargetTagNames.Value = options.ExcludedTargetTagNames resolvedFlags.GitRef.Value = options.GitReference resolvedFlags.PackageVersion.Value = options.DefaultPackageVersion resolvedFlags.PackageVersionSpec.Value = options.PackageVersionOverrides @@ -520,6 +539,8 @@ func runGitRunbook(cmd *cobra.Command, f factory.Factory, flags *RunFlags, octop resolvedFlags.ForcePackageDownload, resolvedFlags.RunTargets, resolvedFlags.ExcludeTargets, + resolvedFlags.SpecificTargetTagNames, + resolvedFlags.ExcludedTargetTagNames, resolvedFlags.Variables, resolvedFlags.PackageVersion, resolvedFlags.PackageVersionSpec, @@ -769,8 +790,9 @@ func AskDbRunbookRunQuestions(octopus *octopusApiClient.Client, stdout io.Writer isGuidedFailureModeSpecified := options.GuidedFailureMode != "" isForcePackageDownloadSpecified := options.ForcePackageDownloadWasSpecified isRunTargetsSpecified := len(options.RunTargets) > 0 || len(options.ExcludeTargets) > 0 + isRunTargetTagsSpecified := len(options.SpecificTargetTagNames) > 0 || len(options.ExcludedTargetTagNames) > 0 - allAdvancedOptionsSpecified := isRunAtSpecified && isExcludedStepsSpecified && isGuidedFailureModeSpecified && isForcePackageDownloadSpecified && isRunTargetsSpecified + allAdvancedOptionsSpecified := isRunAtSpecified && isExcludedStepsSpecified && isGuidedFailureModeSpecified && isForcePackageDownloadSpecified && isRunTargetsSpecified && isRunTargetTagsSpecified shouldAskAdvancedQuestions, err := shouldAskAdvancedOptions(asker, "Change additional options?", allAdvancedOptionsSpecified) if err != nil { @@ -819,6 +841,20 @@ func AskDbRunbookRunQuestions(octopus *octopusApiClient.Client, stdout io.Writer return err } } + + if !isRunTargetTagsSpecified { + if len(selectedEnvironments) == 0 { // if the Q&A process earlier hasn't loaded environments already, we need to load them now + selectedEnvironments, err = executionscommon.FindEnvironments(octopus, options.Environments) + if err != nil { + return err + } + } + + options.SpecificTargetTagNames, options.ExcludedTargetTagNames, err = askRunbookTargetTags(octopus, asker, space.ID, selectedSnapshot.ID, selectedEnvironments) + if err != nil { + return err + } + } } // DONE return nil @@ -977,8 +1013,9 @@ func AskGitRunbookRunQuestions(octopus *octopusApiClient.Client, stdout io.Write isGuidedFailureModeSpecified := options.GuidedFailureMode != "" isForcePackageDownloadSpecified := options.ForcePackageDownloadWasSpecified isRunTargetsSpecified := len(options.RunTargets) > 0 || len(options.ExcludeTargets) > 0 + isRunTargetTagsSpecified := len(options.SpecificTargetTagNames) > 0 || len(options.ExcludedTargetTagNames) > 0 - allAdvancedOptionsSpecified := isRunAtSpecified && isExcludedStepsSpecified && isGuidedFailureModeSpecified && isForcePackageDownloadSpecified && isRunTargetsSpecified + allAdvancedOptionsSpecified := isRunAtSpecified && isExcludedStepsSpecified && isGuidedFailureModeSpecified && isForcePackageDownloadSpecified && isRunTargetsSpecified && isRunTargetTagsSpecified shouldAskAdvancedQuestions, err := shouldAskAdvancedOptions(asker, "Change additional options?", allAdvancedOptionsSpecified) if err != nil { @@ -1027,6 +1064,20 @@ func AskGitRunbookRunQuestions(octopus *octopusApiClient.Client, stdout io.Write return err } } + + if !isRunTargetTagsSpecified { + if len(selectedEnvironments) == 0 { // if the Q&A process earlier hasn't loaded environments already, we need to load them now + selectedEnvironments, err = executionscommon.FindEnvironments(octopus, options.Environments) + if err != nil { + return err + } + } + + options.SpecificTargetTagNames, options.ExcludedTargetTagNames, err = askGitRunbookTargetTags(octopus, asker, space.ID, project.ID, selectedRunbook.ID, options.GitReference, selectedEnvironments) + if err != nil { + return err + } + } } // DONE return nil @@ -1214,6 +1265,84 @@ func askGitRunbookTargets(octopus *octopusApiClient.Client, asker question.Asker return nil, nil } +func askRunbookTargetTags(octopus *octopusApiClient.Client, asker question.Asker, spaceID string, runbookSnapshotID string, selectedEnvironments []*environments.Environment) ([]string, []string, error) { + var results []string + + // collect all available target tags from runbook run previews across all environments + for _, env := range selectedEnvironments { + preview, err := runbooks.GetRunbookSnapshotRunPreview(octopus, spaceID, runbookSnapshotID, env.ID, true) + if err != nil { + return nil, nil, err + } + results = collectAvailableTargetTags(preview.StepsToExecute, results) + } + + return askTargetTagsFromOptions(asker, results) +} + +func askGitRunbookTargetTags(octopus *octopusApiClient.Client, asker question.Asker, spaceID string, projectID string, runbookID string, gitRef string, selectedEnvironments []*environments.Environment) ([]string, []string, error) { + var results []string + + // collect all available target tags from runbook run previews across all environments + for _, env := range selectedEnvironments { + preview, err := runbooks.GetGitRunbookRunPreview(octopus, spaceID, projectID, runbookID, gitRef, env.ID, true) + if err != nil { + return nil, nil, err + } + results = collectAvailableTargetTags(preview.StepsToExecute, results) + } + + return askTargetTagsFromOptions(asker, results) +} + +// collectAvailableTargetTags appends the canonical names of any target tags the steps can run +// against to results, skipping ones that have already been collected +func collectAvailableTargetTags(steps []*deployments.DeploymentTemplateStep, results []string) []string { + for _, step := range steps { + for _, tagSet := range step.AvailableTagSets { + for _, tag := range tagSet.AvailableTags { + canonicalName := tagSet.TagSetName + "/" + tag.TagName + if !util.SliceContains(results, canonicalName) { + results = append(results, canonicalName) + } + } + } + } + return results +} + +// askTargetTagsFromOptions asks which target tags to include, and if none were included, +// which to exclude. If there are no tags available the questions are skipped entirely. +func askTargetTagsFromOptions(asker question.Asker, results []string) ([]string, []string, error) { + if len(results) == 0 { + return nil, nil, nil + } + + sort.Strings(results) + + var selectedSpecificTags []string + err := asker(&survey.MultiSelect{ + Message: "Specific target tags to include (If none selected, include all)", + Options: results, + }, &selectedSpecificTags) + if err != nil { + return nil, nil, err + } + + var selectedExcludedTags []string + if len(selectedSpecificTags) == 0 { + err = asker(&survey.MultiSelect{ + Message: "Target tags to exclude (If none selected, exclude none)", + Options: results, + }, &selectedExcludedTags) + if err != nil { + return nil, nil, err + } + } + + return selectedSpecificTags, selectedExcludedTags, nil +} + // selectRunEnvironment selects a single environment for use in a tenanted run func selectRunEnvironment(ask question.Asker, octopus *octopusApiClient.Client, space *spaces.Space, project *projects.Project, runbook *runbooks.Runbook) (*environments.Environment, error) { envs, err := runbooks.ListEnvironments(octopus, space.ID, project.ID, runbook.ID) @@ -1294,6 +1423,34 @@ func PrintAdvancedSummary(stdout io.Writer, options *executor.TaskOptionsRunbook runTargetsStr = sb.String() } + targetTagsStr := "All included" + if len(options.SpecificTargetTagNames) != 0 || len(options.ExcludedTargetTagNames) != 0 { + sb := strings.Builder{} + if len(options.SpecificTargetTagNames) > 0 { + sb.WriteString("Include ") + for idx, name := range options.SpecificTargetTagNames { + if idx > 0 { + sb.WriteString(",") + } + sb.WriteString(name) + } + } + if len(options.ExcludedTargetTagNames) > 0 { + if sb.Len() > 0 { + sb.WriteString("; ") + } + + sb.WriteString("Exclude ") + for idx, name := range options.ExcludedTargetTagNames { + if idx > 0 { + sb.WriteString(",") + } + sb.WriteString(name) + } + } + targetTagsStr = sb.String() + } + _, _ = fmt.Fprintf(stdout, output.FormatDoc(heredoc.Doc(` bold(Additional Options): Run At: cyan(%s) @@ -1302,7 +1459,8 @@ func PrintAdvancedSummary(stdout io.Writer, options *executor.TaskOptionsRunbook Priority: cyan(%s) Package Download: cyan(%s) Run Targets: cyan(%s) - `)), runAtStr, skipStepsStr, gfmStr, priorityStr, pkgDownloadStr, runTargetsStr) + Target Tags: cyan(%s) + `)), runAtStr, skipStepsStr, gfmStr, priorityStr, pkgDownloadStr, runTargetsStr, targetTagsStr) } func selectRunbook(octopus *octopusApiClient.Client, ask question.Asker, questionText string, space *spaces.Space, project *projects.Project) (*runbooks.Runbook, error) { diff --git a/pkg/cmd/runbook/run/run_test.go b/pkg/cmd/runbook/run/run_test.go index 4febc4e2..7e6d1731 100644 --- a/pkg/cmd/runbook/run/run_test.go +++ b/pkg/cmd/runbook/run/run_test.go @@ -274,7 +274,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, - {"release deploy specifying all the args", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + {"runbook run specifying all the args", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { defer api.Close() rootCmd.SetArgs([]string{ @@ -291,6 +291,8 @@ func TestRunbookRun_AutomationMode(t *testing.T) { "--force-package-download", "--target", "firstMachine", "--target", "secondMachine", "--exclude-target", "thirdMachine", + "--specific-target-tag", "Role/RunbookServer", "--specific-target-tag", "Environment/Production", + "--excluded-target-tag", "Role/Database", "--excluded-target-tag", "Maintenance/True", "--variable", "Approver:John", "--variable", "Signoff:Jane", "--output-format", "basic", }) @@ -311,16 +313,18 @@ func TestRunbookRun_AutomationMode(t *testing.T) { EnvironmentNames: []string{"dev", "test"}, Snapshot: "Snapshot FWKMLUX", CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ - SpaceID: "Spaces-1", - ProjectIDOrName: fireProject.Name, - ForcePackageDownload: true, - SpecificMachineNames: []string{"firstMachine", "secondMachine"}, - ExcludedMachineNames: []string{"thirdMachine"}, - SkipStepNames: []string{"Install", "Cleanup"}, - UseGuidedFailure: &trueVar, - Priority: "On", - RunAt: "2022-09-10 13:32:03 +10:00", - NoRunAfter: "2022-09-10 13:37:03 +10:00", + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + ForcePackageDownload: true, + SpecificMachineNames: []string{"firstMachine", "secondMachine"}, + ExcludedMachineNames: []string{"thirdMachine"}, + SpecificTargetTagNames: []string{"Role/RunbookServer", "Environment/Production"}, + ExcludedTargetTagNames: []string{"Role/Database", "Maintenance/True"}, + SkipStepNames: []string{"Install", "Cleanup"}, + UseGuidedFailure: &trueVar, + Priority: "On", + RunAt: "2022-09-10 13:32:03 +10:00", + NoRunAfter: "2022-09-10 13:37:03 +10:00", Variables: map[string]string{ "Approver": "John", "Signoff": "Jane", @@ -625,7 +629,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, - {"runbook run specifying all the args", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + {"git runbook run specifying all the args", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { defer api.Close() rootCmd.SetArgs([]string{ @@ -642,6 +646,8 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { "--force-package-download", "--target", "firstMachine", "--target", "secondMachine", "--exclude-target", "thirdMachine", + "--specific-target-tag", "Role/GitRunner", "--specific-target-tag", "Version/Latest", + "--excluded-target-tag", "Role/Legacy", "--variable", "Approver:John", "--variable", "Signoff:Jane", "--package-version", "1.2.0", "--package", "APackageStep:1.5.0", @@ -665,16 +671,18 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { EnvironmentNames: []string{"dev", "test"}, GitRef: "main", CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ - SpaceID: "Spaces-1", - ProjectIDOrName: fireProject.Name, - ForcePackageDownload: true, - SpecificMachineNames: []string{"firstMachine", "secondMachine"}, - ExcludedMachineNames: []string{"thirdMachine"}, - SkipStepNames: []string{"Install", "Cleanup"}, - UseGuidedFailure: &trueVar, - Priority: "On", - RunAt: "2022-09-10 13:32:03 +10:00", - NoRunAfter: "2022-09-10 13:37:03 +10:00", + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + ForcePackageDownload: true, + SpecificMachineNames: []string{"firstMachine", "secondMachine"}, + ExcludedMachineNames: []string{"thirdMachine"}, + SpecificTargetTagNames: []string{"Role/GitRunner", "Version/Latest"}, + ExcludedTargetTagNames: []string{"Role/Legacy"}, + SkipStepNames: []string{"Install", "Cleanup"}, + UseGuidedFailure: &trueVar, + Priority: "On", + RunAt: "2022-09-10 13:32:03 +10:00", + NoRunAfter: "2022-09-10 13:37:03 +10:00", Variables: map[string]string{ "Approver": "John", "Signoff": "Jane", @@ -735,6 +743,7 @@ func TestRunbookRun_PrintAdvancedSummary(t *testing.T) { Priority: Do not jump the task queue Package Download: Use cached packages (if available) Run Targets: All included + Target Tags: All included `), stdout.String()) }}, @@ -760,6 +769,7 @@ func TestRunbookRun_PrintAdvancedSummary(t *testing.T) { Priority: Jump the task queue Package Download: Re-download packages from feed Run Targets: Include vm-1,vm-2; Exclude vm-3,vm-4 + Target Tags: All included `), stdout.String()) }}, @@ -779,6 +789,7 @@ func TestRunbookRun_PrintAdvancedSummary(t *testing.T) { Priority: Do not jump the task queue Package Download: Use cached packages (if available) Run Targets: Include vm-2 + Target Tags: All included `), stdout.String()) }}, @@ -798,6 +809,7 @@ func TestRunbookRun_PrintAdvancedSummary(t *testing.T) { Priority: Do not jump the task queue Package Download: Use cached packages (if available) Run Targets: Exclude vm-4 + Target Tags: All included `), stdout.String()) }}, } diff --git a/pkg/executor/release.go b/pkg/executor/release.go index b3390ad7..1c64e4de 100644 --- a/pkg/executor/release.go +++ b/pkg/executor/release.go @@ -106,6 +106,8 @@ type TaskOptionsDeployRelease struct { ForcePackageDownload bool DeploymentTargets []string ExcludeTargets []string + SpecificTargetTagNames []string + ExcludedTargetTagNames []string Variables map[string]string UpdateVariables bool DeploymentFreezeNames []string @@ -155,6 +157,8 @@ func releaseDeploy(octopus *client.Client, space *spaces.Space, input any) error ForcePackageDownload: params.ForcePackageDownload, SpecificMachineNames: params.DeploymentTargets, ExcludedMachineNames: params.ExcludeTargets, + SpecificTargetTagNames: params.SpecificTargetTagNames, + ExcludedTargetTagNames: params.ExcludedTargetTagNames, SkipStepNames: params.ExcludedSteps, RunAt: params.ScheduledStartTime, NoRunAfter: params.ScheduledExpiryTime, diff --git a/pkg/executor/runbook.go b/pkg/executor/runbook.go index 35244f1a..dfec3331 100644 --- a/pkg/executor/runbook.go +++ b/pkg/executor/runbook.go @@ -23,20 +23,22 @@ type TaskResultRunbookRun struct { // and looking them up for their ID's; we should only deal with strong references at this level type TaskOptionsRunbookRunBase struct { - ProjectName string // required - RunbookName string // the name of the runbook to run - Environments []string - Tenants []string - TenantTags []string - ScheduledStartTime string - ScheduledExpiryTime string - ExcludedSteps []string - GuidedFailureMode string // ["", "true", "false", "default"]. Note default and "" are the same, the only difference is whether interactive mode prompts you - Priority string // ["", "true", "false", "default"]. "" and "default" both leave the server to decide - ForcePackageDownload bool - RunTargets []string - ExcludeTargets []string - Variables map[string]string + ProjectName string // required + RunbookName string // the name of the runbook to run + Environments []string + Tenants []string + TenantTags []string + ScheduledStartTime string + ScheduledExpiryTime string + ExcludedSteps []string + GuidedFailureMode string // ["", "true", "false", "default"]. Note default and "" are the same, the only difference is whether interactive mode prompts you + Priority string // ["", "true", "false", "default"]. "" and "default" both leave the server to decide + ForcePackageDownload bool + RunTargets []string + ExcludeTargets []string + SpecificTargetTagNames []string + ExcludedTargetTagNames []string + Variables map[string]string // extra behaviour commands @@ -77,15 +79,17 @@ func runbookRun(octopus *client.Client, space *spaces.Space, input any) error { // common properties abstractCmd := deployments.CreateExecutionAbstractCommandV1{ - SpaceID: space.ID, - ProjectIDOrName: params.ProjectName, - ForcePackageDownload: params.ForcePackageDownload, - SpecificMachineNames: params.RunTargets, - ExcludedMachineNames: params.ExcludeTargets, - SkipStepNames: params.ExcludedSteps, - RunAt: params.ScheduledStartTime, - NoRunAfter: params.ScheduledExpiryTime, - Variables: params.Variables, + SpaceID: space.ID, + ProjectIDOrName: params.ProjectName, + ForcePackageDownload: params.ForcePackageDownload, + SpecificMachineNames: params.RunTargets, + ExcludedMachineNames: params.ExcludeTargets, + SpecificTargetTagNames: params.SpecificTargetTagNames, + ExcludedTargetTagNames: params.ExcludedTargetTagNames, + SkipStepNames: params.ExcludedSteps, + RunAt: params.ScheduledStartTime, + NoRunAfter: params.ScheduledExpiryTime, + Variables: params.Variables, } b, err := strconv.ParseBool(params.GuidedFailureMode) @@ -157,15 +161,17 @@ func gitRunbookRun(octopus *client.Client, space *spaces.Space, input any) error // common properties abstractCmd := deployments.CreateExecutionAbstractCommandV1{ - SpaceID: space.ID, - ProjectIDOrName: params.ProjectName, - ForcePackageDownload: params.ForcePackageDownload, - SpecificMachineNames: params.RunTargets, - ExcludedMachineNames: params.ExcludeTargets, - SkipStepNames: params.ExcludedSteps, - RunAt: params.ScheduledStartTime, - NoRunAfter: params.ScheduledExpiryTime, - Variables: params.Variables, + SpaceID: space.ID, + ProjectIDOrName: params.ProjectName, + ForcePackageDownload: params.ForcePackageDownload, + SpecificMachineNames: params.RunTargets, + ExcludedMachineNames: params.ExcludeTargets, + SpecificTargetTagNames: params.SpecificTargetTagNames, + ExcludedTargetTagNames: params.ExcludedTargetTagNames, + SkipStepNames: params.ExcludedSteps, + RunAt: params.ScheduledStartTime, + NoRunAfter: params.ScheduledExpiryTime, + Variables: params.Variables, } b, err := strconv.ParseBool(params.GuidedFailureMode)