Skip to content

Commit

Permalink
lang: String templates as values
Browse files Browse the repository at this point in the history
This is a design prototype of one possible way to meet the need of
defining a template in a different location from where it will ultimately
be evaluated. For example, a generic module might accept a template as
an input variable and then evaluate that template using values that are
private to the module, thus separating the concern of providing the data
from the concern of assembling the data into a particular string shape.

This is only just enough to try out the behavior and see how it feels and
how intuitive it seems. If we were to move forward with something like
this then a real implementation would need some extra care to some other
concerns, including but not limited to:

 - Generating good error messages when templates don't make valid use of
   their arguments.

 - Properly blocking template values from being used in places where they
   don't make sense, such as as input to jsonencode(...), or as a root
   module output value.

 - Possibly a function similar to templatefile which can create a template
   value from a file on disk, to allow factoring out larger templates
   while still being compatible with modules that expect template values.
  • Loading branch information
apparentlymart committed May 14, 2021
1 parent ef88c54 commit 6eea05b
Show file tree
Hide file tree
Showing 8 changed files with 438 additions and 0 deletions.
48 changes: 48 additions & 0 deletions internal/typeexpr/get_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/terraform/lang/templatevals"
"github.com/zclconf/go-cty/cty"
)

Expand Down Expand Up @@ -46,6 +47,13 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
Detail: "The object type constructor requires one argument specifying the attribute types and values as a map.",
Subject: expr.Range().Ptr(),
}}
case "template":
return cty.DynamicPseudoType, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "The template type constructor requires one argument specifying the argument names and types as a map.",
Subject: expr.Range().Ptr(),
}}
case "tuple":
return cty.DynamicPseudoType, hcl.Diagnostics{{
Severity: hcl.DiagError,
Expand Down Expand Up @@ -113,6 +121,14 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
Subject: &subjectRange,
Context: &contextRange,
}}
case "template":
return cty.DynamicPseudoType, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "The template type constructor requires one argument specifying the argument names and types as a map.",
Subject: &subjectRange,
Context: &contextRange,
}}
case "tuple":
return cty.DynamicPseudoType, hcl.Diagnostics{{
Severity: hcl.DiagError,
Expand Down Expand Up @@ -212,6 +228,38 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
// minor versions of cty. We're accepting that because Terraform
// itself is considering optional attributes as experimental right now.
return cty.ObjectWithOptionalAttrs(atys, optAttrs), diags
case "template":
attrDefs, diags := hcl.ExprMap(call.Arguments[0])
if diags.HasErrors() {
return cty.DynamicPseudoType, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "Template type constructor requires a map whose keys are argument names and whose values are the corresponding argument types.",
Subject: call.Arguments[0].Range().Ptr(),
Context: expr.Range().Ptr(),
}}
}

atys := make(map[string]cty.Type)
for _, attrDef := range attrDefs {
attrName := hcl.ExprAsKeyword(attrDef.Key)
if attrName == "" {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "Template type constructor map keys must be argument names.",
Subject: attrDef.Key.Range().Ptr(),
Context: expr.Range().Ptr(),
})
continue
}
atyExpr := attrDef.Value

aty, attrDiags := getType(atyExpr, constraint)
diags = append(diags, attrDiags...)
atys[attrName] = aty
}
return templatevals.Type(atys), diags
case "tuple":
elemDefs, diags := hcl.ExprList(call.Arguments[0])
if diags.HasErrors() {
Expand Down
35 changes: 35 additions & 0 deletions internal/typeexpr/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"sort"

"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/terraform/lang/templatevals"

"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
Expand Down Expand Up @@ -124,6 +125,40 @@ func TypeString(ty cty.Type) string {
return buf.String()
}

if templatevals.IsTemplateType(ty) {
var buf bytes.Buffer
buf.WriteString("template({")
atys := templatevals.TypeArgs(ty)
names := make([]string, 0, len(atys))
for name := range atys {
names = append(names, name)
}
sort.Strings(names)
first := true
for _, name := range names {
aty := atys[name]
if !first {
buf.WriteByte(',')
}
if !hclsyntax.ValidIdentifier(name) {
// Should never happen for any type produced by this package,
// but we'll do something reasonable here just so we don't
// produce garbage if someone gives us a hand-assembled object
// type that has weird attribute names.
// Using Go-style quoting here isn't perfect, since it doesn't
// exactly match HCL syntax, but it's fine for an edge-case.
buf.WriteString(fmt.Sprintf("%q", name))
} else {
buf.WriteString(name)
}
buf.WriteByte('=')
buf.WriteString(TypeString(aty))
first = false
}
buf.WriteString("})")
return buf.String()
}

// Should never happen because we covered all cases above.
panic(fmt.Errorf("unsupported type %#v", ty))
}
3 changes: 3 additions & 0 deletions lang/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/hashicorp/terraform/experiments"
"github.com/hashicorp/terraform/lang/funcs"
"github.com/hashicorp/terraform/lang/templatevals"
)

