Skip to content

Commit e23e6b8

Browse files
lang/funcs: Allow some more expression types in templatestring
The templatestring function has some special constraints on its first argument that are included to add some intentional friction for those who are new to Terraform, want to do some simple template rendering, but have only found the templatestring function so far. We know from previous experience with the hashicorp/template provider that this sort of functionality tends to attract those who haven't yet learned that the Terraform language has built-in support for string templates (without calling any function), who would then get confused by the need for an extra level of escaping to render a template string only indirectly through this function. However, this rule is not intended to be onerous and require writing the rest of the containing module in an unnatural way to work around it, so here we loosen the rule to allow some additional forms: - An index expression whose collection operand meets these rules. - A relative traversal whose source operand meets these rules. In particular this makes it possible to write an expression like: data.example.example[each.key].result ...which is a relative traversal from an index from a scope traversal, and is a very reasonable thing to write if you've retrieved multiple templates using a data resource that uses for_each. This also treats splat expressions in the same way as index expressions at the static check stage, but that's only to allow us to reach the dynamic type check that will ultimately report that a string is required, because the result of a splat expression is a tuple. The type-related error message is (subjectively) more helpful/relevant than the syntax-related one for this case. Finally, this includes some revisions to the documentation for this function to correct some editing errors from the first pass and to slightly loosen the language about what's allowed. It's still a little vague about what exactly is allowed, but I'm doubtful that a precise definition in terms of HCL's expression types would be very enlightening for a typical reader anyway. We can tweak the specificity of the language here if we start to see repeated questions about what is and is not valid.
1 parent a38be7d commit e23e6b8

File tree

3 files changed

+126
-24
lines changed

3 files changed

+126
-24
lines changed

