Skip to content

Commit

Permalink
Apply a confirmable run when given a saved cloud plan (#33270)
Browse files Browse the repository at this point in the history
It displays a run header with link to web UI, like starting a new plan does, then confirms the run 
and streams the apply logs. If you can't apply the run (it's from a different workspace, is in an
unconfirmable state, etc. etc.), it displays an error instead.

Notable points along the way:

* Implement `WrappedPlanFile` sum type, and update planfile consumers to use it instead of a plain `planfile.Reader`.

* Enable applying a saved cloud plan

* Update TFC mocks — add org name to workspace, and minimal support for includes on MockRuns.ReadWithOptions.
  • Loading branch information
nfagerlund authored Jun 21, 2023
1 parent 74e3132 commit f30bbdb
Show file tree
Hide file tree
Showing 19 changed files with 459 additions and 90 deletions.
2 changes: 1 addition & 1 deletion internal/backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ type Operation struct {

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

// The options below are more self-explanatory and affect the runtime
// behavior of the operation.
Expand Down
9 changes: 7 additions & 2 deletions internal/backend/local/backend_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,12 @@ func (b *Local) localRun(op *backend.Operation) (*backend.LocalRun, *configload.

var ctxDiags tfdiags.Diagnostics
var configSnap *configload.Snapshot
if op.PlanFile != nil {
if op.PlanFile.IsCloud() {
diags = diags.Append(fmt.Errorf("error: using a saved cloud plan with the local backend is not supported"))
return nil, nil, nil, diags
}

if lp, ok := op.PlanFile.Local(); ok {
var stateMeta *statemgr.SnapshotMeta
// If the statemgr implements our optional PersistentMeta interface then we'll
// additionally verify that the state snapshot in the plan file has
Expand All @@ -87,7 +92,7 @@ func (b *Local) localRun(op *backend.Operation) (*backend.LocalRun, *configload.
stateMeta = &m
}
log.Printf("[TRACE] backend/local: populating backend.LocalRun from plan file")
ret, configSnap, ctxDiags = b.localRunForPlanFile(op, op.PlanFile, ret, &coreOpts, stateMeta)
ret, configSnap, ctxDiags = b.localRunForPlanFile(op, lp, ret, &coreOpts, stateMeta)
if ctxDiags.HasErrors() {
diags = diags.Append(ctxDiags)
return nil, nil, nil, diags
Expand Down
37 changes: 36 additions & 1 deletion internal/backend/local/backend_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,41 @@ func TestLocalRun_error(t *testing.T) {
assertBackendStateUnlocked(t, b)
}

func TestLocalRun_cloudPlan(t *testing.T) {
configDir := "./testdata/apply"
b := TestLocal(t)

_, configLoader, configCleanup := initwd.MustLoadConfigForTests(t, configDir)
defer configCleanup()

planPath := "../../cloud/cloudplan/testdata/plan-bookmark/bookmark.json"

planFile, err := planfile.OpenWrapped(planPath)
if err != nil {
t.Fatalf("unexpected error reading planfile: %s", err)
}

streams, _ := terminal.StreamsForTesting(t)
view := views.NewView(streams)
stateLocker := clistate.NewLocker(0, views.NewStateLocker(arguments.ViewHuman, view))

op := &backend.Operation{
ConfigDir: configDir,
ConfigLoader: configLoader,
PlanFile: planFile,
Workspace: backend.DefaultStateName,
StateLocker: stateLocker,
}

_, _, diags := b.LocalRun(op)
if !diags.HasErrors() {
t.Fatal("unexpected success")
}

// LocalRun() unlocks the state on failure
assertBackendStateUnlocked(t, b)
}

func TestLocalRun_stalePlan(t *testing.T) {
configDir := "./testdata/apply"
b := TestLocal(t)
Expand Down Expand Up @@ -146,7 +181,7 @@ func TestLocalRun_stalePlan(t *testing.T) {
if err := planfile.Create(planPath, planfileArgs); err != nil {
t.Fatalf("unexpected error writing planfile: %s", err)
}
planFile, err := planfile.Open(planPath)
planFile, err := planfile.OpenWrapped(planPath)
if err != nil {
t.Fatalf("unexpected error reading planfile: %s", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/backend/remote/backend_apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ func TestRemote_applyWithPlan(t *testing.T) {
op, configCleanup, done := testOperationApply(t, "./testdata/apply")
defer configCleanup()

op.PlanFile = &planfile.Reader{}
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
op.Workspace = backend.DefaultStateName

run, err := b.Operation(context.Background(), op)
Expand Down
2 changes: 1 addition & 1 deletion internal/backend/remote/backend_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ func TestRemote_planWithPlan(t *testing.T) {
op, configCleanup, done := testOperationPlan(t, "./testdata/plan")
defer configCleanup()

op.PlanFile = &planfile.Reader{}
op.PlanFile = planfile.NewWrappedLocal(&planfile.Reader{})
op.Workspace = backend.DefaultStateName

run, err := b.Operation(context.Background(), op)
Expand Down
193 changes: 148 additions & 45 deletions internal/cloud/backend_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"strings"

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

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

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

// Run the plan phase.
r, err := b.plan(stopCtx, cancelCtx, op, w)
if err != nil {
return r, err
}
var r *tfe.Run
var err error

if cp, ok := op.PlanFile.Cloud(); ok {
log.Printf("[TRACE] Loading saved cloud plan for apply")
// Check hostname first, for a more actionable error than a generic 404 later
if cp.Hostname != b.hostname {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Saved plan is for a different hostname",
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),
))
return r, diags.Err()
}
// Fetch the run referenced in the saved plan bookmark.
r, err = b.client.Runs.ReadWithOptions(stopCtx, cp.RunID, &tfe.RunReadOptions{
Include: []tfe.RunIncludeOpt{tfe.RunWorkspace},
})

// This check is also performed in the plan method to determine if
// the policies should be checked, but we need to check the values
// here again to determine if we are done and should return.
if !r.HasChanges || r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored {
return r, nil
}
if err != nil {
return r, err
}

// Retrieve the run to get its current status.
r, err = b.client.Runs.Read(stopCtx, r.ID)
if err != nil {
return r, generalError("Failed to retrieve run", err)
}
if r.Workspace.ID != w.ID {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Saved plan is for a different workspace",
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)),
))
return r, diags.Err()
}

// Return if the run cannot be confirmed.
if !op.AutoApprove && !r.Actions.IsConfirmable {
return r, nil
}
if !r.Actions.IsConfirmable {
url := runURL(b.hostname, b.organization, op.Workspace, r.ID)
return r, unusableSavedPlanError(r.Status, url)
}

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

if mustConfirm && b.input {
opts := &terraform.InputOpts{Id: "approve"}
if err != nil {
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."
// This check is also performed in the plan method to determine if
// the policies should be checked, but we need to check the values
// here again to determine if we are done and should return.
if !r.HasChanges || r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored {
return r, nil
}

err = b.confirm(stopCtx, op, opts, r, "yes")
if err != nil && err != errRunApproved {
return r, err
// Retrieve the run to get its current status.
r, err = b.client.Runs.Read(stopCtx, r.ID)
if err != nil {
return r, generalError("Failed to retrieve run", err)
}
} else if mustConfirm && !b.input {
return r, errApplyNeedsUIConfirmation
} 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("")

// Return if the run cannot be confirmed.
if !op.AutoApprove && !r.Actions.IsConfirmable {
return r, nil
}

mustConfirm := (op.UIIn != nil && op.UIOut != nil) && !op.AutoApprove

if mustConfirm && b.input {
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
}
} else if mustConfirm && !b.input {
return r, errApplyNeedsUIConfirmation
} 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("")
}
}
}

// Do the apply!
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)
Expand Down Expand Up @@ -222,10 +272,63 @@ func (b *Cloud) renderApplyLogs(ctx context.Context, run *tfe.Run) error {
return nil
}

func runURL(hostname, orgName, wsName, runID string) string {
return fmt.Sprintf("https://%s/app/%s/%s/runs/%s", hostname, orgName, wsName, runID)
}

func unusableSavedPlanError(status tfe.RunStatus, url string) error {
var diags tfdiags.Diagnostics
var summary, reason string

switch status {
case tfe.RunApplied:
summary = "Saved plan is already applied"
reason = "The given plan file was already successfully applied, and cannot be applied again."
case tfe.RunApplying, tfe.RunApplyQueued, tfe.RunConfirmed:
summary = "Saved plan is already confirmed"
reason = "The given plan file is already being applied, and cannot be applied again."
case tfe.RunCanceled:
summary = "Saved plan is canceled"
reason = "The given plan file can no longer be applied because the run was canceled via the Terraform Cloud UI or API."
case tfe.RunDiscarded:
summary = "Saved plan is discarded"
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."
case tfe.RunErrored:
summary = "Saved plan is errored"
reason = "The given plan file refers to a plan that had errors and did not complete successfully. It cannot be applied."
case tfe.RunPlannedAndFinished:
// Note: planned and finished can also indicate a plan-only run, but
// terraform plan can't create a saved plan for a plan-only run, so we
// know it's no-changes in this case.
summary = "Saved plan has no changes"
reason = "The given plan file contains no changes, so it cannot be applied."
case tfe.RunPolicyOverride:
summary = "Saved plan requires policy override"
reason = "The given plan file has soft policy failures, and cannot be applied until a user with appropriate permissions overrides the policy check."
default:
summary = "Saved plan cannot be applied"
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."
}

diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
summary,
fmt.Sprintf("%s For more details, view this run in a browser at:\n%s", reason, url),
))
return diags.Err()
}

const applyDefaultHeader = `
[reset][yellow]Running apply in Terraform Cloud. Output will stream here. Pressing Ctrl-C
will cancel the remote apply if it's still pending. If the apply started it
will stop streaming the logs, but will not stop the apply running remotely.[reset]
Preparing the remote apply...
`

const applySavedHeader = `
[reset][yellow]Running apply in Terraform Cloud. Output will stream here. Pressing Ctrl-C
will stop streaming the logs, but will not stop the apply running remotely.[reset]
Preparing the remote apply...
`
Loading

0 comments on commit f30bbdb

Please sign in to comment.