From 3ffe6b6cfb319318243b1bbaa6f103bbf0bc2190 Mon Sep 17 00:00:00 2001 From: Andrea Cappelletti Date: Tue, 28 Jul 2026 14:00:29 -0400 Subject: [PATCH] fix(parser): apply variable type constraints so optional() attributes materialise The optional() support added in #46 (applyOptionalDefaults) only filled object attributes that had an explicit default, and only for a top-level object() type. It skipped bare optional(t) (no default) and did not descend into map(object(...)) / list(object(...)) wrappers. That is exactly the terraform-aws-modules/rds-aurora shape (issue #53): instances is typed map(object({ instance_class = optional(string), ... })), so a caller value of { one = {} } stayed an empty object, try(coalesce(each.value.instance_class, var.cluster_instance_class), null) errored on the missing attribute, try() swallowed it to null, and the resource fell back to the catalogue default class. Replace the hand-rolled extraction with the canonical Terraform mechanism: typeexpr.TypeConstraintWithDefaults, then Defaults.Apply + convert.Convert, applied once the var scope is fully layered, for both the root module and child-module inputs. This is a strict superset: it fills explicit optional defaults (including nested), materialises bare optionals as null, and handles map/list-of-object wrappers. Best-effort: unconvertible values pass through unchanged; any/untyped variables are skipped. Removes optional_defaults.go and its test; ports the explicit-default and nested-object coverage plus adds regression tests for the root and module-input rds-aurora paths. --- internal/parser/terraform/modules.go | 16 +- .../parser/terraform/optional_defaults.go | 245 ------------------ .../terraform/optional_defaults_test.go | 122 --------- internal/parser/terraform/parser.go | 11 +- internal/parser/terraform/parser_test.go | 133 ++++++++++ internal/parser/terraform/variables.go | 81 ++++++ 6 files changed, 230 insertions(+), 378 deletions(-) delete mode 100644 internal/parser/terraform/optional_defaults.go delete mode 100644 internal/parser/terraform/optional_defaults_test.go diff --git a/internal/parser/terraform/modules.go b/internal/parser/terraform/modules.go index c7474b7..8b18907 100644 --- a/internal/parser/terraform/modules.go +++ b/internal/parser/terraform/modules.go @@ -153,13 +153,15 @@ func expandModules( for k, v := range childInputs { childVars[k] = v } - - // Fill in optional() defaults from the variable type - // constraints. This bridges the gap between Terraform's - // runtime type-system and c3x's static parsing: attributes - // like `os_disk = optional(object({disk_size_gb = optional(number, 64)}), {})` - // get their defaults applied to the caller-supplied values. - applyOptionalDefaults(childSources, childVars) + // Normalise the child's inputs (and defaults) to the child's + // declared `type` constraints, filling optional() attribute + // defaults. This bridges Terraform's runtime type-system and + // c3x's static parsing: it applies explicit `optional(t, def)` + // defaults and materialises bare `optional(t)` as null, so a + // caller value like `instances = { one = {} }` exposes + // `each.value.instance_class` instead of erroring inside the + // module's expressions. + applyVariableTypes(childVars, collectVariableTypes(childSources)) childData := collectDataBlocks(childSources) childLocals := resolveLocals(childSources, childVars, childData) diff --git a/internal/parser/terraform/optional_defaults.go b/internal/parser/terraform/optional_defaults.go deleted file mode 100644 index 692cd3b..0000000 --- a/internal/parser/terraform/optional_defaults.go +++ /dev/null @@ -1,245 +0,0 @@ -package terraform - -import ( - "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/zclconf/go-cty/cty" -) - -// applyOptionalDefaults inspects each variable block in the child module's -// sources and, for variables that have a `type` constraint containing -// `optional()` calls with default values, merges those defaults into the -// corresponding variable value in `vars`. This bridges the gap between -// Terraform's runtime type-system (which applies optional defaults -// automatically) and c3x's static HCL parsing. -// -// Example: given a variable typed as -// -// variable "vm" { -// type = object({ -// size = optional(string, "Standard_D2s_v3") -// os_disk = optional(object({ -// disk_size_gb = optional(number, 64) -// }), {}) -// }) -// } -// -// and a caller-supplied value of { dataDisks = [...] }, this function -// fills in os_disk = {} and, within os_disk, disk_size_gb = 64. -func applyOptionalDefaults(sources []sourceFile, vars map[string]cty.Value) { - for _, src := range sources { - for _, block := range src.Body.Blocks { - if block.Type != "variable" || len(block.Labels) == 0 { - continue - } - name := block.Labels[0] - typeAttr, ok := block.Body.Attributes["type"] - if !ok { - continue - } - currentVal, hasVal := vars[name] - if !hasVal || currentVal.IsNull() || !currentVal.IsKnown() { - continue - } - - // Extract optional() defaults from the type expression tree. - defaults := extractOptionalDefaults(typeAttr.Expr) - if len(defaults) == 0 { - continue - } - - // Merge defaults into the current value. - merged := mergeDefaults(currentVal, defaults) - if merged != cty.NilVal { - vars[name] = merged - } - } - } -} - -// optionalDefault represents a single optional() default at one level -// of the type tree. -type optionalDefault struct { - // defaultVal is the literal default value (2nd arg of optional()). - defaultVal cty.Value - // children holds nested optional() defaults for object members. - children map[string]*optionalDefault -} - -// extractOptionalDefaults walks an HCL expression tree representing a -// type constraint and finds all optional(type, default) calls. It -// returns a map of attribute-name → optionalDefault for the top-level -// object's members. -// -// The type constraint grammar we care about: -// -// object({ key = optional(type, default), ... }) -// -// We only process the subset we can statically understand. -func extractOptionalDefaults(expr hclsyntax.Expression) map[string]*optionalDefault { - // The top-level expression should be a function call to object(). - call, ok := expr.(*hclsyntax.FunctionCallExpr) - if !ok { - return nil - } - if call.Name != "object" || len(call.Args) != 1 { - return nil - } - // The argument to object() is an ObjectConsExpr: { key = type, ... } - return extractObjectDefaults(call.Args[0]) -} - -// extractObjectDefaults processes the argument of an object() type -// function call, which is an ObjectConsExpr containing key-value pairs -// where values may be optional(type, default) calls. -func extractObjectDefaults(expr hclsyntax.Expression) map[string]*optionalDefault { - obj, ok := expr.(*hclsyntax.ObjectConsExpr) - if !ok { - return nil - } - out := make(map[string]*optionalDefault) - for _, item := range obj.Items { - // Key must be a traversal or literal string we can read. - keyName := exprToLiteralString(item.KeyExpr) - if keyName == "" { - continue - } - // Value: check if it's optional(type, default) - valCall, isCall := item.ValueExpr.(*hclsyntax.FunctionCallExpr) - if !isCall || valCall.Name != "optional" { - // Not optional — could still be a nested object() with - // optional members inside it. - if nestedCall, nestedOk := item.ValueExpr.(*hclsyntax.FunctionCallExpr); nestedOk && nestedCall.Name == "object" && len(nestedCall.Args) == 1 { - children := extractObjectDefaults(nestedCall.Args[0]) - if len(children) > 0 { - out[keyName] = &optionalDefault{children: children} - } - } - continue - } - - od := &optionalDefault{} - - // optional(type) — no default, skip - // optional(type, default) — has default as 2nd arg - if len(valCall.Args) >= 2 { - // Try to evaluate the default expression as a literal. - ctx := buildEvalContext(cty.EmptyObjectVal, cty.EmptyObjectVal, cty.EmptyObjectVal, nil) - val, diags := valCall.Args[1].Value(ctx) - if !diags.HasErrors() { - od.defaultVal = val - } - } - - // The first arg of optional() is the type — check if it's - // object({...}) so we can recurse for nested defaults. - if len(valCall.Args) >= 1 { - if innerCall, innerOk := valCall.Args[0].(*hclsyntax.FunctionCallExpr); innerOk && innerCall.Name == "object" && len(innerCall.Args) == 1 { - od.children = extractObjectDefaults(innerCall.Args[0]) - } - } - - if od.defaultVal != cty.NilVal || len(od.children) > 0 { - out[keyName] = od - } - } - return out -} - -// exprToLiteralString tries to read a simple key expression as a plain -// string. Handles bare identifiers (the most common case in type -// constraints) and literal strings. -func exprToLiteralString(expr hclsyntax.Expression) string { - switch e := expr.(type) { - case *hclsyntax.ObjectConsKeyExpr: - // ObjectConsKeyExpr wraps the actual key expression; recurse. - return exprToLiteralString(e.Wrapped) - case *hclsyntax.ScopeTraversalExpr: - // Bare identifier like: os_disk = optional(...) - if len(e.Traversal) == 1 { - return e.Traversal.RootName() - } - case *hclsyntax.LiteralValueExpr: - if e.Val.Type() == cty.String { - return e.Val.AsString() - } - } - return "" -} - -// mergeDefaults takes a cty.Value (the variable's current value) and -// fills in missing keys with their optional() defaults, recursing into -// nested objects. -func mergeDefaults(current cty.Value, defaults map[string]*optionalDefault) cty.Value { - if !current.IsKnown() || current.IsNull() { - return cty.NilVal - } - ty := current.Type() - if !ty.IsObjectType() && !ty.IsMapType() { - return cty.NilVal - } - - // Convert to a mutable map of attribute values. - attrs := make(map[string]cty.Value) - it := current.ElementIterator() - for it.Next() { - k, v := it.Element() - attrs[k.AsString()] = v - } - - changed := false - for key, od := range defaults { - existing, exists := attrs[key] - - if !exists || existing.IsNull() { - // Key missing — apply default if we have one. - if od.defaultVal != cty.NilVal { - attrs[key] = applyChildDefaults(od.defaultVal, od.children) - changed = true - } - // No explicit default: Terraform leaves the attribute null, - // so we don't synthesize a value from child defaults alone. - // Only optional(object({...}), {}) (with explicit 2nd arg) - // should produce a populated object. - continue - } - - // Key exists — recurse into children if the value is an object. - if len(od.children) > 0 && existing.IsKnown() && !existing.IsNull() { - childTy := existing.Type() - if childTy.IsObjectType() || childTy.IsMapType() { - merged := mergeDefaults(existing, od.children) - if merged != cty.NilVal { - attrs[key] = merged - changed = true - } - } - } - } - - if !changed { - return cty.NilVal - } - return cty.ObjectVal(attrs) -} - -// applyChildDefaults takes a default value and, if it's an object with -// child optional() defaults defined, recursively fills in those children. -// This handles the common pattern: `os_disk = optional(object({...}), {})` -// where the default is empty but the nested type has its own defaults. -func applyChildDefaults(defVal cty.Value, children map[string]*optionalDefault) cty.Value { - if len(children) == 0 { - return defVal - } - if !defVal.IsKnown() || defVal.IsNull() { - return defVal - } - defTy := defVal.Type() - if !defTy.IsObjectType() && !defTy.IsMapType() { - return defVal - } - merged := mergeDefaults(defVal, children) - if merged != cty.NilVal { - return merged - } - return defVal -} diff --git a/internal/parser/terraform/optional_defaults_test.go b/internal/parser/terraform/optional_defaults_test.go deleted file mode 100644 index d2dfe4d..0000000 --- a/internal/parser/terraform/optional_defaults_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package terraform - -import ( - "testing" - - "github.com/hashicorp/hcl/v2/hclparse" - "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/zclconf/go-cty/cty" -) - -func TestApplyOptionalDefaults_FillsMissingKeys(t *testing.T) { - t.Parallel() - - src := ` -variable "vm" { - type = object({ - size = optional(string, "Standard_D2s_v3") - os_disk = optional(object({ - disk_size_gb = optional(number, 64) - storage_account_type = optional(string, "Premium_LRS") - }), {}) - }) -} -` - parser := hclparse.NewParser() - file, diags := parser.ParseHCL([]byte(src), "test.tf") - if diags.HasErrors() { - t.Fatalf("parse: %s", diags.Error()) - } - body := file.Body.(*hclsyntax.Body) - sources := []sourceFile{{Path: "test.tf", Body: body}} - - // Caller supplies only { dataDisks = [...] } — no size, no os_disk. - vars := map[string]cty.Value{ - "vm": cty.ObjectVal(map[string]cty.Value{ - "dataDisks": cty.ListValEmpty(cty.DynamicPseudoType), - }), - } - - applyOptionalDefaults(sources, vars) - - result := vars["vm"] - if !result.Type().HasAttribute("size") { - t.Fatal("expected 'size' attribute to be filled in") - } - size := result.GetAttr("size") - if size.AsString() != "Standard_D2s_v3" { - t.Errorf("size = %q, want %q", size.AsString(), "Standard_D2s_v3") - } - - if !result.Type().HasAttribute("os_disk") { - t.Fatal("expected 'os_disk' attribute to be filled in") - } - osDisk := result.GetAttr("os_disk") - if !osDisk.IsKnown() { - t.Error("os_disk should be known") - } -} - -func TestApplyOptionalDefaults_PreservesExistingKeys(t *testing.T) { - t.Parallel() - - src := ` -variable "config" { - type = object({ - name = optional(string, "default-name") - region = optional(string, "us-east-1") - }) -} -` - parser := hclparse.NewParser() - file, diags := parser.ParseHCL([]byte(src), "test.tf") - if diags.HasErrors() { - t.Fatalf("parse: %s", diags.Error()) - } - body := file.Body.(*hclsyntax.Body) - sources := []sourceFile{{Path: "test.tf", Body: body}} - - // Caller supplies name but not region. - vars := map[string]cty.Value{ - "config": cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("my-custom-name"), - }), - } - - applyOptionalDefaults(sources, vars) - - result := vars["config"] - name := result.GetAttr("name") - if name.AsString() != "my-custom-name" { - t.Errorf("name should be preserved: got %q", name.AsString()) - } - region := result.GetAttr("region") - if region.AsString() != "us-east-1" { - t.Errorf("region should be filled from default: got %q", region.AsString()) - } -} - -func TestExtractOptionalDefaults_NonObjectType(t *testing.T) { - t.Parallel() - - // variable with type = string — no optional() to extract - src := `variable "simple" { type = string }` - parser := hclparse.NewParser() - file, diags := parser.ParseHCL([]byte(src), "test.tf") - if diags.HasErrors() { - t.Fatalf("parse: %s", diags.Error()) - } - body := file.Body.(*hclsyntax.Body) - sources := []sourceFile{{Path: "test.tf", Body: body}} - - vars := map[string]cty.Value{ - "simple": cty.StringVal("hello"), - } - - // Should not panic or modify the value. - applyOptionalDefaults(sources, vars) - - if vars["simple"].AsString() != "hello" { - t.Errorf("unexpected modification: %v", vars["simple"]) - } -} diff --git a/internal/parser/terraform/parser.go b/internal/parser/terraform/parser.go index 15e2442..6f32234 100644 --- a/internal/parser/terraform/parser.go +++ b/internal/parser/terraform/parser.go @@ -125,10 +125,13 @@ func parseSources(sources []sourceFile, baseDir string, opts Options) ([]domain. variables[k] = parseCLIVar(v) } - // Stage 2b: apply optional() defaults from variable type constraints. - // This fills in attributes like `optional(string, "default")` for - // root-module variables, matching Terraform's runtime behavior. - applyOptionalDefaults(sources, variables) + // Stage 2b: normalise each variable to its declared `type` constraint, + // filling optional() object-attribute defaults the way Terraform does + // before anything reads the value. This covers both `optional(t, def)` + // (explicit default) and bare `optional(t)` (materialised as null), so + // downstream traversals like `each.value.x` resolve instead of erroring + // and being swallowed by try(). + applyVariableTypes(variables, collectVariableTypes(sources)) // Stage 3: data-block placeholders for `data.kind.name.attr` traversals. data := collectDataBlocks(sources) diff --git a/internal/parser/terraform/parser_test.go b/internal/parser/terraform/parser_test.go index b7f766e..23d614e 100644 --- a/internal/parser/terraform/parser_test.go +++ b/internal/parser/terraform/parser_test.go @@ -1,6 +1,7 @@ package terraform_test import ( + "fmt" "os" "path/filepath" "sort" @@ -70,6 +71,138 @@ func TestResolvesVariableReferencesInAttributes(t *testing.T) { } } +// TestOptionalObjectAttributeMaterialisesAsNull guards the fix for the +// terraform-aws-modules/rds-aurora indirection (issue #53): a variable +// typed `map(object({ instance_class = optional(string) }))` with a value +// of `{ one = {} }` must expose `each.value.instance_class` as null — not +// error — so `coalesce(each.value.instance_class, var.class)` falls through +// to the module variable instead of being swallowed by try() to null (which +// would let the resource drop back to the catalogue default class). +func TestOptionalObjectAttributeMaterialisesAsNull(t *testing.T) { + t.Parallel() + dir := t.TempDir() + write(t, dir, "main.tf", ` + variable "cluster_instance_class" { + type = string + default = "db.r6g.large" + } + variable "instances" { + type = map(object({ instance_class = optional(string) })) + default = { one = {} } + } + provider "aws" { region = "us-east-1" } + resource "aws_rds_cluster_instance" "this" { + for_each = var.instances + instance_class = try(coalesce(each.value.instance_class, var.cluster_instance_class), null) + engine = "aurora-postgresql" + } + `) + got, err := terraform.ParseDirectory(dir, terraform.Options{ + Vars: map[string]string{"cluster_instance_class": "db.r7g.2xlarge"}, + }) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("expected 1 resource, got %d", len(got)) + } + if got[0].Attributes["instance_class"] != "db.r7g.2xlarge" { + t.Errorf("instance_class = %v, want db.r7g.2xlarge (coalesce fell through the optional attribute)", + got[0].Attributes["instance_class"]) + } +} + +// TestExplicitOptionalDefaultsAndNesting covers the other side of the type +// constraint: `optional(t, default)` with an explicit default, nested inside +// another optional object. The caller supplies only a partial value, and the +// declared defaults (including the nested one) must fill in. +func TestExplicitOptionalDefaultsAndNesting(t *testing.T) { + t.Parallel() + dir := t.TempDir() + write(t, dir, "main.tf", ` + variable "node" { + type = object({ + size = optional(string, "t3.large") + root = optional(object({ + volume_size = optional(number, 100) + }), {}) + }) + default = {} + } + provider "aws" { region = "us-east-1" } + resource "aws_instance" "web" { + instance_type = var.node.size + root_block_device { + volume_size = var.node.root.volume_size + } + ami = "ami-x" + } + `) + got, err := terraform.ParseDirectory(dir, terraform.Options{}) + if err != nil { + t.Fatal(err) + } + if got[0].Attributes["instance_type"] != "t3.large" { + t.Errorf("instance_type = %v, want t3.large (explicit optional default)", + got[0].Attributes["instance_type"]) + } + rbd, ok := got[0].Attributes["root_block_device"].(map[string]any) + if !ok { + t.Fatalf("root_block_device = %#v, want nested map", got[0].Attributes["root_block_device"]) + } + // volume_size flows through cty as a number; compare stringified to + // avoid coupling to the exact numeric type the parser emits. + if fmt.Sprintf("%v", rbd["volume_size"]) != "100" { + t.Errorf("root volume_size = %v, want 100 (nested optional default)", rbd["volume_size"]) + } +} + +// TestModuleInputNormalisedToDeclaredType proves the same optional() +// materialisation happens for values passed into a child module, not just +// root-level defaults — the actual shape of the rds-aurora report. +func TestModuleInputNormalisedToDeclaredType(t *testing.T) { + t.Parallel() + dir := t.TempDir() + modDir := filepath.Join(dir, "mod") + if err := os.MkdirAll(modDir, 0o755); err != nil { + t.Fatal(err) + } + write(t, modDir, "main.tf", ` + variable "cluster_instance_class" { + type = string + default = "db.r6g.large" + } + variable "instances" { + type = map(object({ instance_class = optional(string) })) + default = {} + } + resource "aws_rds_cluster_instance" "this" { + for_each = var.instances + instance_class = try(coalesce(each.value.instance_class, var.cluster_instance_class), null) + engine = "aurora-postgresql" + } + `) + write(t, dir, "main.tf", ` + provider "aws" { region = "us-east-1" } + module "db" { + source = "./mod" + cluster_instance_class = "db.r7g.2xlarge" + instances = { one = {} } + } + `) + got, err := terraform.ParseDirectory(dir, terraform.Options{}) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("expected 1 resource, got %d", len(got)) + } + if got[0].Attributes["instance_class"] != "db.r7g.2xlarge" { + t.Errorf("instance_class = %v, want db.r7g.2xlarge (module input not normalised to declared type)", + got[0].Attributes["instance_class"]) + } +} + // TestResolvesLocalsWithChainedReferences exercises the fixed-point // loop: local.tier depends on local.prefix which depends on var.env. func TestResolvesLocalsWithChainedReferences(t *testing.T) { diff --git a/internal/parser/terraform/variables.go b/internal/parser/terraform/variables.go index 5f2ba84..eed8fd0 100644 --- a/internal/parser/terraform/variables.go +++ b/internal/parser/terraform/variables.go @@ -8,9 +8,11 @@ import ( "sort" "strings" + "github.com/hashicorp/hcl/v2/ext/typeexpr" "github.com/hashicorp/hcl/v2/hclparse" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" ) // collectVariableDefaults extracts each `variable "x" { default = ... }` @@ -41,6 +43,85 @@ func collectVariableDefaults(sources []sourceFile) (map[string]cty.Value, error) return out, nil } +// variableTypeConstraint is a variable's declared `type` constraint plus +// the optional-attribute defaults derived from it. Applying it to a value +// mirrors what Terraform does when it decodes a variable: object attributes +// declared `optional(...)` that are absent from the given value are filled +// with their declared default (or null), and the value is then converted to +// the constraint type. +// +// This matters for registry modules like terraform-aws-modules/rds-aurora, +// whose `instances` input is typed `map(object({ instance_class = +// optional(string), ... }))`. Without the constraint, a caller value of +// `{ one = {} }` stays an empty object, so a downstream traversal such as +// `try(coalesce(each.value.instance_class, var.cluster_instance_class), null)` +// errors on the missing attribute, `try` swallows the whole expression to +// null, and the resource silently falls back to the catalogue default class. +// With the constraint applied, `each.value.instance_class` resolves to null +// and the coalesce correctly picks up the module variable. +type variableTypeConstraint struct { + ty cty.Type + defaults *typeexpr.Defaults +} + +// apply fills optional-attribute defaults and converts val to the declared +// type. It is best-effort: a value that can't be converted (an unexpected +// shape) is returned unchanged so estimation degrades gracefully rather than +// dropping the resource. Null and unknown values are left untouched. +func (c variableTypeConstraint) apply(val cty.Value) cty.Value { + if val.IsNull() || !val.IsKnown() { + return val + } + out := val + if c.defaults != nil { + out = c.defaults.Apply(out) + } + converted, err := convert.Convert(out, c.ty) + if err != nil { + return val + } + return converted +} + +// collectVariableTypes reads each `variable "x" { type = ... }` constraint, +// keyed by variable name. Variables without a `type`, or typed `any` +// (cty.DynamicPseudoType), are omitted: `any` imposes no constraint, and +// forcing a conversion for it would strip useful structure. Unparseable +// type expressions are skipped rather than failing the estimate. +func collectVariableTypes(sources []sourceFile) map[string]variableTypeConstraint { + out := map[string]variableTypeConstraint{} + for _, src := range sources { + for _, block := range src.Body.Blocks { + if block.Type != "variable" || len(block.Labels) == 0 { + continue + } + typeAttr, ok := block.Body.Attributes["type"] + if !ok { + continue + } + ty, defaults, diags := typeexpr.TypeConstraintWithDefaults(typeAttr.Expr) + if diags.HasErrors() || ty == cty.DynamicPseudoType { + continue + } + out[block.Labels[0]] = variableTypeConstraint{ty: ty, defaults: defaults} + } + } + return out +} + +// applyVariableTypes converts every value in vars that has a declared type +// constraint, in place. Call it once the var scope is fully layered +// (defaults + tfvars + CLI, or defaults + tfvars + module inputs) so the +// final value — whatever its source — is normalised the way Terraform would +// normalise it before locals, meta-arguments, and attributes read it. +func applyVariableTypes(vars map[string]cty.Value, types map[string]variableTypeConstraint) { + for name, c := range types { + if v, ok := vars[name]; ok { + vars[name] = c.apply(v) + } + } +} + // applyAutoTfvars layers Terraform's auto-loading tfvars files onto the // variables map, in the order Terraform itself loads them: //