Skip to content

Commit 8456322

Browse files
terraform: Stabilize the variable_validation_crossref experiment
Previously we introduced a language experiment that would permit variable validation rules to refer to other objects declared in the same module as the variable. Now that experiment is concluded and its behavior is available for all modules. This final version deviates slightly from the experiment: we learned from the experimental implementation that we accidentally made the "validate" command able to validate constant-valued input variables in child modules despite the usual rule that input variables are unknown during validation, because the previous compromise bypassed the main expression evaluator and built its own evaluation context directly. Even though that behavior was not intended, it's a useful behavior that is protected by our compatibility promises and so this commit includes a slightly hacky emulation of that behavior, in eval_variable.go, that fetches the variable value in the same way the old implementation would have and then modifies the hcl evaluation context to include that value, while preserving anything else that our standard evaluation context builder put in there. That narrowly preserves the old behavior for expressions that compare the variable value directly to a constant, while treating all other references (which were previously totally invalid) in the standard way. This quirk was already covered by the existing test TestContext2Validate_variableCustomValidationsFail, which fails if the special workaround is removed.
1 parent 6d45157 commit 8456322

14 files changed

+168
-247
lines changed

internal/configs/experiments.go

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -208,19 +208,5 @@ func checkModuleExperiments(m *Module) hcl.Diagnostics {
208208
}
209209
*/
210210

211-
if !m.ActiveExperiments.Has(experiments.VariableValidationCrossRef) {
212-
// Without this experiment, validation rules are subject to the old
213-
// rule that they can only refer to the variable whose value they
214-
// are checking. This experiment removes that constraint, and makes
215-
// the modules runtime responsible for validating and evaluating
216-
// the conditions and error messages, just as we'd do for any other
217-
// dynamic expression.
218-
for varName, vc := range m.Variables {
219-
for _, vv := range vc.Validations {
220-
diags = append(diags, checkVariableValidationBlock(varName, vv)...)
221-
}
222-
}
223-
}
224-
225211
return diags
226212
}

internal/configs/named_values.go

Lines changed: 1 addition & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno
178178
switch block.Type {
179179

180180
case "validation":
181-
vv, moreDiags := decodeVariableValidationBlock(block, override)
181+
vv, moreDiags := decodeCheckRuleBlock(block, override)
182182
diags = append(diags, moreDiags...)
183183
v.Validations = append(v.Validations, vv)
184184

@@ -324,77 +324,6 @@ func (m VariableParsingMode) Parse(name, value string) (cty.Value, hcl.Diagnosti
324324
}
325325
}
326326

