diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index ffc59133..ac3578cb 100644 --- a/pkg/cmd/release/release.go +++ b/pkg/cmd/release/release.go @@ -7,6 +7,7 @@ import ( cmdDeploy "github.com/OctopusDeploy/cli/pkg/cmd/release/deploy" cmdList "github.com/OctopusDeploy/cli/pkg/cmd/release/list" cmdProgression "github.com/OctopusDeploy/cli/pkg/cmd/release/progression" + cmdUpdateVariables "github.com/OctopusDeploy/cli/pkg/cmd/release/update_variables" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/constants/annotations" "github.com/OctopusDeploy/cli/pkg/factory" @@ -29,6 +30,7 @@ func NewCmdRelease(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdList.NewCmdList(f)) cmd.AddCommand(cmdDelete.NewCmdDelete(f)) cmd.AddCommand(cmdProgression.NewCmdProgression(f)) + cmd.AddCommand(cmdUpdateVariables.NewCmdUpdateVariables(f)) return cmd } diff --git a/pkg/cmd/release/update_variables/update_variables.go b/pkg/cmd/release/update_variables/update_variables.go new file mode 100644 index 00000000..59e8958b --- /dev/null +++ b/pkg/cmd/release/update_variables/update_variables.go @@ -0,0 +1,163 @@ +package update_variables + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/release/progression/shared" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/util" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/spf13/cobra" +) + +const ( + FlagProject = "project" + FlagVersion = "version" + FlagAliasReleaseNumberLegacy = "releaseNumber" +) + +type UpdateVariablesFlags struct { + Project *flag.Flag[string] + Version *flag.Flag[string] +} + +func NewUpdateVariablesFlags() *UpdateVariablesFlags { + return &UpdateVariablesFlags{ + Project: flag.New[string](FlagProject, false), + Version: flag.New[string](FlagVersion, false), + } +} + +type UpdateVariablesOptions struct { + *UpdateVariablesFlags + *cmd.Dependencies +} + +func NewUpdateVariablesOptions(flags *UpdateVariablesFlags, dependencies *cmd.Dependencies) *UpdateVariablesOptions { + return &UpdateVariablesOptions{ + UpdateVariablesFlags: flags, + Dependencies: dependencies, + } +} + +func NewCmdUpdateVariables(f factory.Factory) *cobra.Command { + updateVariablesFlags := NewUpdateVariablesFlags() + + cmd := &cobra.Command{ + Use: "update-variables", + Short: "Update the variable snapshot for a release", + Long: "Update the variable snapshot for a release in Octopus Deploy", + Example: heredoc.Docf(` + $ %[1]s release update-variables --project MyProject --version 1.2.3 + $ %[1]s release update-variables -p MyProject -v 1.2.3 + `, constants.ExecutableName), + RunE: func(c *cobra.Command, args []string) error { + opts := NewUpdateVariablesOptions(updateVariablesFlags, cmd.NewDependencies(f, c)) + return updateVariablesRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&updateVariablesFlags.Project.Value, updateVariablesFlags.Project.Name, "p", "", "Name or ID of the project") + flags.StringVarP(&updateVariablesFlags.Version.Value, updateVariablesFlags.Version.Name, "v", "", "Release version/number") + + flags.SortFlags = false + + flagAliases := make(map[string][]string, 1) + util.AddFlagAliasesString(flags, FlagVersion, flagAliases, FlagAliasReleaseNumberLegacy) + + cmd.PreRunE = func(cmd *cobra.Command, args []string) error { + util.ApplyFlagAliases(cmd.Flags(), flagAliases) + return nil + } + + return cmd +} + +func updateVariablesRun(opts *UpdateVariablesOptions) error { + if !opts.NoPrompt { + if err := PromptMissing(opts); err != nil { + return err + } + } + + if opts.Project.Value == "" { + return errors.New("project must be specified") + } + if opts.Version.Value == "" { + return errors.New("version must be specified") + } + + releaseID, err := shared.GetReleaseID(opts.Client, opts.Client.GetSpaceID(), opts.Project.Value, opts.Version.Value) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/%s/releases/%s/snapshot-variables", opts.Client.GetSpaceID(), releaseID) + req, err := http.NewRequest(http.MethodPost, path, nil) + if err != nil { + return err + } + + resp, err := opts.Client.HttpSession().DoRawRequest(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("failed to update variable snapshot (HTTP %d) and failed to read response body: %w", resp.StatusCode, readErr) + } + return fmt.Errorf("failed to update variable snapshot (HTTP %d): %s", resp.StatusCode, string(body)) + } + + fmt.Fprintf(opts.Out, "Successfully updated variable snapshot for release '%s' (%s)\n", opts.Version.Value, output.Dim(releaseID)) + link := output.Bluef("%s/app#/%s/releases/%s", opts.Host, opts.Space.GetID(), releaseID) + fmt.Fprintf(opts.Out, "View this release on Octopus Deploy: %s\n", link) + + if !opts.NoPrompt { + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.GetSpaceNameOrEmpty(), opts.Project, opts.Version) + fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) + } + + return nil +} + +func PromptMissing(opts *UpdateVariablesOptions) error { + var selectedProject *projects.Project + var err error + + if opts.Project.Value == "" { + selectedProject, err = selectors.Project("Select the project containing the release", opts.Client, opts.Ask) + if err != nil { + return err + } + opts.Project.Value = selectedProject.GetName() + } else { + selectedProject, err = selectors.FindProject(opts.Client, opts.Project.Value) + if err != nil { + return err + } + } + + if opts.Version.Value == "" { + selectedRelease, err := shared.SelectRelease(opts.Client, selectedProject, opts.Ask, "Update Variables for") + if err != nil { + return err + } + opts.Version.Value = selectedRelease.Version + } + + return nil +} diff --git a/pkg/cmd/release/update_variables/update_variables_test.go b/pkg/cmd/release/update_variables/update_variables_test.go new file mode 100644 index 00000000..2c10102d --- /dev/null +++ b/pkg/cmd/release/update_variables/update_variables_test.go @@ -0,0 +1,180 @@ +package update_variables_test + +import ( + "bytes" + "testing" + + "github.com/AlecAivazis/survey/v2" + cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +func TestReleaseUpdateVariables(t *testing.T) { + const spaceID = "Spaces-1" + const fireProjectID = "Projects-22" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + + fireProject := fixtures.NewProject(spaceID, fireProjectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "") + rDefault21 := fixtures.NewRelease(spaceID, "Releases-21", "2.1", fireProjectID, "Channels-1") + rDefault20 := fixtures.NewRelease(spaceID, "Releases-20", "2.0", fireProjectID, "Channels-1") + + expectProjectLookup := func(t *testing.T, api *testutil.MockHttpServer) { + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").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{fireProject}, + }) + } + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"noprompt: missing --project returns clear error", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "update-variables", "--version", "2.1", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "project must be specified") + }}, + + {"noprompt: missing --version returns clear error", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "update-variables", "--project", fireProject.Name, "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "version must be specified") + }}, + + {"noprompt: posts to snapshot-variables endpoint and prints success", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "update-variables", "--project", fireProject.Name, "--version", "2.1", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + expectProjectLookup(t, api) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases/2.1").RespondWith(rDefault21) + api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/Releases-21/snapshot-variables").RespondWith(nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Successfully updated variable snapshot for release '2.1'") + assert.Contains(t, stdOut.String(), "Releases-21") + assert.NotContains(t, stdOut.String(), "Automation Command:") + assert.Equal(t, "", stdErr.String()) + }}, + + {"noprompt: legacy --releaseNumber alias maps to --version", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "update-variables", "--project", fireProject.Name, "--releaseNumber", "2.1", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + expectProjectLookup(t, api) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases/2.1").RespondWith(rDefault21) + api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/Releases-21/snapshot-variables").RespondWith(nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Successfully updated variable snapshot for release '2.1'") + }}, + + {"noprompt: server returns non-2xx status returns wrapped error", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "update-variables", "--project", fireProject.Name, "--version", "2.1", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + expectProjectLookup(t, api) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases/2.1").RespondWith(rDefault21) + api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/Releases-21/snapshot-variables"). + RespondWithStatus(500, "500 Internal Server Error", "boom") + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to update variable snapshot (HTTP 500)") + assert.Contains(t, err.Error(), "boom") + }}, + + {"interactive: prompts for project and release then posts", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "update-variables"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/all").RespondWith([]*projects.Project{fireProject}) + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Select the project containing the release", + Options: []string{fireProject.Name}, + }).AnswerWith(fireProject.Name) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases"). + RespondWith(resources.Resources[*releases.Release]{ + Items: []*releases.Release{rDefault21, rDefault20}, + }) + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Select Release to Update Variables for Progression for", + Options: []string{rDefault21.Version, rDefault20.Version}, + }).AnswerWith(rDefault21.Version) + + 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{fireProject}, + }) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/releases/2.1").RespondWith(rDefault21) + api.ExpectRequest(t, "POST", "/api/Spaces-1/releases/Releases-21/snapshot-variables").RespondWith(nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Successfully updated variable snapshot for release '2.1'") + assert.Contains(t, stdOut.String(), "Automation Command:") + assert.Contains(t, stdOut.String(), "--project 'Fire Project'") + assert.Contains(t, stdOut.String(), "--version '2.1'") + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api, qa := testutil.NewMockServerAndAsker() + askProvider := question.NewAskProvider(qa.AsAsker()) + fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider) + rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + test.run(t, api, qa, rootCmd, stdout, stderr) + }) + } +} diff --git a/pkg/cmd/runbook/snapshot/snapshot.go b/pkg/cmd/runbook/snapshot/snapshot.go index 43f3d043..dc6c2373 100644 --- a/pkg/cmd/runbook/snapshot/snapshot.go +++ b/pkg/cmd/runbook/snapshot/snapshot.go @@ -5,6 +5,7 @@ import ( cmdCreate "github.com/OctopusDeploy/cli/pkg/cmd/runbook/snapshot/create" cmdList "github.com/OctopusDeploy/cli/pkg/cmd/runbook/snapshot/list" cmdPublish "github.com/OctopusDeploy/cli/pkg/cmd/runbook/snapshot/publish" + cmdUpdateVariables "github.com/OctopusDeploy/cli/pkg/cmd/runbook/snapshot/update_variables" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/factory" "github.com/spf13/cobra" @@ -24,5 +25,6 @@ func NewCmdSnapshot(f factory.Factory) *cobra.Command { cmd.AddCommand(cmdList.NewCmdList(f)) cmd.AddCommand(cmdCreate.NewCmdCreate(f)) cmd.AddCommand(cmdPublish.NewCmdPublish(f)) + cmd.AddCommand(cmdUpdateVariables.NewCmdUpdateVariables(f)) return cmd } diff --git a/pkg/cmd/runbook/snapshot/update_variables/update_variables.go b/pkg/cmd/runbook/snapshot/update_variables/update_variables.go new file mode 100644 index 00000000..ed032fd8 --- /dev/null +++ b/pkg/cmd/runbook/snapshot/update_variables/update_variables.go @@ -0,0 +1,239 @@ +package update_variables + +import ( + "errors" + "fmt" + "io" + "net/http" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/runbook/shared" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/question/selectors" + "github.com/OctopusDeploy/cli/pkg/util/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/runbooks" + "github.com/spf13/cobra" +) + +const ( + FlagProject = "project" + FlagRunbook = "runbook" + FlagSnapshot = "snapshot" +) + +type UpdateVariablesFlags struct { + Project *flag.Flag[string] + Runbook *flag.Flag[string] + Snapshot *flag.Flag[string] +} + +func NewUpdateVariablesFlags() *UpdateVariablesFlags { + return &UpdateVariablesFlags{ + Project: flag.New[string](FlagProject, false), + Runbook: flag.New[string](FlagRunbook, false), + Snapshot: flag.New[string](FlagSnapshot, false), + } +} + +type UpdateVariablesOptions struct { + *UpdateVariablesFlags + *shared.RunbooksOptions + GetAllProjectsCallback shared.GetAllProjectsCallback + *cmd.Dependencies +} + +func NewUpdateVariablesOptions(updateVariablesFlags *UpdateVariablesFlags, dependencies *cmd.Dependencies) *UpdateVariablesOptions { + return &UpdateVariablesOptions{ + UpdateVariablesFlags: updateVariablesFlags, + RunbooksOptions: shared.NewGetRunbooksOptions(dependencies), + GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(dependencies.Client) }, + Dependencies: dependencies, + } +} + +func NewCmdUpdateVariables(f factory.Factory) *cobra.Command { + updateVariablesFlags := NewUpdateVariablesFlags() + cmd := &cobra.Command{ + Use: "update-variables", + Short: "Update the variable snapshot for a runbook snapshot", + Long: "Update the variable snapshot for a runbook snapshot in Octopus Deploy", + Example: heredoc.Docf(` + $ %[1]s runbook snapshot update-variables --project MyProject --runbook "Rebuild DB Indexes" + $ %[1]s runbook snapshot update-variables --project MyProject --runbook "Rebuild DB Indexes" --snapshot "Snapshot 40C9ENM" + `, constants.ExecutableName), + RunE: func(c *cobra.Command, args []string) error { + opts := NewUpdateVariablesOptions(updateVariablesFlags, cmd.NewDependencies(f, c)) + return updateVariablesRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&updateVariablesFlags.Project.Value, updateVariablesFlags.Project.Name, "p", "", "Name or ID of the project where the runbook is") + flags.StringVarP(&updateVariablesFlags.Runbook.Value, updateVariablesFlags.Runbook.Name, "r", "", "Name or ID of the runbook") + flags.StringVar(&updateVariablesFlags.Snapshot.Value, updateVariablesFlags.Snapshot.Name, "", "Name or ID of the snapshot to update variables for (defaults to the published snapshot)") + + return cmd +} + +func updateVariablesRun(opts *UpdateVariablesOptions) error { + if !opts.NoPrompt { + if err := PromptMissing(opts); err != nil { + return err + } + } + + if opts.Project.Value == "" { + return errors.New("project must be specified") + } + if opts.Runbook.Value == "" { + return errors.New("runbook must be specified") + } + + project, err := selectors.FindProject(opts.Client, opts.Project.Value) + if err != nil { + return err + } + if project == nil { + return errors.New("unable to find project") + } + + if shared.AreRunbooksInGit(project) { + return errors.New("updating variable snapshots is not supported for runbooks stored in Git") + } + + runbook, err := selectors.FindRunbook(opts.Client, project, opts.Runbook.Value) + if err != nil { + return err + } + if runbook == nil { + return errors.New("unable to find runbook") + } + + snapshotID, snapshotName, defaultedToPublished, err := resolveSnapshot(opts, runbook) + if err != nil { + return err + } + + if defaultedToPublished { + fmt.Fprintf(opts.Out, "Updating variables for published snapshot '%s' (%s)\n", snapshotName, output.Dim(snapshotID)) + } + + path := fmt.Sprintf("/api/%s/runbookSnapshots/%s/snapshot-variables", opts.Space.GetID(), snapshotID) + req, err := http.NewRequest(http.MethodPost, path, nil) + if err != nil { + return err + } + + resp, err := opts.Client.HttpSession().DoRawRequest(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("failed to update variable snapshot (HTTP %d) and failed to read response body: %w", resp.StatusCode, readErr) + } + return fmt.Errorf("failed to update variable snapshot (HTTP %d): %s", resp.StatusCode, string(body)) + } + + fmt.Fprintf(opts.Out, "Successfully updated variable snapshot '%s' (%s) for runbook '%s'\n", snapshotName, output.Dim(snapshotID), runbook.Name) + link := output.Bluef("%s/app#/%s/projects/%s/operations/runbooks/%s/snapshots/%s", opts.Host, opts.Space.GetID(), project.GetID(), runbook.GetID(), snapshotID) + fmt.Fprintf(opts.Out, "View this snapshot on Octopus Deploy: %s\n", link) + + if !opts.NoPrompt { + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.GetSpaceNameOrEmpty(), opts.Project, opts.Runbook, opts.Snapshot) + fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) + } + + return nil +} + +func resolveSnapshot(opts *UpdateVariablesOptions, runbook *runbooks.Runbook) (id string, name string, defaultedToPublished bool, err error) { + if opts.Snapshot.Value != "" { + snapshot, err := runbooks.GetSnapshot(opts.Client, opts.Space.GetID(), runbook.ProjectID, opts.Snapshot.Value) + if err != nil { + return "", "", false, err + } + if snapshot == nil { + return "", "", false, errors.New("unable to find snapshot") + } + return snapshot.GetID(), snapshot.Name, false, nil + } + + if runbook.PublishedRunbookSnapshotID == "" { + return "", "", false, errors.New("runbook has no published snapshot; specify a snapshot with --snapshot") + } + + snapshot, err := runbooks.GetSnapshot(opts.Client, opts.Space.GetID(), runbook.ProjectID, runbook.PublishedRunbookSnapshotID) + if err != nil { + return "", "", false, err + } + if snapshot == nil { + return "", "", false, fmt.Errorf("unable to find published snapshot '%s'", runbook.PublishedRunbookSnapshotID) + } + return snapshot.GetID(), snapshot.Name, true, nil +} + +func PromptMissing(opts *UpdateVariablesOptions) error { + project, err := getProject(opts) + if err != nil { + return err + } + opts.Project.Value = project.GetName() + + if shared.AreRunbooksInGit(project) { + return errors.New("updating variable snapshots is not supported for runbooks stored in Git") + } + + selectedRunbook, err := getRunbook(opts, project) + if err != nil { + return err + } + opts.Runbook.Value = selectedRunbook.Name + + return nil +} + +func getProject(opts *UpdateVariablesOptions) (*projects.Project, error) { + var project *projects.Project + var err error + if opts.Project.Value == "" { + project, err = selectors.Select(opts.Ask, "Select the project containing the runbook:", opts.GetAllProjectsCallback, func(p *projects.Project) string { return p.GetName() }) + } else { + project, err = opts.GetProjectCallback(opts.Project.Value) + } + + if err != nil { + return nil, err + } + if project == nil { + return nil, errors.New("unable to find project") + } + + return project, nil +} + +func getRunbook(opts *UpdateVariablesOptions, project *projects.Project) (*runbooks.Runbook, error) { + var runbook *runbooks.Runbook + var err error + if opts.Runbook.Value == "" { + runbook, err = selectors.Select(opts.Ask, "Select the runbook:", func() ([]*runbooks.Runbook, error) { return opts.GetDbRunbooksCallback(project.GetID()) }, func(r *runbooks.Runbook) string { return r.Name }) + } else { + runbook, err = opts.GetDbRunbookCallback(project.GetID(), opts.Runbook.Value) + } + + if err != nil { + return nil, err + } + if runbook == nil { + return nil, errors.New("unable to find runbook") + } + + return runbook, nil +} diff --git a/pkg/cmd/runbook/snapshot/update_variables/update_variables_test.go b/pkg/cmd/runbook/snapshot/update_variables/update_variables_test.go new file mode 100644 index 00000000..6d8cdd37 --- /dev/null +++ b/pkg/cmd/runbook/snapshot/update_variables/update_variables_test.go @@ -0,0 +1,240 @@ +package update_variables_test + +import ( + "bytes" + "testing" + + "github.com/AlecAivazis/survey/v2" + cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" + "github.com/OctopusDeploy/cli/pkg/question" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/resources" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/runbooks" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +var rootResource = testutil.NewRootResource() + +func TestRunbookSnapshotUpdateVariables(t *testing.T) { + const spaceID = "Spaces-1" + const fireProjectID = "Projects-22" + const waterProjectID = "Projects-23" + const runbookID = "Runbooks-1" + const otherRunbookID = "Runbooks-2" + const publishedSnapshotID = "RunbookSnapshots-1" + const otherSnapshotID = "RunbookSnapshots-2" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + + fireProject := fixtures.NewProject(spaceID, fireProjectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", "") + waterProject := fixtures.NewProject(spaceID, waterProjectID, "Water Project", "Lifecycles-1", "ProjectGroups-1", "") + rebuildIndexes := fixtures.NewRunbook(spaceID, fireProjectID, runbookID, "Rebuild DB Indexes") + rebuildIndexes.PublishedRunbookSnapshotID = publishedSnapshotID + healthCheck := fixtures.NewRunbook(spaceID, fireProjectID, otherRunbookID, "Health Check") + + publishedSnapshot := fixtures.NewRunbookSnapshot(fireProjectID, runbookID, publishedSnapshotID, "Snapshot ABC123") + otherSnapshot := fixtures.NewRunbookSnapshot(fireProjectID, runbookID, otherSnapshotID, "Snapshot 40C9ENM") + + expectProjectAndRunbookLookup := func(t *testing.T, api *testutil.MockHttpServer) { + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").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{fireProject}, + }) + // FindRunbook tries GetByID first, falls back to GetByName + api.ExpectRequest(t, "GET", "/api/Spaces-1/runbooks/Rebuild DB Indexes"). + RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/runbooks?partialName=Rebuild+DB+Indexes"). + RespondWith(resources.Resources[*runbooks.Runbook]{ + Items: []*runbooks.Runbook{rebuildIndexes}, + }) + } + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"noprompt: missing --project returns clear error", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"runbook", "snapshot", "update-variables", "--runbook", rebuildIndexes.Name, "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "project must be specified") + }}, + + {"noprompt: missing --runbook returns clear error", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"runbook", "snapshot", "update-variables", "--project", fireProject.Name, "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "runbook must be specified") + }}, + + {"noprompt with --snapshot: posts to named snapshot", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"runbook", "snapshot", "update-variables", + "--project", fireProject.Name, + "--runbook", rebuildIndexes.Name, + "--snapshot", otherSnapshot.Name, + "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + expectProjectAndRunbookLookup(t, api) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/runbookSnapshots/Snapshot 40C9ENM"). + RespondWith(otherSnapshot) + api.ExpectRequest(t, "POST", "/api/Spaces-1/runbookSnapshots/RunbookSnapshots-2/snapshot-variables").RespondWith(nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Successfully updated variable snapshot 'Snapshot 40C9ENM'") + assert.Contains(t, stdOut.String(), "for runbook 'Rebuild DB Indexes'") + // when --snapshot is provided, no "Updating variables for published snapshot" notice + assert.NotContains(t, stdOut.String(), "Updating variables for published snapshot") + }}, + + {"noprompt without --snapshot: defaults to published snapshot and announces it", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"runbook", "snapshot", "update-variables", + "--project", fireProject.Name, + "--runbook", rebuildIndexes.Name, + "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + expectProjectAndRunbookLookup(t, api) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/runbookSnapshots/RunbookSnapshots-1"). + RespondWith(publishedSnapshot) + api.ExpectRequest(t, "POST", "/api/Spaces-1/runbookSnapshots/RunbookSnapshots-1/snapshot-variables").RespondWith(nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Updating variables for published snapshot 'Snapshot ABC123'") + assert.Contains(t, stdOut.String(), "Successfully updated variable snapshot 'Snapshot ABC123'") + assert.Contains(t, stdOut.String(), "for runbook 'Rebuild DB Indexes'") + }}, + + {"runbook with no published snapshot and no --snapshot returns guidance error", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + noPublished := fixtures.NewRunbook(spaceID, fireProjectID, "Runbooks-99", "Restart App") + + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"runbook", "snapshot", "update-variables", + "--project", fireProject.Name, + "--runbook", noPublished.Name, + "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").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{fireProject}}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/runbooks/Restart App").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/runbooks?partialName=Restart+App"). + RespondWith(resources.Resources[*runbooks.Runbook]{Items: []*runbooks.Runbook{noPublished}}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "runbook has no published snapshot; specify a snapshot with --snapshot") + }}, + + {"server returns non-2xx status returns wrapped error including body", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"runbook", "snapshot", "update-variables", + "--project", fireProject.Name, + "--runbook", rebuildIndexes.Name, + "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + expectProjectAndRunbookLookup(t, api) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/runbookSnapshots/RunbookSnapshots-1"). + RespondWith(publishedSnapshot) + api.ExpectRequest(t, "POST", "/api/Spaces-1/runbookSnapshots/RunbookSnapshots-1/snapshot-variables"). + RespondWithStatus(409, "409 Conflict", "snapshot is locked") + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to update variable snapshot (HTTP 409)") + assert.Contains(t, err.Error(), "snapshot is locked") + }}, + + {"interactive: prompts for project and runbook then updates published snapshot with automation command", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"runbook", "snapshot", "update-variables"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/all"). + RespondWith([]*projects.Project{fireProject, waterProject}) + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Select the project containing the runbook:", + Options: []string{fireProject.Name, waterProject.Name}, + }).AnswerWith(fireProject.Name) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/runbooks?take=2147483647"). + RespondWith(resources.Resources[*runbooks.Runbook]{Items: []*runbooks.Runbook{rebuildIndexes, healthCheck}}) + _ = qa.ExpectQuestion(t, &survey.Select{ + Message: "Select the runbook:", + Options: []string{rebuildIndexes.Name, healthCheck.Name}, + }).AnswerWith(rebuildIndexes.Name) + + // after prompts, run path re-resolves project + runbook and resolves the published snapshot + 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{fireProject}}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/runbooks/Rebuild DB Indexes").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/runbooks?partialName=Rebuild+DB+Indexes"). + RespondWith(resources.Resources[*runbooks.Runbook]{Items: []*runbooks.Runbook{rebuildIndexes}}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/runbookSnapshots/RunbookSnapshots-1"). + RespondWith(publishedSnapshot) + api.ExpectRequest(t, "POST", "/api/Spaces-1/runbookSnapshots/RunbookSnapshots-1/snapshot-variables").RespondWith(nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Contains(t, stdOut.String(), "Updating variables for published snapshot 'Snapshot ABC123'") + assert.Contains(t, stdOut.String(), "Successfully updated variable snapshot 'Snapshot ABC123'") + assert.Contains(t, stdOut.String(), "Automation Command:") + assert.Contains(t, stdOut.String(), "--project 'Fire Project'") + assert.Contains(t, stdOut.String(), "--runbook 'Rebuild DB Indexes'") + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api, qa := testutil.NewMockServerAndAsker() + askProvider := question.NewAskProvider(qa.AsAsker()) + fac := testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider) + rootCmd := cmdRoot.NewCmdRoot(fac, nil, askProvider) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + test.run(t, api, qa, rootCmd, stdout, stderr) + }) + } +}