diff --git a/lang/funcs/string.go b/lang/funcs/string.go index ab6da72778ec..0bd5853895d8 100644 --- a/lang/funcs/string.go +++ b/lang/funcs/string.go @@ -1,9 +1,12 @@ package funcs import ( + "fmt" "regexp" "strings" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/function" ) @@ -51,3 +54,122 @@ var ReplaceFunc = function.New(&function.Spec{ func Replace(str, substr, replace cty.Value) (cty.Value, error) { return ReplaceFunc.Call([]cty.Value{str, substr, replace}) } + +// MakeTemplateFunc constructs a function that takes a template as string and +// an arbitrary object of named values and attempts to render the referenced +// string as a template using HCL template syntax. +// +// The template itself may recursively call other functions so a callback +// must be provided to get access to those functions. The template cannot, +// however, access any variables defined in the scope: it is restricted only to +// those variables provided in the second function argument, to ensure that all +// dependencies on other graph nodes can be seen before executing this function. +func MakeTemplateFunc(funcsCb func() map[string]function.Function) function.Function { + + params := []function.Parameter{ + { + Name: "str", + Type: cty.String, + }, + { + Name: "vars", + Type: cty.DynamicPseudoType, + }, + } + + loadTmpl := func(fn string) (hcl.Expression, error) { + expr, diags := hclsyntax.ParseTemplate([]byte(fn), params[0].Name, hcl.Pos{Line: 1, Column: 1}) + if diags.HasErrors() { + return nil, diags + } + + return expr, nil + } + + renderTmpl := func(expr hcl.Expression, varsVal cty.Value) (cty.Value, error) { + if varsTy := varsVal.Type(); !(varsTy.IsMapType() || varsTy.IsObjectType()) { + return cty.DynamicVal, function.NewArgErrorf(1, "invalid vars value: must be a map") // or an object, but we don't strongly distinguish these most of the time + } + + ctx := &hcl.EvalContext{ + Variables: varsVal.AsValueMap(), + } + + // We require all of the variables to be valid HCL identifiers, because + // otherwise there would be no way to refer to them in the template + // anyway. Rejecting this here gives better feedback to the user + // than a syntax error somewhere in the template itself. + for n := range ctx.Variables { + if !hclsyntax.ValidIdentifier(n) { + // This error message intentionally doesn't describe _all_ of + // the different permutations that are technically valid as an + // HCL identifier, but rather focuses on what we might + // consider to be an "idiomatic" variable name. + return cty.DynamicVal, function.NewArgErrorf(1, "invalid template variable name %q: must start with a letter, followed by zero or more letters, digits, and underscores", n) + } + } + + // We'll pre-check references in the template here so we can give a + // more specialized error message than HCL would by default, so it's + // clearer that this problem is coming from a template call. + for _, traversal := range expr.Variables() { + root := traversal.RootName() + if _, ok := ctx.Variables[root]; !ok { + return cty.DynamicVal, function.NewArgErrorf(1, "vars map does not contain key %q, referenced at %s", root, traversal[0].SourceRange()) + } + } + + givenFuncs := funcsCb() // this callback indirection is to avoid chicken/egg problems + funcs := make(map[string]function.Function, len(givenFuncs)) + for name, fn := range givenFuncs { + if name == "template" { + // We stub this one out to prevent recursive calls. + funcs[name] = function.New(&function.Spec{ + Params: params, + Type: func(args []cty.Value) (cty.Type, error) { + return cty.NilType, fmt.Errorf("cannot recursively call template from inside template call") + }, + }) + continue + } + funcs[name] = fn + } + ctx.Functions = funcs + + val, diags := expr.Value(ctx) + if diags.HasErrors() { + return cty.DynamicVal, diags + } + return val, nil + } + + return function.New(&function.Spec{ + Params: params, + Type: func(args []cty.Value) (cty.Type, error) { + if !(args[0].IsKnown() && args[1].IsKnown()) { + return cty.DynamicPseudoType, nil + } + + // We'll render our template now to see what result type it produces. + // A template consisting only of a single interpolation an potentially + // return any type. + expr, err := loadTmpl(args[0].AsString()) + if err != nil { + return cty.DynamicPseudoType, err + } + + // This is safe even if args[1] contains unknowns because the HCL + // template renderer itself knows how to short-circuit those. + val, err := renderTmpl(expr, args[1]) + return val.Type(), err + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + expr, err := loadTmpl(args[0].AsString()) + if err != nil { + return cty.DynamicVal, err + } + return renderTmpl(expr, args[1]) + }, + }) + +} diff --git a/lang/funcs/string_test.go b/lang/funcs/string_test.go index 7b44a2762402..dd816fc64a62 100644 --- a/lang/funcs/string_test.go +++ b/lang/funcs/string_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" + "github.com/zclconf/go-cty/cty/function/stdlib" ) func TestReplace(t *testing.T) { @@ -71,3 +73,144 @@ func TestReplace(t *testing.T) { }) } } + +func TestTemplate(t *testing.T) { + tests := []struct { + String cty.Value + Vars cty.Value + Want cty.Value + Err string + }{ + { + cty.StringVal("Hello, ${name}!"), + cty.EmptyObjectVal, + cty.NilVal, + `vars map does not contain key "name", referenced at str:1,10-14`, + }, + { + cty.StringVal("\xDF"), + cty.EmptyObjectVal, + cty.NilVal, + `str:1,1-2: Invalid character encoding; All input files must be UTF-8 encoded. Ensure that UTF-8 encoding is selected in your editor., and 1 other diagnostic(s)`, + }, + { + cty.StringVal(""), + cty.MapVal(map[string]cty.Value{ + "name": cty.StringVal("Jodie"), + }), + cty.StringVal(""), + ``, + }, + { + cty.NilVal, + cty.EmptyObjectVal, + cty.NilVal, + `argument must not be null`, + }, + { + cty.StringVal("Hello, ${name}!"), + cty.MapVal(map[string]cty.Value{ + "name": cty.StringVal("Jodie"), + }), + cty.StringVal("Hello, Jodie!"), + ``, + }, + { + cty.StringVal("Hello, ${name}!"), + cty.MapVal(map[string]cty.Value{ + "name!": cty.StringVal("Jodie"), + }), + cty.NilVal, + `invalid template variable name "name!": must start with a letter, followed by zero or more letters, digits, and underscores`, + }, + { + cty.StringVal("Hello, ${name}!"), + cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("Jimbo"), + }), + cty.StringVal("Hello, Jimbo!"), + ``, + }, + { + cty.StringVal("The items are ${join(\", \", list)}"), + cty.ObjectVal(map[string]cty.Value{ + "list": cty.ListVal([]cty.Value{ + cty.StringVal("a"), + cty.StringVal("b"), + cty.StringVal("c"), + }), + }), + cty.StringVal("The items are a, b, c"), + ``, + }, + { + cty.StringVal("Hello, ${template(\"\",{})}!"), + cty.MapValEmpty(cty.String), + cty.NilVal, + `str:1,10-19: Error in function call; Call to function "template" failed: cannot recursively call template from inside template call.`, + }, + { + cty.StringVal("%{ for x in list ~}\n- ${x}\n%{ endfor ~}"), + cty.ObjectVal(map[string]cty.Value{ + "list": cty.ListVal([]cty.Value{ + cty.StringVal("a"), + cty.StringVal("b"), + cty.StringVal("c"), + }), + }), + cty.StringVal("- a\n- b\n- c\n"), + ``, + }, + { + cty.StringVal("%{ for x in list ~}\n- ${x}\n%{ endfor ~}"), + cty.ObjectVal(map[string]cty.Value{ + "list": cty.True, + }), + cty.NilVal, + `str:1,13-17: Iteration over non-iterable value; A value of type bool cannot be used as the collection in a 'for' expression.`, + }, + { + cty.StringVal("${val}"), + cty.ObjectVal(map[string]cty.Value{ + "val": cty.True, + }), + cty.True, // since this template contains only an interpolation, its true value shines through + ``, + }, + } + + templateFn := MakeTemplateFunc(func() map[string]function.Function { + return map[string]function.Function{ + "join": stdlib.JoinFunc, + "template": stdlib.JoinFunc, // just a placeholder, since template itself overrides this + } + }) + + for _, test := range tests { + t.Run(fmt.Sprintf("Template(%#v, %#v)", test.String, test.Vars), func(t *testing.T) { + got, err := templateFn.Call([]cty.Value{test.String, test.Vars}) + + if argErr, ok := err.(function.ArgError); ok { + if argErr.Index < 0 || argErr.Index > 1 { + t.Errorf("ArgError index %d is out of range for template (must be 0 or 1)", argErr.Index) + } + } + + if test.Err != "" { + if err == nil { + t.Fatal("succeeded; want error") + } + if got, want := err.Error(), test.Err; got != want { + t.Errorf("wrong error\ngot: %s\nwant: %s", got, want) + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/lang/functions.go b/lang/functions.go index d48cace9b87f..dfb5ce558191 100644 --- a/lang/functions.go +++ b/lang/functions.go @@ -147,6 +147,12 @@ func (s *Scope) Functions() map[string]function.Function { return s.funcs }) + s.funcs["template"] = funcs.MakeTemplateFunc(func() map[string]function.Function { + // The template function prevents recursive calls to itself + // by copying this map and overwriting the "template" entry. + return s.funcs + }) + if s.PureOnly { // Force our few impure functions to return unknown so that we // can defer evaluating them until a later pass. diff --git a/lang/functions_test.go b/lang/functions_test.go index 46be8649fb72..8855094e993b 100644 --- a/lang/functions_test.go +++ b/lang/functions_test.go @@ -819,6 +819,13 @@ func TestFunctions(t *testing.T) { }, }, + "template": { + { + `template("Hello, $${name}!", {name = "Jodie"})`, + cty.StringVal("Hello, Jodie!"), + }, + }, + "templatefile": { { `templatefile("hello.tmpl", {name = "Jodie"})`, diff --git a/website/docs/configuration/functions/template.html.md b/website/docs/configuration/functions/template.html.md new file mode 100644 index 000000000000..210ecfb15554 --- /dev/null +++ b/website/docs/configuration/functions/template.html.md @@ -0,0 +1,52 @@ +--- +layout: "functions" +page_title: "template - Functions - Configuration Language" +sidebar_current: "docs-funcs-string-replace" +description: |- + The template function read a string and renders it as template. +--- + +# `template` Function + +-> **Note:** This page is about Terraform 0.12 and later. For Terraform 0.11 and +earlier, see +[0.11 Configuration Language: Interpolation Syntax](../../configuration-0-11/interpolation.html). + +`template` reads a string and renders it as a template using a supplied set of template variables. + +```hcl +template(str, vars) +``` + +The template syntax is the same as for +[string templates](../expressions.html#string-templates) in the main Terraform +language, including interpolation sequences delimited with `${` ... `}`. + +The "vars" argument must be a map. Within the template string, each of the keys +in the map is available as a variable for interpolation. The template may +also use any other function available in the Terraform language. Variable names must +each start with a letter, followed by zero or more letters, digits, or +underscores. + +In both quoted and heredoc string expressions, Terraform supports template sequences that begin with `${` and `%{`. These are described in more detail in the following section. To include these sequences literally without beginning a template sequence, double the leading character: `$${` or `%%{`. + +Strings in the Terraform language are sequences of Unicode characters, so if the string contains invalid UTF-8 sequences then this function will produce an error. + +## Examples + +The `template` function renders the template: + +``` +> template("Hello, $${name}!", {name = "Jane"}) +Hello, Jane! + +``` + +The `template` function can be used with the `file` function to read a template from a file. Witch behavior is similar to the `templatefile`. + +## Related Functions + +* [`file`](./file.html) reads a file from disk and returns its literal contents + without any template interpretation. +* [`templatefile`](./templatefile.html) reads the file at the given path and renders its + content as a template.