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/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/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) 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)) + } + }) + } +} 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 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/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 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