From 8f69e36e1b6b5a35ecdb4992d7acff8e0fea3c46 Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Thu, 21 Apr 2022 14:37:30 -0400 Subject: [PATCH 1/5] typeexpr: Add support for nested type defaults In type constraints, object attributes may be marked as optional in order to allow them to be omitted from input values. Doing so results in filling the attribute value with a typed `null`. This commit adds a new type `typeexpr.Defaults` which mirrors the structure of a type constraint, storing default values for optional attributes. This will allow specification of non-`null` default values for attributes. The `Defaults` type is a tree structure, each node containing a sub-tree type, a map of children, and for object nodes, a map of defaults. The keys in the children map depend on the type of the node: - Object nodes have children for each attribute; - Tuple nodes have children for each index, with indices converted to string values; - Collection nodes have a single child at the empty string key. When traversing this tree we must take this structure into account, with special cases for map input values which may later be converted to objects. The traversal defined in this commit uses a pre-order transformer in order to pre-populate descendent nodes before their defaults are applied. This allows type nested type default values to be specified more compactly. --- internal/typeexpr/defaults.go | 157 +++++++++++ internal/typeexpr/defaults_test.go | 429 +++++++++++++++++++++++++++++ 2 files changed, 586 insertions(+) create mode 100644 internal/typeexpr/defaults.go create mode 100644 internal/typeexpr/defaults_test.go diff --git a/internal/typeexpr/defaults.go b/internal/typeexpr/defaults.go new file mode 100644 index 000000000000..fe1d5776b2b8 --- /dev/null +++ b/internal/typeexpr/defaults.go @@ -0,0 +1,157 @@ +package typeexpr + +import ( + "github.com/zclconf/go-cty/cty" +) + +// Defaults represents a type tree which may contain default values for +// optional object attributes at any level. This is used to apply nested +// defaults to an input value before converting it to the concrete type. +type Defaults struct { + // Type of the node for which these defaults apply. This is necessary in + // order to determine how to inspect the Defaults and Children collections. + Type cty.Type + + // DefaultValues contains the default values for each object attribute, + // indexed by attribute name. + DefaultValues map[string]cty.Value + + // Children is a map of Defaults for elements contained in this type. This + // only applies to structural and collection types. + // + // The map is indexed by string instead of cty.Value because cty.Number + // instances are non-comparable, due to embedding a *big.Float. + // + // Collections have a single element type, which is stored at key "". + Children map[string]*Defaults +} + +// Apply walks the given value, applying specified defaults wherever optional +// attributes are missing. The input and output values may have different +// types, and the result may still require type conversion to the final desired +// type. +// +// This function is permissive and does not report errors, assuming that the +// caller will have better context to report useful type conversion failure +// diagnostics. +func (d *Defaults) Apply(val cty.Value) cty.Value { + val, err := cty.TransformWithTransformer(val, &defaultsTransformer{defaults: d}) + + // The transformer should never return an error. + if err != nil { + panic(err) + } + + return val +} + +// defaultsTransformer implements cty.Transformer, as a pre-order traversal, +// applying defaults as it goes. The pre-order traversal allows us to specify +// defaults more loosely for structural types, as the defaults for the types +// will be applied to the default value later in the walk. +type defaultsTransformer struct { + defaults *Defaults +} + +var _ cty.Transformer = (*defaultsTransformer)(nil) + +func (t *defaultsTransformer) Enter(p cty.Path, v cty.Value) (cty.Value, error) { + // Cannot apply defaults to an unknown value + if !v.IsKnown() { + return v, nil + } + + // Look up the defaults for this path. + defaults := t.defaults.traverse(p) + + // If we have no defaults, nothing to do. + if len(defaults) == 0 { + return v, nil + } + + // Ensure we are working with an object or map. + vt := v.Type() + if !vt.IsObjectType() && !vt.IsMapType() { + // Cannot apply defaults because the value type is incompatible. + // We'll ignore this and let the later conversion stage display a + // more useful diagnostic. + return v, nil + } + + // Unmark the value and reapply the marks later. + v, valMarks := v.Unmark() + + // Convert the given value into an attribute map (if it's non-null and + // non-empty). + attrs := make(map[string]cty.Value) + if !v.IsNull() && v.LengthInt() > 0 { + attrs = v.AsValueMap() + } + + // Apply defaults where attributes are missing, constructing a new + // value with the same marks. + for attr, defaultValue := range defaults { + if _, ok := attrs[attr]; !ok { + attrs[attr] = defaultValue + } + } + + // We construct an object even if the input value was a map, as the + // type of an attribute's default value may be incompatible with the + // map element type. + return cty.ObjectVal(attrs).WithMarks(valMarks), nil +} + +func (t *defaultsTransformer) Exit(p cty.Path, v cty.Value) (cty.Value, error) { + return v, nil +} + +// traverse walks the abstract defaults structure for a given path, returning +// a set of default values (if any are present) or nil (if not). This operation +// differs from applying a path to a value because we need to customize the +// traversal steps for collection types, where a single set of defaults can be +// applied to an arbitrary number of elements. +func (d *Defaults) traverse(path cty.Path) map[string]cty.Value { + if len(path) == 0 { + return d.DefaultValues + } + + switch s := path[0].(type) { + case cty.GetAttrStep: + if d.Type.IsObjectType() { + // Attribute path steps are normally applied to objects, where each + // attribute may have different defaults. + return d.traverseChild(s.Name, path) + } else if d.Type.IsMapType() { + // Literal values for maps can result in attribute path steps, in which + // case we need to disregard the attribute name, as maps can have only + // one child. + return d.traverseChild("", path) + } + + return nil + case cty.IndexStep: + if d.Type.IsTupleType() { + // Tuples can have different types for each element, so we look + // up the defaults based on the index key. + return d.traverseChild(s.Key.AsBigFloat().String(), path) + } else if d.Type.IsCollectionType() { + // Defaults for collection element types are stored with a blank + // key, so we disregard the index key. + return d.traverseChild("", path) + } + return nil + default: + // At time of writing there are no other path step types. + return nil + } +} + +// traverseChild continues the traversal for a given child key, and mutually +// recurses with traverse. +func (d *Defaults) traverseChild(name string, path cty.Path) map[string]cty.Value { + if child, ok := d.Children[name]; ok { + return child.traverse(path[1:]) + } + return nil +} diff --git a/internal/typeexpr/defaults_test.go b/internal/typeexpr/defaults_test.go new file mode 100644 index 000000000000..b37e92c515fc --- /dev/null +++ b/internal/typeexpr/defaults_test.go @@ -0,0 +1,429 @@ +package typeexpr + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/zclconf/go-cty/cty" +) + +var ( + valueComparer = cmp.Comparer(cty.Value.RawEquals) +) + +func TestDefaults_Apply(t *testing.T) { + simpleObject := cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.String, + "b": cty.Bool, + }, []string{"b"}) + nestedObject := cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "c": simpleObject, + "d": cty.Number, + }, []string{"c"}) + + testCases := map[string]struct { + defaults *Defaults + value cty.Value + want cty.Value + }{ + // Nothing happens when there are no default values and no children. + "no defaults": { + defaults: &Defaults{ + Type: cty.Map(cty.String), + }, + value: cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.StringVal("bar"), + }), + want: cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.StringVal("bar"), + }), + }, + // Passing a map which does not include one of the attributes with a + // default results in the default being applied to the output. Output + // is always an object. + "simple object with defaults applied": { + defaults: &Defaults{ + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + value: cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + want: cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + }, + // Unknown values may be assigned to root modules during validation, + // and we cannot apply defaults at that time. + "simple object with defaults but unknown value": { + defaults: &Defaults{ + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + value: cty.UnknownVal(cty.Map(cty.String)), + want: cty.UnknownVal(cty.Map(cty.String)), + }, + // Defaults do not override attributes which are present in the given + // value. + "simple object with optional attributes specified": { + defaults: &Defaults{ + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + value: cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.StringVal("false"), + }), + want: cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.StringVal("false"), + }), + }, + // Defaults can be specified at any level of depth and will be applied + // so long as there is a parent value to populate. + "nested object with defaults applied": { + defaults: &Defaults{ + Type: nestedObject, + Children: map[string]*Defaults{ + "c": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.False, + }, + }, + }, + }, + value: cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "d": cty.NumberIntVal(5), + }), + want: cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.False, + }), + "d": cty.NumberIntVal(5), + }), + }, + // Testing traversal of collections. + "map of objects with defaults applied": { + defaults: &Defaults{ + Type: cty.Map(simpleObject), + Children: map[string]*Defaults{ + "": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + value: cty.MapVal(map[string]cty.Value{ + "f": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("bar"), + }), + }), + want: cty.MapVal(map[string]cty.Value{ + "f": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("bar"), + "b": cty.True, + }), + }), + }, + // A map variable value specified in a tfvars file will be an object, + // in which case we must still traverse the defaults structure + // correctly. + "map of objects with defaults applied, given object instead of map": { + defaults: &Defaults{ + Type: cty.Map(simpleObject), + Children: map[string]*Defaults{ + "": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + value: cty.ObjectVal(map[string]cty.Value{ + "f": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("bar"), + }), + }), + want: cty.ObjectVal(map[string]cty.Value{ + "f": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("bar"), + "b": cty.True, + }), + }), + }, + // Another example of a collection type, this time exercising the code + // processing a tuple input. + "list of objects with defaults applied": { + defaults: &Defaults{ + Type: cty.List(simpleObject), + Children: map[string]*Defaults{ + "": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + value: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("bar"), + }), + }), + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("bar"), + "b": cty.True, + }), + }), + }, + // Unlike collections, tuple variable types can have defaults for + // multiple element types. + "tuple of objects with defaults applied": { + defaults: &Defaults{ + Type: cty.Tuple([]cty.Type{simpleObject, nestedObject}), + Children: map[string]*Defaults{ + "0": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.False, + }, + }, + "1": { + Type: nestedObject, + DefaultValues: map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("default"), + "b": cty.True, + }), + }, + }, + }, + }, + value: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + cty.ObjectVal(map[string]cty.Value{ + "d": cty.NumberIntVal(5), + }), + }), + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.False, + }), + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("default"), + "b": cty.True, + }), + "d": cty.NumberIntVal(5), + }), + }), + }, + // More complex cases with deeply nested defaults, testing the "default + // within a default" edges. + "set of nested objects, no default sub-object": { + defaults: &Defaults{ + Type: cty.Set(nestedObject), + Children: map[string]*Defaults{ + "": { + Type: nestedObject, + Children: map[string]*Defaults{ + "c": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + }, + }, + value: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + "d": cty.NumberIntVal(7), + }), + }), + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + // No default value for "c" specified, so none applied. The + // convert stage will fill in a null. + "d": cty.NumberIntVal(7), + }), + }), + }, + "set of nested objects, empty default sub-object": { + defaults: &Defaults{ + Type: cty.Set(nestedObject), + Children: map[string]*Defaults{ + "": { + Type: nestedObject, + DefaultValues: map[string]cty.Value{ + // This is a convenient shorthand which causes a + // missing sub-object to be filled with an object + // with all of the default values specified in the + // sub-object's type. + "c": cty.EmptyObjectVal, + }, + Children: map[string]*Defaults{ + "c": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + }, + }, + value: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + "d": cty.NumberIntVal(7), + }), + }), + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + // Default value for "b" is applied to the empty object + // specified as the default for "c" + "b": cty.True, + }), + "d": cty.NumberIntVal(7), + }), + }), + }, + "set of nested objects, overriding default sub-object": { + defaults: &Defaults{ + Type: cty.Set(nestedObject), + Children: map[string]*Defaults{ + "": { + Type: nestedObject, + DefaultValues: map[string]cty.Value{ + // If no value is given for "c", we use this object + // of non-default values instead. These take + // precedence over the default values specified in + // the child type. + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("fallback"), + "b": cty.NullVal(cty.Bool), + }), + }, + Children: map[string]*Defaults{ + "c": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + }, + }, + value: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + "d": cty.NumberIntVal(7), + }), + }), + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + // The default value for "b" is not applied, as the + // default value for "c" includes a non-default value + // already. + "a": cty.StringVal("fallback"), + "b": cty.NullVal(cty.Bool), + }), + "d": cty.NumberIntVal(7), + }), + }), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got := tc.defaults.Apply(tc.value) + if !cmp.Equal(tc.want, got, valueComparer) { + t.Errorf("wrong result\n%s", cmp.Diff(tc.want, got, valueComparer)) + } + }) + } +} From 650380f3ae5527a5446e35ccb4dd4c801c467204 Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Thu, 21 Apr 2022 15:09:44 -0400 Subject: [PATCH 2/5] configs: Add default argument to optional() The optional modifier previously accepted a single argument: the attribute type. This commit adds an optional second argument, which specifies a default value for the attribute. To record the default values for a variable's type, we use a separate parallel structure of `typeexpr.Defaults`, rather than extending `cty.Type` to include a `cty.Value` of defaults (which may in turn include a `cty.Type` with defaults, and so on, and so forth). The new `typeexpr.TypeConstraintWithDefaults` returns a type constraint and defaults value. Defaults will be `nil` unless there are default values specified somewhere in the variable's type. --- internal/configs/named_values.go | 33 ++- .../object-optional-attrs-experiment.tf | 1 + internal/typeexpr/get_type.go | 172 ++++++++--- internal/typeexpr/get_type_test.go | 270 +++++++++++++++++- internal/typeexpr/public.go | 18 +- 5 files changed, 435 insertions(+), 59 deletions(-) diff --git a/internal/configs/named_values.go b/internal/configs/named_values.go index 10882ac088f4..9491224e1ef6 100644 --- a/internal/configs/named_values.go +++ b/internal/configs/named_values.go @@ -27,6 +27,7 @@ type Variable struct { // ConstraintType is used for decoding and type conversions, and may // contain nested ObjectWithOptionalAttr types. ConstraintType cty.Type + TypeDefaults *typeexpr.Defaults ParsingMode VariableParsingMode Validations []*CheckRule @@ -102,9 +103,10 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno } if attr, exists := content.Attributes["type"]; exists { - ty, parseMode, tyDiags := decodeVariableType(attr.Expr) + ty, tyDefaults, parseMode, tyDiags := decodeVariableType(attr.Expr) diags = append(diags, tyDiags...) v.ConstraintType = ty + v.TypeDefaults = tyDefaults v.Type = ty.WithoutOptionalAttributesDeep() v.ParsingMode = parseMode } @@ -137,6 +139,11 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno // the type might not be set; we'll catch that during merge. 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 { + val = v.TypeDefaults.Apply(val) + } val, err = convert.Convert(val, v.ConstraintType) if err != nil { diags = append(diags, &hcl.Diagnostic{ @@ -179,7 +186,7 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno return v, diags } -func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl.Diagnostics) { +func decodeVariableType(expr hcl.Expression) (cty.Type, *typeexpr.Defaults, VariableParsingMode, hcl.Diagnostics) { if exprIsNativeQuotedString(expr) { // If a user provides the pre-0.12 form of variable type argument where // the string values "string", "list" and "map" are accepted, we @@ -190,7 +197,7 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl // in the normal codepath below. val, diags := expr.Value(nil) if diags.HasErrors() { - return cty.DynamicPseudoType, VariableParseHCL, diags + return cty.DynamicPseudoType, nil, VariableParseHCL, diags } str := val.AsString() switch str { @@ -201,7 +208,7 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl Detail: "Terraform 0.11 and earlier required type constraints to be given in quotes, but that form is now deprecated and will be removed in a future version of Terraform. Remove the quotes around \"string\".", Subject: expr.Range().Ptr(), }) - return cty.DynamicPseudoType, VariableParseLiteral, diags + return cty.DynamicPseudoType, nil, VariableParseLiteral, diags case "list": diags = append(diags, &hcl.Diagnostic{ Severity: hcl.DiagError, @@ -209,7 +216,7 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl Detail: "Terraform 0.11 and earlier required type constraints to be given in quotes, but that form is now deprecated and will be removed in a future version of Terraform. Remove the quotes around \"list\" and write list(string) instead to explicitly indicate that the list elements are strings.", Subject: expr.Range().Ptr(), }) - return cty.DynamicPseudoType, VariableParseHCL, diags + return cty.DynamicPseudoType, nil, VariableParseHCL, diags case "map": diags = append(diags, &hcl.Diagnostic{ Severity: hcl.DiagError, @@ -217,9 +224,9 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl Detail: "Terraform 0.11 and earlier required type constraints to be given in quotes, but that form is now deprecated and will be removed in a future version of Terraform. Remove the quotes around \"map\" and write map(string) instead to explicitly indicate that the map elements are strings.", Subject: expr.Range().Ptr(), }) - return cty.DynamicPseudoType, VariableParseHCL, diags + return cty.DynamicPseudoType, nil, VariableParseHCL, diags default: - return cty.DynamicPseudoType, VariableParseHCL, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, VariableParseHCL, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: "Invalid legacy variable type hint", Detail: `To provide a full type expression, remove the surrounding quotes and give the type expression directly.`, @@ -234,23 +241,23 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl // elements are consistent. This is the same as list(any) or map(any). switch hcl.ExprAsKeyword(expr) { case "list": - return cty.List(cty.DynamicPseudoType), VariableParseHCL, nil + return cty.List(cty.DynamicPseudoType), nil, VariableParseHCL, nil case "map": - return cty.Map(cty.DynamicPseudoType), VariableParseHCL, nil + return cty.Map(cty.DynamicPseudoType), nil, VariableParseHCL, nil } - ty, diags := typeexpr.TypeConstraint(expr) + ty, typeDefaults, diags := typeexpr.TypeConstraintWithDefaults(expr) if diags.HasErrors() { - return cty.DynamicPseudoType, VariableParseHCL, diags + return cty.DynamicPseudoType, nil, VariableParseHCL, diags } switch { case ty.IsPrimitiveType(): // Primitive types use literal parsing. - return ty, VariableParseLiteral, diags + return ty, typeDefaults, VariableParseLiteral, diags default: // Everything else uses HCL parsing - return ty, VariableParseHCL, diags + return ty, typeDefaults, VariableParseHCL, diags } } diff --git a/internal/configs/testdata/warning-files/object-optional-attrs-experiment.tf b/internal/configs/testdata/warning-files/object-optional-attrs-experiment.tf index 1645fb0eca70..90bd8a6329c9 100644 --- a/internal/configs/testdata/warning-files/object-optional-attrs-experiment.tf +++ b/internal/configs/testdata/warning-files/object-optional-attrs-experiment.tf @@ -7,6 +7,7 @@ terraform { variable "a" { type = object({ foo = optional(string) + bar = optional(bool, true) }) } diff --git a/internal/typeexpr/get_type.go b/internal/typeexpr/get_type.go index 726326adccda..10ed611cb2c6 100644 --- a/internal/typeexpr/get_type.go +++ b/internal/typeexpr/get_type.go @@ -5,49 +5,52 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" ) const invalidTypeSummary = "Invalid type specification" -// getType is the internal implementation of both Type and TypeConstraint, -// using the passed flag to distinguish. When constraint is false, the "any" -// keyword will produce an error. -func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { +// getType is the internal implementation of Type, TypeConstraint, and +// TypeConstraintWithDefaults, using the passed flags to distinguish. When +// `constraint` is true, the "any" keyword can be used in place of a concrete +// type. When `withDefaults` is true, the "optional" call expression supports +// an additional argument describing a default value. +func getType(expr hcl.Expression, constraint, withDefaults bool) (cty.Type, *Defaults, hcl.Diagnostics) { // First we'll try for one of our keywords kw := hcl.ExprAsKeyword(expr) switch kw { case "bool": - return cty.Bool, nil + return cty.Bool, nil, nil case "string": - return cty.String, nil + return cty.String, nil, nil case "number": - return cty.Number, nil + return cty.Number, nil, nil case "any": if constraint { - return cty.DynamicPseudoType, nil + return cty.DynamicPseudoType, nil, nil } - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: fmt.Sprintf("The keyword %q cannot be used in this type specification: an exact type is required.", kw), Subject: expr.Range().Ptr(), }} case "list", "map", "set": - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: fmt.Sprintf("The %s type constructor requires one argument specifying the element type.", kw), Subject: expr.Range().Ptr(), }} case "object": - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: "The object type constructor requires one argument specifying the attribute types and values as a map.", Subject: expr.Range().Ptr(), }} case "tuple": - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: "The tuple type constructor requires one argument specifying the element types as a list.", @@ -56,7 +59,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { case "": // okay! we'll fall through and try processing as a call, then. default: - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: fmt.Sprintf("The keyword %q is not a valid type specification.", kw), @@ -68,7 +71,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { // try to process it as a call instead. call, diags := hcl.ExprCall(expr) if diags.HasErrors() { - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: "A type specification is either a primitive type keyword (bool, number, string) or a complex type constructor call, like list(string).", @@ -78,14 +81,14 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { switch call.Name { case "bool", "string", "number": - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: fmt.Sprintf("Primitive type keyword %q does not expect arguments.", call.Name), Subject: &call.ArgsRange, }} case "any": - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: fmt.Sprintf("Type constraint keyword %q does not expect arguments.", call.Name), @@ -105,7 +108,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { switch call.Name { case "list", "set", "map": - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: fmt.Sprintf("The %s type constructor requires one argument specifying the element type.", call.Name), @@ -113,7 +116,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { Context: &contextRange, }} case "object": - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: "The object type constructor requires one argument specifying the attribute types and values as a map.", @@ -121,7 +124,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { Context: &contextRange, }} case "tuple": - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: "The tuple type constructor requires one argument specifying the element types as a list.", @@ -134,18 +137,21 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { switch call.Name { case "list": - ety, diags := getType(call.Arguments[0], constraint) - return cty.List(ety), diags + ety, defaults, diags := getType(call.Arguments[0], constraint, withDefaults) + ty := cty.List(ety) + return ty, collectionDefaults(ty, defaults), diags case "set": - ety, diags := getType(call.Arguments[0], constraint) - return cty.Set(ety), diags + ety, defaults, diags := getType(call.Arguments[0], constraint, withDefaults) + ty := cty.Set(ety) + return ty, collectionDefaults(ty, defaults), diags case "map": - ety, diags := getType(call.Arguments[0], constraint) - return cty.Map(ety), diags + ety, defaults, diags := getType(call.Arguments[0], constraint, withDefaults) + ty := cty.Map(ety) + return ty, collectionDefaults(ty, defaults), diags case "object": attrDefs, diags := hcl.ExprMap(call.Arguments[0]) if diags.HasErrors() { - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: "Object type constructor requires a map whose keys are attribute names and whose values are the corresponding attribute types.", @@ -155,6 +161,8 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { } atys := make(map[string]cty.Type) + defaultValues := make(map[string]cty.Value) + children := make(map[string]*Defaults) var optAttrs []string for _, attrDef := range attrDefs { attrName := hcl.ExprAsKeyword(attrDef.Key) @@ -174,6 +182,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { // modifier optional(...) to indicate an optional attribute. If // so, we'll unwrap that first and make a note about it being // optional for when we construct the type below. + var defaultExpr hcl.Expression if call, callDiags := hcl.ExprCall(atyExpr); !callDiags.HasErrors() { if call.Name == "optional" { if len(call.Arguments) < 1 { @@ -187,16 +196,40 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { continue } if constraint { - if len(call.Arguments) > 1 { - diags = append(diags, &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: invalidTypeSummary, - Detail: "Optional attribute modifier expects only one argument: the attribute type.", - Subject: call.ArgsRange.Ptr(), - Context: atyExpr.Range().Ptr(), - }) + if withDefaults { + switch len(call.Arguments) { + case 2: + defaultExpr = call.Arguments[1] + defaultVal, defaultDiags := defaultExpr.Value(nil) + diags = append(diags, defaultDiags...) + if !defaultDiags.HasErrors() { + optAttrs = append(optAttrs, attrName) + defaultValues[attrName] = defaultVal + } + case 1: + optAttrs = append(optAttrs, attrName) + default: + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "Optional attribute modifier expects at most two arguments: the attribute type, and a default value.", + Subject: call.ArgsRange.Ptr(), + Context: atyExpr.Range().Ptr(), + }) + } + } else { + if len(call.Arguments) == 1 { + optAttrs = append(optAttrs, attrName) + } else { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "Optional attribute modifier expects only one argument: the attribute type.", + Subject: call.ArgsRange.Ptr(), + Context: atyExpr.Range().Ptr(), + }) + } } - optAttrs = append(optAttrs, attrName) } else { diags = append(diags, &hcl.Diagnostic{ Severity: hcl.DiagError, @@ -210,19 +243,39 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { } } - aty, attrDiags := getType(atyExpr, constraint) + aty, aDefaults, attrDiags := getType(atyExpr, constraint, withDefaults) diags = append(diags, attrDiags...) + + // If a default is set for an optional attribute, verify that it is + // convertible to the attribute type. + if defaultVal, ok := defaultValues[attrName]; ok { + _, err := convert.Convert(defaultVal, aty) + if err != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid default value for optional attribute", + Detail: fmt.Sprintf("This default value is not compatible with the attribute's type constraint: %s.", err), + Subject: defaultExpr.Range().Ptr(), + }) + delete(defaultValues, attrName) + } + } + atys[attrName] = aty + if aDefaults != nil { + children[attrName] = aDefaults + } } // NOTE: ObjectWithOptionalAttrs is experimental in cty at the // time of writing, so this interface might change even in future // minor versions of cty. We're accepting that because Terraform // itself is considering optional attributes as experimental right now. - return cty.ObjectWithOptionalAttrs(atys, optAttrs), diags + ty := cty.ObjectWithOptionalAttrs(atys, optAttrs) + return ty, structuredDefaults(ty, defaultValues, children), diags case "tuple": elemDefs, diags := hcl.ExprList(call.Arguments[0]) if diags.HasErrors() { - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: "Tuple type constructor requires a list of element types.", @@ -231,14 +284,19 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { }} } etys := make([]cty.Type, len(elemDefs)) + children := make(map[string]*Defaults, len(elemDefs)) for i, defExpr := range elemDefs { - ety, elemDiags := getType(defExpr, constraint) + ety, elemDefaults, elemDiags := getType(defExpr, constraint, withDefaults) diags = append(diags, elemDiags...) etys[i] = ety + if elemDefaults != nil { + children[fmt.Sprintf("%d", i)] = elemDefaults + } } - return cty.Tuple(etys), diags + ty := cty.Tuple(etys) + return ty, structuredDefaults(ty, nil, children), diags case "optional": - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: fmt.Sprintf("Keyword %q is valid only as a modifier for object type attributes.", call.Name), @@ -247,7 +305,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { default: // Can't access call.Arguments in this path because we've not validated // that it contains exactly one expression here. - return cty.DynamicPseudoType, hcl.Diagnostics{{ + return cty.DynamicPseudoType, nil, hcl.Diagnostics{{ Severity: hcl.DiagError, Summary: invalidTypeSummary, Detail: fmt.Sprintf("Keyword %q is not a valid type constructor.", call.Name), @@ -255,3 +313,33 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { }} } } + +func collectionDefaults(ty cty.Type, defaults *Defaults) *Defaults { + if defaults == nil { + return nil + } + return &Defaults{ + Type: ty, + Children: map[string]*Defaults{ + "": defaults, + }, + } +} + +func structuredDefaults(ty cty.Type, defaultValues map[string]cty.Value, children map[string]*Defaults) *Defaults { + if len(defaultValues) == 0 && len(children) == 0 { + return nil + } + + defaults := &Defaults{ + Type: ty, + } + if len(defaultValues) > 0 { + defaults.DefaultValues = defaultValues + } + if len(children) > 0 { + defaults.Children = children + } + + return defaults +} diff --git a/internal/typeexpr/get_type_test.go b/internal/typeexpr/get_type_test.go index e46dca3ff988..2dca23d27e41 100644 --- a/internal/typeexpr/get_type_test.go +++ b/internal/typeexpr/get_type_test.go @@ -6,12 +6,17 @@ import ( "github.com/hashicorp/hcl/v2/gohcl" + "github.com/google/go-cmp/cmp" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/json" "github.com/zclconf/go-cty/cty" ) +var ( + typeComparer = cmp.Comparer(cty.Type.Equals) +) + func TestGetType(t *testing.T) { tests := []struct { Source string @@ -284,6 +289,23 @@ func TestGetType(t *testing.T) { }), `Optional attribute modifier is only for type constraints, not for exact types.`, }, + { + `object({name=string,meta=optional()})`, + true, + cty.Object(map[string]cty.Type{ + "name": cty.String, + }), + `Optional attribute modifier requires the attribute type as its argument.`, + }, + { + `object({name=string,meta=optional(string, "hello")})`, + true, + cty.Object(map[string]cty.Type{ + "name": cty.String, + "meta": cty.String, + }), + `Optional attribute modifier expects only one argument: the attribute type.`, + }, { `optional(string)`, false, @@ -305,7 +327,7 @@ func TestGetType(t *testing.T) { t.Fatalf("failed to parse: %s", diags) } - got, diags := getType(expr, test.Constraint) + got, _, diags := getType(expr, test.Constraint, false) if test.WantError == "" { for _, diag := range diags { t.Error(diag) @@ -377,7 +399,7 @@ func TestGetTypeJSON(t *testing.T) { t.Fatalf("failed to decode: %s", diags) } - got, diags := getType(content.Expr, test.Constraint) + got, _, diags := getType(content.Expr, test.Constraint, false) if test.WantError == "" { for _, diag := range diags { t.Error(diag) @@ -401,3 +423,247 @@ func TestGetTypeJSON(t *testing.T) { }) } } + +func TestGetTypeDefaults(t *testing.T) { + tests := []struct { + Source string + Want *Defaults + WantError string + }{ + // primitive types have nil defaults + { + `bool`, + nil, + "", + }, + { + `number`, + nil, + "", + }, + { + `string`, + nil, + "", + }, + { + `any`, + nil, + "", + }, + + // complex structures with no defaults have nil defaults + { + `map(string)`, + nil, + "", + }, + { + `set(number)`, + nil, + "", + }, + { + `tuple([number, string])`, + nil, + "", + }, + { + `object({ a = string, b = number })`, + nil, + "", + }, + { + `map(list(object({ a = string, b = optional(number) })))`, + nil, + "", + }, + + // object optional attribute with defaults + { + `object({ a = string, b = optional(number, 5) })`, + &Defaults{ + Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.String, + "b": cty.Number, + }, []string{"b"}), + DefaultValues: map[string]cty.Value{ + "b": cty.NumberIntVal(5), + }, + }, + "", + }, + + // nested defaults + { + `object({ a = optional(object({ b = optional(number, 5) }), {}) })`, + &Defaults{ + Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "b": cty.Number, + }, []string{"b"}), + }, []string{"a"}), + DefaultValues: map[string]cty.Value{ + "a": cty.EmptyObjectVal, + }, + Children: map[string]*Defaults{ + "a": { + Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "b": cty.Number, + }, []string{"b"}), + DefaultValues: map[string]cty.Value{ + "b": cty.NumberIntVal(5), + }, + }, + }, + }, + "", + }, + + // collections of objects with defaults + { + `map(object({ a = string, b = optional(number, 5) }))`, + &Defaults{ + Type: cty.Map(cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.String, + "b": cty.Number, + }, []string{"b"})), + Children: map[string]*Defaults{ + "": { + Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.String, + "b": cty.Number, + }, []string{"b"}), + DefaultValues: map[string]cty.Value{ + "b": cty.NumberIntVal(5), + }, + }, + }, + }, + "", + }, + { + `list(object({ a = string, b = optional(number, 5) }))`, + &Defaults{ + Type: cty.List(cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.String, + "b": cty.Number, + }, []string{"b"})), + Children: map[string]*Defaults{ + "": { + Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.String, + "b": cty.Number, + }, []string{"b"}), + DefaultValues: map[string]cty.Value{ + "b": cty.NumberIntVal(5), + }, + }, + }, + }, + "", + }, + { + `set(object({ a = string, b = optional(number, 5) }))`, + &Defaults{ + Type: cty.Set(cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.String, + "b": cty.Number, + }, []string{"b"})), + Children: map[string]*Defaults{ + "": { + Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.String, + "b": cty.Number, + }, []string{"b"}), + DefaultValues: map[string]cty.Value{ + "b": cty.NumberIntVal(5), + }, + }, + }, + }, + "", + }, + + // tuples containing objects with defaults work differently from + // collections + { + `tuple([string, bool, object({ a = string, b = optional(number, 5) })])`, + &Defaults{ + Type: cty.Tuple([]cty.Type{ + cty.String, + cty.Bool, + cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.String, + "b": cty.Number, + }, []string{"b"}), + }), + Children: map[string]*Defaults{ + "2": { + Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.String, + "b": cty.Number, + }, []string{"b"}), + DefaultValues: map[string]cty.Value{ + "b": cty.NumberIntVal(5), + }, + }, + }, + }, + "", + }, + + // incompatible default value causes an error + { + `object({ a = optional(string, "hello"), b = optional(number, true) })`, + &Defaults{ + Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.String, + "b": cty.Number, + }, []string{"a", "b"}), + DefaultValues: map[string]cty.Value{ + "a": cty.StringVal("hello"), + }, + }, + "This default value is not compatible with the attribute's type constraint: number required.", + }, + + // Too many arguments + { + `object({name=string,meta=optional(string, "hello", "world")})`, + nil, + `Optional attribute modifier expects at most two arguments: the attribute type, and a default value.`, + }, + } + + for _, test := range tests { + t.Run(test.Source, func(t *testing.T) { + expr, diags := hclsyntax.ParseExpression([]byte(test.Source), "", hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + t.Fatalf("failed to parse: %s", diags) + } + + _, got, diags := getType(expr, true, true) + if test.WantError == "" { + for _, diag := range diags { + t.Error(diag) + } + } else { + found := false + for _, diag := range diags { + t.Log(diag) + if diag.Severity == hcl.DiagError && diag.Detail == test.WantError { + found = true + } + } + if !found { + t.Errorf("missing expected error detail message: %s", test.WantError) + } + } + + if !cmp.Equal(test.Want, got, valueComparer, typeComparer) { + t.Errorf("wrong result\n%s", cmp.Diff(test.Want, got, valueComparer, typeComparer)) + } + }) + } +} diff --git a/internal/typeexpr/public.go b/internal/typeexpr/public.go index 3b8f618fbcd1..82f215c0978f 100644 --- a/internal/typeexpr/public.go +++ b/internal/typeexpr/public.go @@ -15,7 +15,8 @@ import ( // successful, returns the resulting type. If unsuccessful, error diagnostics // are returned. func Type(expr hcl.Expression) (cty.Type, hcl.Diagnostics) { - return getType(expr, false) + ty, _, diags := getType(expr, false, false) + return ty, diags } // TypeConstraint attempts to parse the given expression as a type constraint @@ -26,7 +27,20 @@ func Type(expr hcl.Expression) (cty.Type, hcl.Diagnostics) { // allows the keyword "any" to represent cty.DynamicPseudoType, which is often // used as a wildcard in type checking and type conversion operations. func TypeConstraint(expr hcl.Expression) (cty.Type, hcl.Diagnostics) { - return getType(expr, true) + ty, _, diags := getType(expr, true, false) + return ty, diags +} + +// TypeConstraintWithDefaults attempts to parse the given expression as a type +// constraint which may include default values for object attributes. If +// successful both the resulting type and corresponding defaults are returned. +// If unsuccessful, error diagnostics are returned. +// +// When using this function, defaults should be applied to the input value +// before type conversion, to ensure that objects with missing attributes have +// default values populated. +func TypeConstraintWithDefaults(expr hcl.Expression) (cty.Type, *Defaults, hcl.Diagnostics) { + return getType(expr, true, true) } // TypeString returns a string rendering of the given type as it would be From 5b0052cc369e391d46530d7e69357aa003e57c60 Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Thu, 21 Apr 2022 15:10:10 -0400 Subject: [PATCH 3/5] core: Apply type defaults to module variables Now that variables parse and retain a set of default values for object attributes, we must apply the defaults during variable evaluation. We do so immediately before type conversion, preprocessing the given value so that conversion will receive the intended defaults as appropriate. --- internal/terraform/context_apply_test.go | 80 +++++++++++++++++++++++- internal/terraform/eval_variable.go | 5 ++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/internal/terraform/context_apply_test.go b/internal/terraform/context_apply_test.go index 841ec7719d13..97ee07769b58 100644 --- a/internal/terraform/context_apply_test.go +++ b/internal/terraform/context_apply_test.go @@ -11984,8 +11984,17 @@ terraform { variable "in" { type = object({ - required = string - optional = optional(string) + required = string + optional = optional(string) + default = optional(bool, true) + nested = optional( + map(object({ + a = optional(string, "foo") + b = optional(number, 5) + })), { + "boop": {} + } + ) }) } @@ -12023,6 +12032,73 @@ output "out" { // Because "optional" was marked as optional, it got silently filled // in as a null value of string type rather than returning an error. "optional": cty.NullVal(cty.String), + + // Similarly, "default" was marked as optional with a default value, + // and since it was omitted should be filled in with that default. + "default": cty.True, + + // Nested is a complex structure which has fully described defaults, + // so again it should be filled with the default structure. + "nested": cty.MapVal(map[string]cty.Value{ + "boop": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.NumberIntVal(5), + }), + }), + }) + if !want.RawEquals(got) { + t.Fatalf("wrong result\ngot: %#v\nwant: %#v", got, want) + } +} + +func TestContext2Apply_moduleVariableOptionalAttributesDefault(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +terraform { + experiments = [module_variable_optional_attrs] +} + +variable "in" { + type = object({ + required = string + optional = optional(string) + default = optional(bool, true) + }) + default = { + required = "boop" + } +} + +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 + want := cty.ObjectVal(map[string]cty.Value{ + "required": cty.StringVal("boop"), + + // "optional" is not present in the variable default, so it is filled + // with null. + "optional": cty.NullVal(cty.String), + + // Similarly, "default" is not present in the variable default, so its + // value is replaced with the type's specified default. + "default": cty.True, }) if !want.RawEquals(got) { t.Fatalf("wrong result\ngot: %#v\nwant: %#v", got, want) diff --git a/internal/terraform/eval_variable.go b/internal/terraform/eval_variable.go index 1d50b911b206..67a777430fb2 100644 --- a/internal/terraform/eval_variable.go +++ b/internal/terraform/eval_variable.go @@ -90,6 +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 { + given = cfg.TypeDefaults.Apply(given) + } + val, err := convert.Convert(given, convertTy) if err != nil { log.Printf("[ERROR] prepareFinalInputVariableValue: %s has unsuitable type\n got: %s\n want: %s", addr, given.Type(), convertTy) From 718b0875ef5729fdb85d09759cb007fbd3bc5495 Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Thu, 21 Apr 2022 15:20:20 -0400 Subject: [PATCH 4/5] lang: Remove defaults function Now that we are able to specify optional object attribute defaults inline in a type constraint, the separate `defaults` function is no longer needed. --- internal/lang/funcs/defaults.go | 288 --------- internal/lang/funcs/defaults_test.go | 648 ------------------- internal/lang/functions.go | 3 +- internal/lang/functions_test.go | 12 - website/data/language-nav-data.json | 5 - website/docs/language/functions/defaults.mdx | 198 ------ website/layouts/language.erb | 4 - 7 files changed, 2 insertions(+), 1156 deletions(-) delete mode 100644 internal/lang/funcs/defaults.go delete mode 100644 internal/lang/funcs/defaults_test.go delete mode 100644 website/docs/language/functions/defaults.mdx diff --git a/internal/lang/funcs/defaults.go b/internal/lang/funcs/defaults.go deleted file mode 100644 index b91ae9395f77..000000000000 --- a/internal/lang/funcs/defaults.go +++ /dev/null @@ -1,288 +0,0 @@ -package funcs - -import ( - "fmt" - - "github.com/hashicorp/terraform/internal/tfdiags" - "github.com/zclconf/go-cty/cty" - "github.com/zclconf/go-cty/cty/convert" - "github.com/zclconf/go-cty/cty/function" -) - -// DefaultsFunc is a helper function for substituting default values in -// place of null values in a given data structure. -// -// See the documentation for function Defaults for more information. -var DefaultsFunc = function.New(&function.Spec{ - Params: []function.Parameter{ - { - Name: "input", - Type: cty.DynamicPseudoType, - AllowNull: true, - AllowMarked: true, - }, - { - Name: "defaults", - Type: cty.DynamicPseudoType, - AllowMarked: true, - }, - }, - Type: func(args []cty.Value) (cty.Type, error) { - // The result type is guaranteed to be the same as the input type, - // since all we're doing is replacing null values with non-null - // values of the same type. - retType := args[0].Type() - defaultsType := args[1].Type() - - // This function is aimed at filling in object types or collections - // of object types where some of the attributes might be null, so - // it doesn't make sense to use a primitive type directly with it. - // (The "coalesce" function may be appropriate for such cases.) - if retType.IsPrimitiveType() { - // This error message is a bit of a fib because we can actually - // apply defaults to tuples too, but we expect that to be so - // unusual as to not be worth mentioning here, because mentioning - // it would require using some less-well-known Terraform language - // terminology in the message (tuple types, structural types). - return cty.DynamicPseudoType, function.NewArgErrorf(1, "only object types and collections of object types can have defaults applied") - } - - defaultsPath := make(cty.Path, 0, 4) // some capacity so that most structures won't reallocate - if err := defaultsAssertSuitableFallback(retType, defaultsType, defaultsPath); err != nil { - errMsg := tfdiags.FormatError(err) // add attribute path prefix - return cty.DynamicPseudoType, function.NewArgErrorf(1, "%s", errMsg) - } - - return retType, nil - }, - Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { - if args[0].Type().HasDynamicTypes() { - // If the types our input object aren't known yet for some reason - // then we'll defer all of our work here, because our - // interpretation of the defaults depends on the types in - // the input. - return cty.UnknownVal(retType), nil - } - - v := defaultsApply(args[0], args[1]) - return v, nil - }, -}) - -func defaultsApply(input, fallback cty.Value) cty.Value { - wantTy := input.Type() - - umInput, inputMarks := input.Unmark() - umFb, fallbackMarks := fallback.Unmark() - - // If neither are known, we very conservatively return an unknown value - // with the union of marks on both input and default. - if !(umInput.IsKnown() && umFb.IsKnown()) { - return cty.UnknownVal(wantTy).WithMarks(inputMarks).WithMarks(fallbackMarks) - } - - // For the rest of this function we're assuming that the given defaults - // will always be valid, because we expect to have caught any problems - // during the type checking phase. Any inconsistencies that reach here are - // therefore considered to be implementation bugs, and so will panic. - - // Our strategy depends on the kind of type we're working with. - switch { - case wantTy.IsPrimitiveType(): - // For leaf primitive values the rule is relatively simple: use the - // input if it's non-null, or fallback if input is null. - if !umInput.IsNull() { - return input - } - v, err := convert.Convert(umFb, wantTy) - if err != nil { - // Should not happen because we checked in defaultsAssertSuitableFallback - panic(err.Error()) - } - return v.WithMarks(fallbackMarks) - - case wantTy.IsObjectType(): - // For structural types, a null input value must be passed through. We - // do not apply default values for missing optional structural values, - // only their contents. - // - // We also pass through the input if the fallback value is null. This - // can happen if the given defaults do not include a value for this - // attribute. - if umInput.IsNull() || umFb.IsNull() { - return input - } - atys := wantTy.AttributeTypes() - ret := map[string]cty.Value{} - for attr, aty := range atys { - inputSub := umInput.GetAttr(attr) - fallbackSub := cty.NullVal(aty) - if umFb.Type().HasAttribute(attr) { - fallbackSub = umFb.GetAttr(attr) - } - ret[attr] = defaultsApply(inputSub.WithMarks(inputMarks), fallbackSub.WithMarks(fallbackMarks)) - } - return cty.ObjectVal(ret) - - case wantTy.IsTupleType(): - // For structural types, a null input value must be passed through. We - // do not apply default values for missing optional structural values, - // only their contents. - // - // We also pass through the input if the fallback value is null. This - // can happen if the given defaults do not include a value for this - // attribute. - if umInput.IsNull() || umFb.IsNull() { - return input - } - - l := wantTy.Length() - ret := make([]cty.Value, l) - for i := 0; i < l; i++ { - inputSub := umInput.Index(cty.NumberIntVal(int64(i))) - fallbackSub := umFb.Index(cty.NumberIntVal(int64(i))) - ret[i] = defaultsApply(inputSub.WithMarks(inputMarks), fallbackSub.WithMarks(fallbackMarks)) - } - return cty.TupleVal(ret) - - case wantTy.IsCollectionType(): - // For collection types we apply a single fallback value to each - // element of the input collection, because in the situations this - // function is intended for we assume that the number of elements - // is the caller's decision, and so we'll just apply the same defaults - // to all of the elements. - ety := wantTy.ElementType() - switch { - case wantTy.IsMapType(): - newVals := map[string]cty.Value{} - - if !umInput.IsNull() { - for it := umInput.ElementIterator(); it.Next(); { - k, v := it.Element() - newVals[k.AsString()] = defaultsApply(v.WithMarks(inputMarks), fallback.WithMarks(fallbackMarks)) - } - } - - if len(newVals) == 0 { - return cty.MapValEmpty(ety) - } - return cty.MapVal(newVals) - case wantTy.IsListType(), wantTy.IsSetType(): - var newVals []cty.Value - - if !umInput.IsNull() { - for it := umInput.ElementIterator(); it.Next(); { - _, v := it.Element() - newV := defaultsApply(v.WithMarks(inputMarks), fallback.WithMarks(fallbackMarks)) - newVals = append(newVals, newV) - } - } - - if len(newVals) == 0 { - if wantTy.IsSetType() { - return cty.SetValEmpty(ety) - } - return cty.ListValEmpty(ety) - } - if wantTy.IsSetType() { - return cty.SetVal(newVals) - } - return cty.ListVal(newVals) - default: - // There are no other collection types, so this should not happen - panic(fmt.Sprintf("invalid collection type %#v", wantTy)) - } - default: - // We should've caught anything else in defaultsAssertSuitableFallback, - // so this should not happen. - panic(fmt.Sprintf("invalid target type %#v", wantTy)) - } -} - -func defaultsAssertSuitableFallback(wantTy, fallbackTy cty.Type, fallbackPath cty.Path) error { - // If the type we want is a collection type then we need to keep peeling - // away collection type wrappers until we find the non-collection-type - // that's underneath, which is what the fallback will actually be applied - // to. - inCollection := false - for wantTy.IsCollectionType() { - wantTy = wantTy.ElementType() - inCollection = true - } - - switch { - case wantTy.IsPrimitiveType(): - // The fallback is valid if it's equal to or convertible to what we want. - if fallbackTy.Equals(wantTy) { - return nil - } - conversion := convert.GetConversion(fallbackTy, wantTy) - if conversion == nil { - msg := convert.MismatchMessage(fallbackTy, wantTy) - return fallbackPath.NewErrorf("invalid default value for %s: %s", wantTy.FriendlyName(), msg) - } - return nil - case wantTy.IsObjectType(): - if !fallbackTy.IsObjectType() { - if inCollection { - return fallbackPath.NewErrorf("the default value for a collection of an object type must itself be an object type, not %s", fallbackTy.FriendlyName()) - } - return fallbackPath.NewErrorf("the default value for an object type must itself be an object type, not %s", fallbackTy.FriendlyName()) - } - for attr, wantAty := range wantTy.AttributeTypes() { - if !fallbackTy.HasAttribute(attr) { - continue // it's always okay to not have a default value - } - fallbackSubpath := fallbackPath.GetAttr(attr) - fallbackSubTy := fallbackTy.AttributeType(attr) - err := defaultsAssertSuitableFallback(wantAty, fallbackSubTy, fallbackSubpath) - if err != nil { - return err - } - } - for attr := range fallbackTy.AttributeTypes() { - if !wantTy.HasAttribute(attr) { - fallbackSubpath := fallbackPath.GetAttr(attr) - return fallbackSubpath.NewErrorf("target type does not expect an attribute named %q", attr) - } - } - return nil - case wantTy.IsTupleType(): - if !fallbackTy.IsTupleType() { - if inCollection { - return fallbackPath.NewErrorf("the default value for a collection of a tuple type must itself be a tuple type, not %s", fallbackTy.FriendlyName()) - } - return fallbackPath.NewErrorf("the default value for a tuple type must itself be a tuple type, not %s", fallbackTy.FriendlyName()) - } - wantEtys := wantTy.TupleElementTypes() - fallbackEtys := fallbackTy.TupleElementTypes() - if got, want := len(wantEtys), len(fallbackEtys); got != want { - return fallbackPath.NewErrorf("the default value for a tuple type of length %d must also have length %d, not %d", want, want, got) - } - for i := 0; i < len(wantEtys); i++ { - fallbackSubpath := fallbackPath.IndexInt(i) - wantSubTy := wantEtys[i] - fallbackSubTy := fallbackEtys[i] - err := defaultsAssertSuitableFallback(wantSubTy, fallbackSubTy, fallbackSubpath) - if err != nil { - return err - } - } - return nil - default: - // No other types are supported right now. - return fallbackPath.NewErrorf("cannot apply defaults to %s", wantTy.FriendlyName()) - } -} - -// Defaults is a helper function for substituting default values in -// place of null values in a given data structure. -// -// This is primarily intended for use with a module input variable that -// has an object type constraint (or a collection thereof) that has optional -// attributes, so that the receiver of a value that omits those attributes -// can insert non-null default values in place of the null values caused by -// omitting the attributes. -func Defaults(input, defaults cty.Value) (cty.Value, error) { - return DefaultsFunc.Call([]cty.Value{input, defaults}) -} diff --git a/internal/lang/funcs/defaults_test.go b/internal/lang/funcs/defaults_test.go deleted file mode 100644 index e40163265a19..000000000000 --- a/internal/lang/funcs/defaults_test.go +++ /dev/null @@ -1,648 +0,0 @@ -package funcs - -import ( - "fmt" - "testing" - - "github.com/zclconf/go-cty/cty" -) - -func TestDefaults(t *testing.T) { - tests := []struct { - Input, Defaults cty.Value - Want cty.Value - WantErr string - }{ - { // When *either* input or default are unknown, an unknown is returned. - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.UnknownVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.UnknownVal(cty.String), - }), - }, - { - // When *either* input or default are unknown, an unknown is - // returned with marks from both input and defaults. - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.UnknownVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello").Mark("marked"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.UnknownVal(cty.String).Mark("marked"), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hey"), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hey"), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{}), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{}), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - WantErr: `.a: target type does not expect an attribute named "a"`, - }, - - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.StringVal("hello"), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey"), - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.StringVal("hello"), - cty.StringVal("hey"), - cty.StringVal("hello"), - }), - }), - }, - { - // Using defaults with single set elements is a pretty - // odd thing to do, but this behavior is just here because - // it generalizes from how we handle collections. It's - // tested only to ensure it doesn't change accidentally - // in future. - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey"), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.StringVal("hey"), - cty.StringVal("hello"), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.MapVal(map[string]cty.Value{ - "x": cty.NullVal(cty.String), - "y": cty.StringVal("hey"), - "z": cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.MapVal(map[string]cty.Value{ - "x": cty.StringVal("hello"), - "y": cty.StringVal("hey"), - "z": cty.StringVal("hello"), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - }), - }, - { - Input: cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - Want: cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("boop"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("boop"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.NullVal(cty.String), - }), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - // After applying defaults, the one with a null value - // coalesced with the one with a non-null value, - // and so there's only one left. - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.MapVal(map[string]cty.Value{ - "boop": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - "beep": cty.ObjectVal(map[string]cty.Value{ - "b": cty.NullVal(cty.String), - }), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.MapVal(map[string]cty.Value{ - "boop": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - "beep": cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hello"), - }), - }), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "b": cty.StringVal("hey"), - }), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - WantErr: `.a: the default value for a collection of an object type must itself be an object type, not string`, - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey"), - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - // The default value for a list must be a single value - // of the list's element type which provides defaults - // for each element separately, so the default for a - // list of string should be just a single string, not - // a list of string. - "a": cty.ListVal([]cty.Value{ - cty.StringVal("hello"), - }), - }), - WantErr: `.a: invalid default value for string: string required`, - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey"), - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - WantErr: `.a: the default value for a tuple type must itself be a tuple type, not string`, - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey"), - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.StringVal("hello 0"), - cty.StringVal("hello 1"), - cty.StringVal("hello 2"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.StringVal("hello 0"), - cty.StringVal("hey"), - cty.StringVal("hello 2"), - }), - }), - }, - { - // There's no reason to use this function for plain primitive - // types, because the "default" argument in a variable definition - // already has the equivalent behavior. This function is only - // to deal with the situation of a complex-typed variable where - // only parts of the data structure are optional. - Input: cty.NullVal(cty.String), - Defaults: cty.StringVal("hello"), - WantErr: `only object types and collections of object types can have defaults applied`, - }, - // When applying default values to structural types, null objects or - // tuples in the input should be passed through. - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.Object(map[string]cty.Type{ - "x": cty.String, - "y": cty.String, - })), - "b": cty.NullVal(cty.Tuple([]cty.Type{cty.String, cty.String})), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "x": cty.StringVal("hello"), - "y": cty.StringVal("there"), - }), - "b": cty.TupleVal([]cty.Value{ - cty.StringVal("how are"), - cty.StringVal("you?"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.Object(map[string]cty.Type{ - "x": cty.String, - "y": cty.String, - })), - "b": cty.NullVal(cty.Tuple([]cty.Type{cty.String, cty.String})), - }), - }, - // When applying default values to structural types, we permit null - // values in the defaults, and just pass through the input value. - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "p": cty.StringVal("xyz"), - "q": cty.StringVal("xyz"), - }), - }), - "b": cty.SetVal([]cty.Value{ - cty.TupleVal([]cty.Value{ - cty.NumberIntVal(0), - cty.NumberIntVal(2), - }), - cty.TupleVal([]cty.Value{ - cty.NumberIntVal(1), - cty.NumberIntVal(3), - }), - }), - "c": cty.NullVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "c": cty.StringVal("tada"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "p": cty.StringVal("xyz"), - "q": cty.StringVal("xyz"), - }), - }), - "b": cty.SetVal([]cty.Value{ - cty.TupleVal([]cty.Value{ - cty.NumberIntVal(0), - cty.NumberIntVal(2), - }), - cty.TupleVal([]cty.Value{ - cty.NumberIntVal(1), - cty.NumberIntVal(3), - }), - }), - "c": cty.StringVal("tada"), - }), - }, - // When applying default values to collection types, null collections in the - // input should result in empty collections in the output. - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.List(cty.String)), - "b": cty.NullVal(cty.Map(cty.String)), - "c": cty.NullVal(cty.Set(cty.String)), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - "b": cty.StringVal("hi"), - "c": cty.StringVal("greetings"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListValEmpty(cty.String), - "b": cty.MapValEmpty(cty.String), - "c": cty.SetValEmpty(cty.String), - }), - }, - // When specifying fallbacks, we allow mismatched primitive attribute - // types so long as a safe conversion is possible. This means that we - // can accept number or boolean values for string attributes. - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - "b": cty.NullVal(cty.String), - "c": cty.NullVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NumberIntVal(5), - "b": cty.True, - "c": cty.StringVal("greetings"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("5"), - "b": cty.StringVal("true"), - "c": cty.StringVal("greetings"), - }), - }, - // Fallbacks with mismatched primitive attribute types which do not - // have safe conversions must not pass the suitable fallback check, - // even if unsafe conversion would be possible. - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.Bool), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("5"), - }), - WantErr: ".a: invalid default value for bool: bool required", - }, - // marks: we should preserve marks from both input value and defaults as leafily as possible - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello").Mark("world"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello").Mark("world"), - }), - }, - { // "unused" marks don't carry over - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.NullVal(cty.String).Mark("a"), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello"), - }), - }, - { // Marks on tuples remain attached to individual elements - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey").Mark("input"), - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.StringVal("hello 0").Mark("fallback"), - cty.StringVal("hello 1"), - cty.StringVal("hello 2"), - }), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.TupleVal([]cty.Value{ - cty.StringVal("hello 0").Mark("fallback"), - cty.StringVal("hey").Mark("input"), - cty.StringVal("hello 2"), - }), - }), - }, - { // Marks from list elements - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.NullVal(cty.String), - cty.StringVal("hey").Mark("input"), - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello 0").Mark("fallback"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.StringVal("hello 0").Mark("fallback"), - cty.StringVal("hey").Mark("input"), - cty.StringVal("hello 0").Mark("fallback"), - }), - }), - }, - { - // Sets don't allow individually-marked elements, so the marks - // end up aggregating on the set itself anyway in this case. - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.NullVal(cty.String), - cty.NullVal(cty.String), - cty.StringVal("hey").Mark("input"), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello 0").Mark("fallback"), - }), - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.SetVal([]cty.Value{ - cty.StringVal("hello 0"), - cty.StringVal("hey"), - cty.StringVal("hello 0"), - }).WithMarks(cty.NewValueMarks("fallback", "input")), - }), - }, - { - Input: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.NullVal(cty.String), - }), - }), - Defaults: cty.ObjectVal(map[string]cty.Value{ - "a": cty.StringVal("hello").Mark("beep"), - }).Mark("boop"), - // This is the least-intuitive case. The mark "boop" is attached to - // the default object, not it's elements, but both marks end up - // aggregated on the list element. - Want: cty.ObjectVal(map[string]cty.Value{ - "a": cty.ListVal([]cty.Value{ - cty.StringVal("hello").WithMarks(cty.NewValueMarks("beep", "boop")), - }), - }), - }, - } - - for _, test := range tests { - t.Run(fmt.Sprintf("defaults(%#v, %#v)", test.Input, test.Defaults), func(t *testing.T) { - got, gotErr := Defaults(test.Input, test.Defaults) - - if test.WantErr != "" { - if gotErr == nil { - t.Fatalf("unexpected success\nwant error: %s", test.WantErr) - } - if got, want := gotErr.Error(), test.WantErr; got != want { - t.Fatalf("wrong error\ngot: %s\nwant: %s", got, want) - } - return - } else if gotErr != nil { - t.Fatalf("unexpected error\ngot: %s", gotErr.Error()) - } - - if !test.Want.RawEquals(got) { - t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) - } - }) - } -} diff --git a/internal/lang/functions.go b/internal/lang/functions.go index f367f6cf7a25..1b3f88ce0476 100644 --- a/internal/lang/functions.go +++ b/internal/lang/functions.go @@ -56,7 +56,6 @@ func (s *Scope) Functions() map[string]function.Function { "concat": stdlib.ConcatFunc, "contains": stdlib.ContainsFunc, "csvdecode": stdlib.CSVDecodeFunc, - "defaults": s.experimentalFunction(experiments.ModuleVariableOptionalAttrs, funcs.DefaultsFunc), "dirname": funcs.DirnameFunc, "distinct": stdlib.DistinctFunc, "element": stdlib.ElementFunc, @@ -174,6 +173,8 @@ func (s *Scope) Functions() map[string]function.Function { // the recieving scope. If so, it will return the given function verbatim. // If not, it will return a placeholder function that just returns an // error explaining that the function requires the experiment to be enabled. +// +//lint:ignore U1000 Ignore unused function error for now func (s *Scope) experimentalFunction(experiment experiments.Experiment, fn function.Function) function.Function { if s.activeExperiments.Has(experiment) { return fn diff --git a/internal/lang/functions_test.go b/internal/lang/functions_test.go index 9a69432bd1b3..ea2091eb97fa 100644 --- a/internal/lang/functions_test.go +++ b/internal/lang/functions_test.go @@ -291,18 +291,6 @@ func TestFunctions(t *testing.T) { }, }, - "defaults": { - // This function is pretty specialized and so this is mainly - // just a test that it is defined at all. See the function's - // own unit tests for more interesting test cases. - { - `defaults({a: 4}, {a: 5})`, - cty.ObjectVal(map[string]cty.Value{ - "a": cty.NumberIntVal(4), - }), - }, - }, - "dirname": { { `dirname("testdata/hello.txt")`, diff --git a/website/data/language-nav-data.json b/website/data/language-nav-data.json index acde5fa6a916..8771f9e298be 100644 --- a/website/data/language-nav-data.json +++ b/website/data/language-nav-data.json @@ -684,10 +684,6 @@ "title": "Type Conversion Functions", "routes": [ { "title": "can", "href": "/language/functions/can" }, - { - "title": "defaults", - "href": "/language/functions/defaults" - }, { "title": "nonsensitive", "href": "/language/functions/nonsensitive" @@ -777,7 +773,6 @@ { "title": "concat", "path": "functions/concat", "hidden": true }, { "title": "contains", "path": "functions/contains", "hidden": true }, { "title": "csvdecode", "path": "functions/csvdecode", "hidden": true }, - { "title": "defaults", "path": "functions/defaults", "hidden": true }, { "title": "dirname", "path": "functions/dirname", "hidden": true }, { "title": "distinct", "path": "functions/distinct", "hidden": true }, { "title": "element", "path": "functions/element", "hidden": true }, diff --git a/website/docs/language/functions/defaults.mdx b/website/docs/language/functions/defaults.mdx deleted file mode 100644 index 74c535280b01..000000000000 --- a/website/docs/language/functions/defaults.mdx +++ /dev/null @@ -1,198 +0,0 @@ ---- -page_title: defaults - Functions - Configuration Language -description: The defaults function can fill in default values in place of null values. ---- - -# `defaults` Function - --> **Note:** This function is available only in Terraform 0.15 and later. - -~> **Experimental:** This function is part of -[the optional attributes experiment](/language/expressions/type-constraints#experimental-optional-object-type-attributes) -and is only available in modules where the `module_variable_optional_attrs` -experiment is explicitly enabled. - -The `defaults` function is a specialized function intended for use with -input variables whose type constraints are object types or collections of -object types that include optional attributes. - -When you define an attribute as optional and the caller doesn't provide an -explicit value for it, Terraform will set the attribute to `null` to represent -that it was omitted. If you want to use a placeholder value other than `null` -when an attribute isn't set, you can use the `defaults` function to concisely -assign default values only where an attribute value was set to `null`. - -``` -defaults(input_value, defaults) -``` - -The `defaults` function expects that the `input_value` argument will be the -value of an input variable with an exact [type constraint](/language/expressions/types) -(not containing `any`). The function will then visit every attribute in -the data structure, including attributes of nested objects, and apply the -default values given in the defaults object. - -The interpretation of attributes in the `defaults` argument depends on what -type an attribute has in the `input_value`: - -* **Primitive types** (`string`, `number`, `bool`): if a default value is given - then it will be used only if the `input_value`'s attribute of the same - name has the value `null`. The default value's type must match the input - value's type. -* **Structural types** (`object` and `tuple` types): Terraform will recursively - visit all of the attributes or elements of the nested value and repeat the - same defaults-merging logic one level deeper. The default value's type must - be of the same kind as the input value's type, and a default value for an - object type must only contain attribute names that appear in the input - value's type. -* **Collection types** (`list`, `map`, and `set` types): Terraform will visit - each of the collection elements in turn and apply defaults to them. In this - case the default value is only a single value to be applied to _all_ elements - of the collection, so it must have a type compatible with the collection's - element type rather than with the collection type itself. - -The above rules may be easier to follow with an example. Consider the following -Terraform configuration: - -```hcl -terraform { - # Optional attributes and the defaults function are - # both experimental, so we must opt in to the experiment. - experiments = [module_variable_optional_attrs] -} - -variable "storage" { - type = object({ - name = string - enabled = optional(bool) - website = object({ - index_document = optional(string) - error_document = optional(string) - }) - documents = map( - object({ - source_file = string - content_type = optional(string) - }) - ) - }) -} - -locals { - storage = defaults(var.storage, { - # If "enabled" isn't set then it will default - # to true. - enabled = true - - # The "website" attribute is required, but - # it's here to provide defaults for the - # optional attributes inside. - website = { - index_document = "index.html" - error_document = "error.html" - } - - # The "documents" attribute has a map type, - # so the default value represents defaults - # to be applied to all of the elements in - # the map, not for the map itself. Therefore - # it's a single object matching the map - # element type, not a map itself. - documents = { - # If _any_ of the map elements omit - # content_type then this default will be - # used instead. - content_type = "application/octet-stream" - } - }) -} - -output "storage" { - value = local.storage -} -``` - -To test this out, we can create a file `terraform.tfvars` to provide an example -value for `var.storage`: - -```hcl -storage = { - name = "example" - - website = { - error_document = "error.txt" - } - documents = { - "index.html" = { - source_file = "index.html.tmpl" - content_type = "text/html" - } - "error.txt" = { - source_file = "error.txt.tmpl" - content_type = "text/plain" - } - "terraform.exe" = { - source_file = "terraform.exe" - } - } -} -``` - -The above value conforms to the variable's type constraint because it only -omits attributes that are declared as optional. Terraform will automatically -populate those attributes with the value `null` before evaluating anything -else, and then the `defaults` function in `local.storage` will substitute -default values for each of them. - -The result of this `defaults` call would therefore be the following object: - -``` -storage = { - "documents" = tomap({ - "error.txt" = { - "content_type" = "text/plain" - "source_file" = "error.txt.tmpl" - } - "index.html" = { - "content_type" = "text/html" - "source_file" = "index.html.tmpl" - } - "terraform.exe" = { - "content_type" = "application/octet-stream" - "source_file" = "terraform.exe" - } - }) - "enabled" = true - "name" = "example" - "website" = { - "error_document" = "error.txt" - "index_document" = "index.html" - } -} -``` - -Notice that `enabled` and `website.index_document` were both populated directly -from the defaults. Notice also that the `"terraform.exe"` element of -`documents` had its `content_type` attribute populated from the `documents` -default, but the default value didn't need to predict that there would be an -element key `"terraform.exe"` because the default values apply equally to -all elements of the map where the optional attributes are `null`. - -## Using `defaults` elsewhere - -The design of the `defaults` function depends on input values having -well-specified type constraints, so it can reliably recognize the difference -between similar types: maps vs. objects, lists vs. tuples. The type constraint -causes Terraform to convert the caller's value to conform to the constraint -and thus `defaults` can rely on the input to conform. - -Elsewhere in the Terraform language it's typical to be less precise about -types, for example using the object construction syntax `{ ... }` to construct -values that will be used as if they are maps. Because `defaults` uses the -type information of `input_value`, an `input_value` that _doesn't_ originate -in an input variable will tend not to have an appropriate value type and will -thus not be interpreted as expected by `defaults`. - -We recommend using `defaults` only with fully-constrained input variable values -in the first argument, so you can use the variable's type constraint to -explicitly distinguish between collection and structural types. diff --git a/website/layouts/language.erb b/website/layouts/language.erb index b2233530f218..7f7e55e7f3df 100644 --- a/website/layouts/language.erb +++ b/website/layouts/language.erb @@ -792,10 +792,6 @@ can -
  • - defaults -
  • -
  • nonsensitive
  • From e2a304202531a514e48c46348172bdbbebdec703 Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Mon, 25 Apr 2022 14:10:39 -0400 Subject: [PATCH 5/5] website: Update documentation for optional attrs Extend the documentation on type constraints to include the new default functionality, including a detailed example of a nested structure with multiple levels of defaults. --- .../language/expressions/type-constraints.mdx | 142 ++++++++++++++++-- 1 file changed, 132 insertions(+), 10 deletions(-) diff --git a/website/docs/language/expressions/type-constraints.mdx b/website/docs/language/expressions/type-constraints.mdx index 9ff803b81bb3..5925b88472de 100644 --- a/website/docs/language/expressions/type-constraints.mdx +++ b/website/docs/language/expressions/type-constraints.mdx @@ -258,10 +258,10 @@ variable "no_type_constraint" { In this case, Terraform will replace `any` with the exact type of the given value and thus perform no type conversion whatsoever. -## Experimental: Optional Object Type Attributes +## Optional Object Type Attributes -From Terraform v0.14 there is _experimental_ support for marking particular -attributes as optional in an object type constraint. +Terraform v1.3 adds support for marking particular attributes as optional in an +object type constraint. To mark an attribute as optional, use the additional `optional(...)` modifier around its type declaration: @@ -269,17 +269,139 @@ around its type declaration: ```hcl variable "with_optional_attribute" { type = object({ - a = string # a required attribute - b = optional(string) # an optional attribute + a = string # a required attribute + b = optional(string) # an optional attribute + c = optional(number, 127) # an optional attribute with default value }) } ``` -By default, for required attributes, Terraform will return an error if the -source value has no matching attribute. Marking an attribute as optional -changes the behavior in that situation: Terraform will instead just silently -insert `null` as the value of the attribute, allowing the receiving module -to describe an appropriate fallback behavior. +When evaluating variable values, Terraform will return an error if an object +attribute specified in the variable type is not present in the given value. +Marking an attribute as optional changes the behavior in that situation: +Terraform will instead insert a default value for the missing attribute, +allowing the receiving module to describe an appropriate fallback behavior. + +The `optional` modifier takes one or two arguments. The first argument +specifies the type of the attribute, and (if given) the second attribute +defines the default value to use if the attribute is not present. The default +must be compatible with the attribute type. If no default is specified, a +`null` value of the appropriate type will be used as the default. + +During evaluation, object attribute defaults are applied top-down in nested +variable types. This means that a given attribute's default value will also +have any nested default values applied to it later. + +### Example: Nested Structures with Optional Attributes and Defaults + +The following configuration defines a variable which describes a number of storage buckets, each of which is used to host a website. This variable type uses several optional attributes, one of which is itself an `object` type with optional attributes and defaults. + +```hcl +terraform { + # Optional attributes are currently experimental. + experiments = [module_variable_optional_attrs] +} + +variable "buckets" { + type = list(object({ + name = string + enabled = optional(bool, true) + website = optional(object({ + index_document = optional(string, "index.html") + error_document = optional(string, "error.html") + routing_rules = optional(string) + }), {}) + })) +} +``` + +To test this out, we can create a file `terraform.tfvars` to provide an example +value for `var.buckets`: + +```hcl +buckets = [ + { + name = "production" + website = { + routing_rules = <<-EOT + [ + { + "Condition" = { "KeyPrefixEquals": "img/" }, + "Redirect" = { "ReplaceKeyPrefixWith": "images/" } + } + ] + EOT + } + }, + { + name = "archived" + enabled = false + }, + { + name = "docs" + website = { + index_document = "index.txt" + error_document = "error.txt" + } + }, +] +``` + +The intent here is to specify three bucket configurations: + +- `production` sets the routing rules to add a redirect; +- `archived` uses default configuration but is disabled; +- `docs` overrides the index and error documents to use text files. + +Note that `production` does not specify the index and error documents, and `archived` omits the website configuration altogether. Because our type specifies a default value for the `website` attribute as an empty object `{}`, Terraform fills in the defaults specified in the nested type. + +The resulting variable value is: + +```hcl +tolist([ + { + "enabled" = true + "name" = "production" + "website" = { + "error_document" = "error.html" + "index_document" = "index.html" + "routing_rules" = <<-EOT + [ + { + "Condition" = { "KeyPrefixEquals": "img/" }, + "Redirect" = { "ReplaceKeyPrefixWith": "images/" } + } + ] + + EOT + } + }, + { + "enabled" = false + "name" = "archived" + "website" = { + "error_document" = "error.html" + "index_document" = "index.html" + "routing_rules" = tostring(null) + } + }, + { + "enabled" = true + "name" = "docs" + "website" = { + "error_document" = "error.txt" + "index_document" = "index.txt" + "routing_rules" = tostring(null) + } + }, +]) +``` + +Here we can see that for `production` and `docs`, the `enabled` attribute has been filled in as `true`. The default values for the `website` attribute have also been filled in, with the values specified by `docs` overriding the defaults. For `archived`, the entire default `website` value is populated. + +One important point is that the `website` attribute for the `archived` and `docs` buckets contains a `null` value for `routing_rules`. When declaring a type constraint with an optional object attributes without a default, a value which omits that attribute will be populated with a `null` value, rather than continuing to omit the attribute in the final result. + +### Experimental Status Because this feature is currently experimental, it requires an explicit opt-in on a per-module basis. To use it, write a `terraform` block with the