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 7987bff
Show file tree
Hide file tree
Showing 11 changed files with 701 additions and 18 deletions.
106 changes: 105 additions & 1 deletion terraform/evaluator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,20 @@ variable "string_var" {
want: `cty.StringVal("acbd18db4cc2f85cedef654fccc4a4d8")`,
errCheck: neverHappend,
},
{
name: "built-in function with namespace",
expr: expr(`core::md5("foo")`),
ty: cty.String,
want: `cty.StringVal("acbd18db4cc2f85cedef654fccc4a4d8")`,
errCheck: neverHappend,
},
{
name: "provider-contributed functions",
expr: expr(`provider::tflint::echo("Hello", "World!")`),
ty: cty.String,
want: `cty.UnknownVal(cty.String)`,
errCheck: neverHappend,
},
{
name: "terraform workspace",
expr: expr(`terraform.workspace`),
Expand Down Expand Up @@ -928,6 +942,19 @@ resource "aws_instance" "main" {
config: `
resource "aws_instance" "main" {
count = module.meta.count
}`,
schema: &hclext.BodySchema{
Blocks: []hclext.BlockSchema{
{Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{}},
},
},
want: &hclext.BodyContent{Attributes: hclext.Attributes{}, Blocks: hclext.Blocks{}},
},
{
name: "count is using provider-contributed functions",
config: `
resource "aws_instance" "main" {
count = provider::tflint::count()
}`,
schema: &hclext.BodySchema{
Blocks: []hclext.BlockSchema{
Expand Down Expand Up @@ -1021,6 +1048,29 @@ resource "aws_instance" "main" {
},
},
},
{
name: "count.index and provider-contributed functions",
config: `
resource "aws_instance" "main" {
count = 1
value = [count.index, provider::tflint::sum(1, 2, 3)]
}`,
schema: &hclext.BodySchema{
Blocks: []hclext.BlockSchema{
{Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}}},
},
},
want: &hclext.BodyContent{
Attributes: hclext.Attributes{},
Blocks: hclext.Blocks{
{
Type: "resource",
Labels: []string{"aws_instance", "main"},
Body: &hclext.BodyContent{Attributes: hclext.Attributes{"value": {Name: "value", Expr: hcl.StaticExpr(cty.TupleVal([]cty.Value{cty.NumberIntVal(0), cty.DynamicVal}), hcl.Range{})}}, Blocks: hclext.Blocks{}},
},
},
},
},
{
name: "for_each is not empty (literal)",
config: `
Expand Down Expand Up @@ -1086,10 +1136,23 @@ resource "aws_instance" "main" {
want: &hclext.BodyContent{Attributes: hclext.Attributes{}, Blocks: hclext.Blocks{}},
},
{
name: "for_each is evaluable",
name: "for_each is unevaluable",
config: `
resource "aws_instance" "main" {
for_each = module.meta.for_each
}`,
schema: &hclext.BodySchema{
Blocks: []hclext.BlockSchema{
{Type: "resource", LabelNames: []string{"type", "name"}, Body: &hclext.BodySchema{}},
},
},
want: &hclext.BodyContent{Attributes: hclext.Attributes{}, Blocks: hclext.Blocks{}},
},
{
name: "for_each is using provider-contributed functions",
config: `
resource "aws_instance" "main" {
for_each = provider::tflint::for_each()
}`,
schema: &hclext.BodySchema{
Blocks: []hclext.BlockSchema{
Expand Down Expand Up @@ -1415,6 +1478,47 @@ resource "aws_instance" "main" {
value = "${ebs_block_device.key}-${ebs_block_device.value}"
}
}
}`,
schema: &hclext.BodySchema{
Blocks: []hclext.BlockSchema{
{
Type: "resource",
LabelNames: []string{"type", "name"},
Body: &hclext.BodySchema{
Blocks: []hclext.BlockSchema{
{
Type: "ebs_block_device",
Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "value"}}},
},
},
},
},
},
},
want: &hclext.BodyContent{
Attributes: hclext.Attributes{},
Blocks: hclext.Blocks{
{
Type: "resource",
Labels: []string{"aws_instance", "main"},
Body: &hclext.BodyContent{
Attributes: hclext.Attributes{},
Blocks: hclext.Blocks{},
},
},
},
},
},
{
name: "dynamic blocks with provider-contributed functions",
config: `
resource "aws_instance" "main" {
dynamic "ebs_block_device" {
for_each = provider::tflint::for_each()
content {
value = "${ebs_block_device.key}-${ebs_block_device.value}"
}
}
}`,
schema: &hclext.BodySchema{
Blocks: []hclext.BlockSchema{
Expand Down
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
4 changes: 2 additions & 2 deletions terraform/lang/funcs/filesystem.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
// SPDX-License-Identifier: BUSL-1.1

package funcs

Expand Down Expand Up @@ -135,7 +135,7 @@ func MakeTemplateFileFunc(baseDir string, funcsCb func() map[string]function.Fun
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 == "templatefile" {
if name == "templatefile" || name == "core::templatefile" {
// We stub this one out to prevent recursive calls.
funcs[name] = function.New(&function.Spec{
Params: params,
Expand Down
15 changes: 13 additions & 2 deletions terraform/lang/funcs/filesystem_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package funcs

import (
Expand Down Expand Up @@ -149,6 +152,12 @@ func TestTemplateFile(t *testing.T) {
cty.NilVal,
`testdata/recursive.tmpl:1,3-16: Error in function call; Call to function "templatefile" failed: cannot recursively call templatefile from inside templatefile call.`,
},
{
cty.StringVal("testdata/recursive_namespaced.tmpl"),
cty.MapValEmpty(cty.String),
cty.NilVal,
`testdata/recursive_namespaced.tmpl:1,3-22: Error in function call; Call to function "core::templatefile" failed: cannot recursively call templatefile from inside templatefile call.`,
},
{
cty.StringVal("testdata/list.tmpl"),
cty.ObjectVal(map[string]cty.Value{
Expand Down Expand Up @@ -181,8 +190,10 @@ func TestTemplateFile(t *testing.T) {

templateFileFn := MakeTemplateFileFunc(".", func() map[string]function.Function {
return map[string]function.Function{
"join": stdlib.JoinFunc,
"templatefile": MakeFileFunc(".", false), // just a placeholder, since templatefile itself overrides this
"join": stdlib.JoinFunc,
"core::join": stdlib.JoinFunc,
"templatefile": MakeFileFunc(".", false), // just a placeholder, since templatefile itself overrides this
"core::templatefile": MakeFileFunc(".", false), // just a placeholder, since templatefile itself overrides this
}
})

Expand Down
1 change: 1 addition & 0 deletions terraform/lang/funcs/testdata/recursive_namespaced.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
${core::templatefile("recursive_namespaced.tmpl", {})}
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 expr == nil {
return nil, nil
}

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
}

// The JSON syntax is already processed, so it's guaranteed to be native syntax.
nodes = append(nodes, expr.(hclsyntax.Expression))

return nodes, nil
}
Loading

0 comments on commit 7987bff

Please sign in to comment.