diff --git a/backend/local/backend_plan.go b/backend/local/backend_plan.go index 6d2639d323..a50426f3a1 100644 --- a/backend/local/backend_plan.go +++ b/backend/local/backend_plan.go @@ -114,12 +114,14 @@ func (b *Local) opPlan( // Save the plan to disk if path := op.PlanOutPath; path != "" { - if op.PlanOutBackend != nil { - plan.Backend = *op.PlanOutBackend - } else { - op.PlanOutBackend = &plans.Backend{} - plan.Backend = *op.PlanOutBackend + if op.PlanOutBackend == nil { + // This is always a bug in the operation caller; it's not valid + // to set PlanOutPath without also setting PlanOutBackend. + diags = diags.Append(fmt.Errorf("PlanOutPath set without also setting PlanOutBackend (this is a bug in Terraform)")) + b.ReportResult(runningOp, diags) + return } + plan.Backend = *op.PlanOutBackend // We may have updated the state in the refresh step above, but we // will freeze that updated state in the plan file for now and diff --git a/backend/local/backend_plan_test.go b/backend/local/backend_plan_test.go index 7ce9e84521..541a58ad8a 100644 --- a/backend/local/backend_plan_test.go +++ b/backend/local/backend_plan_test.go @@ -174,6 +174,18 @@ func TestLocal_planDestroy(t *testing.T) { op.Destroy = true op.PlanRefresh = true op.PlanOutPath = planPath + cfg := cty.ObjectVal(map[string]cty.Value{ + "path": cty.StringVal(b.StatePath), + }) + cfgRaw, err := plans.NewDynamicValue(cfg, cfg.Type()) + if err != nil { + t.Fatal(err) + } + op.PlanOutBackend = &plans.Backend{ + // Just a placeholder so that we can generate a valid plan file. + Type: "local", + Config: cfgRaw, + } run, err := b.Operation(context.Background(), op) if err != nil { @@ -213,6 +225,18 @@ func TestLocal_planOutPathNoChange(t *testing.T) { op, configCleanup := testOperationPlan(t, "./test-fixtures/plan") defer configCleanup() op.PlanOutPath = planPath + cfg := cty.ObjectVal(map[string]cty.Value{ + "path": cty.StringVal(b.StatePath), + }) + cfgRaw, err := plans.NewDynamicValue(cfg, cfg.Type()) + if err != nil { + t.Fatal(err) + } + op.PlanOutBackend = &plans.Backend{ + // Just a placeholder so that we can generate a valid plan file. + Type: "local", + Config: cfgRaw, + } run, err := b.Operation(context.Background(), op) if err != nil { @@ -314,6 +338,8 @@ func testPlanState() *states.State { } func testReadPlan(t *testing.T, path string) *plans.Plan { + t.Helper() + p, err := planfile.Open(path) if err != nil { t.Fatalf("err: %s", err) diff --git a/backend/local/testing.go b/backend/local/testing.go index 8b4710c22a..9266c9ccbe 100644 --- a/backend/local/testing.go +++ b/backend/local/testing.go @@ -34,6 +34,10 @@ func TestLocal(t *testing.T) (*Local, func()) { var diags tfdiags.Diagnostics diags = diags.Append(vals...) for _, diag := range diags { + // NOTE: Since the caller here is not directly the TestLocal + // function, t.Helper doesn't apply and so the log source + // isn't correctly shown in the test log output. This seems + // unavoidable as long as this is happening so indirectly. t.Log(diag.Description().Summary) if local.CLI != nil { local.CLI.Error(diag.Description().Summary) diff --git a/plans/planfile/tfplan.go b/plans/planfile/tfplan.go index 2db909b9fb..e1deeb083a 100644 --- a/plans/planfile/tfplan.go +++ b/plans/planfile/tfplan.go @@ -351,6 +351,13 @@ func writeTfplan(plan *plans.Plan, w io.Writer) error { rawPlan.Variables[name] = valueToTfplan(val) } + if plan.Backend.Type == "" || plan.Backend.Config == nil { + // This suggests a bug in the code that created the plan, since it + // ought to always have a backend populated, even if it's the default + // "local" backend with a local state file. + return fmt.Errorf("plan does not have a backend configuration") + } + rawPlan.Backend = &planproto.Backend{ Type: plan.Backend.Type, Config: valueToTfplan(plan.Backend.Config),