From d7c7f3689c783ebb5b74af3e8eaf5cd3bb6fa168 Mon Sep 17 00:00:00 2001 From: mrinalirao Date: Tue, 29 Nov 2022 11:57:51 +1100 Subject: [PATCH 01/11] refactor task results to run as a task stage --- go.mod | 4 +- go.sum | 8 +- internal/cloud/backend_common.go | 2 +- internal/cloud/backend_runTasks.go | 149 ------------------ .../cloud/backend_taskStage_taskResults.go | 147 +++++++++++++++++ ... => backend_taskStage_taskResults_test.go} | 77 +++++---- internal/cloud/backend_taskStages.go | 80 ++++++++++ internal/cloud/errors.go | 43 +++++ 8 files changed, 322 insertions(+), 188 deletions(-) delete mode 100644 internal/cloud/backend_runTasks.go create mode 100644 internal/cloud/backend_taskStage_taskResults.go rename internal/cloud/{backend_runTasks_test.go => backend_taskStage_taskResults_test.go} (61%) diff --git a/go.mod b/go.mod index 443ff414c6..5106f7ebe0 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.4.3 github.com/hashicorp/go-retryablehttp v0.7.1 - github.com/hashicorp/go-tfe v1.12.0 + github.com/hashicorp/go-tfe v1.14.0 github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f @@ -145,7 +145,7 @@ require ( github.com/hashicorp/go-msgpack v0.5.4 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect - github.com/hashicorp/go-slug v0.10.0 // indirect + github.com/hashicorp/go-slug v0.10.1 // indirect github.com/hashicorp/golang-lru v0.5.1 // indirect github.com/hashicorp/jsonapi v0.0.0-20210826224640-ee7dae0fb22d // indirect github.com/hashicorp/serf v0.9.5 // indirect diff --git a/go.sum b/go.sum index dce96a8404..1f7c906220 100644 --- a/go.sum +++ b/go.sum @@ -371,13 +371,13 @@ github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5O github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= -github.com/hashicorp/go-slug v0.10.0 h1:mh4DDkBJTh9BuEjY/cv8PTo7k9OjT4PcW8PgZnJ4jTY= -github.com/hashicorp/go-slug v0.10.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41RKLH301v4= +github.com/hashicorp/go-slug v0.10.1 h1:05SCRWCBpCxOeP7stQHvMgOz0raCBCekaytu8Rg/RZ4= +github.com/hashicorp/go-slug v0.10.1/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41RKLH301v4= github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-tfe v1.12.0 h1:2l7emKW8rNTTbnxYHNVj6b46iJzOEp2G/3xIHfGSDnc= -github.com/hashicorp/go-tfe v1.12.0/go.mod h1:thYtIxtgBpDDNdf/2yYPdBJ94Fz5yT5XCNZvGtTGHAU= +github.com/hashicorp/go-tfe v1.14.0 h1:FZKKkwlyTxw8/OE3e7NiFQLcgGXTHra9ogGhMTotxh8= +github.com/hashicorp/go-tfe v1.14.0/go.mod h1:77snluBqtTTvMrY0w/mxQA5jlHQ8NT44AqQ8UdrPf0o= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= diff --git a/internal/cloud/backend_common.go b/internal/cloud/backend_common.go index e03ff66665..a5082f923d 100644 --- a/internal/cloud/backend_common.go +++ b/internal/cloud/backend_common.go @@ -207,7 +207,7 @@ func (b *Cloud) waitTaskStage(stopCtx, cancelCtx context.Context, op *backend.Op Op: op, Run: r, } - return b.runTasks(integration, integration.BeginOutput(outputTitle), stageID) + return b.runTaskStage(integration, integration.BeginOutput(outputTitle), stageID) } func (b *Cloud) costEstimate(stopCtx, cancelCtx context.Context, op *backend.Operation, r *tfe.Run) error { diff --git a/internal/cloud/backend_runTasks.go b/internal/cloud/backend_runTasks.go deleted file mode 100644 index 8b7e45bfd4..0000000000 --- a/internal/cloud/backend_runTasks.go +++ /dev/null @@ -1,149 +0,0 @@ -package cloud - -import ( - "context" - "fmt" - "strings" - - "github.com/hashicorp/go-tfe" -) - -type taskResultSummary struct { - unreachable bool - pending int - failed int - failedMandatory int - passed int -} - -type taskStageReadFunc func(b *Cloud, stopCtx context.Context) (*tfe.TaskStage, error) - -func summarizeTaskResults(taskResults []*tfe.TaskResult) *taskResultSummary { - var pendingCount, errCount, errMandatoryCount, passedCount int - for _, task := range taskResults { - if task.Status == "unreachable" { - return &taskResultSummary{ - unreachable: true, - } - } else if task.Status == "running" || task.Status == "pending" { - pendingCount++ - } else if task.Status == "passed" { - passedCount++ - } else { - // Everything else is a failure - errCount++ - if task.WorkspaceTaskEnforcementLevel == "mandatory" { - errMandatoryCount++ - } - } - } - - return &taskResultSummary{ - unreachable: false, - pending: pendingCount, - failed: errCount, - failedMandatory: errMandatoryCount, - passed: passedCount, - } -} - -func (b *Cloud) runTasksWithTaskResults(context *IntegrationContext, output IntegrationOutputWriter, fetchTaskStage taskStageReadFunc) error { - return context.Poll(func(i int) (bool, error) { - stage, err := fetchTaskStage(b, context.StopContext) - - if err != nil { - return false, generalError("Failed to retrieve task stage", err) - } - - summary := summarizeTaskResults(stage.TaskResults) - - if summary.unreachable { - output.Output("Skipping task results.") - output.End() - return false, nil - } - - if summary.pending > 0 { - pendingMessage := "%d tasks still pending, %d passed, %d failed ... " - message := fmt.Sprintf(pendingMessage, summary.pending, summary.passed, summary.failed) - - if i%4 == 0 { - if i > 0 { - output.OutputElapsed(message, len(pendingMessage)) // Up to 2 digits are allowed by the max message allocation - } - } - return true, nil - } - - // No more tasks pending/running. Print all the results. - - // Track the first task name that is a mandatory enforcement level breach. - var firstMandatoryTaskFailed *string = nil - - if i == 0 { - output.Output(fmt.Sprintf("All tasks completed! %d passed, %d failed", summary.passed, summary.failed)) - } else { - output.OutputElapsed(fmt.Sprintf("All tasks completed! %d passed, %d failed", summary.passed, summary.failed), 50) - } - - output.Output("") - - for _, t := range stage.TaskResults { - capitalizedStatus := string(t.Status) - capitalizedStatus = strings.ToUpper(capitalizedStatus[:1]) + capitalizedStatus[1:] - - status := "[green]" + capitalizedStatus - if t.Status != "passed" { - level := string(t.WorkspaceTaskEnforcementLevel) - level = strings.ToUpper(level[:1]) + level[1:] - status = fmt.Sprintf("[red]%s (%s)", capitalizedStatus, level) - - if t.WorkspaceTaskEnforcementLevel == "mandatory" && firstMandatoryTaskFailed == nil { - firstMandatoryTaskFailed = &t.TaskName - } - } - - title := fmt.Sprintf(`%s ⸺ %s`, t.TaskName, status) - output.SubOutput(title) - - if len(t.Message) > 0 { - output.SubOutput(fmt.Sprintf("[dim]%s", t.Message)) - } - if len(t.URL) > 0 { - output.SubOutput(fmt.Sprintf("[dim]Details: %s", t.URL)) - } - output.SubOutput("") - } - - // If a mandatory enforcement level is breached, return an error. - var taskErr error = nil - var overall string = "[green]Passed" - if firstMandatoryTaskFailed != nil { - overall = "[red]Failed" - if summary.failedMandatory > 1 { - taskErr = fmt.Errorf("the run failed because %d mandatory tasks are required to succeed", summary.failedMandatory) - } else { - taskErr = fmt.Errorf("the run failed because the run task, %s, is required to succeed", *firstMandatoryTaskFailed) - } - } else if summary.failed > 0 { // we have failures but none of them mandatory - overall = "[green]Passed with advisory failures" - } - - output.SubOutput("") - output.SubOutput("[bold]Overall Result: " + overall) - - output.End() - - return false, taskErr - }) -} - -func (b *Cloud) runTasks(ctx *IntegrationContext, output IntegrationOutputWriter, stageID string) error { - return b.runTasksWithTaskResults(ctx, output, func(b *Cloud, stopCtx context.Context) (*tfe.TaskStage, error) { - options := tfe.TaskStageReadOptions{ - Include: []tfe.TaskStageIncludeOpt{tfe.TaskStageTaskResults}, - } - - return b.client.TaskStages.Read(ctx.StopContext, stageID, &options) - }) -} diff --git a/internal/cloud/backend_taskStage_taskResults.go b/internal/cloud/backend_taskStage_taskResults.go new file mode 100644 index 0000000000..96d085df5a --- /dev/null +++ b/internal/cloud/backend_taskStage_taskResults.go @@ -0,0 +1,147 @@ +package cloud + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-tfe" +) + +type taskResultSummary struct { + unreachable bool + pending int + failed int + failedMandatory int + passed int +} + +type taskResultSummarizer struct { + finished bool + cloud *Cloud + counter int +} + +func newTaskResultSummarizer(b *Cloud, ts *tfe.TaskStage) taskStageSummarizer { + if len(ts.TaskResults) == 0 { + return nil + } + return &taskResultSummarizer{ + finished: false, + cloud: b, + } +} + +func (trs *taskResultSummarizer) Summarize(context *IntegrationContext, output IntegrationOutputWriter, ts *tfe.TaskStage) (bool, *string, error) { + if trs.finished { + return false, nil, nil + } + trs.counter++ + + counts := summarizeTaskResults(ts.TaskResults) + + if counts.pending != 0 { + pendingMessage := "%d tasks still pending, %d passed, %d failed ... " + message := fmt.Sprintf(pendingMessage, counts.pending, counts.passed, counts.failed) + return true, &message, nil + } + if counts.unreachable { + output.Output("Skipping task results.") + output.End() + return false, nil, nil + } + + // Print out the summary + trs.runTasksWithTaskResults(output, ts.TaskResults, counts) + + // Mark as finished + trs.finished = true + + return false, nil, nil +} + +func summarizeTaskResults(taskResults []*tfe.TaskResult) *taskResultSummary { + var pendingCount, errCount, errMandatoryCount, passedCount int + for _, task := range taskResults { + if task.Status == "unreachable" { + return &taskResultSummary{ + unreachable: true, + } + } else if task.Status == "running" || task.Status == "pending" { + pendingCount++ + } else if task.Status == "passed" { + passedCount++ + } else { + // Everything else is a failure + errCount++ + if task.WorkspaceTaskEnforcementLevel == "mandatory" { + errMandatoryCount++ + } + } + } + + return &taskResultSummary{ + unreachable: false, + pending: pendingCount, + failed: errCount, + failedMandatory: errMandatoryCount, + passed: passedCount, + } +} + +func (trs *taskResultSummarizer) runTasksWithTaskResults(output IntegrationOutputWriter, taskResults []*tfe.TaskResult, count *taskResultSummary) { + // Track the first task name that is a mandatory enforcement level breach. + var firstMandatoryTaskFailed *string = nil + + if trs.counter == 0 { + output.Output(fmt.Sprintf("All tasks completed! %d passed, %d failed", count.passed, count.failed)) + } else { + output.OutputElapsed(fmt.Sprintf("All tasks completed! %d passed, %d failed", count.passed, count.failed), 50) + } + + output.Output("") + + for _, t := range taskResults { + capitalizedStatus := string(t.Status) + capitalizedStatus = strings.ToUpper(capitalizedStatus[:1]) + capitalizedStatus[1:] + + status := "[green]" + capitalizedStatus + if t.Status != "passed" { + level := string(t.WorkspaceTaskEnforcementLevel) + level = strings.ToUpper(level[:1]) + level[1:] + status = fmt.Sprintf("[red]%s (%s)", capitalizedStatus, level) + + if t.WorkspaceTaskEnforcementLevel == "mandatory" && firstMandatoryTaskFailed == nil { + firstMandatoryTaskFailed = &t.TaskName + } + } + + title := fmt.Sprintf(`%s ⸺ %s`, t.TaskName, status) + output.SubOutput(title) + + if len(t.Message) > 0 { + output.SubOutput(fmt.Sprintf("[dim]%s", t.Message)) + } + if len(t.URL) > 0 { + output.SubOutput(fmt.Sprintf("[dim]Details: %s", t.URL)) + } + output.SubOutput("") + } + + // If a mandatory enforcement level is breached, return an error. + var overall string = "[green]Passed" + if firstMandatoryTaskFailed != nil { + overall = "[red]Failed" + if count.failedMandatory > 1 { + output.Output(fmt.Sprintf("[reset][bold][red]Error:[reset][bold]the run failed because %d mandatory tasks are required to succeed", count.failedMandatory)) + } else { + output.Output(fmt.Sprintf("[reset][bold][red]Error: [reset][bold]the run failed because the run task, %s, is required to succeed", *firstMandatoryTaskFailed)) + } + } else if count.failed > 0 { // we have failures but none of them mandatory + overall = "[green]Passed with advisory failures" + } + + output.SubOutput("") + output.SubOutput("[bold]Overall Result: " + overall) + + output.End() +} diff --git a/internal/cloud/backend_runTasks_test.go b/internal/cloud/backend_taskStage_taskResults_test.go similarity index 61% rename from internal/cloud/backend_runTasks_test.go rename to internal/cloud/backend_taskStage_taskResults_test.go index 0e0bddc202..7e14a15282 100644 --- a/internal/cloud/backend_runTasks_test.go +++ b/internal/cloud/backend_taskStage_taskResults_test.go @@ -82,16 +82,20 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { integrationContext, writer := newMockIntegrationContext(b, t) cases := map[string]struct { - taskResults []*tfe.TaskResult + taskStage func() *tfe.TaskStage context *IntegrationContext writer *testIntegrationOutput expectedOutputs []string isError bool }{ "all-succeeded": { - taskResults: []*tfe.TaskResult{ - {ID: "1", TaskName: "Mandatory", Message: "A-OK", Status: "passed", WorkspaceTaskEnforcementLevel: "mandatory"}, - {ID: "2", TaskName: "Advisory", Message: "A-OK", Status: "passed", WorkspaceTaskEnforcementLevel: "advisory"}, + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{} + ts.TaskResults = []*tfe.TaskResult{ + {ID: "1", TaskName: "Mandatory", Message: "A-OK", Status: "passed", WorkspaceTaskEnforcementLevel: "mandatory"}, + {ID: "2", TaskName: "Advisory", Message: "A-OK", Status: "passed", WorkspaceTaskEnforcementLevel: "advisory"}, + } + return ts }, writer: writer, context: integrationContext, @@ -99,9 +103,13 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { isError: false, }, "mandatory-failed": { - taskResults: []*tfe.TaskResult{ - {ID: "1", TaskName: "Mandatory", Message: "500 Error", Status: "failed", WorkspaceTaskEnforcementLevel: "mandatory"}, - {ID: "2", TaskName: "Advisory", Message: "A-OK", Status: "passed", WorkspaceTaskEnforcementLevel: "advisory"}, + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{} + ts.TaskResults = []*tfe.TaskResult{ + {ID: "1", TaskName: "Mandatory", Message: "500 Error", Status: "failed", WorkspaceTaskEnforcementLevel: "mandatory"}, + {ID: "2", TaskName: "Advisory", Message: "A-OK", Status: "passed", WorkspaceTaskEnforcementLevel: "advisory"}, + } + return ts }, writer: writer, context: integrationContext, @@ -109,9 +117,13 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { isError: true, }, "advisory-failed": { - taskResults: []*tfe.TaskResult{ - {ID: "1", TaskName: "Mandatory", Message: "A-OK", Status: "passed", WorkspaceTaskEnforcementLevel: "mandatory"}, - {ID: "2", TaskName: "Advisory", Message: "500 Error", Status: "failed", WorkspaceTaskEnforcementLevel: "advisory"}, + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{} + ts.TaskResults = []*tfe.TaskResult{ + {ID: "1", TaskName: "Mandatory", Message: "A-OK", Status: "passed", WorkspaceTaskEnforcementLevel: "mandatory"}, + {ID: "2", TaskName: "Advisory", Message: "500 Error", Status: "failed", WorkspaceTaskEnforcementLevel: "advisory"}, + } + return ts }, writer: writer, context: integrationContext, @@ -119,9 +131,13 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { isError: false, }, "unreachable": { - taskResults: []*tfe.TaskResult{ - {ID: "1", TaskName: "Mandatory", Message: "", Status: "unreachable", WorkspaceTaskEnforcementLevel: "mandatory"}, - {ID: "2", TaskName: "Advisory", Message: "", Status: "unreachable", WorkspaceTaskEnforcementLevel: "advisory"}, + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{} + ts.TaskResults = []*tfe.TaskResult{ + {ID: "1", TaskName: "Mandatory", Message: "", Status: "unreachable", WorkspaceTaskEnforcementLevel: "mandatory"}, + {ID: "2", TaskName: "Advisory", Message: "", Status: "unreachable", WorkspaceTaskEnforcementLevel: "advisory"}, + } + return ts }, writer: writer, context: integrationContext, @@ -130,27 +146,24 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { }, } - for caseName, c := range cases { + for _, c := range cases { c.writer.output.Reset() - err := b.runTasksWithTaskResults(c.context, writer, func(b *Cloud, stopCtx context.Context) (*tfe.TaskStage, error) { - return &tfe.TaskStage{ - TaskResults: c.taskResults, - }, nil - }) - - if c.isError && err == nil { - t.Fatalf("Expected %s to be error", caseName) + trs := taskResultSummarizer{ + cloud: b, } - - if !c.isError && err != nil { - t.Errorf("Expected %s to not be error but received %s", caseName, err) - } - - output := c.writer.output.String() - for _, expected := range c.expectedOutputs { - if !strings.Contains(output, expected) { - t.Fatalf("Expected output to contain '%s' but it was:\n\n%s", expected, output) + c.context.Poll(func(i int) (bool, error) { + cont, _, _ := trs.Summarize(c.context, c.writer, c.taskStage()) + if cont { + return true, nil } - } + + output := c.writer.output.String() + for _, expected := range c.expectedOutputs { + if !strings.Contains(output, expected) { + t.Fatalf("Expected output to contain '%s' but it was:\n\n%s", expected, output) + } + } + return false, nil + }) } } diff --git a/internal/cloud/backend_taskStages.go b/internal/cloud/backend_taskStages.go index d2ae881b27..b465af4113 100644 --- a/internal/cloud/backend_taskStages.go +++ b/internal/cloud/backend_taskStages.go @@ -2,6 +2,7 @@ package cloud import ( "context" + "fmt" "strings" tfe "github.com/hashicorp/go-tfe" @@ -9,6 +10,16 @@ import ( type taskStages map[tfe.Stage]*tfe.TaskStage +type taskStageSummarizer interface { + // Summarize takes an IntegrationContext, IntegrationOutputWriter for + // writing output and a pointer to a tfe.TaskStage object as arguments. + // This function summarizes and outputs the results of the task stage. + // It returns a boolean which signifies whether we should continue polling + // for results, an optional message string to print while it is polling + // and an error if any. + Summarize(*IntegrationContext, IntegrationOutputWriter, *tfe.TaskStage) (bool, *string, error) +} + func (b *Cloud) runTaskStages(ctx context.Context, client *tfe.Client, runId string) (taskStages, error) { taskStages := make(taskStages, 0) result, err := client.Runs.ReadWithOptions(ctx, runId, &tfe.RunReadOptions{ @@ -30,3 +41,72 @@ func (b *Cloud) runTaskStages(ctx context.Context, client *tfe.Client, runId str return taskStages, nil } + +func (b *Cloud) getTaskStageWithAllOptions(ctx *IntegrationContext, stageID string) (*tfe.TaskStage, error) { + options := tfe.TaskStageReadOptions{ + Include: []tfe.TaskStageIncludeOpt{tfe.TaskStageTaskResults}, + } + stage, err := b.client.TaskStages.Read(ctx.StopContext, stageID, &options) + if err != nil { + return nil, generalError("Failed to retrieve task stage", err) + } else { + return stage, nil + } +} + +func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWriter, stageID string) error { + var errs multiErrors + + // Create our summarizers + summarizers := make([]taskStageSummarizer, 0) + ts, err := b.getTaskStageWithAllOptions(ctx, stageID) + if err != nil { + return err + } + if s := newTaskResultSummarizer(b, ts); s != nil { + summarizers = append(summarizers, s) + } + + return ctx.Poll(func(i int) (bool, error) { + options := tfe.TaskStageReadOptions{ + Include: []tfe.TaskStageIncludeOpt{tfe.TaskStageTaskResults}, + } + stage, err := b.client.TaskStages.Read(ctx.StopContext, stageID, &options) + if err != nil { + return false, generalError("Failed to retrieve task stage", err) + } + + switch stage.Status { + case tfe.TaskStagePending: + // Waiting for it to start + return true, nil + // Note: Terminal statuses need to print out one last time just in case + case tfe.TaskStageRunning, tfe.TaskStagePassed, tfe.TaskStageCanceled, tfe.TaskStageErrored, tfe.TaskStageFailed: + for _, s := range summarizers { + cont, msg, err := s.Summarize(ctx, output, stage) + if cont { + if msg != nil { + if i%4 == 0 { + if i > 0 { + output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation + } + } + } + return true, nil + } + if err != nil { + errs.Append(err) + } + } + case "unreachable": + return false, nil + default: + return false, fmt.Errorf("Invalid Task stage status: %s ", stage.Status) + } + + if len(errs) > 0 { + return false, errs.Err() + } + return false, nil + }) +} diff --git a/internal/cloud/errors.go b/internal/cloud/errors.go index cf668516f3..339a6ea01f 100644 --- a/internal/cloud/errors.go +++ b/internal/cloud/errors.go @@ -58,3 +58,46 @@ func incompatibleWorkspaceTerraformVersion(message string, ignoreVersionConflict description := strings.TrimSpace(fmt.Sprintf("%s\n\n%s", message, suggestion)) return tfdiags.Sourceless(severity, "Incompatible Terraform version", description) } + +type multiErrors []error + +// Append errs to e only if the individual error in errs is not nil +// +// If any of the errs is itself multiErrors, each individual error in errs is appended. +func (e *multiErrors) Append(errs ...error) { + for _, err := range errs { + if err == nil { + continue + } + if errs, ok := err.(multiErrors); ok { + *e = append(*e, errs...) + } + *e = append(*e, err) + } +} + +// multiErrors returns an error string by joining +// all of its nonnil errors with colon separator. +func (e multiErrors) Error() string { + if len(e) == 0 { + return "" + } + es := make([]string, 0, len(e)) + for _, err := range e { + if err != nil { + es = append(es, err.Error()) + } + } + return strings.Join(es, ":") +} + +// Err returns e as an error or returns nil if no errors were collected. +func (e multiErrors) Err() error { + // Only return self if we have at least one nonnil error. + for _, err := range e { + if err != nil { + return e + } + } + return nil +} From 2be890a37c9467cef470e0d7ffcaf54abfc95f76 Mon Sep 17 00:00:00 2001 From: mrinalirao Date: Tue, 29 Nov 2022 15:10:23 +1100 Subject: [PATCH 02/11] add policy evaluation task stage --- .../backend_taskStage_policyEvaluation.go | 157 ++++++++++++++++++ ...backend_taskStage_policyEvaluation_test.go | 97 +++++++++++ internal/cloud/backend_taskStages.go | 29 +++- internal/cloud/testing.go | 2 + internal/cloud/tfe_client_mock.go | 130 +++++++++++++++ 5 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 internal/cloud/backend_taskStage_policyEvaluation.go create mode 100644 internal/cloud/backend_taskStage_policyEvaluation_test.go diff --git a/internal/cloud/backend_taskStage_policyEvaluation.go b/internal/cloud/backend_taskStage_policyEvaluation.go new file mode 100644 index 0000000000..eeba9591a8 --- /dev/null +++ b/internal/cloud/backend_taskStage_policyEvaluation.go @@ -0,0 +1,157 @@ +package cloud + +import ( + "fmt" + "strings" + + "github.com/hashicorp/go-tfe" +) + +type policyEvaluationSummary struct { + unreachable bool + pending int + failed int + passed int +} + +type Symbol rune + +const ( + Tick Symbol = '\u2713' + Cross Symbol = '\u00d7' + Warning Symbol = '\u24be' + Arrow Symbol = '\u2192' + DownwardArrow Symbol = '\u21b3' +) + +type policyEvaluationSummarizer struct { + finished bool + cloud *Cloud + counter int +} + +func newPolicyEvaluationSummarizer(b *Cloud, ts *tfe.TaskStage) taskStageSummarizer { + if len(ts.PolicyEvaluations) == 0 { + return nil + } + return &policyEvaluationSummarizer{ + finished: false, + cloud: b, + } +} + +func (pes *policyEvaluationSummarizer) Summarize(context *IntegrationContext, output IntegrationOutputWriter, ts *tfe.TaskStage) (bool, *string, error) { + if pes.counter == 0 { + output.Output("[bold]OPA Policy Evaluation\n") + pes.counter++ + } + + if pes.finished { + return false, nil, nil + } + + counts := summarizePolicyEvaluationResults(ts.PolicyEvaluations) + + if counts.pending != 0 { + pendingMessage := "Evaluating ... " + return true, &pendingMessage, nil + } + + if counts.unreachable { + output.Output("Skipping policy evaluation.") + output.End() + return false, nil, nil + } + + // Print out the summary + if err := pes.taskStageWithPolicyEvaluation(context, output, ts.PolicyEvaluations); err != nil { + return false, nil, err + } + // Mark as finished + pes.finished = true + + return false, nil, nil +} + +func summarizePolicyEvaluationResults(policyEvaluations []*tfe.PolicyEvaluation) *policyEvaluationSummary { + var pendingCount, errCount, passedCount int + for _, policyEvaluation := range policyEvaluations { + switch policyEvaluation.Status { + case "unreachable": + return &policyEvaluationSummary{ + unreachable: true, + } + case "running", "pending", "queued": + pendingCount++ + case "passed": + passedCount++ + default: + // Everything else is a failure + errCount++ + } + } + + return &policyEvaluationSummary{ + unreachable: false, + pending: pendingCount, + failed: errCount, + passed: passedCount, + } +} + +func (pes *policyEvaluationSummarizer) taskStageWithPolicyEvaluation(context *IntegrationContext, output IntegrationOutputWriter, policyEvaluation []*tfe.PolicyEvaluation) error { + var result, message string + // Currently only one policy evaluation supported : OPA + for _, polEvaluation := range policyEvaluation { + if polEvaluation.Status == tfe.PolicyEvaluationPassed { + message = "[dim] This result means that all OPA policies passed and the protected behaviour is allowed" + result = fmt.Sprintf("[green]%s", strings.ToUpper(string(tfe.PolicyEvaluationPassed))) + if polEvaluation.ResultCount.AdvisoryFailed > 0 { + result += " (with advisory)" + } + } else { + message = "[dim] This result means that one or more OPA policies failed. More than likely, this was due to the discovery of violations by the main rule and other sub rules" + result = fmt.Sprintf("[red]%s", strings.ToUpper(string(tfe.PolicyEvaluationFailed))) + } + + output.Output(fmt.Sprintf("[bold]%c%c Overall Result: %s", Arrow, Arrow, result)) + + output.Output(message) + + total := getPolicyCount(polEvaluation.ResultCount) + + output.Output(fmt.Sprintf("%d policies evaluated\n", total)) + + policyOutcomes, err := pes.cloud.client.PolicySetOutcomes.List(context.StopContext, polEvaluation.ID, nil) + if err != nil { + return err + } + + for i, out := range policyOutcomes.Items { + output.Output(fmt.Sprintf("%c Policy set %d: [bold]%s (%d)", Arrow, i+1, out.PolicySetName, len(out.Outcomes))) + for _, outcome := range out.Outcomes { + output.Output(fmt.Sprintf(" %c Policy name: [bold]%s", DownwardArrow, outcome.PolicyName)) + switch outcome.Status { + case "passed": + output.Output(fmt.Sprintf(" | [green][bold]%c Passed", Tick)) + case "failed": + if outcome.EnforcementLevel == tfe.EnforcementAdvisory { + output.Output(fmt.Sprintf(" | [blue][bold]%c Advisory", Warning)) + } else { + output.Output(fmt.Sprintf(" | [red][bold]%c Failed", Cross)) + } + } + if outcome.Description != "" { + output.Output(fmt.Sprintf(" | [dim]%s", outcome.Description)) + } else { + output.Output(" | [dim]No description available") + } + } + } + } + return nil +} + +func getPolicyCount(resultCount *tfe.PolicyResultCount) int { + return resultCount.AdvisoryFailed + resultCount.MandatoryFailed + resultCount.Errored + resultCount.Passed +} diff --git a/internal/cloud/backend_taskStage_policyEvaluation_test.go b/internal/cloud/backend_taskStage_policyEvaluation_test.go new file mode 100644 index 0000000000..3d08f74d90 --- /dev/null +++ b/internal/cloud/backend_taskStage_policyEvaluation_test.go @@ -0,0 +1,97 @@ +package cloud + +import ( + "strings" + "testing" + + "github.com/hashicorp/go-tfe" +) + +func TestCloud_runTaskStageWithPolicyEvaluation(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + integrationContext, writer := newMockIntegrationContext(b, t) + + cases := map[string]struct { + taskStage func() *tfe.TaskStage + context *IntegrationContext + writer *testIntegrationOutput + expectedOutputs []string + isError bool + }{ + "all-succeeded": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pass", ResultCount: &tfe.PolicyResultCount{Passed: 1}, Status: "passed"}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"│ [bold]OPA Policy Evaluation\n\n│ [bold]→→ Overall Result: [green]PASSED\n│ [dim] This result means that all OPA policies passed and the protected behaviour is allowed\n│ 1 policies evaluated\n\n│ → Policy set 1: [bold]policy-set-that-passes (1)\n│ ↳ Policy name: [bold]policy-pass\n│ | [green][bold]✓ Passed\n│ | [dim]This policy will pass\n"}, + isError: false, + }, + "mandatory-failed": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-fail", ResultCount: &tfe.PolicyResultCount{MandatoryFailed: 1}, Status: "failed"}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"│ [bold]→→ Overall Result: [red]FAILED\n│ [dim] This result means that one or more OPA policies failed. More than likely, this was due to the discovery of violations by the main rule and other sub rules\n│ 1 policies evaluated\n\n│ → Policy set 1: [bold]policy-set-that-fails (1)\n│ ↳ Policy name: [bold]policy-fail\n│ | [red][bold]× Failed\n│ | [dim]This policy will fail"}, + isError: true, + }, + "advisory-failed": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "adv-fail", ResultCount: &tfe.PolicyResultCount{AdvisoryFailed: 1}, Status: "failed"}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"│ [bold]OPA Policy Evaluation\n\n│ [bold]→→ Overall Result: [red]FAILED\n│ [dim] This result means that one or more OPA policies failed. More than likely, this was due to the discovery of violations by the main rule and other sub rules\n│ 1 policies evaluated\n\n│ → Policy set 1: [bold]policy-set-that-fails (1)\n│ ↳ Policy name: [bold]policy-fail\n│ | [blue][bold]Ⓘ Advisory\n│ | [dim]This policy will fail"}, + isError: false, + }, + "unreachable": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "adv-fail", ResultCount: &tfe.PolicyResultCount{Errored: 1}, Status: "unreachable"}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping policy evaluation."}, + isError: false, + }, + } + + for _, c := range cases { + c.writer.output.Reset() + trs := policyEvaluationSummarizer{ + cloud: b, + } + c.context.Poll(func(i int) (bool, error) { + cont, _, _ := trs.Summarize(c.context, c.writer, c.taskStage()) + if cont { + return true, nil + } + + output := c.writer.output.String() + for _, expected := range c.expectedOutputs { + if !strings.Contains(output, expected) { + t.Fatalf("Expected output to contain '%s' but it was:\n\n%s", expected, output) + } + } + return false, nil + }) + } +} diff --git a/internal/cloud/backend_taskStages.go b/internal/cloud/backend_taskStages.go index b465af4113..3729d16602 100644 --- a/internal/cloud/backend_taskStages.go +++ b/internal/cloud/backend_taskStages.go @@ -44,7 +44,7 @@ func (b *Cloud) runTaskStages(ctx context.Context, client *tfe.Client, runId str func (b *Cloud) getTaskStageWithAllOptions(ctx *IntegrationContext, stageID string) (*tfe.TaskStage, error) { options := tfe.TaskStageReadOptions{ - Include: []tfe.TaskStageIncludeOpt{tfe.TaskStageTaskResults}, + Include: []tfe.TaskStageIncludeOpt{tfe.TaskStageTaskResults, tfe.PolicyEvaluationsTaskResults}, } stage, err := b.client.TaskStages.Read(ctx.StopContext, stageID, &options) if err != nil { @@ -63,13 +63,18 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr if err != nil { return err } + if s := newTaskResultSummarizer(b, ts); s != nil { summarizers = append(summarizers, s) } + if s := newPolicyEvaluationSummarizer(b, ts); s != nil { + summarizers = append(summarizers, s) + } + return ctx.Poll(func(i int) (bool, error) { options := tfe.TaskStageReadOptions{ - Include: []tfe.TaskStageIncludeOpt{tfe.TaskStageTaskResults}, + Include: []tfe.TaskStageIncludeOpt{tfe.TaskStageTaskResults, tfe.PolicyEvaluationsTaskResults}, } stage, err := b.client.TaskStages.Read(ctx.StopContext, stageID, &options) if err != nil { @@ -98,7 +103,25 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr errs.Append(err) } } - case "unreachable": + case tfe.TaskStageAwaitingOverride: + // TODO: Add override functionality + for _, s := range summarizers { + cont, msg, err := s.Summarize(ctx, output, stage) + if cont { + if msg != nil { + if i%4 == 0 { + if i > 0 { + output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation + } + } + } + return true, nil + } + if err != nil { + errs.Append(err) + } + } + case tfe.TaskStageUnreachable: return false, nil default: return false, fmt.Errorf("Invalid Task stage status: %s ", stage.Status) diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index 6cfe898337..9d2fc04452 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -209,6 +209,7 @@ func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) { b.client.CostEstimates = mc.CostEstimates b.client.Organizations = mc.Organizations b.client.Plans = mc.Plans + b.client.PolicySetOutcomes = mc.PolicySetOutcomes b.client.PolicyChecks = mc.PolicyChecks b.client.Runs = mc.Runs b.client.StateVersions = mc.StateVersions @@ -269,6 +270,7 @@ func testUnconfiguredBackend(t *testing.T) (*Cloud, func()) { b.client.CostEstimates = mc.CostEstimates b.client.Organizations = mc.Organizations b.client.Plans = mc.Plans + b.client.PolicySetOutcomes = mc.PolicySetOutcomes b.client.PolicyChecks = mc.PolicyChecks b.client.Runs = mc.Runs b.client.StateVersions = mc.StateVersions diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index 7737eb4f95..7d567833fb 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -27,6 +27,7 @@ type MockClient struct { CostEstimates *MockCostEstimates Organizations *MockOrganizations Plans *MockPlans + PolicySetOutcomes *MockPolicySetOutcomes PolicyChecks *MockPolicyChecks Runs *MockRuns StateVersions *MockStateVersions @@ -42,6 +43,7 @@ func NewMockClient() *MockClient { c.CostEstimates = newMockCostEstimates(c) c.Organizations = newMockOrganizations(c) c.Plans = newMockPlans(c) + c.PolicySetOutcomes = newMockPolicySetOutcomes(c) c.PolicyChecks = newMockPolicyChecks(c) c.Runs = newMockRuns(c) c.StateVersions = newMockStateVersions(c) @@ -545,6 +547,134 @@ func (m *MockPlans) ReadJSONOutput(ctx context.Context, planID string) ([]byte, return []byte(planOutput), nil } +type MockPolicySetOutcomes struct { + client *MockClient +} + +func newMockPolicySetOutcomes(client *MockClient) *MockPolicySetOutcomes { + return &MockPolicySetOutcomes{ + client: client, + } +} + +func (m *MockPolicySetOutcomes) List(ctx context.Context, policyEvaluationID string, options *tfe.PolicySetOutcomeListOptions) (*tfe.PolicySetOutcomeList, error) { + switch policyEvaluationID { + case "pol-pass": + return &tfe.PolicySetOutcomeList{ + Items: []*tfe.PolicySetOutcome{ + { + ID: policyEvaluationID, + Outcomes: []tfe.Outcome{ + { + EnforcementLevel: "mandatory", + Query: "data.example.rule", + Status: "passed", + PolicyName: "policy-pass", + Description: "This policy will pass", + }, + }, + Overridable: tfe.Bool(true), + Error: "", + PolicySetName: "policy-set-that-passes", + PolicySetDescription: "This policy set will always pass", + ResultCount: tfe.PolicyResultCount{ + AdvisoryFailed: 0, + MandatoryFailed: 0, + Passed: 1, + Errored: 0, + }, + }, + }, + }, nil + case "pol-fail": + return &tfe.PolicySetOutcomeList{ + Items: []*tfe.PolicySetOutcome{ + { + ID: policyEvaluationID, + Outcomes: []tfe.Outcome{ + { + EnforcementLevel: "mandatory", + Query: "data.example.rule", + Status: "failed", + PolicyName: "policy-fail", + Description: "This policy will fail", + }, + }, + Overridable: tfe.Bool(true), + Error: "", + PolicySetName: "policy-set-that-fails", + PolicySetDescription: "This policy set will always fail", + ResultCount: tfe.PolicyResultCount{ + AdvisoryFailed: 0, + MandatoryFailed: 1, + Passed: 0, + Errored: 0, + }, + }, + }, + }, nil + + case "adv-fail": + return &tfe.PolicySetOutcomeList{ + Items: []*tfe.PolicySetOutcome{ + { + ID: policyEvaluationID, + Outcomes: []tfe.Outcome{ + { + EnforcementLevel: "advisory", + Query: "data.example.rule", + Status: "failed", + PolicyName: "policy-fail", + Description: "This policy will fail", + }, + }, + Overridable: tfe.Bool(true), + Error: "", + PolicySetName: "policy-set-that-fails", + PolicySetDescription: "This policy set will always fail", + ResultCount: tfe.PolicyResultCount{ + AdvisoryFailed: 1, + MandatoryFailed: 0, + Passed: 0, + Errored: 0, + }, + }, + }, + }, nil + default: + return &tfe.PolicySetOutcomeList{ + Items: []*tfe.PolicySetOutcome{ + { + ID: policyEvaluationID, + Outcomes: []tfe.Outcome{ + { + EnforcementLevel: "mandatory", + Query: "data.example.rule", + Status: "passed", + PolicyName: "policy-pass", + Description: "This policy will pass", + }, + }, + Overridable: tfe.Bool(true), + Error: "", + PolicySetName: "policy-set-that-passes", + PolicySetDescription: "This policy set will always pass", + ResultCount: tfe.PolicyResultCount{ + AdvisoryFailed: 0, + MandatoryFailed: 0, + Passed: 1, + Errored: 0, + }, + }, + }, + }, nil + } +} + +func (m *MockPolicySetOutcomes) Read(ctx context.Context, policySetOutcomeID string) (*tfe.PolicySetOutcome, error) { + return nil, nil +} + type MockPolicyChecks struct { client *MockClient checks map[string]*tfe.PolicyCheck From e36886c0e736c6b34f99498af1e8740c6df99a30 Mon Sep 17 00:00:00 2001 From: mrinalirao Date: Wed, 30 Nov 2022 10:46:28 +1100 Subject: [PATCH 03/11] add task stage override --- internal/cloud/backend_common.go | 2 +- internal/cloud/backend_taskStages.go | 37 +++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/internal/cloud/backend_common.go b/internal/cloud/backend_common.go index a5082f923d..9b0e42d099 100644 --- a/internal/cloud/backend_common.go +++ b/internal/cloud/backend_common.go @@ -450,7 +450,7 @@ func (b *Cloud) confirm(stopCtx context.Context, op *backend.Operation, opts *te switch keyword { case "override": - if r.Status != tfe.RunPolicyOverride { + if r.Status != tfe.RunPolicyOverride && r.Status != tfe.RunPostPlanAwaitingDecision { if r.Status == tfe.RunDiscarded { err = errRunDiscarded } else { diff --git a/internal/cloud/backend_taskStages.go b/internal/cloud/backend_taskStages.go index 3729d16602..2b288c8b15 100644 --- a/internal/cloud/backend_taskStages.go +++ b/internal/cloud/backend_taskStages.go @@ -6,10 +6,16 @@ import ( "strings" tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/terraform" ) type taskStages map[tfe.Stage]*tfe.TaskStage +const taskStageHeader = ` +To view this run in a browser, visit: +https://%s/app/%s/%s/runs/%s +` + type taskStageSummarizer interface { // Summarize takes an IntegrationContext, IntegrationOutputWriter for // writing output and a pointer to a tfe.TaskStage object as arguments. @@ -104,7 +110,6 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr } } case tfe.TaskStageAwaitingOverride: - // TODO: Add override functionality for _, s := range summarizers { cont, msg, err := s.Summarize(ctx, output, stage) if cont { @@ -121,6 +126,12 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr errs.Append(err) } } + cont, err := b.processStageOverrides(ctx, output, stage.ID) + if err != nil { + errs.Append(err) + } else { + return cont, nil + } case tfe.TaskStageUnreachable: return false, nil default: @@ -133,3 +144,27 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr return false, nil }) } + +func (b *Cloud) processStageOverrides(context *IntegrationContext, output IntegrationOutputWriter, taskStageID string) (bool, error) { + opts := &terraform.InputOpts{ + Id: fmt.Sprintf("%c%c [bold]Override", Arrow, Arrow), + Query: "\nDo you want to override the failed policy check?", + Description: "Only 'override' will be accepted to override.", + } + runUrl := fmt.Sprintf(taskStageHeader, b.hostname, b.organization, context.Op.Workspace, context.Run.ID) + err := b.confirm(context.StopContext, context.Op, opts, context.Run, "override") + if err != nil && err != errRunOverridden { + return false, fmt.Errorf( + fmt.Sprintf("Failed to override: %s\n%s\n", err.Error(), runUrl), + ) + } + + if err != errRunOverridden { + if _, err = b.client.TaskStages.Override(context.StopContext, taskStageID, tfe.TaskStageOverrideOptions{}); err != nil { + return false, generalError(fmt.Sprintf("Failed to override policy check.\n%s", runUrl), err) + } + } else { + output.Output(fmt.Sprintf("The run needs to be manually overridden or discarded.\n%s\n", runUrl)) + } + return false, nil +} From 191a5f1018f79010d2db986a7117dd91bd04c8e1 Mon Sep 17 00:00:00 2001 From: mrinalirao Date: Wed, 30 Nov 2022 14:31:10 +1100 Subject: [PATCH 04/11] add test for override --- internal/cloud/backend_taskStages_test.go | 61 +++++++++++++++++++++++ internal/cloud/testing.go | 1 + internal/cloud/tfe_client_mock.go | 32 ++++++++++++ 3 files changed, 94 insertions(+) diff --git a/internal/cloud/backend_taskStages_test.go b/internal/cloud/backend_taskStages_test.go index e52f6a5e70..e922b78331 100644 --- a/internal/cloud/backend_taskStages_test.go +++ b/internal/cloud/backend_taskStages_test.go @@ -3,6 +3,7 @@ package cloud import ( "context" "errors" + "strings" "testing" "github.com/golang/mock/gomock" @@ -205,3 +206,63 @@ func TestTaskStagesWithErrors(t *testing.T) { t.Error("Expected to error but did not") } } + +func TestTaskStageOverride(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + integrationContext, writer := newMockIntegrationContext(b, t) + + integrationContext.Op.UIOut = b.CLI + + cases := map[string]struct { + taskStageID string + isError bool + errMsg string + input *mockInput + }{ + "override-pass": { + taskStageID: "ts-pass", + isError: false, + input: testInput(t, map[string]string{ + "→→ [bold]Override": "override", + }), + errMsg: "", + }, + "override-fail": { + taskStageID: "ts-err", + isError: true, + input: testInput(t, map[string]string{ + "→→ [bold]Override": "override", + }), + errMsg: "", + }, + "skip-override": { + taskStageID: "ts-err", + isError: true, + errMsg: "Failed to override: Apply discarded.", + input: testInput(t, map[string]string{ + "→→ [bold]Override": "no", + }), + }, + } + for _, c := range cases { + integrationContext.Op.UIIn = c.input + _, err := b.processStageOverrides(integrationContext, writer, c.taskStageID) + if c.isError { + if err == nil { + t.Fatalf("Expected to fail with some error") + } + if c.errMsg != "" { + if !strings.Contains(err.Error(), c.errMsg) { + t.Fatalf("Expected: %s, got: %s", c.errMsg, err.Error()) + } + } + + } else { + if err != nil { + t.Fatalf("Expected to pass, got err: %s", err) + } + } + } +} diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index 9d2fc04452..10db6bdddf 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -209,6 +209,7 @@ func testBackend(t *testing.T, obj cty.Value) (*Cloud, func()) { b.client.CostEstimates = mc.CostEstimates b.client.Organizations = mc.Organizations b.client.Plans = mc.Plans + b.client.TaskStages = mc.TaskStages b.client.PolicySetOutcomes = mc.PolicySetOutcomes b.client.PolicyChecks = mc.PolicyChecks b.client.Runs = mc.Runs diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index 7d567833fb..815ce8c169 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -28,6 +28,7 @@ type MockClient struct { Organizations *MockOrganizations Plans *MockPlans PolicySetOutcomes *MockPolicySetOutcomes + TaskStages *MockTaskStages PolicyChecks *MockPolicyChecks Runs *MockRuns StateVersions *MockStateVersions @@ -43,6 +44,7 @@ func NewMockClient() *MockClient { c.CostEstimates = newMockCostEstimates(c) c.Organizations = newMockOrganizations(c) c.Plans = newMockPlans(c) + c.TaskStages = newMockTaskStages(c) c.PolicySetOutcomes = newMockPolicySetOutcomes(c) c.PolicyChecks = newMockPolicyChecks(c) c.Runs = newMockRuns(c) @@ -547,6 +549,36 @@ func (m *MockPlans) ReadJSONOutput(ctx context.Context, planID string) ([]byte, return []byte(planOutput), nil } +type MockTaskStages struct { + client *MockClient +} + +func newMockTaskStages(client *MockClient) *MockTaskStages { + return &MockTaskStages{ + client: client, + } +} + +func (m *MockTaskStages) Override(ctx context.Context, taskStageID string, options tfe.TaskStageOverrideOptions) (*tfe.TaskStage, error) { + switch taskStageID { + case "ts-err": + return nil, errors.New("test error") + + default: + return nil, nil + } +} + +func (m *MockTaskStages) Read(ctx context.Context, taskStageID string, options *tfe.TaskStageReadOptions) (*tfe.TaskStage, error) { + //TODO implement me + panic("implement me") +} + +func (m *MockTaskStages) List(ctx context.Context, runID string, options *tfe.TaskStageListOptions) (*tfe.TaskStageList, error) { + //TODO implement me + panic("implement me") +} + type MockPolicySetOutcomes struct { client *MockClient } From 5dea138a11caf61adb66eca9f240f54f9a695eae Mon Sep 17 00:00:00 2001 From: mrinalirao Date: Thu, 1 Dec 2022 08:40:57 +1100 Subject: [PATCH 05/11] fix logic in multiErrors Append func --- internal/cloud/errors.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/cloud/errors.go b/internal/cloud/errors.go index 339a6ea01f..193db30261 100644 --- a/internal/cloud/errors.go +++ b/internal/cloud/errors.go @@ -71,8 +71,9 @@ func (e *multiErrors) Append(errs ...error) { } if errs, ok := err.(multiErrors); ok { *e = append(*e, errs...) + } else { + *e = append(*e, err) } - *e = append(*e, err) } } From f993106e496a2c8531448016600e15a7dc306109 Mon Sep 17 00:00:00 2001 From: mrinalirao Date: Thu, 1 Dec 2022 08:42:25 +1100 Subject: [PATCH 06/11] fix logic in MultiErrors append func --- internal/cloud/errors.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/cloud/errors.go b/internal/cloud/errors.go index 339a6ea01f..193db30261 100644 --- a/internal/cloud/errors.go +++ b/internal/cloud/errors.go @@ -71,8 +71,9 @@ func (e *multiErrors) Append(errs ...error) { } if errs, ok := err.(multiErrors); ok { *e = append(*e, errs...) + } else { + *e = append(*e, err) } - *e = append(*e, err) } } From 5c5b1099c870d9ce5b5531e03eb4a31dd34ff26b Mon Sep 17 00:00:00 2001 From: mrinalirao Date: Fri, 2 Dec 2022 09:09:51 +1100 Subject: [PATCH 07/11] refactor runTaskStage --- internal/cloud/backend_taskStages.go | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/internal/cloud/backend_taskStages.go b/internal/cloud/backend_taskStages.go index b465af4113..eba4f15de3 100644 --- a/internal/cloud/backend_taskStages.go +++ b/internal/cloud/backend_taskStages.go @@ -84,21 +84,23 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr case tfe.TaskStageRunning, tfe.TaskStagePassed, tfe.TaskStageCanceled, tfe.TaskStageErrored, tfe.TaskStageFailed: for _, s := range summarizers { cont, msg, err := s.Summarize(ctx, output, stage) - if cont { - if msg != nil { - if i%4 == 0 { - if i > 0 { - output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation - } - } - } - return true, nil - } if err != nil { errs.Append(err) + break } + if !cont { + continue + } + // cont is true and we must continue to poll + if msg != nil { + // print msg every 4 seconds + if i%4 == 0 && i > 0 { + output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation + } + } + return true, nil } - case "unreachable": + case tfe.TaskStageUnreachable: return false, nil default: return false, fmt.Errorf("Invalid Task stage status: %s ", stage.Status) From 15288caf644d2c95a401cb5008edc583ffaca98b Mon Sep 17 00:00:00 2001 From: mrinalirao Date: Fri, 2 Dec 2022 10:15:51 +1100 Subject: [PATCH 08/11] Code Improvements: - Use tfe consts instead of hardcoded values - fix logic when polling taskStage - remove inaccurate comment --- internal/cloud/backend_taskStage_taskResults.go | 8 ++++---- internal/cloud/backend_taskStages.go | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/internal/cloud/backend_taskStage_taskResults.go b/internal/cloud/backend_taskStage_taskResults.go index 96d085df5a..0f96889b69 100644 --- a/internal/cloud/backend_taskStage_taskResults.go +++ b/internal/cloud/backend_taskStage_taskResults.go @@ -62,18 +62,18 @@ func (trs *taskResultSummarizer) Summarize(context *IntegrationContext, output I func summarizeTaskResults(taskResults []*tfe.TaskResult) *taskResultSummary { var pendingCount, errCount, errMandatoryCount, passedCount int for _, task := range taskResults { - if task.Status == "unreachable" { + if task.Status == tfe.TaskUnreachable { return &taskResultSummary{ unreachable: true, } - } else if task.Status == "running" || task.Status == "pending" { + } else if task.Status == tfe.TaskRunning || task.Status == tfe.TaskPending { pendingCount++ - } else if task.Status == "passed" { + } else if task.Status == tfe.TaskPassed { passedCount++ } else { // Everything else is a failure errCount++ - if task.WorkspaceTaskEnforcementLevel == "mandatory" { + if task.WorkspaceTaskEnforcementLevel == tfe.Mandatory { errMandatoryCount++ } } diff --git a/internal/cloud/backend_taskStages.go b/internal/cloud/backend_taskStages.go index eba4f15de3..c623d72bb5 100644 --- a/internal/cloud/backend_taskStages.go +++ b/internal/cloud/backend_taskStages.go @@ -89,11 +89,10 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr break } if !cont { - continue + return false, nil } // cont is true and we must continue to poll if msg != nil { - // print msg every 4 seconds if i%4 == 0 && i > 0 { output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation } From a5add7e361fda5019f9ec8dcc1ddb3952e313256 Mon Sep 17 00:00:00 2001 From: mrinalirao Date: Fri, 2 Dec 2022 12:38:55 +1100 Subject: [PATCH 09/11] modify Poll func to pass in backoff interval --- .../cloud/backend_taskStage_taskResults_test.go | 2 +- internal/cloud/backend_taskStages.go | 15 ++++++++++----- internal/cloud/cloud_integration.go | 4 ++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/internal/cloud/backend_taskStage_taskResults_test.go b/internal/cloud/backend_taskStage_taskResults_test.go index 7e14a15282..d6bc731b34 100644 --- a/internal/cloud/backend_taskStage_taskResults_test.go +++ b/internal/cloud/backend_taskStage_taskResults_test.go @@ -151,7 +151,7 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { trs := taskResultSummarizer{ cloud: b, } - c.context.Poll(func(i int) (bool, error) { + c.context.Poll(taskStageBackoffMin, taskStageBackoffMax, func(i int) (bool, error) { cont, _, _ := trs.Summarize(c.context, c.writer, c.taskStage()) if cont { return true, nil diff --git a/internal/cloud/backend_taskStages.go b/internal/cloud/backend_taskStages.go index c623d72bb5..719975d7c7 100644 --- a/internal/cloud/backend_taskStages.go +++ b/internal/cloud/backend_taskStages.go @@ -8,6 +8,11 @@ import ( tfe "github.com/hashicorp/go-tfe" ) +const ( + taskStageBackoffMin = 4000.0 + taskStageBackoffMax = 12000.0 +) + type taskStages map[tfe.Stage]*tfe.TaskStage type taskStageSummarizer interface { @@ -67,7 +72,7 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr summarizers = append(summarizers, s) } - return ctx.Poll(func(i int) (bool, error) { + return ctx.Poll(taskStageBackoffMin, taskStageBackoffMax, func(i int) (bool, error) { options := tfe.TaskStageReadOptions{ Include: []tfe.TaskStageIncludeOpt{tfe.TaskStageTaskResults}, } @@ -88,14 +93,14 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr errs.Append(err) break } + if !cont { - return false, nil + continue } + // cont is true and we must continue to poll if msg != nil { - if i%4 == 0 && i > 0 { - output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation - } + output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation } return true, nil } diff --git a/internal/cloud/cloud_integration.go b/internal/cloud/cloud_integration.go index 047fc57093..cd1c6be96a 100644 --- a/internal/cloud/cloud_integration.go +++ b/internal/cloud/cloud_integration.go @@ -38,14 +38,14 @@ type integrationCLIOutput struct { var _ IntegrationOutputWriter = (*integrationCLIOutput)(nil) // Compile time check -func (s *IntegrationContext) Poll(every func(i int) (bool, error)) error { +func (s *IntegrationContext) Poll(backoffMinInterval float64, backoffMaxInterval float64, every func(i int) (bool, error)) error { for i := 0; ; i++ { select { case <-s.StopContext.Done(): return s.StopContext.Err() case <-s.CancelContext.Done(): return s.CancelContext.Err() - case <-time.After(backoff(backoffMin, backoffMax, i)): + case <-time.After(backoff(backoffMinInterval, backoffMaxInterval, i)): // blocks for a time between min and max } From d11aa099ccd76c9c5aed68053849b9f751fc005e Mon Sep 17 00:00:00 2001 From: mrinalirao Date: Thu, 1 Dec 2022 12:08:32 +1100 Subject: [PATCH 10/11] =?UTF-8?q?=E2=80=A2=20return=20error=20on=20task=20?= =?UTF-8?q?stage=20failed,=20canceled=20or=20errored=20=E2=80=A2=20refacto?= =?UTF-8?q?r=20Poll=20func=20to=20pass=20backoff=20interval=20for=20task?= =?UTF-8?q?=20stage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...backend_taskStage_policyEvaluation_test.go | 2 +- .../backend_taskStage_taskResults_test.go | 2 +- internal/cloud/backend_taskStages.go | 67 +++++++++++++------ internal/cloud/cloud_integration.go | 4 +- 4 files changed, 49 insertions(+), 26 deletions(-) diff --git a/internal/cloud/backend_taskStage_policyEvaluation_test.go b/internal/cloud/backend_taskStage_policyEvaluation_test.go index 3d08f74d90..7ea54ec9e9 100644 --- a/internal/cloud/backend_taskStage_policyEvaluation_test.go +++ b/internal/cloud/backend_taskStage_policyEvaluation_test.go @@ -79,7 +79,7 @@ func TestCloud_runTaskStageWithPolicyEvaluation(t *testing.T) { trs := policyEvaluationSummarizer{ cloud: b, } - c.context.Poll(func(i int) (bool, error) { + c.context.Poll(taskStageBackoffMin, taskStageBackoffMax, func(i int) (bool, error) { cont, _, _ := trs.Summarize(c.context, c.writer, c.taskStage()) if cont { return true, nil diff --git a/internal/cloud/backend_taskStage_taskResults_test.go b/internal/cloud/backend_taskStage_taskResults_test.go index 7e14a15282..d6bc731b34 100644 --- a/internal/cloud/backend_taskStage_taskResults_test.go +++ b/internal/cloud/backend_taskStage_taskResults_test.go @@ -151,7 +151,7 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { trs := taskResultSummarizer{ cloud: b, } - c.context.Poll(func(i int) (bool, error) { + c.context.Poll(taskStageBackoffMin, taskStageBackoffMax, func(i int) (bool, error) { cont, _, _ := trs.Summarize(c.context, c.writer, c.taskStage()) if cont { return true, nil diff --git a/internal/cloud/backend_taskStages.go b/internal/cloud/backend_taskStages.go index 2b288c8b15..72f62f7ba3 100644 --- a/internal/cloud/backend_taskStages.go +++ b/internal/cloud/backend_taskStages.go @@ -11,6 +11,11 @@ import ( type taskStages map[tfe.Stage]*tfe.TaskStage +const ( + taskStageBackoffMin = 4000.0 + taskStageBackoffMax = 12000.0 +) + const taskStageHeader = ` To view this run in a browser, visit: https://%s/app/%s/%s/runs/%s @@ -78,7 +83,7 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr summarizers = append(summarizers, s) } - return ctx.Poll(func(i int) (bool, error) { + return ctx.Poll(taskStageBackoffMin, taskStageBackoffMax, func(i int) (bool, error) { options := tfe.TaskStageReadOptions{ Include: []tfe.TaskStageIncludeOpt{tfe.TaskStageTaskResults, tfe.PolicyEvaluationsTaskResults}, } @@ -92,39 +97,57 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr // Waiting for it to start return true, nil // Note: Terminal statuses need to print out one last time just in case - case tfe.TaskStageRunning, tfe.TaskStagePassed, tfe.TaskStageCanceled, tfe.TaskStageErrored, tfe.TaskStageFailed: + case tfe.TaskStageRunning, tfe.TaskStagePassed: for _, s := range summarizers { cont, msg, err := s.Summarize(ctx, output, stage) - if cont { - if msg != nil { - if i%4 == 0 { - if i > 0 { - output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation - } - } - } - return true, nil - } if err != nil { errs.Append(err) + break } + if !cont { + continue + } + + // cont is true and we must continue to poll + if msg != nil { + output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation + } + return true, nil } + case tfe.TaskStageCanceled, tfe.TaskStageErrored, tfe.TaskStageFailed: + for _, s := range summarizers { + cont, msg, err := s.Summarize(ctx, output, stage) + if err != nil { + errs.Append(err) + break + } + + if !cont { + continue + } + + // cont is true and we must continue to poll + if msg != nil { + output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation + } + return true, nil + } + return false, fmt.Errorf("Task Stage %s.", stage.Status) case tfe.TaskStageAwaitingOverride: for _, s := range summarizers { cont, msg, err := s.Summarize(ctx, output, stage) - if cont { - if msg != nil { - if i%4 == 0 { - if i > 0 { - output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation - } - } - } - return true, nil - } if err != nil { errs.Append(err) + break } + if !cont { + continue + } + // cont is true and we must continue to poll + if msg != nil { + output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation + } + return true, nil } cont, err := b.processStageOverrides(ctx, output, stage.ID) if err != nil { diff --git a/internal/cloud/cloud_integration.go b/internal/cloud/cloud_integration.go index 047fc57093..cd1c6be96a 100644 --- a/internal/cloud/cloud_integration.go +++ b/internal/cloud/cloud_integration.go @@ -38,14 +38,14 @@ type integrationCLIOutput struct { var _ IntegrationOutputWriter = (*integrationCLIOutput)(nil) // Compile time check -func (s *IntegrationContext) Poll(every func(i int) (bool, error)) error { +func (s *IntegrationContext) Poll(backoffMinInterval float64, backoffMaxInterval float64, every func(i int) (bool, error)) error { for i := 0; ; i++ { select { case <-s.StopContext.Done(): return s.StopContext.Err() case <-s.CancelContext.Done(): return s.CancelContext.Err() - case <-time.After(backoff(backoffMin, backoffMax, i)): + case <-time.After(backoff(backoffMinInterval, backoffMaxInterval, i)): // blocks for a time between min and max } From dcd2826277580fa911168222d8d1c1ac251d4059 Mon Sep 17 00:00:00 2001 From: mrinalirao Date: Mon, 16 Jan 2023 11:10:53 +1100 Subject: [PATCH 11/11] refactor runTaskStage func & use multierrors lib --- .../backend_taskStage_policyEvaluation.go | 2 +- ...backend_taskStage_policyEvaluation_test.go | 2 +- internal/cloud/backend_taskStages.go | 89 +++++++++---------- internal/cloud/errors.go | 44 --------- 4 files changed, 42 insertions(+), 95 deletions(-) diff --git a/internal/cloud/backend_taskStage_policyEvaluation.go b/internal/cloud/backend_taskStage_policyEvaluation.go index eeba9591a8..16f4c2e504 100644 --- a/internal/cloud/backend_taskStage_policyEvaluation.go +++ b/internal/cloud/backend_taskStage_policyEvaluation.go @@ -104,7 +104,7 @@ func (pes *policyEvaluationSummarizer) taskStageWithPolicyEvaluation(context *In // Currently only one policy evaluation supported : OPA for _, polEvaluation := range policyEvaluation { if polEvaluation.Status == tfe.PolicyEvaluationPassed { - message = "[dim] This result means that all OPA policies passed and the protected behaviour is allowed" + message = "[dim] This result means that all OPA policies passed and the protected behavior is allowed" result = fmt.Sprintf("[green]%s", strings.ToUpper(string(tfe.PolicyEvaluationPassed))) if polEvaluation.ResultCount.AdvisoryFailed > 0 { result += " (with advisory)" diff --git a/internal/cloud/backend_taskStage_policyEvaluation_test.go b/internal/cloud/backend_taskStage_policyEvaluation_test.go index 7ea54ec9e9..46218396d1 100644 --- a/internal/cloud/backend_taskStage_policyEvaluation_test.go +++ b/internal/cloud/backend_taskStage_policyEvaluation_test.go @@ -30,7 +30,7 @@ func TestCloud_runTaskStageWithPolicyEvaluation(t *testing.T) { }, writer: writer, context: integrationContext, - expectedOutputs: []string{"│ [bold]OPA Policy Evaluation\n\n│ [bold]→→ Overall Result: [green]PASSED\n│ [dim] This result means that all OPA policies passed and the protected behaviour is allowed\n│ 1 policies evaluated\n\n│ → Policy set 1: [bold]policy-set-that-passes (1)\n│ ↳ Policy name: [bold]policy-pass\n│ | [green][bold]✓ Passed\n│ | [dim]This policy will pass\n"}, + expectedOutputs: []string{"│ [bold]OPA Policy Evaluation\n\n│ [bold]→→ Overall Result: [green]PASSED\n│ [dim] This result means that all OPA policies passed and the protected behavior is allowed\n│ 1 policies evaluated\n\n│ → Policy set 1: [bold]policy-set-that-passes (1)\n│ ↳ Policy name: [bold]policy-pass\n│ | [green][bold]✓ Passed\n│ | [dim]This policy will pass\n"}, isError: false, }, "mandatory-failed": { diff --git a/internal/cloud/backend_taskStages.go b/internal/cloud/backend_taskStages.go index 72f62f7ba3..36dab7629a 100644 --- a/internal/cloud/backend_taskStages.go +++ b/internal/cloud/backend_taskStages.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/hashicorp/go-multierror" tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform/internal/terraform" ) @@ -66,7 +67,7 @@ func (b *Cloud) getTaskStageWithAllOptions(ctx *IntegrationContext, stageID stri } func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWriter, stageID string) error { - var errs multiErrors + var errs *multierror.Error // Create our summarizers summarizers := make([]taskStageSummarizer, 0) @@ -98,60 +99,33 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr return true, nil // Note: Terminal statuses need to print out one last time just in case case tfe.TaskStageRunning, tfe.TaskStagePassed: - for _, s := range summarizers { - cont, msg, err := s.Summarize(ctx, output, stage) - if err != nil { - errs.Append(err) - break - } - if !cont { - continue - } - - // cont is true and we must continue to poll - if msg != nil { - output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation - } + ok, e := processSummarizers(ctx, output, stage, summarizers, errs) + if e != nil { + errs = e + } + if ok { return true, nil } case tfe.TaskStageCanceled, tfe.TaskStageErrored, tfe.TaskStageFailed: - for _, s := range summarizers { - cont, msg, err := s.Summarize(ctx, output, stage) - if err != nil { - errs.Append(err) - break - } - - if !cont { - continue - } - - // cont is true and we must continue to poll - if msg != nil { - output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation - } + ok, e := processSummarizers(ctx, output, stage, summarizers, errs) + if e != nil { + errs = e + } + if ok { return true, nil } return false, fmt.Errorf("Task Stage %s.", stage.Status) case tfe.TaskStageAwaitingOverride: - for _, s := range summarizers { - cont, msg, err := s.Summarize(ctx, output, stage) - if err != nil { - errs.Append(err) - break - } - if !cont { - continue - } - // cont is true and we must continue to poll - if msg != nil { - output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation - } + ok, e := processSummarizers(ctx, output, stage, summarizers, errs) + if e != nil { + errs = e + } + if ok { return true, nil } cont, err := b.processStageOverrides(ctx, output, stage.ID) if err != nil { - errs.Append(err) + errs = multierror.Append(errs, err) } else { return cont, nil } @@ -160,14 +134,31 @@ func (b *Cloud) runTaskStage(ctx *IntegrationContext, output IntegrationOutputWr default: return false, fmt.Errorf("Invalid Task stage status: %s ", stage.Status) } - - if len(errs) > 0 { - return false, errs.Err() - } - return false, nil + return false, errs.ErrorOrNil() }) } +func processSummarizers(ctx *IntegrationContext, output IntegrationOutputWriter, stage *tfe.TaskStage, summarizers []taskStageSummarizer, errs *multierror.Error) (bool, *multierror.Error) { + for _, s := range summarizers { + cont, msg, err := s.Summarize(ctx, output, stage) + if err != nil { + errs = multierror.Append(errs, err) + break + } + + if !cont { + continue + } + + // cont is true and we must continue to poll + if msg != nil { + output.OutputElapsed(*msg, len(*msg)) // Up to 2 digits are allowed by the max message allocation + } + return true, nil + } + return false, errs +} + func (b *Cloud) processStageOverrides(context *IntegrationContext, output IntegrationOutputWriter, taskStageID string) (bool, error) { opts := &terraform.InputOpts{ Id: fmt.Sprintf("%c%c [bold]Override", Arrow, Arrow), diff --git a/internal/cloud/errors.go b/internal/cloud/errors.go index 193db30261..cf668516f3 100644 --- a/internal/cloud/errors.go +++ b/internal/cloud/errors.go @@ -58,47 +58,3 @@ func incompatibleWorkspaceTerraformVersion(message string, ignoreVersionConflict description := strings.TrimSpace(fmt.Sprintf("%s\n\n%s", message, suggestion)) return tfdiags.Sourceless(severity, "Incompatible Terraform version", description) } - -type multiErrors []error - -// Append errs to e only if the individual error in errs is not nil -// -// If any of the errs is itself multiErrors, each individual error in errs is appended. -func (e *multiErrors) Append(errs ...error) { - for _, err := range errs { - if err == nil { - continue - } - if errs, ok := err.(multiErrors); ok { - *e = append(*e, errs...) - } else { - *e = append(*e, err) - } - } -} - -// multiErrors returns an error string by joining -// all of its nonnil errors with colon separator. -func (e multiErrors) Error() string { - if len(e) == 0 { - return "" - } - es := make([]string, 0, len(e)) - for _, err := range e { - if err != nil { - es = append(es, err.Error()) - } - } - return strings.Join(es, ":") -} - -// Err returns e as an error or returns nil if no errors were collected. -func (e multiErrors) Err() error { - // Only return self if we have at least one nonnil error. - for _, err := range e { - if err != nil { - return e - } - } - return nil -}