From a1e4760439519068c4e201f02b23836c2e976497 Mon Sep 17 00:00:00 2001 From: Bec Callow Date: Wed, 1 Apr 2026 17:11:33 +1000 Subject: [PATCH 1/6] feat: allow include and exclude by target tag --- pkg/cmd/release/deploy/deploy.go | 48 ++++++++++++++++++++++++++- pkg/cmd/release/deploy/deploy_test.go | 44 ++++++++++++++---------- pkg/executor/release.go | 4 +++ 3 files changed, 77 insertions(+), 19 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index bbf4d738..0889d931 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -81,6 +81,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" @@ -110,6 +113,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] } @@ -130,6 +135,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), } @@ -172,6 +179,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") @@ -226,6 +235,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, @@ -264,6 +275,8 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error resolvedFlags.GuidedFailureMode.Value = options.GuidedFailureMode 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 @@ -296,6 +309,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, @@ -619,6 +634,8 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques return err } } + + // TODO: Add support for isDeploymentTargetTagsSpecified } // DONE return nil @@ -1065,6 +1082,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) @@ -1072,7 +1117,8 @@ func PrintAdvancedSummary(stdout io.Writer, options *executor.TaskOptionsDeployR Guided Failure Mode: cyan(%s) Package Download: cyan(%s) Deployment Targets: cyan(%s) - `)), deployAtStr, skipStepsStr, gfmStr, pkgDownloadStr, depTargetsStr) + Target Tags: cyan(%s) + `)), deployAtStr, skipStepsStr, gfmStr, 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 a03fe494..2500a86e 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -1880,6 +1880,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", @@ -1904,15 +1906,17 @@ 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, - 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, + 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", @@ -1954,6 +1958,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", @@ -1979,15 +1985,17 @@ 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, - 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, + 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", diff --git a/pkg/executor/release.go b/pkg/executor/release.go index 7e57a9c1..50698dc4 100644 --- a/pkg/executor/release.go +++ b/pkg/executor/release.go @@ -104,6 +104,8 @@ type TaskOptionsDeployRelease struct { ForcePackageDownload bool DeploymentTargets []string ExcludeTargets []string + SpecificTargetTagNames []string + ExcludedTargetTagNames []string Variables map[string]string UpdateVariables bool DeploymentFreezeNames []string @@ -153,6 +155,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, From 6e0259ec2b3cdf394faa8a020b7c7e761330b5f7 Mon Sep 17 00:00:00 2001 From: Bec Callow Date: Wed, 1 Apr 2026 17:12:05 +1000 Subject: [PATCH 2/6] feat: allow include and exclude by target tag for runbooks --- pkg/cmd/runbook/run/run.go | 188 ++++++++++++++++++++------------ pkg/cmd/runbook/run/run_test.go | 48 ++++---- pkg/executor/runbook.go | 68 ++++++------ 3 files changed, 184 insertions(+), 120 deletions(-) diff --git a/pkg/cmd/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index 010455dd..390f9878 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -4,15 +4,16 @@ import ( "encoding/json" "errors" "fmt" - "github.com/OctopusDeploy/cli/pkg/cmd/runbook/shared" - "github.com/OctopusDeploy/cli/pkg/packages" - "golang.org/x/exp/maps" "io" "math" "sort" "strings" "time" + "github.com/OctopusDeploy/cli/pkg/cmd/runbook/shared" + "github.com/OctopusDeploy/cli/pkg/packages" + "golang.org/x/exp/maps" + "github.com/OctopusDeploy/cli/pkg/apiclient" "github.com/AlecAivazis/survey/v2" @@ -84,6 +85,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" @@ -93,48 +97,52 @@ 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? - 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? + 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), - 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), + 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), } } @@ -173,6 +181,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.") @@ -279,19 +289,21 @@ 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, - 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, + 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, @@ -331,6 +343,8 @@ func runDbRunbook(cmd *cobra.Command, f factory.Factory, flags *RunFlags, octopu resolvedFlags.GuidedFailureMode.Value = options.GuidedFailureMode 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)) @@ -362,6 +376,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) @@ -406,19 +422,21 @@ 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, - 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, + 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, @@ -461,6 +479,8 @@ func runGitRunbook(cmd *cobra.Command, f factory.Factory, flags *RunFlags, octop resolvedFlags.GuidedFailureMode.Value = options.GuidedFailureMode 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 @@ -496,6 +516,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, @@ -1266,6 +1288,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) @@ -1273,7 +1323,8 @@ func PrintAdvancedSummary(stdout io.Writer, options *executor.TaskOptionsRunbook Guided Failure Mode: cyan(%s) Package Download: cyan(%s) Run Targets: cyan(%s) - `)), runAtStr, skipStepsStr, gfmStr, pkgDownloadStr, runTargetsStr) + Target Tags: cyan(%s) + `)), runAtStr, skipStepsStr, gfmStr, pkgDownloadStr, runTargetsStr, targetTagsStr) } func selectRunbook(octopus *octopusApiClient.Client, ask question.Asker, questionText string, space *spaces.Space, project *projects.Project) (*runbooks.Runbook, error) { @@ -1340,4 +1391,3 @@ func findGitRunbook(octopus *octopusApiClient.Client, spaceID string, projectID } return result, err } - diff --git a/pkg/cmd/runbook/run/run_test.go b/pkg/cmd/runbook/run/run_test.go index 33c1904d..9455a674 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{ @@ -290,6 +290,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", }) @@ -310,15 +312,17 @@ 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, - 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, + 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", @@ -623,7 +627,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{ @@ -639,6 +643,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", @@ -662,15 +668,17 @@ 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, - 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, + 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", diff --git a/pkg/executor/runbook.go b/pkg/executor/runbook.go index e6ca7038..0ed22a16 100644 --- a/pkg/executor/runbook.go +++ b/pkg/executor/runbook.go @@ -22,19 +22,21 @@ 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 - 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 + ForcePackageDownload bool + RunTargets []string + ExcludeTargets []string + SpecificTargetTagNames []string + ExcludedTargetTagNames []string + Variables map[string]string // extra behaviour commands @@ -75,15 +77,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) @@ -149,15 +153,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) From 527e949bf5792c2d6bea73dd494a6f8d3c2fadb9 Mon Sep 17 00:00:00 2001 From: Bec Callow Date: Thu, 2 Apr 2026 14:06:24 +1000 Subject: [PATCH 3/6] feat: support interactive mode for include and exclude target tags --- pkg/cmd/release/deploy/deploy.go | 67 +++++++++++++++++++++- pkg/cmd/release/deploy/deploy_test.go | 82 +++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 0889d931..89eac51d 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -534,8 +534,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 { @@ -635,7 +636,19 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques } } - // TODO: Add support for isDeploymentTargetTagsSpecified + 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 @@ -829,6 +842,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 { diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index 2500a86e..29f09c10 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -1517,6 +1517,88 @@ func TestDeployCreate_AskQuestions(t *testing.T) { ScheduledExpiryTime: "2022-09-08T13:31:03+08:00", }, options) }}, + + {"target tags with specific and excluded tags", 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"}) + + _ = 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: []string{"Role/WebServer", "Environment/Production"}, + ExcludedTargetTagNames: []string{"Role/Legacy"}, + ReleaseID: release19.ID, + ScheduledStartTime: "now", + }, options) + }}, } for _, test := range tests { From dc18028acdbf995261a4abf556aa0f08de88f7db Mon Sep 17 00:00:00 2001 From: Bec Callow Date: Thu, 2 Apr 2026 15:31:25 +1000 Subject: [PATCH 4/6] chore: fix tests --- pkg/cmd/release/deploy/deploy_test.go | 81 ++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index 29f09c10..be7e0f61 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -1518,7 +1518,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }, options) }}, - {"target tags with specific and excluded tags", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, stdout *bytes.Buffer) { + {"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", @@ -1574,6 +1574,83 @@ func TestDeployCreate_AskQuestions(t *testing.T) { 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"}, @@ -1593,7 +1670,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { Variables: make(map[string]string, 0), ExcludedSteps: []string{"Cleanup"}, DeploymentTargets: []string{"vm-1"}, - SpecificTargetTagNames: []string{"Role/WebServer", "Environment/Production"}, + SpecificTargetTagNames: nil, ExcludedTargetTagNames: []string{"Role/Legacy"}, ReleaseID: release19.ID, ScheduledStartTime: "now", From 18aae38874d79b8d5707c24bdc94b4d6cfe9e5e0 Mon Sep 17 00:00:00 2001 From: Bec Callow Date: Thu, 27 Aug 2026 13:36:38 +1000 Subject: [PATCH 5/6] feat: prompt for target tags in interactive runbook run Interactive runbook run listed target tags in the additional options summary but never asked about them, so they could only be set via flags. Release deploy already prompts, making the two inconsistent. Ask for the tags to include (and to exclude, when none were included) after the run targets question, for both database and Git runbooks, matching the release deploy flow. Co-Authored-By: Claude Opus 5 (1M context) --- go.mod | 2 +- go.sum | 4 +- pkg/cmd/runbook/run/run.go | 112 ++++++++++++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 5 deletions(-) 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/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index aef01c97..b0a5fa40 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -790,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 { @@ -840,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 @@ -998,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 { @@ -1048,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 @@ -1235,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) From 66e5def9b707ddf6aecf103ebee1d5a7acdf368a Mon Sep 17 00:00:00 2001 From: Bec Callow Date: Thu, 27 Aug 2026 14:33:01 +1000 Subject: [PATCH 6/6] chore: fix tests for target tags and the priority merge Three groups of failures after merging main: - PrintAdvancedSummary expectations predate the "Target Tags" line, as main's --priority tests were written before it existed. - Two interactive deploy tests deadlocked: they answer "Change" and so now reach the target tags step, which fetches a deployment preview the mock server was never told to serve. Two scheduled start time tests hit the same problem. Serve the extra preview; the responses declare no tag sets, so the tag questions are skipped. - The "doesn't ask if all opts are supplied" tests no longer supplied every advanced option once target tags joined the set, so they were offered the additional options question and hung waiting on it. Give them a tag option so they cover what their name describes. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/deploy/deploy_test.go | 34 ++++++++++++++++++++++++++- pkg/cmd/runbook/run/run_test.go | 4 ++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index 57b46f66..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) @@ -2463,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. @@ -2490,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()) }}, @@ -2513,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()) }}, @@ -2530,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()) }}, @@ -2547,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_test.go b/pkg/cmd/runbook/run/run_test.go index 2bf74e00..7e6d1731 100644 --- a/pkg/cmd/runbook/run/run_test.go +++ b/pkg/cmd/runbook/run/run_test.go @@ -743,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()) }}, @@ -768,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()) }}, @@ -787,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()) }}, @@ -806,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()) }}, }