Skip to content

Commit

Permalink
Merge pull request #31821 from glennsarti/gs/TF-707-add-pre-apply
Browse files Browse the repository at this point in the history
Add support for pre-apply task results in the cloud backend
  • Loading branch information
brandonc authored Oct 10, 2022
2 parents 71f1b12 + 1773129 commit bc1436a
Show file tree
Hide file tree
Showing 6 changed files with 262 additions and 32 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-plugin v1.4.3
github.com/hashicorp/go-retryablehttp v0.7.1
github.com/hashicorp/go-tfe v1.9.0
github.com/hashicorp/go-tfe v1.10.0
github.com/hashicorp/go-uuid v1.0.3
github.com/hashicorp/go-version v1.6.0
github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -368,8 +368,8 @@ github.com/hashicorp/go-slug v0.10.0/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu4
github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-tfe v1.9.0 h1:jkmyo7WKNA7gZDegG5imndoC4sojWXhqMufO+KcHqrU=
github.com/hashicorp/go-tfe v1.9.0/go.mod h1:uSWi2sPw7tLrqNIiASid9j3SprbbkPSJ/2s3X0mMemg=
github.com/hashicorp/go-tfe v1.10.0 h1:mkEge/DSca8VQeBSAQbjEy8fWFHbrJA76M7dny5XlYc=
github.com/hashicorp/go-tfe v1.10.0/go.mod h1:uSWi2sPw7tLrqNIiASid9j3SprbbkPSJ/2s3X0mMemg=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
Expand Down
13 changes: 13 additions & 0 deletions internal/cloud/backend_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,19 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio
}
}

// 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)
if err != nil {
return r, err
}

if stage, ok := taskStages[tfe.PreApply]; ok {
if err := b.waitTaskStage(stopCtx, cancelCtx, op, r, stage.ID, "Pre-apply Tasks"); err != nil {
return r, err
}
}

