Skip to content

Commit 53ad604

Browse files
authored
Merge pull request #36686 from hashicorp/wait-for-post-plan-tasks-remote-backend
Add polling to wait for post plan tasks to complete in remote backend
2 parents 34a35cb + 8934daf commit 53ad604

File tree

5 files changed

+131
-0
lines changed

5 files changed

+131
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: BUG FIXES
2+
body: Fixes unintended exit of CLI when using the remote backend and applying with post-plan tasks configured in HCP Terraform
3+
time: 2025-03-12T11:46:37.275822-04:00
4+
custom:
5+
Issue: "36686"

internal/backend/remote/backend_common.go

+27
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,33 @@ func backoff(min, max float64, iter int) time.Duration {
4747
return time.Duration(backoff) * time.Millisecond
4848
}
4949

50+
func (b *Remote) waitForPostPlanTasks(stopCtx, cancelCtx context.Context, r *tfe.Run) error {
51+
taskStages := make(taskStages)
52+
result, err := b.client.Runs.ReadWithOptions(stopCtx, r.ID, &tfe.RunReadOptions{
53+
Include: []tfe.RunIncludeOpt{tfe.RunTaskStages},
54+
})
55+
if err == nil {
56+
for _, t := range result.TaskStages {
57+
if t != nil {
58+
taskStages[t.Stage] = t
59+
}
60+
}
61+
} else {
62+
// This error would be expected for older versions of TFE that do not allow
63+
// fetching task_stages.
64+
if !strings.HasSuffix(err.Error(), "Invalid include parameter") {
65+
generalError("Failed to retrieve run", err)
66+
}
67+
}
68+
69+
if stage, ok := taskStages[tfe.PostPlan]; ok {
70+
if err := b.waitTaskStage(stopCtx, cancelCtx, r, stage.ID); err != nil {
71+
return err
72+
}
73+
}
74+
return nil
75+
}
76+
5077
func (b *Remote) waitForRun(stopCtx, cancelCtx context.Context, op *backendrun.Operation, opType string, r *tfe.Run, w *tfe.Workspace) (*tfe.Run, error) {
5178
started := time.Now()
5279
updated := started

internal/backend/remote/backend_plan.go

+9
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,15 @@ in order to capture the filesystem context the remote workspace expects:
411411
return r, generalError("Failed to retrieve run", err)
412412
}
413413

414+
// Wait for post plan tasks to complete before proceeding.
415+
// Otherwise, in the case of an apply, if they are still running
416+
// when we check for whether the run is confirmable the CLI will
417+
// uncermoniously exit before the user has a chance to confirm, or for an auto-apply to take place.
418+
err = b.waitForPostPlanTasks(stopCtx, cancelCtx, r)
419+
if err != nil {
420+
return r, err
421+
}
422+
414423
// If the run is canceled or errored, we still continue to the
415424
// cost-estimation and policy check phases to ensure we render any
416425
// results available. In the case of a hard-failed policy check, the
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package remote
5+
6+
import (
7+
"context"
8+
"fmt"
9+
10+
tfe "github.com/hashicorp/go-tfe"
11+
)
12+
13+
type taskStages map[tfe.Stage]*tfe.TaskStage
14+
15+
const (
16+
taskStageBackoffMin = 4000.0
17+
taskStageBackoffMax = 12000.0
18+
)
19+
20+
// waitTaskStage waits for a task stage to complete, only informs the caller if the stage has failed in some way.
21+
func (b *Remote) waitTaskStage(stopCtx, cancelCtx context.Context, r *tfe.Run, stageID string) error {
22+
ctx := &IntegrationContext{
23+
StopContext: stopCtx,
24+
CancelContext: cancelCtx,
25+
}
26+
return ctx.Poll(taskStageBackoffMin, taskStageBackoffMax, func(i int) (bool, error) {
27+
options := tfe.TaskStageReadOptions{
28+
Include: []tfe.TaskStageIncludeOpt{tfe.TaskStageTaskResults, tfe.PolicyEvaluationsTaskResults},
29+
}
30+
stage, err := b.client.TaskStages.Read(ctx.StopContext, stageID, &options)
31+
if err != nil {
32+
return false, generalError("Failed to retrieve task stage", err)
33+
}
34+
35+
switch stage.Status {
36+
case tfe.TaskStagePending:
37+
// Waiting for it to start
38+
return true, nil
39+
case tfe.TaskStageRunning:
40+
// not a terminal status so we continue to poll
41+
return true, nil
42+
case tfe.TaskStagePassed:
43+
return false, nil
44+
case tfe.TaskStageCanceled, tfe.TaskStageErrored, tfe.TaskStageFailed:
45+
return false, fmt.Errorf("Task Stage '%s': %s.", stage.ID, stage.Status)
46+
case tfe.TaskStageAwaitingOverride:
47+
return false, fmt.Errorf("Task Stage '%s' awaiting override.", stage.ID)
48+
case tfe.TaskStageUnreachable:
49+
return false, nil
50+
default:
51+
return false, fmt.Errorf("Task stage '%s' has invalid status: %s", stage.ID, stage.Status)
52+
}
53+
})
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package remote
5+
6+
import (
7+
"context"
8+
"log"
9+
"time"
10+
)
11+
12+
// IntegrationContext is a set of data that is useful when performing HCP Terraform integration operations
13+
type IntegrationContext struct {
14+
StopContext context.Context
15+
CancelContext context.Context
16+
}
17+
18+
func (s *IntegrationContext) Poll(backoffMinInterval float64, backoffMaxInterval float64, every func(i int) (bool, error)) error {
19+
for i := 0; ; i++ {
20+
select {
21+
case <-s.StopContext.Done():
22+
log.Print("IntegrationContext.Poll: StopContext.Done() called")
23+
return s.StopContext.Err()
24+
case <-s.CancelContext.Done():
25+
log.Print("IntegrationContext.Poll: CancelContext.Done() called")
26+
return s.CancelContext.Err()
27+
case <-time.After(backoff(backoffMinInterval, backoffMaxInterval, i)):
28+
// blocks for a time between min and max
29+
}
30+
31+
cont, err := every(i)
32+
if !cont {
33+
return err
34+
}
35+
}
36+
}

0 commit comments

Comments
 (0)