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

String templates as values #28700

Closed
wants to merge 1 commit into from
Closed
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
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