internal/lang/funcs/string.go

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,6 @@ func MakeTemplateStringFunc(funcsCb func() (funcs map[string]function.Function,
213213
// the function documentation to learn about the other options that
214214
// are probably more suitable for what they need.
215215
switch expr := templateClosure.Expression.(type) {
216-
case *hclsyntax.ScopeTraversalExpr:
217-
// A standalone traversal is always acceptable.
218216
case *hclsyntax.TemplateWrapExpr:
219217
// This situation occurs when someone writes an interpolation-only
220218
// expression as was required in Terraform v0.11 and earlier.
@@ -249,16 +247,17 @@ func MakeTemplateStringFunc(funcsCb func() (funcs map[string]function.Function,
249247
)
250248
}
251249
default:
252-
// Nothing else is allowed.
253-
// Someone who really does want to construct a template dynamically
254-
// can factor out that construction into a local value and refer
255-
// to it in the templatestring call, but it's not really feasible
256-
// to explain that clearly in a short error message so we'll deal
257-
// with that option on the function's documentation page instead,
258-
// where we can show a full example.
259-
return cty.UnknownVal(retType), function.NewArgErrorf(
260-
0, "invalid template expression: must be a direct reference to a single string from elsewhere, containing valid Terraform template syntax",
261-
)
250+
if !isValidTemplateStringExpr(expr) {
251+
// Someone who really does want to construct a template dynamically
252+
// can factor out that construction into a local value and refer
253+
// to it in the templatestring call, but it's not really feasible
254+
// to explain that clearly in a short error message so we'll deal
255+
// with that option on the function's documentation page instead,
256+
// where we can show a full example.
257+
return cty.UnknownVal(retType), function.NewArgErrorf(
258+
0, "invalid template expression: must be a direct reference to a single string from elsewhere, containing valid Terraform template syntax",
259+
)
260+
}
262261
}
263262

264263
templateVal, diags := templateClosure.Value()
@@ -385,6 +384,55 @@ func makeRenderTemplateFunc(funcsCb func() (funcs map[string]function.Function,
385384
}
386385
}
387386

387+
func isValidTemplateStringExpr(expr hcl.Expression) bool {
388+
// Our goal with this heuristic is to be as permissive as possible with
389+
// allowing things that authors might try to use as references to a
390+
// template string defined elsewhere, while rejecting complex expressions
391+
// that seem like they might be trying to construct templates dynamically
392+
// or might have resulted from a misunderstanding that "templatestring" is
393+
// the only way to render a template, because someone hasn't learned
394+
// about template expressions yet.
395+
//
396+
// This is here only to give better feedback to folks who seem to be using
397+
// templatestring for something other than what it's intended for, and not
398+
// to block dynamic template generation altogether. Authors who have a
399+
// genuine need for dynamic template generation can always assert that to
400+
// Terraform by factoring out their dynamic generation into a local value
401+
// and referring to it; this rule is just a little speedbump to prompt
402+
// the author to consider whether there's a better way to solve their
403+
// problem, as opposed to just using the first solution they found.
404+
switch expr := expr.(type) {
405+
case *hclsyntax.ScopeTraversalExpr:
406+
// A simple static reference from the current scope is always valid.
407+
return true
408+
409+
case *hclsyntax.RelativeTraversalExpr:
410+
// Relative traversals are allowed as long as they begin from
411+
// something that would otherwise be allowed.
412+
return isValidTemplateStringExpr(expr.Source)
413+
414+
case *hclsyntax.IndexExpr:
415+
// Index expressions are allowed as long as the collection is
416+
// also specified using an expression that conforms to these rules.
417+
// The key operand is intentionally unconstrained because that
418+
// is a rule for how to select an element, and so doesn't represent
419+
// a source from which the template string is being retrieved.
420+
return isValidTemplateStringExpr(expr.Collection)
421+
422+
case *hclsyntax.SplatExpr:
423+
// Splat expressions would be weird to use because they'd typically
424+
// return a tuple and that wouldn't be valid as a template string,
425+
// but we allow it here (as long as the operand would otherwise have
426+
// been allowed) because then we'll let the type mismatch error
427+
// show through, and that's likely a more helpful error message.
428+
return isValidTemplateStringExpr(expr.Source)
429+
430+
default:
431+
// Nothing else is allowed.
432+
return false
433+
}
434+
}
435+
388436
// Replace searches a given string for another given substring,
389437
// and replaces all occurences with a given replacement string.
390438
func Replace(str, substr, replace cty.Value) (cty.Value, error) {

internal/lang/funcs/string_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,55 @@ func TestTemplateString(t *testing.T) {
335335
cty.StringVal(`it's a value`),
336336
``,
337337
},
338+
{
339+
`data.whatever.whatever[each.key].result`,
340+
map[string]cty.Value{
341+
"data": cty.ObjectVal(map[string]cty.Value{
342+
"whatever": cty.ObjectVal(map[string]cty.Value{
343+
"whatever": cty.MapVal(map[string]cty.Value{
344+
"foo": cty.ObjectVal(map[string]cty.Value{
345+
"result": cty.StringVal("it's ${a}"),
346+
}),
347+
}),
348+
}),
349+
}),
350+
"each": cty.ObjectVal(map[string]cty.Value{
351+
"key": cty.StringVal("foo"),
352+
}),
353+
},
354+
cty.ObjectVal(map[string]cty.Value{
355+
"a": cty.StringVal("a value"),
356+
}),
357+
cty.StringVal(`it's a value`),
358+
``,
359+
},
360+
{
361+
`data.whatever.whatever[*].result`,
362+
map[string]cty.Value{
363+
"data": cty.ObjectVal(map[string]cty.Value{
364+
"whatever": cty.ObjectVal(map[string]cty.Value{
365+
"whatever": cty.TupleVal([]cty.Value{
366+
cty.ObjectVal(map[string]cty.Value{
367+
"result": cty.StringVal("it's ${a}"),
368+
}),
369+
}),
370+
}),
371+
}),
372+
"each": cty.ObjectVal(map[string]cty.Value{
373+
"key": cty.StringVal("foo"),
374+
}),
375+
},
376+
cty.ObjectVal(map[string]cty.Value{
377+
"a": cty.StringVal("a value"),
378+
}),
379+
cty.NilVal,
380+
// We have an intentional hole in our heuristic for whether the
381+
// first argument is a suitable expression which permits splat
382+
// expressions just so that we can return the type mismatch error
383+
// from the result not being a string, instead of the more general
384+
// error about it not being a supported expression type.
385+
`invalid template value: a string is required`,
386+
},
338387
{
339388
`"can't write $${not_allowed}"`,
340389
map[string]cty.Value{},

website/docs/language/functions/templatestring.mdx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ description: |-
88

99
-> **Note:** The `templatestring` function is intended for advanced use cases. Most use cases require only a [string template expression](/terraform/language/expressions/strings#string-templates). To render a template from a file, use the [`templatefile` function](/terraform/language/functions/templatefile).
1010

11-
`templatestring` renders a template using a supplied set of template variables.
11+
`templatestring` renders a template given as a string value, using a supplied set of template variables.
1212

1313
```hcl
14-
templatefile(ref, vars)
14+
templatestring(ref, vars)
1515
```
1616

17-
The first parameter must be a simple reference to string value containing the template: for example, `data.aws_s3_object.example.body` or `local.inline_template`.
17+
The first parameter must be a direct reference to string value containing the template: for example, `data.aws_s3_object.example.body` or `local.inline_template`.
1818

1919
It is **not** valid to supply the template expression directly as the first argument:
2020

@@ -25,23 +25,18 @@ templatestring("Hello, $${name}", {
2525
})
2626
```
2727

28-
Instead of the above, you should instead use a string template expression:
28+
Instead of the above, you should use a normal string template expression:
2929

3030
```hcl
3131
"Hello, ${var.name}"
3232
```
3333

34-
The `templatestring` function is needed only when the template is available as a named object in the current module.
34+
The `templatestring` function is needed only when the template is available from a named object, such as a data resource, declared in the current module.
3535

3636
The template syntax is the same as for
3737
[string templates](/terraform/language/expressions/strings#string-templates)
3838
in the main Terraform language, including interpolation sequences delimited with
39-
`${` ... `}`.
40-
41-
Strings in the Terraform language are sequences of Unicode characters, so
42-
this function will interpret the file contents as UTF-8 encoded text and
43-
return the resulting Unicode characters. If the template contains invalid UTF-8
44-
sequences then this function will produce an error.
39+
`${` ... `}`.
4540

4641
## Example
4742

@@ -60,7 +55,17 @@ output "example" {
6055
}
6156
```
6257

63-
For more examples of how to use templates, please see the documentation for the [`templatefile`](/terraform/language/functions/templatefile#Examples) function.
58+
For more examples of how to use templates, refer to the documentation for [the `templatefile` function](/terraform/language/functions/templatefile#Examples).
59+
60+
## Dynamic Template Construction
61+
62+
This function is primarily intended for rendering templates fetched as a single string from remote locations, often using data resources.
63+
64+
You can also use this as a way to construct a template dynamically and then render it, but we recommend treating that only as a last resort because the result tends to be hard to understand, hard to maintain, and fragile to unexpected input.
65+
66+
The restrictions on what kind of syntax is allowed in the first argument are a guardrail to help avoid those new to Terraform thinking that this function is the primary way to render templates in Terraform, but you can bypass those restrictions if you wish by writing an expression that builds a template dynamically and then assigning that expression to a [local value](/terraform/language/values/locals). You can then use a reference to that local value as the first argument to `templatestring`.
67+
68+
If you _do_ choose to construct templates from parts dynamically, be mindful that Terraform has built-in functions that can interact with the local filesystem, and so maliciously-crafted input might produce a template whose result includes data from arbitrary files on the system where Terraform is running.
6469

6570
## Related Functions
6671

0 commit comments

Comments
 (0)