Skip to content

Commit dceb845

Browse files
authored
Merge pull request #33492 from hashicorp/cli-team/saved-cloud-plans
Implement saved cloud plans
2 parents 5588603 + 08e58fd commit dceb845

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+1877
-236
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ require (
4141
github.com/hashicorp/go-multierror v1.1.1
4242
github.com/hashicorp/go-plugin v1.4.3
4343
github.com/hashicorp/go-retryablehttp v0.7.4
44-
github.com/hashicorp/go-tfe v1.28.0
44+
github.com/hashicorp/go-tfe v1.29.0
4545
github.com/hashicorp/go-uuid v1.0.3
4646
github.com/hashicorp/go-version v1.6.0
4747
github.com/hashicorp/hcl v1.0.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -633,8 +633,8 @@ github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerX
633633
github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
634634
github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
635635
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
636-
github.com/hashicorp/go-tfe v1.28.0 h1:YQNfHz5UPMiOD2idad4GCjzG3R2ExPww741PBPqMOIU=
637-
github.com/hashicorp/go-tfe v1.28.0/go.mod h1:z0182DGE/63AKUaWblUVBIrt+xdSmsuuXg5AoxGqDF4=
636+
github.com/hashicorp/go-tfe v1.29.0 h1:hVvgoKtLAWTkXl9p/8WnItCaW65VJwqpjLZkXe8R2AM=
637+
github.com/hashicorp/go-tfe v1.29.0/go.mod h1:z0182DGE/63AKUaWblUVBIrt+xdSmsuuXg5AoxGqDF4=
638638
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
639639
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
640640
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=

internal/backend/backend.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ type Operation struct {
270270

271271
// Plan is a plan that was passed as an argument. This is valid for
272272
// plan and apply arguments but may not work for all backends.
273-
PlanFile *planfile.Reader
273+
PlanFile *planfile.WrappedPlanFile
274274

275275
// The options below are more self-explanatory and affect the runtime
276276
// behavior of the operation.

internal/backend/local/backend_local.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,12 @@ func (b *Local) localRun(op *backend.Operation) (*backend.LocalRun, *configload.
7777

7878
var ctxDiags tfdiags.Diagnostics
7979
var configSnap *configload.Snapshot
80-
if op.PlanFile != nil {
80+
if op.PlanFile.IsCloud() {
81+
diags = diags.Append(fmt.Errorf("error: using a saved cloud plan when executing Terraform locally is not supported"))
82+
return nil, nil, nil, diags
83+
}
84+
85+
if lp, ok := op.PlanFile.Local(); ok {
8186
var stateMeta *statemgr.SnapshotMeta
8287
// If the statemgr implements our optional PersistentMeta interface then we'll
8388
// additionally verify that the state snapshot in the plan file has
@@ -87,7 +92,7 @@ func (b *Local) localRun(op *backend.Operation) (*backend.LocalRun, *configload.
8792
stateMeta = &m
8893
}
8994
log.Printf("[TRACE] backend/local: populating backend.LocalRun from plan file")
90-
ret, configSnap, ctxDiags = b.localRunForPlanFile(op, op.PlanFile, ret, &coreOpts, stateMeta)
95+
ret, configSnap, ctxDiags = b.localRunForPlanFile(op, lp, ret, &coreOpts, stateMeta)
9196
if ctxDiags.HasErrors() {
9297
diags = diags.Append(ctxDiags)
9398
return nil, nil, nil, diags

internal/backend/local/backend_local_test.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,41 @@ func TestLocalRun_error(t *testing.T) {
8686
assertBackendStateUnlocked(t, b)
8787
}
8888

89+
func TestLocalRun_cloudPlan(t *testing.T) {
90+
configDir := "./testdata/apply"
91+
b := TestLocal(t)
92+
93+
_, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir, "tests")
94+
defer configCleanup()
95+
96+
planPath := "./testdata/plan-bookmark/bookmark.json"
97+
98+
planFile, err := planfile.OpenWrapped(planPath)
99+
if err != nil {
100+
t.Fatalf("unexpected error reading planfile: %s", err)
101+
}
102+
103+
streams, _ := terminal.StreamsForTesting(t)
104+
view := views.NewView(streams)
105+
stateLocker := clistate.NewLocker(0, views.NewStateLocker(arguments.ViewHuman, view))
106+
107+
op := &backend.Operation{
108+
ConfigDir: configDir,
109+
ConfigLoader: configLoader,
110+
PlanFile: planFile,
111+
Workspace: backend.DefaultStateName,
112+
StateLocker: stateLocker,
113+
}
114+
115+
_, _, diags := b.LocalRun(op)
116+
if !diags.HasErrors() {
117+
t.Fatal("unexpected success")
118+
}
119+
120+
// LocalRun() unlocks the state on failure
121+
assertBackendStateUnlocked(t, b)
122+
}
123+
89124
func TestLocalRun_stalePlan(t *testing.T) {
90125
configDir := "./testdata/apply"
91126
b := TestLocal(t)
@@ -146,7 +181,7 @@ func TestLocalRun_stalePlan(t *testing.T) {
146181
if err := planfile.Create(planPath, planfileArgs); err != nil {
147182
t.Fatalf("unexpected error writing planfile: %s", err)
148183
}
149-
planFile, err := planfile.Open(planPath)
184+
planFile, err := planfile.OpenWrapped(planPath)
150185
if err != nil {
151186
t.Fatalf("unexpected error reading planfile: %s", err)
152187
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"remote_plan_format": 1,
3+
"run_id": "run-GXfuHMkbyHccAGUg",
4+
"hostname": "app.terraform.io"
5+
}

internal/backend/remote/backend_apply_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ func TestRemote_applyWithPlan(t *testing.T) {
264264
op, configCleanup, done := testOperationApply(t, "./testdata/apply")
265265
defer configCleanup()
266266

267-
op.PlanFile = &planfile.Reader{}
267+
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
268268
op.Workspace = backend.DefaultStateName
269269

270270
run, err := b.Operation(context.Background(), op)

internal/backend/remote/backend_plan_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ func TestRemote_planWithPlan(t *testing.T) {
239239
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
240240
defer configCleanup()
241241

242-
op.PlanFile = &planfile.Reader{}
242+
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
243243
op.Workspace = backend.DefaultStateName
244244

245245
run, err := b.Operation(context.Background(), op)

internal/cloud/backend_apply.go

Lines changed: 148 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import (
77
"bufio"
88
"context"
99
"encoding/json"
10+
"fmt"
1011
"io"
1112
"log"
13+
"strings"
1214

1315
tfe "github.com/hashicorp/go-tfe"
1416
"github.com/hashicorp/terraform/internal/backend"
@@ -54,12 +56,12 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio
5456
))
5557
}
5658

57-
if op.PlanFile != nil {
59+
if op.PlanFile.IsLocal() {
5860
diags = diags.Append(tfdiags.Sourceless(
5961
tfdiags.Error,
60-
"Applying a saved plan is currently not supported",
61-
`Terraform Cloud currently requires configuration to be present and `+
62-
`does not accept an existing saved plan as an argument at this time.`,
62+
"Applying a saved local plan is not supported",
63+
`Terraform Cloud can apply a saved cloud plan, or create a new plan when `+
64+
`configuration is present. It cannot apply a saved local plan.`,
6365
))
6466
}
6567

@@ -79,59 +81,107 @@ func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operatio
7981
return nil, diags.Err()
8082
}
8183

82-
// Run the plan phase.
83-
r, err := b.plan(stopCtx, cancelCtx, op, w)
84-
if err != nil {
85-
return r, err
86-
}
84+
var r *tfe.Run
85+
var err error
86+
87+
if cp, ok := op.PlanFile.Cloud(); ok {
88+
log.Printf("[TRACE] Loading saved cloud plan for apply")
89+
// Check hostname first, for a more actionable error than a generic 404 later
90+
if cp.Hostname != b.hostname {
91+
diags = diags.Append(tfdiags.Sourceless(
92+
tfdiags.Error,
93+
"Saved plan is for a different hostname",
94+
fmt.Sprintf("The given saved plan refers to a run on %s, but the currently configured Terraform Cloud or Terraform Enterprise instance is %s.", cp.Hostname, b.hostname),
95+
))
96+
return r, diags.Err()
97+
}
98+
// Fetch the run referenced in the saved plan bookmark.
99+
r, err = b.client.Runs.ReadWithOptions(stopCtx, cp.RunID, &tfe.RunReadOptions{
100+
Include: []tfe.RunIncludeOpt{tfe.RunWorkspace},
101+
})
87102

88-
// This check is also performed in the plan method to determine if
89-
// the policies should be checked, but we need to check the values
90-
// here again to determine if we are done and should return.
91-
if !r.HasChanges || r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored {
92-
return r, nil
93-
}
103+
if err != nil {
104+
return r, err
105+
}
94106

95-
// Retrieve the run to get its current status.
96-
r, err = b.client.Runs.Read(stopCtx, r.ID)
97-
if err != nil {
98-
return r, generalError("Failed to retrieve run", err)
99-
}
107+
if r.Workspace.ID != w.ID {
108+
diags = diags.Append(tfdiags.Sourceless(
109+
tfdiags.Error,
110+
"Saved plan is for a different workspace",
111+
fmt.Sprintf("The given saved plan does not refer to a run in the current workspace (%s/%s), so it cannot currently be applied. For more details, view this run in a browser at:\n%s", w.Organization.Name, w.Name, runURL(b.hostname, r.Workspace.Organization.Name, r.Workspace.Name, r.ID)),
112+
))
113+
return r, diags.Err()
114+
}
100115

101-
// Return if the run cannot be confirmed.
102-
if !op.AutoApprove && !r.Actions.IsConfirmable {
103-
return r, nil
104-
}
116+
if !r.Actions.IsConfirmable {
117+
url := runURL(b.hostname, b.organization, op.Workspace, r.ID)
118+
return r, unusableSavedPlanError(r.Status, url)
119+
}
105120

106-
mustConfirm := (op.UIIn != nil && op.UIOut != nil) && !op.AutoApprove
121+
// Since we're not calling plan(), we need to print a run header ourselves:
122+
if b.CLI != nil {
123+
b.CLI.Output(b.Colorize().Color(strings.TrimSpace(applySavedHeader) + "\n"))
124+
b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf(
125+
runHeader, b.hostname, b.organization, r.Workspace.Name, r.ID)) + "\n"))
126+
}
127+
} else {
128+
log.Printf("[TRACE] Running new cloud plan for apply")
129+
// Run the plan phase.
130+
r, err = b.plan(stopCtx, cancelCtx, op, w)
107131

108-
if mustConfirm && b.input {
109-
opts := &terraform.InputOpts{Id: "approve"}
132+
if err != nil {
133+
return r, err
134+
}
110135

111-
if op.PlanMode == plans.DestroyMode {
112-
opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?"
113-
opts.Description = "Terraform will destroy all your managed infrastructure, as shown above.\n" +
114-
"There is no undo. Only 'yes' will be accepted to confirm."
115-
} else {
116-
opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?"
117-
opts.Description = "Terraform will perform the actions described above.\n" +
118-
"Only 'yes' will be accepted to approve."
136+
// This check is also performed in the plan method to determine if
137+
// the policies should be checked, but we need to check the values
138+
// here again to determine if we are done and should return.
139+
if !r.HasChanges || r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored {
140+
return r, nil
119141
}
120142

121-
err = b.confirm(stopCtx, op, opts, r, "yes")
122-
if err != nil && err != errRunApproved {
123-
return r, err
143+
// Retrieve the run to get its current status.
144+
r, err = b.client.Runs.Read(stopCtx, r.ID)
145+
if err != nil {
146+
return r, generalError("Failed to retrieve run", err)
124147
}
125-
} else if mustConfirm && !b.input {
126-
return r, errApplyNeedsUIConfirmation
127-
} else {
128-
// If we don't need to ask for confirmation, insert a blank
129-
// line to separate the ouputs.
130-
if b.CLI != nil {
131-
b.CLI.Output("")
148+
149+
// Return if the run cannot be confirmed.
150+
if !op.AutoApprove && !r.Actions.IsConfirmable {
151+
return r, nil
152+
}
153+
154+
mustConfirm := (op.UIIn != nil && op.UIOut != nil) && !op.AutoApprove
155+
156+
if mustConfirm && b.input {
157+
opts := &terraform.InputOpts{Id: "approve"}
158+
159+
if op.PlanMode == plans.DestroyMode {
160+
opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?"
161+
opts.Description = "Terraform will destroy all your managed infrastructure, as shown above.\n" +
162+
"There is no undo. Only 'yes' will be accepted to confirm."
163+
} else {
164+
opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?"
165+
opts.Description = "Terraform will perform the actions described above.\n" +
166+
"Only 'yes' will be accepted to approve."
167+
}
168+
169+
err = b.confirm(stopCtx, op, opts, r, "yes")
170+
if err != nil && err != errRunApproved {
171+
return r, err
172+
}
173+
} else if mustConfirm && !b.input {
174+
return r, errApplyNeedsUIConfirmation
175+
} else {
176+
// If we don't need to ask for confirmation, insert a blank
177+
// line to separate the ouputs.
178+
if b.CLI != nil {
179+
b.CLI.Output("")
180+
}
132181
}
133182
}
134183

184+
// Do the apply!
135185
if !op.AutoApprove && err != errRunApproved {
136186
if err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}); err != nil {
137187
return r, generalError("Failed to approve the apply command", err)
@@ -222,10 +272,63 @@ func (b *Cloud) renderApplyLogs(ctx context.Context, run *tfe.Run) error {
222272
return nil
223273
}
224274

275+
func runURL(hostname, orgName, wsName, runID string) string {
276+
return fmt.Sprintf("https://%s/app/%s/%s/runs/%s", hostname, orgName, wsName, runID)
277+
}
278+
279+
func unusableSavedPlanError(status tfe.RunStatus, url string) error {
280+
var diags tfdiags.Diagnostics
281+
var summary, reason string
282+
283+
switch status {
284+
case tfe.RunApplied:
285+
summary = "Saved plan is already applied"
286+
reason = "The given plan file was already successfully applied, and cannot be applied again."
287+
case tfe.RunApplying, tfe.RunApplyQueued, tfe.RunConfirmed:
288+
summary = "Saved plan is already confirmed"
289+
reason = "The given plan file is already being applied, and cannot be applied again."
290+
case tfe.RunCanceled:
291+
summary = "Saved plan is canceled"
292+
reason = "The given plan file can no longer be applied because the run was canceled via the Terraform Cloud UI or API."
293+
case tfe.RunDiscarded:
294+
summary = "Saved plan is discarded"
295+
reason = "The given plan file can no longer be applied; either another run was applied first, or a user discarded it via the Terraform Cloud UI or API."
296+
case tfe.RunErrored:
297+
summary = "Saved plan is errored"
298+
reason = "The given plan file refers to a plan that had errors and did not complete successfully. It cannot be applied."
299+
case tfe.RunPlannedAndFinished:
300+
// Note: planned and finished can also indicate a plan-only run, but
301+
// terraform plan can't create a saved plan for a plan-only run, so we
302+
// know it's no-changes in this case.
303+
summary = "Saved plan has no changes"
304+
reason = "The given plan file contains no changes, so it cannot be applied."
305+
case tfe.RunPolicyOverride:
306+
summary = "Saved plan requires policy override"
307+
reason = "The given plan file has soft policy failures, and cannot be applied until a user with appropriate permissions overrides the policy check."
308+
default:
309+
summary = "Saved plan cannot be applied"
310+
reason = "Terraform Cloud cannot apply the given plan file. This may mean the plan and checks have not yet completed, or may indicate another problem."
311+
}
312+
313+
diags = diags.Append(tfdiags.Sourceless(
314+
tfdiags.Error,
315+
summary,
316+
fmt.Sprintf("%s For more details, view this run in a browser at:\n%s", reason, url),
317+
))
318+
return diags.Err()
319+
}
320+
225321
const applyDefaultHeader = `
226322
[reset][yellow]Running apply in Terraform Cloud. Output will stream here. Pressing Ctrl-C
227323
will cancel the remote apply if it's still pending. If the apply started it
228324
will stop streaming the logs, but will not stop the apply running remotely.[reset]
229325
230326
Preparing the remote apply...
231327
`
328+
329+
const applySavedHeader = `
330+
[reset][yellow]Running apply in Terraform Cloud. Output will stream here. Pressing Ctrl-C
331+
will stop streaming the logs, but will not stop the apply running remotely.[reset]
332+
333+
Preparing the remote apply...
334+
`

0 commit comments

Comments
 (0)