diff --git a/internal/backend/remote/testing.go b/internal/backend/remote/testing.go index 7dbb9e9b2c..ab87fdfc51 100644 --- a/internal/backend/remote/testing.go +++ b/internal/backend/remote/testing.go @@ -144,6 +144,7 @@ func testBackend(t *testing.T, obj cty.Value) (*Remote, func()) { b.client.Plans = mc.Plans b.client.PolicyChecks = mc.PolicyChecks b.client.Runs = mc.Runs + b.client.RunEvents = mc.RunEvents b.client.StateVersions = mc.StateVersions b.client.Variables = mc.Variables b.client.Workspaces = mc.Workspaces diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go index b2d4651388..3b5dbbd662 100644 --- a/internal/cloud/backend_plan.go +++ b/internal/cloud/backend_plan.go @@ -294,6 +294,11 @@ in order to capture the filesystem context the remote workspace expects: runHeader, b.hostname, b.organization, op.Workspace, r.ID)) + "\n")) } + // Render any warnings that were raised during run creation + if err := b.renderRunWarnings(stopCtx, b.client, r.ID); err != nil { + return r, err + } + // Retrieve the run to get task stages. // Task Stages are calculated upfront so we only need to call this once for the run. taskStages, err := b.runTaskStages(stopCtx, b.client, r.ID) diff --git a/internal/cloud/backend_run_warning.go b/internal/cloud/backend_run_warning.go new file mode 100644 index 0000000000..db82f2a717 --- /dev/null +++ b/internal/cloud/backend_run_warning.go @@ -0,0 +1,46 @@ +package cloud + +import ( + "context" + "fmt" + "strings" + + tfe "github.com/hashicorp/go-tfe" +) + +const ( + changedPolicyEnforcementAction = "changed_policy_enforcements" + changedTaskEnforcementAction = "changed_task_enforcements" + ignoredPolicySetAction = "ignored_policy_sets" +) + +func (b *Cloud) renderRunWarnings(ctx context.Context, client *tfe.Client, runId string) error { + if b.CLI == nil { + return nil + } + + result, err := client.RunEvents.List(ctx, runId, nil) + if err != nil { + return err + } + if result == nil { + return nil + } + + // We don't have to worry about paging as the API doesn't support it yet + for _, re := range result.Items { + switch re.Action { + case changedPolicyEnforcementAction, changedTaskEnforcementAction, ignoredPolicySetAction: + if re.Description != "" { + b.CLI.Warn(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf( + runWarningHeader, re.Description)) + "\n")) + } + } + } + + return nil +} + +const runWarningHeader = ` +[reset][yellow]Warning:[reset] %s +` diff --git a/internal/cloud/backend_run_warning_test.go b/internal/cloud/backend_run_warning_test.go new file mode 100644 index 0000000000..ca8754ff0d --- /dev/null +++ b/internal/cloud/backend_run_warning_test.go @@ -0,0 +1,153 @@ +package cloud + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/hashicorp/go-tfe" + tfemocks "github.com/hashicorp/go-tfe/mocks" + "github.com/mitchellh/cli" +) + +func MockAllRunEvents(t *testing.T, client *tfe.Client) (fullRunID string, emptyRunID string) { + ctrl := gomock.NewController(t) + + fullRunID = "run-full" + emptyRunID = "run-empty" + + mockRunEventsAPI := tfemocks.NewMockRunEvents(ctrl) + + emptyList := tfe.RunEventList{ + Items: []*tfe.RunEvent{}, + } + fullList := tfe.RunEventList{ + Items: []*tfe.RunEvent{ + { + Action: "created", + CreatedAt: time.Now(), + Description: "", + }, + { + Action: "changed_task_enforcements", + CreatedAt: time.Now(), + Description: "The enforcement level for task 'MockTask' was changed to 'advisory' because the run task limit was exceeded.", + }, + { + Action: "changed_policy_enforcements", + CreatedAt: time.Now(), + Description: "The enforcement level for policy 'MockPolicy' was changed to 'advisory' because the policy limit was exceeded.", + }, + { + Action: "ignored_policy_sets", + CreatedAt: time.Now(), + Description: "The policy set 'MockPolicySet' was ignored because the versioned policy set limit was exceeded.", + }, + { + Action: "queued", + CreatedAt: time.Now(), + Description: "", + }, + }, + } + // Mock Full Request + mockRunEventsAPI. + EXPECT(). + List(gomock.Any(), fullRunID, gomock.Any()). + Return(&fullList, nil). + AnyTimes() + + // Mock Full Request + mockRunEventsAPI. + EXPECT(). + List(gomock.Any(), emptyRunID, gomock.Any()). + Return(&emptyList, nil). + AnyTimes() + + // Mock a bad Read response + mockRunEventsAPI. + EXPECT(). + List(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, tfe.ErrInvalidRunID). + AnyTimes() + + // Wire up the mock interfaces + client.RunEvents = mockRunEventsAPI + return +} + +func TestRunEventWarningsAll(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + config := &tfe.Config{ + Token: "not-a-token", + } + client, _ := tfe.NewClient(config) + fullRunID, _ := MockAllRunEvents(t, client) + + ctx := context.TODO() + + err := b.renderRunWarnings(ctx, client, fullRunID) + if err != nil { + t.Fatalf("Expected to not error but received %s", err) + } + + output := b.CLI.(*cli.MockUi).ErrorWriter.String() + testString := "The enforcement level for task 'MockTask'" + if !strings.Contains(output, testString) { + t.Fatalf("Expected %q to contain %q but it did not", output, testString) + } + testString = "The enforcement level for policy 'MockPolicy'" + if !strings.Contains(output, testString) { + t.Fatalf("Expected %q to contain %q but it did not", output, testString) + } + testString = "The policy set 'MockPolicySet'" + if !strings.Contains(output, testString) { + t.Fatalf("Expected %q to contain %q but it did not", output, testString) + } +} + +func TestRunEventWarningsEmpty(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + config := &tfe.Config{ + Token: "not-a-token", + } + client, _ := tfe.NewClient(config) + _, emptyRunID := MockAllRunEvents(t, client) + + ctx := context.TODO() + + err := b.renderRunWarnings(ctx, client, emptyRunID) + if err != nil { + t.Fatalf("Expected to not error but received %s", err) + } + + output := b.CLI.(*cli.MockUi).ErrorWriter.String() + if output != "" { + t.Fatalf("Expected %q to be empty but it was not", output) + } +} + +func TestRunEventWarningsWithError(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + config := &tfe.Config{ + Token: "not-a-token", + } + client, _ := tfe.NewClient(config) + MockAllRunEvents(t, client) + + ctx := context.TODO() + + err := b.renderRunWarnings(ctx, client, "bad run id") + + if err == nil { + t.Error("Expected to error but did not") + } +} diff --git a/internal/cloud/testing.go b/internal/cloud/testing.go index 4c5302ce4a..73ec962b32 100644 --- a/internal/cloud/testing.go +++ b/internal/cloud/testing.go @@ -241,6 +241,7 @@ func testBackend(t *testing.T, obj cty.Value, handlers map[string]func(http.Resp b.client.PolicySetOutcomes = mc.PolicySetOutcomes b.client.PolicyChecks = mc.PolicyChecks b.client.Runs = mc.Runs + b.client.RunEvents = mc.RunEvents b.client.StateVersions = mc.StateVersions b.client.StateVersionOutputs = mc.StateVersionOutputs b.client.Variables = mc.Variables @@ -312,6 +313,7 @@ func testUnconfiguredBackend(t *testing.T) (*Cloud, func()) { b.client.PolicySetOutcomes = mc.PolicySetOutcomes b.client.PolicyChecks = mc.PolicyChecks b.client.Runs = mc.Runs + b.client.RunEvents = mc.RunEvents b.client.StateVersions = mc.StateVersions b.client.Variables = mc.Variables b.client.Workspaces = mc.Workspaces diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index 090343fd0b..153dfd4055 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -34,6 +34,7 @@ type MockClient struct { RedactedPlans *MockRedactedPlans PolicyChecks *MockPolicyChecks Runs *MockRuns + RunEvents *MockRunEvents StateVersions *MockStateVersions StateVersionOutputs *MockStateVersionOutputs Variables *MockVariables @@ -51,6 +52,7 @@ func NewMockClient() *MockClient { c.PolicySetOutcomes = newMockPolicySetOutcomes(c) c.PolicyChecks = newMockPolicyChecks(c) c.Runs = newMockRuns(c) + c.RunEvents = newMockRunEvents(c) c.StateVersions = newMockStateVersions(c) c.StateVersionOutputs = newMockStateVersionOutputs(c) c.Variables = newMockVariables(c) @@ -1168,6 +1170,31 @@ func (m *MockRuns) Discard(ctx context.Context, runID string, options tfe.RunDis return nil } +type MockRunEvents struct{} + +func newMockRunEvents(_ *MockClient) *MockRunEvents { + return &MockRunEvents{} +} + +// List all the runs events of the given run. +func (m *MockRunEvents) List(ctx context.Context, runID string, options *tfe.RunEventListOptions) (*tfe.RunEventList, error) { + return &tfe.RunEventList{ + Items: []*tfe.RunEvent{}, + }, nil +} + +func (m *MockRunEvents) Read(ctx context.Context, runEventID string) (*tfe.RunEvent, error) { + return m.ReadWithOptions(ctx, runEventID, nil) +} + +func (m *MockRunEvents) ReadWithOptions(ctx context.Context, runEventID string, options *tfe.RunEventReadOptions) (*tfe.RunEvent, error) { + return &tfe.RunEvent{ + ID: GenerateID("re-"), + Action: "created", + CreatedAt: time.Now(), + }, nil +} + type MockStateVersions struct { client *MockClient states map[string][]byte