From cec4578005cebc149b034763ac9b46f4603624f9 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 30 Oct 2020 17:19:32 -0700 Subject: [PATCH] lang/funcs: Experimental "defaults" function This is a new part of the existing module_variable_optional_attrs experiment, because it's intended to complement the ability to declare an input variable whose type constraint is an object type with optional attributes. Module authors can use this to replace null values (that were either explicitly set or implied by attribute omission) with other non-null values of the same type. This function is a bit more type-fussy than our functions typically are because it's intended for use primarily with input variables that have fully-specified type constraints, and thus it uses that type information to help inform how the defaults data structure should be interpreted. Other uses of this function will probably be harder today because it takes a lot of extra annotation to build a value of a specific type if it isn't passing through a variable type constraint. Perhaps later language features for more general type conversion will make this more applicable, but for now the more general form of this problem is better solved other ways. --- lang/funcs/defaults.go | 257 ++++++++++++ lang/funcs/defaults_test.go | 396 ++++++++++++++++++ lang/functions.go | 1 + lang/functions_test.go | 108 ++++- .../configuration/functions/defaults.html.md | 201 +++++++++ website/layouts/language.erb | 4 + 6 files changed, 948 insertions(+), 19 deletions(-) create mode 100644 lang/funcs/defaults.go create mode 100644 lang/funcs/defaults_test.go create mode 100644 website/docs/configuration/functions/defaults.html.md diff --git a/lang/funcs/defaults.go b/lang/funcs/defaults.go new file mode 100644 index 000000000..34d4a0275 --- /dev/null +++ b/lang/funcs/defaults.go @@ -0,0 +1,257 @@ +package funcs + +import ( + "fmt" + + "github.com/hashicorp/terraform/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, + }, + { + Name: "defaults", + Type: cty.DynamicPseudoType, + }, + }, + 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 { + const fallbackArgIdx = 1 + + wantTy := input.Type() + if !(input.IsKnown() && fallback.IsKnown()) { + return cty.UnknownVal(wantTy) + } + + // 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 !input.IsNull() { + return input + } + v, err := convert.Convert(fallback, wantTy) + if err != nil { + // Should not happen because we checked in defaultsAssertSuitableFallback + panic(err.Error()) + } + return v + + case wantTy.IsObjectType(): + atys := wantTy.AttributeTypes() + ret := map[string]cty.Value{} + for attr, aty := range atys { + inputSub := input.GetAttr(attr) + fallbackSub := cty.NullVal(aty) + if fallback.Type().HasAttribute(attr) { + fallbackSub = fallback.GetAttr(attr) + } + ret[attr] = defaultsApply(inputSub, fallbackSub) + } + return cty.ObjectVal(ret) + + case wantTy.IsTupleType(): + l := wantTy.Length() + ret := make([]cty.Value, l) + for i := 0; i < l; i++ { + inputSub := input.Index(cty.NumberIntVal(int64(i))) + fallbackSub := fallback.Index(cty.NumberIntVal(int64(i))) + ret[i] = defaultsApply(inputSub, fallbackSub) + } + 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{} + + for it := input.ElementIterator(); it.Next(); { + k, v := it.Element() + newVals[k.AsString()] = defaultsApply(v, fallback) + } + + if len(newVals) == 0 { + return cty.MapValEmpty(ety) + } + return cty.MapVal(newVals) + case wantTy.IsListType(), wantTy.IsSetType(): + var newVals []cty.Value + + for it := input.ElementIterator(); it.Next(); { + _, v := it.Element() + newV := defaultsApply(v, fallback) + 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.GetConversionUnsafe(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/lang/funcs/defaults_test.go b/lang/funcs/defaults_test.go new file mode 100644 index 000000000..ca0c3be65 --- /dev/null +++ b/lang/funcs/defaults_test.go @@ -0,0 +1,396 @@ +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 + }{ + { + 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`, + }, + } + + 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/lang/functions.go b/lang/functions.go index c2f0a6a26..905b00f41 100644 --- a/lang/functions.go +++ b/lang/functions.go @@ -56,6 +56,7 @@ 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, diff --git a/lang/functions_test.go b/lang/functions_test.go index 46be8649f..8f3950eb4 100644 --- a/lang/functions_test.go +++ b/lang/functions_test.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform/experiments" homedir "github.com/mitchellh/go-homedir" "github.com/zclconf/go-cty/cty" ) @@ -289,6 +290,18 @@ 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")`, @@ -1039,32 +1052,89 @@ func TestFunctions(t *testing.T) { }, } - data := &dataForTests{} // no variables available; we only need literals here - scope := &Scope{ - Data: data, - BaseDir: "./testdata/functions-test", // for the functions that read from the filesystem - } + experimentalFuncs := map[string]experiments.Experiment{} + experimentalFuncs["defaults"] = experiments.ModuleVariableOptionalAttrs - // Check that there is at least one test case for each function, omitting - // those functions that do not return consistent values - allFunctions := scope.Functions() - - // TODO: we can test the impure functions partially by configuring the scope - // with PureOnly: true and then verify that they return unknown values of a - // suitable type. - for _, impureFunc := range impureFunctions { - delete(allFunctions, impureFunc) - } - for f, _ := range scope.Functions() { - if _, ok := tests[f]; !ok { - t.Errorf("Missing test for function %s\n", f) + t.Run("all functions are tested", func(t *testing.T) { + data := &dataForTests{} // no variables available; we only need literals here + scope := &Scope{ + Data: data, + BaseDir: "./testdata/functions-test", // for the functions that read from the filesystem } - } + + // Check that there is at least one test case for each function, omitting + // those functions that do not return consistent values + allFunctions := scope.Functions() + + // TODO: we can test the impure functions partially by configuring the scope + // with PureOnly: true and then verify that they return unknown values of a + // suitable type. + for _, impureFunc := range impureFunctions { + delete(allFunctions, impureFunc) + } + for f := range scope.Functions() { + if _, ok := tests[f]; !ok { + t.Errorf("Missing test for function %s\n", f) + } + } + }) for funcName, funcTests := range tests { t.Run(funcName, func(t *testing.T) { + + // prepareScope starts as a no-op, but if a function is marked as + // experimental in our experimentalFuncs table above then we'll + // reassign this to be a function that activates the appropriate + // experiment. + prepareScope := func(t *testing.T, scope *Scope) {} + + if experiment, isExperimental := experimentalFuncs[funcName]; isExperimental { + // First, we'll run all of the tests without the experiment + // enabled to see that they do actually fail in that case. + for _, test := range funcTests { + testName := fmt.Sprintf("experimental(%s)", test.src) + t.Run(testName, func(t *testing.T) { + data := &dataForTests{} // no variables available; we only need literals here + scope := &Scope{ + Data: data, + BaseDir: "./testdata/functions-test", // for the functions that read from the filesystem + } + + expr, parseDiags := hclsyntax.ParseExpression([]byte(test.src), "test.hcl", hcl.Pos{Line: 1, Column: 1}) + if parseDiags.HasErrors() { + for _, diag := range parseDiags { + t.Error(diag.Error()) + } + return + } + + _, diags := scope.EvalExpr(expr, cty.DynamicPseudoType) + if !diags.HasErrors() { + t.Errorf("experimental function %q succeeded without its experiment %s enabled\nexpr: %s", funcName, experiment.Keyword(), test.src) + } + }) + } + + // Now make the experiment active in the scope so that the + // function will actually work when we test it below. + prepareScope = func(t *testing.T, scope *Scope) { + t.Helper() + t.Logf("activating experiment %s to test %q", experiment.Keyword(), funcName) + experimentsSet := experiments.NewSet() + experimentsSet.Add(experiment) + scope.SetActiveExperiments(experimentsSet) + } + } + for _, test := range funcTests { t.Run(test.src, func(t *testing.T) { + data := &dataForTests{} // no variables available; we only need literals here + scope := &Scope{ + Data: data, + BaseDir: "./testdata/functions-test", // for the functions that read from the filesystem + } + prepareScope(t, scope) + expr, parseDiags := hclsyntax.ParseExpression([]byte(test.src), "test.hcl", hcl.Pos{Line: 1, Column: 1}) if parseDiags.HasErrors() { for _, diag := range parseDiags { diff --git a/website/docs/configuration/functions/defaults.html.md b/website/docs/configuration/functions/defaults.html.md new file mode 100644 index 000000000..8b5f22d0e --- /dev/null +++ b/website/docs/configuration/functions/defaults.html.md @@ -0,0 +1,201 @@ +--- +layout: "functions" +page_title: "defaults - Functions - Configuration Language" +sidebar_current: "docs-funcs-conversion-defaults" +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](../types.html#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](../types.html) +(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 196606454..4f66e333f 100644 --- a/website/layouts/language.erb +++ b/website/layouts/language.erb @@ -739,6 +739,10 @@ can +
  • + defaults +
  • +
  • tobool