From a387af6c61263f397739233dc85c08dea70422d6 Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Wed, 22 Sep 2021 17:53:33 -0400 Subject: [PATCH] Add auto-approve logic, e2e tests --- go.mod | 4 +- go.sum | 8 +- internal/cloud/backend_apply.go | 78 ++--- internal/cloud/backend_apply_test.go | 93 +++++- internal/cloud/backend_plan.go | 1 + internal/cloud/e2e/apply_auto_approve_test.go | 280 ++++++++++++++++++ internal/cloud/e2e/helper_test.go | 50 ++++ internal/cloud/e2e/main_test.go | 198 +++++++++++++ internal/cloud/tfe_client_mock.go | 2 +- 9 files changed, 646 insertions(+), 68 deletions(-) create mode 100644 internal/cloud/e2e/apply_auto_approve_test.go create mode 100644 internal/cloud/e2e/helper_test.go create mode 100644 internal/cloud/e2e/main_test.go diff --git a/go.mod b/go.mod index 9074b31d8..6dfa7bdcc 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/dylanmei/winrmtest v0.0.0-20190225150635-99b7fe2fddf1 github.com/go-test/deep v1.0.3 - github.com/golang/mock v1.5.0 + github.com/golang/mock v1.6.0 github.com/golang/protobuf v1.5.2 github.com/google/go-cmp v0.5.5 github.com/google/uuid v1.2.0 @@ -40,7 +40,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.4.3 github.com/hashicorp/go-retryablehttp v0.5.2 - github.com/hashicorp/go-tfe v0.18.1-0.20210902165242-26689edbfddf + github.com/hashicorp/go-tfe v0.19.1-0.20210922134841-a2c1784e9c00 github.com/hashicorp/go-uuid v1.0.1 github.com/hashicorp/go-version v1.2.1 github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f diff --git a/go.sum b/go.sum index 4a2064597..aaba3e315 100644 --- a/go.sum +++ b/go.sum @@ -241,8 +241,9 @@ github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFU github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -375,8 +376,8 @@ github.com/hashicorp/go-slug v0.7.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu41 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 v0.18.1-0.20210902165242-26689edbfddf h1:Tn5cI9kacNyO40ztxmwfAaHrOGd7dELLSAueV2Xfv38= -github.com/hashicorp/go-tfe v0.18.1-0.20210902165242-26689edbfddf/go.mod h1:7lChm1Mjsh0ofrUNkP8MHljUFrnKNZNTw36S6qSbJZU= +github.com/hashicorp/go-tfe v0.19.1-0.20210922134841-a2c1784e9c00 h1:51ARk47jO4piKzhhbwk6u67ErvSuBj4cu2f2VS9HkgI= +github.com/hashicorp/go-tfe v0.19.1-0.20210922134841-a2c1784e9c00/go.mod h1:U5Iy307L+MazGg0uF8annDtaxAbPp4ElFZ9uPMrjw/I= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -933,6 +934,7 @@ golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= diff --git a/internal/cloud/backend_apply.go b/internal/cloud/backend_apply.go index 74d167673..02f006c2c 100644 --- a/internal/cloud/backend_apply.go +++ b/internal/cloud/backend_apply.go @@ -181,72 +181,40 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio } // Return if the run cannot be confirmed. - if !w.AutoApply && !r.Actions.IsConfirmable { + if !op.AutoApprove && !r.Actions.IsConfirmable { return r, nil } - // Since we already checked the permissions before creating the run - // this should never happen. But it doesn't hurt to keep this in as - // a safeguard for any unexpected situations. - if !w.AutoApply && !r.Permissions.CanApply { - // Make sure we discard the run if possible. - if r.Actions.IsDiscardable { - err = b.client.Runs.Discard(stopCtx, r.ID, tfe.RunDiscardOptions{}) - if err != nil { - switch op.PlanMode { - case plans.DestroyMode: - return r, generalError("Failed to discard destroy", err) - default: - return r, generalError("Failed to discard apply", err) - } - } - } - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Insufficient rights to approve the pending changes", - fmt.Sprintf("There are pending changes, but the provided credentials have "+ - "insufficient rights to approve them. The run will be discarded to prevent "+ - "it from blocking the queue waiting for external approval. To queue a run "+ - "that can be approved by someone else, please use the 'Queue Plan' button in "+ - "the web UI:\nhttps://%s/app/%s/%s/runs", b.hostname, b.organization, op.Workspace), - )) - return r, diags.Err() - } - mustConfirm := (op.UIIn != nil && op.UIOut != nil) && !op.AutoApprove - if !w.AutoApply { - if mustConfirm { - opts := &terraform.InputOpts{Id: "approve"} + if mustConfirm { + opts := &terraform.InputOpts{Id: "approve"} - if op.PlanMode == plans.DestroyMode { - opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?" - opts.Description = "Terraform will destroy all your managed infrastructure, as shown above.\n" + - "There is no undo. Only 'yes' will be accepted to confirm." - } else { - opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?" - opts.Description = "Terraform will perform the actions described above.\n" + - "Only 'yes' will be accepted to approve." - } - - err = b.confirm(stopCtx, op, opts, r, "yes") - if err != nil && err != errRunApproved { - return r, err - } + if op.PlanMode == plans.DestroyMode { + opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?" + opts.Description = "Terraform will destroy all your managed infrastructure, as shown above.\n" + + "There is no undo. Only 'yes' will be accepted to confirm." + } else { + opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?" + opts.Description = "Terraform will perform the actions described above.\n" + + "Only 'yes' will be accepted to approve." } - if err != errRunApproved { - if err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}); err != nil { - return r, generalError("Failed to approve the apply command", err) - } + err = b.confirm(stopCtx, op, opts, r, "yes") + if err != nil && err != errRunApproved { + return r, err + } + } else { + // If we don't need to ask for confirmation, insert a blank + // line to separate the ouputs. + if b.CLI != nil { + b.CLI.Output("") } } - // If we don't need to ask for confirmation, insert a blank - // line to separate the ouputs. - if w.AutoApply || !mustConfirm { - if b.CLI != nil { - b.CLI.Output("") + if !op.AutoApprove && err != errRunApproved { + if err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}); err != nil { + return r, generalError("Failed to approve the apply command", err) } } diff --git a/internal/cloud/backend_apply_test.go b/internal/cloud/backend_apply_test.go index 1ae00946a..ff98d9928 100644 --- a/internal/cloud/backend_apply_test.go +++ b/internal/cloud/backend_apply_test.go @@ -2,6 +2,7 @@ package cloud import ( "context" + "fmt" "os" "os/signal" "strings" @@ -9,6 +10,7 @@ import ( "testing" "time" + gomock "github.com/golang/mock/gomock" "github.com/google/go-cmp/cmp" tfe "github.com/hashicorp/go-tfe" version "github.com/hashicorp/go-version" @@ -697,6 +699,14 @@ func TestCloud_applyNoApprove(t *testing.T) { func TestCloud_applyAutoApprove(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() + ctrl := gomock.NewController(t) + + applyMock := tfe.NewMockApplies(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs := strings.NewReader(applySuccessOneResourceAdded) + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + b.client.Applies = applyMock op, configCleanup, done := testOperationApply(t, "./testdata/apply") defer configCleanup() @@ -888,17 +898,24 @@ func TestCloud_applyDiscardedExternally(t *testing.T) { } } -func TestCloud_applyWithAutoApply(t *testing.T) { +func TestCloud_applyWithAutoApprove(t *testing.T) { b, bCleanup := testBackendWithPrefix(t) defer bCleanup() + ctrl := gomock.NewController(t) + + applyMock := tfe.NewMockApplies(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs := strings.NewReader(applySuccessOneResourceAdded) + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + b.client.Applies = applyMock // Create a named workspace that auto applies. _, err := b.client.Workspaces.Create( context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - AutoApply: tfe.Bool(true), - Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), + Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), }, ) if err != nil { @@ -916,6 +933,7 @@ func TestCloud_applyWithAutoApply(t *testing.T) { op.UIIn = input op.UIOut = b.CLI op.Workspace = "prod" + op.AutoApprove = true run, err := b.Operation(context.Background(), op) if err != nil { @@ -1374,6 +1392,34 @@ func TestCloud_applyPolicySoftFail(t *testing.T) { func TestCloud_applyPolicySoftFailAutoApproveSuccess(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() + ctrl := gomock.NewController(t) + + policyCheckMock := tfe.NewMockPolicyChecks(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs := strings.NewReader(fmt.Sprintf("%s\n%s", sentinelSoftFail, applySuccessOneResourceAdded)) + + pc := &tfe.PolicyCheck{ + ID: "pc-1", + Actions: &tfe.PolicyActions{ + IsOverridable: true, + }, + Permissions: &tfe.PolicyPermissions{ + CanOverride: true, + }, + Scope: tfe.PolicyScopeOrganization, + Status: tfe.PolicySoftFailed, + } + policyCheckMock.EXPECT().Read(gomock.Any(), gomock.Any()).Return(pc, nil) + policyCheckMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + policyCheckMock.EXPECT().Override(gomock.Any(), gomock.Any()).Return(nil, nil) + b.client.PolicyChecks = policyCheckMock + applyMock := tfe.NewMockApplies(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs = strings.NewReader("\n\n\n1 added, 0 changed, 0 destroyed") + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + b.client.Applies = applyMock op, configCleanup, done := testOperationApply(t, "./testdata/apply-policy-soft-failed") defer configCleanup() @@ -1422,17 +1468,24 @@ func TestCloud_applyPolicySoftFailAutoApproveSuccess(t *testing.T) { } } -func TestCloud_applyPolicySoftFailAutoApply(t *testing.T) { +func TestCloud_applyPolicySoftFailAutoApprove(t *testing.T) { b, bCleanup := testBackendWithName(t) defer bCleanup() + ctrl := gomock.NewController(t) + + applyMock := tfe.NewMockApplies(ctrl) + // This needs three new lines because we check for a minimum of three lines + // in the parsing of logs in `opApply` function. + logs := strings.NewReader(applySuccessOneResourceAdded) + applyMock.EXPECT().Logs(gomock.Any(), gomock.Any()).Return(logs, nil) + b.client.Applies = applyMock // Create a named workspace that auto applies. _, err := b.client.Workspaces.Create( context.Background(), b.organization, tfe.WorkspaceCreateOptions{ - AutoApply: tfe.Bool(true), - Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), + Name: tfe.String(b.WorkspaceMapping.Prefix + "prod"), }, ) if err != nil { @@ -1451,6 +1504,7 @@ func TestCloud_applyPolicySoftFailAutoApply(t *testing.T) { op.UIIn = input op.UIOut = b.CLI op.Workspace = "prod" + op.AutoApprove = true run, err := b.Operation(context.Background(), op) if err != nil { @@ -1465,7 +1519,7 @@ func TestCloud_applyPolicySoftFailAutoApply(t *testing.T) { t.Fatalf("expected a non-empty plan") } - if len(input.answers) != 1 { + if len(input.answers) != 2 { t.Fatalf("expected an unused answer, got: %v", input.answers) } @@ -1656,3 +1710,28 @@ func TestCloud_applyVersionCheck(t *testing.T) { }) } } + +const applySuccessOneResourceAdded = ` +Terraform v0.11.10 + +Initializing plugins and modules... +null_resource.hello: Creating... +null_resource.hello: Creation complete after 0s (ID: 8657651096157629581) + +Apply complete! Resources: 1 added, 0 changed, 0 destroyed. +` + +const sentinelSoftFail = ` +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/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go index 693210d4f..8b61cae5b 100644 --- a/internal/cloud/backend_plan.go +++ b/internal/cloud/backend_plan.go @@ -277,6 +277,7 @@ in order to capture the filesystem context the remote workspace expects: ConfigurationVersion: cv, Refresh: tfe.Bool(op.PlanRefresh), Workspace: w, + AutoApply: tfe.Bool(op.AutoApprove), } switch op.PlanMode { diff --git a/internal/cloud/e2e/apply_auto_approve_test.go b/internal/cloud/e2e/apply_auto_approve_test.go new file mode 100644 index 000000000..919c457e2 --- /dev/null +++ b/internal/cloud/e2e/apply_auto_approve_test.go @@ -0,0 +1,280 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "fmt" + "io/ioutil" + "log" + "os" + "strings" + "testing" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/e2e" +) + +type tfCommand struct { + command []string + expectedOutput string + expectedErr string +} + +func Test_terraform_apply_autoApprove(t *testing.T) { + ctx := context.Background() + cases := map[string]struct { + setup func(t *testing.T) (map[string]string, func()) + commands []tfCommand + validations func(t *testing.T, orgName, wsName string) + }{ + "workspace manual apply, terraform apply without auto-approve": { + setup: func(t *testing.T) (map[string]string, func()) { + org, orgCleanup := createOrganization(t) + wOpts := tfe.WorkspaceCreateOptions{ + Name: tfe.String(randomString(t)), + TerraformVersion: tfe.String(terraformVersion), + AutoApply: tfe.Bool(false), + } + workspace := createWorkspace(t, org, wOpts) + cleanup := func() { + defer orgCleanup() + } + names := map[string]string{ + "organization": org.Name, + "workspace": workspace.Name, + } + + return names, cleanup + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedOutput: "Terraform has been successfully initialized", + expectedErr: "", + }, + { + command: []string{"apply"}, + expectedOutput: "Do you want to perform these actions in workspace", + expectedErr: "Error asking approve", + }, + }, + validations: func(t *testing.T, orgName, wsName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, wsName, &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatal("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunPlanned { + t.Fatalf("Expected run status to be `planned`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + "workspace auto apply, terraform apply without auto-approve": { + setup: func(t *testing.T) (map[string]string, func()) { + org, orgCleanup := createOrganization(t) + wOpts := tfe.WorkspaceCreateOptions{ + Name: tfe.String(randomString(t)), + TerraformVersion: tfe.String(terraformVersion), + AutoApply: tfe.Bool(true), + } + workspace := createWorkspace(t, org, wOpts) + cleanup := func() { + defer orgCleanup() + } + names := map[string]string{ + "organization": org.Name, + "workspace": workspace.Name, + } + + return names, cleanup + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedOutput: "Terraform has been successfully initialized", + expectedErr: "", + }, + { + command: []string{"apply"}, + expectedOutput: "Do you want to perform these actions in workspace", + expectedErr: "Error asking approve", + }, + }, + validations: func(t *testing.T, orgName, wsName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, wsName, &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatalf("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunPlanned { + t.Fatalf("Expected run status to be `planned`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + "workspace manual apply, terraform apply auto-approve": { + setup: func(t *testing.T) (map[string]string, func()) { + org, orgCleanup := createOrganization(t) + wOpts := tfe.WorkspaceCreateOptions{ + Name: tfe.String(randomString(t)), + TerraformVersion: tfe.String(terraformVersion), + AutoApply: tfe.Bool(false), + } + workspace := createWorkspace(t, org, wOpts) + cleanup := func() { + defer orgCleanup() + } + names := map[string]string{ + "organization": org.Name, + "workspace": workspace.Name, + } + + return names, cleanup + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedOutput: "Terraform has been successfully initialized", + expectedErr: "", + }, + { + command: []string{"apply", "-auto-approve"}, + expectedOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", + expectedErr: "", + }, + }, + validations: func(t *testing.T, orgName, wsName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, wsName, &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatalf("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunApplied { + t.Fatalf("Expected run status to be `applied`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + "workspace auto apply, terraform apply auto-approve": { + setup: func(t *testing.T) (map[string]string, func()) { + org, orgCleanup := createOrganization(t) + + wOpts := tfe.WorkspaceCreateOptions{ + Name: tfe.String(randomString(t)), + TerraformVersion: tfe.String(terraformVersion), + AutoApply: tfe.Bool(true), + } + workspace := createWorkspace(t, org, wOpts) + cleanup := func() { + defer orgCleanup() + } + names := map[string]string{ + "organization": org.Name, + "workspace": workspace.Name, + } + + return names, cleanup + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedOutput: "Terraform has been successfully initialized", + expectedErr: "", + }, + { + command: []string{"apply", "-auto-approve"}, + expectedOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", + expectedErr: "", + }, + }, + validations: func(t *testing.T, orgName, wsName string) { + workspace, err := tfeClient.Workspaces.ReadWithOptions(ctx, orgName, wsName, &tfe.WorkspaceReadOptions{Include: "current_run"}) + if err != nil { + t.Fatal(err) + } + if workspace.CurrentRun == nil { + t.Fatalf("Expected workspace to have run, but got nil") + } + if workspace.CurrentRun.Status != tfe.RunApplied { + t.Fatalf("Expected run status to be `applied`, but is %s", workspace.CurrentRun.Status) + } + }, + }, + } + for name, tc := range cases { + log.Println("Test: ", name) + resourceData, cleanup := tc.setup(t) + defer cleanup() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + orgName := resourceData["organization"] + wsName := resourceData["workspace"] + tfBlock := createTerraformBlock(orgName, wsName) + writeMainTF(t, tfBlock, tmpDir) + tf := e2e.NewBinary(terraformBin, tmpDir) + defer tf.Close() + tf.AddEnv("TF_LOG=debug") + tf.AddEnv(cliConfigFileEnv) + + for _, cmd := range tc.commands { + stdout, stderr, err := tf.Run(cmd.command...) + if cmd.expectedErr == "" && err != nil { + t.Fatalf("Expected no error, but got %v. stderr\n: %s", err, stderr) + } + if cmd.expectedErr != "" { + if !strings.Contains(stderr, cmd.expectedErr) { + t.Fatalf("Expected to find error %s, but got %s", cmd.expectedErr, stderr) + } + } + + if cmd.expectedOutput != "" && !strings.Contains(stdout, cmd.expectedOutput) { + t.Fatalf("Expected to find output %s, but did not find in\n%s", cmd.expectedOutput, stdout) + } + } + + tc.validations(t, orgName, wsName) + } +} + +func createTerraformBlock(org, ws string) string { + return fmt.Sprintf( + `terraform { + cloud { + hostname = "%s" + organization = "%s" + + workspaces { + name = "%s" + } + } +} + +resource "random_pet" "server" { + keepers = { + uuid = uuid() + } + + length = 3 +}`, tfeHostname, org, ws) +} + +func writeMainTF(t *testing.T, block string, dir string) { + f, err := os.Create(fmt.Sprintf("%s/main.tf", dir)) + if err != nil { + t.Fatal(err) + } + _, err = f.WriteString(block) + if err != nil { + t.Fatal(err) + } + f.Close() +} diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go new file mode 100644 index 000000000..7793b917f --- /dev/null +++ b/internal/cloud/e2e/helper_test.go @@ -0,0 +1,50 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "fmt" + "testing" + + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/go-uuid" +) + +func createOrganization(t *testing.T) (*tfe.Organization, func()) { + ctx := context.Background() + org, err := tfeClient.Organizations.Create(ctx, tfe.OrganizationCreateOptions{ + Name: tfe.String("tst-" + randomString(t)), + Email: tfe.String(fmt.Sprintf("%s@tfe.local", randomString(t))), + }) + if err != nil { + t.Fatal(err) + } + + return org, func() { + if err := tfeClient.Organizations.Delete(ctx, org.Name); err != nil { + t.Errorf("Error destroying organization! WARNING: Dangling resources\n"+ + "may exist! The full error is shown below.\n\n"+ + "Organization: %s\nError: %s", org.Name, err) + } + } +} + +func createWorkspace(t *testing.T, org *tfe.Organization, wOpts tfe.WorkspaceCreateOptions) *tfe.Workspace { + ctx := context.Background() + w, err := tfeClient.Workspaces.Create(ctx, org.Name, wOpts) + if err != nil { + t.Fatal(err) + } + + return w +} + +func randomString(t *testing.T) string { + v, err := uuid.GenerateUUID() + if err != nil { + t.Fatal(err) + } + return v +} diff --git a/internal/cloud/e2e/main_test.go b/internal/cloud/e2e/main_test.go new file mode 100644 index 000000000..414442170 --- /dev/null +++ b/internal/cloud/e2e/main_test.go @@ -0,0 +1,198 @@ +//go:build e2e +// +build e2e + +package main + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "strings" + "testing" + + tfe "github.com/hashicorp/go-tfe" +) + +var terraformVersion string +var terraformBin string +var cliConfigFileEnv string + +var tfeClient *tfe.Client +var tfeHostname string +var tfeToken string + +func TestMain(m *testing.M) { + log.SetFlags(log.LstdFlags | log.Lshortfile) + if !accTest() { + // if TF_ACC is not set, we want to skip all these tests. + return + } + teardown := setup() + code := m.Run() + teardown() + + os.Exit(code) +} + +func accTest() bool { + // TF_ACC is set when we want to run acceptance tests, meaning it relies on + // network access. + return os.Getenv("TF_ACC") != "" +} + +func setup() func() { + setTfeClient() + teardown := setupBinary() + setVersion() + ensureVersionExists() + + return func() { + teardown() + } +} + +func setTfeClient() { + hostname := os.Getenv("TFE_HOSTNAME") + token := os.Getenv("TFE_TOKEN") + if hostname == "" { + log.Fatalf("hostname cannot be empty") + } + if token == "" { + log.Fatalf("token cannot be empty") + } + tfeHostname = hostname + tfeToken = token + + cfg := &tfe.Config{ + Address: fmt.Sprintf("https://%s", hostname), + Token: token, + } + + // Create a new TFE client. + client, err := tfe.NewClient(cfg) + if err != nil { + log.Fatal(err) + } + tfeClient = client +} + +func setupBinary() func() { + log.Println("Setting up terraform binary") + tmpTerraformBinaryDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + log.Fatal(err) + } + log.Println(tmpTerraformBinaryDir) + currentDir, err := os.Getwd() + defer os.Chdir(currentDir) + if err != nil { + log.Fatal(err) + } + // Getting top level dir + dirPaths := strings.Split(currentDir, "/") + log.Println(currentDir) + topLevel := len(dirPaths) - 3 + topDir := strings.Join(dirPaths[0:topLevel], "/") + + if err := os.Chdir(topDir); err != nil { + log.Fatal(err) + } + + cmd := exec.Command("go", "build", "-o", tmpTerraformBinaryDir) + err = cmd.Run() + if err != nil { + log.Fatal(err) + } + + credFile := fmt.Sprintf("%s/dev.tfrc", tmpTerraformBinaryDir) + writeCredRC(credFile) + + terraformBin = fmt.Sprintf("%s/terraform", tmpTerraformBinaryDir) + cliConfigFileEnv = fmt.Sprintf("TF_CLI_CONFIG_FILE=%s", credFile) + + return func() { + os.RemoveAll(tmpTerraformBinaryDir) + } +} + +func setVersion() { + log.Println("Retrieving version") + cmd := exec.Command(terraformBin, "version", "-json") + out, err := cmd.Output() + if err != nil { + log.Fatal(fmt.Sprintf("Could not output terraform version: %v", err)) + } + var data map[string]interface{} + if err := json.Unmarshal(out, &data); err != nil { + log.Fatal(fmt.Sprintf("Could not unmarshal version output: %v", err)) + } + + out, err = exec.Command("git", "rev-parse", "HEAD").Output() + if err != nil { + log.Fatal(fmt.Sprintf("Could not execute go build command: %v", err)) + } + + hash := string(out)[0:8] + + terraformVersion = fmt.Sprintf("%s-%s", data["terraform_version"].(string), hash) +} + +func ensureVersionExists() { + opts := tfe.AdminTerraformVersionsListOptions{ + ListOptions: tfe.ListOptions{ + PageNumber: 1, + PageSize: 100, + }, + } + hasVersion := false + +findTfVersion: + for { + tfVersionList, err := tfeClient.Admin.TerraformVersions.List(context.Background(), opts) + if err != nil { + log.Fatalf("Could not retrieve list of terraform versions: %v", err) + } + for _, item := range tfVersionList.Items { + if item.Version == terraformVersion { + hasVersion = true + break findTfVersion + } + } + + // Exit the loop when we've seen all pages. + if tfVersionList.CurrentPage >= tfVersionList.TotalPages { + break + } + + // Update the page number to get the next page. + opts.PageNumber = tfVersionList.NextPage + } + + if !hasVersion { + log.Fatalf("Terraform Version %s does not exist in the list. Please add it.", terraformVersion) + } +} + +func writeCredRC(file string) { + creds := credentialBlock() + f, err := os.Create(file) + if err != nil { + log.Fatal(err) + } + _, err = f.WriteString(creds) + if err != nil { + log.Fatal(err) + } + f.Close() +} + +func credentialBlock() string { + return fmt.Sprintf(` +credentials "%s" { + token = "%s" +}`, tfeHostname, tfeToken) +} diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index fd93c48d9..518602c3c 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -1353,7 +1353,7 @@ func (m *MockWorkspaces) UnassignSSHKey(ctx context.Context, workspaceID string) panic("not implemented") } -func (m *MockWorkspaces) RemoteStateConsumers(ctx context.Context, workspaceID string) (*tfe.WorkspaceList, error) { +func (m *MockWorkspaces) RemoteStateConsumers(ctx context.Context, workspaceID string, options *tfe.RemoteStateConsumersListOptions) (*tfe.WorkspaceList, error) { panic("not implemented") }