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 +}