diff --git a/.changes/v1.16/BUG FIXES-20260728-043144.yaml b/.changes/v1.16/BUG FIXES-20260728-043144.yaml new file mode 100644 index 000000000000..cfc3fff03291 --- /dev/null +++ b/.changes/v1.16/BUG FIXES-20260728-043144.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'cloud: Fixed a bug causing the CLI to pause indefinitely after a run task failure with pending policy evaluations' +time: 2026-07-28T04:31:44.000000-07:00 +custom: + Issue: "38751" diff --git a/go.mod b/go.mod index 80c6ce97aac7..d4438d4a6d74 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/hashicorp/go-plugin v1.7.0 github.com/hashicorp/go-retryablehttp v0.7.8 github.com/hashicorp/go-slug v0.18.1 - github.com/hashicorp/go-tfe v1.108.0 + github.com/hashicorp/go-tfe v1.110.0 github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.9.0 github.com/hashicorp/hcl v1.0.0 diff --git a/go.sum b/go.sum index c0e9fc4dc35f..03d7bc48706a 100644 --- a/go.sum +++ b/go.sum @@ -378,8 +378,8 @@ github.com/hashicorp/go-slug v0.18.1 h1:UnWIy4mq9GaDr1LhAzCPgA6RSQUn952RLFqQe3HP github.com/hashicorp/go-slug v0.18.1/go.mod h1:Zxkkl8/LfXmhxZO3fLXQUCy3MVXAJK9pybY8WoDPgvs= github.com/hashicorp/go-sockaddr v1.0.5 h1:dvk7TIXCZpmfOlM+9mlcrWmWjw/wlKT+VDq2wMvfPJU= github.com/hashicorp/go-sockaddr v1.0.5/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= -github.com/hashicorp/go-tfe v1.108.0 h1:Wsy7Jj0NOfaHs83v+2h7QcTxsVR8svQaUFTZJ0xJSLk= -github.com/hashicorp/go-tfe v1.108.0/go.mod h1:d8js2OmMnCq58gEh26mCS81nD8Aj7HmG6IO1b80gM78= +github.com/hashicorp/go-tfe v1.110.0 h1:R61zw8hgXH+A06rb77GhZXkexjiTls/sPM+lL3G4VsE= +github.com/hashicorp/go-tfe v1.110.0/go.mod h1:VH4URSfSw6421VEBdfjub/oTINTvT5Mhp4Gd9IA3Ifw= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= diff --git a/internal/cloud/backend_taskStage_policyEvaluation.go b/internal/cloud/backend_taskStage_policyEvaluation.go index 6c141a1c5ab0..0f6ef2bfe316 100644 --- a/internal/cloud/backend_taskStage_policyEvaluation.go +++ b/internal/cloud/backend_taskStage_policyEvaluation.go @@ -17,6 +17,29 @@ type policyEvaluationSummary struct { passed int } +func isNonTerminalPolicyEvaluationStatus(status tfe.PolicyEvaluationStatus) bool { + switch status { + case tfe.PolicyEvaluationRunning, tfe.PolicyEvaluationPending, tfe.PolicyEvaluationQueued: + return true + default: + return false + } +} + +func partitionPolicyEvaluations(policyEvaluations []*tfe.PolicyEvaluation) ([]*tfe.PolicyEvaluation, []*tfe.PolicyEvaluation) { + completed := make([]*tfe.PolicyEvaluation, 0, len(policyEvaluations)) + pending := make([]*tfe.PolicyEvaluation, 0, len(policyEvaluations)) + for _, policyEvaluation := range policyEvaluations { + if isNonTerminalPolicyEvaluationStatus(policyEvaluation.Status) { + pending = append(pending, policyEvaluation) + continue + } + completed = append(completed, policyEvaluation) + } + + return completed, pending +} + type Symbol rune const ( @@ -56,7 +79,7 @@ func (pes *policyEvaluationSummarizer) Summarize(context *IntegrationContext, ou counts := summarizePolicyEvaluationResults(ts.PolicyEvaluations) - if counts.pending != 0 { + if counts.pending != 0 && !isTerminalTaskStageStatus(ts.Status) { pendingMessage := "Evaluating ... " return true, &pendingMessage, nil } @@ -68,7 +91,7 @@ func (pes *policyEvaluationSummarizer) Summarize(context *IntegrationContext, ou } // Print out the summary - if err := pes.taskStageWithPolicyEvaluation(context, output, ts.PolicyEvaluations); err != nil { + if err := pes.taskStageWithPolicyEvaluation(context, output, ts); err != nil { return false, nil, err } // Mark as finished @@ -103,10 +126,26 @@ func summarizePolicyEvaluationResults(policyEvaluations []*tfe.PolicyEvaluation) } } -func (pes *policyEvaluationSummarizer) taskStageWithPolicyEvaluation(context *IntegrationContext, output IntegrationOutputWriter, policyEvaluation []*tfe.PolicyEvaluation) error { +func (pes *policyEvaluationSummarizer) taskStageWithPolicyEvaluation(context *IntegrationContext, output IntegrationOutputWriter, ts *tfe.TaskStage) error { + policyEvaluations := ts.PolicyEvaluations + pendingToSkipCount := 0 + + if isTerminalTaskStageStatus(ts.Status) { + completed, pending := partitionPolicyEvaluations(policyEvaluations) + if len(pending) > 0 && len(completed) == 0 { + output.Output("Skipping policy evaluation.") + output.End() + return nil + } + + if len(pending) > 0 { + pendingToSkipCount = len(pending) + } + } + var result, message, kind string // Currently only one policy evaluation supported : OPA - for _, polEvaluation := range policyEvaluation { + for _, polEvaluation := range policyEvaluations { if polEvaluation.PolicyKind == "opa" { kind = "OPA" } else { @@ -118,6 +157,9 @@ func (pes *policyEvaluationSummarizer) taskStageWithPolicyEvaluation(context *In if polEvaluation.ResultCount.AdvisoryFailed > 0 { result += " (with advisory)" } + } else if isNonTerminalPolicyEvaluationStatus(polEvaluation.Status) { + message = fmt.Sprintf("[dim] Pending policy evaluation skipped as task stage is %s.", ts.Status) + result = "[dim]skipped" } else { message = fmt.Sprintf("[dim] This result means that one or more %s policies failed. More than likely, this was due to the discovery of violations by the main rule and other sub rules", kind) result = fmt.Sprintf("[red]%s", strings.ToUpper(string(tfe.PolicyEvaluationFailed))) @@ -160,6 +202,12 @@ func (pes *policyEvaluationSummarizer) taskStageWithPolicyEvaluation(context *In } } } + + if pendingToSkipCount > 0 { + output.Output(fmt.Sprintf("Skipping %d pending policy evaluation(s) because task stage is %s.", pendingToSkipCount, ts.Status)) + output.End() + } + return nil } diff --git a/internal/cloud/backend_taskStage_policyEvaluation_test.go b/internal/cloud/backend_taskStage_policyEvaluation_test.go index 6f5f63c00786..d95435c01bf3 100644 --- a/internal/cloud/backend_taskStage_policyEvaluation_test.go +++ b/internal/cloud/backend_taskStage_policyEvaluation_test.go @@ -17,17 +17,18 @@ func TestCloud_runTaskStageWithOPAPolicyEvaluation(t *testing.T) { integrationContext, writer := newMockIntegrationContext(b, t) cases := map[string]struct { - taskStage func() *tfe.TaskStage - context *IntegrationContext - writer *testIntegrationOutput - expectedOutputs []string - isError bool + taskStage func() *tfe.TaskStage + context *IntegrationContext + writer *testIntegrationOutput + expectedOutputs []string + expectedContinue bool + 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", PolicyKind: "opa"}, + {ID: "pol-pass", ResultCount: &tfe.PolicyResultCount{Passed: 1}, Status: tfe.PolicyEvaluationPassed, PolicyKind: tfe.OPA}, } return ts }, @@ -40,7 +41,7 @@ func TestCloud_runTaskStageWithOPAPolicyEvaluation(t *testing.T) { taskStage: func() *tfe.TaskStage { ts := &tfe.TaskStage{} ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ - {ID: "pol-fail", ResultCount: &tfe.PolicyResultCount{MandatoryFailed: 1}, Status: "failed", PolicyKind: "opa"}, + {ID: "pol-fail", ResultCount: &tfe.PolicyResultCount{MandatoryFailed: 1}, Status: tfe.PolicyEvaluationFailed, PolicyKind: tfe.OPA}, } return ts }, @@ -53,7 +54,7 @@ func TestCloud_runTaskStageWithOPAPolicyEvaluation(t *testing.T) { taskStage: func() *tfe.TaskStage { ts := &tfe.TaskStage{} ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ - {ID: "adv-fail", ResultCount: &tfe.PolicyResultCount{AdvisoryFailed: 1}, Status: "failed", PolicyKind: "opa"}, + {ID: "adv-fail", ResultCount: &tfe.PolicyResultCount{AdvisoryFailed: 1}, Status: tfe.PolicyEvaluationFailed, PolicyKind: tfe.OPA}, } return ts }, @@ -66,7 +67,7 @@ func TestCloud_runTaskStageWithOPAPolicyEvaluation(t *testing.T) { taskStage: func() *tfe.TaskStage { ts := &tfe.TaskStage{} ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ - {ID: "adv-fail", ResultCount: &tfe.PolicyResultCount{Errored: 1}, Status: "unreachable", PolicyKind: "opa"}, + {ID: "adv-fail", ResultCount: &tfe.PolicyResultCount{Errored: 1}, Status: tfe.PolicyEvaluationUnreachable, PolicyKind: tfe.OPA}, } return ts }, @@ -75,6 +76,169 @@ func TestCloud_runTaskStageWithOPAPolicyEvaluation(t *testing.T) { expectedOutputs: []string{"Skipping policy evaluation."}, isError: false, }, + "pending-with-running-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageRunning} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.OPA}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Evaluating ... "}, + expectedContinue: true, + isError: false, + }, + "unreachable-with-pending-in-terminal-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageFailed} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.OPA}, + {ID: "pol-unreachable", ResultCount: &tfe.PolicyResultCount{Errored: 1}, Status: tfe.PolicyEvaluationUnreachable, PolicyKind: tfe.OPA}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping policy evaluation."}, + isError: false, + }, + "pending-with-failed-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageFailed} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.OPA}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping policy evaluation."}, + isError: false, + }, + "pending-with-canceled-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageCanceled} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.OPA}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping policy evaluation."}, + isError: false, + }, + "pending-with-errored-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageErrored} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.OPA}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping policy evaluation."}, + isError: false, + }, + "pending-with-passed-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStagePassed} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.OPA}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping policy evaluation."}, + isError: false, + }, + "mixed-pending-and-completed-with-failed-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageFailed} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pass", ResultCount: &tfe.PolicyResultCount{Passed: 1}, Status: tfe.PolicyEvaluationPassed, PolicyKind: tfe.OPA}, + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.OPA}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{ + "OPA Policy Evaluation", + "Overall Result: [green]PASSED", + "[dim]skipped", + "Pending policy evaluation skipped as task stage is failed.", + "Skipping 1 pending policy evaluation(s) because task stage is failed.", + }, + isError: false, + }, + "multiple-mixed-states-with-failed-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageFailed} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pass", ResultCount: &tfe.PolicyResultCount{Passed: 1}, Status: tfe.PolicyEvaluationPassed, PolicyKind: tfe.OPA}, + {ID: "pol-fail", ResultCount: &tfe.PolicyResultCount{MandatoryFailed: 1}, Status: tfe.PolicyEvaluationFailed, PolicyKind: tfe.OPA}, + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.OPA}, + {ID: "pol-pending-2", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationQueued, PolicyKind: tfe.OPA}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{ + "OPA Policy Evaluation", + "Overall Result: [green]PASSED", + "Overall Result: [red]FAILED", + "[dim]skipped", + "Pending policy evaluation skipped as task stage is failed.", + "Skipping 2 pending policy evaluation(s) because task stage is failed.", + }, + isError: false, + }, + "mixed-pending-and-completed-with-canceled-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageCanceled} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pass", ResultCount: &tfe.PolicyResultCount{Passed: 1}, Status: tfe.PolicyEvaluationPassed, PolicyKind: tfe.OPA}, + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.OPA}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{ + "OPA Policy Evaluation", + "Overall Result: [green]PASSED", + "[dim]skipped", + "Pending policy evaluation skipped as task stage is canceled.", + "Skipping 1 pending policy evaluation(s) because task stage is canceled.", + }, + isError: false, + }, + "mixed-pending-and-completed-with-errored-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageErrored} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pass", ResultCount: &tfe.PolicyResultCount{Passed: 1}, Status: tfe.PolicyEvaluationPassed, PolicyKind: tfe.OPA}, + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.OPA}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{ + "OPA Policy Evaluation", + "Overall Result: [green]PASSED", + "[dim]skipped", + "Pending policy evaluation skipped as task stage is errored.", + "Skipping 1 pending policy evaluation(s) because task stage is errored.", + }, + isError: false, + }, } for _, c := range cases { @@ -83,9 +247,12 @@ func TestCloud_runTaskStageWithOPAPolicyEvaluation(t *testing.T) { cloud: b, } c.context.Poll(0, 0, func(i int) (bool, error) { - cont, _, _ := trs.Summarize(c.context, c.writer, c.taskStage()) - if cont { - return true, nil + cont, msg, _ := trs.Summarize(c.context, c.writer, c.taskStage()) + if cont != c.expectedContinue { + t.Fatalf("expected continue=%t, got %t", c.expectedContinue, cont) + } + if cont && msg != nil { + c.writer.OutputElapsed(*msg, len(*msg)) } output := c.writer.output.String() @@ -106,17 +273,18 @@ func TestCloud_runTaskStageWithSentinelPolicyEvaluation(t *testing.T) { integrationContext, writer := newMockIntegrationContext(b, t) cases := map[string]struct { - taskStage func() *tfe.TaskStage - context *IntegrationContext - writer *testIntegrationOutput - expectedOutputs []string - isError bool + taskStage func() *tfe.TaskStage + context *IntegrationContext + writer *testIntegrationOutput + expectedOutputs []string + expectedContinue bool + 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", PolicyKind: "sentinel"}, + {ID: "pol-pass", ResultCount: &tfe.PolicyResultCount{Passed: 1}, Status: tfe.PolicyEvaluationPassed, PolicyKind: tfe.Sentinel}, } return ts }, @@ -129,7 +297,7 @@ func TestCloud_runTaskStageWithSentinelPolicyEvaluation(t *testing.T) { taskStage: func() *tfe.TaskStage { ts := &tfe.TaskStage{} ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ - {ID: "pol-fail", ResultCount: &tfe.PolicyResultCount{MandatoryFailed: 1}, Status: "failed", PolicyKind: "sentinel"}, + {ID: "pol-fail", ResultCount: &tfe.PolicyResultCount{MandatoryFailed: 1}, Status: tfe.PolicyEvaluationFailed, PolicyKind: tfe.Sentinel}, } return ts }, @@ -142,7 +310,7 @@ func TestCloud_runTaskStageWithSentinelPolicyEvaluation(t *testing.T) { taskStage: func() *tfe.TaskStage { ts := &tfe.TaskStage{} ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ - {ID: "adv-fail", ResultCount: &tfe.PolicyResultCount{AdvisoryFailed: 1}, Status: "failed", PolicyKind: "sentinel"}, + {ID: "adv-fail", ResultCount: &tfe.PolicyResultCount{AdvisoryFailed: 1}, Status: tfe.PolicyEvaluationFailed, PolicyKind: tfe.Sentinel}, } return ts }, @@ -155,7 +323,7 @@ func TestCloud_runTaskStageWithSentinelPolicyEvaluation(t *testing.T) { taskStage: func() *tfe.TaskStage { ts := &tfe.TaskStage{} ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ - {ID: "adv-fail", ResultCount: &tfe.PolicyResultCount{Errored: 1}, Status: "unreachable", PolicyKind: "sentinel"}, + {ID: "adv-fail", ResultCount: &tfe.PolicyResultCount{Errored: 1}, Status: tfe.PolicyEvaluationUnreachable, PolicyKind: tfe.Sentinel}, } return ts }, @@ -164,6 +332,169 @@ func TestCloud_runTaskStageWithSentinelPolicyEvaluation(t *testing.T) { expectedOutputs: []string{"Skipping policy evaluation."}, isError: false, }, + "pending-with-running-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageRunning} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.Sentinel}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Evaluating ... "}, + expectedContinue: true, + isError: false, + }, + "unreachable-with-pending-in-terminal-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageErrored} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.Sentinel}, + {ID: "pol-unreachable", ResultCount: &tfe.PolicyResultCount{Errored: 1}, Status: tfe.PolicyEvaluationUnreachable, PolicyKind: tfe.Sentinel}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping policy evaluation."}, + isError: false, + }, + "pending-with-failed-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageFailed} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.Sentinel}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping policy evaluation."}, + isError: false, + }, + "pending-with-canceled-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageCanceled} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.Sentinel}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping policy evaluation."}, + isError: false, + }, + "pending-with-errored-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageErrored} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.Sentinel}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping policy evaluation."}, + isError: false, + }, + "pending-with-passed-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStagePassed} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.Sentinel}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping policy evaluation."}, + isError: false, + }, + "mixed-pending-and-completed-with-failed-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageFailed} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pass", ResultCount: &tfe.PolicyResultCount{Passed: 1}, Status: tfe.PolicyEvaluationPassed, PolicyKind: tfe.Sentinel}, + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.Sentinel}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{ + "Sentinel Policy Evaluation", + "Overall Result: [green]PASSED", + "[dim]skipped", + "Pending policy evaluation skipped as task stage is failed.", + "Skipping 1 pending policy evaluation(s) because task stage is failed.", + }, + isError: false, + }, + "multiple-mixed-states-with-failed-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageFailed} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pass", ResultCount: &tfe.PolicyResultCount{Passed: 1}, Status: tfe.PolicyEvaluationPassed, PolicyKind: tfe.Sentinel}, + {ID: "pol-fail", ResultCount: &tfe.PolicyResultCount{MandatoryFailed: 1}, Status: tfe.PolicyEvaluationFailed, PolicyKind: tfe.Sentinel}, + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.Sentinel}, + {ID: "pol-pending-2", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationQueued, PolicyKind: tfe.Sentinel}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{ + "Sentinel Policy Evaluation", + "Overall Result: [green]PASSED", + "Overall Result: [red]FAILED", + "[dim]skipped", + "Pending policy evaluation skipped as task stage is failed.", + "Skipping 2 pending policy evaluation(s) because task stage is failed.", + }, + isError: false, + }, + "mixed-pending-and-completed-with-canceled-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageCanceled} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pass", ResultCount: &tfe.PolicyResultCount{Passed: 1}, Status: tfe.PolicyEvaluationPassed, PolicyKind: tfe.Sentinel}, + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.Sentinel}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{ + "Sentinel Policy Evaluation", + "Overall Result: [green]PASSED", + "[dim]skipped", + "Pending policy evaluation skipped as task stage is canceled.", + "Skipping 1 pending policy evaluation(s) because task stage is canceled.", + }, + isError: false, + }, + "mixed-pending-and-completed-with-errored-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageErrored} + ts.PolicyEvaluations = []*tfe.PolicyEvaluation{ + {ID: "pol-pass", ResultCount: &tfe.PolicyResultCount{Passed: 1}, Status: tfe.PolicyEvaluationPassed, PolicyKind: tfe.Sentinel}, + {ID: "pol-pending", ResultCount: &tfe.PolicyResultCount{}, Status: tfe.PolicyEvaluationPending, PolicyKind: tfe.Sentinel}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{ + "Sentinel Policy Evaluation", + "Overall Result: [green]PASSED", + "[dim]skipped", + "Pending policy evaluation skipped as task stage is errored.", + "Skipping 1 pending policy evaluation(s) because task stage is errored.", + }, + isError: false, + }, } for _, c := range cases { @@ -172,9 +503,12 @@ func TestCloud_runTaskStageWithSentinelPolicyEvaluation(t *testing.T) { cloud: b, } c.context.Poll(0, 0, func(i int) (bool, error) { - cont, _, _ := trs.Summarize(c.context, c.writer, c.taskStage()) - if cont { - return true, nil + cont, msg, _ := trs.Summarize(c.context, c.writer, c.taskStage()) + if cont != c.expectedContinue { + t.Fatalf("expected continue=%t, got %t", c.expectedContinue, cont) + } + if cont && msg != nil { + c.writer.OutputElapsed(*msg, len(*msg)) } output := c.writer.output.String() diff --git a/internal/cloud/backend_taskStage_taskResults.go b/internal/cloud/backend_taskStage_taskResults.go index 451026d256ee..4c80ec7dc63f 100644 --- a/internal/cloud/backend_taskStage_taskResults.go +++ b/internal/cloud/backend_taskStage_taskResults.go @@ -18,6 +18,24 @@ type taskResultSummary struct { passed int } +func isNonTerminalTaskResultStatus(status tfe.TaskResultStatus) bool { + return status == tfe.TaskRunning || status == tfe.TaskPending +} + +func partitionTaskResults(taskResults []*tfe.TaskResult) ([]*tfe.TaskResult, []*tfe.TaskResult) { + completed := make([]*tfe.TaskResult, 0, len(taskResults)) + pending := make([]*tfe.TaskResult, 0, len(taskResults)) + for _, taskResult := range taskResults { + if isNonTerminalTaskResultStatus(taskResult.Status) { + pending = append(pending, taskResult) + continue + } + completed = append(completed, taskResult) + } + + return completed, pending +} + type taskResultSummarizer struct { finished bool cloud *Cloud @@ -42,11 +60,28 @@ func (trs *taskResultSummarizer) Summarize(context *IntegrationContext, output I counts := summarizeTaskResults(ts.TaskResults) - if counts.pending != 0 { + if counts.pending != 0 && !isTerminalTaskStageStatus(ts.Status) { 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.pending != 0 { + completed, pending := partitionTaskResults(ts.TaskResults) + if len(completed) == 0 { + output.Output("Skipping task results.") + output.End() + return false, nil, nil + } + + completedCounts := summarizeTaskResults(completed) + trs.runTasksWithTaskResults(output, completed, completedCounts) + output.Output(fmt.Sprintf("Skipping %d pending task result(s) because task stage is %s.", len(pending), ts.Status)) + output.End() + trs.finished = true + return false, nil, nil + } + if counts.unreachable { output.Output("Skipping task results.") output.End() diff --git a/internal/cloud/backend_taskStage_taskResults_test.go b/internal/cloud/backend_taskStage_taskResults_test.go index a0cb74d889f2..0356e3ac4075 100644 --- a/internal/cloud/backend_taskStage_taskResults_test.go +++ b/internal/cloud/backend_taskStage_taskResults_test.go @@ -85,18 +85,19 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { integrationContext, writer := newMockIntegrationContext(b, t) cases := map[string]struct { - taskStage func() *tfe.TaskStage - context *IntegrationContext - writer *testIntegrationOutput - expectedOutputs []string - isError bool + taskStage func() *tfe.TaskStage + context *IntegrationContext + writer *testIntegrationOutput + expectedOutputs []string + expectedContinue bool + isError bool }{ "all-succeeded": { 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"}, + {ID: "1", TaskName: "Mandatory", Message: "A-OK", Status: tfe.TaskPassed, WorkspaceTaskEnforcementLevel: tfe.Mandatory}, + {ID: "2", TaskName: "Advisory", Message: "A-OK", Status: tfe.TaskPassed, WorkspaceTaskEnforcementLevel: tfe.Advisory}, } return ts }, @@ -109,8 +110,8 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { 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"}, + {ID: "1", TaskName: "Mandatory", Message: "500 Error", Status: tfe.TaskFailed, WorkspaceTaskEnforcementLevel: tfe.Mandatory}, + {ID: "2", TaskName: "Advisory", Message: "A-OK", Status: tfe.TaskPassed, WorkspaceTaskEnforcementLevel: tfe.Advisory}, } return ts }, @@ -123,8 +124,8 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { 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"}, + {ID: "1", TaskName: "Mandatory", Message: "A-OK", Status: tfe.TaskPassed, WorkspaceTaskEnforcementLevel: tfe.Mandatory}, + {ID: "2", TaskName: "Advisory", Message: "500 Error", Status: tfe.TaskFailed, WorkspaceTaskEnforcementLevel: tfe.Advisory}, } return ts }, @@ -137,8 +138,8 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { 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"}, + {ID: "1", TaskName: "Mandatory", Message: "", Status: tfe.TaskUnreachable, WorkspaceTaskEnforcementLevel: tfe.Mandatory}, + {ID: "2", TaskName: "Advisory", Message: "", Status: tfe.TaskUnreachable, WorkspaceTaskEnforcementLevel: tfe.Advisory}, } return ts }, @@ -147,6 +148,130 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { expectedOutputs: []string{"Skipping"}, isError: false, }, + "pending-with-running-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageRunning} + ts.TaskResults = []*tfe.TaskResult{ + {ID: "1", TaskName: "Mandatory", Message: "", Status: tfe.TaskPending, WorkspaceTaskEnforcementLevel: tfe.Mandatory}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"tasks still pending"}, + expectedContinue: true, + isError: false, + }, + "pending-with-passed-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStagePassed} + ts.TaskResults = []*tfe.TaskResult{ + {ID: "1", TaskName: "Mandatory", Message: "", Status: tfe.TaskPending, WorkspaceTaskEnforcementLevel: tfe.Mandatory}, + {ID: "2", TaskName: "Advisory", Message: "", Status: tfe.TaskRunning, WorkspaceTaskEnforcementLevel: tfe.Advisory}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping task results."}, + isError: false, + }, + "pending-with-failed-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageFailed} + ts.TaskResults = []*tfe.TaskResult{ + {ID: "1", TaskName: "Mandatory", Message: "", Status: tfe.TaskPending, WorkspaceTaskEnforcementLevel: tfe.Mandatory}, + {ID: "2", TaskName: "Advisory", Message: "", Status: tfe.TaskRunning, WorkspaceTaskEnforcementLevel: tfe.Advisory}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping task results."}, + isError: false, + }, + "pending-with-canceled-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageCanceled} + ts.TaskResults = []*tfe.TaskResult{ + {ID: "1", TaskName: "Mandatory", Message: "", Status: tfe.TaskPending, WorkspaceTaskEnforcementLevel: tfe.Mandatory}, + {ID: "2", TaskName: "Advisory", Message: "", Status: tfe.TaskRunning, WorkspaceTaskEnforcementLevel: tfe.Advisory}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping task results."}, + isError: false, + }, + "pending-with-errored-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageErrored} + ts.TaskResults = []*tfe.TaskResult{ + {ID: "1", TaskName: "Mandatory", Message: "", Status: tfe.TaskPending, WorkspaceTaskEnforcementLevel: tfe.Mandatory}, + {ID: "2", TaskName: "Advisory", Message: "", Status: tfe.TaskRunning, WorkspaceTaskEnforcementLevel: tfe.Advisory}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{"Skipping task results."}, + isError: false, + }, + "mixed-pending-and-completed-with-failed-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageFailed} + ts.TaskResults = []*tfe.TaskResult{ + {ID: "1", TaskName: "Mandatory", Message: "A-OK", Status: tfe.TaskPassed, WorkspaceTaskEnforcementLevel: tfe.Mandatory}, + {ID: "2", TaskName: "Advisory", Message: "Still running", Status: tfe.TaskPending, WorkspaceTaskEnforcementLevel: tfe.Advisory}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{ + "All tasks completed! 1 passed, 0 failed", + "Overall Result: Passed", + "Skipping 1 pending task result(s) because task stage is failed.", + }, + isError: false, + }, + "mixed-pending-and-completed-with-canceled-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageCanceled} + ts.TaskResults = []*tfe.TaskResult{ + {ID: "1", TaskName: "Mandatory", Message: "A-OK", Status: tfe.TaskPassed, WorkspaceTaskEnforcementLevel: tfe.Mandatory}, + {ID: "2", TaskName: "Advisory", Message: "", Status: tfe.TaskPending, WorkspaceTaskEnforcementLevel: tfe.Advisory}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{ + "All tasks completed! 1 passed, 0 failed", + "Overall Result: Passed", + "Skipping 1 pending task result(s) because task stage is canceled.", + }, + isError: false, + }, + "mixed-pending-and-completed-with-errored-task-stage": { + taskStage: func() *tfe.TaskStage { + ts := &tfe.TaskStage{Status: tfe.TaskStageErrored} + ts.TaskResults = []*tfe.TaskResult{ + {ID: "1", TaskName: "Mandatory", Message: "A-OK", Status: tfe.TaskPassed, WorkspaceTaskEnforcementLevel: tfe.Mandatory}, + {ID: "2", TaskName: "Advisory", Message: "", Status: tfe.TaskPending, WorkspaceTaskEnforcementLevel: tfe.Advisory}, + } + return ts + }, + writer: writer, + context: integrationContext, + expectedOutputs: []string{ + "All tasks completed! 1 passed, 0 failed", + "Overall Result: Passed", + "Skipping 1 pending task result(s) because task stage is errored.", + }, + isError: false, + }, } for _, c := range cases { @@ -155,9 +280,12 @@ func TestCloud_runTasksWithTaskResults(t *testing.T) { cloud: b, } c.context.Poll(0, 0, func(i int) (bool, error) { - cont, _, _ := trs.Summarize(c.context, c.writer, c.taskStage()) - if cont { - return true, nil + cont, msg, _ := trs.Summarize(c.context, c.writer, c.taskStage()) + if cont != c.expectedContinue { + t.Fatalf("expected continue=%t, got %t", c.expectedContinue, cont) + } + if cont && msg != nil { + c.writer.OutputElapsed(*msg, len(*msg)) } output := c.writer.output.String() diff --git a/internal/cloud/backend_taskStages.go b/internal/cloud/backend_taskStages.go index b9f4f23dfacd..fd9aae806c32 100644 --- a/internal/cloud/backend_taskStages.go +++ b/internal/cloud/backend_taskStages.go @@ -36,6 +36,15 @@ type taskStageSummarizer interface { Summarize(*IntegrationContext, IntegrationOutputWriter, *tfe.TaskStage) (bool, *string, error) } +func isTerminalTaskStageStatus(status tfe.TaskStageStatus) bool { + switch status { + case tfe.TaskStagePassed, tfe.TaskStageFailed, tfe.TaskStageCanceled, tfe.TaskStageErrored: + return true + default: + return false + } +} + 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{