Skip to content

Commit f0a64eb

Browse files
committed
configs: explicitly nullable variable values
The current behavior of module input variables is to allow users to override a default by assigning `null`, which works contrary to the behavior of resource attributes, and prevents explicitly accepting a default when the input must be defined in the configuration. Add a new variable attribute called `nullable` will allow explicitly defining when a variable can be set to null or not. The current default behavior is that of `nullable=true`. Setting `nullable=false` in a variable block indicates that the variable value can never be null. This either requires a non-null input value, or a non-null default value. In the case of the latter, we also opt-in to the new behavior of a `null` input value taking the default rather than overriding it. In a future language edition where we make `nullable=false` the default, setting `nullable=true` will allow the legacy behavior of `null` overriding a default value. The only future configuration in which this would be required even if the legacy behavior were not desired is when setting an optional+nullable value. In that case `default=null` would also be needed and we could therefor imply `nullable=true` without requiring it in the configuration.
1 parent 3c64b9b commit f0a64eb

File tree

8 files changed

+186
-7
lines changed

8 files changed

+186
-7
lines changed

internal/configs/module_merge_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func TestModuleOverrideVariable(t *testing.T) {
2424
Description: "b_override description",
2525
DescriptionSet: true,
2626
Default: cty.StringVal("b_override"),
27+
Nullable: true,
2728
Type: cty.String,
2829
ConstraintType: cty.String,
2930
ParsingMode: VariableParseLiteral,
@@ -46,6 +47,7 @@ func TestModuleOverrideVariable(t *testing.T) {
4647
Description: "base description",
4748
DescriptionSet: true,
4849
Default: cty.StringVal("b_override partial"),
50+
Nullable: true,
4951
Type: cty.String,
5052
ConstraintType: cty.String,
5153
ParsingMode: VariableParseLiteral,

internal/configs/named_values.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ type Variable struct {
3636
DescriptionSet bool
3737
SensitiveSet bool
3838

39+
// Nullable indicates that null is a valid value for this variable. Setting
40+
// Nullable to false means that the module can expect this variable to
41+
// never be null.
42+
Nullable bool
43+
3944
DeclRange hcl.Range
4045
}
4146

@@ -110,6 +115,15 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno
110115
v.SensitiveSet = true
111116
}
112117

118+
if attr, exists := content.Attributes["nullable"]; exists {
119+
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &v.Nullable)
120+
diags = append(diags, valDiags...)
121+
} else {
122+
// The current default is true, which is subject to change in a future
123+
// language edition.
124+
v.Nullable = true
125+
}
126+
113127
if attr, exists := content.Attributes["default"]; exists {
114128
val, valDiags := attr.Expr.Value(nil)
115129
diags = append(diags, valDiags...)
@@ -134,6 +148,15 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno
134148
}
135149
}
136150

151+
if !v.Nullable && val.IsNull() {
152+
diags = append(diags, &hcl.Diagnostic{
153+
Severity: hcl.DiagError,
154+
Summary: "Invalid default value for variable",
155+
Detail: "A null default value is not valid when nullable=false.",
156+
Subject: attr.Expr.Range().Ptr(),
157+
})
158+
}
159+
137160
v.Default = val
138161
}
139162

@@ -556,6 +579,9 @@ var variableBlockSchema = &hcl.BodySchema{
556579
{
557580
Name: "sensitive",
558581
},
582+
{
583+
Name: "nullable",
584+
},
559585
},
560586
Blocks: []hcl.BlockHeaderSchema{
561587
{
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
variable "in" {
2+
type = number
3+
nullable = false
4+
default = null
5+
}

internal/configs/testdata/valid-files/variables.tf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,15 @@ variable "sensitive_value" {
3030
}
3131
sensitive = true
3232
}
33+
34+
variable "nullable" {
35+
type = string
36+
nullable = true
37+
default = "ok"
38+
}
39+
40+
variable "nullable_default_null" {
41+
type = map(string)
42+
nullable = true
43+
default = null
44+
}

internal/terraform/context_apply2_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,3 +596,36 @@ resource "test_object" "x" {
596596
}
597597

598598
}
599+
600+
func TestContext2Apply_nullableVariables(t *testing.T) {
601+
m := testModule(t, "apply-nullable-variables")
602+
state := states.NewState()
603+
ctx := testContext2(t, &ContextOpts{})
604+
plan, diags := ctx.Plan(m, state, &PlanOpts{})
605+
if diags.HasErrors() {
606+
t.Fatalf("plan: %s", diags.Err())
607+
}
608+
state, diags = ctx.Apply(plan, m)
609+
if diags.HasErrors() {
610+
t.Fatalf("apply: %s", diags.Err())
611+
}
612+
613+
outputs := state.Module(addrs.RootModuleInstance).OutputValues
614+
// we check for null outputs be seeing that they don't exists
615+
if _, ok := outputs["nullable_null_default"]; ok {
616+
t.Error("nullable_null_default: expected no output value")
617+
}
618+
if _, ok := outputs["nullable_non_null_default"]; ok {
619+
t.Error("nullable_non_null_default: expected no output value")
620+
}
621+
if _, ok := outputs["nullable_no_default"]; ok {
622+
t.Error("nullable_no_default: expected no output value")
623+
}
624+
625+
if v := outputs["non_nullable_default"].Value; v.AsString() != "ok" {
626+
t.Fatalf("incorrect 'non_nullable_default' output value: %#v\n", v)
627+
}
628+
if v := outputs["non_nullable_no_default"].Value; v.AsString() != "ok" {
629+
t.Fatalf("incorrect 'non_nullable_no_default' output value: %#v\n", v)
630+
}
631+
}

