diff --git a/pkg/cmd/release/progression/shared/shared.go b/pkg/cmd/release/progression/shared/shared.go index 94f669b3..1f87a6f7 100644 --- a/pkg/cmd/release/progression/shared/shared.go +++ b/pkg/cmd/release/progression/shared/shared.go @@ -15,6 +15,9 @@ func GetReleaseID(octopus *client.Client, spaceID string, projectIdentifier stri if err != nil { return "", err } + if selectedProject == nil { + return "", fmt.Errorf("unable to find project '%s'", projectIdentifier) + } selectedRelease, err := FindRelease(octopus, selectedProject, version) if err != nil { diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go index a6da7735..61a8ed6e 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" + cmdSnapshotVariables "github.com/OctopusDeploy/cli/pkg/cmd/release/snapshot_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(cmdSnapshotVariables.NewCmdSnapshotVariables(f)) return cmd } diff --git a/pkg/cmd/release/snapshot_variables/snapshot_variables.go b/pkg/cmd/release/snapshot_variables/snapshot_variables.go new file mode 100644 index 00000000..4f74983d --- /dev/null +++ b/pkg/cmd/release/snapshot_variables/snapshot_variables.go @@ -0,0 +1,139 @@ +package snapshot_variables + +import ( + "errors" + "fmt" + + "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/flag" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" + "github.com/spf13/cobra" +) + +const ( + FlagProject = "project" + FlagVersion = "version" +) + +type SnapshotVariablesFlags struct { + Project *flag.Flag[string] + Version *flag.Flag[string] +} + +func NewSnapshotVariablesFlags() *SnapshotVariablesFlags { + return &SnapshotVariablesFlags{ + Project: flag.New[string](FlagProject, false), + Version: flag.New[string](FlagVersion, false), + } +} + +type SnapshotVariablesOptions struct { + *SnapshotVariablesFlags + *cmd.Dependencies +} + +func NewSnapshotVariablesOptions(flags *SnapshotVariablesFlags, dependencies *cmd.Dependencies) *SnapshotVariablesOptions { + return &SnapshotVariablesOptions{ + SnapshotVariablesFlags: flags, + Dependencies: dependencies, + } +} + +func NewCmdSnapshotVariables(f factory.Factory) *cobra.Command { + snapshotVariablesFlags := NewSnapshotVariablesFlags() + + cmd := &cobra.Command{ + Use: "snapshot-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 snapshot-variables --project MyProject --version 1.2.3 + $ %[1]s release snapshot-variables -p MyProject -v 1.2.3 + `, constants.ExecutableName), + RunE: func(c *cobra.Command, args []string) error { + opts := NewSnapshotVariablesOptions(snapshotVariablesFlags, cmd.NewDependencies(f, c)) + return snapshotVariablesRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&snapshotVariablesFlags.Project.Value, snapshotVariablesFlags.Project.Name, "p", "", "Name or ID of the project") + flags.StringVarP(&snapshotVariablesFlags.Version.Value, snapshotVariablesFlags.Version.Name, "v", "", "Release version/number") + + flags.SortFlags = false + + return cmd +} + +func snapshotVariablesRun(opts *SnapshotVariablesOptions) 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 + } + + if _, err := releases.UpdateSnapshotVariables(opts.Client, opts.Client.GetSpaceID(), releaseID); err != nil { + return err + } + + 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 *SnapshotVariablesOptions) 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 selectedProject == nil { + return fmt.Errorf("unable to find project '%s'", opts.Project.Value) + } + } + + 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/snapshot_variables/snapshot_variables_test.go b/pkg/cmd/release/snapshot_variables/snapshot_variables_test.go new file mode 100644 index 00000000..a0193ffc --- /dev/null +++ b/pkg/cmd/release/snapshot_variables/snapshot_variables_test.go @@ -0,0 +1,177 @@ +package snapshot_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/core" + "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 TestReleaseSnapshotVariables(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", "snapshot-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", "snapshot-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", "snapshot-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(rDefault21) + + _, 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: --releaseNumber is not a recognized flag", 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", "snapshot-variables", "--project", fireProject.Name, "--releaseNumber", "2.1", "--no-prompt"}) + return rootCmd.ExecuteC() + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown flag: --releaseNumber") + }}, + + {"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", "snapshot-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", &core.APIError{ErrorMessage: "boom"}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Octopus API error") + 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", "snapshot-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(rDefault21) + + _, 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 992cc971..514f9ec7 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" + cmdSnapshotVariables "github.com/OctopusDeploy/cli/pkg/cmd/runbook/snapshot/snapshot_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(cmdSnapshotVariables.NewCmdSnapshotVariables(f)) return cmd } diff --git a/pkg/cmd/runbook/snapshot/snapshot_variables/snapshot_variables.go b/pkg/cmd/runbook/snapshot/snapshot_variables/snapshot_variables.go new file mode 100644 index 00000000..9ffb81cc --- /dev/null +++ b/pkg/cmd/runbook/snapshot/snapshot_variables/snapshot_variables.go @@ -0,0 +1,221 @@ +package snapshot_variables + +import ( + "errors" + "fmt" + + "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 SnapshotVariablesFlags struct { + Project *flag.Flag[string] + Runbook *flag.Flag[string] + Snapshot *flag.Flag[string] +} + +func NewSnapshotVariablesFlags() *SnapshotVariablesFlags { + return &SnapshotVariablesFlags{ + Project: flag.New[string](FlagProject, false), + Runbook: flag.New[string](FlagRunbook, false), + Snapshot: flag.New[string](FlagSnapshot, false), + } +} + +type SnapshotVariablesOptions struct { + *SnapshotVariablesFlags + *shared.RunbooksOptions + GetAllProjectsCallback shared.GetAllProjectsCallback + *cmd.Dependencies +} + +func NewSnapshotVariablesOptions(snapshotVariablesFlags *SnapshotVariablesFlags, dependencies *cmd.Dependencies) *SnapshotVariablesOptions { + return &SnapshotVariablesOptions{ + SnapshotVariablesFlags: snapshotVariablesFlags, + RunbooksOptions: shared.NewGetRunbooksOptions(dependencies), + GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(dependencies.Client) }, + Dependencies: dependencies, + } +} + +func NewCmdSnapshotVariables(f factory.Factory) *cobra.Command { + snapshotVariablesFlags := NewSnapshotVariablesFlags() + cmd := &cobra.Command{ + Use: "snapshot-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 snapshot-variables --project MyProject --runbook "Rebuild DB Indexes" + $ %[1]s runbook snapshot snapshot-variables --project MyProject --runbook "Rebuild DB Indexes" --snapshot "Snapshot 40C9ENM" + `, constants.ExecutableName), + RunE: func(c *cobra.Command, args []string) error { + opts := NewSnapshotVariablesOptions(snapshotVariablesFlags, cmd.NewDependencies(f, c)) + return snapshotVariablesRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&snapshotVariablesFlags.Project.Value, snapshotVariablesFlags.Project.Name, "p", "", "Name or ID of the project where the runbook is") + flags.StringVarP(&snapshotVariablesFlags.Runbook.Value, snapshotVariablesFlags.Runbook.Name, "r", "", "Name or ID of the runbook") + flags.StringVar(&snapshotVariablesFlags.Snapshot.Value, snapshotVariablesFlags.Snapshot.Name, "", "Name or ID of the snapshot to update variables for (defaults to the published snapshot)") + + return cmd +} + +func snapshotVariablesRun(opts *SnapshotVariablesOptions) 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)) + } + + if _, err := runbooks.UpdateSnapshotVariables(opts.Client, opts.Space.GetID(), snapshotID); err != nil { + return err + } + + 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 *SnapshotVariablesOptions, 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 *SnapshotVariablesOptions) 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 *SnapshotVariablesOptions) (*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 *SnapshotVariablesOptions, 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/snapshot_variables/snapshot_variables_test.go b/pkg/cmd/runbook/snapshot/snapshot_variables/snapshot_variables_test.go new file mode 100644 index 00000000..3e54c985 --- /dev/null +++ b/pkg/cmd/runbook/snapshot/snapshot_variables/snapshot_variables_test.go @@ -0,0 +1,241 @@ +package snapshot_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/core" + "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 TestRunbookSnapshotVariables(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", &core.APIError{ErrorMessage: "not found"}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/runbooks?partialName=Rebuild%20DB%20Indexes"). + 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", "snapshot-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", "snapshot-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", "snapshot-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(otherSnapshot) + + _, 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", "snapshot-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(publishedSnapshot) + + _, 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", "snapshot-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", &core.APIError{ErrorMessage: "not found"}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/runbooks?partialName=Restart%20App"). + 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", "snapshot-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", &core.APIError{ErrorMessage: "snapshot is locked"}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Octopus API error") + 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", "snapshot-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", &core.APIError{ErrorMessage: "not found"}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/runbooks?partialName=Rebuild%20DB%20Indexes"). + 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(publishedSnapshot) + + _, 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) + }) + } +}