diff --git a/backend/remote/backend.go b/backend/remote/backend.go index 26c27159b..7c2457d73 100644 --- a/backend/remote/backend.go +++ b/backend/remote/backend.go @@ -461,6 +461,9 @@ func (b *Remote) cancel(cancelCtx context.Context, r *tfe.Run) error { if err != nil { return generalError("error cancelling run", err) } + if b.CLI != nil { + b.CLI.Output(b.Colorize().Color(strings.TrimSpace(cancelPendingOperation))) + } } return nil @@ -511,6 +514,10 @@ connection problem, in which case you could retry the command. If the issue persists please open a support ticket to get help resolving the problem. ` +const cancelPendingOperation = `[reset][red] +Pending remote operation cancelled.[reset] +` + var schemaDescriptions = map[string]string{ "hostname": "The remote backend hostname to connect to (defaults to app.terraform.io).", "organization": "The name of the organization containing the targeted workspace(s).", diff --git a/backend/remote/backend_apply_test.go b/backend/remote/backend_apply_test.go index 245666307..7e1684aef 100644 --- a/backend/remote/backend_apply_test.go +++ b/backend/remote/backend_apply_test.go @@ -91,7 +91,7 @@ func TestRemote_applyWithVCS(t *testing.T) { <-run.Done() if run.Err == nil { - t.Fatalf("expected a apply error, got: %v", run.Err) + t.Fatalf("expected an apply error, got: %v", run.Err) } if !strings.Contains(run.Err.Error(), "not allowed for workspaces with a VCS") { t.Fatalf("expected a VCS error, got: %v", run.Err) @@ -116,7 +116,7 @@ func TestRemote_applyWithPlan(t *testing.T) { <-run.Done() if run.Err == nil { - t.Fatalf("expected a apply error, got: %v", run.Err) + t.Fatalf("expected an apply error, got: %v", run.Err) } if !strings.Contains(run.Err.Error(), "saved plan is currently not supported") { t.Fatalf("expected a saved plan error, got: %v", run.Err) @@ -141,7 +141,7 @@ func TestRemote_applyWithTarget(t *testing.T) { <-run.Done() if run.Err == nil { - t.Fatalf("expected a apply error, got: %v", run.Err) + t.Fatalf("expected an apply error, got: %v", run.Err) } if !strings.Contains(run.Err.Error(), "targeting is currently not supported") { t.Fatalf("expected a targeting error, got: %v", run.Err) @@ -162,7 +162,7 @@ func TestRemote_applyNoConfig(t *testing.T) { <-run.Done() if run.Err == nil { - t.Fatalf("expected a apply error, got: %v", run.Err) + t.Fatalf("expected an apply error, got: %v", run.Err) } if !strings.Contains(run.Err.Error(), "configuration files found") { t.Fatalf("expected configuration files error, got: %v", run.Err) @@ -218,10 +218,10 @@ func TestRemote_applyNoApprove(t *testing.T) { <-run.Done() if run.Err == nil { - t.Fatalf("expected a apply error, got: %v", run.Err) + t.Fatalf("expected an apply error, got: %v", run.Err) } if !strings.Contains(run.Err.Error(), "Apply discarded") { - t.Fatalf("expected a apply discarded error, got: %v", run.Err) + t.Fatalf("expected an apply discarded error, got: %v", run.Err) } if len(input.answers) > 0 { t.Fatalf("expected no unused answers, got: %v", input.answers) @@ -406,3 +406,175 @@ func TestRemote_applyDestroyNoConfig(t *testing.T) { t.Fatalf("expected no unused answers, got: %v", input.answers) } } + +func TestRemote_applyPolicyPass(t *testing.T) { + b := testBackendDefault(t) + + mod, modCleanup := module.TestTree(t, "./test-fixtures/apply-policy-passed") + defer modCleanup() + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op := testOperationApply() + op.Module = mod + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Err != nil { + t.Fatalf("error running operation: %v", run.Err) + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: true") { + t.Fatalf("missing Sentinel result in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("missing apply summery in output: %s", output) + } +} + +func TestRemote_applyPolicyHardFail(t *testing.T) { + b := testBackendDefault(t) + + mod, modCleanup := module.TestTree(t, "./test-fixtures/apply-policy-hard-failed") + defer modCleanup() + + input := testInput(t, map[string]string{ + "approve": "yes", + }) + + op := testOperationApply() + op.Module = mod + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Err == nil { + t.Fatalf("expected an apply error, got: %v", run.Err) + } + if !strings.Contains(run.Err.Error(), "hard failed") { + t.Fatalf("expected a policy check error, got: %v", run.Err) + } + if len(input.answers) != 1 { + t.Fatalf("expected an unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("missing Sentinel result in output: %s", output) + } + if strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("unexpected apply summery in output: %s", output) + } +} + +func TestRemote_applyPolicySoftFail(t *testing.T) { + b := testBackendDefault(t) + + mod, modCleanup := module.TestTree(t, "./test-fixtures/apply-policy-soft-failed") + defer modCleanup() + + input := testInput(t, map[string]string{ + "override": "override", + "approve": "yes", + }) + + op := testOperationApply() + op.Module = mod + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Err != nil { + t.Fatalf("error running operation: %v", run.Err) + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("missing Sentinel result in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("missing apply summery in output: %s", output) + } +} + +func TestRemote_applyPolicySoftFailAutoApprove(t *testing.T) { + b := testBackendDefault(t) + + mod, modCleanup := module.TestTree(t, "./test-fixtures/apply-policy-soft-failed") + defer modCleanup() + + input := testInput(t, map[string]string{ + "override": "override", + }) + + op := testOperationApply() + op.AutoApprove = true + op.Module = mod + op.UIIn = input + op.UIOut = b.CLI + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Err != nil { + t.Fatalf("error running operation: %v", run.Err) + } + + if len(input.answers) > 0 { + t.Fatalf("expected no unused answers, got: %v", input.answers) + } + + output := b.CLI.(*cli.MockUi).OutputWriter.String() + if !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { + t.Fatalf("missing plan summery in output: %s", output) + } + if !strings.Contains(output, "Sentinel Result: false") { + t.Fatalf("missing Sentinel result in output: %s", output) + } + if !strings.Contains(output, "1 added, 0 changed, 0 destroyed") { + t.Fatalf("missing apply summery in output: %s", output) + } +} diff --git a/backend/remote/backend_mock.go b/backend/remote/backend_mock.go index dc3aa3098..e391ff5f6 100644 --- a/backend/remote/backend_mock.go +++ b/backend/remote/backend_mock.go @@ -23,6 +23,7 @@ type mockClient struct { ConfigurationVersions *mockConfigurationVersions Organizations *mockOrganizations Plans *mockPlans + PolicyChecks *mockPolicyChecks Runs *mockRuns StateVersions *mockStateVersions Workspaces *mockWorkspaces @@ -34,6 +35,7 @@ func newMockClient() *mockClient { c.ConfigurationVersions = newMockConfigurationVersions(c) c.Organizations = newMockOrganizations(c) c.Plans = newMockPlans(c) + c.PolicyChecks = newMockPolicyChecks(c) c.Runs = newMockRuns(c) c.StateVersions = newMockStateVersions(c) c.Workspaces = newMockWorkspaces(c) @@ -55,8 +57,7 @@ func newMockApplies(client *mockClient) *mockApplies { } // create is a helper function to create a mock apply that uses the configured -// working directory to find the logfile. This enables us to test if we are -// using the +// working directory to find the logfile. func (m *mockApplies) create(cvID, workspaceID string) (*tfe.Apply, error) { c, ok := m.client.ConfigurationVersions.configVersions[cvID] if !ok { @@ -320,8 +321,7 @@ func newMockPlans(client *mockClient) *mockPlans { } // create is a helper function to create a mock plan that uses the configured -// working directory to find the logfile. This enables us to test if we are -// using the +// working directory to find the logfile. func (m *mockPlans) create(cvID, workspaceID string) (*tfe.Plan, error) { id := generateID("plan-") url := fmt.Sprintf("https://app.terraform.io/_archivist/%s", id) @@ -396,6 +396,133 @@ func (m *mockPlans) Logs(ctx context.Context, planID string) (io.Reader, error) }, nil } +type mockPolicyChecks struct { + client *mockClient + checks map[string]*tfe.PolicyCheck + logs map[string]string +} + +func newMockPolicyChecks(client *mockClient) *mockPolicyChecks { + return &mockPolicyChecks{ + client: client, + checks: make(map[string]*tfe.PolicyCheck), + logs: make(map[string]string), + } +} + +// create is a helper function to create a mock policy check that uses the +// configured working directory to find the logfile. +func (m *mockPolicyChecks) create(cvID, workspaceID string) (*tfe.PolicyCheck, error) { + id := generateID("pc-") + + pc := &tfe.PolicyCheck{ + ID: id, + Actions: &tfe.PolicyActions{}, + Permissions: &tfe.PolicyPermissions{}, + Scope: tfe.PolicyScopeOrganization, + Status: tfe.PolicyPending, + } + + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + logfile := filepath.Join( + m.client.ConfigurationVersions.uploadPaths[cvID], + w.WorkingDirectory, + "policy.log", + ) + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return nil, nil + } + + m.logs[pc.ID] = logfile + m.checks[pc.ID] = pc + + return pc, nil +} + +func (m *mockPolicyChecks) List(ctx context.Context, runID string, options tfe.PolicyCheckListOptions) (*tfe.PolicyCheckList, error) { + _, ok := m.client.Runs.runs[runID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + pcl := &tfe.PolicyCheckList{} + for _, pc := range m.checks { + pcl.Items = append(pcl.Items, pc) + } + + pcl.Pagination = &tfe.Pagination{ + CurrentPage: 1, + NextPage: 1, + PreviousPage: 1, + TotalPages: 1, + TotalCount: len(pcl.Items), + } + + return pcl, nil +} + +func (m *mockPolicyChecks) Read(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { + pc, ok := m.checks[policyCheckID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + return pc, nil +} + +func (m *mockPolicyChecks) Override(ctx context.Context, policyCheckID string) (*tfe.PolicyCheck, error) { + pc, ok := m.checks[policyCheckID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + pc.Status = tfe.PolicyOverridden + return pc, nil +} + +func (m *mockPolicyChecks) Logs(ctx context.Context, policyCheckID string) (io.Reader, error) { + pc, ok := m.checks[policyCheckID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + logfile, ok := m.logs[pc.ID] + if !ok { + return nil, tfe.ErrResourceNotFound + } + + if _, err := os.Stat(logfile); os.IsNotExist(err) { + return bytes.NewBufferString("logfile does not exist"), nil + } + + logs, err := ioutil.ReadFile(logfile) + if err != nil { + return nil, err + } + + switch { + case bytes.Contains(logs, []byte("Sentinel Result: true")): + pc.Status = tfe.PolicyPasses + case bytes.Contains(logs, []byte("Sentinel Result: false")): + switch { + case bytes.Contains(logs, []byte("hard-mandatory")): + pc.Status = tfe.PolicyHardFailed + case bytes.Contains(logs, []byte("soft-mandatory")): + pc.Actions.IsOverridable = true + pc.Permissions.CanOverride = true + pc.Status = tfe.PolicySoftFailed + } + default: + // As this is an unexpected state, we say the policy errored. + pc.Status = tfe.PolicyErrored + } + + return bytes.NewBuffer(logs), nil +} + type mockRuns struct { client *mockClient runs map[string]*tfe.Run @@ -443,6 +570,11 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t return nil, err } + pc, err := m.client.PolicyChecks.create(options.ConfigurationVersion.ID, options.Workspace.ID) + if err != nil { + return nil, err + } + r := &tfe.Run{ ID: generateID("run-"), Actions: &tfe.RunActions{}, @@ -453,6 +585,10 @@ func (m *mockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t Status: tfe.RunPending, } + if pc != nil { + r.PolicyChecks = []*tfe.PolicyCheck{pc} + } + if options.IsDestroy != nil { r.IsDestroy = *options.IsDestroy } diff --git a/backend/remote/backend_plan.go b/backend/remote/backend_plan.go index 48df0f47d..4ed810213 100644 --- a/backend/remote/backend_plan.go +++ b/backend/remote/backend_plan.go @@ -144,7 +144,12 @@ func (b *Remote) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, if b.CLI != nil { b.CLI.Output(b.Colorize().Color(strings.TrimSpace(lockTimeoutErr))) } - syscall.Kill(syscall.Getpid(), syscall.SIGINT) + p, err := os.FindProcess(os.Getpid()) + if err != nil { + log.Printf("[ERROR] error searching process ID: %v", err) + return + } + p.Signal(syscall.SIGINT) } } }() diff --git a/backend/remote/test-fixtures/apply-destroy/apply.log b/backend/remote/test-fixtures/apply-destroy/apply.log index 6f9fb42e4..34adfcd6b 100644 --- a/backend/remote/test-fixtures/apply-destroy/apply.log +++ b/backend/remote/test-fixtures/apply-destroy/apply.log @@ -1,11 +1,3 @@ ------------------------------------------------------------------------- - -Do you really want to destroy all resources in workspace "my-app-dev"? - Terraform will destroy all your managed infrastructure, as shown above. - There is no undo. Only 'yes' will be accepted to confirm. - - Enter a value: yes - null_resource.hello: Destroying... (ID: 8657651096157629581) null_resource.hello: Destruction complete after 0s diff --git a/backend/remote/test-fixtures/apply-policy-hard-failed/main.tf b/backend/remote/test-fixtures/apply-policy-hard-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-hard-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/apply-policy-hard-failed/plan.log b/backend/remote/test-fixtures/apply-policy-hard-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-hard-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/apply-policy-hard-failed/policy.log b/backend/remote/test-fixtures/apply-policy-hard-failed/policy.log new file mode 100644 index 000000000..5d6e6935b --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-hard-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (hard-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/backend/remote/test-fixtures/apply-policy-passed/apply.log b/backend/remote/test-fixtures/apply-policy-passed/apply.log new file mode 100644 index 000000000..89c0dbc42 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-passed/apply.log @@ -0,0 +1,4 @@ +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/backend/remote/test-fixtures/apply-policy-passed/main.tf b/backend/remote/test-fixtures/apply-policy-passed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-passed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/apply-policy-passed/plan.log b/backend/remote/test-fixtures/apply-policy-passed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-passed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/apply/policy-passed.log b/backend/remote/test-fixtures/apply-policy-passed/policy.log similarity index 72% rename from backend/remote/test-fixtures/apply/policy-passed.log rename to backend/remote/test-fixtures/apply-policy-passed/policy.log index c5c112f58..b0cb1e598 100644 --- a/backend/remote/test-fixtures/apply/policy-passed.log +++ b/backend/remote/test-fixtures/apply-policy-passed/policy.log @@ -1,7 +1,3 @@ ------------------------------------------------------------------------- - -Organization policy check: - Sentinel Result: true This result means that Sentinel policies returned true and the protected diff --git a/backend/remote/test-fixtures/apply-policy-soft-failed/apply.log b/backend/remote/test-fixtures/apply-policy-soft-failed/apply.log new file mode 100644 index 000000000..89c0dbc42 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-soft-failed/apply.log @@ -0,0 +1,4 @@ +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. diff --git a/backend/remote/test-fixtures/apply-policy-soft-failed/main.tf b/backend/remote/test-fixtures/apply-policy-soft-failed/main.tf new file mode 100644 index 000000000..3911a2a9b --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-soft-failed/main.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/backend/remote/test-fixtures/apply-policy-soft-failed/plan.log b/backend/remote/test-fixtures/apply-policy-soft-failed/plan.log new file mode 100644 index 000000000..5849e5759 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-soft-failed/plan.log @@ -0,0 +1,21 @@ +Terraform v0.11.7 + +Configuring remote state backend... +Initializing Terraform configuration... +Refreshing Terraform state in-memory prior to plan... +The refreshed state will be used to calculate this plan, but will not be +persisted to local or remote state storage. + +------------------------------------------------------------------------ + +An execution plan has been generated and is shown below. +Resource actions are indicated with the following symbols: + + create + +Terraform will perform the following actions: + + + null_resource.foo + id: + + +Plan: 1 to add, 0 to change, 0 to destroy. diff --git a/backend/remote/test-fixtures/apply-policy-soft-failed/policy.log b/backend/remote/test-fixtures/apply-policy-soft-failed/policy.log new file mode 100644 index 000000000..3e4ebedf6 --- /dev/null +++ b/backend/remote/test-fixtures/apply-policy-soft-failed/policy.log @@ -0,0 +1,12 @@ +Sentinel Result: false + +Sentinel evaluated to false because one or more Sentinel policies evaluated +to false. This false was not due to an undefined value or runtime error. + +1 policies evaluated. + +## Policy 1: Passthrough.sentinel (soft-mandatory) + +Result: false + +FALSE - Passthrough.sentinel:1:1 - Rule "main" diff --git a/backend/remote/test-fixtures/apply/apply.log b/backend/remote/test-fixtures/apply/apply.log index 0665a7a30..89c0dbc42 100644 --- a/backend/remote/test-fixtures/apply/apply.log +++ b/backend/remote/test-fixtures/apply/apply.log @@ -1,11 +1,3 @@ ------------------------------------------------------------------------- - -Do you want to perform these actions in workspace "my-workspace-name"? - Terraform will perform the actions described above. - Only 'yes' will be accepted to approve. - - Enter a value: yes - null_resource.hello: Creating... null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) diff --git a/backend/remote/testing.go b/backend/remote/testing.go index 999fc6615..a21905216 100644 --- a/backend/remote/testing.go +++ b/backend/remote/testing.go @@ -82,6 +82,7 @@ func testBackend(t *testing.T, c map[string]interface{}) *Remote { b.client.ConfigurationVersions = mc.ConfigurationVersions b.client.Organizations = mc.Organizations b.client.Plans = mc.Plans + b.client.PolicyChecks = mc.PolicyChecks b.client.Runs = mc.Runs b.client.StateVersions = mc.StateVersions b.client.Workspaces = mc.Workspaces