diff --git a/internal/configs/named_values.go b/internal/configs/named_values.go index b12b212880..3ce759c871 100644 --- a/internal/configs/named_values.go +++ b/internal/configs/named_values.go @@ -140,8 +140,11 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno if v.ConstraintType != cty.NilType { var err error // If the type constraint has defaults, we must apply those - // defaults to the variable default value before type conversion. - if v.TypeDefaults != nil { + // defaults to the variable default value before type conversion, + // unless the default value is null. Null is excluded from the + // type default application process as a special case, to allow + // nullable variables to have a null default value. + if v.TypeDefaults != nil && !val.IsNull() { val = v.TypeDefaults.Apply(val) } val, err = convert.Convert(val, v.ConstraintType) diff --git a/internal/configs/testdata/valid-files/object-optional-attrs.tf b/internal/configs/testdata/valid-files/object-optional-attrs.tf new file mode 100644 index 0000000000..8b7fda9a74 --- /dev/null +++ b/internal/configs/testdata/valid-files/object-optional-attrs.tf @@ -0,0 +1,38 @@ +variable "a" { + type = object({ + foo = optional(string) + bar = optional(bool, true) + }) +} + +variable "b" { + type = list( + object({ + foo = optional(string) + }) + ) +} + +variable "c" { + type = set( + object({ + foo = optional(string) + }) + ) +} + +variable "d" { + type = map( + object({ + foo = optional(string) + }) + ) +} + +variable "e" { + type = object({ + foo = string + bar = optional(bool, true) + }) + default = null +} diff --git a/internal/terraform/context_apply_test.go b/internal/terraform/context_apply_test.go index c3e3beecbc..b36b51c77a 100644 --- a/internal/terraform/context_apply_test.go +++ b/internal/terraform/context_apply_test.go @@ -12119,6 +12119,52 @@ output "out" { } } +func TestContext2Apply_moduleVariableOptionalAttributesDefaultNull(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +variable "in" { + type = object({ + required = string + optional = optional(string) + default = optional(bool, true) + }) + default = null +} + +# Wrap the input variable in a tuple because a null output value is elided from +# the plan, which prevents us from testing its type. +output "out" { + value = [var.in] +} +`}) + + ctx := testContext2(t, &ContextOpts{}) + + // We don't specify a value for the variable here, relying on its defined + // default. + plan, diags := ctx.Plan(m, states.NewState(), SimplePlanOpts(plans.NormalMode, testInputValuesUnset(m.Module.Variables))) + if diags.HasErrors() { + t.Fatal(diags.ErrWithWarnings()) + } + + state, diags := ctx.Apply(plan, m) + if diags.HasErrors() { + t.Fatal(diags.ErrWithWarnings()) + } + + got := state.RootModule().OutputValues["out"].Value + // The null default value should be bound, after type converting to the + // full object type + want := cty.TupleVal([]cty.Value{cty.NullVal(cty.Object(map[string]cty.Type{ + "required": cty.String, + "optional": cty.String, + "default": cty.Bool, + }))}) + if !want.RawEquals(got) { + t.Fatalf("wrong result\ngot: %#v\nwant: %#v", got, want) + } +} + func TestContext2Apply_provisionerSensitive(t *testing.T) { m := testModule(t, "apply-provisioner-sensitive") p := testProvider("aws") diff --git a/internal/terraform/eval_variable.go b/internal/terraform/eval_variable.go index 67a777430f..167840e7c1 100644 --- a/internal/terraform/eval_variable.go +++ b/internal/terraform/eval_variable.go @@ -90,8 +90,11 @@ func prepareFinalInputVariableValue(addr addrs.AbsInputVariableInstance, raw *In given = defaultVal // must be set, because we checked above that the variable isn't required } - // Apply defaults from the variable's type constraint to the given value - if cfg.TypeDefaults != nil { + // Apply defaults from the variable's type constraint to the given value, + // unless the given value is null. We do not apply defaults to top-level + // null values, as doing so could prevent assigning null to a nullable + // variable. + if cfg.TypeDefaults != nil && !given.IsNull() { given = cfg.TypeDefaults.Apply(given) }