Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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.82.0
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
Expand Down
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ 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/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=
Expand Down
231 changes: 164 additions & 67 deletions pkg/cmd/release/deploy/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ 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"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments/v2/ephemeralenvironments"
"golang.org/x/exp/maps"

"github.com/OctopusDeploy/cli/pkg/apiclient"

"github.com/AlecAivazis/survey/v2"
Expand Down Expand Up @@ -414,9 +416,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
}
Expand All @@ -429,6 +432,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
Expand All @@ -439,87 +446,46 @@ 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")
}
}

// 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
}
_, _ = fmt.Fprintf(stdout, "Environment %s\n", output.Cyan(selectedEnvironment.Name))
var deploymentEnvironmentIds []string
if selectedChannel.Type == channels.ChannelTypeLifecycle {
deploymentEnvironmentIds, err = selectDeploymentEnvironmentsForLifecycleChannel(octopus, stdout, asker, options, selectedRelease, isTenanted)
if err != nil {
return err
}
selectedEnvironments = []*environments.Environment{selectedEnvironment}

// 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 selectedChannel.Type == channels.ChannelTypeEphemeral {
deploymentEnvironmentIds, err = selectDeploymentEnvironmentsForEphemeralChannel(octopus, stdout, asker, options, selectedRelease)
if err != nil {
return err
}
} 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.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 errors.New("invalid channel type: " + string(selectedChannel.Type))
}

variableSet, err := variables.GetVariableSet(octopus, space.ID, selectedRelease.ProjectVariableSetSnapshotID)
if err != nil {
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: "",
Expand Down Expand Up @@ -632,13 +598,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
}
Expand All @@ -648,6 +615,114 @@ 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 []*ephemeralenvironments.EphemeralEnvironment

if len(options.Environments) == 0 {
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
}

allowedEnvironmentIds := map[string]bool{}
for _, p := range deploymentEnvironmentTemplate.PromoteTo {
allowedEnvironmentIds[p.ID] = true
}

var availableEnvironments []*ephemeralenvironments.EphemeralEnvironment
for _, env := range allEphemeralEnvironments.Items {
if _, ok := allowedEnvironmentIds[env.ID]; ok {
availableEnvironments = append(availableEnvironments, env)
}
}

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 })
}
}

return deploymentEnvironmentIds, 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")
Expand All @@ -656,12 +731,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
}
Expand Down Expand Up @@ -762,7 +837,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)
}
Expand Down Expand Up @@ -857,6 +932,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 {
Expand Down
Loading