Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Always show and store planned actions and checks even when planning fails #32395

Merged
merged 6 commits into from
Jan 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
25 changes: 25 additions & 0 deletions internal/backend/local/backend_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ func (b *Local) opApply(
plan, moreDiags = lr.Core.Plan(lr.Config, lr.InputState, lr.PlanOpts)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
// If Terraform Core generated a partial plan despite the errors
// then we'll make a best effort to render it. Terraform Core
// promises that if it returns a non-nil plan along with errors
// then the plan won't necessarily contain all of the needed
// actions but that any it does include will be properly-formed.
// plan.Errored will be true in this case, which our plan
// renderer can rely on to tailor its messaging.
if plan != nil && (len(plan.Changes.Resources) != 0 || len(plan.Changes.Outputs) != 0) {
schemas, moreDiags := lr.Core.Schemas(lr.Config, lr.InputState)
// If schema loading returns errors then we'll just give up and
// ignore them to avoid distracting from the plan-time errors we're
// mainly trying to report here.
if !moreDiags.HasErrors() {
op.View.Plan(plan, schemas)
}
}
op.ReportResult(runningOp, diags)
return
}
Expand Down Expand Up @@ -162,6 +178,15 @@ func (b *Local) opApply(
}
} else {
plan = lr.Plan
if plan.Errored {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Cannot apply incomplete plan",
"Terraform encountered an error when generating this plan, so it cannot be applied.",
))
op.ReportResult(runningOp, diags)
return
}
for _, change := range plan.Changes.Resources {
if change.Action != plans.NoOp {
op.View.PlannedChange(change)
Expand Down
36 changes: 20 additions & 16 deletions internal/backend/local/backend_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,16 @@ func (b *Local) opPlan(
}
log.Printf("[INFO] backend/local: plan operation completed")

// NOTE: We intentionally don't stop here on errors because we always want
// to try to present a partial plan report and, if the user chose to,
// generate a partial saved plan file for external analysis.
diags = diags.Append(planDiags)
if planDiags.HasErrors() {
op.ReportResult(runningOp, diags)
return
}

// Record whether this plan includes any side-effects that could be applied.
runningOp.PlanEmpty = !plan.CanApply()

// Save the plan to disk
if path := op.PlanOutPath; path != "" {
if path := op.PlanOutPath; path != "" && plan != nil {
if op.PlanOutBackend == nil {
// This is always a bug in the operation caller; it's not valid
// to set PlanOutPath without also setting PlanOutBackend.
Expand Down Expand Up @@ -153,19 +152,24 @@ func (b *Local) opPlan(
}
}

// Render the plan
schemas, moreDiags := lr.Core.Schemas(lr.Config, lr.InputState)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
op.ReportResult(runningOp, diags)
return
// Render the plan, if we produced one.
// (This might potentially be a partial plan with Errored set to true)
if plan != nil {
schemas, moreDiags := lr.Core.Schemas(lr.Config, lr.InputState)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
op.ReportResult(runningOp, diags)
return
}
op.View.Plan(plan, schemas)
}
op.View.Plan(plan, schemas)

// If we've accumulated any warnings along the way then we'll show them
// here just before we show the summary and next steps. If we encountered
// errors then we would've returned early at some other point above.
op.View.Diagnostics(diags)
// If we've accumulated any diagnostics along the way then we'll show them
// here just before we show the summary and next steps. This can potentially
// include errors, because we intentionally try to show a partial plan
// above even if Terraform Core encountered an error partway through
// creating it.
op.ReportResult(runningOp, diags)

if !runningOp.PlanEmpty {
op.View.PlanNextStep(op.PlanOutPath)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we still call this "next step" view function even if the plan errored? Maybe we should pass the errored status (or the entire plan) to the function so that it doesn't say "you can apply this saved plan".

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PlanEmpty isn't set if there are errors, so keeps the zero value and this won't be called. There's still some more refinement to do in the CLI around errors, so we may need to revisit this anyway, but for now it doesn't interfere with the POC.

Expand Down
49 changes: 49 additions & 0 deletions internal/command/plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/hashicorp/terraform/internal/addrs"
backendinit "github.com/hashicorp/terraform/internal/backend/init"
"github.com/hashicorp/terraform/internal/checks"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/providers"
Expand Down Expand Up @@ -275,6 +276,54 @@ func TestPlan_outPathNoChange(t *testing.T) {
}
}

func TestPlan_outPathWithError(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("plan-fail-condition"), td)
defer testChdir(t, td)()

outPath := filepath.Join(td, "test.plan")

p := planFixtureProvider()
view, done := testView(t)
c := &PlanCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
View: view,
},
}

p.PlanResourceChangeResponse = &providers.PlanResourceChangeResponse{
PlannedState: cty.NullVal(cty.EmptyObject),
}

args := []string{
"-out", outPath,
}
code := c.Run(args)
output := done(t)
if code == 0 {
t.Fatal("expected non-zero exit status", output)
}

plan := testReadPlan(t, outPath) // will call t.Fatal itself if the file cannot be read
if !plan.Errored {
t.Fatal("plan should be marked with Errored")
}

if plan.Checks == nil {
t.Fatal("plan contains no checks")
}

// the checks should only contain one failure
results := plan.Checks.ConfigResults.Elements()
if len(results) != 1 {
t.Fatal("incorrect number of check results", len(results))
}
if results[0].Value.Status != checks.StatusFail {
t.Errorf("incorrect status, got %s", results[0].Value.Status)
}
}

