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

Address a few inconsistencies in how we process root module input variables vs. called module input variables #29959

Merged
merged 4 commits into from Jan 10, 2022
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
17 changes: 14 additions & 3 deletions internal/backend/unparsed_value.go
Expand Up @@ -164,13 +164,18 @@ func ParseVariableValues(vv map[string]UnparsedVariableValue, decls map[string]*

// By this point we should've gathered all of the required root module
// variables from one of the many possible sources. We'll now populate
// any we haven't gathered as their defaults and fail if any of the
// missing ones are required.
// any we haven't gathered as unset placeholders which Terraform Core
// can then react to.
for name, vc := range decls {
if isDefinedAny(name, ret, undeclared) {
continue
}

// This check is redundant with a check made in Terraform Core when
// processing undeclared variables, but allows us to generate a more
// specific error message which mentions -var and -var-file command
// line options, whereas the one in Terraform Core is more general
// due to supporting both root and child module variables.
if vc.Required() {
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Expand All @@ -189,8 +194,14 @@ func ParseVariableValues(vv map[string]UnparsedVariableValue, decls map[string]*
SourceRange: tfdiags.SourceRangeFromHCL(vc.DeclRange),
}
} else {
// We're still required to put an entry for this variable
// in the mapping to be explicit to Terraform Core that we
// visited it, but its value will be cty.NilVal to represent
// that it wasn't set at all at this layer, and so Terraform Core
// should substitute a default if available, or generate an error
// if not.
ret[name] = &terraform.InputValue{
Value: vc.Default,
Value: cty.NilVal,
SourceType: terraform.ValueFromConfig,
SourceRange: tfdiags.SourceRangeFromHCL(vc.DeclRange),
}
Expand Down
2 changes: 1 addition & 1 deletion internal/backend/unparsed_value_test.go
Expand Up @@ -204,7 +204,7 @@ func TestUnparsedValue(t *testing.T) {
},
},
"missing2": {
Value: cty.StringVal("default for missing2"),
Value: cty.NilVal, // Terraform Core handles substituting the default
SourceType: terraform.ValueFromConfig,
SourceRange: tfdiags.SourceRange{
Filename: "fake.tf",
Expand Down
43 changes: 37 additions & 6 deletions internal/command/jsonplan/plan.go
Expand Up @@ -118,7 +118,7 @@ func Marshal(
output := newPlan()
output.TerraformVersion = version.String()

err := output.marshalPlanVariables(p.VariableValues, schemas)
err := output.marshalPlanVariables(p.VariableValues, config.Module.Variables)
if err != nil {
return nil, fmt.Errorf("error in marshalPlanVariables: %s", err)
}
Expand Down Expand Up @@ -183,11 +183,7 @@ func Marshal(
return ret, err
}

func (p *plan) marshalPlanVariables(vars map[string]plans.DynamicValue, schemas *terraform.Schemas) error {
if len(vars) == 0 {
return nil
}

func (p *plan) marshalPlanVariables(vars map[string]plans.DynamicValue, decls map[string]*configs.Variable) error {
p.Variables = make(variables, len(vars))

for k, v := range vars {
Expand All @@ -203,6 +199,41 @@ func (p *plan) marshalPlanVariables(vars map[string]plans.DynamicValue, schemas
Value: valJSON,
}
}

// In Terraform v1.1 and earlier we had some confusion about which subsystem
// of Terraform was the one responsible for substituting in default values
// for unset module variables, with root module variables being handled in
// three different places while child module variables were only handled
// during the Terraform Core graph walk.
//
// For Terraform v1.2 and later we rationalized that by having the Terraform
Copy link
Member

@jbardin jbardin Jan 7, 2022

Choose a reason for hiding this comment

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

In #30312 I came to mostly the same functional conclusions you did here about module variables, but opted to unify handling in the module exec node. Dealing with the default feels very much outside the graph building responsibilities of creating and connecting the nodes, and I definitely found the fake expression which was injected before any evaluation very surprising.

If that's something which can be fixed but you want to leave that for later to keep this PR from growing, I can wait for this to land and do the refactoring later.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not exactly sure what you're referring to here, probably at least in part because I've not been looking at this PR for several weeks and so I no longer have the context loaded 😀 , but I would generally prefer to avoid growing the scope of this even more if it seems correct enough, since it already sprawled a lot more than I wanted and is at high risk of becoming conflicted as we start other work. If you do have ideas for further improvements then I'd love to discuss them in a followup PR though, assuming that would be motivated by maintainability rather than by correctness. (I say that just because my original intent here was to fix a bug, and I only ended up refactoring when I learned that the bug was caused by a design problem.)

I believe my thought process here was that we ought to never have included the default values in this JSON plan output at all, and that they only really ended up here as an unintended side-effect of a strange separation of concerns elsewhere. The place to look for default values for variables would be a hypothetical JSON serialization of the configuration, because default values belong to the configuration rather than to the plan. Ideally it should be possible to see from this JSON plan output whether a particular variable was set or not, and perhaps one day we'll find some way to opt-in to that (arguably) better behavior, and so this special case is here to keep this oddity isolated out here in the one system we'd be changing anyway if we were to "fix" this, rather than having this backward-compatibility shim effectively spread across multiple subsystems.

Copy link
Member

Choose a reason for hiding this comment

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

That's totally reasonable. I can follow up and see if anything from 30312 is still applicable afterwards, but the changes here also seem to fix the processing order for module variables.

// Core graph walk always be responsible for selecting defaults regardless
// of root vs. child module, but unfortunately our earlier accidental
// misbehavior bled out into the public interface by making the defaults
// show up in the "vars" map to this function. Those are now correctly
// omitted (so that the plan file only records the variables _actually_
// set by the caller) but consumers of the JSON plan format may be depending
// on our old behavior and so we'll fake it here just in time so that
// outside consumers won't see a behavior change.
for name, decl := range decls {
if _, ok := p.Variables[name]; ok {
continue
}
if val := decl.Default; val != cty.NilVal {
valJSON, err := ctyjson.Marshal(val, val.Type())
if err != nil {
return err
}
p.Variables[name] = &variable{
Value: valJSON,
}
}
}

if len(p.Variables) == 0 {
p.Variables = nil // omit this property if there are no variables to describe
}

return nil
}

Expand Down
84 changes: 54 additions & 30 deletions internal/terraform/context_apply.go
Expand Up @@ -30,30 +30,11 @@ func (c *Context) Apply(plan *plans.Plan, config *configs.Config) (*states.State
return nil, diags
}

variables := InputValues{}
for name, dyVal := range plan.VariableValues {
val, err := dyVal.Decode(cty.DynamicPseudoType)
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid variable value in plan",
fmt.Sprintf("Invalid value for variable %q recorded in plan file: %s.", name, err),
))
continue
}

variables[name] = &InputValue{
Value: val,
SourceType: ValueFromPlan,
}
}

workingState := plan.PriorState.DeepCopy()
walker, walkDiags := c.walk(graph, operation, &graphWalkOpts{
Config: config,
InputState: workingState,
Changes: plan.Changes,
RootVariableValues: variables,
Config: config,
InputState: workingState,
Changes: plan.Changes,
})
diags = diags.Append(walker.NonFatalDiagnostics)
diags = diags.Append(walkDiags)
Expand Down Expand Up @@ -83,15 +64,58 @@ Note that the -target option is not suitable for routine use, and is provided on
}

func (c *Context) applyGraph(plan *plans.Plan, config *configs.Config, validate bool) (*Graph, walkOperation, tfdiags.Diagnostics) {
graph, diags := (&ApplyGraphBuilder{
Config: config,
Changes: plan.Changes,
State: plan.PriorState,
Plugins: c.plugins,
Targets: plan.TargetAddrs,
ForceReplace: plan.ForceReplaceAddrs,
Validate: validate,
var diags tfdiags.Diagnostics

variables := InputValues{}
for name, dyVal := range plan.VariableValues {
val, err := dyVal.Decode(cty.DynamicPseudoType)
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid variable value in plan",
fmt.Sprintf("Invalid value for variable %q recorded in plan file: %s.", name, err),
))
continue
}

variables[name] = &InputValue{
Value: val,
SourceType: ValueFromPlan,
}
}
if diags.HasErrors() {
return nil, walkApply, diags
}

// The plan.VariableValues field only records variables that were actually
// set by the caller in the PlanOpts, so we may need to provide
// placeholders for any other variables that the user didn't set, in
// which case Terraform will once again use the default value from the
// configuration when we visit these variables during the graph walk.
for name := range config.Module.Variables {
if _, ok := variables[name]; ok {
continue
}
variables[name] = &InputValue{
Value: cty.NilVal,
SourceType: ValueFromPlan,
}
}

graph, moreDiags := (&ApplyGraphBuilder{
Config: config,
Changes: plan.Changes,
State: plan.PriorState,
RootVariableValues: variables,
Plugins: c.plugins,
Targets: plan.TargetAddrs,
ForceReplace: plan.ForceReplaceAddrs,
Validate: validate,
}).Build(addrs.RootModuleInstance)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
return nil, walkApply, diags
}

operation := walkApply
if plan.UIMode == plans.DestroyMode {
Expand Down
6 changes: 3 additions & 3 deletions internal/terraform/context_apply2_test.go
Expand Up @@ -426,7 +426,7 @@ resource "test_resource" "b" {
},
})

plan, diags := ctx.Plan(m, state, DefaultPlanOpts)
plan, diags := ctx.Plan(m, state, SimplePlanOpts(plans.NormalMode, testInputValuesUnset(m.Module.Variables)))
assertNoErrors(t, diags)

_, diags = ctx.Apply(plan, m)
Expand Down Expand Up @@ -530,14 +530,14 @@ resource "test_object" "y" {
},
})

plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts)
plan, diags := ctx.Plan(m, states.NewState(), SimplePlanOpts(plans.NormalMode, testInputValuesUnset(m.Module.Variables)))
assertNoErrors(t, diags)

state, diags := ctx.Apply(plan, m)
assertNoErrors(t, diags)

// FINAL PLAN:
plan, diags = ctx.Plan(m, state, DefaultPlanOpts)
plan, diags = ctx.Plan(m, state, SimplePlanOpts(plans.NormalMode, testInputValuesUnset(m.Module.Variables)))
assertNoErrors(t, diags)

// make sure the same marks are compared in the next plan as well
Expand Down