mirror of
https://github.com/opentofu/opentofu.git
synced 2024-12-28 01:41:48 -06:00
Merge pull request #26766 from hashicorp/f-experimental-funcs
lang/funcs: Experimental "defaults" function
This commit is contained in:
commit
ca4b860902
257
lang/funcs/defaults.go
Normal file
257
lang/funcs/defaults.go
Normal 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
396
lang/funcs/defaults_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
201
website/docs/configuration/functions/defaults.html.md
Normal file
201
website/docs/configuration/functions/defaults.html.md
Normal 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.
|
@ -739,6 +739,10 @@
|
||||
<a href="/docs/configuration/functions/can.html">can</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/docs/configuration/functions/defaults.html">defaults</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/docs/configuration/functions/tobool.html">tobool</a>
|
||||
</li>
|
||||
|
Loading…
Reference in New Issue
Block a user