diff --git a/config/interpolate_funcs.go b/config/interpolate_funcs.go index e912493b6..91aa66da7 100644 --- a/config/interpolate_funcs.go +++ b/config/interpolate_funcs.go @@ -69,6 +69,7 @@ func Funcs() map[string]ast.Function { "join": interpolationFuncJoin(), "jsonencode": interpolationFuncJSONEncode(), "length": interpolationFuncLength(), + "list": interpolationFuncList(), "lower": interpolationFuncLower(), "md5": interpolationFuncMd5(), "uuid": interpolationFuncUUID(), @@ -83,6 +84,45 @@ func Funcs() map[string]ast.Function { } } +// interpolationFuncList creates a list from the parameters passed +// to it. +func interpolationFuncList() ast.Function { + return ast.Function{ + ArgTypes: []ast.Type{}, + ReturnType: ast.TypeList, + Variadic: true, + VariadicType: ast.TypeAny, + Callback: func(args []interface{}) (interface{}, error) { + var outputList []ast.Variable + + for i, val := range args { + switch v := val.(type) { + case string: + outputList = append(outputList, ast.Variable{Type: ast.TypeString, Value: v}) + case []ast.Variable: + outputList = append(outputList, ast.Variable{Type: ast.TypeList, Value: v}) + case map[string]ast.Variable: + outputList = append(outputList, ast.Variable{Type: ast.TypeMap, Value: v}) + default: + return nil, fmt.Errorf("unexpected type %T for argument %d in list", v, i) + } + } + + // we don't support heterogeneous types, so make sure all types match the first + if len(outputList) > 0 { + firstType := outputList[0].Type + for i, v := range outputList[1:] { + if v.Type != firstType { + return nil, fmt.Errorf("unexpected type %s for argument %d in list", v.Type, i+1) + } + } + } + + return outputList, nil + }, + } +} + // interpolationFuncCompact strips a list of multi-variable values // (e.g. as returned by "split") of any empty strings. func interpolationFuncCompact() ast.Function { @@ -218,10 +258,8 @@ func interpolationFuncCoalesce() ast.Function { } } -// interpolationFuncConcat implements the "concat" function that -// concatenates multiple strings. This isn't actually necessary anymore -// since our language supports string concat natively, but for backwards -// compat we do this. +// interpolationFuncConcat implements the "concat" function that concatenates +// multiple lists. func interpolationFuncConcat() ast.Function { return ast.Function{ ArgTypes: []ast.Type{ast.TypeAny}, @@ -229,33 +267,42 @@ func interpolationFuncConcat() ast.Function { Variadic: true, VariadicType: ast.TypeAny, Callback: func(args []interface{}) (interface{}, error) { - var finalListElements []string + var outputList []ast.Variable for _, arg := range args { - // Append strings for backward compatibility - if argument, ok := arg.(string); ok { - finalListElements = append(finalListElements, argument) - continue - } - - // Otherwise variables - if argument, ok := arg.([]ast.Variable); ok { - for _, element := range argument { - t := element.Type - switch t { + switch arg := arg.(type) { + case string: + outputList = append(outputList, ast.Variable{Type: ast.TypeString, Value: arg}) + case []ast.Variable: + for _, v := range arg { + switch v.Type { case ast.TypeString: - finalListElements = append(finalListElements, element.Value.(string)) + outputList = append(outputList, v) + case ast.TypeList: + outputList = append(outputList, v) + case ast.TypeMap: + outputList = append(outputList, v) default: - return nil, fmt.Errorf("concat() does not support lists of %s", t.Printable()) + return nil, fmt.Errorf("concat() does not support lists of %s", v.Type.Printable()) } } - continue - } - return nil, fmt.Errorf("arguments to concat() must be a string or list of strings") + default: + return nil, fmt.Errorf("concat() does not support %T", arg) + } } - return stringSliceToVariableValue(finalListElements), nil + // we don't support heterogeneous types, so make sure all types match the first + if len(outputList) > 0 { + firstType := outputList[0].Type + for _, v := range outputList[1:] { + if v.Type != firstType { + return nil, fmt.Errorf("unexpected %s in list of %s", v.Type.Printable(), firstType.Printable()) + } + } + } + + return outputList, nil }, } } diff --git a/config/interpolate_funcs_test.go b/config/interpolate_funcs_test.go index 46a6eba75..541bcffab 100644 --- a/config/interpolate_funcs_test.go +++ b/config/interpolate_funcs_test.go @@ -5,13 +5,114 @@ import ( "io/ioutil" "os" "reflect" - "strings" "testing" "github.com/hashicorp/hil" "github.com/hashicorp/hil/ast" ) +func TestInterpolateFuncList(t *testing.T) { + testFunction(t, testFunctionConfig{ + Cases: []testFunctionCase{ + // empty input returns empty list + { + `${list()}`, + []interface{}{}, + false, + }, + + // single input returns list of length 1 + { + `${list("hello")}`, + []interface{}{"hello"}, + false, + }, + + // two inputs returns list of length 2 + { + `${list("hello", "world")}`, + []interface{}{"hello", "world"}, + false, + }, + + // not a string input gives error + { + `${list("hello", 42)}`, + nil, + true, + }, + + // list of lists + { + `${list("${var.list}", "${var.list2}")}`, + []interface{}{[]interface{}{"Hello", "World"}, []interface{}{"bar", "baz"}}, + false, + }, + + // list of maps + { + `${list("${var.map}", "${var.map2}")}`, + []interface{}{map[string]interface{}{"key": "bar"}, map[string]interface{}{"key2": "baz"}}, + false, + }, + + // error on a heterogeneous list + { + `${list("first", "${var.list}")}`, + nil, + true, + }, + }, + Vars: map[string]ast.Variable{ + "var.list": { + Type: ast.TypeList, + Value: []ast.Variable{ + { + Type: ast.TypeString, + Value: "Hello", + }, + { + Type: ast.TypeString, + Value: "World", + }, + }, + }, + "var.list2": { + Type: ast.TypeList, + Value: []ast.Variable{ + { + Type: ast.TypeString, + Value: "bar", + }, + { + Type: ast.TypeString, + Value: "baz", + }, + }, + }, + + "var.map": { + Type: ast.TypeMap, + Value: map[string]ast.Variable{ + "key": { + Type: ast.TypeString, + Value: "bar", + }, + }, + }, + "var.map2": { + Type: ast.TypeMap, + Value: map[string]ast.Variable{ + "key2": { + Type: ast.TypeString, + Value: "baz", + }, + }, + }, + }, + }) +} + func TestInterpolateFuncCompact(t *testing.T) { testFunction(t, testFunctionConfig{ Cases: []testFunctionCase{ @@ -223,44 +324,88 @@ func TestInterpolateFuncConcat(t *testing.T) { []interface{}{"a", "b", "c", "d", "e", "f", "0", "1"}, false, }, + + // list vars + { + `${concat("${var.list}", "${var.list}")}`, + []interface{}{"a", "b", "a", "b"}, + false, + }, + // lists of lists + { + `${concat("${var.lists}", "${var.lists}")}`, + []interface{}{[]interface{}{"c", "d"}, []interface{}{"c", "d"}}, + false, + }, + + // lists of maps + { + `${concat("${var.maps}", "${var.maps}")}`, + []interface{}{map[string]interface{}{"key1": "a", "key2": "b"}, map[string]interface{}{"key1": "a", "key2": "b"}}, + false, + }, + + // mismatched types + { + `${concat("${var.lists}", "${var.maps}")}`, + nil, + true, + }, + }, + Vars: map[string]ast.Variable{ + "var.list": { + Type: ast.TypeList, + Value: []ast.Variable{ + { + Type: ast.TypeString, + Value: "a", + }, + { + Type: ast.TypeString, + Value: "b", + }, + }, + }, + "var.lists": { + Type: ast.TypeList, + Value: []ast.Variable{ + { + Type: ast.TypeList, + Value: []ast.Variable{ + { + Type: ast.TypeString, + Value: "c", + }, + { + Type: ast.TypeString, + Value: "d", + }, + }, + }, + }, + }, + "var.maps": { + Type: ast.TypeList, + Value: []ast.Variable{ + { + Type: ast.TypeMap, + Value: map[string]ast.Variable{ + "key1": { + Type: ast.TypeString, + Value: "a", + }, + "key2": { + Type: ast.TypeString, + Value: "b", + }, + }, + }, + }, + }, }, }) } -// TODO: This test is split out and calls a private function -// because there's no good way to get a list of maps into the unit -// tests due to GH-7142 - once lists of maps can be expressed properly as -// literals this unit test can be wrapped back into the suite above. -// -// Reproduces crash reported in GH-7030. -func TestInterpolationFuncConcatListOfMaps(t *testing.T) { - listOfMapsOne := ast.Variable{ - Type: ast.TypeList, - Value: []ast.Variable{ - { - Type: ast.TypeMap, - Value: map[string]interface{}{"one": "foo"}, - }, - }, - } - listOfMapsTwo := ast.Variable{ - Type: ast.TypeList, - Value: []ast.Variable{ - { - Type: ast.TypeMap, - Value: map[string]interface{}{"two": "bar"}, - }, - }, - } - args := []interface{}{listOfMapsOne.Value, listOfMapsTwo.Value} - - _, err := interpolationFuncConcat().Callback(args) - - if err == nil || !strings.Contains(err.Error(), "concat() does not support lists of type map") { - t.Fatalf("Expected err, got: %v", err) - } -} - func TestInterpolateFuncDistinct(t *testing.T) { testFunction(t, testFunctionConfig{ Cases: []testFunctionCase{ diff --git a/website/source/docs/configuration/interpolation.html.md b/website/source/docs/configuration/interpolation.html.md index c765e2d60..338550377 100644 --- a/website/source/docs/configuration/interpolation.html.md +++ b/website/source/docs/configuration/interpolation.html.md @@ -168,6 +168,11 @@ The supported built-in functions are: * `${length(split(",", "a,b,c"))}` = 3 * `${length("a,b,c")}` = 5 + * `list(items...)` - Returns a list consisting of the arguments to the function. + This function provides a way of representing list literals in interpolation. + * `${list("a", "b", "c")}` returns a list of `"a", "b", "c"`. + * `${list()}` returns an empty list. + * `lookup(map, key [, default])` - Performs a dynamic lookup into a mapping variable. The `map` parameter should be another variable, such as `var.amis`. If `key` does not exist in `map`, the interpolation will