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/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 new file mode 100644 index 00000000..2d6c673c --- /dev/null +++ b/pkg/cmd/release/promote/promote.go @@ -0,0 +1,619 @@ +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/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/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 := cmdDeploy.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 := cmdDeploy.PromptMissingPackages(octopus, stdout, asker, selectedRelease) + if !proceed { + return errors.New("aborting deployment creation as requested") + } + } + + err = cmdDeploy.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 = cmdDeploy.SelectDeploymentEnvironmentsForLifecycleChannel(octopus, stdout, asker, &options.TaskOptionsDeployRelease, selectedRelease, isTenanted) + if err != nil { + return err + } + case channels.ChannelTypeEphemeral: + deploymentEnvironmentIDs, err = cmdDeploy.SelectDeploymentEnvironmentsForEphemeralChannel(octopus, stdout, asker, &options.TaskOptionsDeployRelease, 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 = cmdDeploy.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 = cmdDeploy.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 }) + } + + cmdDeploy.PrintAdvancedSummary(stdout, &options.TaskOptionsDeployRelease) + + 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 = cmdDeploy.AskDeploymentTargets(octopus, asker, space.ID, selectedRelease.ID, deploymentEnvironmentIDs) + if err != nil { + return err + } + } + } + // DONE + return nil +} + +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 + }) + 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" { + version = item.ReleaseVersion + break + } + } + } + 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..8ce9285f 100644 --- a/pkg/executor/release.go +++ b/pkg/executor/release.go @@ -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) +}