From d25b40c20bbde9417d234542014a7050e059b2d9 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 19 Aug 2026 11:42:06 +1000 Subject: [PATCH 01/18] fix: accept comma-separated values on deployment target and scope flags `--deployment-target "ABC,XYZ"` was sent to the server as a single target name because the flag is a pflag StringArray, while its legacy aliases (`--target`, `--specificMachines`) are StringSlice and already split on commas. Expand comma-separated values for the environment, tenant, tenant-tag and target flags on `release deploy` and `runbook run`, so the comma form matches the repeat-the-flag form. Values that can legitimately contain a comma (--variable, --skip, package/git-resource specs) are left alone. Fixes #556 Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/deploy/deploy.go | 17 +++- pkg/cmd/release/deploy/deploy_test.go | 95 +++++++++++++++++++ pkg/cmd/runbook/run/run.go | 17 +++- pkg/cmd/runbook/run/run_test.go | 48 ++++++++++ pkg/executionscommon/executionscommon.go | 23 +++++ pkg/executionscommon/executionscommon_test.go | 30 ++++++ 6 files changed, 220 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 0df6d614..0bb5c4ea 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -160,9 +160,9 @@ func NewCmdDeploy(f factory.Factory) *cobra.Command { flags := cmd.Flags() flags.StringVarP(&deployFlags.Project.Value, deployFlags.Project.Name, "p", "", "Name or ID of the project to deploy the release from") flags.StringVarP(&deployFlags.ReleaseVersion.Value, deployFlags.ReleaseVersion.Name, "", "", "Release version to deploy") - flags.StringArrayVarP(&deployFlags.Environments.Value, deployFlags.Environments.Name, "e", nil, "Deploy to this environment (can be specified multiple times)") - flags.StringArrayVarP(&deployFlags.Tenants.Value, deployFlags.Tenants.Name, "", nil, "Deploy to this tenant (can be specified multiple times)") - flags.StringArrayVarP(&deployFlags.TenantTags.Value, deployFlags.TenantTags.Name, "", nil, "Deploy to tenants matching this tag (can be specified multiple times). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") + flags.StringArrayVarP(&deployFlags.Environments.Value, deployFlags.Environments.Name, "e", nil, "Deploy to this environment (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&deployFlags.Tenants.Value, deployFlags.Tenants.Name, "", nil, "Deploy to this tenant (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&deployFlags.TenantTags.Value, deployFlags.TenantTags.Name, "", nil, "Deploy to tenants matching this tag (can be specified multiple times, or as a comma-separated list). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") flags.StringVarP(&deployFlags.DeployAt.Value, deployFlags.DeployAt.Name, "", "", "Deploy at a later time. Deploy now if omitted. TODO date formats and timezones!") flags.StringVarP(&deployFlags.MaxQueueTime.Value, deployFlags.MaxQueueTime.Name, "", "", "Cancel the deployment if it hasn't started within this time period.") flags.StringArrayVarP(&deployFlags.Variables.Value, deployFlags.Variables.Name, "v", nil, "Set the value for a prompted variable in the format Label:Value") @@ -170,8 +170,8 @@ func NewCmdDeploy(f factory.Factory) *cobra.Command { flags.StringArrayVarP(&deployFlags.ExcludedSteps.Value, deployFlags.ExcludedSteps.Name, "", nil, "Exclude specific steps from the deployment") flags.StringVarP(&deployFlags.GuidedFailureMode.Value, deployFlags.GuidedFailureMode.Name, "", "", "Enable Guided failure mode (true/false/default)") 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.DeploymentTargets.Value, deployFlags.DeploymentTargets.Name, "", nil, "Deploy to this target (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&deployFlags.ExcludeTargets.Value, deployFlags.ExcludeTargets.Name, "", nil, "Deploy to targets except for this (can be specified multiple times, or as a comma-separated list)") 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") @@ -198,6 +198,13 @@ func NewCmdDeploy(f factory.Factory) *cobra.Command { } func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error { + // these flags accept a comma-separated list as well as being specified multiple times + flags.Environments.Value = executionscommon.ExpandCommaSeparated(flags.Environments.Value) + flags.Tenants.Value = executionscommon.ExpandCommaSeparated(flags.Tenants.Value) + flags.TenantTags.Value = executionscommon.ExpandCommaSeparated(flags.TenantTags.Value) + flags.DeploymentTargets.Value = executionscommon.ExpandCommaSeparated(flags.DeploymentTargets.Value) + flags.ExcludeTargets.Value = executionscommon.ExpandCommaSeparated(flags.ExcludeTargets.Value) + outputFormat, err := cmd.Flags().GetString(constants.FlagOutputFormat) if err != nil { // should never happen, but fallback if it does outputFormat = constants.OutputFormatTable diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index fde01017..618ec30e 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -2006,6 +2006,101 @@ func TestDeployCreate_AutomationMode(t *testing.T) { assert.Equal(t, "ServerTasks-29394\n", stdOut.String()) assert.Equal(t, "", stdErr.String()) }}, + + {"release deploy accepts comma-separated targets and environments; untenanted", 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{ + "release", "deploy", + "--project", fireProject.Name, + "--version", "1.0", + "--environment", "dev,test", // comma form + // mixed form; names containing spaces are preserved, whitespace around the comma is not + "--deployment-target", "first Machine, second Machine", "--deployment-target", "third Machine", + "--exclude-deployment-target", "fourthMachine,fifthMachine", + "--output-format", "basic", // not neccessary, just means we don't need the follow up HTTP requests at the end to print the web link + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") + requestBody, err := testutil.ReadJson[deployments.CreateDeploymentUntenantedCommandV1](req.Request.Body) + assert.Nil(t, err) + + assert.Equal(t, deployments.CreateDeploymentUntenantedCommandV1{ + ReleaseVersion: "1.0", + EnvironmentNames: []string{"dev", "test"}, + CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + SpecificMachineNames: []string{"first Machine", "second Machine", "third Machine"}, + ExcludedMachineNames: []string{"fourthMachine", "fifthMachine"}, + }, + }, requestBody) + + req.RespondWith(&deployments.CreateDeploymentResponseV1{ + DeploymentServerTasks: []*deployments.DeploymentServerTask{ + {DeploymentID: "Deployments-203", ServerTaskID: "ServerTasks-29394"}, + }, + }) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, "ServerTasks-29394\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"release deploy accepts comma-separated tenants and tenant tags; tenanted", 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{ + "release", "deploy", + "--project", fireProject.Name, + "--version", "1.0", + "--environment", "dev", + "--tenant", "Coke,Pepsi", // comma form + "--tenant-tag", "Region/us-east", "--tenant-tag", "Region/us-west,Region/eu", // mixed form + "--output-format", "basic", + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") + requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) + assert.Nil(t, err) + + assert.Equal(t, deployments.CreateDeploymentTenantedCommandV1{ + ReleaseVersion: "1.0", + EnvironmentName: "dev", + Tenants: []string{"Coke", "Pepsi"}, + TenantTags: []string{"Region/us-east", "Region/us-west", "Region/eu"}, + CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + }, + }, requestBody) + + req.RespondWith(&deployments.CreateDeploymentResponseV1{ + DeploymentServerTasks: []*deployments.DeploymentServerTask{ + {DeploymentID: "Deployments-203", ServerTaskID: "ServerTasks-29394"}, + }, + }) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, "ServerTasks-29394\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, } for _, test := range tests { diff --git a/pkg/cmd/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index ad57eb89..83959392 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -162,9 +162,9 @@ func NewCmdRun(f factory.Factory) *cobra.Command { flags.StringVarP(&runFlags.Project.Value, runFlags.Project.Name, "p", "", "Name or ID of the project to run the runbook from") flags.StringVarP(&runFlags.RunbookName.Value, runFlags.RunbookName.Name, "n", "", "Name of the runbook to run") flags.StringArrayVarP(&runFlags.RunbookTags.Value, runFlags.RunbookTags.Name, "", nil, "Run all runbooks matching this tag (can be specified multiple times). Format is 'Tag Set Name/Tag Name'. Mutually exclusive with --name.") - flags.StringArrayVarP(&runFlags.Environments.Value, runFlags.Environments.Name, "e", nil, "Run in this environment (can be specified multiple times)") - flags.StringArrayVarP(&runFlags.Tenants.Value, runFlags.Tenants.Name, "", nil, "Run for this tenant (can be specified multiple times)") - flags.StringArrayVarP(&runFlags.TenantTags.Value, runFlags.TenantTags.Name, "", nil, "Run for tenants matching this tag (can be specified multiple times). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") + flags.StringArrayVarP(&runFlags.Environments.Value, runFlags.Environments.Name, "e", nil, "Run in this environment (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&runFlags.Tenants.Value, runFlags.Tenants.Name, "", nil, "Run for this tenant (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&runFlags.TenantTags.Value, runFlags.TenantTags.Name, "", nil, "Run for tenants matching this tag (can be specified multiple times, or as a comma-separated list). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") flags.StringVarP(&runFlags.RunAt.Value, runFlags.RunAt.Name, "", "", "Run at a later time. Run now if omitted. TODO date formats and timezones!") flags.StringVarP(&runFlags.MaxQueueTime.Value, runFlags.MaxQueueTime.Name, "", "", "Cancel a scheduled run if it hasn't started within this time period.") flags.StringArrayVarP(&runFlags.Variables.Value, runFlags.Variables.Name, "v", nil, "Set the value for a prompted variable in the format Label:Value") @@ -172,8 +172,8 @@ func NewCmdRun(f factory.Factory) *cobra.Command { flags.StringArrayVarP(&runFlags.ExcludedSteps.Value, runFlags.ExcludedSteps.Name, "", nil, "Exclude specific steps from the runbook") flags.StringVarP(&runFlags.GuidedFailureMode.Value, runFlags.GuidedFailureMode.Name, "", "", "Enable Guided failure mode (true/false/default)") 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.RunTargets.Value, runFlags.RunTargets.Name, "", nil, "Run on this target (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&runFlags.ExcludeTargets.Value, runFlags.ExcludeTargets.Name, "", nil, "Run on targets except for this (can be specified multiple times, or as a comma-separated list)") 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.") @@ -201,6 +201,13 @@ func NewCmdRun(f factory.Factory) *cobra.Command { } func runbookRun(cmd *cobra.Command, f factory.Factory, flags *RunFlags) error { + // these flags accept a comma-separated list as well as being specified multiple times + flags.Environments.Value = executionscommon.ExpandCommaSeparated(flags.Environments.Value) + flags.Tenants.Value = executionscommon.ExpandCommaSeparated(flags.Tenants.Value) + flags.TenantTags.Value = executionscommon.ExpandCommaSeparated(flags.TenantTags.Value) + flags.RunTargets.Value = executionscommon.ExpandCommaSeparated(flags.RunTargets.Value) + flags.ExcludeTargets.Value = executionscommon.ExpandCommaSeparated(flags.ExcludeTargets.Value) + if flags.RunbookName.Value != "" && len(flags.RunbookTags.Value) > 0 { return errors.New("--name and --runbook-tag are mutually exclusive. Please specify either a runbook name or runbook tags, not both") } diff --git a/pkg/cmd/runbook/run/run_test.go b/pkg/cmd/runbook/run/run_test.go index 33c1904d..fc5b872b 100644 --- a/pkg/cmd/runbook/run/run_test.go +++ b/pkg/cmd/runbook/run/run_test.go @@ -338,6 +338,54 @@ func TestRunbookRun_AutomationMode(t *testing.T) { assert.Contains(t, stdOut.String(), "ServerTasks-29394\n") assert.Equal(t, "", stdErr.String()) }}, + + {"runbook run accepts comma-separated environments and targets", 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{ + "runbook", "run", + "--project", "Fire Project", + "--runbook", "Provision Database", + "--environment", "dev,test", // comma form + // mixed form; names containing spaces are preserved, whitespace around the comma is not + "--run-target", "first Machine, second Machine", "--run-target", "third Machine", + "--exclude-run-target", "fourthMachine,fifthMachine", + "--output-format", "basic", + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1") + requestBody, err := testutil.ReadJson[runbooks.RunbookRunCommandV1](req.Request.Body) + assert.Nil(t, err) + + assert.Equal(t, runbooks.RunbookRunCommandV1{ + RunbookName: "Provision Database", + EnvironmentNames: []string{"dev", "test"}, + CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + SpecificMachineNames: []string{"first Machine", "second Machine", "third Machine"}, + ExcludedMachineNames: []string{"fourthMachine", "fifthMachine"}, + }, + }, requestBody) + + req.RespondWith(&runbooks.RunbookRunResponseV1{ + RunbookRunServerTasks: []*runbooks.RunbookRunServerTask{ + {RunbookRunID: "RunbookRun-203", ServerTaskID: "ServerTasks-29394"}, + }, + }) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Contains(t, stdOut.String(), "ServerTasks-29394\n") + assert.Equal(t, "", stdErr.String()) + }}, } for _, test := range tests { diff --git a/pkg/executionscommon/executionscommon.go b/pkg/executionscommon/executionscommon.go index 4348d7bd..e875557e 100644 --- a/pkg/executionscommon/executionscommon.go +++ b/pkg/executionscommon/executionscommon.go @@ -301,6 +301,29 @@ func AskVariableSpecificPrompt(asker question.Asker, message string, variableTyp } } +// ExpandCommaSeparated splits each entry on commas so `--flag "A,B"` behaves the same as +// `--flag A --flag B`. Whitespace around each entry is trimmed and blank entries are dropped. +// Only apply this to flags whose values cannot legitimately contain a comma; notably NOT to +// --variable, --skip or the package/git-resource specs. +func ExpandCommaSeparated(values []string) []string { + if len(values) == 0 { + return values + } + result := make([]string, 0, len(values)) + for _, value := range values { + for _, component := range strings.Split(value, ",") { + component = strings.TrimSpace(component) + if component != "" { + result = append(result, component) + } + } + } + if len(result) == 0 { + return nil + } + return result +} + func ParseVariableStringArray(variables []string) (map[string]string, error) { result := make(map[string]string, len(variables)) for _, v := range variables { diff --git a/pkg/executionscommon/executionscommon_test.go b/pkg/executionscommon/executionscommon_test.go index 72604be2..26db6a16 100644 --- a/pkg/executionscommon/executionscommon_test.go +++ b/pkg/executionscommon/executionscommon_test.go @@ -412,3 +412,33 @@ func TestToVariableStringArray(t *testing.T) { }) } } + +func TestExpandCommaSeparated(t *testing.T) { + tests := []struct { + name string + input []string + expect []string + }{ + {name: "nil stays nil", input: nil, expect: nil}, + {name: "single value", input: []string{"ABC"}, expect: []string{"ABC"}}, + + {name: "comma form", input: []string{"ABC,XYZ"}, expect: []string{"ABC", "XYZ"}}, + {name: "repeated form", input: []string{"ABC", "XYZ"}, expect: []string{"ABC", "XYZ"}}, + {name: "mixed form", input: []string{"ABC,XYZ", "DEF"}, expect: []string{"ABC", "XYZ", "DEF"}}, + + {name: "preserves spaces within values", input: []string{"Web Server 01,Web Server 02"}, expect: []string{"Web Server 01", "Web Server 02"}}, + {name: "trims spaces around values", input: []string{" ABC ,\tXYZ "}, expect: []string{"ABC", "XYZ"}}, + + {name: "preserves order and duplicates", input: []string{"ABC,ABC"}, expect: []string{"ABC", "ABC"}}, + {name: "tenant tags", input: []string{"Regions/us-east,Regions/us-west"}, expect: []string{"Regions/us-east", "Regions/us-west"}}, + + {name: "drops blank entries", input: []string{"ABC,,XYZ"}, expect: []string{"ABC", "XYZ"}}, + {name: "all blank entries returns nil", input: []string{"", " , "}, expect: nil}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expect, executionscommon.ExpandCommaSeparated(test.input)) + }) + } +} From 073cf922d745bc065f75bd4e7802858527935e4c Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 19 Aug 2026 11:44:08 +1000 Subject: [PATCH 02/18] fix: report missing package versions instead of a server null reference `release create --no-prompt` sends the create request straight to the server without resolving package versions first. When a package has no version in its feed the server raises a null reference exception, which surfaces as "Octopus API error: Object reference not set to an instance of an object. []". On a 5xx failure the CLI now repeats the package version resolution the server does, and reports the packages, steps and feeds that have no version available. Where it can't identify a specific package, an unhandled server error now carries a hint about the likely causes. Fixes #426 Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create.go | 93 +++++++++++- pkg/cmd/release/create/create_test.go | 207 ++++++++++++++++++++++++++ pkg/packages/packages.go | 101 ++++++++++--- 3 files changed, 382 insertions(+), 19 deletions(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index b4b50caa..2f1d835b 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -28,6 +28,7 @@ import ( "github.com/OctopusDeploy/cli/pkg/util/flag" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels" octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/feeds" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" @@ -318,7 +319,7 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error executor.NewTask(executor.TaskTypeCreateRelease, options), }) if err != nil { - return err + return DiagnoseCreateReleaseFailure(octopus, options, err) } if options.Response != nil { @@ -420,6 +421,96 @@ func BuildPackageVersionBaselineForChannel(octopus *octopusApiClient.Client, dep return result, nil } +// serverNullReferenceMessage is what an Octopus Server sends back when it hits an unhandled +// null reference exception; it carries no information about what actually went wrong. +const serverNullReferenceMessage = "Object reference not set to an instance of an object" + +// DiagnoseCreateReleaseFailure replaces an opaque server-side failure with an actionable message where +// it can. The server raises a null reference exception, surfaced as a bare 500, when it can't select a +// version for a package; see https://github.com/OctopusDeploy/cli/issues/426 +func DiagnoseCreateReleaseFailure(octopus *octopusApiClient.Client, options *executor.TaskOptionsCreateRelease, cause error) error { + var apiError *core.APIError + if !errors.As(cause, &apiError) || apiError.StatusCode < 500 { + return cause + } + + // diagnosis is best-effort; if any part of it fails we must not mask the original failure + if octopus != nil && options != nil { + if missingPackages, findErr := findPackagesWithoutVersions(octopus, options); findErr == nil && len(missingPackages) > 0 { + return packages.NewMissingPackageVersionsError(missingPackages, cause) + } + } + + if strings.Contains(apiError.ErrorMessage, serverNullReferenceMessage) { + return fmt.Errorf("%w\nthe server failed with an unhandled error; this usually means it could not resolve the packages, channel or git reference for the release", cause) + } + return cause +} + +// findPackagesWithoutVersions repeats the package version resolution the server does when it assembles a +// release, so we can report which packages have no version available in their feed. +func findPackagesWithoutVersions(octopus *octopusApiClient.Client, options *executor.TaskOptionsCreateRelease) ([]releases.ReleaseTemplatePackage, error) { + project, err := selectors.FindProject(octopus, options.ProjectName) + if err != nil { + return nil, err + } + + gitReferenceKey := "" + if project.PersistenceSettings != nil && project.PersistenceSettings.Type() == projects.PersistenceSettingsTypeVersionControlled { + gitReferenceKey = options.GitReference + if options.GitCommit != "" { // prefer a specific git commit if one was specified + gitReferenceKey = options.GitCommit + } + } + + deploymentProcess, err := octopus.DeploymentProcesses.Get(project, gitReferenceKey) + if err != nil { + return nil, err + } + + channel, err := findChannelForDiagnosis(octopus, project, options.ChannelName) + if err != nil { + return nil, err + } + + deploymentProcessTemplate, err := octopus.DeploymentProcesses.GetTemplate(deploymentProcess, channel.ID, "") + if err != nil { + return nil, err + } + + packageVersionBaseline, err := BuildPackageVersionBaselineForChannel(octopus, deploymentProcessTemplate, channel) + if err != nil { + return nil, err + } + + overrides := packages.BuildPackageVersionOverrides(packageVersionBaseline, options.DefaultPackageVersion, options.PackageVersionOverrides) + resolvedVersions := packages.ApplyPackageOverrides(packageVersionBaseline, overrides) + + return packages.FindPackagesWithoutVersions(deploymentProcessTemplate.Packages, resolvedVersions), nil +} + +// findChannelForDiagnosis locates the channel the server would have used. When no channel was specified we +// can only guess; the default channel is the best approximation available to us. +func findChannelForDiagnosis(octopus *octopusApiClient.Client, project *projects.Project, channelName string) (*channels.Channel, error) { + if channelName != "" { + return selectors.FindChannel(octopus, project, channelName) + } + + existingChannels, err := octopus.Projects.GetChannels(project) + if err != nil { + return nil, err + } + if len(existingChannels) == 1 { + return existingChannels[0], nil + } + for _, c := range existingChannels { + if c.IsDefault { + return c, nil + } + } + return nil, fmt.Errorf("cannot determine the default channel for project %s", project.GetName()) +} + func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsCreateRelease) error { if octopus == nil { return cliErrors.NewArgumentNullOrEmptyError("octopus") diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 65ee97c7..87078d8e 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -3,6 +3,7 @@ package create_test import ( "bytes" "errors" + "net/http" "net/url" "os" "testing" @@ -19,6 +20,7 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels" octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/credentials" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/feeds" @@ -2829,3 +2831,208 @@ func TestReleaseCreate_ApplyPackageOverride(t *testing.T) { }, result) }) } + +func TestReleaseCreate_FindPackagesWithoutVersions(t *testing.T) { + resolvable := releases.ReleaseTemplatePackage{ + ActionName: "Deploy Website", + FeedID: "feeds-builtin", + FeedName: "Octopus Server (built-in)", + PackageID: "acme-web", + PackageReferenceName: "acme-web", + IsResolvable: true, + } + + t.Run("reports a resolvable package with no version", func(t *testing.T) { + missing := packages.FindPackagesWithoutVersions( + []releases.ReleaseTemplatePackage{resolvable}, + []*packages.StepPackageVersion{{PackageID: "acme-web", ActionName: "Deploy Website", PackageReferenceName: "acme-web", Version: ""}}) + + assert.Equal(t, []releases.ReleaseTemplatePackage{resolvable}, missing) + }) + + t.Run("ignores a package which has a version", func(t *testing.T) { + missing := packages.FindPackagesWithoutVersions( + []releases.ReleaseTemplatePackage{resolvable}, + []*packages.StepPackageVersion{{PackageID: "acme-web", ActionName: "Deploy Website", PackageReferenceName: "acme-web", Version: "1.0.0"}}) + + assert.Equal(t, []releases.ReleaseTemplatePackage{}, missing) + }) + + t.Run("ignores packages which don't need a version at release creation time", func(t *testing.T) { + fixed := resolvable + fixed.FixedVersion = "1.0.0" + unresolvable := resolvable + unresolvable.IsResolvable = false + + missing := packages.FindPackagesWithoutVersions( + []releases.ReleaseTemplatePackage{fixed, unresolvable}, + []*packages.StepPackageVersion{{PackageID: "acme-web", ActionName: "Deploy Website", PackageReferenceName: "acme-web", Version: ""}}) + + assert.Equal(t, []releases.ReleaseTemplatePackage{}, missing) + }) + + t.Run("matches on step and package reference, not just package ID", func(t *testing.T) { + secondStep := resolvable + secondStep.ActionName = "Deploy Worker" + + missing := packages.FindPackagesWithoutVersions( + []releases.ReleaseTemplatePackage{resolvable, secondStep}, + []*packages.StepPackageVersion{ + {PackageID: "acme-web", ActionName: "Deploy Website", PackageReferenceName: "acme-web", Version: "1.0.0"}, + {PackageID: "acme-web", ActionName: "Deploy Worker", PackageReferenceName: "acme-web", Version: ""}, + }) + + assert.Equal(t, []releases.ReleaseTemplatePackage{secondStep}, missing) + }) +} + +func TestReleaseCreate_MissingPackageVersionsError(t *testing.T) { + cause := errors.New("Octopus API error: Object reference not set to an instance of an object. []") + + t.Run("names the package, step and feed", func(t *testing.T) { + err := packages.NewMissingPackageVersionsError([]releases.ReleaseTemplatePackage{{ + ActionName: "Deploy Website", + FeedID: "feeds-builtin", + FeedName: "Octopus Server (built-in)", + PackageID: "acme-web", + PackageReferenceName: "acme-web", + }}, cause) + + assert.EqualError(t, err, heredoc.Doc(` + cannot create release; no version could be found for the following packages: + - 'acme-web' in step 'Deploy Website' (feed 'Octopus Server (built-in)') + push the package(s) to the feed, or supply a version with --package or --package-version`)) + + assert.Equal(t, cause, errors.Unwrap(err)) + }) + + t.Run("qualifies the package with its reference name where they differ", func(t *testing.T) { + err := packages.NewMissingPackageVersionsError([]releases.ReleaseTemplatePackage{{ + ActionName: "Deploy Website", + FeedID: "Feeds-1001", + PackageID: "acme-web", + PackageReferenceName: "extra-config", + }}, cause) + + // no FeedName in this response, so it falls back to the feed ID + assert.EqualError(t, err, heredoc.Doc(` + cannot create release; no version could be found for the following packages: + - 'acme-web/extra-config' in step 'Deploy Website' (feed 'Feeds-1001') + push the package(s) to the feed, or supply a version with --package or --package-version`)) + }) +} + +func TestReleaseCreate_DiagnoseCreateReleaseFailure(t *testing.T) { + t.Run("passes through errors which aren't server faults", func(t *testing.T) { + cause := errors.New("no such host") + assert.Equal(t, cause, create.DiagnoseCreateReleaseFailure(nil, nil, cause)) + + badRequest := &core.APIError{ErrorMessage: "release version 1.0.0 already exists", StatusCode: http.StatusBadRequest} + assert.Equal(t, error(badRequest), create.DiagnoseCreateReleaseFailure(nil, nil, badRequest)) + }) +} + +// issue #426: the server raises a null reference exception rather than telling us that a package +// referenced by the deployment process has no version available in its feed +func TestReleaseCreate_AutomationMode_MissingPackageDiagnosis(t *testing.T) { + const spaceID = "Spaces-1" + const fireProjectID = "Projects-22" + const builtinFeedID = "feeds-builtin" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + depProcess := fixtures.NewDeploymentProcessForProject(spaceID, fireProjectID) + fireProject := fixtures.NewProject(spaceID, fireProjectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", depProcess.ID) + defaultChannel := fixtures.NewChannel(spaceID, "Channels-1", "Default", fireProjectID) + + nullReferenceError := &core.APIError{ErrorMessage: "Object reference not set to an instance of an object."} + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"reports the package which has no version in its feed", 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{"release", "create", "--project", fireProject.Name, "--version", "1.0.0"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/create/v1"). + RespondWithStatus(http.StatusInternalServerError, "500 Internal Server Error", nullReferenceError) + + // the CLI now goes back to the server to work out what the real problem was + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/deploymentprocesses/"+depProcess.ID).RespondWith(depProcess) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/channels").RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{defaultChannel}, + }) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/deploymentprocesses/template?channel=Channels-1"). + RespondWith(&deployments.DeploymentProcessTemplate{ + Packages: []releases.ReleaseTemplatePackage{{ + ActionName: "Deploy Website", + FeedID: builtinFeedID, + FeedName: "Octopus Server (built-in)", + PackageID: "acme-web", + PackageReferenceName: "acme-web", + IsResolvable: true, + }}, + }) + api.ExpectRequest(t, "GET", "/api/Spaces-1/feeds?ids="+builtinFeedID+"&take=1").RespondWith(&feeds.Feeds{Items: []feeds.IFeed{ + &feeds.FeedResource{Name: "Octopus Server (built-in)", FeedType: feeds.FeedTypeBuiltIn, Resource: resources.Resource{ + ID: builtinFeedID, + Links: map[string]string{ + constants.LinkSearchPackageVersionsTemplate: "/api/Spaces-1/feeds/feeds-builtin/packages/versions{?packageId,take,skip,includePreRelease,versionRange,preReleaseTag,filter,includeReleaseNotes}", + }}}, + }}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/feeds/feeds-builtin/packages/versions?packageId=acme-web&take=1"). + RespondWith(&resources.Resources[*octopusPackages.PackageVersion]{Items: []*octopusPackages.PackageVersion{}}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, heredoc.Doc(` + cannot create release; no version could be found for the following packages: + - 'acme-web' in step 'Deploy Website' (feed 'Octopus Server (built-in)') + push the package(s) to the feed, or supply a version with --package or --package-version`)) + + assert.Equal(t, "", stdOut.String()) + }}, + + {"falls back to a hint when it can't identify a missing package", 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{"release", "create", "--project", fireProject.Name}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/create/v1"). + RespondWithStatus(http.StatusInternalServerError, "500 Internal Server Error", nullReferenceError) + + // the diagnosis is best-effort; this server can't tell us about the deployment process + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/deploymentprocesses/"+depProcess.ID).RespondWithStatus(http.StatusNotFound, "404 Not Found", nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "Octopus API error: Object reference not set to an instance of an object. [] \nthe server failed with an unhandled error; this usually means it could not resolve the packages, channel or git reference for the release") + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api := testutil.NewMockHttpServer() + + rootCmd := cmdRoot.NewCmdRoot(testutil.NewMockFactoryWithSpace(api, space1), nil, nil) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + + test.run(t, api, rootCmd, stdout, stderr) + }) + } +} diff --git a/pkg/packages/packages.go b/pkg/packages/packages.go index 3eff889a..e32d0986 100644 --- a/pkg/packages/packages.go +++ b/pkg/packages/packages.go @@ -180,6 +180,88 @@ func BuildPackageVersionBaseline(octopus *octopusApiClient.Client, packages []re return result, nil } +// FindPackagesWithoutVersions returns the deployment process template packages which the server +// expects to have a version at release creation time, but for which no version could be found in the feed. +// Packages with a fixed version, or which aren't resolvable until deployment time, are excluded because +// they don't need one. +func FindPackagesWithoutVersions(templatePackages []releases.ReleaseTemplatePackage, resolvedVersions []*StepPackageVersion) []releases.ReleaseTemplatePackage { + result := make([]releases.ReleaseTemplatePackage, 0) + for _, templatePackage := range templatePackages { + if templatePackage.FixedVersion != "" || !templatePackage.IsResolvable { + continue + } + for _, resolved := range resolvedVersions { + if resolved.PackageID == templatePackage.PackageID && + resolved.ActionName == templatePackage.ActionName && + resolved.PackageReferenceName == templatePackage.PackageReferenceName { + if strings.TrimSpace(resolved.Version) == "" { + result = append(result, templatePackage) + } + break + } + } + } + return result +} + +// MissingPackageVersionsError is raised when one or more packages referenced by the deployment process +// have no version available in their feed. The server can't assemble a release in this state; rather than +// reporting that, it raises a null reference exception, so the CLI detects the situation itself. +type MissingPackageVersionsError struct { + Packages []releases.ReleaseTemplatePackage + cause error +} + +func NewMissingPackageVersionsError(missingPackages []releases.ReleaseTemplatePackage, cause error) *MissingPackageVersionsError { + return &MissingPackageVersionsError{Packages: missingPackages, cause: cause} +} + +func (e *MissingPackageVersionsError) Unwrap() error { return e.cause } + +func (e *MissingPackageVersionsError) Error() string { + sb := &strings.Builder{} + sb.WriteString("cannot create release; no version could be found for the following packages:") + for _, p := range e.Packages { + packageName := p.PackageID + if p.PackageReferenceName != "" && p.PackageReferenceName != p.PackageID { + packageName = fmt.Sprintf("%s/%s", packageName, p.PackageReferenceName) + } + feedName := p.FeedName + if feedName == "" { + feedName = p.FeedID + } + sb.WriteString(fmt.Sprintf("\n - '%s' in step '%s' (feed '%s')", packageName, p.ActionName, feedName)) + } + sb.WriteString("\npush the package(s) to the feed, or supply a version with --package or --package-version") + return sb.String() +} + +// BuildPackageVersionOverrides converts the --package-version and --package command line flags into +// resolved overrides, using the baseline to work out which step or package each override refers to. +// Anything that can't be parsed or resolved is ignored; the server reports those. +func BuildPackageVersionOverrides(packageVersionBaseline []*StepPackageVersion, defaultPackageVersion string, packageOverrideFlags []string) []*PackageVersionOverride { + packageVersionOverrides := make([]*PackageVersionOverride, 0, len(packageOverrideFlags)+1) + + if defaultPackageVersion != "" { + // blind apply to everything + packageVersionOverrides = append(packageVersionOverrides, &PackageVersionOverride{Version: defaultPackageVersion}) + } + + for _, s := range packageOverrideFlags { + ambOverride, err := ParsePackageOverrideString(s) + if err != nil { + continue // silently ignore anything that wasn't parseable (should we emit a warning?) + } + resolvedOverride, err := ResolvePackageOverride(ambOverride, packageVersionBaseline) + if err != nil { + continue // silently ignore anything that wasn't parseable (should we emit a warning?) + } + packageVersionOverrides = append(packageVersionOverrides, resolvedOverride) + } + + return packageVersionOverrides +} + type PackageVersionOverride struct { ActionName string // optional, but one or both of ActionName or PackageID must be supplied PackageID string // optional, but one or both of ActionName or PackageID must be supplied @@ -539,25 +621,8 @@ func AskPackageOverrideLoop( initialPackageOverrideFlags []string, // the --package command line flag (multiple occurrences) asker question.Asker, stdout io.Writer) ([]*StepPackageVersion, []*PackageVersionOverride, error) { - packageVersionOverrides := make([]*PackageVersionOverride, 0) - // pickup any partial package specifications that may have arrived on the commandline - if defaultPackageVersion != "" { - // blind apply to everything - packageVersionOverrides = append(packageVersionOverrides, &PackageVersionOverride{Version: defaultPackageVersion}) - } - - for _, s := range initialPackageOverrideFlags { - ambOverride, err := ParsePackageOverrideString(s) - if err != nil { - continue // silently ignore anything that wasn't parseable (should we emit a warning?) - } - resolvedOverride, err := ResolvePackageOverride(ambOverride, packageVersionBaseline) - if err != nil { - continue // silently ignore anything that wasn't parseable (should we emit a warning?) - } - packageVersionOverrides = append(packageVersionOverrides, resolvedOverride) - } + packageVersionOverrides := BuildPackageVersionOverrides(packageVersionBaseline, defaultPackageVersion, initialPackageOverrideFlags) overriddenPackageVersions := ApplyPackageOverrides(packageVersionBaseline, packageVersionOverrides) From af509e5b63b4cb4edf1b754af1a67fc45561ed46 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 19 Aug 2026 11:45:14 +1000 Subject: [PATCH 03/18] fix: report unknown release versions instead of a server null reference `release deploy` passed --version straight to the executions API, which answers an unknown version with "Object reference not set to an instance of an object". Resolve the release before deploying so a version that doesn't exist is reported by name, and call out `latest` explicitly since it is not a supported alias. Refs #294 Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/deploy/deploy.go | 12 +++- pkg/cmd/release/deploy/deploy_test.go | 76 +++++++++++++------- pkg/cmd/release/progression/shared/shared.go | 11 +-- pkg/question/selectors/releases.go | 45 ++++++++++++ 4 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 pkg/question/selectors/releases.go diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 0df6d614..cc314649 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -317,6 +317,16 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error return err } options.ProjectName = project.GetName() + + if options.ReleaseVersion != "" { + // resolve the release up front; the executions API reports an unknown version as an + // unhelpful null reference error, and having the ID saves looking it up again later + release, err := selectors.FindRelease(octopus, f.GetCurrentSpace().ID, project, options.ReleaseVersion) + if err != nil { + return err + } + options.ReleaseID = release.ID + } } } @@ -426,7 +436,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques return err } } else { - selectedRelease, err = releases.GetReleaseInProject(octopus, space.ID, selectedProject.ID, options.ReleaseVersion) + selectedRelease, err = selectors.FindRelease(octopus, space.ID, selectedProject, options.ReleaseVersion) if err != nil { return err } diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index fde01017..5baade63 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -1594,6 +1594,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.9").RespondWith(release10) _, err := testutil.ReceivePair(cmdReceiver) assert.EqualError(t, err, "environment(s) must be specified") @@ -1602,6 +1603,45 @@ func TestDeployCreate_AutomationMode(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, + {"release deploy reports a release version that doesn't exist", 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{"release", "deploy", "--project", fireProject.Name, "--version", "9.9", "--environment", "dev"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/9.9"). + RespondWithStatus(404, "404 Not Found", &core.APIError{ErrorMessage: "The resource you requested was not found."}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "cannot find a release with version '9.9' in project 'Fire Project'") + + assert.Equal(t, "", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"release deploy explains that 'latest' is not a supported release version", 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{"release", "deploy", "--project", fireProject.Name, "--version", "latest", "--environment", "dev"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/latest").RespondWithStatus(404, "NotFound", nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "cannot find a release with version 'latest' in project 'Fire Project'; 'latest' is not a supported alias, specify an exact version. Run 'octopus release list --project \"Fire Project\"' to see the available versions") + + assert.Equal(t, "", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + {"release deploy specifying project, version, env only (bare minimum) assuming untenanted", 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() @@ -1612,6 +1652,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") @@ -1634,12 +1675,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { }, }) - // now it's going to try and look up the project/version to generate the web URL - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithStatus(404, "NotFound", nil) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?partialName=Fire+Project").RespondWith(resources.Resources[*projects.Project]{ - Items: []*projects.Project{fireProject}, - }) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + // no lookup to generate the web URL; the release was already resolved before deploying _, err = testutil.ReceivePair(cmdReceiver) assert.Nil(t, err) @@ -1662,6 +1698,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/2.1").RespondWith(release10) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") @@ -1684,12 +1721,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { }, }) - // now it's going to try and look up the project/version to generate the web URL - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithStatus(404, "NotFound", nil) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?partialName=Fire+Project").RespondWith(resources.Resources[*projects.Project]{ - Items: []*projects.Project{fireProject}, - }) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/2.1").RespondWith(release10) + // no lookup to generate the web URL; the release was already resolved before deploying _, err = testutil.ReceivePair(cmdReceiver) assert.Nil(t, err) @@ -1712,6 +1744,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1").RespondWith(&deployments.CreateDeploymentResponseV1{ @@ -1742,6 +1775,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted serverTasks := []*deployments.DeploymentServerTask{ @@ -1773,6 +1807,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) @@ -1794,12 +1829,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { }, }) - // now it's going to try and look up the project/version to generate the web URL - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithStatus(404, "NotFound", nil) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?partialName=Fire+Project").RespondWith(resources.Resources[*projects.Project]{ - Items: []*projects.Project{fireProject}, - }) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + // no lookup to generate the web URL; the release was already resolved before deploying _, err = testutil.ReceivePair(cmdReceiver) assert.Nil(t, err) @@ -1822,6 +1852,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) @@ -1843,12 +1874,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { }, }) - // now it's going to try and look up the project/version to generate the web URL - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithStatus(404, "NotFound", nil) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?partialName=Fire+Project").RespondWith(resources.Resources[*projects.Project]{ - Items: []*projects.Project{fireProject}, - }) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + // no lookup to generate the web URL; the release was already resolved before deploying _, err = testutil.ReceivePair(cmdReceiver) assert.Nil(t, err) @@ -1888,6 +1914,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") @@ -1962,6 +1989,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) diff --git a/pkg/cmd/release/progression/shared/shared.go b/pkg/cmd/release/progression/shared/shared.go index 94f669b3..a181a771 100644 --- a/pkg/cmd/release/progression/shared/shared.go +++ b/pkg/cmd/release/progression/shared/shared.go @@ -40,14 +40,5 @@ func SelectRelease(octopus *client.Client, project *projects.Project, ask questi } func FindRelease(octopus *client.Client, project *projects.Project, version string) (*releases.Release, error) { - existingRelease, err := releases.GetReleaseInProject(octopus, octopus.GetSpaceID(), project.GetID(), version) - if err != nil { - return nil, err - } - - if existingRelease == nil { - return nil, fmt.Errorf("unable to locate a release with version/release number '%s'", version) - } - - return existingRelease, nil + return selectors.FindRelease(octopus, octopus.GetSpaceID(), project, version) } diff --git a/pkg/question/selectors/releases.go b/pkg/question/selectors/releases.go new file mode 100644 index 00000000..04e44cb0 --- /dev/null +++ b/pkg/question/selectors/releases.go @@ -0,0 +1,45 @@ +package selectors + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/OctopusDeploy/cli/pkg/constants" + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" +) + +// latestReleaseAlias is the value the old `octo` CLI accepted to mean "the newest release". +// This CLI has no equivalent, so it is called out explicitly when the lookup fails. +const latestReleaseAlias = "latest" + +// FindRelease looks up a release by version within a project. A version that doesn't exist is +// reported here, because the executions API answers one with a null reference error instead. +func FindRelease(octopus *octopusApiClient.Client, spaceID string, project *projects.Project, releaseVersion string) (*releases.Release, error) { + release, err := releases.GetReleaseInProject(octopus, spaceID, project.GetID(), releaseVersion) + if err != nil { + var apiError *core.APIError + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusNotFound { + return nil, releaseNotFoundError(project, releaseVersion) + } + return nil, err + } + // a 404 with an empty body doesn't reach the error path above; it decodes as an empty release + if release == nil || release.GetID() == "" { + return nil, releaseNotFoundError(project, releaseVersion) + } + + return release, nil +} + +func releaseNotFoundError(project *projects.Project, releaseVersion string) error { + if strings.EqualFold(releaseVersion, latestReleaseAlias) { + return fmt.Errorf("cannot find a release with version '%s' in project '%s'; '%s' is not a supported alias, specify an exact version. Run '%s release list --project \"%s\"' to see the available versions", + releaseVersion, project.GetName(), releaseVersion, constants.ExecutableName, project.GetName()) + } + return fmt.Errorf("cannot find a release with version '%s' in project '%s'", releaseVersion, project.GetName()) +} From e083816260f4429a38444d19d8a86bfb11beb069 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 19 Aug 2026 12:24:27 +1000 Subject: [PATCH 04/18] fix: accept IDs as well as names for --channel, --environment and --tenant The executions API only matches channels, environments and tenants by name, so `release create`, `release deploy` and `runbook run` passed whatever the caller typed straight through and the server rejected IDs. `--project` already worked because the server accepts a project ID or name. Resolve those identifiers client side through the shared selectors package before handing them to the executor, preferring an ID match over a name match so it behaves the same way as `--project`. Fixes #250 Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/channel/delete/delete_test.go | 2 +- pkg/cmd/channel/view/view_test.go | 4 +- pkg/cmd/release/create/create.go | 8 + pkg/cmd/release/create/create_test.go | 64 ++++++++ pkg/cmd/release/deploy/deploy.go | 53 +++++- pkg/cmd/release/deploy/deploy_test.go | 87 +++++++++- pkg/cmd/runbook/run/run.go | 18 +++ pkg/cmd/runbook/run/run_test.go | 78 +++++++++ pkg/executionscommon/executionscommon.go | 39 +---- pkg/question/selectors/channels.go | 13 +- pkg/question/selectors/environments.go | 54 +++++-- pkg/question/selectors/find_test.go | 198 +++++++++++++++++++++++ pkg/question/selectors/tenants.go | 38 +++++ 13 files changed, 590 insertions(+), 66 deletions(-) create mode 100644 pkg/question/selectors/find_test.go create mode 100644 pkg/question/selectors/tenants.go diff --git a/pkg/cmd/channel/delete/delete_test.go b/pkg/cmd/channel/delete/delete_test.go index d4a57197..eab6c2ce 100644 --- a/pkg/cmd/channel/delete/delete_test.go +++ b/pkg/cmd/channel/delete/delete_test.go @@ -167,7 +167,7 @@ func TestChannelDelete(t *testing.T) { // No DELETE request is expected; api.Close() asserts nothing further was requested. _, err := testutil.ReceivePair(cmdReceiver) - assert.EqualError(t, err, "no channel found with name of Channels-99") + assert.EqualError(t, err, "cannot find a channel in project 'Fire Project' with the ID or name of 'Channels-99'") assert.Equal(t, "", stdErr.String()) }}, diff --git a/pkg/cmd/channel/view/view_test.go b/pkg/cmd/channel/view/view_test.go index 556f85f5..96e69fe2 100644 --- a/pkg/cmd/channel/view/view_test.go +++ b/pkg/cmd/channel/view/view_test.go @@ -238,7 +238,7 @@ func TestChannelView(t *testing.T) { }) _, err := testutil.ReceivePair(cmdReceiver) - assert.EqualError(t, err, "no channel found with name of Channels-99") + assert.EqualError(t, err, "cannot find a channel in project 'Fire Project' with the ID or name of 'Channels-99'") assert.Equal(t, "", stdOut.String()) assert.Equal(t, "", stdErr.String()) @@ -262,7 +262,7 @@ func TestChannelView(t *testing.T) { }) _, err := testutil.ReceivePair(cmdReceiver) - assert.EqualError(t, err, "no channel found with name of Nonexistent") + assert.EqualError(t, err, "cannot find a channel in project 'Fire Project' with the ID or name of 'Nonexistent'") assert.Equal(t, "", stdOut.String()) assert.Equal(t, "", stdErr.String()) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index b4b50caa..a9bfd01c 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -310,6 +310,14 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error return err } options.ProjectName = project.GetName() + + if options.ChannelName != "" { // the executions API only matches channels by name, so resolve any ID we were given + channel, err := selectors.FindChannel(octopus, project, options.ChannelName) + if err != nil { + return err + } + options.ChannelName = channel.Name + } } } diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 65ee97c7..872b87b3 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -1209,6 +1209,7 @@ func TestReleaseCreate_AutomationMode(t *testing.T) { protectedBranchNamePatterns := []string{} cacProject := fixtures.NewProject(space1.ID, cacProjectID, "CaC Project", "Lifecycles-1", "ProjectGroups-1", depProcess.ID) + betaChannel := fixtures.NewChannel(space1.ID, "Channels-31", "BetaChannel", cacProjectID) cacProject.PersistenceSettings = projects.NewGitPersistenceSettings( ".octopus", credentials.NewAnonymous(), @@ -1588,6 +1589,53 @@ func TestReleaseCreate_AutomationMode(t *testing.T) { assert.EqualError(t, err, "cannot specify both --release-notes and --release-notes-file at the same time") }}, + {"release creation specifying the project and channel by ID", 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{"release", "create", "--project", cacProjectID, "--channel", betaChannel.ID}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProjectID).RespondWith(cacProject) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProjectID+"/channels").RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{betaChannel}, + }) + + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/create/v1") + + // the executions API only matches channels by name, so the ID must have been resolved before we got here + requestBody, err := testutil.ReadJson[releases.CreateReleaseCommandV1](req.Request.Body) + assert.Nil(t, err) + + assert.Equal(t, releases.CreateReleaseCommandV1{ + SpaceID: "Spaces-1", + ProjectIDOrName: cacProject.Name, + ChannelIDOrName: betaChannel.Name, + }, requestBody) + + req.RespondWith(&releases.CreateReleaseResponseV1{ + ReleaseID: "Releases-999", + ReleaseVersion: "1.2.3", + }) + + releaseInfo := releases.NewRelease(betaChannel.ID, cacProject.ID, "1.2.3") + api.ExpectRequest(t, "GET", "/api/Spaces-1/releases/Releases-999").RespondWith(releaseInfo) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/"+betaChannel.ID).RespondWith(betaChannel) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Doc(` + Successfully created release version 1.2.3 using channel BetaChannel + + View this release on Octopus Deploy: http://server/app#/Spaces-1/releases/Releases-999 + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + {"release creation with all the flags", 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() @@ -1611,6 +1659,10 @@ func TestReleaseCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProject.GetName()).RespondWith(cacProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProjectID+"/channels").RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{betaChannel}, + }) + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/create/v1") // check that it sent the server the right request body @@ -1682,6 +1734,10 @@ func TestReleaseCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProject.GetName()).RespondWith(cacProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProjectID+"/channels").RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{betaChannel}, + }) + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/create/v1") // check that it sent the server the right request body @@ -1748,6 +1804,10 @@ func TestReleaseCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProject.GetName()).RespondWith(cacProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProjectID+"/channels").RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{betaChannel}, + }) + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/create/v1") // check that it sent the server the right request body @@ -1817,6 +1877,10 @@ func TestReleaseCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProject.GetName()).RespondWith(cacProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProjectID+"/channels").RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{betaChannel}, + }) + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/create/v1") // check that it sent the server the right request body diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 0df6d614..9779dd6e 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -36,6 +36,7 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" "github.com/spf13/cobra" ) @@ -237,6 +238,15 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error options.ForcePackageDownloadWasSpecified = true } + // the executions API only matches tenants by name, so resolve any IDs we were given + if len(options.Tenants) > 0 { + selectedTenants, err := selectors.FindTenants(octopus, options.Tenants) + if err != nil { + return err + } + options.Tenants = util.SliceTransform(selectedTenants, func(t *tenants.Tenant) string { return t.Name }) + } + if f.IsPromptEnabled() { now := time.Now if cmd.Context() != nil { // allow context to override the definition of 'now' for testing @@ -319,6 +329,13 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error options.ProjectName = project.GetName() } + // the executions API only matches environments by name, so resolve any IDs we were given + if len(options.Environments) > 0 { + options.Environments, err = resolveEnvironmentNames(octopus, f.GetCurrentSpace(), options.Environments) + if err != nil { + return err + } + } } // the executor will raise errors if any required options are missing @@ -474,18 +491,21 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques if len(deploymentEnvironmentIDs) == 0 { // if the Q&A process earlier hasn't loaded environments already, we need to load them now if selectedChannel.Type == channels.ChannelTypeLifecycle { - selectedEnvironments, err := executionscommon.FindEnvironments(octopus, options.Environments) + selectedEnvironments, err := selectors.FindEnvironments(octopus, options.Environments) if err != nil { return err } deploymentEnvironmentIDs = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.ID }) + options.Environments = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.Name }) } else if selectedChannel.Type == channels.ChannelTypeEphemeral { - deploymentEnvironmentIDs, err = findEphemeralEnvironmentIDs(octopus, space, options.Environments) - + selectedEnvironments, err := findEphemeralEnvironments(octopus, space, options.Environments) if err != nil { return err } + + deploymentEnvironmentIDs = util.SliceTransform(selectedEnvironments, func(env *ephemeralenvironments.EphemeralEnvironment) string { return env.ID }) + options.Environments = util.SliceTransform(selectedEnvironments, func(env *ephemeralenvironments.EphemeralEnvironment) string { return env.Name }) } } @@ -622,7 +642,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques return nil } -func findEphemeralEnvironmentIDs(octopus *octopusApiClient.Client, space *spaces.Space, environments []string) ([]string, error) { +func findEphemeralEnvironments(octopus *octopusApiClient.Client, space *spaces.Space, environmentIdentifiers []string) ([]*ephemeralenvironments.EphemeralEnvironment, error) { allEphemeralEnvironments, err := ephemeralenvironments.GetAll(octopus, space.ID) if err != nil { return nil, err @@ -632,8 +652,8 @@ func findEphemeralEnvironmentIDs(octopus *octopusApiClient.Client, space *spaces return nil, errors.New("no ephemeral environments exist to deploy to") } - var selectedEnvironments []string - if len(environments) == 0 { + var selectedEnvironments []*ephemeralenvironments.EphemeralEnvironment + if len(environmentIdentifiers) == 0 { return nil, nil } @@ -643,17 +663,33 @@ func findEphemeralEnvironmentIDs(octopus *octopusApiClient.Client, space *spaces envMap[strings.ToLower(ephemeralEnv.Name)] = ephemeralEnv } - for _, envIdentifier := range environments { + for _, envIdentifier := range environmentIdentifiers { ephemeralEnv, found := envMap[strings.ToLower(envIdentifier)] if !found { return nil, fmt.Errorf("environment '%s' not found in ephemeral environments", envIdentifier) } - selectedEnvironments = append(selectedEnvironments, ephemeralEnv.ID) + selectedEnvironments = append(selectedEnvironments, ephemeralEnv) } return selectedEnvironments, nil } +// resolveEnvironmentNames maps environment names or IDs onto canonical environment names, because +// the executions API only matches environments by name. Ephemeral environments aren't part of the +// regular environment list, so they're looked up separately when the regular lookup comes up empty. +func resolveEnvironmentNames(octopus *octopusApiClient.Client, space *spaces.Space, environmentIdentifiers []string) ([]string, error) { + selectedEnvironments, err := selectors.FindEnvironments(octopus, environmentIdentifiers) + if err == nil { + return util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.Name }), nil + } + + ephemeralEnvironments, ephemeralErr := findEphemeralEnvironments(octopus, space, environmentIdentifiers) + if ephemeralErr != nil { + return nil, err // ephemeral environments are the rarer case; report why the regular lookup failed + } + return util.SliceTransform(ephemeralEnvironments, func(env *ephemeralenvironments.EphemeralEnvironment) string { return env.Name }), nil +} + func selectDeploymentEnvironmentsForEphemeralChannel(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsDeployRelease, selectedRelease *releases.Release) ([]string, error) { var deploymentEnvironmentIds []string var selectedEnvironments []*ephemeralenvironments.EphemeralEnvironment @@ -721,6 +757,7 @@ func selectDeploymentEnvironmentsForLifecycleChannel(octopus *octopusApiClient.C if err != nil { return nil, err } + options.Environments = []string{selectedEnvironment.Name} _, _ = fmt.Fprintf(stdout, "Environment %s\n", output.Cyan(selectedEnvironment.Name)) } selectedEnvironments = []*environments.Environment{selectedEnvironment} diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index fde01017..c2eab692 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -508,7 +508,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { assert.Equal(t, &executor.TaskOptionsDeployRelease{ ProjectName: "Fire Project", ReleaseVersion: "2.1", - Environments: []string{"ephemeral environment"}, + Environments: []string{"Ephemeral Environment"}, // the identifier from the command line is resolved to the canonical name GuidedFailureMode: "", Variables: make(map[string]string, 0), ReleaseID: release21.ID, @@ -1542,7 +1542,12 @@ func TestDeployCreate_AutomationMode(t *testing.T) { ////release20.ProjectDeploymentProcessSnapshotID = depProcessSnapshot.ID //release20.ProjectVariableSetSnapshotID = variableSnapshotWithPromptedVariables.ID // - //devEnvironment := fixtures.NewEnvironment(spaceID, "Environments-12", "dev") + devEnvironment := fixtures.NewEnvironment(spaceID, "Environments-12", "dev") + testEnvironment := fixtures.NewEnvironment(spaceID, "Environments-13", "test") + ephemeralEnvironment := fixtures.NewEphemeralEnvironment(spaceID, "Environments-123", "Ephemeral Environment", "Environments-12") + + cokeTenant := fixtures.NewTenant(spaceID, "Tenants-29", "Coke", "Regions/us-east", "Importance/High") + pepsiTenant := fixtures.NewTenant(spaceID, "Tenants-37", "Pepsi", "Regions/us-east", "Importance/Low") // TEST STARTS HERE tests := []struct { @@ -1612,6 +1617,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") @@ -1652,6 +1658,60 @@ func TestDeployCreate_AutomationMode(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, + {"release deploy specifying project, environment and tenant by ID", 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{"release", "deploy", "--project", fireProjectID, "--version", "1.0", "--environment", devEnvironment.ID, "--tenant", cokeTenant.ID}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/"+cokeTenant.ID).RespondWith(cokeTenant) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) + + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") + + // the executions API only matches environments and tenants by name, so the IDs must have been resolved before we got here + requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) + assert.Nil(t, err) + + assert.Equal(t, deployments.CreateDeploymentTenantedCommandV1{ + ReleaseVersion: "1.0", + EnvironmentName: devEnvironment.Name, + Tenants: []string{cokeTenant.Name}, + CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + }, + }, requestBody) + + req.RespondWith(&deployments.CreateDeploymentResponseV1{ + DeploymentServerTasks: []*deployments.DeploymentServerTask{ + {DeploymentID: "Deployments-203", ServerTaskID: "ServerTasks-29394"}, + }, + }) + + // now it's going to try and look up the project/version to generate the web URL + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?partialName=Fire+Project").RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{fireProject}, + }) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, heredoc.Docf(` + Successfully started 1 deployment(s) + + View this release on Octopus Deploy: http://server/app#/Spaces-1/releases/%s + `, release10.ID), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + {"release deploy specifying project, version, ephemeral env only (bare minimum)", 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() @@ -1662,6 +1722,13 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/v2?skip=0&take=2147483647&type=Ephemeral").RespondWith(resources.Resources[*ephemeralenvironments.EphemeralEnvironment]{ + Items: []*ephemeralenvironments.EphemeralEnvironment{ephemeralEnvironment}, + PagedResults: resources.PagedResults{ + TotalResults: 1, + }, + }) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") @@ -1712,6 +1779,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1").RespondWith(&deployments.CreateDeploymentResponseV1{ @@ -1742,6 +1810,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted serverTasks := []*deployments.DeploymentServerTask{ @@ -1772,7 +1841,13 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Coke").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Coke").RespondWith(resources.Resources[*tenants.Tenant]{Items: []*tenants.Tenant{cokeTenant}}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Pepsi").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Pepsi").RespondWith(resources.Resources[*tenants.Tenant]{Items: []*tenants.Tenant{pepsiTenant}}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) @@ -1822,6 +1897,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) @@ -1888,6 +1964,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") @@ -1961,7 +2038,13 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Coke").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Coke").RespondWith(resources.Resources[*tenants.Tenant]{Items: []*tenants.Tenant{cokeTenant}}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Pepsi").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Pepsi").RespondWith(resources.Resources[*tenants.Tenant]{Items: []*tenants.Tenant{pepsiTenant}}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) diff --git a/pkg/cmd/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index ad57eb89..000c665e 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -38,6 +38,7 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/runbooks" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" "github.com/spf13/cobra" ) @@ -228,6 +229,23 @@ func runbookRun(cmd *cobra.Command, f factory.Factory, flags *RunFlags) error { flags.Project.Value = project.Name + // the executions API only matches environments and tenants by name, so resolve any IDs we were given + if len(flags.Environments.Value) > 0 { + selectedEnvironments, err := selectors.FindEnvironments(octopus, flags.Environments.Value) + if err != nil { + return err + } + flags.Environments.Value = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.Name }) + } + + if len(flags.Tenants.Value) > 0 { + selectedTenants, err := selectors.FindTenants(octopus, flags.Tenants.Value) + if err != nil { + return err + } + flags.Tenants.Value = util.SliceTransform(selectedTenants, func(t *tenants.Tenant) string { return t.Name }) + } + if f.IsPromptEnabled() && flags.RunbookName.Value == "" && len(flags.RunbookTags.Value) == 0 { var runBySelection string err = f.Ask(&survey.Select{ diff --git a/pkg/cmd/runbook/run/run_test.go b/pkg/cmd/runbook/run/run_test.go index 33c1904d..82173da2 100644 --- a/pkg/cmd/runbook/run/run_test.go +++ b/pkg/cmd/runbook/run/run_test.go @@ -15,8 +15,11 @@ import ( "github.com/OctopusDeploy/cli/test/fixtures" "github.com/OctopusDeploy/cli/test/testutil" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/runbooks" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" ) @@ -39,6 +42,12 @@ func TestRunbookRun_AutomationMode(t *testing.T) { fireProject := fixtures.NewProject(spaceID, fireProjectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "deploymentprocess-"+fireProjectID) _ = fireProject + devEnvironment := fixtures.NewEnvironment(spaceID, "Environments-12", "dev") + testEnvironment := fixtures.NewEnvironment(spaceID, "Environments-13", "test") + + cokeTenant := fixtures.NewTenant(spaceID, "Tenants-29", "Coke", "Regions/us-east", "Importance/High") + pepsiTenant := fixtures.NewTenant(spaceID, "Tenants-37", "Pepsi", "Regions/us-east", "Importance/Low") + // TEST STARTS HERE tests := []struct { name string @@ -107,6 +116,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1") @@ -146,6 +156,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1").RespondWith(&runbooks.RunbookRunResponseV1{ @@ -175,6 +186,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) serverTasks := []*runbooks.RunbookRunServerTask{ {RunbookRunID: "RunbookRun-203", ServerTaskID: "ServerTasks-29394"}, @@ -196,6 +208,48 @@ func TestRunbookRun_AutomationMode(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, + {"runbook run specifying project, environment and tenant by ID", 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{"runbook", "run", "--project", fireProjectID, "--runbook", "Provision Database", "--environment", devEnvironment.ID, "--tenant", cokeTenant.ID}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID).RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/"+cokeTenant.ID).RespondWith(cokeTenant) + + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1") + + // the executions API only matches environments and tenants by name, so the IDs must have been resolved before we got here + requestBody, err := testutil.ReadJson[runbooks.RunbookRunCommandV1](req.Request.Body) + assert.Nil(t, err) + + assert.Equal(t, runbooks.RunbookRunCommandV1{ + RunbookName: "Provision Database", + EnvironmentNames: []string{devEnvironment.Name}, + Tenants: []string{cokeTenant.Name}, + CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + }, + }, requestBody) + + req.RespondWith(&runbooks.RunbookRunResponseV1{ + RunbookRunServerTasks: []*runbooks.RunbookRunServerTask{ + {RunbookRunID: "RunbookRun-203", ServerTaskID: "ServerTasks-29394"}, + }, + }) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, "Successfully started 1 runbook run(s)\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + {"runbook run specifying project, runbook, env only (bare minimum) assuming tenanted", 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() @@ -206,6 +260,11 @@ func TestRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Coke").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Coke").RespondWith(resources.Resources[*tenants.Tenant]{Items: []*tenants.Tenant{cokeTenant}}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Pepsi").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Pepsi").RespondWith(resources.Resources[*tenants.Tenant]{Items: []*tenants.Tenant{pepsiTenant}}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1") requestBody, err := testutil.ReadJson[runbooks.RunbookRunCommandV1](req.Request.Body) @@ -245,6 +304,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1") requestBody, err := testutil.ReadJson[runbooks.RunbookRunCommandV1](req.Request.Body) @@ -299,6 +359,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1") requestBody, err := testutil.ReadJson[runbooks.RunbookRunCommandV1](req.Request.Body) @@ -367,6 +428,12 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { fireProject.PersistenceSettings.(projects.GitPersistenceSettings).SetRunbooksAreInGit() _ = fireProject + devEnvironment := fixtures.NewEnvironment(spaceID, "Environments-12", "dev") + testEnvironment := fixtures.NewEnvironment(spaceID, "Environments-13", "test") + + cokeTenant := fixtures.NewTenant(spaceID, "Tenants-29", "Coke", "Regions/us-east", "Importance/High") + pepsiTenant := fixtures.NewTenant(spaceID, "Tenants-37", "Pepsi", "Regions/us-east", "Importance/Low") + // TEST STARTS HERE tests := []struct { name string @@ -435,6 +502,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) _, err := testutil.ReceivePair(cmdReceiver) assert.EqualError(t, err, "git reference must be specified") @@ -453,6 +521,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/git/create/v1") @@ -493,6 +562,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/git/create/v1").RespondWith(&runbooks.GitRunbookRunResponseV1{ @@ -522,6 +592,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) serverTasks := []*runbooks.RunbookRunServerTask{ {RunbookRunID: "RunbookRun-203", ServerTaskID: "ServerTasks-29394"}, @@ -553,6 +624,11 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Coke").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Coke").RespondWith(resources.Resources[*tenants.Tenant]{Items: []*tenants.Tenant{cokeTenant}}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Pepsi").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Pepsi").RespondWith(resources.Resources[*tenants.Tenant]{Items: []*tenants.Tenant{pepsiTenant}}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/git/create/v1") requestBody, err := testutil.ReadJson[runbooks.GitRunbookRunCommandV1](req.Request.Body) @@ -593,6 +669,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/git/create/v1") requestBody, err := testutil.ReadJson[runbooks.GitRunbookRunCommandV1](req.Request.Body) @@ -651,6 +728,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/git/create/v1") requestBody, err := testutil.ReadJson[runbooks.GitRunbookRunCommandV1](req.Request.Body) diff --git a/pkg/executionscommon/executionscommon.go b/pkg/executionscommon/executionscommon.go index 4348d7bd..8e3d9827 100644 --- a/pkg/executionscommon/executionscommon.go +++ b/pkg/executionscommon/executionscommon.go @@ -8,6 +8,7 @@ import ( "github.com/AlecAivazis/survey/v2" cliErrors "github.com/OctopusDeploy/cli/pkg/errors" "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/pkg/question/selectors" "github.com/OctopusDeploy/cli/pkg/surveyext" "github.com/OctopusDeploy/cli/pkg/util" octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" @@ -431,40 +432,8 @@ func ScheduledStartTimeAnswerFormatter(datePicker *surveyext.DatePicker, t time. } } -// given an array of environment names, maps these all to actual objects by querying the server +// FindEnvironments maps an array of environment names or IDs onto the matching objects. +// Kept as an alias so existing callers don't have to change; selectors owns the lookup. func FindEnvironments(client *octopusApiClient.Client, environmentNamesOrIds []string) ([]*environments.Environment, error) { - if len(environmentNamesOrIds) == 0 { - return nil, nil - } - // there's no "bulk lookup" API, so we either need to do a foreach loop to find each environment individually, or load the entire server's worth of environments - // it's probably going to be cheaper to just list out all the environments and match them client side, so we'll do that for simplicity's sake - allEnvs, err := client.Environments.GetAll() - if err != nil { - return nil, err - } - - nameLookup := make(map[string]*environments.Environment, len(allEnvs)) - idLookup := make(map[string]*environments.Environment, len(allEnvs)) - - for _, env := range allEnvs { - nameLookup[strings.ToLower(env.GetName())] = env - idLookup[strings.ToLower(env.GetID())] = env - } - - var result []*environments.Environment - for _, n := range environmentNamesOrIds { - nameOrId := strings.ToLower(n) - env := nameLookup[nameOrId] - if env != nil { - result = append(result, env) - } else { - env = idLookup[nameOrId] - if env != nil { - result = append(result, env) - } else { - return nil, fmt.Errorf("cannot find environment %s", nameOrId) - } - } - } - return result, nil + return selectors.FindEnvironments(client, environmentNamesOrIds) } diff --git a/pkg/question/selectors/channels.go b/pkg/question/selectors/channels.go index 7a5452b6..59f330e9 100644 --- a/pkg/question/selectors/channels.go +++ b/pkg/question/selectors/channels.go @@ -26,15 +26,22 @@ func Channel(octopus *octopusApiClient.Client, ask question.Asker, io io.Writer, }) } -func FindChannel(octopus *octopusApiClient.Client, project *projects.Project, channelName string) (*channels.Channel, error) { +// FindChannel looks a channel up within a project by either its ID or its name. An ID match +// wins over a name match, so it stays consistent with how projects and tenants resolve. +func FindChannel(octopus *octopusApiClient.Client, project *projects.Project, channelIdentifier string) (*channels.Channel, error) { foundChannels, err := octopus.Projects.GetChannels(project) // TODO change this to channel partial name search on server; will require go client update if err != nil { return nil, err } + for _, c := range foundChannels { + if strings.EqualFold(c.ID, channelIdentifier) { + return c, nil + } + } for _, c := range foundChannels { // server doesn't support channel search by exact name so we must emulate it - if strings.EqualFold(c.Name, channelName) { + if strings.EqualFold(c.Name, channelIdentifier) { return c, nil } } - return nil, fmt.Errorf("no channel found with name of %s", channelName) + return nil, fmt.Errorf("cannot find a channel in project '%s' with the ID or name of '%s'", project.GetName(), channelIdentifier) } diff --git a/pkg/question/selectors/environments.go b/pkg/question/selectors/environments.go index 0570782f..2176b400 100644 --- a/pkg/question/selectors/environments.go +++ b/pkg/question/selectors/environments.go @@ -2,10 +2,11 @@ package selectors import ( "fmt" + "strings" + "github.com/OctopusDeploy/cli/pkg/question" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" - "strings" ) type GetAllEnvironmentsCallback func() ([]*environments.Environment, error) @@ -34,25 +35,48 @@ func EnvironmentSelect(ask question.Asker, getAllEnvironmentsCallback GetAllEnvi }) } -func FindEnvironment(octopus *client.Client, environmentName string) (*environments.Environment, error) { - resultPage, err := octopus.Environments.Get(environments.EnvironmentsQuery{PartialName: environmentName}) +// FindEnvironment looks an environment up by either its ID or its name. +func FindEnvironment(octopus *client.Client, environmentIdentifier string) (*environments.Environment, error) { + found, err := FindEnvironments(octopus, []string{environmentIdentifier}) if err != nil { return nil, err } - // environmentsQuery has "Name" but it's just an alias in the server for PartialName; we need to filter client side - for resultPage != nil && len(resultPage.Items) > 0 { - for _, c := range resultPage.Items { // server doesn't support search by exact name so we must emulate it - if strings.EqualFold(c.Name, environmentName) { - return c, nil - } - } - resultPage, err = resultPage.GetNextPage(octopus.Environments.GetClient()) - if err != nil { - return nil, err - } // if there are no more pages, then GetNextPage will return nil, which breaks us out of the loop + return found[0], nil +} + +// FindEnvironments looks environments up by either their IDs or their names. An ID match +// wins over a name match, so it stays consistent with how projects and tenants resolve. +func FindEnvironments(octopus *client.Client, environmentIdentifiers []string) ([]*environments.Environment, error) { + if len(environmentIdentifiers) == 0 { + return nil, nil + } + // there's no "bulk lookup" API, so we either need to do a foreach loop to find each environment individually, or load the entire server's worth of environments + // it's probably going to be cheaper to just list out all the environments and match them client side, so we'll do that for simplicity's sake + allEnvs, err := octopus.Environments.GetAll() + if err != nil { + return nil, err + } + + idLookup := make(map[string]*environments.Environment, len(allEnvs)) + nameLookup := make(map[string]*environments.Environment, len(allEnvs)) + for _, env := range allEnvs { + idLookup[strings.ToLower(env.GetID())] = env + nameLookup[strings.ToLower(env.GetName())] = env } - return nil, fmt.Errorf("no environment found with name of %s", environmentName) + result := make([]*environments.Environment, 0, len(environmentIdentifiers)) + for _, identifier := range environmentIdentifiers { + key := strings.ToLower(identifier) + env, found := idLookup[key] + if !found { + env, found = nameLookup[key] + } + if !found { + return nil, fmt.Errorf("cannot find an environment with the ID or name of '%s'", identifier) + } + result = append(result, env) + } + return result, nil } func EnvironmentsMultiSelect(ask question.Asker, getAllEnvironmentsCallback GetAllEnvironmentsCallback, message string, required bool) ([]*environments.Environment, error) { diff --git a/pkg/question/selectors/find_test.go b/pkg/question/selectors/find_test.go new file mode 100644 index 00000000..0ff5612f --- /dev/null +++ b/pkg/question/selectors/find_test.go @@ -0,0 +1,198 @@ +package selectors_test + +import ( + "net/url" + "testing" + + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels" + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" + "github.com/stretchr/testify/assert" +) + +var serverUrl, _ = url.Parse("http://server") + +const placeholderApiKey = "API-XXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + +var findRootResource = testutil.NewRootResource() + +const findSpaceID = "Spaces-1" +const findProjectID = "Projects-22" + +// beginRequest spins up a mock server and hands back the client to run `action` against; +// the octopus client makes network calls on construction so it has to live in the goroutine +func beginRequest[T any](api *testutil.MockHttpServer, action func(octopus *octopusApiClient.Client) (T, error)) chan testutil.Pair[T, error] { + return testutil.GoBegin2(func() (T, error) { + defer api.Close() + octopus, _ := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, "") + return action(octopus) + }) +} + +func TestFindEnvironments(t *testing.T) { + devEnvironment := fixtures.NewEnvironment(findSpaceID, "Environments-12", "dev") + prodEnvironment := fixtures.NewEnvironment(findSpaceID, "Environments-13", "production") + + // an environment which is *named* like an ID, to prove the precedence rule + decoyEnvironment := fixtures.NewEnvironment(findSpaceID, "Environments-99", "Environments-13") + + allEnvironments := []*environments.Environment{devEnvironment, prodEnvironment, decoyEnvironment} + + tests := []struct { + name string + identifiers []string + expectedIDs []string + expectedErr string + }{ + {"finds an environment by name", []string{"dev"}, []string{devEnvironment.ID}, ""}, + {"finds an environment by name, ignoring case", []string{"DEV"}, []string{devEnvironment.ID}, ""}, + {"finds an environment by ID", []string{"Environments-12"}, []string{devEnvironment.ID}, ""}, + {"finds several environments at once", []string{"Environments-12", "production"}, []string{devEnvironment.ID, prodEnvironment.ID}, ""}, + {"prefers an ID match over a name match", []string{"Environments-13"}, []string{prodEnvironment.ID}, ""}, + {"errors when nothing matches", []string{"Environments-404"}, nil, "cannot find an environment with the ID or name of 'Environments-404'"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + api := testutil.NewMockHttpServer() + receiver := beginRequest(api, func(octopus *octopusApiClient.Client) ([]*environments.Environment, error) { + return selectors.FindEnvironments(octopus, test.identifiers) + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith(allEnvironments) + + result, err := testutil.ReceivePair(receiver) + if test.expectedErr == "" { + assert.Nil(t, err) + assert.Equal(t, test.expectedIDs, util.SliceTransform(result, func(env *environments.Environment) string { return env.ID })) + } else { + assert.EqualError(t, err, test.expectedErr) + } + }) + } +} + +func TestFindEnvironment(t *testing.T) { + devEnvironment := fixtures.NewEnvironment(findSpaceID, "Environments-12", "dev") + + api := testutil.NewMockHttpServer() + receiver := beginRequest(api, func(octopus *octopusApiClient.Client) (*environments.Environment, error) { + return selectors.FindEnvironment(octopus, "Environments-12") + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment}) + + result, err := testutil.ReceivePair(receiver) + assert.Nil(t, err) + assert.Equal(t, devEnvironment.ID, result.ID) +} + +func TestFindChannel(t *testing.T) { + project := fixtures.NewProject(findSpaceID, findProjectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "deploymentprocess-"+findProjectID) + + defaultChannel := fixtures.NewChannel(findSpaceID, "Channels-1", "Default", findProjectID) + betaChannel := fixtures.NewChannel(findSpaceID, "Channels-2", "Beta", findProjectID) + + // a channel which is *named* like an ID, to prove the precedence rule + decoyChannel := fixtures.NewChannel(findSpaceID, "Channels-3", "Channels-2", findProjectID) + + allChannels := []*channels.Channel{defaultChannel, betaChannel, decoyChannel} + + tests := []struct { + name string + identifier string + expectedID string + expectedErr string + }{ + {"finds a channel by name", "Beta", betaChannel.ID, ""}, + {"finds a channel by name, ignoring case", "beta", betaChannel.ID, ""}, + {"finds a channel by ID", "Channels-1", defaultChannel.ID, ""}, + {"prefers an ID match over a name match", "Channels-2", betaChannel.ID, ""}, + {"errors when nothing matches", "Channels-404", "", "cannot find a channel in project 'Fire Project' with the ID or name of 'Channels-404'"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + api := testutil.NewMockHttpServer() + receiver := beginRequest(api, func(octopus *octopusApiClient.Client) (*channels.Channel, error) { + return selectors.FindChannel(octopus, project, test.identifier) + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+findProjectID+"/channels").RespondWith(resources.Resources[*channels.Channel]{ + Items: allChannels, + }) + + result, err := testutil.ReceivePair(receiver) + if test.expectedErr == "" { + assert.Nil(t, err) + assert.Equal(t, test.expectedID, result.ID) + } else { + assert.EqualError(t, err, test.expectedErr) + } + }) + } +} + +func TestFindTenants(t *testing.T) { + cokeTenant := fixtures.NewTenant(findSpaceID, "Tenants-29", "Coke", "Regions/us-east") + + t.Run("finds a tenant by ID", func(t *testing.T) { + api := testutil.NewMockHttpServer() + receiver := beginRequest(api, func(octopus *octopusApiClient.Client) ([]*tenants.Tenant, error) { + return selectors.FindTenants(octopus, []string{"Tenants-29"}) + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Tenants-29").RespondWith(cokeTenant) + + result, err := testutil.ReceivePair(receiver) + assert.Nil(t, err) + assert.Equal(t, []string{cokeTenant.ID}, util.SliceTransform(result, func(tenant *tenants.Tenant) string { return tenant.ID })) + }) + + t.Run("falls back to a name lookup when the ID doesn't exist", func(t *testing.T) { + api := testutil.NewMockHttpServer() + receiver := beginRequest(api, func(octopus *octopusApiClient.Client) ([]*tenants.Tenant, error) { + return selectors.FindTenants(octopus, []string{"Coke"}) + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Coke").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Coke").RespondWith(resources.Resources[*tenants.Tenant]{ + Items: []*tenants.Tenant{cokeTenant}, + }) + + result, err := testutil.ReceivePair(receiver) + assert.Nil(t, err) + assert.Equal(t, []string{cokeTenant.ID}, util.SliceTransform(result, func(tenant *tenants.Tenant) string { return tenant.ID })) + }) + + t.Run("errors when nothing matches", func(t *testing.T) { + api := testutil.NewMockHttpServer() + receiver := beginRequest(api, func(octopus *octopusApiClient.Client) ([]*tenants.Tenant, error) { + return selectors.FindTenants(octopus, []string{"Tenants-404"}) + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Tenants-404").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Tenants-404").RespondWith(resources.Resources[*tenants.Tenant]{}) + + _, err := testutil.ReceivePair(receiver) + assert.EqualError(t, err, "cannot find a tenant with the ID or name of 'Tenants-404'") + }) +} diff --git a/pkg/question/selectors/tenants.go b/pkg/question/selectors/tenants.go new file mode 100644 index 00000000..6b51d26d --- /dev/null +++ b/pkg/question/selectors/tenants.go @@ -0,0 +1,38 @@ +package selectors + +import ( + "errors" + "fmt" + + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/services" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" +) + +// FindTenant looks a tenant up by either its ID or its name. +func FindTenant(octopus *octopusApiClient.Client, tenantIdentifier string) (*tenants.Tenant, error) { + tenant, err := octopus.Tenants.GetByIdentifier(tenantIdentifier) + if err != nil { + if errors.Is(err, services.ErrItemNotFound) { + return nil, fmt.Errorf("cannot find a tenant with the ID or name of '%s'", tenantIdentifier) + } + return nil, err + } + return tenant, nil +} + +// FindTenants looks tenants up by either their IDs or their names. +func FindTenants(octopus *octopusApiClient.Client, tenantIdentifiers []string) ([]*tenants.Tenant, error) { + if len(tenantIdentifiers) == 0 { + return nil, nil + } + result := make([]*tenants.Tenant, 0, len(tenantIdentifiers)) + for _, identifier := range tenantIdentifiers { + tenant, err := FindTenant(octopus, identifier) + if err != nil { + return nil, err + } + result = append(result, tenant) + } + return result, nil +} From ea972e2c9c8e3b5262e3cd1efb38c08539b8ca30 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:03:53 +1000 Subject: [PATCH 05/18] fix: only diagnose the null reference failure, not every 5xx The package diagnosis ran for any APIError with a 5xx status. On an unrelated server error that had the side effect of (a) replacing a real server message with MissingPackageVersionsError, whose Error() doesn't include the cause, and (b) firing ~6 extra requests at a server that is already failing. Require the null reference message before diagnosing, which is the only failure this code knows how to explain. The fallback hint no longer needs its own check, since reaching it now implies the message matched. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create.go | 10 +++++----- pkg/cmd/release/create/create_test.go | 10 ++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 2f1d835b..90ae84cb 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -429,8 +429,11 @@ const serverNullReferenceMessage = "Object reference not set to an instance of a // it can. The server raises a null reference exception, surfaced as a bare 500, when it can't select a // version for a package; see https://github.com/OctopusDeploy/cli/issues/426 func DiagnoseCreateReleaseFailure(octopus *octopusApiClient.Client, options *executor.TaskOptionsCreateRelease, cause error) error { + // only the specific null reference failure is worth diagnosing. Any other 5xx is a real server error + // that we must report as-is; replacing it would hide the cause, and re-querying the server would pile + // more requests onto something that is already failing. var apiError *core.APIError - if !errors.As(cause, &apiError) || apiError.StatusCode < 500 { + if !errors.As(cause, &apiError) || apiError.StatusCode < 500 || !strings.Contains(apiError.ErrorMessage, serverNullReferenceMessage) { return cause } @@ -441,10 +444,7 @@ func DiagnoseCreateReleaseFailure(octopus *octopusApiClient.Client, options *exe } } - if strings.Contains(apiError.ErrorMessage, serverNullReferenceMessage) { - return fmt.Errorf("%w\nthe server failed with an unhandled error; this usually means it could not resolve the packages, channel or git reference for the release", cause) - } - return cause + return fmt.Errorf("%w\nthe server failed with an unhandled error; this usually means it could not resolve the packages, channel or git reference for the release", cause) } // findPackagesWithoutVersions repeats the package version resolution the server does when it assembles a diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 87078d8e..ce04cbd9 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -2930,6 +2930,16 @@ func TestReleaseCreate_DiagnoseCreateReleaseFailure(t *testing.T) { badRequest := &core.APIError{ErrorMessage: "release version 1.0.0 already exists", StatusCode: http.StatusBadRequest} assert.Equal(t, error(badRequest), create.DiagnoseCreateReleaseFailure(nil, nil, badRequest)) }) + + t.Run("passes through server faults which aren't the null reference we know how to diagnose", func(t *testing.T) { + // an unrelated 5xx must be reported as-is; we mustn't replace it with a package diagnosis + // (nor go back to an already-failing server to run one) + serverError := &core.APIError{ErrorMessage: "The database is unavailable", StatusCode: http.StatusInternalServerError} + assert.Equal(t, error(serverError), create.DiagnoseCreateReleaseFailure(nil, nil, serverError)) + + badGateway := &core.APIError{ErrorMessage: "Bad Gateway", StatusCode: http.StatusBadGateway} + assert.Equal(t, error(badGateway), create.DiagnoseCreateReleaseFailure(nil, nil, badGateway)) + }) } // issue #426: the server raises a null reference exception rather than telling us that a package From 66ee41168b915eddd28ea72389a7bd37f4e9a193 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:05:36 +1000 Subject: [PATCH 06/18] fix: honour --ignore-channel-rules and channel IDs in the diagnosis Two ways the replay could diverge from what the server actually did: - With --ignore-channel-rules the server resolves package versions without applying the channel's version rules, but the replay always applied them. A package with versions in its feed, none satisfying the rules, would be reported as "no version could be found", misdiagnosing the real failure. Build the baseline without the rule filter in that case. - --channel reaches the server as ChannelIDOrName, but the lookup matched on name only, so passing a channel ID silently dropped the diagnosis to the generic hint. Match on either. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create.go | 31 ++++++++++---- pkg/cmd/release/create/create_test.go | 59 +++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 90ae84cb..5c7786de 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -478,7 +478,15 @@ func findPackagesWithoutVersions(octopus *octopusApiClient.Client, options *exec return nil, err } - packageVersionBaseline, err := BuildPackageVersionBaselineForChannel(octopus, deploymentProcessTemplate, channel) + // mirror what the server did: with --ignore-channel-rules it selects versions without applying the + // channel's version rules, so applying them here would report packages as missing when they only + // failed the rules. + var packageVersionBaseline []*packages.StepPackageVersion + if options.IgnoreChannelRules { + packageVersionBaseline, err = packages.BuildPackageVersionBaseline(octopus, deploymentProcessTemplate.Packages, nil) + } else { + packageVersionBaseline, err = BuildPackageVersionBaselineForChannel(octopus, deploymentProcessTemplate, channel) + } if err != nil { return nil, err } @@ -489,17 +497,24 @@ func findPackagesWithoutVersions(octopus *octopusApiClient.Client, options *exec return packages.FindPackagesWithoutVersions(deploymentProcessTemplate.Packages, resolvedVersions), nil } -// findChannelForDiagnosis locates the channel the server would have used. When no channel was specified we -// can only guess; the default channel is the best approximation available to us. -func findChannelForDiagnosis(octopus *octopusApiClient.Client, project *projects.Project, channelName string) (*channels.Channel, error) { - if channelName != "" { - return selectors.FindChannel(octopus, project, channelName) - } - +// findChannelForDiagnosis locates the channel the server would have used. --channel reaches the server as +// ChannelIDOrName, so we match on either. When no channel was specified we can only guess; the default +// channel is the best approximation available to us. +func findChannelForDiagnosis(octopus *octopusApiClient.Client, project *projects.Project, channelIDOrName string) (*channels.Channel, error) { existingChannels, err := octopus.Projects.GetChannels(project) if err != nil { return nil, err } + + if channelIDOrName != "" { + for _, c := range existingChannels { + if strings.EqualFold(c.Name, channelIDOrName) || c.ID == channelIDOrName { + return c, nil + } + } + return nil, fmt.Errorf("no channel found with name or ID of %s", channelIDOrName) + } + if len(existingChannels) == 1 { return existingChannels[0], nil } diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index ce04cbd9..e7fee464 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -3010,6 +3010,65 @@ func TestReleaseCreate_AutomationMode_MissingPackageDiagnosis(t *testing.T) { assert.Equal(t, "", stdOut.String()) }}, + {"doesn't apply channel version rules when --ignore-channel-rules was specified", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + // the server resolved versions without the channel rules, so the diagnosis must too; + // otherwise a package which only fails the rules gets reported as having no version at all + ruledChannel := fixtures.NewChannel(spaceID, "Channels-1", "Default", fireProjectID) + ruledChannel.Rules = []channels.ChannelRule{{ + Tag: "^pre$", + VersionRange: "[5.0,6.0)", + ActionPackages: []octopusPackages.DeploymentActionPackage{ + {DeploymentAction: "Deploy Website", PackageReference: "acme-web"}, + }, + }} + + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "create", "--project", fireProject.Name, "--ignore-channel-rules"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/create/v1"). + RespondWithStatus(http.StatusInternalServerError, "500 Internal Server Error", nullReferenceError) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/deploymentprocesses/"+depProcess.ID).RespondWith(depProcess) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/channels").RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{ruledChannel}, + }) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/deploymentprocesses/template?channel=Channels-1"). + RespondWith(&deployments.DeploymentProcessTemplate{ + Packages: []releases.ReleaseTemplatePackage{{ + ActionName: "Deploy Website", + FeedID: builtinFeedID, + FeedName: "Octopus Server (built-in)", + PackageID: "acme-web", + PackageReferenceName: "acme-web", + IsResolvable: true, + }}, + }) + api.ExpectRequest(t, "GET", "/api/Spaces-1/feeds?ids="+builtinFeedID+"&take=1").RespondWith(&feeds.Feeds{Items: []feeds.IFeed{ + &feeds.FeedResource{Name: "Octopus Server (built-in)", FeedType: feeds.FeedTypeBuiltIn, Resource: resources.Resource{ + ID: builtinFeedID, + Links: map[string]string{ + constants.LinkSearchPackageVersionsTemplate: "/api/Spaces-1/feeds/feeds-builtin/packages/versions{?packageId,take,skip,includePreRelease,versionRange,preReleaseTag,filter,includeReleaseNotes}", + }}}, + }}) + // no versionRange or preReleaseTag in the query, despite the channel carrying a rule for this package + api.ExpectRequest(t, "GET", "/api/Spaces-1/feeds/feeds-builtin/packages/versions?packageId=acme-web&take=1"). + RespondWith(&resources.Resources[*octopusPackages.PackageVersion]{Items: []*octopusPackages.PackageVersion{}}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, heredoc.Doc(` + cannot create release; no version could be found for the following packages: + - 'acme-web' in step 'Deploy Website' (feed 'Octopus Server (built-in)') + push the package(s) to the feed, or supply a version with --package or --package-version`)) + }}, + {"falls back to a hint when it can't identify a missing package", 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() From 25411369b616d0a99e0267657e73d545478faded Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:08:39 +1000 Subject: [PATCH 07/18] fix: don't assert "not found" when the release lookup is ambiguous The SDK's DoRawJsonRequest short-circuits on `resp.ContentLength == 0` and returns `(resp, nil)` for any status code, so DoRequest hands back a zero-valued Release with a nil error. A 404 with no body lands there, but so does a 403 with an empty body or a 502 from a proxy, and the status code is not recoverable at this layer. Reporting all of those as "cannot find a release with version X" is misleading during an outage or a permissions failure. Introduce selectors.ReleaseNotFoundError, which records whether the server confirmed the answer with a 404 carrying an APIError body, and hedge the wording when it did not. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/deploy/deploy_test.go | 2 +- pkg/question/selectors/releases.go | 47 ++++++++++++++++++++------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index 5baade63..53500ea2 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -1636,7 +1636,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/latest").RespondWithStatus(404, "NotFound", nil) _, err := testutil.ReceivePair(cmdReceiver) - assert.EqualError(t, err, "cannot find a release with version 'latest' in project 'Fire Project'; 'latest' is not a supported alias, specify an exact version. Run 'octopus release list --project \"Fire Project\"' to see the available versions") + assert.EqualError(t, err, "could not resolve a release with version 'latest' in project 'Fire Project'; the server returned an empty response, which usually means there is no such release, but can also mean the lookup itself failed. 'latest' is not a supported alias, specify an exact version. Run 'octopus release list --project \"Fire Project\"' to see the available versions") assert.Equal(t, "", stdOut.String()) assert.Equal(t, "", stdErr.String()) diff --git a/pkg/question/selectors/releases.go b/pkg/question/selectors/releases.go index 04e44cb0..56e8026e 100644 --- a/pkg/question/selectors/releases.go +++ b/pkg/question/selectors/releases.go @@ -17,29 +17,54 @@ import ( // This CLI has no equivalent, so it is called out explicitly when the lookup fails. const latestReleaseAlias = "latest" +// ReleaseNotFoundError reports a release version that the server didn't return a release for. +// +// Confirmed distinguishes the two ways that answer arrives. A 404 carrying an APIError body is a +// definite "no such release". An empty response body is not: the SDK's DoRawJsonRequest short-circuits +// on `resp.ContentLength == 0` and returns (resp, nil) for *any* status code, so a 403 with no body, or +// a 502 from a proxy, decodes into a zero-valued Release with a nil error and is indistinguishable from +// a 404 by the time it reaches us. The status code isn't recoverable at this layer, so the message +// hedges rather than asserting the release is missing. +type ReleaseNotFoundError struct { + ProjectName string + ReleaseVersion string + Confirmed bool +} + +func (e *ReleaseNotFoundError) Error() string { + var message string + if e.Confirmed { + message = fmt.Sprintf("cannot find a release with version '%s' in project '%s'", e.ReleaseVersion, e.ProjectName) + } else { + message = fmt.Sprintf("could not resolve a release with version '%s' in project '%s'; the server returned an empty response, which usually means there is no such release, but can also mean the lookup itself failed", e.ReleaseVersion, e.ProjectName) + } + + if strings.EqualFold(e.ReleaseVersion, latestReleaseAlias) { + message += fmt.Sprintf(". '%s' is not a supported alias, specify an exact version. Run '%s release list --project \"%s\"' to see the available versions", + e.ReleaseVersion, constants.ExecutableName, e.ProjectName) + } + + return message +} + // FindRelease looks up a release by version within a project. A version that doesn't exist is // reported here, because the executions API answers one with a null reference error instead. +// Anything else (a permissions failure, a transport error) is returned untouched, so callers that +// would rather let the server be the authority can tell the two apart with errors.As. func FindRelease(octopus *octopusApiClient.Client, spaceID string, project *projects.Project, releaseVersion string) (*releases.Release, error) { release, err := releases.GetReleaseInProject(octopus, spaceID, project.GetID(), releaseVersion) if err != nil { var apiError *core.APIError if errors.As(err, &apiError) && apiError.StatusCode == http.StatusNotFound { - return nil, releaseNotFoundError(project, releaseVersion) + return nil, &ReleaseNotFoundError{ProjectName: project.GetName(), ReleaseVersion: releaseVersion, Confirmed: true} } return nil, err } - // a 404 with an empty body doesn't reach the error path above; it decodes as an empty release + // an empty response body doesn't reach the error path above; it decodes as an empty release. + // See ReleaseNotFoundError for why this can't be reported as a definite "not found". if release == nil || release.GetID() == "" { - return nil, releaseNotFoundError(project, releaseVersion) + return nil, &ReleaseNotFoundError{ProjectName: project.GetName(), ReleaseVersion: releaseVersion} } return release, nil } - -func releaseNotFoundError(project *projects.Project, releaseVersion string) error { - if strings.EqualFold(releaseVersion, latestReleaseAlias) { - return fmt.Errorf("cannot find a release with version '%s' in project '%s'; '%s' is not a supported alias, specify an exact version. Run '%s release list --project \"%s\"' to see the available versions", - releaseVersion, project.GetName(), releaseVersion, constants.ExecutableName, project.GetName()) - } - return fmt.Errorf("cannot find a release with version '%s' in project '%s'", releaseVersion, project.GetName()) -} From 93ab1c651e8f3170f6df623393f95f73c5101b2c Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:09:02 +1000 Subject: [PATCH 08/18] fix: resolve tenant names with a paginated exact-match lookup `Tenants.GetByIdentifier`'s name fallback (`GetByName`) issues a single `tenants?partialName=` query and scans only the first page of the result. `partialName` is a contains filter, so an exact name that sorts past a page's worth of other tenants containing the same substring - e.g. `--tenant Smith` in a space full of `... Smith` tenants - came back as `ErrItemNotFound` and failed the deploy, even though the same name worked before this branch, when it was passed through and matched server side. `selectors.FindTenant` now does the ID lookup itself and walks every page of the partial name search looking for an exact match, keeping the same ID-beats-name precedence. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/question/selectors/find_test.go | 29 +++++++++++++++++++ pkg/question/selectors/tenants.go | 43 +++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/pkg/question/selectors/find_test.go b/pkg/question/selectors/find_test.go index 0ff5612f..83b208dd 100644 --- a/pkg/question/selectors/find_test.go +++ b/pkg/question/selectors/find_test.go @@ -181,6 +181,35 @@ func TestFindTenants(t *testing.T) { assert.Equal(t, []string{cokeTenant.ID}, util.SliceTransform(result, func(tenant *tenants.Tenant) string { return tenant.ID })) }) + t.Run("finds an exact name match beyond the first page of the partial name search", func(t *testing.T) { + // `partialName` is a contains filter, so a tenant exactly named "Smith" can be pushed off + // the first page by every other tenant whose name also contains "Smith" + aaronSmith := fixtures.NewTenant(findSpaceID, "Tenants-30", "Aaron Smith") + smith := fixtures.NewTenant(findSpaceID, "Tenants-31", "Smith") + + api := testutil.NewMockHttpServer() + receiver := beginRequest(api, func(octopus *octopusApiClient.Client) ([]*tenants.Tenant, error) { + return selectors.FindTenants(octopus, []string{"Smith"}) + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Smith").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Smith").RespondWith(resources.Resources[*tenants.Tenant]{ + Items: []*tenants.Tenant{aaronSmith}, + PagedResults: resources.PagedResults{ + Links: resources.Links{PageNext: "/api/Spaces-1/tenants?partialName=Smith&skip=1"}, + }, + }) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Smith&skip=1").RespondWith(resources.Resources[*tenants.Tenant]{ + Items: []*tenants.Tenant{smith}, + }) + + result, err := testutil.ReceivePair(receiver) + assert.Nil(t, err) + assert.Equal(t, []string{smith.ID}, util.SliceTransform(result, func(tenant *tenants.Tenant) string { return tenant.ID })) + }) + t.Run("errors when nothing matches", func(t *testing.T) { api := testutil.NewMockHttpServer() receiver := beginRequest(api, func(octopus *octopusApiClient.Client) ([]*tenants.Tenant, error) { diff --git a/pkg/question/selectors/tenants.go b/pkg/question/selectors/tenants.go index 6b51d26d..ffb0af34 100644 --- a/pkg/question/selectors/tenants.go +++ b/pkg/question/selectors/tenants.go @@ -3,22 +3,53 @@ package selectors import ( "errors" "fmt" + "strings" octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" - "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/services" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" ) -// FindTenant looks a tenant up by either its ID or its name. +// FindTenant looks a tenant up by either its ID or its name. An ID match wins over a name +// match, so it stays consistent with how projects, environments and channels resolve. +// +// Deliberately not Tenants.GetByIdentifier: its name fallback issues a single `partialName` +// (i.e. contains) query and only scans the first page of the result, so an exact name that +// sorts past that page is reported as not found. Names are on the deploy hot path and used +// to be resolved server side, so a miss here is a regression rather than an inconvenience. func FindTenant(octopus *octopusApiClient.Client, tenantIdentifier string) (*tenants.Tenant, error) { - tenant, err := octopus.Tenants.GetByIdentifier(tenantIdentifier) + if tenantIdentifier == "" { + return nil, errors.New("cannot find a tenant without an ID or name") + } + + tenant, err := octopus.Tenants.GetByID(tenantIdentifier) if err != nil { - if errors.Is(err, services.ErrItemNotFound) { - return nil, fmt.Errorf("cannot find a tenant with the ID or name of '%s'", tenantIdentifier) + var apiError *core.APIError + if errors.As(err, &apiError) && apiError.StatusCode != 404 { + return nil, err } + // a 404 (or an identifier that doesn't look like an ID at all) just means "try the name" + } else if tenant != nil { + return tenant, nil + } + + resultPage, err := octopus.Tenants.Get(tenants.TenantsQuery{PartialName: tenantIdentifier}) + if err != nil { return nil, err } - return tenant, nil + for resultPage != nil && len(resultPage.Items) > 0 { + for _, t := range resultPage.Items { // the server has no exact-name search, so we emulate one + if strings.EqualFold(t.Name, tenantIdentifier) { + return t, nil + } + } + resultPage, err = resultPage.GetNextPage(octopus.Tenants.GetClient()) + if err != nil { + return nil, err + } // if there are no more pages, GetNextPage returns nil, which breaks us out of the loop + } + + return nil, fmt.Errorf("cannot find a tenant with the ID or name of '%s'", tenantIdentifier) } // FindTenants looks tenants up by either their IDs or their names. From 8c07b9281f676051feacbd44cc79e72b79a2f7e1 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:10:17 +1000 Subject: [PATCH 09/18] refactor: collapse the duplicated comma-expansion block into one helper Review feedback: the five-line expansion block at the top of deployRun was duplicated verbatim in runbookRun, so any new multi-value flag has to be added to two hand-maintained lists. ExpandCommaSeparatedFlags takes the flags themselves and expands them in place, leaving one call per command. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/deploy/deploy.go | 12 +++++++----- pkg/cmd/runbook/run/run.go | 12 +++++++----- pkg/executionscommon/executionscommon.go | 9 +++++++++ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 0bb5c4ea..89967425 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -199,11 +199,13 @@ func NewCmdDeploy(f factory.Factory) *cobra.Command { func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error { // these flags accept a comma-separated list as well as being specified multiple times - flags.Environments.Value = executionscommon.ExpandCommaSeparated(flags.Environments.Value) - flags.Tenants.Value = executionscommon.ExpandCommaSeparated(flags.Tenants.Value) - flags.TenantTags.Value = executionscommon.ExpandCommaSeparated(flags.TenantTags.Value) - flags.DeploymentTargets.Value = executionscommon.ExpandCommaSeparated(flags.DeploymentTargets.Value) - flags.ExcludeTargets.Value = executionscommon.ExpandCommaSeparated(flags.ExcludeTargets.Value) + executionscommon.ExpandCommaSeparatedFlags( + flags.Environments, + flags.Tenants, + flags.TenantTags, + flags.DeploymentTargets, + flags.ExcludeTargets, + ) outputFormat, err := cmd.Flags().GetString(constants.FlagOutputFormat) if err != nil { // should never happen, but fallback if it does diff --git a/pkg/cmd/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index 83959392..1a02b2c6 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -202,11 +202,13 @@ func NewCmdRun(f factory.Factory) *cobra.Command { func runbookRun(cmd *cobra.Command, f factory.Factory, flags *RunFlags) error { // these flags accept a comma-separated list as well as being specified multiple times - flags.Environments.Value = executionscommon.ExpandCommaSeparated(flags.Environments.Value) - flags.Tenants.Value = executionscommon.ExpandCommaSeparated(flags.Tenants.Value) - flags.TenantTags.Value = executionscommon.ExpandCommaSeparated(flags.TenantTags.Value) - flags.RunTargets.Value = executionscommon.ExpandCommaSeparated(flags.RunTargets.Value) - flags.ExcludeTargets.Value = executionscommon.ExpandCommaSeparated(flags.ExcludeTargets.Value) + executionscommon.ExpandCommaSeparatedFlags( + flags.Environments, + flags.Tenants, + flags.TenantTags, + flags.RunTargets, + flags.ExcludeTargets, + ) if flags.RunbookName.Value != "" && len(flags.RunbookTags.Value) > 0 { return errors.New("--name and --runbook-tag are mutually exclusive. Please specify either a runbook name or runbook tags, not both") diff --git a/pkg/executionscommon/executionscommon.go b/pkg/executionscommon/executionscommon.go index e875557e..06e9beb9 100644 --- a/pkg/executionscommon/executionscommon.go +++ b/pkg/executionscommon/executionscommon.go @@ -10,6 +10,7 @@ import ( "github.com/OctopusDeploy/cli/pkg/question" "github.com/OctopusDeploy/cli/pkg/surveyext" "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" @@ -324,6 +325,14 @@ func ExpandCommaSeparated(values []string) []string { return result } +// ExpandCommaSeparatedFlags applies ExpandCommaSeparated in place to each of the given flags, +// so callers don't have to keep a hand-maintained list of assignments in sync. +func ExpandCommaSeparatedFlags(flags ...*flag.Flag[[]string]) { + for _, f := range flags { + f.Value = ExpandCommaSeparated(f.Value) + } +} + func ParseVariableStringArray(variables []string) (map[string]string, error) { result := make(map[string]string, len(variables)) for _, v := range variables { From c64ab938d5fd51d221712a7a07386dd770505aa5 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:11:25 +1000 Subject: [PATCH 10/18] fix: reject blank comma-separated values instead of silently dropping them Review feedback: dropping blanks let an explicitly-provided flag expand to nothing. Because pkg/executor/release.go routes on `len(params.Tenants) > 0 || len(params.TenantTags) > 0`, `--tenant "$A,$B"` with both variables unset expanded to nil and the CLI silently submitted an *untenanted* deployment to the environment. Before this branch the literal "," was sent as a tenant name and the server rejected it. The same class of change applied to `--exclude-deployment-target "$X"` with $X empty, where the exclusion list quietly became empty. A blank component always means a caller-side substitution produced nothing, so ExpandCommaSeparated now returns an error naming the flag and quoting the offending value. This also covers the partial case ("$A,$B" with only $B empty), which would otherwise have silently narrowed the deployment scope. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/deploy/deploy.go | 6 ++- pkg/cmd/release/deploy/deploy_test.go | 21 +++++++++ pkg/cmd/runbook/run/run.go | 6 ++- pkg/executionscommon/executionscommon.go | 31 ++++++++----- pkg/executionscommon/executionscommon_test.go | 46 +++++++++++++++++-- 5 files changed, 92 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 89967425..f1629ca0 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -199,13 +199,15 @@ func NewCmdDeploy(f factory.Factory) *cobra.Command { func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error { // these flags accept a comma-separated list as well as being specified multiple times - executionscommon.ExpandCommaSeparatedFlags( + if err := executionscommon.ExpandCommaSeparatedFlags( flags.Environments, flags.Tenants, flags.TenantTags, flags.DeploymentTargets, flags.ExcludeTargets, - ) + ); err != nil { + return err + } outputFormat, err := cmd.Flags().GetString(constants.FlagOutputFormat) if err != nil { // should never happen, but fallback if it does diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index 618ec30e..3f7fbc0f 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -2101,6 +2101,27 @@ func TestDeployCreate_AutomationMode(t *testing.T) { assert.Equal(t, "ServerTasks-29394\n", stdOut.String()) assert.Equal(t, "", stdErr.String()) }}, + + // a --tenant that expands to nothing must not fall through to an untenanted deployment + {"release deploy rejects a blank comma-separated value rather than silently dropping it", 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{ + "release", "deploy", + "--project", fireProject.Name, + "--version", "1.0", + "--environment", "dev", + "--tenant", ",", // e.g. "$TENANT_A,$TENANT_B" where both are unset + "--output-format", "basic", + }) + return rootCmd.ExecuteC() + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.ErrorContains(t, err, "--tenant has a blank value") + + assert.Equal(t, "", stdOut.String()) + }}, } for _, test := range tests { diff --git a/pkg/cmd/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index 1a02b2c6..4ad0eb1a 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -202,13 +202,15 @@ func NewCmdRun(f factory.Factory) *cobra.Command { func runbookRun(cmd *cobra.Command, f factory.Factory, flags *RunFlags) error { // these flags accept a comma-separated list as well as being specified multiple times - executionscommon.ExpandCommaSeparatedFlags( + if err := executionscommon.ExpandCommaSeparatedFlags( flags.Environments, flags.Tenants, flags.TenantTags, flags.RunTargets, flags.ExcludeTargets, - ) + ); err != nil { + return err + } if flags.RunbookName.Value != "" && len(flags.RunbookTags.Value) > 0 { return errors.New("--name and --runbook-tag are mutually exclusive. Please specify either a runbook name or runbook tags, not both") diff --git a/pkg/executionscommon/executionscommon.go b/pkg/executionscommon/executionscommon.go index 06e9beb9..58dab31c 100644 --- a/pkg/executionscommon/executionscommon.go +++ b/pkg/executionscommon/executionscommon.go @@ -303,34 +303,43 @@ func AskVariableSpecificPrompt(asker question.Asker, message string, variableTyp } // ExpandCommaSeparated splits each entry on commas so `--flag "A,B"` behaves the same as -// `--flag A --flag B`. Whitespace around each entry is trimmed and blank entries are dropped. +// `--flag A --flag B`. Whitespace around each entry is trimmed. +// +// Blank entries are rejected rather than silently dropped. A value such as "," or "A,,B" +// almost always means a caller-side variable substitution produced nothing, and quietly +// dropping it would change the scope of the deployment: an empty --tenant list, for example, +// turns a tenanted deployment into an untenanted one rather than failing. +// // Only apply this to flags whose values cannot legitimately contain a comma; notably NOT to // --variable, --skip or the package/git-resource specs. -func ExpandCommaSeparated(values []string) []string { +func ExpandCommaSeparated(flagName string, values []string) ([]string, error) { if len(values) == 0 { - return values + return values, nil } result := make([]string, 0, len(values)) for _, value := range values { for _, component := range strings.Split(value, ",") { component = strings.TrimSpace(component) - if component != "" { - result = append(result, component) + if component == "" { + return nil, fmt.Errorf("--%s has a blank value; check for an empty variable or a stray comma in %q", flagName, value) } + result = append(result, component) } } - if len(result) == 0 { - return nil - } - return result + return result, nil } // ExpandCommaSeparatedFlags applies ExpandCommaSeparated in place to each of the given flags, // so callers don't have to keep a hand-maintained list of assignments in sync. -func ExpandCommaSeparatedFlags(flags ...*flag.Flag[[]string]) { +func ExpandCommaSeparatedFlags(flags ...*flag.Flag[[]string]) error { for _, f := range flags { - f.Value = ExpandCommaSeparated(f.Value) + expanded, err := ExpandCommaSeparated(f.Name, f.Value) + if err != nil { + return err + } + f.Value = expanded } + return nil } func ParseVariableStringArray(variables []string) (map[string]string, error) { diff --git a/pkg/executionscommon/executionscommon_test.go b/pkg/executionscommon/executionscommon_test.go index 26db6a16..0942b9c4 100644 --- a/pkg/executionscommon/executionscommon_test.go +++ b/pkg/executionscommon/executionscommon_test.go @@ -8,6 +8,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/OctopusDeploy/cli/pkg/executionscommon" + "github.com/OctopusDeploy/cli/pkg/util/flag" "github.com/OctopusDeploy/cli/test/fixtures" "github.com/OctopusDeploy/cli/test/testutil" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" @@ -431,14 +432,53 @@ func TestExpandCommaSeparated(t *testing.T) { {name: "preserves order and duplicates", input: []string{"ABC,ABC"}, expect: []string{"ABC", "ABC"}}, {name: "tenant tags", input: []string{"Regions/us-east,Regions/us-west"}, expect: []string{"Regions/us-east", "Regions/us-west"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := executionscommon.ExpandCommaSeparated("environment", test.input) + assert.NoError(t, err) + assert.Equal(t, test.expect, result) + }) + } +} - {name: "drops blank entries", input: []string{"ABC,,XYZ"}, expect: []string{"ABC", "XYZ"}}, - {name: "all blank entries returns nil", input: []string{"", " , "}, expect: nil}, +// a blank component almost always means a caller-side variable expanded to nothing; dropping it +// silently would narrow the scope of a deployment, or flip a tenanted deploy to untenanted +func TestExpandCommaSeparated_RejectsBlankValues(t *testing.T) { + tests := []struct { + name string + input []string + }{ + {name: "empty string", input: []string{""}}, + {name: "lone comma", input: []string{","}}, + {name: "whitespace only", input: []string{" , "}}, + {name: "blank in the middle", input: []string{"ABC,,XYZ"}}, + {name: "trailing comma", input: []string{"ABC,"}}, + {name: "blank alongside a good repeat", input: []string{"ABC", ""}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.expect, executionscommon.ExpandCommaSeparated(test.input)) + result, err := executionscommon.ExpandCommaSeparated("tenant", test.input) + assert.Nil(t, result) + assert.ErrorContains(t, err, "--tenant has a blank value") }) } } + +func TestExpandCommaSeparatedFlags(t *testing.T) { + environments := flag.New[[]string]("environment", false) + environments.Value = []string{"dev,test"} + tenants := flag.New[[]string]("tenant", false) + tenants.Value = []string{"Tenant A", "Tenant B,Tenant C"} + + assert.NoError(t, executionscommon.ExpandCommaSeparatedFlags(environments, tenants)) + assert.Equal(t, []string{"dev", "test"}, environments.Value) + assert.Equal(t, []string{"Tenant A", "Tenant B", "Tenant C"}, tenants.Value) + + bad := flag.New[[]string]("deployment-target", false) + bad.Value = []string{"ABC,"} + err := executionscommon.ExpandCommaSeparatedFlags(environments, bad) + assert.ErrorContains(t, err, "--deployment-target has a blank value") +} From b77c149675faca4bcbef469dad4484df4daebfca Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:12:06 +1000 Subject: [PATCH 11/18] fix: add a backslash escape hatch for commas in target and scope values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: the split was unconditional, so a tenant/target/environment named e.g. "Foo, Inc" could no longer be passed through the primary flags at all. The sharper edge was the interactive echo — a value chosen from a picker is backfilled into resolvedFlags and flag.GenerateAutomationCmd emits it verbatim, so the printed "Automation Command" was not re-runnable: pasting it into CI would split "Foo, Inc" back into two names, erroring if they don't exist or deploying to the wrong tenants if they do. `\,` now means a literal comma. A backslash anywhere else is preserved verbatim, so names such as DOMAIN\host are unaffected. Interactive selections are escaped with executionscommon.EscapeCommas on the way into the automation command, so the echoed command round-trips. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/deploy/deploy.go | 20 ++++---- pkg/cmd/release/deploy/deploy_test.go | 37 +++++++++++++++ pkg/cmd/runbook/run/run.go | 30 ++++++------ pkg/cmd/runbook/run/run_by_tag.go | 6 +-- pkg/executionscommon/executionscommon.go | 46 +++++++++++++++++-- pkg/executionscommon/executionscommon_test.go | 21 ++++++++- 6 files changed, 127 insertions(+), 33 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index f1629ca0..4b1431c9 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -160,9 +160,9 @@ func NewCmdDeploy(f factory.Factory) *cobra.Command { flags := cmd.Flags() flags.StringVarP(&deployFlags.Project.Value, deployFlags.Project.Name, "p", "", "Name or ID of the project to deploy the release from") flags.StringVarP(&deployFlags.ReleaseVersion.Value, deployFlags.ReleaseVersion.Name, "", "", "Release version to deploy") - flags.StringArrayVarP(&deployFlags.Environments.Value, deployFlags.Environments.Name, "e", nil, "Deploy to this environment (can be specified multiple times, or as a comma-separated list)") - flags.StringArrayVarP(&deployFlags.Tenants.Value, deployFlags.Tenants.Name, "", nil, "Deploy to this tenant (can be specified multiple times, or as a comma-separated list)") - flags.StringArrayVarP(&deployFlags.TenantTags.Value, deployFlags.TenantTags.Name, "", nil, "Deploy to tenants matching this tag (can be specified multiple times, or as a comma-separated list). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") + flags.StringArrayVarP(&deployFlags.Environments.Value, deployFlags.Environments.Name, "e", nil, "Deploy to this environment (can be specified multiple times, or as a comma-separated list; escape a comma inside a value as '\\,')") + flags.StringArrayVarP(&deployFlags.Tenants.Value, deployFlags.Tenants.Name, "", nil, "Deploy to this tenant (can be specified multiple times, or as a comma-separated list; escape a comma inside a value as '\\,')") + flags.StringArrayVarP(&deployFlags.TenantTags.Value, deployFlags.TenantTags.Name, "", nil, "Deploy to tenants matching this tag (can be specified multiple times, or as a comma-separated list; escape a comma inside a value as '\\,'). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") flags.StringVarP(&deployFlags.DeployAt.Value, deployFlags.DeployAt.Name, "", "", "Deploy at a later time. Deploy now if omitted. TODO date formats and timezones!") flags.StringVarP(&deployFlags.MaxQueueTime.Value, deployFlags.MaxQueueTime.Name, "", "", "Cancel the deployment if it hasn't started within this time period.") flags.StringArrayVarP(&deployFlags.Variables.Value, deployFlags.Variables.Name, "v", nil, "Set the value for a prompted variable in the format Label:Value") @@ -170,8 +170,8 @@ func NewCmdDeploy(f factory.Factory) *cobra.Command { flags.StringArrayVarP(&deployFlags.ExcludedSteps.Value, deployFlags.ExcludedSteps.Name, "", nil, "Exclude specific steps from the deployment") flags.StringVarP(&deployFlags.GuidedFailureMode.Value, deployFlags.GuidedFailureMode.Name, "", "", "Enable Guided failure mode (true/false/default)") 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, or as a comma-separated list)") - flags.StringArrayVarP(&deployFlags.ExcludeTargets.Value, deployFlags.ExcludeTargets.Name, "", nil, "Deploy to targets except for this (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&deployFlags.DeploymentTargets.Value, deployFlags.DeploymentTargets.Name, "", nil, "Deploy to this target (can be specified multiple times, or as a comma-separated list; escape a comma inside a value as '\\,')") + flags.StringArrayVarP(&deployFlags.ExcludeTargets.Value, deployFlags.ExcludeTargets.Name, "", nil, "Deploy to targets except for this (can be specified multiple times, or as a comma-separated list; escape a comma inside a value as '\\,')") 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") @@ -266,15 +266,15 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error resolvedFlags := NewDeployFlags() resolvedFlags.Project.Value = options.ProjectName resolvedFlags.ReleaseVersion.Value = options.ReleaseVersion - resolvedFlags.Environments.Value = options.Environments - resolvedFlags.Tenants.Value = options.Tenants - resolvedFlags.TenantTags.Value = options.TenantTags + resolvedFlags.Environments.Value = executionscommon.EscapeCommas(options.Environments) + resolvedFlags.Tenants.Value = executionscommon.EscapeCommas(options.Tenants) + resolvedFlags.TenantTags.Value = executionscommon.EscapeCommas(options.TenantTags) resolvedFlags.DeployAt.Value = options.ScheduledStartTime resolvedFlags.MaxQueueTime.Value = options.ScheduledExpiryTime resolvedFlags.ExcludedSteps.Value = options.ExcludedSteps resolvedFlags.GuidedFailureMode.Value = options.GuidedFailureMode - resolvedFlags.DeploymentTargets.Value = options.DeploymentTargets - resolvedFlags.ExcludeTargets.Value = options.ExcludeTargets + resolvedFlags.DeploymentTargets.Value = executionscommon.EscapeCommas(options.DeploymentTargets) + resolvedFlags.ExcludeTargets.Value = executionscommon.EscapeCommas(options.ExcludeTargets) resolvedFlags.DeploymentFreezeNames.Value = options.DeploymentFreezeNames resolvedFlags.DeploymentFreezeOverrideReason.Value = options.DeploymentFreezeOverrideReason diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index 3f7fbc0f..9c785b34 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -2102,6 +2102,43 @@ func TestDeployCreate_AutomationMode(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, + {"release deploy treats a backslash-escaped comma as part of the value", 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{ + "release", "deploy", + "--project", fireProject.Name, + "--version", "1.0", + "--environment", "dev", + "--deployment-target", `Web\, Prod,Other`, + "--output-format", "basic", + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") + requestBody, err := testutil.ReadJson[deployments.CreateDeploymentUntenantedCommandV1](req.Request.Body) + assert.Nil(t, err) + + assert.Equal(t, []string{"Web, Prod", "Other"}, requestBody.SpecificMachineNames) + + req.RespondWith(&deployments.CreateDeploymentResponseV1{ + DeploymentServerTasks: []*deployments.DeploymentServerTask{ + {DeploymentID: "Deployments-203", ServerTaskID: "ServerTasks-29394"}, + }, + }) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + assert.Equal(t, "ServerTasks-29394\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + // a --tenant that expands to nothing must not fall through to an untenanted deployment {"release deploy rejects a blank comma-separated value rather than silently dropping it", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { diff --git a/pkg/cmd/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index 4ad0eb1a..c0717d67 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -162,9 +162,9 @@ func NewCmdRun(f factory.Factory) *cobra.Command { flags.StringVarP(&runFlags.Project.Value, runFlags.Project.Name, "p", "", "Name or ID of the project to run the runbook from") flags.StringVarP(&runFlags.RunbookName.Value, runFlags.RunbookName.Name, "n", "", "Name of the runbook to run") flags.StringArrayVarP(&runFlags.RunbookTags.Value, runFlags.RunbookTags.Name, "", nil, "Run all runbooks matching this tag (can be specified multiple times). Format is 'Tag Set Name/Tag Name'. Mutually exclusive with --name.") - flags.StringArrayVarP(&runFlags.Environments.Value, runFlags.Environments.Name, "e", nil, "Run in this environment (can be specified multiple times, or as a comma-separated list)") - flags.StringArrayVarP(&runFlags.Tenants.Value, runFlags.Tenants.Name, "", nil, "Run for this tenant (can be specified multiple times, or as a comma-separated list)") - flags.StringArrayVarP(&runFlags.TenantTags.Value, runFlags.TenantTags.Name, "", nil, "Run for tenants matching this tag (can be specified multiple times, or as a comma-separated list). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") + flags.StringArrayVarP(&runFlags.Environments.Value, runFlags.Environments.Name, "e", nil, "Run in this environment (can be specified multiple times, or as a comma-separated list; escape a comma inside a value as '\\,')") + flags.StringArrayVarP(&runFlags.Tenants.Value, runFlags.Tenants.Name, "", nil, "Run for this tenant (can be specified multiple times, or as a comma-separated list; escape a comma inside a value as '\\,')") + flags.StringArrayVarP(&runFlags.TenantTags.Value, runFlags.TenantTags.Name, "", nil, "Run for tenants matching this tag (can be specified multiple times, or as a comma-separated list; escape a comma inside a value as '\\,'). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") flags.StringVarP(&runFlags.RunAt.Value, runFlags.RunAt.Name, "", "", "Run at a later time. Run now if omitted. TODO date formats and timezones!") flags.StringVarP(&runFlags.MaxQueueTime.Value, runFlags.MaxQueueTime.Name, "", "", "Cancel a scheduled run if it hasn't started within this time period.") flags.StringArrayVarP(&runFlags.Variables.Value, runFlags.Variables.Name, "v", nil, "Set the value for a prompted variable in the format Label:Value") @@ -172,8 +172,8 @@ func NewCmdRun(f factory.Factory) *cobra.Command { flags.StringArrayVarP(&runFlags.ExcludedSteps.Value, runFlags.ExcludedSteps.Name, "", nil, "Exclude specific steps from the runbook") flags.StringVarP(&runFlags.GuidedFailureMode.Value, runFlags.GuidedFailureMode.Name, "", "", "Enable Guided failure mode (true/false/default)") 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, or as a comma-separated list)") - flags.StringArrayVarP(&runFlags.ExcludeTargets.Value, runFlags.ExcludeTargets.Name, "", nil, "Run on targets except for this (can be specified multiple times, or as a comma-separated list)") + flags.StringArrayVarP(&runFlags.RunTargets.Value, runFlags.RunTargets.Name, "", nil, "Run on this target (can be specified multiple times, or as a comma-separated list; escape a comma inside a value as '\\,')") + flags.StringArrayVarP(&runFlags.ExcludeTargets.Value, runFlags.ExcludeTargets.Name, "", nil, "Run on targets except for this (can be specified multiple times, or as a comma-separated list; escape a comma inside a value as '\\,')") 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.") @@ -334,15 +334,15 @@ func runDbRunbook(cmd *cobra.Command, f factory.Factory, flags *RunFlags, octopu resolvedFlags := NewRunFlags() resolvedFlags.Project.Value = options.ProjectName resolvedFlags.RunbookName.Value = options.RunbookName - resolvedFlags.Environments.Value = options.Environments - resolvedFlags.Tenants.Value = options.Tenants - resolvedFlags.TenantTags.Value = options.TenantTags + resolvedFlags.Environments.Value = executionscommon.EscapeCommas(options.Environments) + resolvedFlags.Tenants.Value = executionscommon.EscapeCommas(options.Tenants) + resolvedFlags.TenantTags.Value = executionscommon.EscapeCommas(options.TenantTags) resolvedFlags.RunAt.Value = options.ScheduledStartTime resolvedFlags.MaxQueueTime.Value = options.ScheduledExpiryTime resolvedFlags.ExcludedSteps.Value = options.ExcludedSteps resolvedFlags.GuidedFailureMode.Value = options.GuidedFailureMode - resolvedFlags.RunTargets.Value = options.RunTargets - resolvedFlags.ExcludeTargets.Value = options.ExcludeTargets + resolvedFlags.RunTargets.Value = executionscommon.EscapeCommas(options.RunTargets) + resolvedFlags.ExcludeTargets.Value = executionscommon.EscapeCommas(options.ExcludeTargets) didMaskSensitiveVariable := false automationVariables := make(map[string]string, len(options.Variables)) @@ -468,15 +468,15 @@ func runGitRunbook(cmd *cobra.Command, f factory.Factory, flags *RunFlags, octop resolvedFlags := NewRunFlags() resolvedFlags.Project.Value = options.ProjectName resolvedFlags.RunbookName.Value = options.RunbookName - resolvedFlags.Environments.Value = options.Environments - resolvedFlags.Tenants.Value = options.Tenants - resolvedFlags.TenantTags.Value = options.TenantTags + resolvedFlags.Environments.Value = executionscommon.EscapeCommas(options.Environments) + resolvedFlags.Tenants.Value = executionscommon.EscapeCommas(options.Tenants) + resolvedFlags.TenantTags.Value = executionscommon.EscapeCommas(options.TenantTags) resolvedFlags.RunAt.Value = options.ScheduledStartTime resolvedFlags.MaxQueueTime.Value = options.ScheduledExpiryTime resolvedFlags.ExcludedSteps.Value = options.ExcludedSteps resolvedFlags.GuidedFailureMode.Value = options.GuidedFailureMode - resolvedFlags.RunTargets.Value = options.RunTargets - resolvedFlags.ExcludeTargets.Value = options.ExcludeTargets + resolvedFlags.RunTargets.Value = executionscommon.EscapeCommas(options.RunTargets) + resolvedFlags.ExcludeTargets.Value = executionscommon.EscapeCommas(options.ExcludeTargets) resolvedFlags.GitRef.Value = options.GitReference resolvedFlags.PackageVersion.Value = options.DefaultPackageVersion resolvedFlags.PackageVersionSpec.Value = options.PackageVersionOverrides diff --git a/pkg/cmd/runbook/run/run_by_tag.go b/pkg/cmd/runbook/run/run_by_tag.go index f72a030f..1548fb96 100644 --- a/pkg/cmd/runbook/run/run_by_tag.go +++ b/pkg/cmd/runbook/run/run_by_tag.go @@ -321,9 +321,9 @@ func runRunbooksByTag(cmd *cobra.Command, f factory.Factory, flags *RunFlags, oc resolvedFlags := NewRunFlags() resolvedFlags.Project.Value = flags.Project.Value resolvedFlags.RunbookTags.Value = flags.RunbookTags.Value - resolvedFlags.Environments.Value = flags.Environments.Value - resolvedFlags.Tenants.Value = flags.Tenants.Value - resolvedFlags.TenantTags.Value = flags.TenantTags.Value + resolvedFlags.Environments.Value = executionscommon.EscapeCommas(flags.Environments.Value) + resolvedFlags.Tenants.Value = executionscommon.EscapeCommas(flags.Tenants.Value) + resolvedFlags.TenantTags.Value = executionscommon.EscapeCommas(flags.TenantTags.Value) spaceName := "" if s := f.GetCurrentSpace(); s != nil { diff --git a/pkg/executionscommon/executionscommon.go b/pkg/executionscommon/executionscommon.go index 58dab31c..1486a7a7 100644 --- a/pkg/executionscommon/executionscommon.go +++ b/pkg/executionscommon/executionscommon.go @@ -305,23 +305,27 @@ func AskVariableSpecificPrompt(asker question.Asker, message string, variableTyp // ExpandCommaSeparated splits each entry on commas so `--flag "A,B"` behaves the same as // `--flag A --flag B`. Whitespace around each entry is trimmed. // +// A comma that is part of a value can be escaped with a backslash, so +// `--deployment-target 'Web\, Prod'` yields the single value `Web, Prod`. A backslash in any +// other position is left alone, so target names such as `DOMAIN\host` are unaffected. +// // Blank entries are rejected rather than silently dropped. A value such as "," or "A,,B" // almost always means a caller-side variable substitution produced nothing, and quietly // dropping it would change the scope of the deployment: an empty --tenant list, for example, // turns a tenanted deployment into an untenanted one rather than failing. // -// Only apply this to flags whose values cannot legitimately contain a comma; notably NOT to -// --variable, --skip or the package/git-resource specs. +// Only apply this to flags whose values cannot legitimately contain an unescaped comma; +// notably NOT to --variable, --skip or the package/git-resource specs. func ExpandCommaSeparated(flagName string, values []string) ([]string, error) { if len(values) == 0 { return values, nil } result := make([]string, 0, len(values)) for _, value := range values { - for _, component := range strings.Split(value, ",") { + for _, component := range splitOnUnescapedCommas(value) { component = strings.TrimSpace(component) if component == "" { - return nil, fmt.Errorf("--%s has a blank value; check for an empty variable or a stray comma in %q", flagName, value) + return nil, fmt.Errorf("--%s has a blank value; check for an empty variable or a stray comma in %q. Use '\\,' to include a comma in a value", flagName, value) } result = append(result, component) } @@ -329,6 +333,26 @@ func ExpandCommaSeparated(flagName string, values []string) ([]string, error) { return result, nil } +// splitOnUnescapedCommas splits on commas, treating `\,` as an escaped literal comma. +// Any other backslash is preserved verbatim. +func splitOnUnescapedCommas(value string) []string { + var result []string + var current strings.Builder + for i := 0; i < len(value); i++ { + switch { + case value[i] == '\\' && i+1 < len(value) && value[i+1] == ',': + current.WriteByte(',') + i++ + case value[i] == ',': + result = append(result, current.String()) + current.Reset() + default: + current.WriteByte(value[i]) + } + } + return append(result, current.String()) +} + // ExpandCommaSeparatedFlags applies ExpandCommaSeparated in place to each of the given flags, // so callers don't have to keep a hand-maintained list of assignments in sync. func ExpandCommaSeparatedFlags(flags ...*flag.Flag[[]string]) error { @@ -342,6 +366,20 @@ func ExpandCommaSeparatedFlags(flags ...*flag.Flag[[]string]) error { return nil } +// EscapeCommas escapes any comma within each value so that the result survives a round trip +// back through ExpandCommaSeparated. Used when echoing user selections into the generated +// automation command, which emits values verbatim. +func EscapeCommas(values []string) []string { + if len(values) == 0 { + return values + } + result := make([]string, 0, len(values)) + for _, value := range values { + result = append(result, strings.ReplaceAll(value, ",", "\\,")) + } + return result +} + func ParseVariableStringArray(variables []string) (map[string]string, error) { result := make(map[string]string, len(variables)) for _, v := range variables { diff --git a/pkg/executionscommon/executionscommon_test.go b/pkg/executionscommon/executionscommon_test.go index 0942b9c4..701cf942 100644 --- a/pkg/executionscommon/executionscommon_test.go +++ b/pkg/executionscommon/executionscommon_test.go @@ -432,6 +432,11 @@ func TestExpandCommaSeparated(t *testing.T) { {name: "preserves order and duplicates", input: []string{"ABC,ABC"}, expect: []string{"ABC", "ABC"}}, {name: "tenant tags", input: []string{"Regions/us-east,Regions/us-west"}, expect: []string{"Regions/us-east", "Regions/us-west"}}, + + {name: "escaped comma is a literal comma", input: []string{`Web\, Prod`}, expect: []string{"Web, Prod"}}, + {name: "escaped and unescaped commas mix", input: []string{`Web\, Prod,Other`}, expect: []string{"Web, Prod", "Other"}}, + {name: "backslash not before a comma is preserved", input: []string{`DOMAIN\host,Other`}, expect: []string{`DOMAIN\host`, "Other"}}, + {name: "trailing backslash is preserved", input: []string{`ABC\`}, expect: []string{`ABC\`}}, } for _, test := range tests { @@ -444,7 +449,7 @@ func TestExpandCommaSeparated(t *testing.T) { } // a blank component almost always means a caller-side variable expanded to nothing; dropping it -// silently would narrow the scope of a deployment, or flip a tenanted deploy to untenanted +// silently would narrow the scope of a deployment (or flip a tenanted deploy to untenanted) func TestExpandCommaSeparated_RejectsBlankValues(t *testing.T) { tests := []struct { name string @@ -482,3 +487,17 @@ func TestExpandCommaSeparatedFlags(t *testing.T) { err := executionscommon.ExpandCommaSeparatedFlags(environments, bad) assert.ErrorContains(t, err, "--deployment-target has a blank value") } + +// values chosen interactively are echoed back as an automation command verbatim, so any comma +// inside them has to be escaped or the replayed command would split it back apart +func TestEscapeCommas_RoundTripsThroughExpand(t *testing.T) { + assert.Nil(t, executionscommon.EscapeCommas(nil)) + + input := []string{"Web, Prod", "Plain", `Already\, Escaped`} + escaped := executionscommon.EscapeCommas(input) + assert.Equal(t, []string{`Web\, Prod`, "Plain", `Already\\, Escaped`}, escaped) + + expanded, err := executionscommon.ExpandCommaSeparated("deployment-target", escaped) + assert.NoError(t, err) + assert.Equal(t, []string{"Web, Prod", "Plain", `Already\, Escaped`}, expanded) +} From cf2dc4c2c1b43cf0e10669c7a11acf8d27c1b32e Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:09:16 +1000 Subject: [PATCH 12/18] refactor: drop the now-unreachable web-URL release lookup With the release resolved before the deploy, `options.ReleaseID` is always set on both paths that reach the link: AskQuestions in interactive mode, the pre-flight lookup in automation mode (the executor rejects the deploy unless both ProjectName and ReleaseVersion are set, which is exactly when the pre-flight runs). The FindProject + GetReleaseInProject fallback can only run when the pre-flight lookup already failed, where repeating it would fail too. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/deploy/deploy.go | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index cc314649..5273603d 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -360,20 +360,10 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error // output web URL all the time, so long as output format is not JSON or basic if err == nil && !constants.IsProgrammaticOutputFormat(outputFormat) { - releaseID := options.ReleaseID - if releaseID == "" { - // we may already have the release ID from AskQuestions. If not, we need to go and look up the release ID to link to it - // which needs the project ID. Errors here are ignorable; it's not the end of the world if we can't print the web link - prj, err := selectors.FindProject(octopus, options.ProjectName) - if err == nil { - rel, err := releases.GetReleaseInProject(octopus, f.GetCurrentSpace().ID, prj.ID, options.ReleaseVersion) - if err == nil { - releaseID = rel.ID - } - } - } - - if releaseID != "" { + // both paths that reach here have already resolved the release: AskQuestions in interactive + // mode, the pre-flight lookup in automation mode. It stays empty only when that lookup failed + // for a reason we deliberately ignored, in which case repeating it here would fail too. + if releaseID := options.ReleaseID; releaseID != "" { link := output.Bluef("%s/app#/%s/releases/%s", f.GetCurrentHost(), f.GetCurrentSpace().ID, releaseID) cmd.Printf("\nView this release on Octopus Deploy: %s\n", link) } From 14ff2ee4b3390e4a4c97ece9eba54ae7d40a92ca Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:09:06 +1000 Subject: [PATCH 13/18] fix: only a missing release aborts the deploy pre-flight The pre-flight lookup is new to `release deploy`; before it, the automation path never read the release and the executions API only ever saw the version string. Failing the whole deploy on any lookup error would break a CI service account scoped to deploy but not to ReleaseView, and would turn a transient 5xx on that GET into an aborted deployment that previously succeeded. Fail only on a ReleaseNotFoundError, which is the case issue #294 is about. For anything else, carry on without the release ID and let the server remain the authority on permissions and availability. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/deploy/deploy.go | 13 +++++++-- pkg/cmd/release/deploy/deploy_test.go | 42 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 5273603d..72ab0cc2 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -320,12 +320,19 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error if options.ReleaseVersion != "" { // resolve the release up front; the executions API reports an unknown version as an - // unhelpful null reference error, and having the ID saves looking it up again later + // unhelpful null reference error, and having the ID saves looking it up again later. + // Only a "no such release" answer is fatal: this lookup is new to the deploy path, so + // anything else (no ReleaseView permission, a transient 5xx) must not fail a deploy + // that would previously have succeeded. In those cases the server stays the authority + // and we simply go without the release ID. release, err := selectors.FindRelease(octopus, f.GetCurrentSpace().ID, project, options.ReleaseVersion) - if err != nil { + var releaseNotFound *selectors.ReleaseNotFoundError + if errors.As(err, &releaseNotFound) { return err } - options.ReleaseID = release.ID + if err == nil { + options.ReleaseID = release.ID + } } } diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index 53500ea2..185c48a8 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -1642,6 +1642,48 @@ func TestDeployCreate_AutomationMode(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, + {"release deploy proceeds when the release lookup fails for a reason other than not-found", 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{"release", "deploy", "--project", fireProject.Name, "--version", "1.0", "--environment", "dev"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + + // an account allowed to deploy but not to read releases must not be blocked by the pre-flight lookup + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0"). + RespondWithStatus(403, "403 Forbidden", &core.APIError{ErrorMessage: "You do not have permission to perform this action."}) + + req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") + requestBody, err := testutil.ReadJson[deployments.CreateDeploymentUntenantedCommandV1](req.Request.Body) + assert.Nil(t, err) + + assert.Equal(t, deployments.CreateDeploymentUntenantedCommandV1{ + ReleaseVersion: "1.0", + EnvironmentNames: []string{"dev"}, + CreateExecutionAbstractCommandV1: deployments.CreateExecutionAbstractCommandV1{ + SpaceID: "Spaces-1", + ProjectIDOrName: fireProject.Name, + }, + }, requestBody) + + req.RespondWith(&deployments.CreateDeploymentResponseV1{ + DeploymentServerTasks: []*deployments.DeploymentServerTask{ + {DeploymentID: "Deployments-203", ServerTaskID: "ServerTasks-29394"}, + }, + }) + + _, err = testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + + // no release ID, so no web link; the deployment itself still went ahead + assert.Equal(t, "Successfully started 1 deployment(s)\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + {"release deploy specifying project, version, env only (bare minimum) assuming untenanted", 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() From 4e9ec94208c5c4d06097067e390248ef7da6f101 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:09:16 +1000 Subject: [PATCH 14/18] refactor: call selectors.FindRelease directly from GetReleaseID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shared.FindRelease was left as a one-line passthrough, so remove it. Doing so also puts GetReleaseID's spaceID parameter to use — it was accepted and then ignored in favour of octopus.GetSpaceID(). Both callers already pass opts.Client.GetSpaceID(), so the resolved space is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/progression/shared/shared.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pkg/cmd/release/progression/shared/shared.go b/pkg/cmd/release/progression/shared/shared.go index a181a771..e9d7249d 100644 --- a/pkg/cmd/release/progression/shared/shared.go +++ b/pkg/cmd/release/progression/shared/shared.go @@ -16,7 +16,7 @@ func GetReleaseID(octopus *client.Client, spaceID string, projectIdentifier stri return "", err } - selectedRelease, err := FindRelease(octopus, selectedProject, version) + selectedRelease, err := selectors.FindRelease(octopus, spaceID, selectedProject, version) if err != nil { return "", err } @@ -38,7 +38,3 @@ func SelectRelease(octopus *client.Client, project *projects.Project, ask questi return selectedRelease, nil } - -func FindRelease(octopus *client.Client, project *projects.Project, version string) (*releases.Release, error) { - return selectors.FindRelease(octopus, octopus.GetSpaceID(), project, version) -} From 3d8333ba4a17d8b312287ba6c86b2240cfeb39a4 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:13:29 +1000 Subject: [PATCH 15/18] fix: resolve environments one identifier at a time, and share the ephemeral fallback with runbook run The ephemeral fallback was all-or-nothing over the whole `--environment` list: a list mixing a regular and an ephemeral environment could never resolve, because the regular lookup errored on the ephemeral name and the ephemeral lookup then errored on the regular one, leaving the user with `cannot find an environment with the ID or name of ''` - blaming an environment that exists. It also fell back on *any* error from the regular lookup, including a transport failure. `selectors.ResolveEnvironmentNames` now resolves each identifier in turn against the regular environment list, consulting the ephemeral list only for identifiers that list doesn't have (fetched once, lazily). Single-type lists behave exactly as before; mixed lists resolve, and a genuine miss names the identifier that actually went missing. `runbook run` uses the same resolver, so an ephemeral environment name that used to be passed through to the server no longer fails client side. Also flips ephemeral name/ID indexing in `findEphemeralEnvironments` so an ID match wins a collision, matching the precedence everywhere else. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/deploy/deploy.go | 22 ++---- pkg/cmd/runbook/run/run.go | 3 +- pkg/question/selectors/environments.go | 98 ++++++++++++++++++++++---- pkg/question/selectors/find_test.go | 57 +++++++++++++++ 4 files changed, 148 insertions(+), 32 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 9779dd6e..c5080efa 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -331,7 +331,7 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error // the executions API only matches environments by name, so resolve any IDs we were given if len(options.Environments) > 0 { - options.Environments, err = resolveEnvironmentNames(octopus, f.GetCurrentSpace(), options.Environments) + options.Environments, err = selectors.ResolveEnvironmentNames(octopus, f.GetCurrentSpace(), options.Environments) if err != nil { return err } @@ -659,9 +659,11 @@ func findEphemeralEnvironments(octopus *octopusApiClient.Client, space *spaces.S envMap := make(map[string]*ephemeralenvironments.EphemeralEnvironment, len(allEphemeralEnvironments.Items)*2) for _, ephemeralEnv := range allEphemeralEnvironments.Items { - envMap[strings.ToLower(ephemeralEnv.ID)] = ephemeralEnv envMap[strings.ToLower(ephemeralEnv.Name)] = ephemeralEnv } + for _, ephemeralEnv := range allEphemeralEnvironments.Items { // IDs go in second so an ID match wins a collision with another environment's name + envMap[strings.ToLower(ephemeralEnv.ID)] = ephemeralEnv + } for _, envIdentifier := range environmentIdentifiers { ephemeralEnv, found := envMap[strings.ToLower(envIdentifier)] @@ -674,22 +676,6 @@ func findEphemeralEnvironments(octopus *octopusApiClient.Client, space *spaces.S return selectedEnvironments, nil } -// resolveEnvironmentNames maps environment names or IDs onto canonical environment names, because -// the executions API only matches environments by name. Ephemeral environments aren't part of the -// regular environment list, so they're looked up separately when the regular lookup comes up empty. -func resolveEnvironmentNames(octopus *octopusApiClient.Client, space *spaces.Space, environmentIdentifiers []string) ([]string, error) { - selectedEnvironments, err := selectors.FindEnvironments(octopus, environmentIdentifiers) - if err == nil { - return util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.Name }), nil - } - - ephemeralEnvironments, ephemeralErr := findEphemeralEnvironments(octopus, space, environmentIdentifiers) - if ephemeralErr != nil { - return nil, err // ephemeral environments are the rarer case; report why the regular lookup failed - } - return util.SliceTransform(ephemeralEnvironments, func(env *ephemeralenvironments.EphemeralEnvironment) string { return env.Name }), nil -} - func selectDeploymentEnvironmentsForEphemeralChannel(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsDeployRelease, selectedRelease *releases.Release) ([]string, error) { var deploymentEnvironmentIds []string var selectedEnvironments []*ephemeralenvironments.EphemeralEnvironment diff --git a/pkg/cmd/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index 000c665e..ff13cd72 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -231,11 +231,10 @@ func runbookRun(cmd *cobra.Command, f factory.Factory, flags *RunFlags) error { // the executions API only matches environments and tenants by name, so resolve any IDs we were given if len(flags.Environments.Value) > 0 { - selectedEnvironments, err := selectors.FindEnvironments(octopus, flags.Environments.Value) + flags.Environments.Value, err = selectors.ResolveEnvironmentNames(octopus, f.GetCurrentSpace(), flags.Environments.Value) if err != nil { return err } - flags.Environments.Value = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.Name }) } if len(flags.Tenants.Value) > 0 { diff --git a/pkg/question/selectors/environments.go b/pkg/question/selectors/environments.go index 2176b400..b7a832ec 100644 --- a/pkg/question/selectors/environments.go +++ b/pkg/question/selectors/environments.go @@ -7,6 +7,8 @@ import ( "github.com/OctopusDeploy/cli/pkg/question" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" ) type GetAllEnvironmentsCallback func() ([]*environments.Environment, error) @@ -56,21 +58,13 @@ func FindEnvironments(octopus *client.Client, environmentIdentifiers []string) ( if err != nil { return nil, err } - - idLookup := make(map[string]*environments.Environment, len(allEnvs)) - nameLookup := make(map[string]*environments.Environment, len(allEnvs)) - for _, env := range allEnvs { - idLookup[strings.ToLower(env.GetID())] = env - nameLookup[strings.ToLower(env.GetName())] = env - } + lookup := newIdentifierLookup(allEnvs, + func(env *environments.Environment) string { return env.GetID() }, + func(env *environments.Environment) string { return env.GetName() }) result := make([]*environments.Environment, 0, len(environmentIdentifiers)) for _, identifier := range environmentIdentifiers { - key := strings.ToLower(identifier) - env, found := idLookup[key] - if !found { - env, found = nameLookup[key] - } + env, found := lookup.find(identifier) if !found { return nil, fmt.Errorf("cannot find an environment with the ID or name of '%s'", identifier) } @@ -79,6 +73,86 @@ func FindEnvironments(octopus *client.Client, environmentIdentifiers []string) ( return result, nil } +// ResolveEnvironmentNames maps environment names or IDs onto canonical environment names, because +// the executions API only matches environments by name. +// +// Ephemeral environments aren't part of the regular environment list, so that list is consulted - +// once, lazily - for any identifier the regular list doesn't have. Resolving one identifier at a +// time means a list mixing the two kinds still reports the identifier that actually went missing. +func ResolveEnvironmentNames(octopus *client.Client, space *spaces.Space, environmentIdentifiers []string) ([]string, error) { + if len(environmentIdentifiers) == 0 { + return nil, nil + } + allEnvs, err := octopus.Environments.GetAll() + if err != nil { + return nil, err + } + regular := newIdentifierLookup(allEnvs, + func(env *environments.Environment) string { return env.GetID() }, + func(env *environments.Environment) string { return env.GetName() }) + + var ephemeral *identifierLookup[*ephemeralenvironments.EphemeralEnvironment] + + names := make([]string, 0, len(environmentIdentifiers)) + for _, identifier := range environmentIdentifiers { + if env, found := regular.find(identifier); found { + names = append(names, env.GetName()) + continue + } + + if ephemeral == nil { + if space == nil { + return nil, fmt.Errorf("cannot find an environment with the ID or name of '%s'", identifier) + } + allEphemeral, ephemeralErr := ephemeralenvironments.GetAll(octopus, space.ID) + if ephemeralErr != nil { + // ephemeral environments are the rarer case, and the endpoint doesn't exist on + // every server; either way the identifier is genuinely not a regular environment + return nil, fmt.Errorf("cannot find an environment with the ID or name of '%s'", identifier) + } + lookup := newIdentifierLookup(allEphemeral.Items, + func(env *ephemeralenvironments.EphemeralEnvironment) string { return env.ID }, + func(env *ephemeralenvironments.EphemeralEnvironment) string { return env.Name }) + ephemeral = &lookup + } + + if env, found := ephemeral.find(identifier); found { + names = append(names, env.Name) + continue + } + return nil, fmt.Errorf("cannot find an environment with the ID or name of '%s'", identifier) + } + return names, nil +} + +// identifierLookup indexes items by both ID and name so an identifier can be matched against +// either, with an ID match winning when an item's name collides with another item's ID. +type identifierLookup[T any] struct { + byID map[string]T + byName map[string]T +} + +func newIdentifierLookup[T any](items []T, id func(T) string, name func(T) string) identifierLookup[T] { + lookup := identifierLookup[T]{ + byID: make(map[string]T, len(items)), + byName: make(map[string]T, len(items)), + } + for _, item := range items { + lookup.byID[strings.ToLower(id(item))] = item + lookup.byName[strings.ToLower(name(item))] = item + } + return lookup +} + +func (l identifierLookup[T]) find(identifier string) (T, bool) { + key := strings.ToLower(identifier) + if item, found := l.byID[key]; found { + return item, true + } + item, found := l.byName[key] + return item, found +} + func EnvironmentsMultiSelect(ask question.Asker, getAllEnvironmentsCallback GetAllEnvironmentsCallback, message string, required bool) ([]*environments.Environment, error) { allEnvs, err := getAllEnvironmentsCallback() if err != nil { diff --git a/pkg/question/selectors/find_test.go b/pkg/question/selectors/find_test.go index 83b208dd..daeded28 100644 --- a/pkg/question/selectors/find_test.go +++ b/pkg/question/selectors/find_test.go @@ -11,6 +11,7 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels" octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tenants" "github.com/stretchr/testify/assert" @@ -80,6 +81,62 @@ func TestFindEnvironments(t *testing.T) { } } +func TestResolveEnvironmentNames(t *testing.T) { + findSpace := fixtures.NewSpace(findSpaceID, "Default") + devEnvironment := fixtures.NewEnvironment(findSpaceID, "Environments-12", "dev") + ephemeralEnvironment := fixtures.NewEphemeralEnvironment(findSpaceID, "Environments-123", "Ephemeral Environment", "Environments-12") + + t.Run("resolves a mix of regular and ephemeral environments", func(t *testing.T) { + api := testutil.NewMockHttpServer() + receiver := beginRequest(api, func(octopus *octopusApiClient.Client) ([]string, error) { + return selectors.ResolveEnvironmentNames(octopus, findSpace, []string{"DEV", "Environments-123"}) + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/v2?skip=0&take=2147483647&type=Ephemeral").RespondWith(resources.Resources[*ephemeralenvironments.EphemeralEnvironment]{ + Items: []*ephemeralenvironments.EphemeralEnvironment{ephemeralEnvironment}, + }) + + result, err := testutil.ReceivePair(receiver) + assert.Nil(t, err) + assert.Equal(t, []string{"dev", "Ephemeral Environment"}, result) + }) + + t.Run("doesn't look at ephemeral environments when everything resolves", func(t *testing.T) { + api := testutil.NewMockHttpServer() + receiver := beginRequest(api, func(octopus *octopusApiClient.Client) ([]string, error) { + return selectors.ResolveEnvironmentNames(octopus, findSpace, []string{"Environments-12"}) + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment}) + + result, err := testutil.ReceivePair(receiver) + assert.Nil(t, err) + assert.Equal(t, []string{"dev"}, result) + }) + + t.Run("names the environment that is actually missing", func(t *testing.T) { + api := testutil.NewMockHttpServer() + receiver := beginRequest(api, func(octopus *octopusApiClient.Client) ([]string, error) { + return selectors.ResolveEnvironmentNames(octopus, findSpace, []string{"dev", "Environments-404"}) + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(findRootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/v2?skip=0&take=2147483647&type=Ephemeral").RespondWith(resources.Resources[*ephemeralenvironments.EphemeralEnvironment]{ + Items: []*ephemeralenvironments.EphemeralEnvironment{ephemeralEnvironment}, + }) + + _, err := testutil.ReceivePair(receiver) + assert.EqualError(t, err, "cannot find an environment with the ID or name of 'Environments-404'") + }) +} + func TestFindEnvironment(t *testing.T) { devEnvironment := fixtures.NewEnvironment(findSpaceID, "Environments-12", "dev") From c12edf907ba98d6d2ab8830c8a74a13dbb39f078 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Fri, 4 Sep 2026 14:23:46 +1000 Subject: [PATCH 16/18] fix: report the server's own error alongside the package diagnosis ea972e2 narrowed the diagnosis to failures carrying the server's null reference message, to stop an unrelated 5xx being reported as a package problem. That works, but it also switches the fix off on current servers: the #426 path there fails with "There are no viable release plans in any channels", not a null reference, so the message the server sends for this is version-dependent and can't be relied on as the trigger. Address the underlying complaint instead. MissingPackageVersionsError now prints what the server actually said, so a misattributed diagnosis costs the user a misleading paragraph rather than the real cause, which was previously reachable only via Unwrap and never printed (main.go prints err.Error() alone). With nothing hidden, the trigger widens back to any 5xx and keeps working across server versions. The null reference message itself is still suppressed from that output -- it says nothing the diagnosis doesn't say better -- so the integration test's guard against it resurfacing stays valid. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create.go | 19 +++++++------ pkg/cmd/release/create/create_test.go | 13 +++++++-- pkg/packages/packages.go | 13 +++++++++ pkg/packages/packages_test.go | 40 +++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 pkg/packages/packages_test.go diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 5c7786de..3d3be1bb 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -421,19 +421,17 @@ func BuildPackageVersionBaselineForChannel(octopus *octopusApiClient.Client, dep return result, nil } -// serverNullReferenceMessage is what an Octopus Server sends back when it hits an unhandled -// null reference exception; it carries no information about what actually went wrong. -const serverNullReferenceMessage = "Object reference not set to an instance of an object" - // DiagnoseCreateReleaseFailure replaces an opaque server-side failure with an actionable message where // it can. The server raises a null reference exception, surfaced as a bare 500, when it can't select a // version for a package; see https://github.com/OctopusDeploy/cli/issues/426 +// +// Any 5xx is diagnosed, not just the null reference one, because the message a server sends for this +// varies by version: current servers report "no viable release plans" instead. The cost of being wrong +// is bounded, since MissingPackageVersionsError reports what the server actually said alongside the +// diagnosis. func DiagnoseCreateReleaseFailure(octopus *octopusApiClient.Client, options *executor.TaskOptionsCreateRelease, cause error) error { - // only the specific null reference failure is worth diagnosing. Any other 5xx is a real server error - // that we must report as-is; replacing it would hide the cause, and re-querying the server would pile - // more requests onto something that is already failing. var apiError *core.APIError - if !errors.As(cause, &apiError) || apiError.StatusCode < 500 || !strings.Contains(apiError.ErrorMessage, serverNullReferenceMessage) { + if !errors.As(cause, &apiError) || apiError.StatusCode < 500 { return cause } @@ -444,7 +442,10 @@ func DiagnoseCreateReleaseFailure(octopus *octopusApiClient.Client, options *exe } } - return fmt.Errorf("%w\nthe server failed with an unhandled error; this usually means it could not resolve the packages, channel or git reference for the release", cause) + if strings.Contains(apiError.ErrorMessage, packages.ServerNullReferenceMessage) { + return fmt.Errorf("%w\nthe server failed with an unhandled error; this usually means it could not resolve the packages, channel or git reference for the release", cause) + } + return cause } // findPackagesWithoutVersions repeats the package version resolution the server does when it assembles a diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index e7fee464..77b85acc 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -2931,15 +2931,22 @@ func TestReleaseCreate_DiagnoseCreateReleaseFailure(t *testing.T) { assert.Equal(t, error(badRequest), create.DiagnoseCreateReleaseFailure(nil, nil, badRequest)) }) - t.Run("passes through server faults which aren't the null reference we know how to diagnose", func(t *testing.T) { - // an unrelated 5xx must be reported as-is; we mustn't replace it with a package diagnosis - // (nor go back to an already-failing server to run one) + t.Run("passes through server faults it cannot diagnose", func(t *testing.T) { + // a 5xx is only replaced when the CLI can positively name the packages behind it. With no + // client to go and look, and no null reference message to explain, the server error stands. serverError := &core.APIError{ErrorMessage: "The database is unavailable", StatusCode: http.StatusInternalServerError} assert.Equal(t, error(serverError), create.DiagnoseCreateReleaseFailure(nil, nil, serverError)) badGateway := &core.APIError{ErrorMessage: "Bad Gateway", StatusCode: http.StatusBadGateway} assert.Equal(t, error(badGateway), create.DiagnoseCreateReleaseFailure(nil, nil, badGateway)) }) + + t.Run("explains a bare null reference fault even when no packages are missing", func(t *testing.T) { + nullRef := &core.APIError{ErrorMessage: "Object reference not set to an instance of an object.", StatusCode: http.StatusInternalServerError} + err := create.DiagnoseCreateReleaseFailure(nil, nil, nullRef) + assert.ErrorIs(t, err, nullRef) + assert.Contains(t, err.Error(), "the server failed with an unhandled error") + }) } // issue #426: the server raises a null reference exception rather than telling us that a package diff --git a/pkg/packages/packages.go b/pkg/packages/packages.go index e32d0986..52f38eed 100644 --- a/pkg/packages/packages.go +++ b/pkg/packages/packages.go @@ -204,6 +204,11 @@ func FindPackagesWithoutVersions(templatePackages []releases.ReleaseTemplatePack return result } +// ServerNullReferenceMessage is what an Octopus Server sends back when it hits an unhandled null +// reference exception; it carries no information about what actually went wrong, so it is worth +// replacing rather than reporting. +const ServerNullReferenceMessage = "Object reference not set to an instance of an object" + // MissingPackageVersionsError is raised when one or more packages referenced by the deployment process // have no version available in their feed. The server can't assemble a release in this state; rather than // reporting that, it raises a null reference exception, so the CLI detects the situation itself. @@ -233,6 +238,14 @@ func (e *MissingPackageVersionsError) Error() string { sb.WriteString(fmt.Sprintf("\n - '%s' in step '%s' (feed '%s')", packageName, p.ActionName, feedName)) } sb.WriteString("\npush the package(s) to the feed, or supply a version with --package or --package-version") + // this diagnosis is inferred from a failure the server doesn't describe, so it can be wrong. + // Report what the server actually said too, unless that's the null reference message, which + // says nothing the lines above don't already say better. + if e.cause != nil { + if causeText := e.cause.Error(); !strings.Contains(causeText, ServerNullReferenceMessage) { + sb.WriteString(fmt.Sprintf("\nthe server reported: %s", causeText)) + } + } return sb.String() } diff --git a/pkg/packages/packages_test.go b/pkg/packages/packages_test.go new file mode 100644 index 00000000..c6e9a813 --- /dev/null +++ b/pkg/packages/packages_test.go @@ -0,0 +1,40 @@ +package packages_test + +import ( + "errors" + "testing" + + "github.com/OctopusDeploy/cli/pkg/packages" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" + "github.com/stretchr/testify/assert" +) + +func TestMissingPackageVersionsError_Error(t *testing.T) { + missing := []releases.ReleaseTemplatePackage{ + {PackageID: "acme.web", ActionName: "Deploy Web", FeedID: "feeds-builtin", FeedName: "Octopus Server (built-in)"}, + } + + t.Run("names the package, the step and the feed", func(t *testing.T) { + err := packages.NewMissingPackageVersionsError(missing, nil) + assert.Contains(t, err.Error(), "no version could be found for the following packages") + assert.Contains(t, err.Error(), "'acme.web' in step 'Deploy Web' (feed 'Octopus Server (built-in)')") + assert.Contains(t, err.Error(), "push the package(s) to the feed") + }) + + // the diagnosis is inferred from a failure the server doesn't describe, so if we guessed wrong + // the user still needs to be able to see what actually went wrong + t.Run("reports what the server said alongside the diagnosis", func(t *testing.T) { + cause := errors.New("There are no viable release plans in any channels") + err := packages.NewMissingPackageVersionsError(missing, cause) + assert.Contains(t, err.Error(), "the server reported: There are no viable release plans in any channels") + assert.ErrorIs(t, err, cause) + }) + + t.Run("omits the null reference message, which explains nothing", func(t *testing.T) { + cause := errors.New("Octopus API error: " + packages.ServerNullReferenceMessage + " []") + err := packages.NewMissingPackageVersionsError(missing, cause) + assert.NotContains(t, err.Error(), packages.ServerNullReferenceMessage) + assert.NotContains(t, err.Error(), "the server reported") + assert.ErrorIs(t, err, cause) // still unwrappable, just not printed + }) +} From 1e69f936e25ffc774dad7a0ab3e8773c84ec0fc4 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 19 Aug 2026 15:06:35 +1000 Subject: [PATCH 17/18] test: add integration tests for the tier 1 release fixes Covers behaviour that only a real server exercises: unknown release versions, packages with no version in their feed, channel and environment IDs on the executions API, and comma-separated deployment targets. Refs #294, #426, #250, #556 Co-Authored-By: Claude Opus 5 (1M context) --- test/integration/release_test.go | 299 +++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) diff --git a/test/integration/release_test.go b/test/integration/release_test.go index b6f476f3..5f439555 100644 --- a/test/integration/release_test.go +++ b/test/integration/release_test.go @@ -8,13 +8,19 @@ import ( octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/lifecycles" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/machines" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/packages" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/tasks" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "os/exec" "testing" + "time" ) const space1ID = "Spaces-1" @@ -256,3 +262,296 @@ func TestReleaseListAndDelete(t *testing.T) { // the error struct contains an error message, but the server can/will change this over time, and we don't particularly care about it; 404 statuscode is the important bit }) } + +func createEnvironment(t *testing.T, apiClient *octopusApiClient.Client, name string) *environments.Environment { + environment, err := apiClient.Environments.Add(environments.NewEnvironment(name)) + if !testutil.AssertSuccess(t, err) { + return nil + } + t.Cleanup(func() { assert.Nil(t, apiClient.Environments.DeleteByID(environment.GetID())) }) + return environment +} + +func createCloudRegionTarget(t *testing.T, apiClient *octopusApiClient.Client, name string, environmentID string) *machines.DeploymentTarget { + target, err := apiClient.Machines.Add(machines.NewDeploymentTarget(name, machines.NewCloudRegionEndpoint(), []string{environmentID}, []string{"deploy"})) + if !testutil.AssertSuccess(t, err) { + return nil + } + t.Cleanup(func() { assert.Nil(t, apiClient.Machines.DeleteByID(target.GetID())) }) + return target +} + +// allowDeploymentsTo replaces the fixture lifecycle's phases with a single phase for the +// given environment, so releases in the project can be deployed to it. +func allowDeploymentsTo(t *testing.T, apiClient *octopusApiClient.Client, lifecycle *lifecycles.Lifecycle, environmentID string) bool { + phase := lifecycles.NewPhase("phase1") + phase.OptionalDeploymentTargets = []string{environmentID} + lifecycle.Phases = []*lifecycles.Phase{phase} + updated, err := apiClient.Lifecycles.Update(lifecycle) + if !testutil.AssertSuccess(t, err) { + return false + } + t.Cleanup(func() { + updated.Phases = nil + _, err := apiClient.Lifecycles.Update(updated) + assert.Nil(t, err) + }) + return true +} + +// waitForTaskToComplete blocks until the deployment's server task finishes; the project cannot be +// deleted while it is still running. Whether it succeeded is not this test's concern. +func waitForTaskToComplete(t *testing.T, apiClient *octopusApiClient.Client, taskID string) { + deadline := time.Now().Add(2 * time.Minute) + for time.Now().Before(deadline) { + found, err := apiClient.Tasks.Get(tasks.TasksQuery{IDs: []string{taskID}}) + if !testutil.AssertSuccess(t, err) { + return + } + if len(found.Items) == 1 && found.Items[0].IsCompleted != nil && *found.Items[0].IsCompleted { + return + } + time.Sleep(2 * time.Second) + } + t.Errorf("timed out waiting for task %s to complete", taskID) +} + +// scriptStep builds a single inline script step. With no target roles it runs on the server, so +// the project is deployable without any deployment targets. +func scriptStep(name string, targetRoles string) *deployments.DeploymentStep { + stepProperties := map[string]core.PropertyValue{} + action := &deployments.DeploymentAction{ + ActionType: "Octopus.Script", + Name: name, + Properties: map[string]core.PropertyValue{ + "Octopus.Action.Script.ScriptBody": core.NewPropertyValue("echo 'hello'", false), + }, + } + if targetRoles != "" { + stepProperties["Octopus.Action.TargetRoles"] = core.NewPropertyValue(targetRoles, false) + } else { + action.Properties["Octopus.Action.RunOnServer"] = core.NewPropertyValue("true", false) + } + return &deployments.DeploymentStep{Name: name, Properties: stepProperties, Actions: []*deployments.DeploymentAction{action}} +} + +// packageStep builds a single package step. The server rejects a package on an inline script, +// so a release that needs a package version has to go through this step type. +func packageStep(name string, targetRoles string, packageID string) *deployments.DeploymentStep { + return &deployments.DeploymentStep{ + Name: name, + Properties: map[string]core.PropertyValue{"Octopus.Action.TargetRoles": core.NewPropertyValue(targetRoles, false)}, + Actions: []*deployments.DeploymentAction{ + { + ActionType: "Octopus.TentaclePackage", + Name: name, + Properties: map[string]core.PropertyValue{}, + Packages: []*packages.PackageReference{ + { + PackageID: packageID, + FeedID: "feeds-builtin", + AcquisitionLocation: "Server", + Properties: map[string]string{"SelectionMode": "immediate"}, + }, + }, + }, + }, + } +} + +func setDeploymentProcess(t *testing.T, apiClient *octopusApiClient.Client, project *projects.Project, step *deployments.DeploymentStep) bool { + deploymentProcess, err := apiClient.DeploymentProcesses.Get(project, "") + if !testutil.AssertSuccess(t, err) { + return false + } + deploymentProcess.Steps = []*deployments.DeploymentStep{step} + _, err = apiClient.DeploymentProcesses.Update(deploymentProcess) + return testutil.AssertSuccess(t, err) +} + +func onlyReleaseInProject(t *testing.T, apiClient *octopusApiClient.Client, project *projects.Project) *releases.Release { + projectReleases, err := apiClient.Projects.GetReleases(project) + if !testutil.AssertSuccess(t, err) { + return nil + } + require.Equal(t, 1, len(projectReleases)) + return projectReleases[0] +} + +func onlyDeploymentOfRelease(t *testing.T, apiClient *octopusApiClient.Client, release *releases.Release) *deployments.Deployment { + releaseDeployments, err := apiClient.Deployments.GetDeployments(release) + if !testutil.AssertSuccess(t, err) { + return nil + } + require.Equal(t, 1, len(releaseDeployments.Items)) + return releaseDeployments.Items[0] +} + +// The executions API reports an unknown release version poorly - as a null reference error on the +// servers in issue #294, and as a bare "was not found" on current ones - so the CLI resolves the +// version up front and says what it looked for. +func TestReleaseDeployUnknownVersion(t *testing.T) { + runId := uuid.New() + apiClient, err := integration.GetApiClient(space1ID) + testutil.RequireSuccess(t, err) + + fx, err := integration.CreateCommonProject(t, apiClient, runId) + testutil.RequireSuccess(t, err) + project := fx.Project + + environment := createEnvironment(t, apiClient, fmt.Sprintf("env-%s", runId)) + if environment == nil { + return + } + + t.Run("the API does not answer with a usable release", func(t *testing.T) { + release, err := releases.GetReleaseInProject(apiClient, space1ID, project.GetID(), "9.9.9") + assert.True(t, err != nil || release == nil || release.GetID() == "") + }) + + t.Run("deploy names the version it could not find", func(t *testing.T) { + _, stdErr, err := integration.RunCli(space1ID, "release", "deploy", "--project", project.Name, "--version", "9.9.9", "--environment", environment.Name) + assert.Error(t, err) + assert.Contains(t, stdErr, fmt.Sprintf("cannot find a release with version '9.9.9' in project '%s'", project.Name)) + assert.NotContains(t, stdErr, "Object reference not set") + }) + + t.Run("deploy reports that latest is not an alias", func(t *testing.T) { + _, stdErr, err := integration.RunCli(space1ID, "release", "deploy", "--project", project.Name, "--version", "latest", "--environment", environment.Name) + assert.Error(t, err) + assert.Contains(t, stdErr, "'latest' is not a supported alias") + assert.NotContains(t, stdErr, "Object reference not set") + }) +} + +// A package with no version in its feed fails the release with no indication of which package is +// at fault, so the CLI diagnoses the failure and names them. See issue #426. +func TestReleaseCreateMissingPackageVersion(t *testing.T) { + runId := uuid.New() + apiClient, err := integration.GetApiClient(space1ID) + testutil.RequireSuccess(t, err) + + fx, err := integration.CreateCommonProject(t, apiClient, runId) + testutil.RequireSuccess(t, err) + project := fx.Project + + stepName := fmt.Sprintf("step-%s", runId) + packageID := fmt.Sprintf("package-%s", runId) + if !setDeploymentProcess(t, apiClient, project, packageStep(stepName, "deploy", packageID)) { + return + } + + t.Run("create names the package that has no version", func(t *testing.T) { + _, stdErr, err := integration.RunCli(space1ID, "release", "create", "--project", project.Name) + assert.Error(t, err) + assert.Contains(t, stdErr, "no version could be found for the following packages") + assert.Contains(t, stdErr, packageID) + assert.Contains(t, stdErr, stepName) + assert.NotContains(t, stdErr, "Object reference not set") + }) +} + +// The executions API matches channels and environments by name only, so the CLI resolves IDs +// before sending them. See issue #250. +func TestReleaseCreateAndDeployByID(t *testing.T) { + runId := uuid.New() + apiClient, err := integration.GetApiClient(space1ID) + testutil.RequireSuccess(t, err) + + fx, err := integration.CreateCommonProject(t, apiClient, runId) + testutil.RequireSuccess(t, err) + project := fx.Project + + environment := createEnvironment(t, apiClient, fmt.Sprintf("env-%s", runId)) + if environment == nil { + return + } + if !allowDeploymentsTo(t, apiClient, fx.Lifecycle, environment.GetID()) { + return + } + if !setDeploymentProcess(t, apiClient, project, scriptStep(fmt.Sprintf("step-%s", runId), "")) { + return + } + t.Cleanup(func() { deleteAllReleasesInProject(t, apiClient, project) }) + + t.Run("create accepts a channel ID", func(t *testing.T) { + _, stdErr, err := integration.RunCli(space1ID, "release", "create", "--project", project.Name, "--channel", fx.ProjectDefaultChannel.GetID(), "--version", "1.0.0") + if !testutil.AssertSuccess(t, err, stdErr) { + return + } + release := onlyReleaseInProject(t, apiClient, project) + if release == nil { + return + } + assert.Equal(t, fx.ProjectDefaultChannel.GetID(), release.ChannelID) + }) + + t.Run("deploy accepts an environment ID", func(t *testing.T) { + _, stdErr, err := integration.RunCli(space1ID, "release", "deploy", "--project", project.Name, "--version", "1.0.0", "--environment", environment.GetID()) + if !testutil.AssertSuccess(t, err, stdErr) { + return + } + release := onlyReleaseInProject(t, apiClient, project) + if release == nil { + return + } + deployment := onlyDeploymentOfRelease(t, apiClient, release) + if deployment == nil { + return + } + assert.Equal(t, environment.GetID(), deployment.EnvironmentID) + waitForTaskToComplete(t, apiClient, deployment.TaskID) + }) +} + +// Comma-separated values are split before they reach the executions API, which otherwise reports +// the whole string as one unknown target. See issue #556. +func TestReleaseDeployCommaSeparatedTargets(t *testing.T) { + runId := uuid.New() + apiClient, err := integration.GetApiClient(space1ID) + testutil.RequireSuccess(t, err) + + fx, err := integration.CreateCommonProject(t, apiClient, runId) + testutil.RequireSuccess(t, err) + project := fx.Project + + environment := createEnvironment(t, apiClient, fmt.Sprintf("env-%s", runId)) + if environment == nil { + return + } + if !allowDeploymentsTo(t, apiClient, fx.Lifecycle, environment.GetID()) { + return + } + if !setDeploymentProcess(t, apiClient, project, scriptStep(fmt.Sprintf("step-%s", runId), "deploy")) { + return + } + + targetA := createCloudRegionTarget(t, apiClient, fmt.Sprintf("target-a-%s", runId), environment.GetID()) + targetB := createCloudRegionTarget(t, apiClient, fmt.Sprintf("target-b-%s", runId), environment.GetID()) + if targetA == nil || targetB == nil { + return + } + t.Cleanup(func() { deleteAllReleasesInProject(t, apiClient, project) }) + + _, stdErr, err := integration.RunCli(space1ID, "release", "create", "--project", project.Name, "--version", "1.0.0") + if !testutil.AssertSuccess(t, err, stdErr) { + return + } + + t.Run("deploy splits a comma-separated target list", func(t *testing.T) { + _, stdErr, err := integration.RunCli(space1ID, "release", "deploy", "--project", project.Name, "--version", "1.0.0", "--environment", environment.Name, "--deployment-target", fmt.Sprintf("%s,%s", targetA.Name, targetB.Name)) + if !testutil.AssertSuccess(t, err, stdErr) { + return + } + release := onlyReleaseInProject(t, apiClient, project) + if release == nil { + return + } + deployment := onlyDeploymentOfRelease(t, apiClient, release) + if deployment == nil { + return + } + assert.ElementsMatch(t, []string{targetA.GetID(), targetB.GetID()}, deployment.SpecificMachineIDs) + waitForTaskToComplete(t, apiClient, deployment.TaskID) + }) +} From 54072b60e4bdef74625bf1e9260f0036875a0b16 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Fri, 4 Sep 2026 14:46:43 +1000 Subject: [PATCH 18/18] test: reconcile the deploy and runbook expectations across the tier 1 fixes Each of the four fixes is green on its own branch, but merged they change each other's request sequences, and the unit tests that merged cleanly are the ones that break. Nothing here is a defect in an individual PR; it is ordinary merge fallout, recorded because whichever lands last will hit it. - #294 adds a release pre-flight lookup and #250 an environments/all lookup ahead of the deployment POST. The tests added by #556, and the --priority tests that arrived on main in #708, merged without conflict and went looking for the POST, finding a GET. For the tenanted comma test the result was a hang rather than a failure: MockHttpServer blocks waiting for a request the CLI no longer makes in that order. - #250's "specifying project, environment and tenant by ID" still expected the two post-deploy web-URL lookups that #294 drops; the pre-flight now supplies the release ID, so no request follows the POST. - #556's runbook comma test needed #250's environments/all lookup, which runbook run performs unconditionally. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/deploy/deploy_test.go | 25 +++++++++++++++++++------ pkg/cmd/runbook/run/run_test.go | 1 + 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index b0602b23..ce36d2e6 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -1847,6 +1847,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { // an account allowed to deploy but not to read releases must not be blocked by the pre-flight lookup api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0"). RespondWithStatus(403, "403 Forbidden", &core.APIError{ErrorMessage: "You do not have permission to perform this action."}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentUntenantedCommandV1](req.Request.Body) @@ -1934,6 +1935,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/"+cokeTenant.ID).RespondWith(cokeTenant) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") @@ -1958,12 +1960,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { }, }) - // now it's going to try and look up the project/version to generate the web URL - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithStatus(404, "NotFound", nil) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?partialName=Fire+Project").RespondWith(resources.Resources[*projects.Project]{ - Items: []*projects.Project{fireProject}, - }) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + // no further requests: the pre-flight lookup already gave us the release ID for the web URL _, err = testutil.ReceivePair(cmdReceiver) assert.Nil(t, err) @@ -2201,6 +2198,8 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentUntenantedCommandV1](req.Request.Body) @@ -2239,6 +2238,8 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentUntenantedCommandV1](req.Request.Body) @@ -2466,6 +2467,8 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentUntenantedCommandV1](req.Request.Body) @@ -2512,7 +2515,15 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + // the comma form must resolve each tenant individually, exactly as the repeated form does + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Coke").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Coke").RespondWith(resources.Resources[*tenants.Tenant]{Items: []*tenants.Tenant{cokeTenant}}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants/Pepsi").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/tenants?partialName=Pepsi").RespondWith(resources.Resources[*tenants.Tenant]{Items: []*tenants.Tenant{pepsiTenant}}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) @@ -2559,6 +2570,8 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentUntenantedCommandV1](req.Request.Body) diff --git a/pkg/cmd/runbook/run/run_test.go b/pkg/cmd/runbook/run/run_test.go index 50ebe4a0..dd2c8b42 100644 --- a/pkg/cmd/runbook/run/run_test.go +++ b/pkg/cmd/runbook/run/run_test.go @@ -425,6 +425,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) + api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{devEnvironment, testEnvironment}) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1") requestBody, err := testutil.ReadJson[runbooks.RunbookRunCommandV1](req.Request.Body)