From 415a13e20b11d0f2b4ff225a4de953aace728d67 Mon Sep 17 00:00:00 2001 From: Lourens de Jager <165963988+lourens-octopus@users.noreply.github.com> Date: Tue, 7 Oct 2025 09:23:21 +1300 Subject: [PATCH 1/5] Refactor client to no longer use resource but rather the environment ids --- go.mod | 2 +- go.sum | 4 + pkg/cmd/release/deploy/deploy.go | 139 ++++++++++++++------------ pkg/cmd/release/deploy/deploy_test.go | 17 +++- 4 files changed, 97 insertions(+), 65 deletions(-) diff --git a/go.mod b/go.mod index b63f7d8f..f1512931 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.82.0 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251006201940-df6027c5e647 github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index 2f468c58..79ff78e8 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,10 @@ github.com/OctopusDeploy/go-octodiff v1.0.0 h1:U+ORg6azniwwYo+O44giOw6TiD5USk8S4 github.com/OctopusDeploy/go-octodiff v1.0.0/go.mod h1:Mze0+EkOWTgTmi8++fyUc6r0aLZT7qD9gX+31t8MmIU= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.82.0 h1:4Pc2W74VKp7Qm0uV0Dv99QKqRWg8WriVikdZPBpIZgY= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.82.0/go.mod h1:J1UdIilp41MRuFl+5xZm88ywFqJGYCCqxqod+/ZH8ko= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251005222655-e09b9b4a67c4 h1:JKgbm6nIg5agURwR53yn17s6T6JNWGxGPkpibqldN1o= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251005222655-e09b9b4a67c4/go.mod h1:J1UdIilp41MRuFl+5xZm88ywFqJGYCCqxqod+/ZH8ko= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251006201940-df6027c5e647 h1:udkmpRbHDRbEc2MgSk/hjRGcI5NR23gjOa0OY2uzVho= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251006201940-df6027c5e647/go.mod h1:J1UdIilp41MRuFl+5xZm88ywFqJGYCCqxqod+/ZH8ko= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 83ecab03..9f35a6e1 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -4,13 +4,14 @@ import ( "encoding/json" "errors" "fmt" - "github.com/OctopusDeploy/cli/pkg/util/featuretoggle" - "golang.org/x/exp/maps" "io" "sort" "strings" "time" + "github.com/OctopusDeploy/cli/pkg/util/featuretoggle" + "golang.org/x/exp/maps" + "github.com/OctopusDeploy/cli/pkg/apiclient" "github.com/AlecAivazis/survey/v2" @@ -414,9 +415,10 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques // select release var selectedRelease *releases.Release + var selectedChannel *channels.Channel if options.ReleaseVersion == "" { // first we want to ask them to pick a channel just to narrow down the search space for releases (not sent to server) - selectedChannel, err := selectors.Channel(octopus, asker, stdout, "Select channel", selectedProject) + selectedChannel, err = selectors.Channel(octopus, asker, stdout, "Select channel", selectedProject) if err != nil { return err } @@ -429,6 +431,10 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques if err != nil { return err } + selectedChannel, err = channels.GetByID(octopus, space.ID, selectedRelease.ChannelID) + if err != nil { + return err + } _, _ = fmt.Fprintf(stdout, "Release %s\n", output.Cyan(selectedRelease.Version)) } options.ReleaseVersion = selectedRelease.Version @@ -439,7 +445,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques indicateMissingPackagesForReleaseFeatureToggleValue, err := featuretoggle.IsToggleEnabled(octopus, "indicate-missing-packages-for-release") if indicateMissingPackagesForReleaseFeatureToggleValue { - proceed := promptMissingPackages(octopus, stdout, asker, selectedRelease); + proceed := promptMissingPackages(octopus, stdout, asker, selectedRelease) if !proceed { return errors.New("aborting deployment creation as requested") } @@ -447,59 +453,64 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques // machine selection later on needs to refer back to the environments. // NOTE: this is allowed to remain nil; environments will get looked up later on if needed - var selectedEnvironments []*environments.Environment - if isTenanted { - var selectedEnvironment *environments.Environment - if len(options.Environments) == 0 { - deployableEnvironmentIDs, nextEnvironmentID, err := FindDeployableEnvironmentIDs(octopus, selectedRelease) - if err != nil { - return err - } - selectedEnvironment, err = selectDeploymentEnvironment(asker, octopus, deployableEnvironmentIDs, nextEnvironmentID) - if err != nil { - return err - } - options.Environments = []string{selectedEnvironment.Name} // executions api allows env names, so let's use these instead so they look nice in generated automationcmd - } else { - selectedEnvironment, err = selectors.FindEnvironment(octopus, options.Environments[0]) - if err != nil { - return err + var deploymentEnvironmentIds []string + if selectedChannel.Type == channels.ChannelTypeLifecycle { + var selectedEnvironments []*environments.Environment + if isTenanted { + var selectedEnvironment *environments.Environment + if len(options.Environments) == 0 { + deployableEnvironmentIDs, nextEnvironmentID, err := FindDeployableEnvironmentIDs(octopus, selectedRelease) + if err != nil { + return err + } + selectedEnvironment, err = selectDeploymentEnvironment(asker, octopus, deployableEnvironmentIDs, nextEnvironmentID) + if err != nil { + return err + } + options.Environments = []string{selectedEnvironment.Name} // executions api allows env names, so let's use these instead so they look nice in generated automationcmd + } else { + selectedEnvironment, err = selectors.FindEnvironment(octopus, options.Environments[0]) + if err != nil { + return err + } + _, _ = fmt.Fprintf(stdout, "Environment %s\n", output.Cyan(selectedEnvironment.Name)) } - _, _ = fmt.Fprintf(stdout, "Environment %s\n", output.Cyan(selectedEnvironment.Name)) - } - selectedEnvironments = []*environments.Environment{selectedEnvironment} + selectedEnvironments = []*environments.Environment{selectedEnvironment} + deploymentEnvironmentIds = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.ID }) - // ask for tenants and/or tags unless some were specified on the command line - if len(options.Tenants) == 0 && len(options.TenantTags) == 0 { - options.Tenants, options.TenantTags, err = executionscommon.AskTenantsAndTags(asker, octopus, selectedRelease.ProjectID, selectedEnvironments, true) + // ask for tenants and/or tags unless some were specified on the command line if len(options.Tenants) == 0 && len(options.TenantTags) == 0 { - return errors.New("no tenants or tags available; cannot deploy") - } - if err != nil { - return err - } - } else { - if len(options.Tenants) > 0 { - _, _ = fmt.Fprintf(stdout, "Tenants %s\n", output.Cyan(strings.Join(options.Tenants, ","))) - } - if len(options.TenantTags) > 0 { - _, _ = fmt.Fprintf(stdout, "Tenant Tags %s\n", output.Cyan(strings.Join(options.TenantTags, ","))) - } - } - } else { - if len(options.Environments) == 0 { - deployableEnvironmentIDs, nextEnvironmentID, err := FindDeployableEnvironmentIDs(octopus, selectedRelease) - if err != nil { - return err - } - selectedEnvironments, err = selectDeploymentEnvironments(asker, octopus, deployableEnvironmentIDs, nextEnvironmentID) - if err != nil { - return err + options.Tenants, options.TenantTags, err = executionscommon.AskTenantsAndTags(asker, octopus, selectedRelease.ProjectID, selectedEnvironments, true) + if len(options.Tenants) == 0 && len(options.TenantTags) == 0 { + return errors.New("no tenants or tags available; cannot deploy") + } + if err != nil { + return err + } + } else { + if len(options.Tenants) > 0 { + _, _ = fmt.Fprintf(stdout, "Tenants %s\n", output.Cyan(strings.Join(options.Tenants, ","))) + } + if len(options.TenantTags) > 0 { + _, _ = fmt.Fprintf(stdout, "Tenant Tags %s\n", output.Cyan(strings.Join(options.TenantTags, ","))) + } } - options.Environments = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.Name }) } else { - if len(options.Environments) > 0 { - _, _ = fmt.Fprintf(stdout, "Environments %s\n", output.Cyan(strings.Join(options.Environments, ","))) + if len(options.Environments) == 0 { + deployableEnvironmentIDs, nextEnvironmentID, err := FindDeployableEnvironmentIDs(octopus, selectedRelease) + if err != nil { + return err + } + selectedEnvironments, err = selectDeploymentEnvironments(asker, octopus, deployableEnvironmentIDs, nextEnvironmentID) + 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 len(options.Environments) > 0 { + _, _ = fmt.Fprintf(stdout, "Environments %s\n", output.Cyan(strings.Join(options.Environments, ","))) + } } } } @@ -509,17 +520,18 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques return err } - if len(selectedEnvironments) == 0 { // if the Q&A process earlier hasn't loaded environments already, we need to load them now - selectedEnvironments, err = executionscommon.FindEnvironments(octopus, options.Environments) + if len(deploymentEnvironmentIds) == 0 { // if the Q&A process earlier hasn't loaded environments already, we need to load them now + selectedEnvironments, err := executionscommon.FindEnvironments(octopus, options.Environments) if err != nil { return err } + deploymentEnvironmentIds = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.ID }) } var deploymentPreviewRequests []deployments.DeploymentPreviewRequest - for _, environment := range selectedEnvironments { + for _, environmentId := range deploymentEnvironmentIds { preview := deployments.DeploymentPreviewRequest{ - EnvironmentId: environment.ID, + EnvironmentId: environmentId, // We ignore the TenantId here as we're just using the deployments previews for prompted variables. // Tenant variables do not support prompted variables TenantId: "", @@ -632,13 +644,14 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques } if !isDeploymentTargetsSpecified { - if len(selectedEnvironments) == 0 { // if the Q&A process earlier hasn't loaded environments already, we need to load them now - selectedEnvironments, err = executionscommon.FindEnvironments(octopus, options.Environments) + if len(deploymentEnvironmentIds) == 0 { // if the Q&A process earlier hasn't loaded environments already, we need to load them now + selectedEnvironments, err := executionscommon.FindEnvironments(octopus, options.Environments) if err != nil { return err } + deploymentEnvironmentIds = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.ID }) } - options.DeploymentTargets, err = askDeploymentTargets(octopus, asker, space.ID, selectedRelease.ID, selectedEnvironments) + options.DeploymentTargets, err = askDeploymentTargets(octopus, asker, space.ID, selectedRelease.ID, deploymentEnvironmentIds) if err != nil { return err } @@ -656,12 +669,12 @@ func validateDeployment(isTenanted bool, environments []string) error { return nil } -func askDeploymentTargets(octopus *octopusApiClient.Client, asker question.Asker, spaceID string, releaseID string, selectedEnvironments []*environments.Environment) ([]string, error) { +func askDeploymentTargets(octopus *octopusApiClient.Client, asker question.Asker, spaceID string, releaseID string, deploymentEnvironmentIds []string) ([]string, error) { var results []string // this is what the portal does. Can we do it better? I don't know - for _, env := range selectedEnvironments { - preview, err := deployments.GetReleaseDeploymentPreview(octopus, spaceID, releaseID, env.ID, true) + for _, envID := range deploymentEnvironmentIds { + preview, err := deployments.GetReleaseDeploymentPreview(octopus, spaceID, releaseID, envID, true) if err != nil { return nil, err } @@ -762,7 +775,7 @@ func promptMissingPackages(octopus *octopusApiClient.Client, stdout io.Writer, a return true } - _, _ = fmt.Fprintf(stdout ,"Warning: The following packages are missing from the built-in feed for this release:\n") + _, _ = fmt.Fprintf(stdout, "Warning: The following packages are missing from the built-in feed for this release:\n") for _, p := range missingPackages { _, _ = fmt.Fprintf(stdout, " - %s (Version: %s)\n", p.ID, p.Version) } diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index 416d16c5..b7c9746c 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -5,11 +5,12 @@ import ( "context" "encoding/json" "fmt" - "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/configuration" "net/url" "testing" "time" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/configuration" + "github.com/AlecAivazis/survey/v2" surveyCore "github.com/AlecAivazis/survey/v2/core" "github.com/MakeNowJust/heredoc/v2" @@ -53,7 +54,9 @@ func TestDeployCreate_AskQuestions(t *testing.T) { space1 := fixtures.NewSpace(spaceID, "Default Space") defaultChannel := fixtures.NewChannel(spaceID, "Channels-1", "Fire Project Default Channel", fireProjectID) + defaultChannel.Type = channels.ChannelTypeLifecycle altChannel := fixtures.NewChannel(spaceID, "Channels-97", "Fire Project Alt Channel", fireProjectID) + altChannel.Type = channels.ChannelTypeLifecycle fireProject := fixtures.NewProject(spaceID, fireProjectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "deploymentprocess-"+fireProjectID) @@ -135,6 +138,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release.Version).RespondWith(release) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/"+altChannel.ID).RespondWith(altChannel) api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ FeatureToggles: []configuration.ConfiguredFeatureToggle{ { @@ -287,6 +291,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release19.Version).RespondWith(release19) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/"+altChannel.ID).RespondWith(altChannel) api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ FeatureToggles: []configuration.ConfiguredFeatureToggle{ { @@ -362,6 +367,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release20.Version).RespondWith(release20) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/"+altChannel.ID).RespondWith(altChannel) api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ FeatureToggles: []configuration.ConfiguredFeatureToggle{ { @@ -443,6 +449,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release20.Version).RespondWith(release20) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/"+altChannel.ID).RespondWith(altChannel) api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ FeatureToggles: []configuration.ConfiguredFeatureToggle{ { @@ -521,6 +528,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release19.Version).RespondWith(release19) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/"+altChannel.ID).RespondWith(altChannel) api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ FeatureToggles: []configuration.ConfiguredFeatureToggle{ { @@ -632,6 +640,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }).AnswerWith("Tenanted") api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release19.Version).RespondWith(release19) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/"+altChannel.ID).RespondWith(altChannel) api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ FeatureToggles: []configuration.ConfiguredFeatureToggle{ { @@ -741,6 +750,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }).AnswerWith("Untenanted") api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release19.Version).RespondWith(release19) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/"+altChannel.ID).RespondWith(altChannel) api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ FeatureToggles: []configuration.ConfiguredFeatureToggle{ { @@ -829,6 +839,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }).AnswerWith("Untenanted") api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release19.Version).RespondWith(release19) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/"+altChannel.ID).RespondWith(altChannel) api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ FeatureToggles: []configuration.ConfiguredFeatureToggle{ { @@ -1033,6 +1044,7 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release19.Version).RespondWith(release19) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/"+altChannel.ID).RespondWith(altChannel) api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ FeatureToggles: []configuration.ConfiguredFeatureToggle{ { @@ -1347,6 +1359,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { space1 := fixtures.NewSpace(spaceID, "Default Space") defaultChannel := fixtures.NewChannel(spaceID, "Channels-1", "Fire Project Default Channel", fireProjectID) + defaultChannel.Type = channels.ChannelTypeLifecycle fireProject := fixtures.NewProject(spaceID, fireProjectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "deploymentprocess-"+fireProjectID) // @@ -1794,6 +1807,7 @@ func TestDeployCreate_GenerationOfAutomationCommand_MasksSensitiveVariables(t *t space1 := fixtures.NewSpace(spaceID, "Default Space") defaultChannel := fixtures.NewChannel(spaceID, "Channels-1", "Fire Project Default Channel", fireProjectID) + defaultChannel.Type = channels.ChannelTypeLifecycle fireProject := fixtures.NewProject(spaceID, fireProjectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "deploymentprocess-"+fireProjectID) @@ -1859,6 +1873,7 @@ func TestDeployCreate_GenerationOfAutomationCommand_MasksSensitiveVariables(t *t }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release20.Version).RespondWith(release20) + api.ExpectRequest(t, "GET", "/api/Spaces-1/channels/"+defaultChannel.ID).RespondWith(defaultChannel) api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ FeatureToggles: []configuration.ConfiguredFeatureToggle{ { From 1279dc825b9eee057e8c690370d28e7ed15a8687 Mon Sep 17 00:00:00 2001 From: Lourens de Jager <165963988+lourens-octopus@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:42:28 +1300 Subject: [PATCH 2/5] Wip --- pkg/cmd/release/deploy/deploy.go | 147 +++++++++++++++++++------------ 1 file changed, 90 insertions(+), 57 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 9f35a6e1..872282a8 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -455,64 +455,14 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques // NOTE: this is allowed to remain nil; environments will get looked up later on if needed var deploymentEnvironmentIds []string if selectedChannel.Type == channels.ChannelTypeLifecycle { - var selectedEnvironments []*environments.Environment - if isTenanted { - var selectedEnvironment *environments.Environment - if len(options.Environments) == 0 { - deployableEnvironmentIDs, nextEnvironmentID, err := FindDeployableEnvironmentIDs(octopus, selectedRelease) - if err != nil { - return err - } - selectedEnvironment, err = selectDeploymentEnvironment(asker, octopus, deployableEnvironmentIDs, nextEnvironmentID) - if err != nil { - return err - } - options.Environments = []string{selectedEnvironment.Name} // executions api allows env names, so let's use these instead so they look nice in generated automationcmd - } else { - selectedEnvironment, err = selectors.FindEnvironment(octopus, options.Environments[0]) - if err != nil { - return err - } - _, _ = fmt.Fprintf(stdout, "Environment %s\n", output.Cyan(selectedEnvironment.Name)) - } - selectedEnvironments = []*environments.Environment{selectedEnvironment} - deploymentEnvironmentIds = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.ID }) - - // ask for tenants and/or tags unless some were specified on the command line - if len(options.Tenants) == 0 && len(options.TenantTags) == 0 { - options.Tenants, options.TenantTags, err = executionscommon.AskTenantsAndTags(asker, octopus, selectedRelease.ProjectID, selectedEnvironments, true) - if len(options.Tenants) == 0 && len(options.TenantTags) == 0 { - return errors.New("no tenants or tags available; cannot deploy") - } - if err != nil { - return err - } - } else { - if len(options.Tenants) > 0 { - _, _ = fmt.Fprintf(stdout, "Tenants %s\n", output.Cyan(strings.Join(options.Tenants, ","))) - } - if len(options.TenantTags) > 0 { - _, _ = fmt.Fprintf(stdout, "Tenant Tags %s\n", output.Cyan(strings.Join(options.TenantTags, ","))) - } - } - } else { - if len(options.Environments) == 0 { - deployableEnvironmentIDs, nextEnvironmentID, err := FindDeployableEnvironmentIDs(octopus, selectedRelease) - if err != nil { - return err - } - selectedEnvironments, err = selectDeploymentEnvironments(asker, octopus, deployableEnvironmentIDs, nextEnvironmentID) - 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 len(options.Environments) > 0 { - _, _ = fmt.Fprintf(stdout, "Environments %s\n", output.Cyan(strings.Join(options.Environments, ","))) - } - } + deploymentEnvironmentIds, err = selectDeploymentEnvironmentsForLifecycleChannel(octopus, stdout, asker, options, selectedRelease, isTenanted) + if err != nil { + return err } + } else if selectedChannel.Type == channels.ChannelTypeEphemeral { + deploymentEnvironmentIds = nil + } else { + return errors.New("invalid channel type: " + string(selectedChannel.Type)) } variableSet, err := variables.GetVariableSet(octopus, space.ID, selectedRelease.ProjectVariableSetSnapshotID) @@ -661,6 +611,89 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques return 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 []*environments.Environment + + if len(options.Environments) == 0 { + allEphemeralEnvironments, err := environments.GetAllEphemeralEnvironments(octopus, selectedRelease.SpaceID) + if err != nil { + return nil, err + } + deploymentEnvironmentTemplate, err := releases.GetReleaseDeploymentTemplate(octopus, selectedRelease.SpaceID, selectedRelease.ID) + if err != nil { + return nil, err + } + } + + return nil, nil +} + +func selectDeploymentEnvironmentsForLifecycleChannel(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsDeployRelease, selectedRelease *releases.Release, isTenanted bool) ([]string, error) { + var deploymentEnvironmentIds []string + var selectedEnvironments []*environments.Environment + var err error + + if isTenanted { + var selectedEnvironment *environments.Environment + if len(options.Environments) == 0 { + deployableEnvironmentIDs, nextEnvironmentID, err := FindDeployableEnvironmentIDs(octopus, selectedRelease) + if err != nil { + return nil, err + } + selectedEnvironment, err = selectDeploymentEnvironment(asker, octopus, deployableEnvironmentIDs, nextEnvironmentID) + if err != nil { + return nil, err + } + options.Environments = []string{selectedEnvironment.Name} // executions api allows env names, so let's use these instead so they look nice in generated automationcmd + } else { + selectedEnvironment, err = selectors.FindEnvironment(octopus, options.Environments[0]) + if err != nil { + return nil, err + } + _, _ = fmt.Fprintf(stdout, "Environment %s\n", output.Cyan(selectedEnvironment.Name)) + } + selectedEnvironments = []*environments.Environment{selectedEnvironment} + deploymentEnvironmentIds = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.ID }) + + // ask for tenants and/or tags unless some were specified on the command line + if len(options.Tenants) == 0 && len(options.TenantTags) == 0 { + options.Tenants, options.TenantTags, err = executionscommon.AskTenantsAndTags(asker, octopus, selectedRelease.ProjectID, selectedEnvironments, true) + if len(options.Tenants) == 0 && len(options.TenantTags) == 0 { + return nil, errors.New("no tenants or tags available; cannot deploy") + } + if err != nil { + return nil, err + } + } else { + if len(options.Tenants) > 0 { + _, _ = fmt.Fprintf(stdout, "Tenants %s\n", output.Cyan(strings.Join(options.Tenants, ","))) + } + if len(options.TenantTags) > 0 { + _, _ = fmt.Fprintf(stdout, "Tenant Tags %s\n", output.Cyan(strings.Join(options.TenantTags, ","))) + } + } + } else { + if len(options.Environments) == 0 { + deployableEnvironmentIDs, nextEnvironmentID, err := FindDeployableEnvironmentIDs(octopus, selectedRelease) + if err != nil { + return nil, err + } + selectedEnvironments, err = selectDeploymentEnvironments(asker, octopus, deployableEnvironmentIDs, nextEnvironmentID) + if err != nil { + return nil, 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 len(options.Environments) > 0 { + _, _ = fmt.Fprintf(stdout, "Environments %s\n", output.Cyan(strings.Join(options.Environments, ","))) + } + } + } + return deploymentEnvironmentIds, nil +} + func validateDeployment(isTenanted bool, environments []string) error { if isTenanted && len(environments) > 1 { return fmt.Errorf("tenanted deployments can only specify one environment") From cf4fa89c760969946ab3696004d42e4d85e4cab6 Mon Sep 17 00:00:00 2001 From: Lourens de Jager <165963988+lourens-octopus@users.noreply.github.com> Date: Tue, 7 Oct 2025 11:06:00 +1300 Subject: [PATCH 3/5] Wip --- pkg/cmd/release/deploy/deploy.go | 56 ++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 872282a8..448d2b0b 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -10,6 +10,7 @@ import ( "time" "github.com/OctopusDeploy/cli/pkg/util/featuretoggle" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/ephemeralenvironments" "golang.org/x/exp/maps" "github.com/OctopusDeploy/cli/pkg/apiclient" @@ -460,7 +461,10 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques return err } } else if selectedChannel.Type == channels.ChannelTypeEphemeral { - deploymentEnvironmentIds = nil + deploymentEnvironmentIds, err = selectDeploymentEnvironmentsForEphemeralChannel(octopus, stdout, asker, options, selectedRelease) + if err != nil { + return err + } } else { return errors.New("invalid channel type: " + string(selectedChannel.Type)) } @@ -613,7 +617,6 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques func selectDeploymentEnvironmentsForEphemeralChannel(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsDeployRelease, selectedRelease *releases.Release) ([]string, error) { var deploymentEnvironmentIds []string - var selectedEnvironments []*environments.Environment if len(options.Environments) == 0 { allEphemeralEnvironments, err := environments.GetAllEphemeralEnvironments(octopus, selectedRelease.SpaceID) @@ -624,9 +627,56 @@ func selectDeploymentEnvironmentsForEphemeralChannel(octopus *octopusApiClient.C if err != nil { return nil, err } + + allowedEnvironmentIds := map[string]bool{} + for _, p := range deploymentEnvironmentTemplate.PromoteTo { + allowedEnvironmentIds[p.ID] = true + } + + var promotableEnvironments []*ephemeralenvironments.EphemeralEnvironment + for _, env := range allEphemeralEnvironments.Items { + if _, ok := allowedEnvironmentIds[env.ID]; ok { + deploymentEnvironmentIds = append(deploymentEnvironmentIds, env.ID) + } + } + if len(deploymentEnvironmentIds) == 0 { + return nil, errors.New("no promotable environments found for this release") + } + + // Print all promotable environments + _, _ = fmt.Fprintf(stdout, "Promotable environments:\n") + for _, env := range allEphemeralEnvironments.Items { + if _, ok := allowedEnvironmentIds[env.ID]; ok { + prefix := " " + if util.SliceContains(deploymentEnvironmentIds, env.ID) { + prefix = " * " + } + _, _ = fmt.Fprintf(stdout, "%s%s\n", prefix, output.Cyan(env.Name)) + } + } + _, _ = fmt.Fprintln(stdout, "") + + optionMap, optionsForMap := question.MakeItemMapAndOptions(promotableEnvironments, func(e *ephemeralenvironments.EphemeralEnvironment) string { return e.Name }) + var selectedKeys []string + err = asker(&survey.MultiSelect{ + Message: "Select environment(s)", + Options: optionsForMap, + }, &selectedKeys, survey.WithValidator(survey.Required)) + + if err != nil { + return nil, err + } + var selectedValues []*ephemeralenvironments.EphemeralEnvironment + for _, k := range selectedKeys { + if value, ok := optionMap[k]; ok { + selectedValues = append(selectedValues, value) + } // if we were to somehow get invalid answers, ignore them + } + deploymentEnvironmentIds = util.SliceTransform(selectedValues, func(env *ephemeralenvironments.EphemeralEnvironment) string { return env.ID }) + options.Environments = util.SliceTransform(selectedValues, func(env *ephemeralenvironments.EphemeralEnvironment) string { return env.Name }) } - return nil, nil + return deploymentEnvironmentIds, nil } func selectDeploymentEnvironmentsForLifecycleChannel(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsDeployRelease, selectedRelease *releases.Release, isTenanted bool) ([]string, error) { From c24e7f6b93188a112b2cd14406d72cf4560765a2 Mon Sep 17 00:00:00 2001 From: Lourens de Jager <165963988+lourens-octopus@users.noreply.github.com> Date: Tue, 7 Oct 2025 11:20:41 +1300 Subject: [PATCH 4/5] Working display of ephemeral environments --- pkg/cmd/release/deploy/deploy.go | 65 +++++++++++++++----------------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 448d2b0b..25f3283d 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -617,6 +617,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques 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 if len(options.Environments) == 0 { allEphemeralEnvironments, err := environments.GetAllEphemeralEnvironments(octopus, selectedRelease.SpaceID) @@ -633,47 +634,21 @@ func selectDeploymentEnvironmentsForEphemeralChannel(octopus *octopusApiClient.C allowedEnvironmentIds[p.ID] = true } - var promotableEnvironments []*ephemeralenvironments.EphemeralEnvironment + var availableEnvironments []*ephemeralenvironments.EphemeralEnvironment for _, env := range allEphemeralEnvironments.Items { if _, ok := allowedEnvironmentIds[env.ID]; ok { - deploymentEnvironmentIds = append(deploymentEnvironmentIds, env.ID) + availableEnvironments = append(availableEnvironments, env) } } - if len(deploymentEnvironmentIds) == 0 { - return nil, errors.New("no promotable environments found for this release") - } - // Print all promotable environments - _, _ = fmt.Fprintf(stdout, "Promotable environments:\n") - for _, env := range allEphemeralEnvironments.Items { - if _, ok := allowedEnvironmentIds[env.ID]; ok { - prefix := " " - if util.SliceContains(deploymentEnvironmentIds, env.ID) { - prefix = " * " - } - _, _ = fmt.Fprintf(stdout, "%s%s\n", prefix, output.Cyan(env.Name)) + if len(availableEnvironments) > 0 { + selectedEnvironments, err = selectEphemeralDeploymentEnvironments(asker, availableEnvironments) + if err != nil { + return nil, 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 }) } - _, _ = fmt.Fprintln(stdout, "") - - optionMap, optionsForMap := question.MakeItemMapAndOptions(promotableEnvironments, func(e *ephemeralenvironments.EphemeralEnvironment) string { return e.Name }) - var selectedKeys []string - err = asker(&survey.MultiSelect{ - Message: "Select environment(s)", - Options: optionsForMap, - }, &selectedKeys, survey.WithValidator(survey.Required)) - - if err != nil { - return nil, err - } - var selectedValues []*ephemeralenvironments.EphemeralEnvironment - for _, k := range selectedKeys { - if value, ok := optionMap[k]; ok { - selectedValues = append(selectedValues, value) - } // if we were to somehow get invalid answers, ignore them - } - deploymentEnvironmentIds = util.SliceTransform(selectedValues, func(env *ephemeralenvironments.EphemeralEnvironment) string { return env.ID }) - options.Environments = util.SliceTransform(selectedValues, func(env *ephemeralenvironments.EphemeralEnvironment) string { return env.Name }) } return deploymentEnvironmentIds, nil @@ -953,6 +928,28 @@ func selectDeploymentEnvironment(asker question.Asker, octopus *octopusApiClient return selectedValue, nil } +func selectEphemeralDeploymentEnvironments(asker question.Asker, deployableEnvironments []*ephemeralenvironments.EphemeralEnvironment) ([]*ephemeralenvironments.EphemeralEnvironment, error) { + var err error + optionMap, options := question.MakeItemMapAndOptions(deployableEnvironments, func(e *ephemeralenvironments.EphemeralEnvironment) string { return e.Name }) + var selectedKeys []string + err = asker(&survey.MultiSelect{ + Message: "Select environment(s)", + Options: options, + Default: nil, + }, &selectedKeys, survey.WithValidator(survey.Required)) + + if err != nil { + return nil, err + } + var selectedValues []*ephemeralenvironments.EphemeralEnvironment + for _, k := range selectedKeys { + if value, ok := optionMap[k]; ok { + selectedValues = append(selectedValues, value) + } // if we were to somehow get invalid answers, ignore them + } + return selectedValues, nil +} + func selectDeploymentEnvironments(asker question.Asker, octopus *octopusApiClient.Client, deployableEnvironmentIDs []string, nextDeployEnvironmentID string) ([]*environments.Environment, error) { allEnvs, nextDeployEnvironmentName, err := loadEnvironmentsForDeploy(octopus, deployableEnvironmentIDs, nextDeployEnvironmentID) if err != nil { From 65001fe383d1a9e0073cdba57331522fe203003e Mon Sep 17 00:00:00 2001 From: Lourens de Jager <165963988+lourens-octopus@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:12:54 +1300 Subject: [PATCH 5/5] WIP --- go.mod | 4 ++-- go.sum | 8 ++++++++ pkg/cmd/release/deploy/deploy.go | 8 ++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index f1512931..9e638d9e 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/OctopusDeploy/cli -go 1.23.0 +go 1.23.12 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251006201940-df6027c5e647 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251102214655-01f4f4d03e21 github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index 79ff78e8..600ce4f7 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,14 @@ github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251005222655-e09b9b4a67 github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251005222655-e09b9b4a67c4/go.mod h1:J1UdIilp41MRuFl+5xZm88ywFqJGYCCqxqod+/ZH8ko= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251006201940-df6027c5e647 h1:udkmpRbHDRbEc2MgSk/hjRGcI5NR23gjOa0OY2uzVho= github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251006201940-df6027c5e647/go.mod h1:J1UdIilp41MRuFl+5xZm88ywFqJGYCCqxqod+/ZH8ko= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251007215726-40eaae230d06 h1:bqXBpfMCzq/MrTxN1ggK0Int8cfcgEC6U3gLUPhaT7Q= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251007215726-40eaae230d06/go.mod h1:J1UdIilp41MRuFl+5xZm88ywFqJGYCCqxqod+/ZH8ko= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251007215804-e52970e73fe3 h1:rr1wzIXs8gzpuYsSsmFZ2xtdckYhPoMBLjtgw7Y4Ku4= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.84.2-0.20251007215804-e52970e73fe3/go.mod h1:J1UdIilp41MRuFl+5xZm88ywFqJGYCCqxqod+/ZH8ko= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251030234213-1459264e80fa h1:D0no62DnqcuS/OOQWD50dmoGEWhHshqEj+esUR9qoAA= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251030234213-1459264e80fa/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251102214655-01f4f4d03e21 h1:8YVMZEu/U8sLWuI1M76e1esEr9yz4ctK379o4O8oH2M= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.85.2-0.20251102214655-01f4f4d03e21/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 25f3283d..37c99ec4 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -10,7 +10,7 @@ import ( "time" "github.com/OctopusDeploy/cli/pkg/util/featuretoggle" - "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/ephemeralenvironments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments" "golang.org/x/exp/maps" "github.com/OctopusDeploy/cli/pkg/apiclient" @@ -620,10 +620,14 @@ func selectDeploymentEnvironmentsForEphemeralChannel(octopus *octopusApiClient.C var selectedEnvironments []*ephemeralenvironments.EphemeralEnvironment if len(options.Environments) == 0 { - allEphemeralEnvironments, err := environments.GetAllEphemeralEnvironments(octopus, selectedRelease.SpaceID) + allEphemeralEnvironments, err := ephemeralenvironments.GetAll(octopus, selectedRelease.SpaceID) if err != nil { return nil, err } + if allEphemeralEnvironments == nil || allEphemeralEnvironments.TotalResults == 0 { + return nil, errors.New("no ephemeral environments exist to deploy to") + } + deploymentEnvironmentTemplate, err := releases.GetReleaseDeploymentTemplate(octopus, selectedRelease.SpaceID, selectedRelease.ID) if err != nil { return nil, err