var impureFunctions = []string{
Expand Down Expand Up @@ -60,6 +61,7 @@ func (s *Scope) Functions() map[string]function.Function {
"dirname": funcs.DirnameFunc,
"distinct": stdlib.DistinctFunc,
"element": stdlib.ElementFunc,
"evaltemplate": templatevals.EvalTemplateFunc,
"chunklist": stdlib.ChunklistFunc,
"file": funcs.MakeFileFunc(s.BaseDir, false),
"fileexists": funcs.MakeFileExistsFunc(s.BaseDir),
Expand Down Expand Up @@ -87,6 +89,7 @@ func (s *Scope) Functions() map[string]function.Function {
"log": stdlib.LogFunc,
"lookup": funcs.LookupFunc,
"lower": stdlib.LowerFunc,
"maketemplate": templatevals.MakeTemplateFunc,
"map": funcs.MapFunc,
"matchkeys": funcs.MatchkeysFunc,
"max": stdlib.MaxFunc,
Expand Down
14 changes: 14 additions & 0 deletions lang/functions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,13 @@ func TestFunctions(t *testing.T) {
},
},

"evaltemplate": {
{
`evaltemplate(maketemplate("Hello ${template.a}!"), {a = "world"})`,
cty.StringVal("Hello world!"),
},
},

"file": {
{
`file("hello.txt")`,
Expand Down Expand Up @@ -550,6 +557,13 @@ func TestFunctions(t *testing.T) {
},
},

"maketemplate": {
{
`evaltemplate(maketemplate("Hello ${template.a}!"), {a = "world"})`,
cty.StringVal("Hello world!"),
},
},

"map": {
// There are intentionally no test cases for "map" because
// it is a stub that always returns an error.
Expand Down
10 changes: 10 additions & 0 deletions lang/references.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ func References(traversals []hcl.Traversal) ([]*addrs.Reference, tfdiags.Diagnos
refs := make([]*addrs.Reference, 0, len(traversals))

for _, traversal := range traversals {
if traversal.RootName() == "template" {
// "template" traversals don't create references. Instead, they
// are just part of the template values syntax within the
// special "maketemplate" function. We'll ignore references
// here to allow maketemplate calls to work, although any use
// of these outside of maketemplate will ultimately fail during
// evaluation, because we don't define this symbol there.
continue
}

ref, refDiags := addrs.ParseRef(traversal)
diags = diags.Append(refDiags)
if ref == nil {
Expand Down
5 changes: 5 additions & 0 deletions lang/templatevals/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Package templatevals deals with the idea of "template values" in the
// Terraform language, which allow passing around not-yet-evaluated string
// templates in a structured way that allows for type checking and avoids
// confusing additional template escaping.
package templatevals
163 changes: 163 additions & 0 deletions lang/templatevals/funcs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package templatevals

import (
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/ext/customdecode"
"github.com/hashicorp/hcl/v2/hclsyntax"

"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
"github.com/zclconf/go-cty/cty/function"
)

var MakeTemplateFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "source",
Type: customdecode.ExpressionClosureType,
},
},
Type: func(args []cty.Value) (cty.Type, error) {
// Because our parameter is constrained with the special
// customdecode.ExpressionClosureType constraint, it's
// guaranteed to be a known value of a capsule type
// wrapping customdecode.ExpressionClosure.
closure := args[0].EncapsulatedValue().(*customdecode.ExpressionClosure)
expr := closure.Expression
parentCtx := closure.EvalContext

// Although in principle this lazy evaluation mechanism could
// apply to any sort of expression, we intentionally constrain
// it only to template expressions here to reinforce that this
// is not intended as a general lambda function mechanism.
switch expr.(type) {
case *hclsyntax.TemplateExpr, *hclsyntax.TemplateWrapExpr:
// ok
default:
return cty.DynamicPseudoType, function.NewArgErrorf(0, "must be a string template expression")
}

// Our initial template type has arguments derived from the references
// that have traversals starting with "template".
atys := make(map[string]cty.Type)
for _, traversal := range expr.Variables() {
if traversal.RootName() != "template" {
// We don't care about any other traversals
continue
}
var step1 hcl.TraverseAttr
if len(traversal) >= 2 {
if ta, ok := traversal[1].(hcl.TraverseAttr); ok {
step1 = ta
}
}
name := step1.Name
if name == "" { // The conditions above didn't match, then
return cty.DynamicPseudoType, function.NewArgErrorf(0, "template argument reference at %s must include an attribute lookup representing the argument name", traversal.SourceRange())
}
// All of our arguments start off with unconstrained types because
// we can't walk backwards from an expression to all of the types
// that could succeed with it. However, the type conversion
// behavior for template values includes a more specific type check
// if the destination type has more constrained arguments.
atys[name] = cty.DynamicPseudoType
}

// Before we return we'll check to make sure the expression is
// evaluable _at all_ (even before we know the argument values)
// because that'll help users catch totally-invalid templates early,
// even before they try to pass them to another module to be evaluated.
ctx := parentCtx.NewChild()
ctx.Variables = map[string]cty.Value{
"template": cty.UnknownVal(cty.Object(atys)),
}
v, diags := expr.Value(ctx)
if diags.HasErrors() {
// It would be nice to have a way to report these diags
// directly out to HCL, but unfortunately we're sending them
// out through cty and it doesn't understand HCL diagnostics.
return cty.DynamicPseudoType, function.NewArgErrorf(0, "invalid template: %s", diags.Error())
}
if _, err := convert.Convert(v, cty.String); err != nil {
// We'll catch this early where possible. It won't always be
// possible, because the return type might vary depending on
// the input, so we must re-check this in evaltemplate too.
return cty.DynamicPseudoType, function.NewArgErrorf(0, "invalid template: must produce a string result")
}

// If all of the above was successful then this template seems valid
// and we can determine which type we're returning. (The actual
// _value_ of that type will come in Impl.)
return Type(atys), nil
},
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
// We already did all of our checking inside Type, so our only remaining
// job now is to wrap the expression closure up inside a value of
// our capsule type.
closure := args[0].EncapsulatedValue().(*customdecode.ExpressionClosure)
tv := &templateVal{
expr: closure.Expression,
ctx: closure.EvalContext,
}
return cty.CapsuleVal(retType, tv), nil
},
})

// TODO: Consider also a "templatefromfile" function that compiles a separate
// file as a template in a similar way that "templatefile" does, but which
// returns a template value rather than immediately evaluating the template.
// This would then be more convenient for situations where the expected
// template is quite large in itself and thus worth factoring out into a
// separate file.

var EvalTemplateFunc = function.New(&function.Spec{
Params: []function.Parameter{
{
Name: "template",
// We need to type-check this dynamically because there is an
// infinite number of possible template types.
Type: cty.DynamicPseudoType,
},
{
Name: "args",
// We also need to type-check _this_ dynamically, because
// we expect an object type whose attributes depend on the
// template type.
Type: cty.DynamicPseudoType,
AllowMarked: true,
},
},
Type: function.StaticReturnType(cty.String),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
template := args[0]
rawArgsObj, retMarks := args[1].Unmark()

tv := template.EncapsulatedValue().(*templateVal)
atys := TypeArgs(template.Type())

// The given arguments object must be compatible with the expected
// argument types. This'll catch if the call lacks any arguments
// that the template requires, or if any of them are of an unsuitable
// type.
argsObj, err := convert.Convert(rawArgsObj, cty.Object(atys))
if err != nil {
return cty.NilVal, function.NewArgError(1, err)
}

ctx := tv.ctx.NewChild()
ctx.Variables = map[string]cty.Value{
"template": argsObj,
}
v, diags := tv.expr.Value(ctx)
if diags.HasErrors() {
return cty.NilVal, function.NewArgErrorf(0, "incompatible template: %s", diags.Error())
}

v, err = convert.Convert(v, retType)
if err != nil {
return cty.NilVal, function.NewArgErrorf(0, "template must produce a string result")
}

return v.WithMarks(retMarks), nil
},
})
Loading

0 comments on commit 6eea05b

Please sign in to comment.