// When using "-out" with a backend, the plan should encode the backend config
func TestPlan_outBackend(t *testing.T) {
// Create a temporary working directory that is empty
Expand Down
15 changes: 15 additions & 0 deletions internal/command/testdata/plan-fail-condition/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
locals {
ami = "bar"
}

resource "test_instance" "foo" {
ami = local.ami

lifecycle {
precondition {
// failing condition
condition = local.ami != "bar"
error_message = "ami is bar"
}
}
}
174 changes: 96 additions & 78 deletions internal/command/views/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,79 +138,89 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
// the plan is "applyable" and, if so, whether it had refresh changes
// that we already would've presented above.

switch plan.UIMode {
case plans.RefreshOnlyMode:
if haveRefreshChanges {
// We already generated a sufficient prompt about what will
// happen if applying this change above, so we don't need to
// say anything more.
return
}

view.streams.Print(
view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] Your infrastructure still matches the configuration.[reset]\n\n"),
)
view.streams.Println(format.WordWrap(
"Terraform has checked that the real remote objects still match the result of your most recent changes, and found no differences.",
view.outputColumns(),
))

case plans.DestroyMode:
if plan.Errored {
if haveRefreshChanges {
view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns()))
view.streams.Println("")
}
view.streams.Print(
view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] No objects need to be destroyed.[reset]\n\n"),
view.colorize.Color("\n[reset][bold][red]Planning failed.[reset][bold] Terraform encountered an error while generating this plan.[reset]\n\n"),
)
view.streams.Println(format.WordWrap(
"Either you have not created any objects yet or the existing objects were already deleted outside of Terraform.",
view.outputColumns(),
))
} else {
switch plan.UIMode {
case plans.RefreshOnlyMode:
if haveRefreshChanges {
// We already generated a sufficient prompt about what will
// happen if applying this change above, so we don't need to
// say anything more.
return
}

default:
if haveRefreshChanges {
view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns()))
view.streams.Println("")
}
view.streams.Print(
view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] Your infrastructure matches the configuration.[reset]\n\n"),
)
view.streams.Print(
view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] Your infrastructure still matches the configuration.[reset]\n\n"),
)
view.streams.Println(format.WordWrap(
"Terraform has checked that the real remote objects still match the result of your most recent changes, and found no differences.",
view.outputColumns(),
))

case plans.DestroyMode:
if haveRefreshChanges {
view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns()))
view.streams.Println("")
}
view.streams.Print(
view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] No objects need to be destroyed.[reset]\n\n"),
)
view.streams.Println(format.WordWrap(
"Either you have not created any objects yet or the existing objects were already deleted outside of Terraform.",
view.outputColumns(),
))