r, err = b.waitForRun(stopCtx, cancelCtx, op, "apply", r, w)
if err != nil {
return r, err
Expand Down
36 changes: 7 additions & 29 deletions internal/cloud/backend_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,22 +293,13 @@ in order to capture the filesystem context the remote workspace expects:

// Retrieve the run to get task stages.
// Task Stages are calculated upfront so we only need to call this once for the run.
taskStages := make([]*tfe.TaskStage, 0)
result, err := b.client.Runs.ReadWithOptions(stopCtx, r.ID, &tfe.RunReadOptions{
Include: []tfe.RunIncludeOpt{tfe.RunTaskStages},
})
if err == nil {
taskStages = result.TaskStages
} else {
// This error would be expected for older versions of TFE that do not allow
// fetching task_stages.
if !strings.HasSuffix(err.Error(), "Invalid include parameter") {
return r, generalError("Failed to retrieve run", err)
}
taskStages, err := b.runTaskStages(stopCtx, b.client, r.ID)
if err != nil {
return r, err
}

if stageID := getTaskStageIDByName(taskStages, tfe.PrePlan); stageID != nil {
if err := b.waitTaskStage(stopCtx, cancelCtx, op, r, *stageID, "Pre-plan Tasks"); err != nil {
if stage, ok := taskStages[tfe.PrePlan]; ok {
if err := b.waitTaskStage(stopCtx, cancelCtx, op, r, stage.ID, "Pre-plan Tasks"); err != nil {
return r, err
}
}
Expand Down Expand Up @@ -357,8 +348,8 @@ in order to capture the filesystem context the remote workspace expects:
// status of the run will be "errored", but there is still policy
// information which should be shown.

if stageID := getTaskStageIDByName(taskStages, tfe.PostPlan); stageID != nil {
if err := b.waitTaskStage(stopCtx, cancelCtx, op, r, *stageID, "Post-plan Tasks"); err != nil {
if stage, ok := taskStages[tfe.PostPlan]; ok {
if err := b.waitTaskStage(stopCtx, cancelCtx, op, r, stage.ID, "Post-plan Tasks"); err != nil {
return r, err
}
}
Expand All @@ -382,19 +373,6 @@ in order to capture the filesystem context the remote workspace expects:
return r, nil
}

func getTaskStageIDByName(stages []*tfe.TaskStage, stageName tfe.Stage) *string {
if len(stages) == 0 {
return nil
}

for _, stage := range stages {
if stage.Stage == stageName {
return &stage.ID
}
}
return nil
}

const planDefaultHeader = `
[reset][yellow]Running plan in Terraform Cloud. Output will stream here. Pressing Ctrl-C
will stop streaming the logs, but will not stop the plan running remotely.[reset]
Expand Down
32 changes: 32 additions & 0 deletions internal/cloud/backend_taskStages.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package cloud

import (
"context"
"strings"

tfe "github.com/hashicorp/go-tfe"
)

type taskStages map[tfe.Stage]*tfe.TaskStage

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{
Include: []tfe.RunIncludeOpt{tfe.RunTaskStages},
})
if err == nil {
for _, t := range result.TaskStages {
if t != nil {
taskStages[t.Stage] = t
}
}
} else {
// This error would be expected for older versions of TFE that do not allow
// fetching task_stages.
if !strings.HasSuffix(err.Error(), "Invalid include parameter") {
return taskStages, generalError("Failed to retrieve run", err)
}
}

return taskStages, nil
}
207 changes: 207 additions & 0 deletions internal/cloud/backend_taskStages_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package cloud

import (
"context"
"errors"
"testing"

"github.com/golang/mock/gomock"
"github.com/hashicorp/go-tfe"
tfemocks "github.com/hashicorp/go-tfe/mocks"
)

func MockAllTaskStages(t *testing.T, client *tfe.Client) (RunID string) {
ctrl := gomock.NewController(t)

RunID = "run-all_task_stages"

mockRunsAPI := tfemocks.NewMockRuns(ctrl)

goodRun := tfe.Run{
TaskStages: []*tfe.TaskStage{
{
Stage: tfe.PrePlan,
},
{
Stage: tfe.PostPlan,
},
{
Stage: tfe.PreApply,
},
},
}
mockRunsAPI.
EXPECT().
ReadWithOptions(gomock.Any(), RunID, gomock.Any()).
Return(&goodRun, nil).
AnyTimes()

// Mock a bad Read response
mockRunsAPI.
EXPECT().
ReadWithOptions(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, tfe.ErrInvalidOrg).
AnyTimes()

// Wire up the mock interfaces
client.Runs = mockRunsAPI
return
}

func MockPrePlanTaskStage(t *testing.T, client *tfe.Client) (RunID string) {
ctrl := gomock.NewController(t)

RunID = "run-pre_plan_task_stage"

mockRunsAPI := tfemocks.NewMockRuns(ctrl)

goodRun := tfe.Run{
TaskStages: []*tfe.TaskStage{
{
Stage: tfe.PrePlan,
},
},
}
mockRunsAPI.
EXPECT().
ReadWithOptions(gomock.Any(), RunID, gomock.Any()).
Return(&goodRun, nil).
AnyTimes()

// Mock a bad Read response
mockRunsAPI.
EXPECT().
ReadWithOptions(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, tfe.ErrInvalidOrg).
AnyTimes()

// Wire up the mock interfaces
client.Runs = mockRunsAPI
return
}

func MockTaskStageUnsupported(t *testing.T, client *tfe.Client) (RunID string) {
ctrl := gomock.NewController(t)

RunID = "run-unsupported_task_stage"

mockRunsAPI := tfemocks.NewMockRuns(ctrl)

mockRunsAPI.
EXPECT().
ReadWithOptions(gomock.Any(), RunID, gomock.Any()).
Return(nil, errors.New("Invalid include parameter")).
AnyTimes()

mockRunsAPI.
EXPECT().
ReadWithOptions(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, tfe.ErrInvalidOrg).
AnyTimes()

client.Runs = mockRunsAPI
return
}

func TestTaskStagesWithAllStages(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()

config := &tfe.Config{
Token: "not-a-token",
}
client, _ := tfe.NewClient(config)
runID := MockAllTaskStages(t, client)

ctx := context.TODO()
taskStages, err := b.runTaskStages(ctx, client, runID)

if err != nil {
t.Fatalf("Expected to not error but received %s", err)
}

for _, stageName := range []tfe.Stage{
tfe.PrePlan,
tfe.PostPlan,
tfe.PreApply,
} {
if stage, ok := taskStages[stageName]; ok {
if stage.Stage != stageName {
t.Errorf("Expected task stage indexed by %s to find a Task Stage with the same index, but receieved %s", stageName, stage.Stage)
}
} else {
t.Errorf("Expected task stage indexed by %s to exist, but it did not", stageName)
}
}
}

func TestTaskStagesWithOneStage(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()

config := &tfe.Config{
Token: "not-a-token",
}
client, _ := tfe.NewClient(config)
runID := MockPrePlanTaskStage(t, client)

ctx := context.TODO()
taskStages, err := b.runTaskStages(ctx, client, runID)

if err != nil {
t.Fatalf("Expected to not error but received %s", err)
}

if _, ok := taskStages[tfe.PrePlan]; !ok {
t.Errorf("Expected task stage indexed by %s to exist, but it did not", tfe.PrePlan)
}

for _, stageName := range []tfe.Stage{
tfe.PostPlan,
tfe.PreApply,
} {
if _, ok := taskStages[stageName]; ok {
t.Errorf("Expected task stage indexed by %s to not exist, but it did", stageName)
}
}
}

func TestTaskStagesWithOldTFC(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()

config := &tfe.Config{
Token: "not-a-token",
}
client, _ := tfe.NewClient(config)
runID := MockTaskStageUnsupported(t, client)

ctx := context.TODO()
taskStages, err := b.runTaskStages(ctx, client, runID)

if err != nil {
t.Fatalf("Expected to not error but received %s", err)
}

if len(taskStages) != 0 {
t.Errorf("Expected task stage to be empty, but found %d stages", len(taskStages))
}
}

func TestTaskStagesWithErrors(t *testing.T) {
b, bCleanup := testBackendWithName(t)
defer bCleanup()

config := &tfe.Config{
Token: "not-a-token",
}
client, _ := tfe.NewClient(config)
MockTaskStageUnsupported(t, client)

ctx := context.TODO()
_, err := b.runTaskStages(ctx, client, "this run ID will not exist is invalid anyway")

if err == nil {
t.Error("Expected to error but did not")
}
}

0 comments on commit bc1436a

Please sign in to comment.