internal/terraform/evaluate.go

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -268,11 +268,27 @@ func (d *evaluationStateData) GetInputVariable(addr addrs.InputVariable, rng tfd
268268
}
269269

270270
val, isSet := vals[addr.Name]
271-
if !isSet {
272-
if config.Default != cty.NilVal {
273-
return config.Default, diags
274-
}
275-
return cty.UnknownVal(config.Type), diags
271+
switch {
272+
case !isSet:
273+
// The config loader will ensure there is a default if the value is not
274+
// set at all.
275+
val = config.Default
276+
277+
case val.IsNull() && !config.Nullable && config.Default != cty.NilVal:
278+
// If nullable=false a null value will use the configured default.
279+
val = config.Default
280+
281+
case val.IsNull() && !config.Nullable:
282+
// The value cannot be null, and there is no configured default.
283+
diags = diags.Append(&hcl.Diagnostic{
284+
Severity: hcl.DiagError,
285+
Summary: `Invalid variable value`,
286+
Detail: fmt.Sprintf(`The resolved value of variable %q cannot be null.`, addr.Name),
287+
Subject: &config.DeclRange,
288+
})
289+
// Stub out our return value so that the semantic checker doesn't
290+
// produce redundant downstream errors.
291+
val = cty.UnknownVal(config.Type)
276292
}
277293

278294
var err error
@@ -286,8 +302,6 @@ func (d *evaluationStateData) GetInputVariable(addr addrs.InputVariable, rng tfd
286302
Detail: fmt.Sprintf(`The resolved value of variable %q is not appropriate: %s.`, addr.Name, err),
287303
Subject: &config.DeclRange,
288304
})
289-
// Stub out our return value so that the semantic checker doesn't
290-
// produce redundant downstream errors.
291305
val = cty.UnknownVal(config.Type)
292306
}
293307

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
module "mod" {
2+
source = "./mod"
3+
nullable_null_default = null
4+
nullable_non_null_default = null
5+
nullable_no_default = null
6+
non_nullable_default = null
7+
non_nullable_no_default = "ok"
8+
}
9+
10+
output "nullable_null_default" {
11+
value = module.mod.nullable_null_default
12+
}
13+
14+
output "nullable_non_null_default" {
15+
value = module.mod.nullable_non_null_default
16+
}
17+
18+
output "nullable_no_default" {
19+
value = module.mod.nullable_no_default
20+
}
21+
22+
output "non_nullable_default" {
23+
value = module.mod.non_nullable_default
24+
}
25+
26+
output "non_nullable_no_default" {
27+
value = module.mod.non_nullable_no_default
28+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// optional, and this can take null as an input
2+
variable "nullable_null_default" {
3+
// This is implied now as the default, and probably should be implied even
4+
// when nullable=false is the default, so we're leaving this unset for the test.
5+
// nullable = true
6+
7+
default = null
8+
}
9+
10+
// assigning null can still override the default.
11+
variable "nullable_non_null_default" {
12+
nullable = true
13+
default = "ok"
14+
}
15+
16+
// required, and assigning null is valid.
17+
variable "nullable_no_default" {
18+
nullable = true
19+
}
20+
21+
22+
// this combination is invalid
23+
//variable "non_nullable_null_default" {
24+
// nullable = false
25+
// default = null
26+
//}
27+
28+
29+
// assigning null will take the default
30+
variable "non_nullable_default" {
31+
nullable = false
32+
default = "ok"
33+
}
34+
35+
// required, but null is not a valid value
36+
variable "non_nullable_no_default" {
37+
nullable = false
38+
}
39+
40+
output "nullable_null_default" {
41+
value = var.nullable_null_default
42+
}
43+
44+
output "nullable_non_null_default" {
45+
value = var.nullable_non_null_default
46+
}
47+
48+
output "nullable_no_default" {
49+
value = var.nullable_no_default
50+
}
51+
52+
output "non_nullable_default" {
53+
value = var.non_nullable_default
54+
}
55+
56+
output "non_nullable_no_default" {
57+
value = var.non_nullable_no_default
58+
}
59+

0 commit comments

Comments
 (0)