Skip to content

Commit

Permalink
Add support for provider-contributed functions
Browse files Browse the repository at this point in the history
  • Loading branch information
wata727 committed Apr 30, 2024
1 parent 2f69e5a commit 2d108a7
Show file tree
Hide file tree
Showing 6 changed files with 343 additions and 12 deletions.
37 changes: 29 additions & 8 deletions terraform/lang/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ import (
// Note that Terraform only expands dynamic blocks, but TFLint also expands
// count/for_each here.
//
// Expressions in expanded blocks are evaluated immediately, so all variables
// contained in attributes specified in the body schema are gathered.
// Expressions in expanded blocks are evaluated immediately, so all variables and
// function calls contained in attributes specified in the body schema are gathered.
func (s *Scope) ExpandBlock(body hcl.Body, schema *hclext.BodySchema) (hcl.Body, hcl.Diagnostics) {
traversals := tfhcl.ExpandVariablesHCLExt(body, schema)
refs, diags := References(traversals)

ctx, ctxDiags := s.EvalContext(refs)
exprs := tfhcl.ExpandExpressionsHCLExt(body, schema)
funcCalls := []*FunctionCall{}
for _, expr := range exprs {
calls, funcDiags := FunctionCallsInExpr(expr)
diags = diags.Extend(funcDiags)
funcCalls = append(funcCalls, calls...)
}

ctx, ctxDiags := s.EvalContext(refs, funcCalls)
diags = diags.Extend(ctxDiags)

return tfhcl.Expand(body, ctx), diags
Expand All @@ -40,8 +48,10 @@ func (s *Scope) ExpandBlock(body hcl.Body, schema *hclext.BodySchema) (hcl.Body,
// incomplete, but will always be of the requested type.
func (s *Scope) EvalExpr(expr hcl.Expression, wantType cty.Type) (cty.Value, hcl.Diagnostics) {
refs, diags := ReferencesInExpr(expr)
funcCalls, funcDiags := FunctionCallsInExpr(expr)
diags = diags.Extend(funcDiags)

ctx, ctxDiags := s.EvalContext(refs)
ctx, ctxDiags := s.EvalContext(refs, funcCalls)
diags = diags.Extend(ctxDiags)
if diags.HasErrors() {
// We'll stop early if we found problems in the references, because
Expand Down Expand Up @@ -72,23 +82,34 @@ func (s *Scope) EvalExpr(expr hcl.Expression, wantType cty.Type) (cty.Value, hcl
}

// EvalContext constructs a HCL expression evaluation context whose variable
// scope contains sufficient values to satisfy the given set of references.
// scope contains sufficient values to satisfy the given set of references
// and function calls.
//
// Most callers should prefer to use the evaluation helper methods that
// this type offers, but this is here for less common situations where the
// caller will handle the evaluation calls itself.
func (s *Scope) EvalContext(refs []*addrs.Reference) (*hcl.EvalContext, hcl.Diagnostics) {
return s.evalContext(refs, s.SelfAddr)
func (s *Scope) EvalContext(refs []*addrs.Reference, funcCalls []*FunctionCall) (*hcl.EvalContext, hcl.Diagnostics) {
return s.evalContext(refs, s.SelfAddr, funcCalls)
}

func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceable) (*hcl.EvalContext, hcl.Diagnostics) {
func (s *Scope) evalContext(refs []*addrs.Reference, selfAddr addrs.Referenceable, funcCalls []*FunctionCall) (*hcl.EvalContext, hcl.Diagnostics) {
if s == nil {
panic("attempt to construct EvalContext for nil Scope")
}

var diags hcl.Diagnostics
vals := make(map[string]cty.Value)
funcs := s.Functions()
// Provider-contributed functions introduced in Terraform v1.8 cannot be
// evaluated statically in many cases. Here, we avoid the error by dynamically
// generating an evaluation context in which the provider-contributed functions
// in the given expression are replaced with mock functions.
for _, call := range funcCalls {
if !call.IsProviderContributed() {
continue
}
funcs[call.Name] = NewMockFunction(call)
}
ctx := &hcl.EvalContext{
Variables: vals,
Functions: funcs,
Expand Down
7 changes: 6 additions & 1 deletion terraform/lang/eval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,15 @@ func TestScopeEvalContext(t *testing.T) {
t.Fatal(refsDiags)
}

funcCalls, funcDiags := FunctionCallsInExpr(expr)
if funcDiags.HasErrors() {
t.Fatal(funcDiags)
}

scope := &Scope{
Data: data,
}
ctx, ctxDiags := scope.EvalContext(refs)
ctx, ctxDiags := scope.EvalContext(refs, funcCalls)
if ctxDiags.HasErrors() {
t.Fatal(ctxDiags)
}
Expand Down
109 changes: 109 additions & 0 deletions terraform/lang/function_calls.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package lang

import (
"strings"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/hcl/v2/json"
"github.com/zclconf/go-cty/cty"
)

// FunctionCall represents a function call in an HCL expression.
// The difference with hclsyntax.FunctionCallExpr is that
// function calls are also available in JSON syntax.
type FunctionCall struct {
Name string
ArgsCount int
}

// FunctionCallsInExpr finds all of the function calls in the given expression.
func FunctionCallsInExpr(expr hcl.Expression) ([]*FunctionCall, hcl.Diagnostics) {
if expr == nil {
return nil, nil
}

// For JSON syntax, walker is not implemented,
// so extract the hclsyntax.Node that we can walk on.
// See https://github.com/hashicorp/hcl/issues/543
nodes, diags := walkableNodesInExpr(expr)
ret := []*FunctionCall{}

for _, node := range nodes {
visitDiags := hclsyntax.VisitAll(node, func(n hclsyntax.Node) hcl.Diagnostics {
if funcCallExpr, ok := n.(*hclsyntax.FunctionCallExpr); ok {
ret = append(ret, &FunctionCall{
Name: funcCallExpr.Name,
ArgsCount: len(funcCallExpr.Args),
})
}
return nil
})
diags = diags.Extend(visitDiags)
}
return ret, diags
}

// IsProviderContributed returns true if the function is provider-contributed.
func (f *FunctionCall) IsProviderContributed() bool {
return strings.HasPrefix(f.Name, "provider::")
}

// walkableNodesInExpr returns hclsyntax.Node from the given expression.
// If the expression is an hclsyntax expression, it is returned as is.
// If the expression is a JSON expression, it is parsed and
// hclsyntax.Node it contains is returned.
func walkableNodesInExpr(expr hcl.Expression) ([]hclsyntax.Node, hcl.Diagnostics) {
nodes := []hclsyntax.Node{}

expr = hcl.UnwrapExpressionUntil(expr, func(expr hcl.Expression) bool {
_, native := expr.(hclsyntax.Expression)
return native || json.IsJSONExpression(expr)
})

if json.IsJSONExpression(expr) {
// HACK: For JSON expressions, we can get the JSON value as a literal
// without any prior HCL parsing by evaluating it in a nil context.
// We can take advantage of this property to walk through cty.Value
// that may contain HCL expressions instead of walking through
// expression nodes directly.
// See https://github.com/hashicorp/hcl/issues/642
val, diags := expr.Value(nil)
if diags.HasErrors() {
return nodes, diags
}

err := cty.Walk(val, func(path cty.Path, v cty.Value) (bool, error) {
if v.Type() != cty.String || v.IsNull() || !v.IsKnown() {
return true, nil
}

node, parseDiags := hclsyntax.ParseTemplate([]byte(v.AsString()), expr.Range().Filename, expr.Range().Start)
if diags.HasErrors() {
diags = diags.Extend(parseDiags)
return true, nil
}

nodes = append(nodes, node)
return true, nil
})
if err != nil {
return nodes, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: "Failed to walk the expression value",
Detail: err.Error(),
Subject: expr.Range().Ptr(),
}}
}

return nodes, diags
}

// Usually only a native expression is here since a JSON expression is already
// processed, but we should check the type to avoid typed-nil.
if node, ok := expr.(hclsyntax.Expression); ok {
nodes = append(nodes, node)
}

return nodes, nil
}
38 changes: 35 additions & 3 deletions terraform/lang/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func (s *Scope) Functions() map[string]function.Function {
// later if the functionality seems to be something domain-agnostic
// that would be useful to all applications using cty functions.

s.funcs = map[string]function.Function{
coreFuncs := map[string]function.Function{
"abs": stdlib.AbsoluteFunc,
"abspath": funcs.AbsPathFunc,
"alltrue": funcs.AllTrueFunc,
Expand Down Expand Up @@ -151,7 +151,7 @@ func (s *Scope) Functions() map[string]function.Function {
"zipmap": stdlib.ZipmapFunc,
}

s.funcs["templatefile"] = funcs.MakeTemplateFileFunc(s.BaseDir, func() map[string]function.Function {
coreFuncs["templatefile"] = funcs.MakeTemplateFileFunc(s.BaseDir, func() map[string]function.Function {
// The templatefile function prevents recursive calls to itself
// by copying this map and overwriting the "templatefile" entry.
return s.funcs
Expand All @@ -161,11 +161,43 @@ func (s *Scope) Functions() map[string]function.Function {
// Force our few impure functions to return unknown so that we
// can defer evaluating them until a later pass.
for _, name := range impureFunctions {
s.funcs[name] = function.Unpredictable(s.funcs[name])
coreFuncs[name] = function.Unpredictable(s.funcs[name])
}
}

// All of the built-in functions are also available under the "core::"
// namespace, to distinguish from the "provider::" and "module::"
// namespaces that can serve as external extension points.
s.funcs = make(map[string]function.Function, len(coreFuncs)*2)
for name, fn := range coreFuncs {
s.funcs[name] = fn
s.funcs["core::"+name] = fn
}
}
s.funcsLock.Unlock()

return s.funcs
}

// NewMockFunction creates a mock function that returns a dynamic value.
// This is primarily used to replace provider-contributed functions.
func NewMockFunction(call *FunctionCall) function.Function {
params := make([]function.Parameter, call.ArgsCount)
for idx := 0; idx < call.ArgsCount; idx++ {
params[idx] = function.Parameter{
Type: cty.DynamicPseudoType,
AllowNull: true,
AllowUnknown: true,
AllowDynamicType: true,
AllowMarked: true,
}
}

return function.New(&function.Spec{
Params: params,
Type: function.StaticReturnType(cty.DynamicPseudoType),
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
return cty.DynamicVal, nil
},
})
}
14 changes: 14 additions & 0 deletions terraform/lang/functions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/hashicorp/hcl/v2"
Expand Down Expand Up @@ -905,6 +906,10 @@ func TestFunctions(t *testing.T) {
`templatefile("hello.tmpl", {name = "Jodie"})`,
cty.StringVal("Hello, Jodie!"),
},
{
`core::templatefile("hello.tmpl", {name = "Namespaced Jodie"})`,
cty.StringVal("Hello, Namespaced Jodie!"),
},
},

"timeadd": {
Expand Down Expand Up @@ -1045,6 +1050,10 @@ func TestFunctions(t *testing.T) {
`upper("hello")`,
cty.StringVal("HELLO"),
},
{
`core::upper("hello")`,
cty.StringVal("HELLO"),
},
},

"urlencode": {
Expand Down Expand Up @@ -1145,6 +1154,11 @@ func TestFunctions(t *testing.T) {
delete(allFunctions, impureFunc)
}
for f := range scope.Functions() {
if strings.Contains(f, "::") {
// Only non-namespaced functions are absolutely required to
// have at least one test. (Others _may_ have tests.)
continue
}
if _, ok := tests[f]; !ok {
t.Errorf("Missing test for function %s\n", f)
}
Expand Down
Loading

0 comments on commit 2d108a7

Please sign in to comment.