command: Prototype of handling errored plans

This is a prototype of how the CLI layer might make use of Terraform
Core's ability to produce a partial plan if it encounters an error during
planning, with two new situations:

- When using local CLI workflow, Terraform will show the partial plan
  before showing any errors.
- "terraform plan" has a new option -always-out=..., which is similar to
  the existing -out=... but additionally instructs Terraform to produce
  a plan file even if the plan is incomplete due to errors. This means
  that the plan can still be inspected by external UI implementations.

This is just a prototype to explore how these parts might fit together.
It's not a complete implementation and so should not be shipped. In
particular, it doesn't include any mention of a plan being incomplete in
the "terraform show -json" output or in the "terraform plan -json" output,
both of which would be required for a complete solution.
This commit is contained in:
Martin Atkins 2022-09-30 11:02:36 -07:00 committed by James Bardin
parent 6d9ddbacec
commit 4660dacd59
7 changed files with 212 additions and 102 deletions

View File

@ -230,6 +230,7 @@ type Operation struct {
PlanRefresh bool // PlanRefresh will do a refresh before a plan
PlanOutPath string // PlanOutPath is the path to save the plan
PlanOutBackend *plans.Backend
PlanOutAlways bool // Produce PlanOutPath even if plan is incomplete
// ConfigDir is the path to the directory containing the configuration's
// root module.

View File

@ -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
}
@ -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)

View File

@ -95,17 +95,21 @@ 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()
planErrored := false
if plan != nil {
planErrored = plan.Errored
}
// Save the plan to disk
if path := op.PlanOutPath; path != "" {
if path := op.PlanOutPath; path != "" && plan != nil && (op.PlanOutAlways || !planErrored) {
if op.PlanOutBackend == nil {
// This is always a bug in the operation caller; it's not valid
// to set PlanOutPath without also setting PlanOutBackend.
@ -153,7 +157,9 @@ func (b *Local) opPlan(
}
}
// Render the plan
// 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() {
@ -161,11 +167,14 @@ func (b *Local) opPlan(
return
}
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)

View File

@ -19,8 +19,12 @@ type Plan struct {
// variable and backend config values. Default is true.
InputEnabled bool
// OutPath contains an optional path to store the plan file
// OutPath contains an optional path to store the plan file, while
// AlwaysOut means that we'll write to OutPath even if the plan is
// incomplete, so that it's still possible to inspect it with
// "terraform show". AlwaysOut is irrelevant if OutPath isn't set.
OutPath string
AlwaysOut bool
// ViewType specifies which output format to use
ViewType ViewType
@ -37,10 +41,13 @@ func ParsePlan(args []string) (*Plan, tfdiags.Diagnostics) {
Vars: &Vars{},
}
var outPath, alwaysOutPath string
cmdFlags := extendedFlagSet("plan", plan.State, plan.Operation, plan.Vars)
cmdFlags.BoolVar(&plan.DetailedExitCode, "detailed-exitcode", false, "detailed-exitcode")
cmdFlags.BoolVar(&plan.InputEnabled, "input", true, "input")
cmdFlags.StringVar(&plan.OutPath, "out", "", "out")
cmdFlags.StringVar(&outPath, "out", "", "out")
cmdFlags.StringVar(&alwaysOutPath, "always-out", "", "always-out")
var json bool
cmdFlags.BoolVar(&json, "json", false, "json")
@ -53,6 +60,22 @@ func ParsePlan(args []string) (*Plan, tfdiags.Diagnostics) {
))
}
switch {
case outPath != "":
if alwaysOutPath != "" {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Incompatible command line options",
"The -out=... and -always-out=... options are mutually-exclusive.",
))
}
plan.OutPath = outPath
plan.AlwaysOut = false
case alwaysOutPath != "":
plan.OutPath = alwaysOutPath
plan.AlwaysOut = true
}
args = cmdFlags.Args()
if len(args) > 0 {

View File

@ -31,12 +31,30 @@ func TestParsePlan_basicValid(t *testing.T) {
},
},
},
"setting all options": {
"setting all options with -out": {
[]string{"-destroy", "-detailed-exitcode", "-input=false", "-out=saved.tfplan"},
&Plan{
DetailedExitCode: true,
InputEnabled: false,
OutPath: "saved.tfplan",
AlwaysOut: false,
ViewType: ViewHuman,
State: &State{Lock: true},
Vars: &Vars{},
Operation: &Operation{
PlanMode: plans.DestroyMode,
Parallelism: 10,
Refresh: true,
},
},
},
"setting all options with -always-out": {
[]string{"-destroy", "-detailed-exitcode", "-input=false", "-always-out=saved.tfplan"},
&Plan{
DetailedExitCode: true,
InputEnabled: false,
OutPath: "saved.tfplan",
AlwaysOut: true,
ViewType: ViewHuman,
State: &State{Lock: true},
Vars: &Vars{},
@ -93,6 +111,20 @@ func TestParsePlan_invalid(t *testing.T) {
}
}
func TestParsePlan_conflictingOutPath(t *testing.T) {
got, diags := ParsePlan([]string{"-out=foo", "-always-out=bar"})
if len(diags) == 0 {
t.Fatal("expected diags but got none")
}
const wantSubstr = "Incompatible command line options: The -out=... and -always-out=... options are mutually-exclusive."
if got, want := diags.Err().Error(), wantSubstr; !strings.Contains(got, want) {
t.Fatalf("wrong diags\ngot: %s\nwant: %s", got, want)
}
if got.ViewType != ViewHuman {
t.Fatalf("wrong view type\ngot: %#v\nwant: %#v", got.ViewType, ViewHuman)
}
}
func TestParsePlan_tooManyArguments(t *testing.T) {
got, diags := ParsePlan([]string{"saved.tfplan"})
if len(diags) == 0 {

View File

@ -72,7 +72,7 @@ func (c *PlanCommand) Run(rawArgs []string) int {
}
// Build the operation request
opReq, opDiags := c.OperationRequest(be, view, args.Operation, args.OutPath)
opReq, opDiags := c.OperationRequest(be, view, args.Operation, args.OutPath, args.AlwaysOut)
diags = diags.Append(opDiags)
if diags.HasErrors() {
view.Diagnostics(diags)
@ -139,6 +139,7 @@ func (c *PlanCommand) OperationRequest(
view views.Plan,
args *arguments.Operation,
planOutPath string,
planOutAlways bool,
) (*backend.Operation, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
@ -149,6 +150,7 @@ func (c *PlanCommand) OperationRequest(
opReq.Hooks = view.Hooks()
opReq.PlanRefresh = args.Refresh
opReq.PlanOutPath = planOutPath
opReq.PlanOutAlways = planOutAlways
opReq.Targets = args.Targets
opReq.ForceReplace = args.ForceReplace
opReq.Type = backend.OperationTypePlan

View File

@ -138,6 +138,15 @@ 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.
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][red]Planning failed.[reset][bold] Terraform encountered an error while generating this plan.[reset]\n\n"),
)
} else {
switch plan.UIMode {
case plans.RefreshOnlyMode:
if haveRefreshChanges {
@ -212,6 +221,7 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
view.outputColumns(),
))
}
}
return
}
if haveRefreshChanges {
@ -245,7 +255,11 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
}
if len(rChanges) > 0 {
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,
@ -286,6 +300,9 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
))
}
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
@ -304,6 +321,7 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
stats[plans.Create], stats[plans.Update], stats[plans.Delete],
)
}
}
// If there is at least one planned change to the root module outputs
// then we'll render a summary of those too.