diff --git a/go.mod b/go.mod index 45461615..2a678ee6 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.89.1 + github.com/OctopusDeploy/go-octopusdeploy/v2 v2.99.0 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 40ee8852..22d760ab 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +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.89.1 h1:EDo4CdA7jYZBHiaKu8nZRf9ndonJXC70OlU29+6KXKc= -github.com/OctopusDeploy/go-octopusdeploy/v2 v2.89.1/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.99.0 h1:0HzgNBPiOGY7ekP+uoRbX1DeMs0Y2JpJ3ecmUxFtC1o= +github.com/OctopusDeploy/go-octopusdeploy/v2 v2.99.0/go.mod h1:VkTXDoIPbwGFi5+goo1VSwFNdMVo784cVtJdKIEvfus= 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/list/list_test.go b/pkg/cmd/runbook/list/list_test.go index 6ffffb27..9f23be19 100644 --- a/pkg/cmd/runbook/list/list_test.go +++ b/pkg/cmd/runbook/list/list_test.go @@ -378,9 +378,7 @@ func TestGitRunbookList(t *testing.T) { 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}, - }) + RespondWithJSON(fixtures.AsServerResponseArray([]*projects.Project{fireProject})) _, err := testutil.ReceivePair(cmdReceiver) assert.EqualError(t, err, "git reference must be specified") @@ -399,7 +397,7 @@ func TestGitRunbookList(t *testing.T) { 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}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/all").RespondWithJSON(fixtures.AsServerResponsePlainArray([]*projects.Project{fireProject})) _ = qa.ExpectQuestion(t, &survey.Select{ Message: "Select the project to list runbooks for", @@ -453,9 +451,7 @@ func TestGitRunbookList(t *testing.T) { 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}, - }) + RespondWithJSON(fixtures.AsServerResponseArray([]*projects.Project{fireProject})) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/refs%2Fheads%2Fmain/runbooks?take=2147483647"). RespondWith(resources.Resources[*runbooks.Runbook]{ @@ -484,9 +480,7 @@ func TestGitRunbookList(t *testing.T) { 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}, - }) + RespondWithJSON(fixtures.AsServerResponseArray([]*projects.Project{fireProject})) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/refs%2Fheads%2Fmain/runbooks?take=2147483647"). RespondWith(resources.Resources[*runbooks.Runbook]{ @@ -515,9 +509,7 @@ func TestGitRunbookList(t *testing.T) { 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}, - }) + RespondWithJSON(fixtures.AsServerResponseArray([]*projects.Project{fireProject})) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/refs%2Fheads%2Fmain/runbooks?take=2147483647"). RespondWith(resources.Resources[*runbooks.Runbook]{ @@ -547,9 +539,7 @@ func TestGitRunbookList(t *testing.T) { 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}, - }) + RespondWithJSON(fixtures.AsServerResponseArray([]*projects.Project{fireProject})) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/refs%2Fheads%2Fmain/runbooks?take=1&partialName=Apply"). RespondWith(resources.Resources[*runbooks.Runbook]{ @@ -577,9 +567,7 @@ func TestGitRunbookList(t *testing.T) { 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}, - }) + RespondWithJSON(fixtures.AsServerResponseArray([]*projects.Project{fireProject})) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/refs%2Fheads%2Fmain/runbooks?take=2147483647"). RespondWith(resources.Resources[*runbooks.Runbook]{ @@ -620,9 +608,7 @@ func TestGitRunbookList(t *testing.T) { 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}, - }) + RespondWithJSON(fixtures.AsServerResponseArray([]*projects.Project{fireProject})) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/refs%2Fheads%2Fmain/runbooks?take=2147483647"). RespondWith(resources.Resources[*runbooks.Runbook]{ @@ -650,9 +636,7 @@ func TestGitRunbookList(t *testing.T) { 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}, - }) + RespondWithJSON(fixtures.AsServerResponseArray([]*projects.Project{fireProject})) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Projects-22/refs%2Fheads%2Fmain/runbooks?take=2147483647"). RespondWith(resources.Resources[*runbooks.Runbook]{ diff --git a/pkg/cmd/runbook/run/run.go b/pkg/cmd/runbook/run/run.go index ba30fdc4..d7da5b9c 100644 --- a/pkg/cmd/runbook/run/run.go +++ b/pkg/cmd/runbook/run/run.go @@ -46,6 +46,8 @@ const ( FlagRunbookName = "name" FlagAliasRunbookLegacy = "runbook" + FlagRunbookTag = "runbook-tag" // can be specified multiple times + FlagSnapshot = "snapshot" FlagEnvironment = "environment" // can be specified multiple times; but only once if tenanted @@ -93,6 +95,7 @@ const ( type RunFlags struct { Project *flag.Flag[string] RunbookName *flag.Flag[string] // the runbook to run + RunbookTags *flag.Flag[[]string] Environments *flag.Flag[[]string] Tenants *flag.Flag[[]string] TenantTags *flag.Flag[[]string] @@ -115,6 +118,7 @@ func NewRunFlags() *RunFlags { return &RunFlags{ Project: flag.New[string](FlagProject, false), RunbookName: flag.New[string](FlagRunbookName, false), + RunbookTags: flag.New[[]string](FlagRunbookTag, false), Environments: flag.New[[]string](FlagEnvironment, false), Tenants: flag.New[[]string](FlagTenant, false), TenantTags: flag.New[[]string](FlagTenantTag, false), @@ -156,6 +160,7 @@ func NewCmdRun(f factory.Factory) *cobra.Command { flags := cmd.Flags() flags.StringVarP(&runFlags.Project.Value, runFlags.Project.Name, "p", "", "Name or ID of the project to run the runbook from") flags.StringVarP(&runFlags.RunbookName.Value, runFlags.RunbookName.Name, "n", "", "Name of the runbook to run") + flags.StringArrayVarP(&runFlags.RunbookTags.Value, runFlags.RunbookTags.Name, "", nil, "Run all runbooks matching this tag (can be specified multiple times). Format is 'Tag Set Name/Tag Name'. Mutually exclusive with --name.") flags.StringArrayVarP(&runFlags.Environments.Value, runFlags.Environments.Name, "e", nil, "Run in this environment (can be specified multiple times)") flags.StringArrayVarP(&runFlags.Tenants.Value, runFlags.Tenants.Name, "", nil, "Run for this tenant (can be specified multiple times)") flags.StringArrayVarP(&runFlags.TenantTags.Value, runFlags.TenantTags.Name, "", nil, "Run for tenants matching this tag (can be specified multiple times). Format is 'Tag Set Name/Tag Name', such as 'Regions/South'.") @@ -195,6 +200,10 @@ func NewCmdRun(f factory.Factory) *cobra.Command { } func runbookRun(cmd *cobra.Command, f factory.Factory, flags *RunFlags) error { + if flags.RunbookName.Value != "" && len(flags.RunbookTags.Value) > 0 { + return errors.New("--name and --runbook-tag are mutually exclusive. Please specify either a runbook name or runbook tags, not both") + } + outputFormat, err := cmd.Flags().GetString(constants.FlagOutputFormat) if err != nil { // should never happen, but fallback if it does outputFormat = constants.OutputFormatTable @@ -217,6 +226,49 @@ func runbookRun(cmd *cobra.Command, f factory.Factory, flags *RunFlags) error { } flags.Project.Value = project.Name + + if f.IsPromptEnabled() && flags.RunbookName.Value == "" && len(flags.RunbookTags.Value) == 0 { + var runBySelection string + err = f.Ask(&survey.Select{ + Message: "How do you want to run runbooks?", + Options: []string{"By name", "By tag"}, + }, &runBySelection) + if err != nil { + return err + } + + if runBySelection == "By tag" { + if shared.AreRunbooksInGit(project) { + if flags.GitRef.Value == "" { + gitRef, err := selectors.GitReference("Select the Git Reference to run for", octopus, f.Ask, project) + if err != nil { + return err + } + flags.GitRef.Value = gitRef.CanonicalName + } + tags, err := selectGitRunbookTags(octopus, f.Ask, f.GetCurrentSpace(), project, flags.GitRef.Value) + if err != nil { + return err + } + flags.RunbookTags.Value = tags + } else { + tags, err := selectRunbookTags(octopus, f.Ask, f.GetCurrentSpace(), project) + if err != nil { + return err + } + flags.RunbookTags.Value = tags + } + } + } + + if len(flags.RunbookTags.Value) > 0 { + if shared.AreRunbooksInGit(project) { + return runRunbooksByTag(cmd, f, flags, octopus, project, parsedVariables, outputFormat, true) + } else { + return runRunbooksByTag(cmd, f, flags, octopus, project, parsedVariables, outputFormat, false) + } + } + if shared.AreRunbooksInGit(project) { return runGitRunbook(cmd, f, flags, octopus, project, parsedVariables, outputFormat) } else { @@ -1295,3 +1347,361 @@ func findGitRunbook(octopus *octopusApiClient.Client, spaceID string, projectID } return result, err } + +func filterRunbooksByTags(allRunbooks []*runbooks.Runbook, tags []string) []*runbooks.Runbook { + var matchingRunbooks []*runbooks.Runbook + for _, runbook := range allRunbooks { + for _, tag := range tags { + if util.SliceContains(runbook.RunbookTags, tag) { + matchingRunbooks = append(matchingRunbooks, runbook) + break + } + } + } + return matchingRunbooks +} + +func selectRunbookTags(octopus *octopusApiClient.Client, asker question.Asker, space *spaces.Space, project *projects.Project) ([]string, error) { + allRunbooks, err := shared.GetAllRunbooks(octopus, project.ID) + if err != nil { + return nil, err + } + + tagMap := make(map[string]bool) + for _, runbook := range allRunbooks { + for _, tag := range runbook.RunbookTags { + tagMap[tag] = true + } + } + + if len(tagMap) == 0 { + return nil, fmt.Errorf("no runbooks with tags found in project %s", project.Name) + } + + availableTags := make([]string, 0, len(tagMap)) + for tag := range tagMap { + availableTags = append(availableTags, tag) + } + sort.Strings(availableTags) + + var selectedTags []string + err = asker(&survey.MultiSelect{ + Message: "Select runbook tags (space to select, enter to confirm):", + Options: availableTags, + }, &selectedTags) + if err != nil { + return nil, err + } + + if len(selectedTags) == 0 { + return nil, fmt.Errorf("at least one tag must be selected") + } + + return selectedTags, nil +} + +func selectGitRunbookTags(octopus *octopusApiClient.Client, asker question.Asker, space *spaces.Space, project *projects.Project, gitRef string) ([]string, error) { + allRunbooks, err := shared.GetAllGitRunbooks(octopus, project.ID, gitRef) + if err != nil { + return nil, err + } + + tagMap := make(map[string]bool) + for _, runbook := range allRunbooks { + for _, tag := range runbook.RunbookTags { + tagMap[tag] = true + } + } + + if len(tagMap) == 0 { + return nil, fmt.Errorf("no runbooks with tags found in project %s", project.Name) + } + + availableTags := make([]string, 0, len(tagMap)) + for tag := range tagMap { + availableTags = append(availableTags, tag) + } + sort.Strings(availableTags) + + var selectedTags []string + err = asker(&survey.MultiSelect{ + Message: "Select runbook tags (space to select, enter to confirm):", + Options: availableTags, + }, &selectedTags) + if err != nil { + return nil, err + } + + if len(selectedTags) == 0 { + return nil, fmt.Errorf("at least one tag must be selected") + } + + return selectedTags, nil +} + +type runbookTaskResult struct { + runbookName string + environments []string + runbookRunServerTasks []*runbooks.RunbookRunServerTask + err error +} + +func processRunbookTasks(octopus *octopusApiClient.Client, space *spaces.Space, tasks []*executor.Task) []runbookTaskResult { + results := make([]runbookTaskResult, len(tasks)) + + for i, task := range tasks { + var runbookName string + var environments []string + var serverTasks []*runbooks.RunbookRunServerTask + var err error + + switch task.Type { + case executor.TaskTypeRunbookRun: + params, ok := task.Options.(*executor.TaskOptionsRunbookRun) + if ok { + runbookName = params.RunbookName + environments = params.Environments + err = executor.ProcessTasks(octopus, space, []*executor.Task{task}) + if params.Response != nil { + serverTasks = params.Response.RunbookRunServerTasks + } + } else { + err = fmt.Errorf("invalid task options type for RunbookRun") + } + case executor.TaskTypeGitRunbookRun: + params, ok := task.Options.(*executor.TaskOptionsGitRunbookRun) + if ok { + runbookName = params.RunbookName + environments = params.Environments + err = executor.ProcessTasks(octopus, space, []*executor.Task{task}) + if params.Response != nil { + serverTasks = params.Response.RunbookRunServerTasks + } + } else { + err = fmt.Errorf("invalid task options type for GitRunbookRun") + } + default: + err = fmt.Errorf("unhandled task type %s", task.Type) + } + + results[i] = runbookTaskResult{ + runbookName: runbookName, + environments: environments, + runbookRunServerTasks: serverTasks, + err: err, + } + } + + return results +} + +func runRunbooksByTag(cmd *cobra.Command, f factory.Factory, flags *RunFlags, octopus *octopusApiClient.Client, project *projects.Project, parsedVariables map[string]string, outputFormat string, isGit bool) error { + var allRunbooks []*runbooks.Runbook + var err error + + if isGit { + if flags.GitRef.Value == "" { + return errors.New("--git-ref is required when running runbooks by tag in a git-based project") + } + allRunbooks, err = shared.GetAllGitRunbooks(octopus, project.ID, flags.GitRef.Value) + } else { + allRunbooks, err = shared.GetAllRunbooks(octopus, project.ID) + } + + if err != nil { + return err + } + + matchingRunbooks := filterRunbooksByTags(allRunbooks, flags.RunbookTags.Value) + + if len(matchingRunbooks) == 0 { + return fmt.Errorf("no runbooks found matching tags: %s", strings.Join(flags.RunbookTags.Value, ", ")) + } + + if !constants.IsProgrammaticOutputFormat(outputFormat) { + cmd.Printf("Found %d runbook(s) matching tags:\n", len(matchingRunbooks)) + for _, rb := range matchingRunbooks { + cmd.Printf(" - %s\n", rb.Name) + } + cmd.Println() + } + + var selectedEnvironments []*environments.Environment + if f.IsPromptEnabled() { + if len(flags.Environments.Value) == 0 { + if isGit { + selectedEnvironments, err = selectGitRunEnvironments(f.Ask, octopus, f.GetCurrentSpace(), project, matchingRunbooks[0], flags.GitRef.Value) + } else { + selectedEnvironments, err = selectRunEnvironments(f.Ask, octopus, f.GetCurrentSpace(), project, matchingRunbooks[0]) + } + if err != nil { + return err + } + flags.Environments.Value = util.SliceTransform(selectedEnvironments, func(env *environments.Environment) string { return env.Name }) + } + + if len(flags.Tenants.Value) == 0 && len(flags.TenantTags.Value) == 0 { + tenantedDeploymentMode := false + if project.TenantedDeploymentMode == core.TenantedDeploymentModeTenanted { + tenantedDeploymentMode = true + } + flags.Tenants.Value, flags.TenantTags.Value, _ = executionscommon.AskTenantsAndTags(f.Ask, octopus, matchingRunbooks[0].ProjectID, selectedEnvironments, tenantedDeploymentMode) + } + } + + if len(flags.Environments.Value) == 0 { + return errors.New("environment(s) must be specified") + } + + if f.IsPromptEnabled() && !constants.IsProgrammaticOutputFormat(outputFormat) { + resolvedFlags := NewRunFlags() + resolvedFlags.Project.Value = flags.Project.Value + resolvedFlags.RunbookTags.Value = flags.RunbookTags.Value + resolvedFlags.Environments.Value = flags.Environments.Value + resolvedFlags.Tenants.Value = flags.Tenants.Value + resolvedFlags.TenantTags.Value = flags.TenantTags.Value + + if isGit { + resolvedFlags.GitRef.Value = flags.GitRef.Value + autoCmd := flag.GenerateAutomationCmd(constants.ExecutableName+" runbook run", + resolvedFlags.Project, + resolvedFlags.RunbookTags, + resolvedFlags.GitRef, + resolvedFlags.Environments, + resolvedFlags.Tenants, + resolvedFlags.TenantTags, + ) + cmd.Printf("\nAutomation Command: %s\n", autoCmd) + } else { + autoCmd := flag.GenerateAutomationCmd(constants.ExecutableName+" runbook run", + resolvedFlags.Project, + resolvedFlags.RunbookTags, + resolvedFlags.Environments, + resolvedFlags.Tenants, + resolvedFlags.TenantTags, + ) + cmd.Printf("\nAutomation Command: %s\n", autoCmd) + } + } + + tasks := make([]*executor.Task, 0, len(matchingRunbooks)) + for _, runbook := range matchingRunbooks { + commonOptions := &executor.TaskOptionsRunbookRunBase{ + ProjectName: project.Name, + RunbookName: runbook.Name, + Environments: flags.Environments.Value, + Tenants: flags.Tenants.Value, + TenantTags: flags.TenantTags.Value, + ScheduledStartTime: flags.RunAt.Value, + ScheduledExpiryTime: flags.MaxQueueTime.Value, + ExcludedSteps: flags.ExcludedSteps.Value, + GuidedFailureMode: flags.GuidedFailureMode.Value, + ForcePackageDownload: flags.ForcePackageDownload.Value, + RunTargets: flags.RunTargets.Value, + ExcludeTargets: flags.ExcludeTargets.Value, + Variables: parsedVariables, + } + + if isGit { + gitOptions := &executor.TaskOptionsGitRunbookRun{ + GitReference: flags.GitRef.Value, + DefaultPackageVersion: flags.PackageVersion.Value, + PackageVersionOverrides: flags.PackageVersionSpec.Value, + GitResourceRefs: flags.GitResourceRefsSpec.Value, + } + gitOptions.TaskOptionsRunbookRunBase = *commonOptions + tasks = append(tasks, executor.NewTask(executor.TaskTypeGitRunbookRun, gitOptions)) + } else { + dbOptions := &executor.TaskOptionsRunbookRun{ + Snapshot: flags.Snapshot.Value, + } + dbOptions.TaskOptionsRunbookRunBase = *commonOptions + if cmd.Flags().Lookup(FlagForcePackageDownload).Changed { + dbOptions.ForcePackageDownloadWasSpecified = true + } + tasks = append(tasks, executor.NewTask(executor.TaskTypeRunbookRun, dbOptions)) + } + } + + results := processRunbookTasks(octopus, f.GetCurrentSpace(), tasks) + + type runbookRunResult struct { + RunbookName string `json:"runbookName"` + Environment string `json:"environment"` + Status string `json:"status"` + TaskID string `json:"taskId"` + } + + var flatResults []runbookRunResult + successCount := 0 + failCount := 0 + + for _, result := range results { + if result.err != nil { + failCount++ + for _, env := range result.environments { + flatResults = append(flatResults, runbookRunResult{ + RunbookName: result.runbookName, + Environment: env, + Status: fmt.Sprintf("Failed: %v", result.err), + TaskID: "", + }) + } + } else { + for i, task := range result.runbookRunServerTasks { + successCount++ + env := "Unknown" + if i < len(result.environments) { + env = result.environments[i] + } else if len(result.environments) > 0 { + env = result.environments[0] + } + flatResults = append(flatResults, runbookRunResult{ + RunbookName: result.runbookName, + Environment: env, + Status: "Started", + TaskID: task.ServerTaskID, + }) + } + } + } + + switch outputFormat { + case constants.OutputFormatBasic: + for _, result := range flatResults { + if result.Status == "Started" { + cmd.Printf("%s\n", result.TaskID) + } + } + case constants.OutputFormatJson: + data, err := json.Marshal(flatResults) + if err != nil { + cmd.PrintErrln(err) + } else { + _, _ = cmd.OutOrStdout().Write(data) + cmd.Println() + } + default: + cmd.Println() + t := output.NewTable(cmd.OutOrStdout()) + t.AddRow(output.Bold("RUNBOOK"), output.Bold("ENVIRONMENT"), output.Bold("STATUS"), output.Bold("TASK ID")) + for _, result := range flatResults { + statusDisplay := result.Status + if result.Status == "Started" { + statusDisplay = output.Cyan(result.Status) + } else { + statusDisplay = output.Red(result.Status) + } + t.AddRow(result.RunbookName, result.Environment, statusDisplay, result.TaskID) + } + t.Print() + cmd.Println() + cmd.Printf("Successfully started: %d, Failed: %d\n", successCount, failCount) + } + + if failCount > 0 { + return fmt.Errorf("%d runbook run(s) failed to start", failCount) + } + + return nil +} diff --git a/pkg/cmd/runbook/run/run_test.go b/pkg/cmd/runbook/run/run_test.go index 51897918..33c1904d 100644 --- a/pkg/cmd/runbook/run/run_test.go +++ b/pkg/cmd/runbook/run/run_test.go @@ -70,7 +70,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) _, err := testutil.ReceivePair(cmdReceiver) assert.EqualError(t, err, "runbook name must be specified") @@ -88,7 +88,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) _, err := testutil.ReceivePair(cmdReceiver) assert.EqualError(t, err, "environment(s) must be specified") @@ -106,7 +106,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1") @@ -145,7 +145,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1").RespondWith(&runbooks.RunbookRunResponseV1{ @@ -174,7 +174,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) serverTasks := []*runbooks.RunbookRunServerTask{ {RunbookRunID: "RunbookRun-203", ServerTaskID: "ServerTasks-29394"}, @@ -205,7 +205,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1") requestBody, err := testutil.ReadJson[runbooks.RunbookRunCommandV1](req.Request.Body) @@ -244,7 +244,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1") requestBody, err := testutil.ReadJson[runbooks.RunbookRunCommandV1](req.Request.Body) @@ -298,7 +298,7 @@ func TestRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/create/v1") requestBody, err := testutil.ReadJson[runbooks.RunbookRunCommandV1](req.Request.Body) @@ -398,7 +398,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) _, err := testutil.ReceivePair(cmdReceiver) assert.EqualError(t, err, "runbook name must be specified") @@ -416,7 +416,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) _, err := testutil.ReceivePair(cmdReceiver) assert.EqualError(t, err, "environment(s) must be specified") @@ -434,7 +434,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) _, err := testutil.ReceivePair(cmdReceiver) assert.EqualError(t, err, "git reference must be specified") @@ -452,7 +452,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/git/create/v1") @@ -492,7 +492,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/git/create/v1").RespondWith(&runbooks.GitRunbookRunResponseV1{ @@ -521,7 +521,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) serverTasks := []*runbooks.RunbookRunServerTask{ {RunbookRunID: "RunbookRun-203", ServerTaskID: "ServerTasks-29394"}, @@ -552,7 +552,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/git/create/v1") requestBody, err := testutil.ReadJson[runbooks.GitRunbookRunCommandV1](req.Request.Body) @@ -592,7 +592,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/git/create/v1") requestBody, err := testutil.ReadJson[runbooks.GitRunbookRunCommandV1](req.Request.Body) @@ -650,7 +650,7 @@ func TestGitRunbookRun_AutomationMode(t *testing.T) { 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").RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWithJSON(fixtures.AsServerResponse(fireProject)) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/runbook-runs/git/create/v1") requestBody, err := testutil.ReadJson[runbooks.GitRunbookRunCommandV1](req.Request.Body) diff --git a/test/fixtures/projects.go b/test/fixtures/projects.go index 797cf501..25525fce 100644 --- a/test/fixtures/projects.go +++ b/test/fixtures/projects.go @@ -1,6 +1,7 @@ package fixtures import ( + "encoding/json" "fmt" "net/url" @@ -100,6 +101,86 @@ func NewVersionControlledProject(spaceID string, projectID string, projectName s return result } +func AsServerResponse(project *projects.Project) []byte { + projectJSON, err := json.Marshal(project) + if err != nil { + panic(err) + } + + if gitSettings, ok := project.PersistenceSettings.(projects.GitPersistenceSettings); ok { + var projectMap map[string]interface{} + if err := json.Unmarshal(projectJSON, &projectMap); err != nil { + panic(err) + } + + if persistenceSettings, ok := projectMap["PersistenceSettings"].(map[string]interface{}); ok { + persistenceSettings["ConversionState"] = map[string]interface{}{ + "VariablesAreInGit": gitSettings.VariablesAreInGit(), + "RunbooksAreInGit": gitSettings.RunbooksAreInGit(), + } + } + + modifiedJSON, err := json.Marshal(projectMap) + if err != nil { + panic(err) + } + return modifiedJSON + } + + return projectJSON +} + +func AsServerResponsePlainArray(projectList []*projects.Project) []byte { + var result []json.RawMessage + for _, project := range projectList { + result = append(result, AsServerResponse(project)) + } + arrayJSON, err := json.Marshal(result) + if err != nil { + panic(err) + } + return arrayJSON +} + +func AsServerResponseArray(projectList []*projects.Project) []byte { + projectsJSON, err := json.Marshal(resources.Resources[*projects.Project]{ + Items: projectList, + }) + if err != nil { + panic(err) + } + + var wrapper map[string]interface{} + if err := json.Unmarshal(projectsJSON, &wrapper); err != nil { + panic(err) + } + + if items, ok := wrapper["Items"].([]interface{}); ok { + for i, item := range items { + if projectMap, ok := item.(map[string]interface{}); ok { + if persistenceSettings, ok := projectMap["PersistenceSettings"].(map[string]interface{}); ok { + if persistenceSettings["Type"] == "VersionControlled" { + if i < len(projectList) { + if gitSettings, ok := projectList[i].PersistenceSettings.(projects.GitPersistenceSettings); ok { + persistenceSettings["ConversionState"] = map[string]interface{}{ + "VariablesAreInGit": gitSettings.VariablesAreInGit(), + "RunbooksAreInGit": gitSettings.RunbooksAreInGit(), + } + } + } + } + } + } + } + } + + modifiedJSON, err := json.Marshal(wrapper) + if err != nil { + panic(err) + } + return modifiedJSON +} + func NewChannel(spaceID string, channelID string, channelName string, projectID string) *channels.Channel { result := channels.NewChannel(channelName, projectID) result.ID = channelID diff --git a/test/testutil/fakeoctopusserver.go b/test/testutil/fakeoctopusserver.go index cce099b0..d417eee3 100644 --- a/test/testutil/fakeoctopusserver.go +++ b/test/testutil/fakeoctopusserver.go @@ -164,6 +164,17 @@ func (r *RequestWrapper) RespondWith(responseObject any) *RequestWrapper { return r } +func (r *RequestWrapper) RespondWithJSON(jsonBytes []byte) *RequestWrapper { + r.Server.Respond(&http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(bytes.NewReader(jsonBytes)), + ContentLength: int64(len(jsonBytes)), + Header: make(http.Header), + }, nil) + return r +} + func (r *RequestWrapper) ExpectHeader(t *testing.T, name string, value string) *RequestWrapper { assert.Contains(t, r.Request.Header, name) headerValues := r.Request.Header[name]