diff --git a/go.mod b/go.mod index 8d33070c..c6c629a9 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/OctopusDeploy/go-octodiff v1.0.0 - github.com/OctopusDeploy/go-octopusdeploy/v2 v2.65.4 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.70.1 github.com/bmatcuk/doublestar/v4 v4.4.0 github.com/briandowns/spinner v1.19.0 github.com/google/uuid v1.3.0 diff --git a/go.sum b/go.sum index 31b84b1c..e861fbc3 100644 --- a/go.sum +++ b/go.sum @@ -46,10 +46,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/OctopusDeploy/go-octodiff v1.0.0 h1:U+ORg6azniwwYo+O44giOw6TiD5USk8S4VDhOQ0Ven0= github.com/OctopusDeploy/go-octodiff v1.0.0/go.mod h1:Mze0+EkOWTgTmi8++fyUc6r0aLZT7qD9gX+31t8MmIU= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.65.3 h1:qZfyylXCIXPKRwUwG3fsyhubQblKZBfxphQDJg5Uf/k= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.65.3/go.mod h1:ZCOnCz9ae/uuOk7AIQ9NzjnzFbuN8Q7H3oj2Eq4QSgQ= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.65.4 h1:2y0wbmPT5D1MD2Xvyme0GZXkGF41Y9J84HP5PKkUEQI= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.65.4/go.mod h1:ZCOnCz9ae/uuOk7AIQ9NzjnzFbuN8Q7H3oj2Eq4QSgQ= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.70.1 h1:aW1K0utvdDPSRElTnvp40Xs9oPmJR6IyPAT6PBvPSJs= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.70.1/go.mod h1:ZCOnCz9ae/uuOk7AIQ9NzjnzFbuN8Q7H3oj2Eq4QSgQ= github.com/bmatcuk/doublestar/v4 v4.4.0 h1:LmAwNwhjEbYtyVLzjcP/XeVw4nhuScHGkF/XWXnvIic= github.com/bmatcuk/doublestar/v4 v4.4.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.19.0 h1:s8aq38H+Qju89yhp89b4iIiMzMm8YN3p6vGpwyh/a8E= diff --git a/pkg/cmd/runbook/delete/delete.go b/pkg/cmd/runbook/delete/delete.go index 4b1f7a03..f9b8dab3 100644 --- a/pkg/cmd/runbook/delete/delete.go +++ b/pkg/cmd/runbook/delete/delete.go @@ -95,13 +95,7 @@ func DeleteRun(opts *DeleteOptions) error { return err } - runbooksAreInGit := false - - if project.PersistenceSettings.Type() == projects.PersistenceSettingsTypeVersionControlled { - runbooksAreInGit = project.PersistenceSettings.(projects.GitPersistenceSettings).RunbooksAreInGit() - } - - if runbooksAreInGit { + if shared.AreRunbooksInGit(project) { gitReference, err := getGitReference(opts, project) if err != nil { return err diff --git a/pkg/cmd/runbook/list/list.go b/pkg/cmd/runbook/list/list.go index 88f14fef..38782404 100644 --- a/pkg/cmd/runbook/list/list.go +++ b/pkg/cmd/runbook/list/list.go @@ -2,6 +2,7 @@ package list import ( "errors" + "github.com/OctopusDeploy/cli/pkg/cmd/runbook/shared" "math" "github.com/OctopusDeploy/cli/pkg/apiclient" @@ -120,17 +121,11 @@ func listRun(cmd *cobra.Command, f factory.Factory, flags *ListFlags) error { } var foundRunbooks *resources.Resources[*runbooks.Runbook] - runbooksAreInGit := false - - if selectedProject.PersistenceSettings.Type() == projects.PersistenceSettingsTypeVersionControlled { - runbooksAreInGit = selectedProject.PersistenceSettings.(projects.GitPersistenceSettings).RunbooksAreInGit() - } - if limit <= 0 { limit = math.MaxInt32 } - if runbooksAreInGit { + if shared.AreRunbooksInGit(selectedProject) { if f.IsPromptEnabled() { if gitReference == "" { // we need a git ref; ask for one gitRef, err := selectors.GitReference("Select the Git reference to list runbooks for", octopus, f.Ask, selectedProject) diff --git a/pkg/cmd/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index 4a7a7934..ba30fdc4 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/OctopusDeploy/cli/pkg/cmd/runbook/shared" "github.com/OctopusDeploy/cli/pkg/packages" "golang.org/x/exp/maps" "io" @@ -141,7 +142,7 @@ func NewCmdRun(f factory.Factory) *cobra.Command { Long: "Run runbooks in Octopus Deploy", Example: heredoc.Docf(` $ %[1]s runbook run # fully interactive - $ %[1]s runbook run --project MyProject ... TODO + $ %[1]s runbook run --project MyProject --runbook "Rebuild DB indexes" `, constants.ExecutableName), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 && runFlags.Project.Value == "" { @@ -216,13 +217,7 @@ func runbookRun(cmd *cobra.Command, f factory.Factory, flags *RunFlags) error { } flags.Project.Value = project.Name - runbooksAreInGit := false - - if project.PersistenceSettings.Type() == projects.PersistenceSettingsTypeVersionControlled { - runbooksAreInGit = project.PersistenceSettings.(projects.GitPersistenceSettings).RunbooksAreInGit() - } - - if runbooksAreInGit { + if shared.AreRunbooksInGit(project) { return runGitRunbook(cmd, f, flags, octopus, project, parsedVariables, outputFormat) } else { return runDbRunbook(cmd, f, flags, octopus, project, parsedVariables, outputFormat) diff --git a/pkg/cmd/runbook/shared/shared.go b/pkg/cmd/runbook/shared/shared.go index d78820cd..b3eae866 100644 --- a/pkg/cmd/runbook/shared/shared.go +++ b/pkg/cmd/runbook/shared/shared.go @@ -164,3 +164,13 @@ func GetProject(octopus *client.Client, projectIdentifier string) (*projects.Pro return project, nil } + +func AreRunbooksInGit(project *projects.Project) bool { + inGit := false + + if project.PersistenceSettings.Type() == projects.PersistenceSettingsTypeVersionControlled { + inGit = project.PersistenceSettings.(projects.GitPersistenceSettings).RunbooksAreInGit() + } + + return inGit +} diff --git a/pkg/cmd/runbook/snapshot/create/create.go b/pkg/cmd/runbook/snapshot/create/create.go new file mode 100644 index 00000000..1d9e7be6 --- /dev/null +++ b/pkg/cmd/runbook/snapshot/create/create.go @@ -0,0 +1,431 @@ +package create + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc/v2" + "github.com/OctopusDeploy/cli/pkg/cmd" + "github.com/OctopusDeploy/cli/pkg/cmd/runbook/shared" + "github.com/OctopusDeploy/cli/pkg/cmd/runbook/snapshot/list" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/factory" + "github.com/OctopusDeploy/cli/pkg/gitresources" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/OctopusDeploy/cli/pkg/packages" + "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/feeds" + clientGit "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/gitdependencies" + clientPackages "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/packages" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/runbooks" + "github.com/spf13/cobra" + "os" +) + +const ( + FlagProject = "project" + FlagRunbook = "runbook" + FlagName = "name" + FlagPublish = "publish" + FlagSnapshotNotes = "snapshot-notes" + FlagSnapshotNotesFile = "snapshot-notes-file" + FlagPackageVersionSpec = "package" + FlagPackageVersion = "package-version" + FlagGitResourceRefSpec = "git-resource" +) + +type CreateFlags struct { + Runbook *flag.Flag[string] + Project *flag.Flag[string] + Name *flag.Flag[string] + Publish *flag.Flag[bool] + SnapshotNotes *flag.Flag[string] + SnapshotNotesFile *flag.Flag[string] + PackageVersion *flag.Flag[string] + PackageVersionSpec *flag.Flag[[]string] + GitResourceRefsSpec *flag.Flag[[]string] +} + +func NewCreateFlags() *CreateFlags { + return &CreateFlags{ + Project: flag.New[string](FlagProject, false), + Runbook: flag.New[string](FlagRunbook, false), + Name: flag.New[string](FlagName, false), + Publish: flag.New[bool](FlagPublish, false), + SnapshotNotes: flag.New[string](FlagSnapshotNotes, false), + SnapshotNotesFile: flag.New[string](FlagSnapshotNotesFile, false), + PackageVersion: flag.New[string](FlagPackageVersion, false), + PackageVersionSpec: flag.New[[]string](FlagPackageVersionSpec, false), + GitResourceRefsSpec: flag.New[[]string](FlagGitResourceRefSpec, false), + } +} + +type CreateOptions struct { + *CreateFlags + PackageVersionOverrides []string + *shared.RunbooksOptions + GetAllProjectsCallback shared.GetAllProjectsCallback + *cmd.Dependencies +} + +func NewCreateOptions(createFlags *CreateFlags, dependencies *cmd.Dependencies) *CreateOptions { + return &CreateOptions{ + CreateFlags: createFlags, + RunbooksOptions: shared.NewGetRunbooksOptions(dependencies), + GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(dependencies.Client) }, + Dependencies: dependencies, + } +} + +func NewCmdCreate(f factory.Factory) *cobra.Command { + createFlags := NewCreateFlags() + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a runbook snapshot", + Long: "Create a runbook snapshot in Octopus Deploy", + Aliases: []string{"new"}, + Example: heredoc.Docf(` + $ %[1]s runbook snapshot create --project MyProject --runbook "Rebuild DB Indexes" + $ %[1]s runbook snapshot create --project MyProject --runbook "Rebuild DB Indexes" --name "My cool snapshot" + $ %[1]s runbook snapshot create -p MyProject -r "Restart App" --package "azure-cli:1.2.3" --no-prompt + $ %[1]s runbook snapshot create -p MyProject -r "Restart App" --git-resource "Script step from Git:refs/heads/dev-branch" --publish --no-prompt + `, constants.ExecutableName), + RunE: func(c *cobra.Command, args []string) error { + opts := NewCreateOptions(createFlags, cmd.NewDependencies(f, c)) + outputFormat, err := c.Flags().GetString(constants.FlagOutputFormat) + if err != nil { + outputFormat = constants.OutputFormatTable + } + + return createRun(opts, outputFormat) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&createFlags.Project.Value, createFlags.Project.Name, "p", "", "Name or ID of the project where the runbook is") + flags.StringVarP(&createFlags.Runbook.Value, createFlags.Runbook.Name, "r", "", "Name or ID of the runbook to create the snapshot for") + flags.StringVarP(&createFlags.Name.Value, createFlags.Name.Name, "n", "", "Override the snapshot name") + flags.StringVarP(&createFlags.PackageVersion.Value, createFlags.PackageVersion.Name, "", "", "Default version to use for all packages. Only relevant for config-as-code projects where runbooks are stored in Git.") + flags.StringArrayVarP(&createFlags.PackageVersionSpec.Value, createFlags.PackageVersionSpec.Name, "", []string{}, "Version specification a specific packages.\nFormat as {package}:{version}, {step}:{version} or {package-ref-name}:{packageOrStep}:{version}\nYou may specify this multiple times") + flags.StringVar(&createFlags.SnapshotNotes.Value, createFlags.SnapshotNotes.Name, "", "Release notes to attach") + flags.StringVar(&createFlags.SnapshotNotesFile.Value, createFlags.SnapshotNotesFile.Name, "", "Release notes to attach (from file)") + flags.BoolVar(&createFlags.Publish.Value, createFlags.Publish.Name, false, "Publish the snapshot immediately") + flags.StringArrayVarP(&createFlags.GitResourceRefsSpec.Value, createFlags.GitResourceRefsSpec.Name, "", nil, "Git reference for a specific Git resource.\nFormat as {step}:{git-ref}, {step}:{git-resource-name}:{git-ref}\nYou may specify this multiple times.\nOnly relevant for config-as-code projects where runbooks are stored in Git.") + + return cmd +} + +func createRun(opts *CreateOptions, outputFormat string) error { + if opts.SnapshotNotes.Value != "" && opts.SnapshotNotesFile.Value != "" { + return errors.New("cannot specify both --snapshot-notes and --snapshot-notes-file at the same time") + } + + if !opts.NoPrompt { + if err := PromptMissing(opts); err != nil { + return err + } + } + + if opts.SnapshotNotesFile.Value != "" { + fileContents, err := os.ReadFile(opts.SnapshotNotesFile.Value) + if err != nil { + return err + } + opts.SnapshotNotes.Value = string(fileContents) + } + + project, err := selectors.FindProject(opts.Client, opts.Project.Value) + if err != nil { + return err + } + if project == nil { + return errors.New("unable to find project") + } + + runbooksInGit := shared.AreRunbooksInGit(project) + if runbooksInGit { + return errors.New("creating independent Runbook 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") + } + + runbookTemplate, err := opts.Client.Runbooks.GetRunbookSnapshotTemplate(runbook) + if err != nil { + return err + } + + snapshotName := getSnapshotName(opts, runbookTemplate) + if err != nil { + return err + } + + packageVersionOverrides := make([]*packages.PackageVersionOverride, 0) + packageVersionBaseline := buildPackageVersionBaseline(opts, runbookTemplate) + for _, s := range opts.PackageVersionSpec.Value { + ambOverride, err := packages.ParsePackageOverrideString(s) + if err != nil { + continue // silently ignore anything that wasn't parseable (should we emit a warning?) + } + resolvedOverride, err := packages.ResolvePackageOverride(ambOverride, packageVersionBaseline) + if err != nil { + continue // silently ignore anything that wasn't parseable (should we emit a warning?) + } + packageVersionOverrides = append(packageVersionOverrides, resolvedOverride) + } + + selectedPackages := packages.ApplyPackageOverrides(packageVersionBaseline, packageVersionOverrides) + + snapshot := runbooks.NewRunbookSnapshot(snapshotName, project.GetID(), runbook.ID) + if opts.SnapshotNotes.Value != "" { + snapshot.Notes = opts.SnapshotNotes.Value + } + snapshot.SelectedPackages = util.SliceTransform(selectedPackages, func(p *packages.StepPackageVersion) *clientPackages.SelectedPackage { + return &clientPackages.SelectedPackage{ + ActionName: p.ActionName, + StepName: p.ActionName, + PackageReferenceName: p.PackageReferenceName, + Version: p.Version, + } + }) + + gitRefOverrides := make([]*gitresources.GitResourceGitRef, 0) + gitResourceBaseline := gitresources.BuildGitResourcesBaseline(runbookTemplate.GitResources) + for _, s := range opts.GitResourceRefsSpec.Value { + ambOverride, err := gitresources.ParseGitResourceGitRefString(s) + if err != nil { + continue // silently ignore anything that wasn't parseable (should we emit a warning?) + } + resolvedOverride, err := gitresources.ResolveGitResourceOverride(ambOverride, gitResourceBaseline) + if err != nil { + continue // silently ignore anything that wasn't parseable (should we emit a warning?) + } + gitRefOverrides = append(gitRefOverrides, resolvedOverride) + } + + selectedGitRefs := gitresources.ApplyGitResourceOverrides(gitResourceBaseline, gitRefOverrides) + snapshot.SelectedGitResources = util.SliceTransform(selectedGitRefs, func(g *gitresources.GitResourceGitRef) *clientGit.SelectedGitResources { + return &clientGit.SelectedGitResources{ + ActionName: g.ActionName, + GitReference: &clientGit.GitReference{ + GitRef: g.GitRef, + }, + GitResourceReferenceName: g.GitResourceName, + } + }) + + if opts.Publish.Value { + snapshot, err = opts.Client.RunbookSnapshots.Publish(snapshot) + if err != nil { + return err + } + + } else { + snapshot, err = opts.Client.RunbookSnapshots.Add(snapshot) + if err != nil { + return err + } + } + + switch outputFormat { + case constants.OutputFormatBasic: + fmt.Fprintf(opts.Out, "%s\n", snapshot.GetID()) + case constants.OutputFormatJson: + outputJson := list.SnapshotsAsJson{ + Id: snapshot.GetID(), + Name: snapshotName, + Assembled: snapshot.Assembled, + Published: opts.Publish.Value, + } + data, _ := json.MarshalIndent(outputJson, "", " ") + if err != nil { + return err + } + fmt.Fprintf(opts.Out, string(data)) + default: + if opts.Publish.Value { + _, _ = fmt.Fprintf(opts.Out, "\nSuccessfully created and published runbook snapshot '%s' (%s) for runbook '%s'\n", snapshot.Name, snapshot.GetID(), runbook.Name) + } else { + _, _ = fmt.Fprintf(opts.Out, "\nSuccessfully created runbook snapshot '%s' (%s) for runbook '%s'\n", snapshot.Name, snapshot.GetID(), runbook.Name) + } + link := output.Bluef("%s/app#/%s/projects/%s/operations/runbooks/%s/snapshots/%s", opts.Host, opts.Space.GetID(), project.GetID(), runbook.GetID(), snapshot.GetID()) + fmt.Fprintf(opts.Out, "View this snapshot on Octopus Deploy: %s\n", link) + } + + return nil +} + +func buildPackageVersionBaseline(opts *CreateOptions, runbookTemplate *runbooks.RunbookSnapshotTemplate) []*packages.StepPackageVersion { + feedPackageVersions := make(map[string]string) + pkgs := make([]*packages.StepPackageVersion, 0, len(runbookTemplate.Packages)) + for _, p := range runbookTemplate.Packages { + { + key := fmt.Sprintf("%s/%s", p.FeedID, p.PackageID) + if _, ok := feedPackageVersions[key]; !ok { + packageVersion, err := feeds.SearchPackageVersions(opts.Client, opts.Client.GetSpaceID(), p.FeedID, p.PackageID, "", 1) + if err == nil && packageVersion != nil && !util.Empty(packageVersion.Items) { + feedPackageVersions[key] = packageVersion.Items[0].Version + } + } + } + } + + for _, p := range runbookTemplate.Packages { + pkg := &packages.StepPackageVersion{ + PackageID: p.PackageID, + ActionName: p.ActionName, + PackageReferenceName: p.PackageReferenceName} + if !p.IsResolvable { + pkg.Version = "" + } else { + key := fmt.Sprintf("%s/%s", p.FeedID, p.PackageID) + pkg.Version = feedPackageVersions[key] + } + pkgs = append(pkgs, pkg) + } + + return pkgs +} + +func getSnapshotName(opts *CreateOptions, template *runbooks.RunbookSnapshotTemplate) string { + if opts.Name.Value != "" { + return opts.Name.Value + } + + return template.NextNameIncrement +} + +func PromptMissing(opts *CreateOptions) error { + project, err := getProject(opts) + if err != nil { + return err + } + opts.Project.Value = project.GetName() + + runbooksInGit := shared.AreRunbooksInGit(project) + if runbooksInGit { + return errors.New("creating independent Runbook snapshots is not supported for Runbooks stored in Git") + } + + selectedRunbook, err := getRunbook(opts, project) + if err != nil { + return err + } + opts.Runbook.Value = selectedRunbook.Name + + template, err := opts.Client.Runbooks.GetRunbookSnapshotTemplate(selectedRunbook) + + if err != nil { + return err + } + + if opts.Name.Value == "" { + if err := opts.Ask(&survey.Input{ + Message: "Snapshot name", + Help: "A short, memorable, name for this snapshot.", + Default: template.NextNameIncrement, + }, &opts.Name.Value); err != nil { + return err + } + } + + packageVersionBaseline, err := packages.BuildPackageVersionBaseline(opts.Client, util.SliceTransform(template.Packages, func(pkg *releases.ReleaseTemplatePackage) releases.ReleaseTemplatePackage { return *pkg }), nil) + if err != nil { + return err + } + + if len(packageVersionBaseline) > 0 { // if we have packages, run the package flow + _, packageVersionOverrides, err := packages.AskPackageOverrideLoop( + packageVersionBaseline, + opts.PackageVersion.Value, + opts.PackageVersionOverrides, + opts.Ask, + opts.Out) + + if err != nil { + return err + } + + if len(packageVersionOverrides) > 0 { + opts.PackageVersionOverrides = make([]string, 0, len(packageVersionOverrides)) + for _, ov := range packageVersionOverrides { + opts.PackageVersionOverrides = append(opts.PackageVersionOverrides, ov.ToPackageOverrideString()) + } + } + } + + gitResourcesBaseline := gitresources.BuildGitResourcesBaseline(template.GitResources) + if len(gitResourcesBaseline) > 0 { + overriddenGitResources, err := gitresources.AskGitResourceOverrideLoop( + gitResourcesBaseline, + opts.GitResourceRefsSpec.Value, + opts.Ask, + opts.Out) + + if err != nil { + return err + } + + if len(overriddenGitResources) > 0 { + opts.GitResourceRefsSpec.Value = make([]string, 0, len(overriddenGitResources)) + for _, ov := range overriddenGitResources { + opts.GitResourceRefsSpec.Value = append(opts.GitResourceRefsSpec.Value, ov.ToGitResourceGitRefString()) + } + } + } + + if !opts.Publish.Value { + if err = opts.Ask(&survey.Confirm{ + Message: "Would you like to publish this snapshot immediately?", + Default: false, + }, &opts.Publish.Value); err != nil { + return err + } + } + + return nil +} + +func getProject(opts *CreateOptions) (*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 you wish to snapshot:", opts.GetAllProjectsCallback, func(project *projects.Project) string { return project.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 *CreateOptions, 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 you wish to to snapshot:", func() ([]*runbooks.Runbook, error) { return opts.GetDbRunbooksCallback(project.GetID()) }, func(runbook *runbooks.Runbook) string { return runbook.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 +} diff --git a/pkg/cmd/runbook/snapshot/list/list.go b/pkg/cmd/runbook/snapshot/list/list.go index 8ac7e1d1..51e460e5 100644 --- a/pkg/cmd/runbook/snapshot/list/list.go +++ b/pkg/cmd/runbook/snapshot/list/list.go @@ -40,6 +40,7 @@ type SnapshotsAsJson struct { Id string `json:"Id"` Name string `json:"Name"` Assembled *time.Time `json:"Assembled"` + Published bool `json:"Published"` } func NewCmdList(f factory.Factory) *cobra.Command { @@ -141,12 +142,17 @@ func listRun(cmd *cobra.Command, f factory.Factory, flags *ListFlags) error { Id: s.GetID(), Name: s.Name, Assembled: s.Assembled, + Published: s.GetID() == selectedRunbook.PublishedRunbookSnapshotID, } }, Table: output.TableDefinition[*runbooks.RunbookSnapshot]{ - Header: []string{"ID", "NAME", "ASSEMBLED"}, + Header: []string{"ID", "NAME", "PUBLISHED", "ASSEMBLED"}, Row: func(s *runbooks.RunbookSnapshot) []string { - return []string{s.GetID(), output.Bold(s.Name), s.Assembled.Format(time.RFC1123Z)} + published := "" + if selectedRunbook.PublishedRunbookSnapshotID == s.GetID() { + published = "Yes" + } + return []string{s.GetID(), output.Bold(s.Name), output.Green(published), s.Assembled.Format(time.RFC1123Z)} }, }, Basic: func(s *runbooks.RunbookSnapshot) string { diff --git a/pkg/cmd/runbook/snapshot/publish/publish.go b/pkg/cmd/runbook/snapshot/publish/publish.go new file mode 100644 index 00000000..ce3c1c9f --- /dev/null +++ b/pkg/cmd/runbook/snapshot/publish/publish.go @@ -0,0 +1,237 @@ +package publish + +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" + "math" + "time" +) + +const ( + FlagProject = "project" + FlagRunbook = "runbook" + FlagSnapshot = "snapshot" +) + +type PublishFlags struct { + Runbook *flag.Flag[string] + Project *flag.Flag[string] + Snapshot *flag.Flag[string] +} + +func NewPublishFlags() *PublishFlags { + return &PublishFlags{ + Runbook: flag.New[string](FlagRunbook, false), + Project: flag.New[string](FlagProject, false), + Snapshot: flag.New[string](FlagSnapshot, false), + } +} + +type PublishOptions struct { + *PublishFlags + *shared.RunbooksOptions + GetAllProjectsCallback shared.GetAllProjectsCallback + *cmd.Dependencies +} + +func NewPublishOptions(publishFlags *PublishFlags, dependencies *cmd.Dependencies) *PublishOptions { + return &PublishOptions{ + PublishFlags: publishFlags, + RunbooksOptions: shared.NewGetRunbooksOptions(dependencies), + GetAllProjectsCallback: func() ([]*projects.Project, error) { return shared.GetAllProjects(dependencies.Client) }, + Dependencies: dependencies, + } +} + +func NewCmdPublish(f factory.Factory) *cobra.Command { + publishFlags := NewPublishFlags() + cmd := &cobra.Command{ + Use: "publish", + Short: "Publish a runbook snapshot", + Long: "Publish a runbook snapshot in Octopus Deploy", + Example: heredoc.Docf(` + $ %[1]s runbook snapshot publish --project MyProject --runbook "Rebuild DB Indexes" --snapshot "Snapshot 40C9ENM" + `, constants.ExecutableName), + RunE: func(c *cobra.Command, args []string) error { + opts := NewPublishOptions(publishFlags, cmd.NewDependencies(f, c)) + return publishRun(opts) + }, + } + + flags := cmd.Flags() + flags.StringVarP(&publishFlags.Project.Value, publishFlags.Project.Name, "p", "", "Name or ID of the project where the runbook is") + flags.StringVarP(&publishFlags.Runbook.Value, publishFlags.Runbook.Name, "r", "", "Name or ID of the runbook to publish an existing snapshot") + flags.StringVarP(&publishFlags.Snapshot.Value, publishFlags.Snapshot.Name, "", "", "Name or ID of the snapshot to publish") + + return cmd +} + +func publishRun(opts *PublishOptions) 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") + } + + runbooksInGit := shared.AreRunbooksInGit(project) + if runbooksInGit { + return errors.New("independent Runbook 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") + } + + snapshot, err := findSnapshot(opts, runbook) + if err != nil { + return err + } + + runbook.PublishedRunbookSnapshotID = snapshot.ID + runbook, err = runbooks.Update(opts.Client, runbook) + if err != nil { + return err + } + + fmt.Fprintf(opts.Out, "Runbook snapshot %s has been published\n", output.Greenf("%s", snapshot.Name)) + link := output.Bluef("%s/app#/%s/projects/%s/operations/runbooks/%s/snapshots/%s", opts.Host, opts.Space.GetID(), project.GetID(), runbook.GetID(), snapshot.GetID()) + fmt.Fprintf(opts.Out, "View this snapshot on Octopus Deploy: %s\n", link) + + if !opts.NoPrompt { + autoCmd := flag.GenerateAutomationCmd(opts.CmdPath, opts.Project, opts.Runbook, opts.Snapshot) + fmt.Fprintf(opts.Out, "\nAutomation Command: %s\n", autoCmd) + } + return nil +} + +func PromptMissing(opts *PublishOptions) error { + project, err := getProject(opts) + if err != nil { + return err + } + opts.Project.Value = project.GetName() + + runbooksInGit := shared.AreRunbooksInGit(project) + if runbooksInGit { + return errors.New("independent Runbook snapshots is not supported for Runbooks stored in Git") + } + + selectedRunbook, err := getRunbook(opts, project) + if err != nil { + return err + } + opts.Runbook.Value = selectedRunbook.Name + + selectedSnapshot, err := getSnapshot(opts, selectedRunbook) + if err != nil { + return err + } + opts.Snapshot.Value = selectedSnapshot.Name + + return nil +} + +func getSnapshot(opts *PublishOptions, runbook *runbooks.Runbook) (*runbooks.RunbookSnapshot, error) { + if opts.Snapshot.Value != "" { + snapshot, err := runbooks.GetSnapshot(opts.Client, opts.Space.GetID(), runbook.ProjectID, opts.Snapshot.Value) + if err != nil { + return nil, err + } + if snapshot == nil { + return nil, errors.New("unable to find snapshot") + } + + return snapshot, nil + } + + snapshot, err := selectors.Select(opts.Ask, "Select the snapshot to publish:", func() ([]*runbooks.RunbookSnapshot, error) { + allSnapshots, err := runbooks.ListSnapshots(opts.Client, opts.Space.GetID(), runbook.ProjectID, runbook.GetID(), math.MaxInt16) + if err != nil { + return nil, err + } + + var availableSnapshots = make([]*runbooks.RunbookSnapshot, 0) + for _, s := range allSnapshots.Items { + if s.GetID() != runbook.PublishedRunbookSnapshotID { + availableSnapshots = append(availableSnapshots, s) + } + } + return availableSnapshots, nil + }, func(s *runbooks.RunbookSnapshot) string { + return fmt.Sprintf("%s (Assembled: %s)", s.Name, s.Assembled.Format(time.RFC1123Z)) + }) + + if err != nil { + return nil, err + } + return snapshot, nil +} + +func getProject(opts *PublishOptions) (*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 you wish update the snapshot:", opts.GetAllProjectsCallback, func(project *projects.Project) string { return project.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 *PublishOptions, 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 you wish to you wish to update the snapshot:", func() ([]*runbooks.Runbook, error) { return opts.GetDbRunbooksCallback(project.GetID()) }, func(runbook *runbooks.Runbook) string { return runbook.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 +} + +func findSnapshot(opts *PublishOptions, runbook *runbooks.Runbook) (*runbooks.RunbookSnapshot, error) { + snapshot, err := runbooks.GetSnapshot(opts.Client, opts.Space.GetID(), runbook.ProjectID, opts.Snapshot.Value) + + if err != nil { + return nil, err + } + if snapshot == nil { + return nil, errors.New("unable to find snapshot") + } + + return snapshot, nil +} diff --git a/pkg/cmd/runbook/snapshot/snapshot.go b/pkg/cmd/runbook/snapshot/snapshot.go index 86fee745..43f3d043 100644 --- a/pkg/cmd/runbook/snapshot/snapshot.go +++ b/pkg/cmd/runbook/snapshot/snapshot.go @@ -2,7 +2,9 @@ package snapshot import ( "github.com/MakeNowJust/heredoc/v2" + 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" "github.com/OctopusDeploy/cli/pkg/constants" "github.com/OctopusDeploy/cli/pkg/factory" "github.com/spf13/cobra" @@ -20,5 +22,7 @@ func NewCmdSnapshot(f factory.Factory) *cobra.Command { } cmd.AddCommand(cmdList.NewCmdList(f)) + cmd.AddCommand(cmdCreate.NewCmdCreate(f)) + cmd.AddCommand(cmdPublish.NewCmdPublish(f)) return cmd } diff --git a/pkg/util/util.go b/pkg/util/util.go index 96e519c1..90d8fd81 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -1,6 +1,7 @@ package util import ( + "encoding/json" "fmt" "slices" "sort" @@ -286,3 +287,9 @@ func SplitString(s string, delimiters []rune) []string { } return a } + +// helpful for debugging +func PrintJSON(obj interface{}) { + bytes, _ := json.MarshalIndent(obj, "\t", "\t") + fmt.Println(string(bytes)) +}