From b5b93ff8c540108ce860d56d7c88b2161851dad6 Mon Sep 17 00:00:00 2001 From: Chris Kim Date: Tue, 27 May 2025 10:23:34 +1200 Subject: [PATCH] feat: warn about missing packages when deploying --- go.mod | 2 +- go.sum | 4 +- pkg/cmd/release/deploy/deploy.go | 40 ++++++ pkg/cmd/release/deploy/deploy_test.go | 196 ++++++++++++++++++++++++-- 4 files changed, 231 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 41422fec..7c2713fb 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.72.0 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.73.0 github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index 0617141a..d790764b 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ 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.72.0 h1:q7bAzC/gdTvgeVxypHyTSlBYoH0ejbjE3VIyDfJ2lzw= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.72.0/go.mod h1:ZCOnCz9ae/uuOk7AIQ9NzjnzFbuN8Q7H3oj2Eq4QSgQ= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.73.0 h1:zLDnx3vpFAoNnGLWlPy01Oxr2DjxEwdD5mRu+aoPArA= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.73.0/go.mod h1:ZCOnCz9ae/uuOk7AIQ9NzjnzFbuN8Q7H3oj2Eq4QSgQ= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 67105e77..83ecab03 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/OctopusDeploy/cli/pkg/util/featuretoggle" "golang.org/x/exp/maps" "io" "sort" @@ -436,6 +437,14 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques return err } + 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") + } + } + // machine selection later on needs to refer back to the environments. // NOTE: this is allowed to remain nil; environments will get looked up later on if needed var selectedEnvironments []*environments.Environment @@ -741,6 +750,37 @@ func askDeploymentPreviewVariables(octopus *octopusApiClient.Client, variablesFr 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) { diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index 48d05853..fa6e68c4 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/configuration" "net/url" "testing" "time" @@ -134,6 +135,14 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release.Version).RespondWith(release) + api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ + FeatureToggles: []configuration.ConfiguredFeatureToggle{ + { + Name: "indicate-missing-packages-for-release", + IsEnabled: false, + }, + }, + }) api.ExpectRequest(t, "GET", "/api/Spaces-1/variables/"+vars.ID).RespondWith(&vars) @@ -197,6 +206,15 @@ func TestDeployCreate_AskQuestions(t *testing.T) { Options: []string{release20.Version, release19.Version}, }).AnswerWith(release19.Version) + api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ + FeatureToggles: []configuration.ConfiguredFeatureToggle{ + { + Name: "indicate-missing-packages-for-release", + IsEnabled: false, + }, + }, + }) + api.ExpectRequest(t, "GET", "/api/Spaces-1/releases/"+release19.ID+"/progression").RespondWith(&releases.LifecycleProgression{ Phases: []*releases.LifecycleProgressionPhase{ {Name: "Dev", Progress: releases.PhaseProgressCurrent, AutomaticDeploymentTargets: []string{scratchEnvironment.ID}, OptionalDeploymentTargets: []string{devEnvironment.ID}}, @@ -269,6 +287,14 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release19.Version).RespondWith(release19) + api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ + FeatureToggles: []configuration.ConfiguredFeatureToggle{ + { + Name: "indicate-missing-packages-for-release", + IsEnabled: false, + }, + }, + }) // doesn't lookup the progression or env names because it already has them @@ -336,6 +362,14 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release20.Version).RespondWith(release20) + api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ + FeatureToggles: []configuration.ConfiguredFeatureToggle{ + { + Name: "indicate-missing-packages-for-release", + IsEnabled: false, + }, + }, + }) // now it's going to go looking for prompted variables; we don't have any prompted variables here so it skips api.ExpectRequest(t, "GET", "/api/Spaces-1/variables/"+variableSnapshotWithPromptedVariables.ID).RespondWith(&variableSnapshotWithPromptedVariables) @@ -409,6 +443,14 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release20.Version).RespondWith(release20) + api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ + FeatureToggles: []configuration.ConfiguredFeatureToggle{ + { + Name: "indicate-missing-packages-for-release", + IsEnabled: false, + }, + }, + }) // now it's going to go looking for prompted variables; we don't have any prompted variables here so it skips api.ExpectRequest(t, "GET", "/api/Spaces-1/variables/"+variableSnapshotWithPromptedVariables.ID).RespondWith(&variableSnapshotWithPromptedVariables) @@ -479,6 +521,14 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release19.Version).RespondWith(release19) + api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ + FeatureToggles: []configuration.ConfiguredFeatureToggle{ + { + Name: "indicate-missing-packages-for-release", + IsEnabled: false, + }, + }, + }) api.ExpectRequest(t, "GET", "/api/Spaces-1/releases/"+release19.ID+"/progression").RespondWith(&releases.LifecycleProgression{ Phases: []*releases.LifecycleProgressionPhase{ @@ -582,6 +632,14 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }).AnswerWith("Tenanted") api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release19.Version).RespondWith(release19) + api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ + FeatureToggles: []configuration.ConfiguredFeatureToggle{ + { + Name: "indicate-missing-packages-for-release", + IsEnabled: false, + }, + }, + }) // find environments via progression api.ExpectRequest(t, "GET", "/api/Spaces-1/releases/"+release19.ID+"/progression").RespondWith(&releases.LifecycleProgression{ @@ -683,6 +741,115 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }).AnswerWith("Untenanted") api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release19.Version).RespondWith(release19) + api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ + FeatureToggles: []configuration.ConfiguredFeatureToggle{ + { + Name: "indicate-missing-packages-for-release", + IsEnabled: false, + }, + }, + }) + + // find environments via progression + api.ExpectRequest(t, "GET", "/api/Spaces-1/releases/"+release19.ID+"/progression").RespondWith(&releases.LifecycleProgression{ + Phases: []*releases.LifecycleProgressionPhase{ + {Name: "Dev", Progress: releases.PhaseProgressCurrent, AutomaticDeploymentTargets: []string{scratchEnvironment.ID}, OptionalDeploymentTargets: []string{devEnvironment.ID}}, + {Name: "Prod", Progress: releases.PhaseProgressPending, OptionalDeploymentTargets: []string{prodEnvironment.ID}}, // should scope this out due to pending + }, + NextDeployments: []string{devEnvironment.ID}, + }) + api.ExpectRequest(t, "GET", fmt.Sprintf("/api/Spaces-1/environments?ids=%s%%2C%s", scratchEnvironment.ID, devEnvironment.ID)).RespondWith(resources.Resources[*environments.Environment]{ + Items: []*environments.Environment{scratchEnvironment, devEnvironment}, + }) + + // Note: scratch comes first but default should be dev, due to NextDeployments + _ = qa.ExpectQuestion(t, &survey.MultiSelect{ + Message: "Select environment(s)", + Options: []string{scratchEnvironment.Name, devEnvironment.Name}, + Default: []string{devEnvironment.Name}, + }).AnswerWith([]surveyCore.OptionAnswer{ + {Value: devEnvironment.Name, Index: 0}, + }) + + // now it's going to go looking for prompted variables; we don't have any prompted variables here so it skips + api.ExpectRequest(t, "GET", "/api/Spaces-1/variables/"+variableSnapshotNoVars.ID).RespondWith(&variableSnapshotNoVars) + emptyDeploymentPreviews := fixtures.EmptyDeploymentPreviews() + api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/"+release19.ID+"/deployments/previews").RespondWith(&emptyDeploymentPreviews) + + assert.Equal(t, heredoc.Doc(` + Project Fire Project + Release 1.9 + `), stdout.String()) + stdout.Reset() + + q := qa.ExpectQuestion(t, &survey.Select{ + Message: "Change additional options?", + Options: []string{"Proceed to deploy", "Change"}, + }) + assert.Regexp(t, "Additional Options", stdout.String()) // actual options tested in PrintAdvancedSummary + _ = q.AnswerWith("Proceed to deploy") + + err := <-errReceiver + assert.Nil(t, err) + + // check that the question-asking process has filled out the things we told it to + assert.Equal(t, &executor.TaskOptionsDeployRelease{ + ProjectName: "Fire Project", + ReleaseVersion: "1.9", + Environments: []string{"dev"}, + GuidedFailureMode: "", + Variables: make(map[string]string, 0), + ReleaseID: release19.ID, + }, options) + }}, + + {"prompt if feature toggle is on and a release has missing packages", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, stdout *bytes.Buffer) { + options := &executor.TaskOptionsDeployRelease{ + ProjectName: "fire project", + ReleaseVersion: "1.9", + } + + errReceiver := testutil.GoBegin(func() error { + defer testutil.Close(api, qa) + // NewClient makes network calls so we have to run it in the goroutine + octopus, _ := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, "") + return deploy.AskQuestions(octopus, stdout, qa.AsAsker(), space1, options, now) + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/fire project").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?partialName=fire+project"). + RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{fireProjectMaybeTenanted}, + }) + + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Select Tenanted or Untenanted deployment", + Options: []string{"Tenanted", "Untenanted"}, + }).AnswerWith("Untenanted") + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release19.Version).RespondWith(release19) + api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ + FeatureToggles: []configuration.ConfiguredFeatureToggle{ + { + Name: "indicate-missing-packages-for-release", + IsEnabled: true, + }, + }, + }) + api.ExpectRequest(t, "GET", "/api/Spaces-1/releases/"+release19.ID+"/missingpackages").RespondWith(&releases.MissingPackages{ + Packages: []releases.MissingPackageInfo{ + {ID: "apples", Version: "1.0.0"}, + {ID: "bananas", Version: "2.0.0"}, + }, + }) + + _ = qa.ExpectQuestion(t, &survey.Confirm{ + Message: "Do you want to continue?", + Default: false, + }).AnswerWith("true") // find environments via progression api.ExpectRequest(t, "GET", "/api/Spaces-1/releases/"+release19.ID+"/progression").RespondWith(&releases.LifecycleProgression{ @@ -713,6 +880,11 @@ func TestDeployCreate_AskQuestions(t *testing.T) { assert.Equal(t, heredoc.Doc(` Project Fire Project Release 1.9 + Warning: The following packages are missing from the built-in feed for this release: + - apples (Version: 1.0.0) + - bananas (Version: 2.0.0) + + This might cause the deployment to fail. `), stdout.String()) stdout.Reset() @@ -743,14 +915,6 @@ func TestDeployCreate_AskQuestions(t *testing.T) { errReceiver := testutil.GoBegin(func() error { defer testutil.Close(api, qa) octopus, _ := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, "") - // - //api.ExpectRequest(t, "GET", "/api/Spaces-1/environments/all").RespondWith([]*environments.Environment{ - // devEnvironment, scratchEnvironment, - //}) - // - //emptyDeploymentPreviews := fixtures.EmptyDeploymentPreviews() - //api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/"+release19.ID+"/deployments/previews").RespondWith(&emptyDeploymentPreviews) - return deploy.AskQuestions(octopus, stdout, qa.AsAsker(), space1, options, now) }) @@ -871,6 +1035,14 @@ func TestDeployCreate_AskQuestions(t *testing.T) { }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release19.Version).RespondWith(release19) + api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ + FeatureToggles: []configuration.ConfiguredFeatureToggle{ + { + Name: "indicate-missing-packages-for-release", + IsEnabled: false, + }, + }, + }) api.ExpectRequest(t, "GET", "/api/Spaces-1/releases/"+release19.ID+"/progression").RespondWith(&releases.LifecycleProgression{ Phases: []*releases.LifecycleProgressionPhase{ @@ -1689,6 +1861,14 @@ func TestDeployCreate_GenerationOfAutomationCommand_MasksSensitiveVariables(t *t }) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/"+release20.Version).RespondWith(release20) + api.ExpectRequest(t, "GET", "/api/configuration/feature-toggles?Name=indicate-missing-packages-for-release").RespondWith(&configuration.FeatureToggleConfigurationResponse{ + FeatureToggles: []configuration.ConfiguredFeatureToggle{ + { + Name: "indicate-missing-packages-for-release", + IsEnabled: false, + }, + }, + }) // now it's going to go looking for prompted variables; we don't have any prompted variables here so it skips api.ExpectRequest(t, "GET", "/api/Spaces-1/variables/"+variableSnapshotWithPromptedVariables.ID).RespondWith(&variableSnapshotWithPromptedVariables)