Merge pull request #26766 from hashicorp/f-experimental-funcs

lang/funcs: Experimental "defaults" function
This commit is contained in:
Martin Atkins 2020-11-13 17:41:06 -08:00 committed by GitHub
commit ca4b860902
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1004 additions and 20 deletions

257
lang/funcs/defaults.go Normal file
View File

@ -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})
}

396
lang/funcs/defaults_test.go Normal file
View File

@ -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)
}
})
}
}

View File

@ -9,6 +9,7 @@ import (
"github.com/zclconf/go-cty/cty/function" "github.com/zclconf/go-cty/cty/function"
"github.com/zclconf/go-cty/cty/function/stdlib" "github.com/zclconf/go-cty/cty/function/stdlib"
"github.com/hashicorp/terraform/experiments"
"github.com/hashicorp/terraform/lang/funcs" "github.com/hashicorp/terraform/lang/funcs"
) )
@ -55,6 +56,7 @@ func (s *Scope) Functions() map[string]function.Function {
"concat": stdlib.ConcatFunc, "concat": stdlib.ConcatFunc,
"contains": stdlib.ContainsFunc, "contains": stdlib.ContainsFunc,
"csvdecode": stdlib.CSVDecodeFunc, "csvdecode": stdlib.CSVDecodeFunc,
"defaults": s.experimentalFunction(experiments.ModuleVariableOptionalAttrs, funcs.DefaultsFunc),
"dirname": funcs.DirnameFunc, "dirname": funcs.DirnameFunc,
"distinct": stdlib.DistinctFunc, "distinct": stdlib.DistinctFunc,
"element": stdlib.ElementFunc, "element": stdlib.ElementFunc,
@ -168,3 +170,32 @@ var unimplFunc = function.New(&function.Spec{
return cty.DynamicVal, fmt.Errorf("function not yet implemented") 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
},
})
}

View File

@ -8,6 +8,7 @@ import (
"github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/terraform/experiments"
homedir "github.com/mitchellh/go-homedir" homedir "github.com/mitchellh/go-homedir"
"github.com/zclconf/go-cty/cty" "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": {
{ {
`dirname("testdata/hello.txt")`, `dirname("testdata/hello.txt")`,
@ -1039,6 +1052,10 @@ func TestFunctions(t *testing.T) {
}, },
} }
experimentalFuncs := map[string]experiments.Experiment{}
experimentalFuncs["defaults"] = experiments.ModuleVariableOptionalAttrs
t.Run("all functions are tested", func(t *testing.T) {
data := &dataForTests{} // no variables available; we only need literals here data := &dataForTests{} // no variables available; we only need literals here
scope := &Scope{ scope := &Scope{
Data: data, Data: data,
@ -1055,16 +1072,69 @@ func TestFunctions(t *testing.T) {
for _, impureFunc := range impureFunctions { for _, impureFunc := range impureFunctions {
delete(allFunctions, impureFunc) delete(allFunctions, impureFunc)
} }
for f, _ := range scope.Functions() { for f := range scope.Functions() {
if _, ok := tests[f]; !ok { if _, ok := tests[f]; !ok {
t.Errorf("Missing test for function %s\n", f) t.Errorf("Missing test for function %s\n", f)
} }
} }
})
for funcName, funcTests := range tests { for funcName, funcTests := range tests {
t.Run(funcName, func(t *testing.T) { 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 { for _, test := range funcTests {
t.Run(test.src, func(t *testing.T) { 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}) expr, parseDiags := hclsyntax.ParseExpression([]byte(test.src), "test.hcl", hcl.Pos{Line: 1, Column: 1})
if parseDiags.HasErrors() { if parseDiags.HasErrors() {
for _, diag := range parseDiags { for _, diag := range parseDiags {

View File

@ -6,6 +6,7 @@ import (
"github.com/zclconf/go-cty/cty/function" "github.com/zclconf/go-cty/cty/function"
"github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/experiments"
) )
// Scope is the main type in this package, allowing dynamic evaluation of // Scope is the main type in this package, allowing dynamic evaluation of
@ -31,4 +32,16 @@ type Scope struct {
funcs map[string]function.Function funcs map[string]function.Function
funcsLock sync.Mutex 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
} }

View File

@ -297,7 +297,19 @@ func (ctx *BuiltinEvalContext) EvaluationScope(self addrs.Referenceable, keyData
InstanceKeyData: keyData, InstanceKeyData: keyData,
Operation: ctx.Evaluator.Operation, 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 { func (ctx *BuiltinEvalContext) Path() addrs.ModuleInstance {

View File

@ -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.

View File

@ -739,6 +739,10 @@
<a href="/docs/configuration/functions/can.html">can</a> <a href="/docs/configuration/functions/can.html">can</a>
</li> </li>
<li>
<a href="/docs/configuration/functions/defaults.html">defaults</a>
</li>
<li> <li>
<a href="/docs/configuration/functions/tobool.html">tobool</a> <a href="/docs/configuration/functions/tobool.html">tobool</a>
</li> </li>