327-
// decodeVariableValidationBlock is a wrapper around decodeCheckRuleBlock
328-
// that imposes the additional rule that the condition expression can refer
329-
// only to an input variable of the given name.
330-
func decodeVariableValidationBlock(block *hcl.Block, override bool) (*CheckRule, hcl.Diagnostics) {
331-
return decodeCheckRuleBlock(block, override)
332-
}
333-
334-
func checkVariableValidationBlock(varName string, vv *CheckRule) hcl.Diagnostics {
335-
var diags hcl.Diagnostics
336-
337-
if vv.Condition != nil {
338-
// The validation condition can only refer to the variable itself,
339-
// to ensure that the variable declaration can't create additional
340-
// edges in the dependency graph.
341-
goodRefs := 0
342-
for _, traversal := range vv.Condition.Variables() {
343-
ref, moreDiags := addrs.ParseRef(traversal)
344-
if !moreDiags.HasErrors() {
345-
if addr, ok := ref.Subject.(addrs.InputVariable); ok {
346-
if addr.Name == varName {
347-
goodRefs++
348-
continue // Reference is valid
349-
}
350-
}
351-
}
352-
// If we fall out here then the reference is invalid.
353-
diags = diags.Append(&hcl.Diagnostic{
354-
Severity: hcl.DiagError,
355-
Summary: "Invalid reference in variable validation",
356-
Detail: fmt.Sprintf("The condition for variable %q can only refer to the variable itself, using var.%s.", varName, varName),
357-
Subject: traversal.SourceRange().Ptr(),
358-
})
359-
}
360-
if goodRefs < 1 {
361-
diags = diags.Append(&hcl.Diagnostic{
362-
Severity: hcl.DiagError,
363-
Summary: "Invalid variable validation condition",
364-
Detail: fmt.Sprintf("The condition for variable %q must refer to var.%s in order to test incoming values.", varName, varName),
365-
Subject: vv.Condition.Range().Ptr(),
366-
})
367-
}
368-
}
369-
370-
if vv.ErrorMessage != nil {
371-
// The same applies to the validation error message, except that
372-
// references are not required. A string literal is a valid error
373-
// message.
374-
goodRefs := 0
375-
for _, traversal := range vv.ErrorMessage.Variables() {
376-
ref, moreDiags := addrs.ParseRef(traversal)
377-
if !moreDiags.HasErrors() {
378-
if addr, ok := ref.Subject.(addrs.InputVariable); ok {
379-
if addr.Name == varName {
380-
goodRefs++
381-
continue // Reference is valid
382-
}
383-
}
384-
}
385-
// If we fall out here then the reference is invalid.
386-
diags = diags.Append(&hcl.Diagnostic{
387-
Severity: hcl.DiagError,
388-
Summary: "Invalid reference in variable validation",
389-
Detail: fmt.Sprintf("The error message for variable %q can only refer to the variable itself, using var.%s.", varName, varName),
390-
Subject: traversal.SourceRange().Ptr(),
391-
})
392-
}
393-
}
394-
395-
return diags
396-
}
397-
398327
// Output represents an "output" block in a module or file.
399328
type Output struct {
400329
Name string
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ locals {
55

66
variable "validation" {
77
validation {
8-
condition = local.foo == var.validation # ERROR: Invalid reference in variable validation
8+
condition = local.foo == var.validation
99
error_message = "Must be five."
1010
}
1111
}
1212

1313
variable "validation_error_expression" {
1414
validation {
1515
condition = var.validation_error_expression != 1
16-
error_message = "Cannot equal ${local.foo}." # ERROR: Invalid reference in variable validation
16+
error_message = "Cannot equal ${local.foo}."
1717
}
1818
}

internal/experiments/experiment.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func init() {
3131
// a current or a concluded experiment.
3232
registerConcludedExperiment(UnknownInstances, "Unknown instances are being rolled into a larger feature for deferring unready resources and modules.")
3333
registerConcludedExperiment(VariableValidation, "Custom variable validation can now be used by default, without enabling an experiment.")
34-
registerCurrentExperiment(VariableValidationCrossRef)
34+
registerConcludedExperiment(VariableValidationCrossRef, "Input variable validation rules may now refer to other objects in the same module without enabling any experiment.")
3535
registerConcludedExperiment(SuppressProviderSensitiveAttrs, "Provider-defined sensitive attributes are now redacted by default, without enabling an experiment.")
3636
registerCurrentExperiment(TemplateStringFunc)
3737
registerConcludedExperiment(ConfigDrivenMove, "Declarations of moved resource instances using \"moved\" blocks can now be used by default, without enabling an experiment.")

internal/terraform/context_plan_test.go

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6921,8 +6921,6 @@ resource "test_resource" "foo" {
69216921
}
69226922

69236923
func TestContext2Plan_variableCustomValidationsSimple(t *testing.T) {
6924-
// This test is dealing with validation rules that refer to other objects
6925-
// in the same module.
69266924
m := testModuleInline(t, map[string]string{
69276925
"main.tf": `
69286926
variable "a" {
@@ -6978,11 +6976,6 @@ func TestContext2Plan_variableCustomValidationsCrossRef(t *testing.T) {
69786976
// in the same module.
69796977
m := testModuleInline(t, map[string]string{
69806978
"main.tf": `
6981-
# Validation cross-references are currently experimental
6982-
terraform {
6983-
experiments = [variable_validation_crossref]
6984-
}
6985-
69866979
variable "a" {
69876980
type = string
69886981
}

internal/terraform/eval_variable.go

Lines changed: 40 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -215,67 +215,6 @@ func evalVariableValidations(addr addrs.AbsInputVariableInstance, ctx EvalContex
215215
return diags
216216
}
217217

218-
// Validation expressions are statically validated (during configuration
219-
// loading) to refer only to the variable being validated, so we can
220-
// bypass our usual evaluation machinery here and just produce a minimal
221-
// evaluation context containing just the required value.
222-
val := ctx.NamedValues().GetInputVariableValue(addr)
223-
if val == cty.NilVal {
224-
diags = diags.Append(&hcl.Diagnostic{
225-
Severity: hcl.DiagError,
226-
Summary: "No final value for variable",
227-
Detail: fmt.Sprintf("Terraform doesn't have a final value for %s during validation. This is a bug in Terraform; please report it!", addr),
228-
})
229-
return diags
230-
}
231-
hclCtx := &hcl.EvalContext{
232-
Variables: map[string]cty.Value{
233-
"var": cty.ObjectVal(map[string]cty.Value{
234-
addr.Variable.Name: val,
235-
}),
236-
},
237-
Functions: ctx.EvaluationScope(nil, nil, EvalDataForNoInstanceKey).Functions(),
238-
}
239-
240-
for ix, validation := range rules {
241-
result, ruleDiags := evalVariableValidation(validation, hclCtx, valueRng, addr, ix)
242-
diags = diags.Append(ruleDiags)
243-
244-
log.Printf("[TRACE] evalVariableValidations: %s status is now %s", addr, result.Status)
245-
if result.Status == checks.StatusFail {
246-
checkState.ReportCheckFailure(addr, addrs.InputValidation, ix, result.FailureMessage)
247-
} else {
248-
checkState.ReportCheckResult(addr, addrs.InputValidation, ix, result.Status)
249-
}
250-
}
251-
252-
return diags
253-
}
254-
255-
// evalVariableValidationsCrossRef is an experimental variant of
256-
// [evalVariableValidations] that allows arbitrary references to any object
257-
// declared in the same module as the variable.
258-
//
259-
// If the experiment is successful, this function should replace
260-
// [evalVariableValidations], but it's currently written separately to minimize
261-
// the risk of the experiment impacting non-opted modules.
262-
func evalVariableValidationsCrossRef(addr addrs.AbsInputVariableInstance, ctx EvalContext, rules []*configs.CheckRule, valueRng hcl.Range) (diags tfdiags.Diagnostics) {
263-
if len(rules) == 0 {
264-
log.Printf("[TRACE] evalVariableValidations: no validation rules declared for %s, so skipping", addr)
265-
return nil
266-
}
267-
log.Printf("[TRACE] evalVariableValidations: validating %s", addr)
268-
269-
checkState := ctx.Checks()
270-
if !checkState.ConfigHasChecks(addr.ConfigCheckable()) {
271-
// We have nothing to do if this object doesn't have any checks,
272-
// but the "rules" slice should agree that we don't.
273-
if ct := len(rules); ct != 0 {
274-
panic(fmt.Sprintf("check state says that %s should have no rules, but it has %d", addr, ct))
275-
}
276-
return diags
277-
}
278-
279218
// We'll build just one evaluation context covering the data needed by
280219
// all of the rules together, since that'll minimize lock contention
281220
// on the state, plan, etc.
@@ -300,6 +239,46 @@ func evalVariableValidationsCrossRef(addr addrs.AbsInputVariableInstance, ctx Ev
300239
return diags
301240
}
302241

242+
// HACK: Historically we manually built a very constrained hcl.EvalContext
243+
// here, which only included the value of the one specific input variable
244+
// we're validating, since we didn't yet support referring to anything
245+
// else. That accidentally bypassed our rule that input variables are
246+
// always unknown during the validate walk, and thus accidentally created
247+
// a useful behavior of actually checking constant-only values against
248+
// their validation rules just during "terraform validate", rather than
249+
// having to run "terraform plan".
250+
//
251+
// Although that behavior was accidental, it makes simple validation rules
252+
// more useful and is protected by compatibility promises, and so we'll
253+
// fake it here by overwriting the unknown value that scope.EvalContext
254+
// will have inserted with a possibly-more-known value using the same
255+
// strategy our special code used to use.
256+
ourVal := ctx.NamedValues().GetInputVariableValue(addr)
257+
if ourVal != cty.NilVal {
258+
// (it would be weird for ourVal to be nil here, but we'll tolerate it
259+
// because it was scope.EvalContext's responsibility to check for the
260+
// absent final value, and even if it didn't we'll just get an
261+
// evaluation error when evaluating the expressions below anyway.)
262+
263+
// Our goal here is to make sure that a reference to the variable
264+
// we're checking will evaluate to ourVal, regardless of what else
265+
// scope.EvalContext might have put in the variables table.
266+
if hclCtx.Variables == nil {
267+
hclCtx.Variables = make(map[string]cty.Value)
268+
}
269+
if varsVal, ok := hclCtx.Variables["var"]; ok {
270+
// Unfortunately we need to unpack and repack the object here,
271+
// because cty values are immutable.
272+
attrs := varsVal.AsValueMap()
273+
attrs[addr.Variable.Name] = ourVal
274+
hclCtx.Variables["var"] = cty.ObjectVal(attrs)
275+
} else {
276+
hclCtx.Variables["var"] = cty.ObjectVal(map[string]cty.Value{
277+
addr.Variable.Name: ourVal,
278+
})
279+
}
280+
}
281+
303282
for ix, validation := range rules {
304283
result, ruleDiags := evalVariableValidation(validation, hclCtx, valueRng, addr, ix)
305284
diags = diags.Append(ruleDiags)

internal/terraform/eval_variable_test.go

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,7 +1173,13 @@ func TestEvalVariableValidations_jsonErrorMessageEdgeCase(t *testing.T) {
11731173

11741174
// We need a minimal scope to allow basic functions to be passed to
11751175
// the HCL scope
1176-
ctx.EvaluationScopeScope = &lang.Scope{}
1176+
ctx.EvaluationScopeScope = &lang.Scope{
1177+
Data: &fakeEvaluationData{
1178+
inputVariables: map[addrs.InputVariable]cty.Value{
1179+
varAddr.Variable: test.given,
1180+
},
1181+
},
1182+
}
11771183
ctx.NamedValuesState = namedvals.NewState()
11781184
ctx.NamedValuesState.SetInputVariableValue(varAddr, test.given)
11791185
ctx.ChecksState = checks.NewState(cfg)
@@ -1322,13 +1328,19 @@ variable "bar" {
13221328

13231329
// We need a minimal scope to allow basic functions to be passed to
13241330
// the HCL scope
1325-
ctx.EvaluationScopeScope = &lang.Scope{}
1326-
ctx.NamedValuesState = namedvals.NewState()
1331+
varVal := test.given
13271332
if varCfg.Sensitive {
1328-
ctx.NamedValuesState.SetInputVariableValue(varAddr, test.given.Mark(marks.Sensitive))
1329-
} else {
1330-
ctx.NamedValuesState.SetInputVariableValue(varAddr, test.given)
1333+
varVal = varVal.Mark(marks.Sensitive)
13311334
}
1335+
ctx.EvaluationScopeScope = &lang.Scope{
1336+
Data: &fakeEvaluationData{
1337+
inputVariables: map[addrs.InputVariable]cty.Value{
1338+
varAddr.Variable: varVal,
1339+
},
1340+
},
1341+
}
1342+
ctx.NamedValuesState = namedvals.NewState()
1343+
ctx.NamedValuesState.SetInputVariableValue(varAddr, varVal)
13321344
ctx.ChecksState = checks.NewState(cfg)
13331345
ctx.ChecksState.ReportCheckableObjects(varAddr.ConfigCheckable(), addrs.MakeSet[addrs.Checkable](varAddr))
13341346

internal/terraform/graph_builder_apply.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,7 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer {
125125
Config: b.Config,
126126
DestroyApply: b.Operation == walkDestroy,
127127
},
128-
&variableValidationTransformer{
129-
config: b.Config,
130-
},
128+
&variableValidationTransformer{},
131129
&LocalTransformer{Config: b.Config},
132130
&OutputTransformer{
133131
Config: b.Config,

internal/terraform/graph_builder_eval.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,7 @@ func (b *EvalGraphBuilder) Steps() []GraphTransformer {
7676
// Add dynamic values
7777
&RootVariableTransformer{Config: b.Config, RawValues: b.RootVariableValues, Planning: true},
7878
&ModuleVariableTransformer{Config: b.Config, Planning: true},
79-
&variableValidationTransformer{
80-
config: b.Config,
81-
},
79+
&variableValidationTransformer{},
8280
&LocalTransformer{Config: b.Config},
8381
&OutputTransformer{
8482
Config: b.Config,

internal/terraform/graph_builder_plan.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,7 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer {
159159
Planning: true,
160160
DestroyApply: false, // always false for planning
161161
},
162-
&variableValidationTransformer{
163-
config: b.Config,
164-
},
162+
&variableValidationTransformer{},
165163
&LocalTransformer{Config: b.Config},
166164
&OutputTransformer{
167165
Config: b.Config,

0 commit comments

Comments
 (0)