Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lang/funcs: add template function #24978

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions lang/funcs/string.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -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])
},
})

}
143 changes: 143 additions & 0 deletions lang/funcs/string_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
})
}
}
6 changes: 6 additions & 0 deletions lang/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions lang/functions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})`,
Expand Down
52 changes: 52 additions & 0 deletions website/docs/configuration/functions/template.html.md
Original file line number Diff line number Diff line change
@@ -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.