if haveRefreshChanges {
if plan.CanApply() {
// In this case, applying this plan will not change any
// remote objects but _will_ update the state to match what
// we detected during refresh, so we'll reassure the user
// about that.
view.streams.Println(format.WordWrap(
"Your configuration already matches the changes detected above, so applying this plan will only update the state to include the changes detected above and won't change any real infrastructure.",
view.outputColumns(),
))
} else {
// In this case we detected changes during refresh but this isn't
// a planning mode where we consider those to be applyable. The
// user must re-run in refresh-only mode in order to update the
// state to match the upstream changes.
suggestion := "."
if !view.runningInAutomation {
// The normal message includes a specific command line to run.
suggestion = ":\n terraform apply -refresh-only"
default:
if haveRefreshChanges {
view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns()))
view.streams.Println("")
}
view.streams.Print(
view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] Your infrastructure matches the configuration.[reset]\n\n"),
)

if haveRefreshChanges {
if plan.CanApply() {
// In this case, applying this plan will not change any
// remote objects but _will_ update the state to match what
// we detected during refresh, so we'll reassure the user
// about that.
view.streams.Println(format.WordWrap(
"Your configuration already matches the changes detected above, so applying this plan will only update the state to include the changes detected above and won't change any real infrastructure.",
view.outputColumns(),
))
} else {
// In this case we detected changes during refresh but this isn't
// a planning mode where we consider those to be applyable. The
// user must re-run in refresh-only mode in order to update the
// state to match the upstream changes.
suggestion := "."
if !view.runningInAutomation {
// The normal message includes a specific command line to run.
suggestion = ":\n terraform apply -refresh-only"
}
view.streams.Println(format.WordWrap(
"Your configuration already matches the changes detected above. If you'd like to update the Terraform state to match, create and apply a refresh-only plan"+suggestion,
view.outputColumns(),
))
}
view.streams.Println(format.WordWrap(
"Your configuration already matches the changes detected above. If you'd like to update the Terraform state to match, create and apply a refresh-only plan"+suggestion,
view.outputColumns(),
))
return
}
return
}

// If we get down here then we're just in the simple situation where
// the plan isn't applyable at all.
view.streams.Println(format.WordWrap(
"Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.",
view.outputColumns(),
))
// If we get down here then we're just in the simple situation where
// the plan isn't applyable at all.
view.streams.Println(format.WordWrap(
"Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.",
view.outputColumns(),
))
}
}
return
}
Expand Down Expand Up @@ -245,7 +255,11 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
}

if len(rChanges) > 0 {
view.streams.Printf("\nTerraform will perform the following actions:\n\n")
if plan.Errored {
view.streams.Printf("\nTerraform planned the following actions, but then encountered a problem:\n\n")
} else {
view.streams.Printf("\nTerraform will perform the following actions:\n\n")
}

// Note: we're modifying the backing slice of this plan object in-place
// here. The ordering of resource changes in a plan is not significant,
Expand Down Expand Up @@ -286,23 +300,27 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
))
}

// stats is similar to counts above, but:
// - it considers only resource changes
// - it simplifies "replace" into both a create and a delete
stats := map[plans.Action]int{}
for _, change := range rChanges {
switch change.Action {
case plans.CreateThenDelete, plans.DeleteThenCreate:
stats[plans.Create]++
stats[plans.Delete]++
default:
stats[change.Action]++
if plan.Errored {
view.streams.Printf("This plan is incomplete and therefore cannot be applied.\n\n")
} else {
// stats is similar to counts above, but:
// - it considers only resource changes
// - it simplifies "replace" into both a create and a delete
stats := map[plans.Action]int{}
for _, change := range rChanges {
switch change.Action {
case plans.CreateThenDelete, plans.DeleteThenCreate:
stats[plans.Create]++
stats[plans.Delete]++
default:
stats[change.Action]++
}
}
view.streams.Printf(
view.colorize.Color("[reset][bold]Plan:[reset] %d to add, %d to change, %d to destroy.\n"),
stats[plans.Create], stats[plans.Update], stats[plans.Delete],
)
}
view.streams.Printf(
view.colorize.Color("[reset][bold]Plan:[reset] %d to add, %d to change, %d to destroy.\n"),
stats[plans.Create], stats[plans.Update], stats[plans.Delete],
)
}

// If there is at least one planned change to the root module outputs
Expand Down