From b76b70d0db191c2977e2962d3ef41a287c70a8e0 Mon Sep 17 00:00:00 2001 From: Justin Newman Date: Wed, 15 Apr 2026 14:52:30 -0600 Subject: [PATCH 1/6] feat: add update-variables commands for release and runbook snapshots Adds two new commands that refresh variable snapshots in-place via POST to the Octopus Deploy snapshot-variables endpoint, replacing the need for manual PowerShell/REST calls: - `octopus release update-variables --project

--version ` - `octopus runbook snapshot update-variables --project

--runbook [--snapshot ]` The runbook command defaults to the published snapshot when --snapshot is omitted; the release command targets the release directly by version. Co-Authored-By: Claude Sonnet 4.6 --- pkg/cmd/release/release.go | 2 + .../update_variables/update_variables.go | 152 ++++++++++++ pkg/cmd/runbook/snapshot/snapshot.go | 2 + .../update_variables/update_variables.go | 219 ++++++++++++++++++ 4 files changed, 375 insertions(+) create mode 100644 pkg/cmd/release/update_variables/update_variables.go create mode 100644 pkg/cmd/runbook/snapshot/update_variables/update_variables.go 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..eb11bee2 --- /dev/null +++ b/pkg/cmd/release/update_variables/update_variables.go @@ -0,0 +1,152 @@ +package update_variables + +import ( + "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 + } + } + + 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, _ := io.ReadAll(resp.Body) + 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/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..f471f03b --- /dev/null +++ b/pkg/cmd/runbook/snapshot/update_variables/update_variables.go @@ -0,0 +1,219 @@ +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 + } + } + + 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, err := resolveSnapshot(opts, runbook) + if err != nil { + return err + } + + 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, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to update variable snapshot (HTTP %d): %s", resp.StatusCode, string(body)) + } + + fmt.Fprintf(opts.Out, "Successfully updated variable snapshot for '%s'\n", snapshotName) + 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, err error) { + if opts.Snapshot.Value != "" { + snapshot, err := runbooks.GetSnapshot(opts.Client, opts.Space.GetID(), runbook.ProjectID, opts.Snapshot.Value) + if err != nil { + return "", "", err + } + if snapshot == nil { + return "", "", errors.New("unable to find snapshot") + } + return snapshot.GetID(), snapshot.Name, nil + } + + if runbook.PublishedRunbookSnapshotID == "" { + return "", "", 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 "", "", err + } + if snapshot == nil { + return "", "", fmt.Errorf("unable to find published snapshot '%s'", runbook.PublishedRunbookSnapshotID) + } + return snapshot.GetID(), snapshot.Name, 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 project == nil { + return nil, errors.New("unable to find project") + } + + return project, err +} + +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 runbook == nil { + return nil, errors.New("unable to find runbook") + } + + return runbook, err +} From 86b0de6aea186d262aab2c9d21518c79812ddf09 Mon Sep 17 00:00:00 2001 From: Justin Newman Date: Mon, 27 Apr 2026 15:07:54 -0600 Subject: [PATCH 2/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkg/cmd/release/update_variables/update_variables.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/release/update_variables/update_variables.go b/pkg/cmd/release/update_variables/update_variables.go index eb11bee2..8599a304 100644 --- a/pkg/cmd/release/update_variables/update_variables.go +++ b/pkg/cmd/release/update_variables/update_variables.go @@ -107,7 +107,10 @@ func updateVariablesRun(opts *UpdateVariablesOptions) error { defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) + 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)) } From dd88da0b038dd89524e98969299753c1241f8633 Mon Sep 17 00:00:00 2001 From: Justin Newman Date: Mon, 27 Apr 2026 15:12:54 -0600 Subject: [PATCH 3/6] fix: return error if reading HTTP response body fails when updating variable snapshot When the updateVariables call returned a non-2xx status the code previously ignored errors from io.ReadAll and could lose the underlying read error. Now read errors are checked and returned with a descriptive message including the HTTP status code. --- .../runbook/snapshot/update_variables/update_variables.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/runbook/snapshot/update_variables/update_variables.go b/pkg/cmd/runbook/snapshot/update_variables/update_variables.go index f471f03b..94783fbd 100644 --- a/pkg/cmd/runbook/snapshot/update_variables/update_variables.go +++ b/pkg/cmd/runbook/snapshot/update_variables/update_variables.go @@ -124,7 +124,10 @@ func updateVariablesRun(opts *UpdateVariablesOptions) error { defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) + 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)) } From f2adc90eeb9baa26e96348f49fde0602ee1c378f Mon Sep 17 00:00:00 2001 From: Justin Newman Date: Mon, 27 Apr 2026 15:57:26 -0600 Subject: [PATCH 4/6] fix: indicate when published snapshot is used and propagate errors in update-variables When updating runbook variable snapshots, return whether the published snapshot was used and print a notice showing the snapshot name/ID. Improve success output to include snapshot ID and runbook name. Also ensure errors from helper lookups (project/runbook) are propagated immediately rather than returned alongside a nil value. These changes clarify behavior when --snapshot is omitted and tighten error handling. --- .../update_variables/update_variables.go | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/runbook/snapshot/update_variables/update_variables.go b/pkg/cmd/runbook/snapshot/update_variables/update_variables.go index 94783fbd..c4447c29 100644 --- a/pkg/cmd/runbook/snapshot/update_variables/update_variables.go +++ b/pkg/cmd/runbook/snapshot/update_variables/update_variables.go @@ -106,11 +106,15 @@ func updateVariablesRun(opts *UpdateVariablesOptions) error { return errors.New("unable to find runbook") } - snapshotID, snapshotName, err := resolveSnapshot(opts, 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 { @@ -125,13 +129,13 @@ func updateVariablesRun(opts *UpdateVariablesOptions) error { 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) - } + 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 '%s'\n", snapshotName) + 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) @@ -143,30 +147,30 @@ func updateVariablesRun(opts *UpdateVariablesOptions) error { return nil } -func resolveSnapshot(opts *UpdateVariablesOptions, runbook *runbooks.Runbook) (id string, name string, err error) { +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 "", "", err + return "", "", false, err } if snapshot == nil { - return "", "", errors.New("unable to find snapshot") + return "", "", false, errors.New("unable to find snapshot") } - return snapshot.GetID(), snapshot.Name, nil + return snapshot.GetID(), snapshot.Name, false, nil } if runbook.PublishedRunbookSnapshotID == "" { - return "", "", errors.New("runbook has no published snapshot; specify a snapshot with --snapshot") + 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 "", "", err + return "", "", false, err } if snapshot == nil { - return "", "", fmt.Errorf("unable to find published snapshot '%s'", runbook.PublishedRunbookSnapshotID) + return "", "", false, fmt.Errorf("unable to find published snapshot '%s'", runbook.PublishedRunbookSnapshotID) } - return snapshot.GetID(), snapshot.Name, nil + return snapshot.GetID(), snapshot.Name, true, nil } func PromptMissing(opts *UpdateVariablesOptions) error { @@ -198,11 +202,14 @@ func getProject(opts *UpdateVariablesOptions) (*projects.Project, error) { 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, err + return project, nil } func getRunbook(opts *UpdateVariablesOptions, project *projects.Project) (*runbooks.Runbook, error) { @@ -214,9 +221,12 @@ func getRunbook(opts *UpdateVariablesOptions, project *projects.Project) (*runbo 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, err + return runbook, nil } From a79d880d634fc6e5cada7fdf8d024ece9b219511 Mon Sep 17 00:00:00 2001 From: Justin Newman Date: Mon, 27 Apr 2026 16:41:37 -0600 Subject: [PATCH 5/6] feat: add tests for release and runbook snapshot update-variables commands Add comprehensive tests for the new "update-variables" commands: - pkg/cmd/release/update_variables/update_variables_test.go - pkg/cmd/runbook/snapshot/update_variables/update_variables_test.go Tests cover interactive and no-prompt flows, project/runbook lookup behavior, handling of published vs. explicit runbook snapshots, successful POST to snapshot-variables, and error responses (including non-2xx status bodies). These tests help validate CLI prompts, automation command output, and error propagation for the update-variables functionality. --- .../update_variables/update_variables_test.go | 152 +++++++++++++ .../update_variables/update_variables_test.go | 212 ++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 pkg/cmd/release/update_variables/update_variables_test.go create mode 100644 pkg/cmd/runbook/snapshot/update_variables/update_variables_test.go 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..585dc742 --- /dev/null +++ b/pkg/cmd/release/update_variables/update_variables_test.go @@ -0,0 +1,152 @@ +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: 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/update_variables/update_variables_test.go b/pkg/cmd/runbook/snapshot/update_variables/update_variables_test.go new file mode 100644 index 00000000..31b1dd56 --- /dev/null +++ b/pkg/cmd/runbook/snapshot/update_variables/update_variables_test.go @@ -0,0 +1,212 @@ +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 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) + }) + } +} From 4ad8b0b77e54e745915c8f3c3d72842ab70fa370 Mon Sep 17 00:00:00 2001 From: Justin Newman Date: Mon, 27 Apr 2026 16:46:37 -0600 Subject: [PATCH 6/6] feat: validate required project/version/runbook flags for update-variables and add no-prompt tests Return clear errors when required flags are missing (project/version for release, project/runbook for runbook snapshot) and add corresponding no-prompt tests to ensure the CLI fails fast with a helpful message. --- .../update_variables/update_variables.go | 8 ++++++ .../update_variables/update_variables_test.go | 28 +++++++++++++++++++ .../update_variables/update_variables.go | 7 +++++ .../update_variables/update_variables_test.go | 28 +++++++++++++++++++ 4 files changed, 71 insertions(+) diff --git a/pkg/cmd/release/update_variables/update_variables.go b/pkg/cmd/release/update_variables/update_variables.go index 8599a304..59e8958b 100644 --- a/pkg/cmd/release/update_variables/update_variables.go +++ b/pkg/cmd/release/update_variables/update_variables.go @@ -1,6 +1,7 @@ package update_variables import ( + "errors" "fmt" "io" "net/http" @@ -89,6 +90,13 @@ func updateVariablesRun(opts *UpdateVariablesOptions) error { } } + 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 diff --git a/pkg/cmd/release/update_variables/update_variables_test.go b/pkg/cmd/release/update_variables/update_variables_test.go index 585dc742..2c10102d 100644 --- a/pkg/cmd/release/update_variables/update_variables_test.go +++ b/pkg/cmd/release/update_variables/update_variables_test.go @@ -42,6 +42,34 @@ func TestReleaseUpdateVariables(t *testing.T) { 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() diff --git a/pkg/cmd/runbook/snapshot/update_variables/update_variables.go b/pkg/cmd/runbook/snapshot/update_variables/update_variables.go index c4447c29..ed032fd8 100644 --- a/pkg/cmd/runbook/snapshot/update_variables/update_variables.go +++ b/pkg/cmd/runbook/snapshot/update_variables/update_variables.go @@ -86,6 +86,13 @@ func updateVariablesRun(opts *UpdateVariablesOptions) error { } } + 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 diff --git a/pkg/cmd/runbook/snapshot/update_variables/update_variables_test.go b/pkg/cmd/runbook/snapshot/update_variables/update_variables_test.go index 31b1dd56..6d8cdd37 100644 --- a/pkg/cmd/runbook/snapshot/update_variables/update_variables_test.go +++ b/pkg/cmd/runbook/snapshot/update_variables/update_variables_test.go @@ -59,6 +59,34 @@ func TestRunbookSnapshotUpdateVariables(t *testing.T) { 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()