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 d48cace9b..905b00f41 100644 --- a/lang/functions.go +++ b/lang/functions.go @@ -9,6 +9,7 @@ import ( "github.com/zclconf/go-cty/cty/function" "github.com/zclconf/go-cty/cty/function/stdlib" + "github.com/hashicorp/terraform/experiments" "github.com/hashicorp/terraform/lang/funcs" ) @@ -55,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, @@ -168,3 +170,32 @@ var unimplFunc = function.New(&function.Spec{ return cty.DynamicVal, fmt.Errorf("function not yet implemented") }, }) + +// experimentalFunction checks whether the given experiment is enabled for +// 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. +func (s *Scope) experimentalFunction(experiment experiments.Experiment, fn function.Function) function.Function { + if s.activeExperiments.Has(experiment) { + return fn + } + + err := fmt.Errorf( + "this function is experimental and available only when the experiment keyword %s is enabled for the current module", + experiment.Keyword(), + ) + + return function.New(&function.Spec{ + Params: fn.Params(), + VarParam: fn.VarParam(), + Type: func(args []cty.Value) (cty.Type, error) { + return cty.DynamicPseudoType, err + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + // It would be weird to get here because the Type function always + // fails, but we'll return an error here too anyway just to be + // robust. + return cty.DynamicVal, err + }, + }) +} 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/lang/scope.go b/lang/scope.go index 98fca6baa..103d2529c 100644 --- a/lang/scope.go +++ b/lang/scope.go @@ -6,6 +6,7 @@ import ( "github.com/zclconf/go-cty/cty/function" "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/experiments" ) // Scope is the main type in this package, allowing dynamic evaluation of @@ -31,4 +32,16 @@ type Scope struct { funcs map[string]function.Function funcsLock sync.Mutex + + // activeExperiments is an optional set of experiments that should be + // considered as active in the module that this scope will be used for. + // Callers can populate it by calling the SetActiveExperiments method. + activeExperiments experiments.Set +} + +// SetActiveExperiments allows a caller to declare that a set of experiments +// is active for the module that the receiving Scope belongs to, which might +// then cause the scope to activate some additional experimental behaviors. +func (s *Scope) SetActiveExperiments(active experiments.Set) { + s.activeExperiments = active } diff --git a/terraform/eval_context_builtin.go b/terraform/eval_context_builtin.go index 63f87cc27..ae4d16d2a 100644 --- a/terraform/eval_context_builtin.go +++ b/terraform/eval_context_builtin.go @@ -297,7 +297,19 @@ func (ctx *BuiltinEvalContext) EvaluationScope(self addrs.Referenceable, keyData InstanceKeyData: keyData, Operation: ctx.Evaluator.Operation, } - return ctx.Evaluator.Scope(data, self) + scope := ctx.Evaluator.Scope(data, self) + + // ctx.PathValue is the path of the module that contains whatever + // expression the caller will be trying to evaluate, so this will + // activate only the experiments from that particular module, to + // be consistent with how experiment checking in the "configs" + // package itself works. The nil check here is for robustness in + // incompletely-mocked testing situations; mc should never be nil in + // real situations. + if mc := ctx.Evaluator.Config.DescendentForInstance(ctx.PathValue); mc != nil { + scope.SetActiveExperiments(mc.Module.ActiveExperiments) + } + return scope } func (ctx *BuiltinEvalContext) Path() addrs.ModuleInstance { 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