From a37a27dfc8e08a43e8625d9b89807f3fd3384847 Mon Sep 17 00:00:00 2001 From: Chen Keinan Date: Sun, 15 Feb 2026 16:56:24 +0200 Subject: [PATCH 1/4] feat: release promote support Signed-off-by: Chen Keinan --- go.mod | 2 + go.sum | 4 +- pkg/cmd/release/promote/promote.go | 1136 ++++++++++++++++++++++++++++ pkg/cmd/release/release.go | 2 + pkg/executor/executor.go | 5 + pkg/executor/release.go | 23 +- 6 files changed, 1169 insertions(+), 3 deletions(-) create mode 100644 pkg/cmd/release/promote/promote.go diff --git a/go.mod b/go.mod index 2a678ee6..73aadcbf 100644 --- a/go.mod +++ b/go.mod @@ -61,3 +61,5 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/OctopusDeploy/go-octopusdeploy/v2 => github.com/chen-keinan/go-octopusdeploy/v2 v2.0.0-20260215141226-f0761d37b8a3 diff --git a/go.sum b/go.sum index 22d760ab..b56f1394 100644 --- a/go.sum +++ b/go.sum @@ -46,13 +46,13 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/OctopusDeploy/go-octodiff v1.0.0 h1:U+ORg6azniwwYo+O44giOw6TiD5USk8S4VDhOQ0Ven0= github.com/OctopusDeploy/go-octodiff v1.0.0/go.mod h1:Mze0+EkOWTgTmi8++fyUc6r0aLZT7qD9gX+31t8MmIU= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.99.0 h1:0HzgNBPiOGY7ekP+uoRbX1DeMs0Y2JpJ3ecmUxFtC1o= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.99.0/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= github.com/briandowns/spinner v1.19.0/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chen-keinan/go-octopusdeploy/v2 v2.0.0-20260215141226-f0761d37b8a3 h1:M0JgP8bc/Q4/BO1jExusi3Mr9h9W037TB/Qby+lYZ6Q= +github.com/chen-keinan/go-octopusdeploy/v2 v2.0.0-20260215141226-f0761d37b8a3/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= diff --git a/pkg/cmd/release/promote/promote.go b/pkg/cmd/release/promote/promote.go new file mode 100644 index 00000000..3d54a10f --- /dev/null +++ b/pkg/cmd/release/promote/promote.go @@ -0,0 +1,1136 @@ +package promote + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strings" + "time" + + cmdDeploy "github.com/OctopusDeploy/cli/pkg/cmd/release/deploy" + "github.com/OctopusDeploy/cli/pkg/executor" + "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" + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/constants" + cliErrors "github.com/OctopusDeploy/cli/pkg/errors" + "github.com/OctopusDeploy/cli/pkg/executionscommon" + + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "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" + "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/dashboard" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + env "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + proj "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/variables" + "github.com/spf13/cobra" +) + +const ( + FlagSourceEnvironment = "source-env" + FlagAliasSourceEnvToLegacy = "from" + + FlagLatestSuccessful = "latest-successful" + FlagAliasLatestSuccessfulLegacy = "latestSuccessful" +) + +// PromoteFlags embeds DeployFlags to reuse all common flags and adds promote-specific flags +type PromoteFlags struct { + *cmdDeploy.DeployFlags + SourceEnvironment *flag.Flag[string] + LatestSuccessful *flag.Flag[bool] +} + +func NewPromoteFlags() *PromoteFlags { + return &PromoteFlags{ + DeployFlags: cmdDeploy.NewDeployFlags(), + SourceEnvironment: flag.New[string](FlagSourceEnvironment, false), + LatestSuccessful: flag.New[bool](FlagLatestSuccessful, false), + } +} + +func NewCmdPromote(f factory.Factory) *cobra.Command { + promoteFlags := NewPromoteFlags() + cmd := &cobra.Command{ + Use: "promote", + Short: "Promote release", + Long: "Promote release in Octopus Deploy", + Example: heredoc.Docf(` + $ %[1]s release promote # fully interactive + $ %[1]s release promote -p MyProject --version 1.0 --source-env Dev -e Staging --environment Production --skip InstallStep --variable VarName:VarValue + $ %[1]s release promote -p MyProject --version 1.0 --source-env Dev -e Staging --environment Production --force-package-download --guided-failure true --latest-successful + $ %[1]s release promote -p MyProject --version 1.0 --source-env Dev -e Staging --environment Production --force-package-download --guided-failure true --latest-successful --update-variables + $ %[1]s release promote -p MyProject --version 1.0 --source-env Dev -e Staging --environment Production --force-package-download --guided-failure true --latest-successful --update-variables + `, constants.ExecutableName), + + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && promoteFlags.Project.Value == "" { + promoteFlags.Project.Value = args[0] + } + + return promoteRun(cmd, f, promoteFlags) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&promoteFlags.Project.Value, promoteFlags.Project.Name, "p", "", "Name or ID of the project to promote the release from") + flags.StringArrayVarP(&promoteFlags.Environments.Value, promoteFlags.Environments.Name, "e", nil, "Promote to this environment (can be specified multiple times)") + flags.StringArrayVarP(&promoteFlags.Tenants.Value, promoteFlags.Tenants.Name, "", nil, "Promote to this tenant (can be specified multiple times)") + flags.StringArrayVarP(&promoteFlags.TenantTags.Value, promoteFlags.TenantTags.Name, "", nil, "Promote to tenants matching this tag (can be specified multiple times). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") + flags.StringVarP(&promoteFlags.DeployAt.Value, promoteFlags.DeployAt.Name, "", "", "Deploy at a later time. Deploy now if omitted. TODO date formats and timezones!") + flags.StringVarP(&promoteFlags.MaxQueueTime.Value, promoteFlags.MaxQueueTime.Name, "", "", "Cancel the deployment if it hasn't started within this time period.") + flags.StringArrayVarP(&promoteFlags.Variables.Value, promoteFlags.Variables.Name, "v", nil, "Set the value for a prompted variable in the format Label:Value") + flags.BoolVarP(&promoteFlags.UpdateVariables.Value, promoteFlags.UpdateVariables.Name, "", false, "Overwrite the variable snapshot for the release by re-importing the variables from the project.") + flags.StringArrayVarP(&promoteFlags.ExcludedSteps.Value, promoteFlags.ExcludedSteps.Name, "", nil, "Exclude specific steps from the deployment") + flags.StringVarP(&promoteFlags.GuidedFailureMode.Value, promoteFlags.GuidedFailureMode.Name, "", "", "Enable Guided failure mode (true/false/default)") + flags.BoolVarP(&promoteFlags.ForcePackageDownload.Value, promoteFlags.ForcePackageDownload.Name, "", false, "Force re-download of packages") + flags.StringArrayVarP(&promoteFlags.DeploymentTargets.Value, promoteFlags.DeploymentTargets.Name, "", nil, "Deploy to this target (can be specified multiple times)") + flags.StringArrayVarP(&promoteFlags.ExcludeTargets.Value, promoteFlags.ExcludeTargets.Name, "", nil, "Deploy to targets except for this (can be specified multiple times)") + flags.StringArrayVarP(&promoteFlags.DeploymentFreezeNames.Value, promoteFlags.DeploymentFreezeNames.Name, "", nil, "Override this deployment freeze (can be specified multiple times)") + flags.StringVarP(&promoteFlags.DeploymentFreezeOverrideReason.Value, promoteFlags.DeploymentFreezeOverrideReason.Name, "", "", "Reason for overriding a deployment freeze") + + // Promote-specific flags + flags.StringVar(&promoteFlags.SourceEnvironment.Value, promoteFlags.SourceEnvironment.Name, "", "Source environment to promote from") + flags.BoolVarP(&promoteFlags.LatestSuccessful.Value, promoteFlags.LatestSuccessful.Name, "", false, "Use the latest successful release to promote.") + + flags.SortFlags = false + + // flags aliases for compat with old .NET CLI - reuse deploy's aliases + flagAliases := make(map[string][]string, 10) + util.AddFlagAliasesString(flags, FlagSourceEnvironment, flagAliases, FlagAliasSourceEnvToLegacy) + util.AddFlagAliasesStringSlice(flags, cmdDeploy.FlagEnvironment, flagAliases, cmdDeploy.FlagAliasDeployToLegacy, cmdDeploy.FlagAliasEnv) + util.AddFlagAliasesStringSlice(flags, cmdDeploy.FlagTenantTag, flagAliases, cmdDeploy.FlagAliasTag, cmdDeploy.FlagAliasTenantTagLegacy) + util.AddFlagAliasesString(flags, cmdDeploy.FlagDeployAt, flagAliases, cmdDeploy.FlagAliasWhen, cmdDeploy.FlagAliasDeployAtLegacy) + util.AddFlagAliasesString(flags, cmdDeploy.FlagDeployAtExpiry, flagAliases, cmdDeploy.FlagDeployAtExpire, cmdDeploy.FlagAliasNoDeployAfterLegacy) + util.AddFlagAliasesString(flags, cmdDeploy.FlagUpdateVariables, flagAliases, cmdDeploy.FlagAliasUpdateVariablesLegacy) + util.AddFlagAliasesBool(flags, FlagLatestSuccessful, flagAliases, FlagAliasLatestSuccessfulLegacy) + util.AddFlagAliasesString(flags, cmdDeploy.FlagGuidedFailure, flagAliases, cmdDeploy.FlagAliasGuidedFailureMode, cmdDeploy.FlagAliasGuidedFailureModeLegacy) + util.AddFlagAliasesBool(flags, cmdDeploy.FlagForcePackageDownload, flagAliases, cmdDeploy.FlagAliasForcePackageDownloadLegacy) + util.AddFlagAliasesStringSlice(flags, cmdDeploy.FlagDeploymentTarget, flagAliases, cmdDeploy.FlagAliasTarget, cmdDeploy.FlagAliasSpecificMachines) + util.AddFlagAliasesStringSlice(flags, cmdDeploy.FlagExcludeDeploymentTarget, flagAliases, cmdDeploy.FlagAliasExcludeTarget, cmdDeploy.FlagAliasExcludeMachines) + cmd.PreRunE = func(cmd *cobra.Command, _ []string) error { + util.ApplyFlagAliases(cmd.Flags(), flagAliases) + return nil + } + return cmd +} + +func promoteRun(cmd *cobra.Command, f factory.Factory, flags *PromoteFlags) error { + outputFormat, err := cmd.Flags().GetString(constants.FlagOutputFormat) + if err != nil { // should never happen, but fallback if it does + outputFormat = constants.OutputFormatTable + } + + octopus, err := f.GetSpacedClient(apiclient.NewRequester(cmd)) + if err != nil { + return err + } + + parsedVariables, err := executionscommon.ParseVariableStringArray(flags.Variables.Value) + if err != nil { + return err + } + + options := &executor.TaskOptionsPromoteRelease{ + TaskOptionsDeployRelease: executor.TaskOptionsDeployRelease{ + ProjectName: flags.Project.Value, + ReleaseVersion: flags.ReleaseVersion.Value, + Environments: flags.Environments.Value, + Tenants: flags.Tenants.Value, + TenantTags: flags.TenantTags.Value, + ScheduledStartTime: flags.DeployAt.Value, + ScheduledExpiryTime: flags.MaxQueueTime.Value, + ExcludedSteps: flags.ExcludedSteps.Value, + GuidedFailureMode: flags.GuidedFailureMode.Value, + ForcePackageDownload: flags.ForcePackageDownload.Value, + DeploymentTargets: flags.DeploymentTargets.Value, + ExcludeTargets: flags.ExcludeTargets.Value, + DeploymentFreezeNames: flags.DeploymentFreezeNames.Value, + DeploymentFreezeOverrideReason: flags.DeploymentFreezeOverrideReason.Value, + Variables: parsedVariables, + UpdateVariables: flags.UpdateVariables.Value, + }, + SourceEnvironment: flags.SourceEnvironment.Value, + LatestSuccessful: flags.LatestSuccessful.Value, + } + + // special case for FlagForcePackageDownload bool so we can tell if it was set on the cmdline or missing + if cmd.Flags().Lookup(cmdDeploy.FlagForcePackageDownload).Changed { + options.ForcePackageDownloadWasSpecified = true + } + + if f.IsPromptEnabled() { + now := time.Now + if cmd.Context() != nil { // allow context to override the definition of 'now' for testing + if n, ok := cmd.Context().Value(constants.ContextKeyTimeNow).(func() time.Time); ok { + now = n + } + } + + err = AskQuestions(octopus, cmd.OutOrStdout(), f.Ask, f.GetCurrentSpace(), options, now) + if err != nil { + return err + } + + if !constants.IsProgrammaticOutputFormat(outputFormat) { + // the Q&A process will have modified options;backfill into flags for generation of the automation cmd + resolvedFlags := NewPromoteFlags() + resolvedFlags.Project.Value = options.ProjectName + resolvedFlags.ReleaseVersion.Value = options.ReleaseVersion + resolvedFlags.SourceEnvironment.Value = options.SourceEnvironment + resolvedFlags.Environments.Value = options.Environments + resolvedFlags.Tenants.Value = options.Tenants + resolvedFlags.TenantTags.Value = 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.DeploymentFreezeNames.Value = options.DeploymentFreezeNames + resolvedFlags.DeploymentFreezeOverrideReason.Value = options.DeploymentFreezeOverrideReason + + didMaskSensitiveVariable := false + automationVariables := make(map[string]string, len(options.Variables)) + for variableName, variableValue := range options.Variables { + if util.SliceContainsAny(options.SensitiveVariableNames, func(x string) bool { return strings.EqualFold(x, variableName) }) { + didMaskSensitiveVariable = true + automationVariables[variableName] = "*****" + } else { + automationVariables[variableName] = variableValue + } + } + resolvedFlags.Variables.Value = executionscommon.ToVariableStringArray(automationVariables) + + // we're deliberately adding --no-prompt to the generated cmdline so ForcePackageDownload=false will be missing, + // but that's fine + resolvedFlags.ForcePackageDownload.Value = options.ForcePackageDownload + resolvedFlags.UpdateVariables.Value = options.UpdateVariables + resolvedFlags.LatestSuccessful.Value = options.LatestSuccessful + + autoCmd := flag.GenerateAutomationCmd(constants.ExecutableName+" release promote", + resolvedFlags.Project, + resolvedFlags.ReleaseVersion, + resolvedFlags.SourceEnvironment, + resolvedFlags.Environments, // Use Environments from embedded DeployFlags + resolvedFlags.Tenants, + resolvedFlags.TenantTags, + resolvedFlags.DeployAt, + resolvedFlags.MaxQueueTime, + resolvedFlags.ExcludedSteps, + resolvedFlags.GuidedFailureMode, + resolvedFlags.ForcePackageDownload, + resolvedFlags.DeploymentTargets, + resolvedFlags.ExcludeTargets, + resolvedFlags.Variables, + resolvedFlags.UpdateVariables, + resolvedFlags.LatestSuccessful, + resolvedFlags.DeploymentFreezeNames, + resolvedFlags.DeploymentFreezeOverrideReason, + ) + cmd.Printf("\nAutomation Command: %s\n", autoCmd) + + if didMaskSensitiveVariable { + cmd.Printf("%s\n", output.Yellow("Warning: Command includes some sensitive variable values which have been replaced with placeholders.")) + } + } + } else { + release, err := getPromotionReleaseVersion(octopus, + f.GetCurrentSpace(), + options.ProjectName, + options.SourceEnvironment, + options.LatestSuccessful) + if err != nil { + return err + } + options.ReleaseVersion = release.Version + options.ReleaseID = release.ID + } + + // the executor will raise errors if any required options are missing + err = executor.ProcessTasks(octopus, f.GetCurrentSpace(), []*executor.Task{ + executor.NewTask(executor.TaskTypePromoteRelease, options), + }) + if err != nil { + return err + } + + if options.Response != nil { + switch outputFormat { + case constants.OutputFormatBasic: + for _, task := range options.Response.DeploymentServerTasks { + cmd.Printf("%s\n", task.ServerTaskID) + } + + case constants.OutputFormatJson: + data, err := json.Marshal(options.Response.DeploymentServerTasks) + if err != nil { // shouldn't happen but fallback in case + cmd.PrintErrln(err) + } else { + _, _ = cmd.OutOrStdout().Write(data) + cmd.Println() + } + default: // table + cmd.Printf("Successfully started %d deployment(s)\n", len(options.Response.DeploymentServerTasks)) + } + + // 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 != "" { + 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) + } + } + } + + return nil +} + +func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, space *spaces.Space, options *executor.TaskOptionsPromoteRelease, now func() time.Time) error { + if octopus == nil { + return cliErrors.NewArgumentNullOrEmptyError("octopus") + } + if asker == nil { + return cliErrors.NewArgumentNullOrEmptyError("asker") + } + if options == nil { + return cliErrors.NewArgumentNullOrEmptyError("options") + } + // Note: we don't get here at all if no-prompt is enabled, so we know we are free to ask questions + + // Note on output: survey prints things; if the option is specified already from the command line, + // we should emulate that so there is always a line where you can see what the item was when specified on the command line, + // however if we support a "quiet mode" then we shouldn't emit those + + var err error + + // select project + var selectedProject *projects.Project + if options.ProjectName == "" { + selectedProject, err = selectors.Project("Select project", octopus, asker) + if err != nil { + return err + } + } else { // project name is already provided, fetch the object because it's needed for further questions + selectedProject, err = selectors.FindProject(octopus, options.ProjectName) + if err != nil { + return err + } + _, _ = fmt.Fprintf(stdout, "Project %s\n", output.Cyan(selectedProject.Name)) + } + options.ProjectName = selectedProject.Name + + isTenanted, err := determineIsTenanted(selectedProject, asker) + if err != nil { + return err + } + + // For promote, we need source environment first to find the release + if options.SourceEnvironment == "" { + selectedSourceEnvironment, err := selectors.EnvironmentSelect(asker, func() ([]*environments.Environment, error) { + return selectors.GetAllEnvironments(octopus) + }, "Select source environment") + if err != nil { + return err + } + _, _ = fmt.Fprintf(stdout, "Source Environment %s\n", output.Cyan(selectedSourceEnvironment.Name)) + options.SourceEnvironment = selectedSourceEnvironment.Name + } + + // Find release from source environment + var selectedRelease *releases.Release + var selectedChannel *channels.Channel + + release, err := getPromotionReleaseVersion(octopus, space, selectedProject.Name, options.SourceEnvironment, options.LatestSuccessful) + if err != nil { + return err + } + if err != nil { + return err + } + _, _ = fmt.Fprintf(stdout, "Release %s\n", output.Cyan(release.Version)) + selectedRelease = release + selectedChannel, err = channels.GetByID(octopus, space.ID, selectedRelease.ChannelID) + if err != nil { + return err + } + options.ReleaseVersion = release.Version + options.ReleaseID = release.ID + + indicateMissingPackagesForReleaseFeatureToggleValue, err := featuretoggle.IsToggleEnabled(octopus, "indicate-missing-packages-for-release") + if indicateMissingPackagesForReleaseFeatureToggleValue { + proceed := promptMissingPackages(octopus, stdout, asker, selectedRelease) + if !proceed { + return errors.New("aborting deployment creation as requested") + } + } + + err = validateDeployment(isTenanted, options.Environments) + if err != nil { + return err + } + // 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 deploymentEnvironmentIDs []string + switch selectedChannel.Type { + case channels.ChannelTypeLifecycle: + deploymentEnvironmentIDs, err = selectDeploymentEnvironmentsForLifecycleChannel(octopus, stdout, asker, options, selectedRelease, isTenanted) + if err != nil { + return err + } + case channels.ChannelTypeEphemeral: + deploymentEnvironmentIDs, err = selectDeploymentEnvironmentsForEphemeralChannel(octopus, stdout, asker, options, selectedRelease) + if err != nil { + return err + } + default: + 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(deploymentEnvironmentIDs) == 0 { // if the Q&A process earlier hasn't loaded environments already, we need to load them now + switch selectedChannel.Type { + case channels.ChannelTypeLifecycle: + selectedEnvironments, err := executionscommon.FindEnvironments(octopus, options.Environments) + if err != nil { + return err + } + + deploymentEnvironmentIDs = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.ID }) + case channels.ChannelTypeEphemeral: + deploymentEnvironmentIDs, err = findEphemeralEnvironmentIDs(octopus, space, options.Environments) + + if err != nil { + return err + } + } + } + + var deploymentPreviewRequests []deployments.DeploymentPreviewRequest + for _, environmentId := range deploymentEnvironmentIDs { + preview := deployments.DeploymentPreviewRequest{ + 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: "", + } + deploymentPreviewRequests = append(deploymentPreviewRequests, preview) + } + + options.Variables, err = askDeploymentPreviewVariables(octopus, options.Variables, asker, space.ID, selectedRelease.ID, deploymentPreviewRequests) + if err != nil { + return err + } + // provide list of sensitive variables to the output phase so it doesn't have to go to the server for the variableSet a second time + if variableSet.Variables != nil { + sv := util.SliceFilter(variableSet.Variables, func(v *variables.Variable) bool { return v.IsSensitive || v.Type == "Sensitive" }) + options.SensitiveVariableNames = util.SliceTransform(sv, func(v *variables.Variable) string { return v.Name }) + } + + PrintAdvancedSummary(stdout, options) + + isDeployAtSpecified := options.ScheduledStartTime != "" + isExcludedStepsSpecified := len(options.ExcludedSteps) > 0 + isGuidedFailureModeSpecified := options.GuidedFailureMode != "" + isForcePackageDownloadSpecified := options.ForcePackageDownloadWasSpecified + isDeploymentTargetsSpecified := len(options.DeploymentTargets) > 0 || len(options.ExcludeTargets) > 0 + + allAdvancedOptionsSpecified := isDeployAtSpecified && isExcludedStepsSpecified && isGuidedFailureModeSpecified && isForcePackageDownloadSpecified && isDeploymentTargetsSpecified + + shouldAskAdvancedQuestions := false + if !allAdvancedOptionsSpecified { + var changeOptionsAnswer string + err = asker(&survey.Select{ + Message: "Change additional options?", + Options: []string{"Proceed to deploy", "Change"}, + }, &changeOptionsAnswer) + if err != nil { + return err + } + if changeOptionsAnswer == "Change" { + shouldAskAdvancedQuestions = true + } else { + shouldAskAdvancedQuestions = false + } + } + + if shouldAskAdvancedQuestions { + if !isDeployAtSpecified { + referenceNow := now() + maxSchedStartTime := referenceNow.Add(30 * 24 * time.Hour) // octopus server won't let you schedule things more than 30d in the future + + var answer surveyext.DatePickerAnswer + err = asker(&surveyext.DatePicker{ + Message: "Scheduled start time", + Help: "Enter the date and time that this deployment should start. A value less than 1 minute in the future means 'now'", + Default: referenceNow, + Min: referenceNow, + Max: maxSchedStartTime, + OverrideNow: referenceNow, + AnswerFormatter: executionscommon.ScheduledStartTimeAnswerFormatter, + }, &answer) + if err != nil { + return err + } + scheduledStartTime := answer.Time + // if they enter a time within 1 minute, assume 'now', else we need to pick it up. + // note: the server has some code in it which attempts to detect past + if scheduledStartTime.After(referenceNow.Add(1 * time.Minute)) { + options.ScheduledStartTime = scheduledStartTime.Format(time.RFC3339) + + // only ask for an expiry if they didn't pick "now" + startPlusFiveMin := scheduledStartTime.Add(5 * time.Minute) + err = asker(&surveyext.DatePicker{ + Message: "Scheduled expiry time", + Help: "At the start time, the deployment will be queued. If it does not begin before 'expiry' time, it will be cancelled. Minimum of 5 minutes after start time", + Default: startPlusFiveMin, + Min: startPlusFiveMin, + Max: maxSchedStartTime.Add(24 * time.Hour), // the octopus server doesn't enforce any upper bound for schedule expiry, so we make a minor judgement call and pick 1d extra here. + OverrideNow: referenceNow, + }, &answer) + if err != nil { + return err + } + options.ScheduledExpiryTime = answer.Time.Format(time.RFC3339) + } + } + + if !isExcludedStepsSpecified { + // select steps to exclude + deploymentProcess, err := deployments.GetDeploymentProcessByID(octopus, space.ID, selectedRelease.ProjectDeploymentProcessSnapshotID) + if err != nil { + return err + } + options.ExcludedSteps, err = executionscommon.AskExcludedSteps(asker, deploymentProcess.Steps) + if err != nil { + return err + } + } + + if !isGuidedFailureModeSpecified { // if they deliberately specified false, don't ask them + options.GuidedFailureMode, err = executionscommon.AskGuidedFailureMode(asker) + if err != nil { + return err + } + } + + if !isForcePackageDownloadSpecified { // if they deliberately specified false, don't ask them + options.ForcePackageDownload, err = executionscommon.AskPackageDownload(asker) + if err != nil { + return err + } + } + + if !isDeploymentTargetsSpecified { + 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, deploymentEnvironmentIDs) + if err != nil { + return err + } + } + } + // DONE + return nil +} + +func findEphemeralEnvironmentIDs(octopus *octopusApiClient.Client, space *spaces.Space, environments []string) ([]string, error) { + allEphemeralEnvironments, err := ephemeralenvironments.GetAll(octopus, space.ID) + if err != nil { + return nil, err + } + + if allEphemeralEnvironments == nil || allEphemeralEnvironments.TotalResults == 0 { + return nil, errors.New("no ephemeral environments exist to deploy to") + } + + var selectedEnvironments []string + if len(environments) == 0 { + return nil, nil + } + + 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 _, envIdentifier := range environments { + 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) + } + + return selectedEnvironments, nil +} + +func selectDeploymentEnvironmentsForEphemeralChannel(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsPromoteRelease, 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 }) + } + } else { + _, _ = fmt.Fprintf(stdout, "Environments %s\n", output.Cyan(strings.Join(options.Environments, ","))) + } + + return deploymentEnvironmentIds, nil +} + +func selectDeploymentEnvironmentsForLifecycleChannel(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsPromoteRelease, 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") + } + + return nil +} + +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 _, envID := range deploymentEnvironmentIDs { + preview, err := deployments.GetReleaseDeploymentPreview(octopus, spaceID, releaseID, envID, true) + if err != nil { + return nil, err + } + for _, step := range preview.StepsToExecute { + for _, m := range step.MachineNames { + if !util.SliceContains(results, m) { + results = append(results, m) + } + } + } + } + + // if there are no machines, then either + // a) everything is server based + // b) machines will be provisioned dynamically + // c) or the deployment will fail. + // In all of the above cases, we can't do anything about it so the correct course of action is just skip the question + if len(results) > 0 { + var selectedDeploymentTargetNames []string + err := asker(&survey.MultiSelect{ + Message: "Deployment targets (If none selected, deploy to all)", + Options: results, + }, &selectedDeploymentTargetNames) + if err != nil { + return nil, err + } + + return selectedDeploymentTargetNames, nil + } + return nil, nil +} + +func askDeploymentPreviewVariables(octopus *octopusApiClient.Client, variablesFromCmd map[string]string, asker question.Asker, spaceID string, releaseID string, deploymentPreviewsReqests []deployments.DeploymentPreviewRequest) (map[string]string, error) { + previews, err := deployments.GetReleaseDeploymentPreviews(octopus, spaceID, releaseID, deploymentPreviewsReqests, true) + if err != nil { + return nil, err + } + + flattenedValues := make(map[string]string) + flattenedControls := make(map[string]*deployments.Control) + for _, preview := range previews { + for _, element := range preview.Form.Elements { + flattenedControls[element.Name] = element.Control + } + for key, value := range preview.Form.Values { + flattenedValues[key] = value + } + } + + result := make(map[string]string) + lcaseVarsFromCmd := make(map[string]string, len(variablesFromCmd)) + for k, v := range variablesFromCmd { + lcaseVarsFromCmd[strings.ToLower(k)] = v + } + + keys := maps.Keys(flattenedControls) + sort.Slice(keys, func(i, j int) bool { + return keys[i] > keys[j] + }) + + for _, key := range keys { + control := flattenedControls[key] + valueFromCmd, foundValueOnCommandLine := lcaseVarsFromCmd[strings.ToLower(control.Name)] + if foundValueOnCommandLine { + // implicitly fixes up variable casing + result[control.Name] = valueFromCmd + } + if control.Required == true && !foundValueOnCommandLine { + + defaultValue := flattenedValues[key] + isSensitive := control.DisplaySettings.ControlType == "Sensitive" + promptMessage := control.Name + + if control.Description != "" { + promptMessage = fmt.Sprintf("%s (%s)", promptMessage, control.Description) // we'd like to dim the description, but survey overrides this, so we can't + } + + responseString, err := executionscommon.AskVariableSpecificPrompt(asker, promptMessage, control.Type, defaultValue, control.Required, isSensitive, control.DisplaySettings) + if err != nil { + return nil, err + } + result[control.Name] = responseString + } + } + + return result, nil +} + +func promptMissingPackages(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, release *releases.Release) bool { + missingPackages, err := releases.GetMissingPackages(octopus, release) + if err != nil { + // We don't want to prevent deployments from going through because of this check + _, _ = fmt.Fprintf(stdout, "Unable to determine if there are missing packages for this release - %v\n", err) + return true + } + + if len(missingPackages) == 0 { + return true + } + + _, _ = 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) + } + _, _ = fmt.Fprintln(stdout, "\nThis might cause the deployment to fail.") + + prompt := &survey.Confirm{ + Message: "Do you want to continue?", + Default: false, + } + + var answer bool + if err := asker(prompt, &answer); err != nil { + return answer + } + + return answer +} + +// FindDeployableEnvironmentIDs returns an array of environment IDs that we can deploy to, +// the preferred 'next' environment, and an error +func FindDeployableEnvironmentIDs(octopus *octopusApiClient.Client, release *releases.Release) ([]string, string, error) { + var result []string + // to determine the list of viable environments we need to hit /api/projects/{ID}/progression. + releaseProgression, err := octopus.Deployments.GetProgression(release) + if err != nil { + return nil, "", err + } + for _, phase := range releaseProgression.Phases { + if phase.Progress == releases.PhaseProgressPending { + continue // we can't deploy to this phase yet + } + for _, id := range phase.AutomaticDeploymentTargets { + if !util.SliceContains(result, id) { + result = append(result, id) + } + } + for _, id := range phase.OptionalDeploymentTargets { + if !util.SliceContains(result, id) { + result = append(result, id) + } + } + } + nextDeployEnvID := "" + if len(releaseProgression.NextDeployments) > 0 { + nextDeployEnvID = releaseProgression.NextDeployments[0] + } + + return result, nextDeployEnvID, nil +} + +func loadEnvironmentsForDeploy(octopus *octopusApiClient.Client, deployableEnvironmentIDs []string, nextDeployEnvironmentID string) ([]*environments.Environment, string, error) { + envResources, err := octopus.Environments.Get(environments.EnvironmentsQuery{IDs: deployableEnvironmentIDs}) + if err != nil { + return nil, "", err + } + allEnvs, err := envResources.GetAllPages(octopus.Environments.GetClient()) + if err != nil { + return nil, "", err + } + + // match the next deploy environment + nextDeployEnvironmentName := "" + for _, e := range allEnvs { + if e.ID == nextDeployEnvironmentID { + nextDeployEnvironmentName = e.Name + break + } + } + return allEnvs, nextDeployEnvironmentName, nil +} + +func selectDeploymentEnvironment(asker question.Asker, octopus *octopusApiClient.Client, deployableEnvironmentIDs []string, nextDeployEnvironmentID string) (*environments.Environment, error) { + allEnvs, nextDeployEnvironmentName, err := loadEnvironmentsForDeploy(octopus, deployableEnvironmentIDs, nextDeployEnvironmentID) + if err != nil { + return nil, err + } + + optionMap, options := question.MakeItemMapAndOptions(allEnvs, func(e *environments.Environment) string { return e.Name }) + var selectedKey string + err = asker(&survey.Select{ + Message: "Select target environment", + Options: options, + Default: nextDeployEnvironmentName, + }, &selectedKey) + if err != nil { + return nil, err + } + selectedValue, ok := optionMap[selectedKey] + if !ok { + return nil, fmt.Errorf("selectDeploymentEnvironment did not get valid answer (selectedKey=%s)", selectedKey) + } + 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 target 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 { + return nil, err + } + + optionMap, options := question.MakeItemMapAndOptions(allEnvs, func(e *environments.Environment) string { return e.Name }) + var selectedKeys []string + err = asker(&survey.MultiSelect{ + Message: "Select target environment(s)", + Options: options, + Default: []string{nextDeployEnvironmentName}, + }, &selectedKeys, survey.WithValidator(survey.Required)) + + if err != nil { + return nil, err + } + var selectedValues []*environments.Environment + 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 PrintAdvancedSummary(stdout io.Writer, options *executor.TaskOptionsPromoteRelease) { + deployAtStr := "Now" + if options.ScheduledStartTime != "" { + deployAtStr = options.ScheduledStartTime // we assume the server is going to understand this + } + skipStepsStr := "None" + if len(options.ExcludedSteps) > 0 { + skipStepsStr = strings.Join(options.ExcludedSteps, ",") + } + + gfmStr := executionscommon.LookupGuidedFailureModeString(options.GuidedFailureMode) + + pkgDownloadStr := executionscommon.LookupPackageDownloadString(!options.ForcePackageDownload) + + depTargetsStr := "All included" + if len(options.DeploymentTargets) != 0 || len(options.ExcludeTargets) != 0 { + sb := strings.Builder{} + if len(options.DeploymentTargets) > 0 { + sb.WriteString("Include ") + for idx, name := range options.DeploymentTargets { + if idx > 0 { + sb.WriteString(",") + } + sb.WriteString(name) + } + } + if len(options.ExcludeTargets) > 0 { + if sb.Len() > 0 { + sb.WriteString("; ") + } + + sb.WriteString("Exclude ") + for idx, name := range options.ExcludeTargets { + if idx > 0 { + sb.WriteString(",") + } + sb.WriteString(name) + } + } + depTargetsStr = sb.String() + } + + _, _ = fmt.Fprintf(stdout, output.FormatDoc(heredoc.Doc(` + bold(Additional Options): + Deploy Time: cyan(%s) + Skipped Steps: cyan(%s) + Guided Failure Mode: cyan(%s) + Package Download: cyan(%s) + Deployment Targets: cyan(%s) + `)), deployAtStr, skipStepsStr, gfmStr, pkgDownloadStr, depTargetsStr) +} + +// findReleaseFromSourceEnvironment finds the release deployed to the source environment using the dashboard API +func findReleaseFromSourceEnvironment(octopus *octopusApiClient.Client, space *spaces.Space, project *projects.Project, sourceEnvironmentName string, latestSuccessful bool) (*releases.Release, error) { + // Find the source environment + sourceEnv, err := selectors.FindEnvironment(octopus, sourceEnvironmentName) + if err != nil { + return nil, err + } + + // Get dashboard items for the project and source environment + dashboardItem, err := dashboard.GetDynamicDashboardItem(octopus, space.ID, dashboard.DashboardDynamicQuery{ + Environments: []string{sourceEnv.ID}, + Projects: []string{project.ID}, + IncludePrevious: latestSuccessful, + }) + if err != nil { + return nil, err + } + + if len(dashboardItem.Items) == 0 { + return nil, fmt.Errorf("no releases found in source environment '%s'", sourceEnvironmentName) + } + + // Sort by release version (ascending) to get the latest + sort.Slice(dashboardItem.Items, func(i, j int) bool { + return dashboardItem.Items[i].ReleaseVersion < dashboardItem.Items[j].ReleaseVersion + }) + + // Get the latest (last) release + latestReleaseVersion := dashboardItem.Items[len(dashboardItem.Items)-1].ReleaseVersion + selectedRelease, err := releases.GetReleaseInProject(octopus, space.ID, project.ID, latestReleaseVersion) + if err != nil { + return nil, err + } + + return selectedRelease, nil +} + +// determineIsTenanted returns true if we are going to do a tenanted deployment, false if untenanted +// NOTE: Tenant can be disabled or forced. In these cases we know what to do. +// The middle case is "allowed, but not forced", in which case we don't know ahead of time what to do WRT tenants, +// so we'd need to ask the user. This is not great UX, but the intent of the 'middle ground' tenant state +// is to allow for graceful migrations of older projects, and we don't expect it to happen very often. +// We COULD do a little bit of a shortcut; if tenant is 'allowed but not required' but the project has no +// linked tenants, then it can't be tenanted, but is this worth the extra complexity? Decision: no +func determineIsTenanted(project *projects.Project, ask question.Asker) (bool, error) { + switch project.TenantedDeploymentMode { + case core.TenantedDeploymentModeUntenanted: + return false, nil + case core.TenantedDeploymentModeTenanted: + return true, nil + case core.TenantedDeploymentModeTenantedOrUntenanted: + return question.SelectMap(ask, "Select Tenanted or Untenanted deployment", []bool{true, false}, func(b bool) string { + if b { + return "Tenanted" // should be the default; they probably want tenanted + } else { + return "Untenanted" + } + }) + + default: // should not get here + return false, fmt.Errorf("unhandled tenanted deployment mode %s", project.TenantedDeploymentMode) + } +} + +func getPromotionReleaseVersion(octopus *octopusApiClient.Client, + space *spaces.Space, + projectName string, + sourceEnvironmentName string, + latestSuccessful bool) (*releases.Release, error) { + + projectResource, err := proj.GetByName(octopus, space.Resource.ID, projectName) + if err != nil { + return nil, err + } + environmentResource, err := env.Get(octopus, space.Resource.ID, env.EnvironmentsQuery{ + Name: sourceEnvironmentName, + }) + if err != nil { + return nil, err + } + dashboardItem, err := dashboard.GetDynamicDashboardItem(octopus, space.Resource.ID, dashboard.DashboardDynamicQuery{ + Environments: []string{environmentResource.Items[0].ID}, + Projects: []string{projectResource.ID}, + IncludePrevious: latestSuccessful, + }) + if err != nil { + return nil, err + } + sort.Slice(dashboardItem.Items, func(i, j int) bool { + return dashboardItem.Items[i].ReleaseVersion < dashboardItem.Items[j].ReleaseVersion + }) + var version string + if latestSuccessful { + for _, item := range dashboardItem.Items { + if item.State == "Success" { + version = item.ReleaseVersion + break + } + } + } else { + version = dashboardItem.Items[0].ReleaseVersion + } + releaseResource, err := releases.GetReleaseInProject(octopus, space.Resource.ID, projectResource.ID, version) + if err != nil { + return nil, err + } + return releaseResource, nil +} diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index ffc59133..b85efe5c 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -5,6 +5,7 @@ import ( cmdCreate "github.com/OctopusDeploy/cli/pkg/cmd/release/create" cmdDelete "github.com/OctopusDeploy/cli/pkg/cmd/release/delete" cmdDeploy "github.com/OctopusDeploy/cli/pkg/cmd/release/deploy" + cmdPromote "github.com/OctopusDeploy/cli/pkg/cmd/release/promote" cmdList "github.com/OctopusDeploy/cli/pkg/cmd/release/list" cmdProgression "github.com/OctopusDeploy/cli/pkg/cmd/release/progression" "github.com/OctopusDeploy/cli/pkg/constants" @@ -26,6 +27,7 @@ func NewCmdRelease(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdCreate.NewCmdCreate(f)) cmd.AddCommand(cmdDeploy.NewCmdDeploy(f)) + cmd.AddCommand(cmdPromote.NewCmdPromote(f)) cmd.AddCommand(cmdList.NewCmdList(f)) cmd.AddCommand(cmdDelete.NewCmdDelete(f)) cmd.AddCommand(cmdProgression.NewCmdProgression(f)) diff --git a/pkg/executor/executor.go b/pkg/executor/executor.go index 80fed5c0..9337b18b 100644 --- a/pkg/executor/executor.go +++ b/pkg/executor/executor.go @@ -14,6 +14,7 @@ const ( TaskTypeCreateAccount = TaskType("CreateAccount") TaskTypeCreateRelease = TaskType("CreateRelease") TaskTypeDeployRelease = TaskType("DeployRelease") + TaskTypePromoteRelease = TaskType("PromoteRelease") TaskTypeRunbookRun = TaskType("RunbookRun") TaskTypeGitRunbookRun = TaskType("GitRunbookRun") ) @@ -53,6 +54,10 @@ func ProcessTasks(octopus *client.Client, space *spaces.Space, tasks []*Task) er if err := releaseDeploy(octopus, space, task.Options); err != nil { return err } + case TaskTypePromoteRelease: + if err := releasePromote(octopus, space, task.Options); err != nil { + return err + } case TaskTypeRunbookRun: if err := runbookRun(octopus, space, task.Options); err != nil { return err diff --git a/pkg/executor/release.go b/pkg/executor/release.go index 7e57a9c1..e085ecca 100644 --- a/pkg/executor/release.go +++ b/pkg/executor/release.go @@ -3,6 +3,7 @@ package executor import ( "errors" "fmt" + "strconv" "strings" @@ -170,7 +171,6 @@ func releaseDeploy(octopus *client.Client, space *spaces.Space, input any) error return fmt.Errorf("'%s' is not a valid value for guided failure mode", params.GuidedFailureMode) } } - // If either tenants or tenantTags are specified then it must be a tenanted deployment. // Otherwise it must be untenanted. // If the server has a tenanted deployment and both TenantNames+Tags are empty, the request fails, @@ -213,3 +213,24 @@ func releaseDeploy(octopus *client.Client, space *spaces.Space, input any) error return nil } + +// ----- Promote Release -------------------------------------- + +type TaskOptionsPromoteRelease struct { + TaskOptionsDeployRelease + SourceEnvironment string // the source environment to promote from + LatestSuccessful bool + // After we send the request to the server, the response is stored here + Response *deployments.CreateDeploymentResponseV1 +} + +func releasePromote(octopus *client.Client, space *spaces.Space, input any) error { + params, ok := input.(*TaskOptionsPromoteRelease) + if !ok { + return errors.New("invalid input type; expecting TaskOptionsPromoteRelease") + } + if params.SourceEnvironment == "" { + return errors.New("source environment must be specified") + } + return releaseDeploy(octopus, space, ¶ms.TaskOptionsDeployRelease) +} From 57ba0426a05627758ff5b0766da51ffb8766e51d Mon Sep 17 00:00:00 2001 From: Chen Keinan Date: Sun, 15 Feb 2026 23:46:02 +0200 Subject: [PATCH 2/4] feat: update logic Signed-off-by: Chen Keinan --- pkg/cmd/release/promote/promote.go | 37 ------------------------------ pkg/executor/release.go | 6 ++--- 2 files changed, 3 insertions(+), 40 deletions(-) diff --git a/pkg/cmd/release/promote/promote.go b/pkg/cmd/release/promote/promote.go index 3d54a10f..98fd1841 100644 --- a/pkg/cmd/release/promote/promote.go +++ b/pkg/cmd/release/promote/promote.go @@ -1026,43 +1026,6 @@ func PrintAdvancedSummary(stdout io.Writer, options *executor.TaskOptionsPromote `)), deployAtStr, skipStepsStr, gfmStr, pkgDownloadStr, depTargetsStr) } -// findReleaseFromSourceEnvironment finds the release deployed to the source environment using the dashboard API -func findReleaseFromSourceEnvironment(octopus *octopusApiClient.Client, space *spaces.Space, project *projects.Project, sourceEnvironmentName string, latestSuccessful bool) (*releases.Release, error) { - // Find the source environment - sourceEnv, err := selectors.FindEnvironment(octopus, sourceEnvironmentName) - if err != nil { - return nil, err - } - - // Get dashboard items for the project and source environment - dashboardItem, err := dashboard.GetDynamicDashboardItem(octopus, space.ID, dashboard.DashboardDynamicQuery{ - Environments: []string{sourceEnv.ID}, - Projects: []string{project.ID}, - IncludePrevious: latestSuccessful, - }) - if err != nil { - return nil, err - } - - if len(dashboardItem.Items) == 0 { - return nil, fmt.Errorf("no releases found in source environment '%s'", sourceEnvironmentName) - } - - // Sort by release version (ascending) to get the latest - sort.Slice(dashboardItem.Items, func(i, j int) bool { - return dashboardItem.Items[i].ReleaseVersion < dashboardItem.Items[j].ReleaseVersion - }) - - // Get the latest (last) release - latestReleaseVersion := dashboardItem.Items[len(dashboardItem.Items)-1].ReleaseVersion - selectedRelease, err := releases.GetReleaseInProject(octopus, space.ID, project.ID, latestReleaseVersion) - if err != nil { - return nil, err - } - - return selectedRelease, nil -} - // determineIsTenanted returns true if we are going to do a tenanted deployment, false if untenanted // NOTE: Tenant can be disabled or forced. In these cases we know what to do. // The middle case is "allowed, but not forced", in which case we don't know ahead of time what to do WRT tenants, diff --git a/pkg/executor/release.go b/pkg/executor/release.go index e085ecca..17010aca 100644 --- a/pkg/executor/release.go +++ b/pkg/executor/release.go @@ -3,7 +3,6 @@ package executor import ( "errors" "fmt" - "strconv" "strings" @@ -171,6 +170,7 @@ func releaseDeploy(octopus *client.Client, space *spaces.Space, input any) error return fmt.Errorf("'%s' is not a valid value for guided failure mode", params.GuidedFailureMode) } } + // If either tenants or tenantTags are specified then it must be a tenanted deployment. // Otherwise it must be untenanted. // If the server has a tenanted deployment and both TenantNames+Tags are empty, the request fails, @@ -218,8 +218,8 @@ func releaseDeploy(octopus *client.Client, space *spaces.Space, input any) error type TaskOptionsPromoteRelease struct { TaskOptionsDeployRelease - SourceEnvironment string // the source environment to promote from - LatestSuccessful bool + SourceEnvironment string // the source environment to promote from + LatestSuccessful bool // After we send the request to the server, the response is stored here Response *deployments.CreateDeploymentResponseV1 } From a3abb1c1c5d77893a7812385cb089f05dc19ac69 Mon Sep 17 00:00:00 2001 From: Chen Keinan Date: Sun, 15 Feb 2026 23:46:43 +0200 Subject: [PATCH 3/4] feat: update logic Signed-off-by: Chen Keinan --- pkg/executor/release.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/executor/release.go b/pkg/executor/release.go index 17010aca..8ce9285f 100644 --- a/pkg/executor/release.go +++ b/pkg/executor/release.go @@ -170,7 +170,7 @@ func releaseDeploy(octopus *client.Client, space *spaces.Space, input any) error return fmt.Errorf("'%s' is not a valid value for guided failure mode", params.GuidedFailureMode) } } - + // If either tenants or tenantTags are specified then it must be a tenanted deployment. // Otherwise it must be untenanted. // If the server has a tenanted deployment and both TenantNames+Tags are empty, the request fails, From 0c2be163c0fab5caabeb4804c6c9bec62a883105 Mon Sep 17 00:00:00 2001 From: Chen Keinan Date: Mon, 16 Feb 2026 00:27:44 +0200 Subject: [PATCH 4/4] feat: reuse deploy function Signed-off-by: Chen Keinan --- pkg/cmd/release/deploy/deploy.go | 50 +-- pkg/cmd/release/promote/promote.go | 506 +---------------------------- 2 files changed, 38 insertions(+), 518 deletions(-) diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index bbf4d738..b0f2f9c9 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -403,12 +403,12 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques } options.ProjectName = selectedProject.Name - isTenanted, err := determineIsTenanted(selectedProject, asker) + isTenanted, err := DetermineIsTenanted(selectedProject, asker) if err != nil { return err } - err = validateDeployment(isTenanted, options.Environments) + err = ValidateDeployment(isTenanted, options.Environments) if err != nil { return err } @@ -446,7 +446,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") } @@ -456,12 +456,12 @@ 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 { - deploymentEnvironmentIDs, err = selectDeploymentEnvironmentsForLifecycleChannel(octopus, stdout, asker, options, selectedRelease, isTenanted) + deploymentEnvironmentIDs, err = SelectDeploymentEnvironmentsForLifecycleChannel(octopus, stdout, asker, options, selectedRelease, isTenanted) if err != nil { return err } } else if selectedChannel.Type == channels.ChannelTypeEphemeral { - deploymentEnvironmentIDs, err = selectDeploymentEnvironmentsForEphemeralChannel(octopus, stdout, asker, options, selectedRelease) + deploymentEnvironmentIDs, err = SelectDeploymentEnvironmentsForEphemeralChannel(octopus, stdout, asker, options, selectedRelease) if err != nil { return err } @@ -483,7 +483,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques deploymentEnvironmentIDs = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.ID }) } else if selectedChannel.Type == channels.ChannelTypeEphemeral { - deploymentEnvironmentIDs, err = findEphemeralEnvironmentIDs(octopus, space, options.Environments) + deploymentEnvironmentIDs, err = FindEphemeralEnvironmentIDs(octopus, space, options.Environments) if err != nil { return err @@ -502,7 +502,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques deploymentPreviewRequests = append(deploymentPreviewRequests, preview) } - options.Variables, err = askDeploymentPreviewVariables(octopus, options.Variables, asker, space.ID, selectedRelease.ID, deploymentPreviewRequests) + options.Variables, err = AskDeploymentPreviewVariables(octopus, options.Variables, asker, space.ID, selectedRelease.ID, deploymentPreviewRequests) if err != nil { return err } @@ -614,7 +614,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques } deploymentEnvironmentIDs = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.ID }) } - options.DeploymentTargets, err = askDeploymentTargets(octopus, asker, space.ID, selectedRelease.ID, deploymentEnvironmentIDs) + options.DeploymentTargets, err = AskDeploymentTargets(octopus, asker, space.ID, selectedRelease.ID, deploymentEnvironmentIDs) if err != nil { return err } @@ -624,7 +624,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 FindEphemeralEnvironmentIDs(octopus *octopusApiClient.Client, space *spaces.Space, environments []string) ([]string, error) { allEphemeralEnvironments, err := ephemeralenvironments.GetAll(octopus, space.ID) if err != nil { return nil, err @@ -656,7 +656,7 @@ func findEphemeralEnvironmentIDs(octopus *octopusApiClient.Client, space *spaces return selectedEnvironments, nil } -func selectDeploymentEnvironmentsForEphemeralChannel(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsDeployRelease, selectedRelease *releases.Release) ([]string, error) { +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 @@ -687,7 +687,7 @@ func selectDeploymentEnvironmentsForEphemeralChannel(octopus *octopusApiClient.C } if len(availableEnvironments) > 0 { - selectedEnvironments, err = selectEphemeralDeploymentEnvironments(asker, availableEnvironments) + selectedEnvironments, err = SelectEphemeralDeploymentEnvironments(asker, availableEnvironments, "Select environment(s)") if err != nil { return nil, err } @@ -701,7 +701,7 @@ func selectDeploymentEnvironmentsForEphemeralChannel(octopus *octopusApiClient.C return deploymentEnvironmentIds, nil } -func selectDeploymentEnvironmentsForLifecycleChannel(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsDeployRelease, selectedRelease *releases.Release, isTenanted bool) ([]string, error) { +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 @@ -713,7 +713,7 @@ func selectDeploymentEnvironmentsForLifecycleChannel(octopus *octopusApiClient.C if err != nil { return nil, err } - selectedEnvironment, err = selectDeploymentEnvironment(asker, octopus, deployableEnvironmentIDs, nextEnvironmentID) + selectedEnvironment, err = SelectDeploymentEnvironment(asker, octopus, deployableEnvironmentIDs, nextEnvironmentID, "Select environment") if err != nil { return nil, err } @@ -751,7 +751,7 @@ func selectDeploymentEnvironmentsForLifecycleChannel(octopus *octopusApiClient.C if err != nil { return nil, err } - selectedEnvironments, err = selectDeploymentEnvironments(asker, octopus, deployableEnvironmentIDs, nextEnvironmentID) + selectedEnvironments, err = SelectDeploymentEnvironments(asker, octopus, deployableEnvironmentIDs, nextEnvironmentID, "Select environment(s)") if err != nil { return nil, err } @@ -766,7 +766,7 @@ func selectDeploymentEnvironmentsForLifecycleChannel(octopus *octopusApiClient.C return deploymentEnvironmentIds, nil } -func validateDeployment(isTenanted bool, environments []string) error { +func ValidateDeployment(isTenanted bool, environments []string) error { if isTenanted && len(environments) > 1 { return fmt.Errorf("tenanted deployments can only specify one environment") } @@ -774,7 +774,7 @@ func validateDeployment(isTenanted bool, environments []string) error { return nil } -func askDeploymentTargets(octopus *octopusApiClient.Client, asker question.Asker, spaceID string, releaseID string, deploymentEnvironmentIDs []string) ([]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 @@ -812,7 +812,7 @@ func askDeploymentTargets(octopus *octopusApiClient.Client, asker question.Asker return nil, nil } -func askDeploymentPreviewVariables(octopus *octopusApiClient.Client, variablesFromCmd map[string]string, asker question.Asker, spaceID string, releaseID string, deploymentPreviewsReqests []deployments.DeploymentPreviewRequest) (map[string]string, error) { +func AskDeploymentPreviewVariables(octopus *octopusApiClient.Client, variablesFromCmd map[string]string, asker question.Asker, spaceID string, releaseID string, deploymentPreviewsReqests []deployments.DeploymentPreviewRequest) (map[string]string, error) { previews, err := deployments.GetReleaseDeploymentPreviews(octopus, spaceID, releaseID, deploymentPreviewsReqests, true) if err != nil { return nil, err @@ -868,7 +868,7 @@ func askDeploymentPreviewVariables(octopus *octopusApiClient.Client, variablesFr return result, nil } -func promptMissingPackages(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, release *releases.Release) bool { +func PromptMissingPackages(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, release *releases.Release) bool { missingPackages, err := releases.GetMissingPackages(octopus, release) if err != nil { // We don't want to prevent deployments from going through because of this check @@ -952,7 +952,7 @@ func loadEnvironmentsForDeploy(octopus *octopusApiClient.Client, deployableEnvir return allEnvs, nextDeployEnvironmentName, nil } -func selectDeploymentEnvironment(asker question.Asker, octopus *octopusApiClient.Client, deployableEnvironmentIDs []string, nextDeployEnvironmentID string) (*environments.Environment, error) { +func SelectDeploymentEnvironment(asker question.Asker, octopus *octopusApiClient.Client, deployableEnvironmentIDs []string, nextDeployEnvironmentID string, message string) (*environments.Environment, error) { allEnvs, nextDeployEnvironmentName, err := loadEnvironmentsForDeploy(octopus, deployableEnvironmentIDs, nextDeployEnvironmentID) if err != nil { return nil, err @@ -961,7 +961,7 @@ func selectDeploymentEnvironment(asker question.Asker, octopus *octopusApiClient optionMap, options := question.MakeItemMapAndOptions(allEnvs, func(e *environments.Environment) string { return e.Name }) var selectedKey string err = asker(&survey.Select{ - Message: "Select environment", + Message: message, Options: options, Default: nextDeployEnvironmentName, }, &selectedKey) @@ -975,12 +975,12 @@ func selectDeploymentEnvironment(asker question.Asker, octopus *octopusApiClient return selectedValue, nil } -func selectEphemeralDeploymentEnvironments(asker question.Asker, deployableEnvironments []*ephemeralenvironments.EphemeralEnvironment) ([]*ephemeralenvironments.EphemeralEnvironment, error) { +func SelectEphemeralDeploymentEnvironments(asker question.Asker, deployableEnvironments []*ephemeralenvironments.EphemeralEnvironment, message string) ([]*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)", + Message: message, Options: options, Default: nil, }, &selectedKeys, survey.WithValidator(survey.Required)) @@ -997,7 +997,7 @@ func selectEphemeralDeploymentEnvironments(asker question.Asker, deployableEnvir return selectedValues, nil } -func selectDeploymentEnvironments(asker question.Asker, octopus *octopusApiClient.Client, deployableEnvironmentIDs []string, nextDeployEnvironmentID string) ([]*environments.Environment, error) { +func SelectDeploymentEnvironments(asker question.Asker, octopus *octopusApiClient.Client, deployableEnvironmentIDs []string, nextDeployEnvironmentID string, message string) ([]*environments.Environment, error) { allEnvs, nextDeployEnvironmentName, err := loadEnvironmentsForDeploy(octopus, deployableEnvironmentIDs, nextDeployEnvironmentID) if err != nil { return nil, err @@ -1006,7 +1006,7 @@ func selectDeploymentEnvironments(asker question.Asker, octopus *octopusApiClien optionMap, options := question.MakeItemMapAndOptions(allEnvs, func(e *environments.Environment) string { return e.Name }) var selectedKeys []string err = asker(&survey.MultiSelect{ - Message: "Select environment(s)", + Message: message, Options: options, Default: []string{nextDeployEnvironmentName}, }, &selectedKeys, survey.WithValidator(survey.Required)) @@ -1093,7 +1093,7 @@ func selectRelease(octopus *octopusApiClient.Client, ask question.Asker, questio // is to allow for graceful migrations of older projects, and we don't expect it to happen very often. // We COULD do a little bit of a shortcut; if tenant is 'allowed but not required' but the project has no // linked tenants, then it can't be tenanted, but is this worth the extra complexity? Decision: no -func determineIsTenanted(project *projects.Project, ask question.Asker) (bool, error) { +func DetermineIsTenanted(project *projects.Project, ask question.Asker) (bool, error) { switch project.TenantedDeploymentMode { case core.TenantedDeploymentModeUntenanted: return false, nil diff --git a/pkg/cmd/release/promote/promote.go b/pkg/cmd/release/promote/promote.go index 98fd1841..2d6c673c 100644 --- a/pkg/cmd/release/promote/promote.go +++ b/pkg/cmd/release/promote/promote.go @@ -12,8 +12,6 @@ import ( cmdDeploy "github.com/OctopusDeploy/cli/pkg/cmd/release/deploy" "github.com/OctopusDeploy/cli/pkg/executor" "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" @@ -32,7 +30,6 @@ 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/dashboard" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/environments" @@ -352,7 +349,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques } options.ProjectName = selectedProject.Name - isTenanted, err := determineIsTenanted(selectedProject, asker) + isTenanted, err := cmdDeploy.DetermineIsTenanted(selectedProject, asker) if err != nil { return err } @@ -391,13 +388,13 @@ 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 := cmdDeploy.PromptMissingPackages(octopus, stdout, asker, selectedRelease) if !proceed { return errors.New("aborting deployment creation as requested") } } - err = validateDeployment(isTenanted, options.Environments) + err = cmdDeploy.ValidateDeployment(isTenanted, options.Environments) if err != nil { return err } @@ -406,12 +403,12 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques var deploymentEnvironmentIDs []string switch selectedChannel.Type { case channels.ChannelTypeLifecycle: - deploymentEnvironmentIDs, err = selectDeploymentEnvironmentsForLifecycleChannel(octopus, stdout, asker, options, selectedRelease, isTenanted) + deploymentEnvironmentIDs, err = cmdDeploy.SelectDeploymentEnvironmentsForLifecycleChannel(octopus, stdout, asker, &options.TaskOptionsDeployRelease, selectedRelease, isTenanted) if err != nil { return err } case channels.ChannelTypeEphemeral: - deploymentEnvironmentIDs, err = selectDeploymentEnvironmentsForEphemeralChannel(octopus, stdout, asker, options, selectedRelease) + deploymentEnvironmentIDs, err = cmdDeploy.SelectDeploymentEnvironmentsForEphemeralChannel(octopus, stdout, asker, &options.TaskOptionsDeployRelease, selectedRelease) if err != nil { return err } @@ -434,7 +431,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques deploymentEnvironmentIDs = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.ID }) case channels.ChannelTypeEphemeral: - deploymentEnvironmentIDs, err = findEphemeralEnvironmentIDs(octopus, space, options.Environments) + deploymentEnvironmentIDs, err = cmdDeploy.FindEphemeralEnvironmentIDs(octopus, space, options.Environments) if err != nil { return err @@ -453,7 +450,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques deploymentPreviewRequests = append(deploymentPreviewRequests, preview) } - options.Variables, err = askDeploymentPreviewVariables(octopus, options.Variables, asker, space.ID, selectedRelease.ID, deploymentPreviewRequests) + options.Variables, err = cmdDeploy.AskDeploymentPreviewVariables(octopus, options.Variables, asker, space.ID, selectedRelease.ID, deploymentPreviewRequests) if err != nil { return err } @@ -463,7 +460,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques options.SensitiveVariableNames = util.SliceTransform(sv, func(v *variables.Variable) string { return v.Name }) } - PrintAdvancedSummary(stdout, options) + cmdDeploy.PrintAdvancedSummary(stdout, &options.TaskOptionsDeployRelease) isDeployAtSpecified := options.ScheduledStartTime != "" isExcludedStepsSpecified := len(options.ExcludedSteps) > 0 @@ -565,7 +562,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques } deploymentEnvironmentIDs = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.ID }) } - options.DeploymentTargets, err = askDeploymentTargets(octopus, asker, space.ID, selectedRelease.ID, deploymentEnvironmentIDs) + options.DeploymentTargets, err = cmdDeploy.AskDeploymentTargets(octopus, asker, space.ID, selectedRelease.ID, deploymentEnvironmentIDs) if err != nil { return err } @@ -575,484 +572,6 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques return nil } -func findEphemeralEnvironmentIDs(octopus *octopusApiClient.Client, space *spaces.Space, environments []string) ([]string, error) { - allEphemeralEnvironments, err := ephemeralenvironments.GetAll(octopus, space.ID) - if err != nil { - return nil, err - } - - if allEphemeralEnvironments == nil || allEphemeralEnvironments.TotalResults == 0 { - return nil, errors.New("no ephemeral environments exist to deploy to") - } - - var selectedEnvironments []string - if len(environments) == 0 { - return nil, nil - } - - 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 _, envIdentifier := range environments { - 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) - } - - return selectedEnvironments, nil -} - -func selectDeploymentEnvironmentsForEphemeralChannel(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsPromoteRelease, 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 }) - } - } else { - _, _ = fmt.Fprintf(stdout, "Environments %s\n", output.Cyan(strings.Join(options.Environments, ","))) - } - - return deploymentEnvironmentIds, nil -} - -func selectDeploymentEnvironmentsForLifecycleChannel(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsPromoteRelease, 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") - } - - return nil -} - -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 _, envID := range deploymentEnvironmentIDs { - preview, err := deployments.GetReleaseDeploymentPreview(octopus, spaceID, releaseID, envID, true) - if err != nil { - return nil, err - } - for _, step := range preview.StepsToExecute { - for _, m := range step.MachineNames { - if !util.SliceContains(results, m) { - results = append(results, m) - } - } - } - } - - // if there are no machines, then either - // a) everything is server based - // b) machines will be provisioned dynamically - // c) or the deployment will fail. - // In all of the above cases, we can't do anything about it so the correct course of action is just skip the question - if len(results) > 0 { - var selectedDeploymentTargetNames []string - err := asker(&survey.MultiSelect{ - Message: "Deployment targets (If none selected, deploy to all)", - Options: results, - }, &selectedDeploymentTargetNames) - if err != nil { - return nil, err - } - - return selectedDeploymentTargetNames, nil - } - return nil, nil -} - -func askDeploymentPreviewVariables(octopus *octopusApiClient.Client, variablesFromCmd map[string]string, asker question.Asker, spaceID string, releaseID string, deploymentPreviewsReqests []deployments.DeploymentPreviewRequest) (map[string]string, error) { - previews, err := deployments.GetReleaseDeploymentPreviews(octopus, spaceID, releaseID, deploymentPreviewsReqests, true) - if err != nil { - return nil, err - } - - flattenedValues := make(map[string]string) - flattenedControls := make(map[string]*deployments.Control) - for _, preview := range previews { - for _, element := range preview.Form.Elements { - flattenedControls[element.Name] = element.Control - } - for key, value := range preview.Form.Values { - flattenedValues[key] = value - } - } - - result := make(map[string]string) - lcaseVarsFromCmd := make(map[string]string, len(variablesFromCmd)) - for k, v := range variablesFromCmd { - lcaseVarsFromCmd[strings.ToLower(k)] = v - } - - keys := maps.Keys(flattenedControls) - sort.Slice(keys, func(i, j int) bool { - return keys[i] > keys[j] - }) - - for _, key := range keys { - control := flattenedControls[key] - valueFromCmd, foundValueOnCommandLine := lcaseVarsFromCmd[strings.ToLower(control.Name)] - if foundValueOnCommandLine { - // implicitly fixes up variable casing - result[control.Name] = valueFromCmd - } - if control.Required == true && !foundValueOnCommandLine { - - defaultValue := flattenedValues[key] - isSensitive := control.DisplaySettings.ControlType == "Sensitive" - promptMessage := control.Name - - if control.Description != "" { - promptMessage = fmt.Sprintf("%s (%s)", promptMessage, control.Description) // we'd like to dim the description, but survey overrides this, so we can't - } - - responseString, err := executionscommon.AskVariableSpecificPrompt(asker, promptMessage, control.Type, defaultValue, control.Required, isSensitive, control.DisplaySettings) - if err != nil { - return nil, err - } - result[control.Name] = responseString - } - } - - return result, nil -} - -func promptMissingPackages(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, release *releases.Release) bool { - missingPackages, err := releases.GetMissingPackages(octopus, release) - if err != nil { - // We don't want to prevent deployments from going through because of this check - _, _ = fmt.Fprintf(stdout, "Unable to determine if there are missing packages for this release - %v\n", err) - return true - } - - if len(missingPackages) == 0 { - return true - } - - _, _ = 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) - } - _, _ = fmt.Fprintln(stdout, "\nThis might cause the deployment to fail.") - - prompt := &survey.Confirm{ - Message: "Do you want to continue?", - Default: false, - } - - var answer bool - if err := asker(prompt, &answer); err != nil { - return answer - } - - return answer -} - -// FindDeployableEnvironmentIDs returns an array of environment IDs that we can deploy to, -// the preferred 'next' environment, and an error -func FindDeployableEnvironmentIDs(octopus *octopusApiClient.Client, release *releases.Release) ([]string, string, error) { - var result []string - // to determine the list of viable environments we need to hit /api/projects/{ID}/progression. - releaseProgression, err := octopus.Deployments.GetProgression(release) - if err != nil { - return nil, "", err - } - for _, phase := range releaseProgression.Phases { - if phase.Progress == releases.PhaseProgressPending { - continue // we can't deploy to this phase yet - } - for _, id := range phase.AutomaticDeploymentTargets { - if !util.SliceContains(result, id) { - result = append(result, id) - } - } - for _, id := range phase.OptionalDeploymentTargets { - if !util.SliceContains(result, id) { - result = append(result, id) - } - } - } - nextDeployEnvID := "" - if len(releaseProgression.NextDeployments) > 0 { - nextDeployEnvID = releaseProgression.NextDeployments[0] - } - - return result, nextDeployEnvID, nil -} - -func loadEnvironmentsForDeploy(octopus *octopusApiClient.Client, deployableEnvironmentIDs []string, nextDeployEnvironmentID string) ([]*environments.Environment, string, error) { - envResources, err := octopus.Environments.Get(environments.EnvironmentsQuery{IDs: deployableEnvironmentIDs}) - if err != nil { - return nil, "", err - } - allEnvs, err := envResources.GetAllPages(octopus.Environments.GetClient()) - if err != nil { - return nil, "", err - } - - // match the next deploy environment - nextDeployEnvironmentName := "" - for _, e := range allEnvs { - if e.ID == nextDeployEnvironmentID { - nextDeployEnvironmentName = e.Name - break - } - } - return allEnvs, nextDeployEnvironmentName, nil -} - -func selectDeploymentEnvironment(asker question.Asker, octopus *octopusApiClient.Client, deployableEnvironmentIDs []string, nextDeployEnvironmentID string) (*environments.Environment, error) { - allEnvs, nextDeployEnvironmentName, err := loadEnvironmentsForDeploy(octopus, deployableEnvironmentIDs, nextDeployEnvironmentID) - if err != nil { - return nil, err - } - - optionMap, options := question.MakeItemMapAndOptions(allEnvs, func(e *environments.Environment) string { return e.Name }) - var selectedKey string - err = asker(&survey.Select{ - Message: "Select target environment", - Options: options, - Default: nextDeployEnvironmentName, - }, &selectedKey) - if err != nil { - return nil, err - } - selectedValue, ok := optionMap[selectedKey] - if !ok { - return nil, fmt.Errorf("selectDeploymentEnvironment did not get valid answer (selectedKey=%s)", selectedKey) - } - 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 target 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 { - return nil, err - } - - optionMap, options := question.MakeItemMapAndOptions(allEnvs, func(e *environments.Environment) string { return e.Name }) - var selectedKeys []string - err = asker(&survey.MultiSelect{ - Message: "Select target environment(s)", - Options: options, - Default: []string{nextDeployEnvironmentName}, - }, &selectedKeys, survey.WithValidator(survey.Required)) - - if err != nil { - return nil, err - } - var selectedValues []*environments.Environment - 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 PrintAdvancedSummary(stdout io.Writer, options *executor.TaskOptionsPromoteRelease) { - deployAtStr := "Now" - if options.ScheduledStartTime != "" { - deployAtStr = options.ScheduledStartTime // we assume the server is going to understand this - } - skipStepsStr := "None" - if len(options.ExcludedSteps) > 0 { - skipStepsStr = strings.Join(options.ExcludedSteps, ",") - } - - gfmStr := executionscommon.LookupGuidedFailureModeString(options.GuidedFailureMode) - - pkgDownloadStr := executionscommon.LookupPackageDownloadString(!options.ForcePackageDownload) - - depTargetsStr := "All included" - if len(options.DeploymentTargets) != 0 || len(options.ExcludeTargets) != 0 { - sb := strings.Builder{} - if len(options.DeploymentTargets) > 0 { - sb.WriteString("Include ") - for idx, name := range options.DeploymentTargets { - if idx > 0 { - sb.WriteString(",") - } - sb.WriteString(name) - } - } - if len(options.ExcludeTargets) > 0 { - if sb.Len() > 0 { - sb.WriteString("; ") - } - - sb.WriteString("Exclude ") - for idx, name := range options.ExcludeTargets { - if idx > 0 { - sb.WriteString(",") - } - sb.WriteString(name) - } - } - depTargetsStr = sb.String() - } - - _, _ = fmt.Fprintf(stdout, output.FormatDoc(heredoc.Doc(` - bold(Additional Options): - Deploy Time: cyan(%s) - Skipped Steps: cyan(%s) - Guided Failure Mode: cyan(%s) - Package Download: cyan(%s) - Deployment Targets: cyan(%s) - `)), deployAtStr, skipStepsStr, gfmStr, pkgDownloadStr, depTargetsStr) -} - -// determineIsTenanted returns true if we are going to do a tenanted deployment, false if untenanted -// NOTE: Tenant can be disabled or forced. In these cases we know what to do. -// The middle case is "allowed, but not forced", in which case we don't know ahead of time what to do WRT tenants, -// so we'd need to ask the user. This is not great UX, but the intent of the 'middle ground' tenant state -// is to allow for graceful migrations of older projects, and we don't expect it to happen very often. -// We COULD do a little bit of a shortcut; if tenant is 'allowed but not required' but the project has no -// linked tenants, then it can't be tenanted, but is this worth the extra complexity? Decision: no -func determineIsTenanted(project *projects.Project, ask question.Asker) (bool, error) { - switch project.TenantedDeploymentMode { - case core.TenantedDeploymentModeUntenanted: - return false, nil - case core.TenantedDeploymentModeTenanted: - return true, nil - case core.TenantedDeploymentModeTenantedOrUntenanted: - return question.SelectMap(ask, "Select Tenanted or Untenanted deployment", []bool{true, false}, func(b bool) string { - if b { - return "Tenanted" // should be the default; they probably want tenanted - } else { - return "Untenanted" - } - }) - - default: // should not get here - return false, fmt.Errorf("unhandled tenanted deployment mode %s", project.TenantedDeploymentMode) - } -} - func getPromotionReleaseVersion(octopus *octopusApiClient.Client, space *spaces.Space, projectName string, @@ -1080,7 +599,10 @@ func getPromotionReleaseVersion(octopus *octopusApiClient.Client, sort.Slice(dashboardItem.Items, func(i, j int) bool { return dashboardItem.Items[i].ReleaseVersion < dashboardItem.Items[j].ReleaseVersion }) - var version string + if len(dashboardItem.Items) == 0 { + return nil, errors.New("no release found in dashboard") + } + version := dashboardItem.Items[0].ReleaseVersion if latestSuccessful { for _, item := range dashboardItem.Items { if item.State == "Success" { @@ -1088,8 +610,6 @@ func getPromotionReleaseVersion(octopus *octopusApiClient.Client, break } } - } else { - version = dashboardItem.Items[0].ReleaseVersion } releaseResource, err := releases.GetReleaseInProject(octopus, space.Resource.ID, projectResource.ID, version) if err != nil {