diff --git a/internal/backend/local/backend_apply.go b/internal/backend/local/backend_apply.go index 2a0a4d38ba..5c2fa59b24 100644 --- a/internal/backend/local/backend_apply.go +++ b/internal/backend/local/backend_apply.go @@ -11,6 +11,7 @@ import ( "time" "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend/backendrun" @@ -325,23 +326,41 @@ func (b *Local) opApply( // its value on to the ApplyOpts. applyTimeValues[varName] = val } else { - // TODO: We should probably actually tolerate this if the new - // value is equal to the value that was saved in the plan, since - // that'd make it possible to, for example, reuse a .tfvars file - // containing a mixture of ephemeral and non-ephemeral definitions - // during the apply phase, rather than having to split ephemeral - // and non-ephemeral definitions into separate files. For initial - // experiment we'll keep things a little simpler, though, and - // just skip this check if we're doing a combined plan/apply where - // the apply phase will therefore always have exactly the same - // inputs as the plan phase. - - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Can't set variable when applying a saved plan", - Detail: fmt.Sprintf("The variable %s cannot be set using the -var and -var-file options when applying a saved plan file, because a saved plan includes the variable values that were set when it was created. To declare an ephemeral variable which is not saved in the plan file, use ephemeral = true.", varName), - Subject: rng, - }) + // If a non-ephemeral variable is set differently between plan and apply, we should emit a diagnostic. + value, ok := plan.VariableValues[varName] + if !ok { + if v.Value.IsNull() { + continue + } else { + // TODO: Add test for this case + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Can't set variable when applying a saved plan", + Detail: fmt.Sprintf("The variable %s cannot be set using the -var and -var-file options when applying a saved plan file, because a saved plan includes the variable values that were set when it was created. To declare an ephemeral variable which is not saved in the plan file, use ephemeral = true.", varName), + Subject: rng, + }) + } + } + + val, err := value.Decode(cty.DynamicPseudoType) + if err != nil { + // TODO: Reword error message + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Variable was set with a different type when applying a saved plan", + Detail: fmt.Sprintf("The variable %s was set to a different type of value during plan than during apply. Please either don't supply the value or supply the same value if the variable.", varName), + Subject: rng, + }) + } else { + if v.Value.Equals(val) == cty.False { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Can't set variable when applying a saved plan", + Detail: fmt.Sprintf("The variable %s cannot be set using the -var and -var-file options when applying a saved plan file, because a saved plan includes the variable values that were set when it was created. The saved plan specifies %q as the value whereas during apply the value %q was %s. To declare an ephemeral variable which is not saved in the plan file, use ephemeral = true.", varName, v.Value.GoString(), val.GoString(), v.SourceType.DiagnosticLabel()), + Subject: rng, + }) + } + } } } applyOpts = &terraform.ApplyOpts{ diff --git a/internal/command/apply_test.go b/internal/command/apply_test.go index 2d05fb8e2d..7222a0dd3e 100644 --- a/internal/command/apply_test.go +++ b/internal/command/apply_test.go @@ -860,7 +860,7 @@ func TestApply_planWithVarFile(t *testing.T) { t.Fatalf("err: %s", err) } - planPath := applyFixturePlanFile(t) + planPath := applyFixturePlanFileWithVariableValue(t, "bar") statePath := testTempFile(t) cwd, err := os.Getwd() @@ -2407,6 +2407,53 @@ func applyFixturePlanFileMatchState(t *testing.T, stateMeta statemgr.SnapshotMet ) } +// applyFixturePlanFileWithVariableValue creates a plan file at a temporary location containing +// a single change to create the test_instance.foo and a variable value that is included in the +// "apply" test fixture, returning the location of that plan file. +func applyFixturePlanFileWithVariableValue(t *testing.T, value string) string { + _, snap := testModuleWithSnapshot(t, "apply") + plannedVal := cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "ami": cty.StringVal("bar"), + }) + priorValRaw, err := plans.NewDynamicValue(cty.NullVal(plannedVal.Type()), plannedVal.Type()) + if err != nil { + t.Fatal(err) + } + plannedValRaw, err := plans.NewDynamicValue(plannedVal, plannedVal.Type()) + if err != nil { + t.Fatal(err) + } + plan := testPlan(t) + plan.Changes.AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{ + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ProviderAddr: addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + Before: priorValRaw, + After: plannedValRaw, + }, + }) + + plan.VariableValues = map[string]plans.DynamicValue{ + "foo": mustNewDynamicValue(value, cty.DynamicPseudoType), + } + return testPlanFileMatchState( + t, + snap, + states.NewState(), + plan, + statemgr.SnapshotMeta{}, + ) +} + const applyVarFile = ` foo = "bar" ` @@ -2414,3 +2461,12 @@ foo = "bar" const applyVarFileJSON = ` { "foo": "bar" } ` + +func mustNewDynamicValue(val string, ty cty.Type) plans.DynamicValue { + realVal := cty.StringVal(val) + ret, err := plans.NewDynamicValue(realVal, ty) + if err != nil { + panic(err) + } + return ret +} diff --git a/internal/terraform/variables.go b/internal/terraform/variables.go index 4b2984789c..4adae05a7d 100644 --- a/internal/terraform/variables.go +++ b/internal/terraform/variables.go @@ -137,6 +137,27 @@ func (v ValueSourceType) GoString() string { return fmt.Sprintf("terraform.%s", v) } +func (v ValueSourceType) DiagnosticLabel() string { + switch v { + case ValueFromConfig: + return "set by the default value in configuration" + case ValueFromAutoFile: + return "set by an automatically loaded .tfvars file" + case ValueFromNamedFile: + return "set by a .tfvars file passed through -var-file argument" + case ValueFromCLIArg: + return "set by a CLI argument" + case ValueFromEnvVar: + return "set by an environment variable" + case ValueFromInput: + return "set by an interactive input" + case ValueFromPlan: + return "set by the plan" + default: + return "unknown" + } +} + //go:generate go run golang.org/x/tools/cmd/stringer -type ValueSourceType // InputValues is a map of InputValue instances.