Skip to content

Commit

Permalink
ext/dynblock: Allow callers to veto for_each values
Browse files Browse the repository at this point in the history
Callers might have additional rules for what's acceptable in a for_each
value for a dynamic block. For example, Terraform wants to forbid using
sensitive values here because it would cause the expansion to disclose the
length of the given collection.

Therefore this provides a hook point for callers to insert additional
checks just after the for_each expression has been evaluated and before
any of the built-in checks are run.

This introduces the "functional options" pattern for ExpandBlock for the
first time, as a way to extend the API without breaking compatibility with
existing callers. There is currently only this one option.
  • Loading branch information
apparentlymart committed Oct 16, 2023
1 parent 4945193 commit 0af4fe2
Show file tree
Hide file tree
Showing 5 changed files with 143 additions and 17 deletions.
4 changes: 4 additions & 0 deletions ext/dynblock/expand_body.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type expandBody struct {
forEachCtx *hcl.EvalContext
iteration *iteration // non-nil if we're nested inside another "dynamic" block

checkForEach []func(cty.Value, hcl.Expression, *hcl.EvalContext) hcl.Diagnostics

// These are used with PartialContent to produce a "remaining items"
// body to return. They are nil on all bodies fresh out of the transformer.
//
Expand Down Expand Up @@ -66,6 +68,7 @@ func (b *expandBody) PartialContent(schema *hcl.BodySchema) (*hcl.BodyContent, h
original: b.original,
forEachCtx: b.forEachCtx,
iteration: b.iteration,
checkForEach: b.checkForEach,
hiddenAttrs: make(map[string]struct{}),
hiddenBlocks: make(map[string]hcl.BlockHeaderSchema),
}
Expand Down Expand Up @@ -236,6 +239,7 @@ func (b *expandBody) expandChild(child hcl.Body, i *iteration) hcl.Body {
chiCtx := i.EvalContext(b.forEachCtx)
ret := Expand(child, chiCtx)
ret.(*expandBody).iteration = i
ret.(*expandBody).checkForEach = b.checkForEach
return ret
}

Expand Down
85 changes: 85 additions & 0 deletions ext/dynblock/expand_body_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"strings"
"testing"

"github.com/davecgh/go-spew/spew"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hcldec"
"github.com/hashicorp/hcl/v2/hcltest"
"github.com/zclconf/go-cty-debug/ctydebug"
"github.com/zclconf/go-cty/cty"
)

Expand Down Expand Up @@ -336,6 +338,89 @@ func TestExpand(t *testing.T) {

}

func TestExpandWithForEachCheck(t *testing.T) {
forEachExpr := hcltest.MockExprLiteral(cty.MapValEmpty(cty.String).Mark("boop"))
evalCtx := &hcl.EvalContext{}
srcContent := &hcl.BodyContent{
Blocks: hcl.Blocks{
{
Type: "dynamic",
Labels: []string{"foo"},
LabelRanges: []hcl.Range{{}},
Body: hcltest.MockBody(&hcl.BodyContent{
Attributes: hcltest.MockAttrs(map[string]hcl.Expression{
"for_each": forEachExpr,
}),
Blocks: hcl.Blocks{
{
Type: "content",
Body: hcltest.MockBody(&hcl.BodyContent{}),
},
},
}),
},
},
}
srcBody := hcltest.MockBody(srcContent)

hookCalled := false
var gotV cty.Value
var gotEvalCtx *hcl.EvalContext

expBody := Expand(
srcBody, evalCtx,
OptCheckForEach(func(v cty.Value, e hcl.Expression, ec *hcl.EvalContext) hcl.Diagnostics {
hookCalled = true
gotV = v
gotEvalCtx = ec
return hcl.Diagnostics{
&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Bad for_each",
Detail: "I don't like it.",
Expression: e,
EvalContext: ec,
Extra: "diagnostic extra",
},
}
}),
)

_, diags := expBody.Content(&hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{
Type: "foo",
},
},
})
if !diags.HasErrors() {
t.Fatal("succeeded; want an error")
}
if len(diags) != 1 {
t.Fatalf("wrong number of diagnostics; want only one\n%s", spew.Sdump(diags))
}
if got, want := diags[0].Summary, "Bad for_each"; got != want {
t.Fatalf("wrong error\ngot: %s\nwant: %s\n\n%s", got, want, spew.Sdump(diags[0]))
}
if got, want := diags[0].Extra, "diagnostic extra"; got != want {
// This is important to allow the application which provided the
// hook to pass application-specific extra values through this
// API in case the hook's diagnostics need some sort of special
// treatment.
t.Fatalf("diagnostic didn't preserve 'extra' field\ngot: %s\nwant: %s\n\n%s", got, want, spew.Sdump(diags[0]))
}

if !hookCalled {
t.Fatal("check hook wasn't called")
}
if !gotV.HasMark("boop") {
t.Errorf("wrong value passed to check hook; want the value marked \"boop\"\n%s", ctydebug.ValueString(gotV))
}
if gotEvalCtx != evalCtx {
t.Error("wrong EvalContext passed to check hook; want the one passed to Expand")
}
}

func TestExpandUnknownBodies(t *testing.T) {
srcContent := &hcl.BodyContent{
Blocks: hcl.Blocks{
Expand Down
10 changes: 10 additions & 0 deletions ext/dynblock/expand_spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ func (b *expandBody) decodeSpec(blockS *hcl.BlockHeaderSchema, rawSpec *hcl.Bloc
eachAttr := specContent.Attributes["for_each"]
eachVal, eachDiags := eachAttr.Expr.Value(b.forEachCtx)
diags = append(diags, eachDiags...)
if diags.HasErrors() {
return nil, diags
}
for _, check := range b.checkForEach {
moreDiags := check(eachVal, eachAttr.Expr, b.forEachCtx)
diags = append(diags, moreDiags...)
if moreDiags.HasErrors() {
return nil, diags
}
}

if !eachVal.CanIterateElements() && eachVal.Type() != cty.DynamicPseudoType {
// We skip this error for DynamicPseudoType because that means we either
Expand Down
23 changes: 23 additions & 0 deletions ext/dynblock/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package dynblock

import (
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
)

type ExpandOption interface {
applyExpandOption(*expandBody)
}

type optCheckForEach struct {
check func(cty.Value, hcl.Expression, *hcl.EvalContext) hcl.Diagnostics
}

func OptCheckForEach(check func(cty.Value, hcl.Expression, *hcl.EvalContext) hcl.Diagnostics) ExpandOption {
return optCheckForEach{check}
}

// applyExpandOption implements ExpandOption.
func (o optCheckForEach) applyExpandOption(body *expandBody) {
body.checkForEach = append(body.checkForEach, o.check)
}
38 changes: 21 additions & 17 deletions ext/dynblock/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,28 @@ import (
// multi-dimensional iteration. However, it is not possible to
// dynamically-generate the "dynamic" blocks themselves except through nesting.
//
// parent {
// dynamic "child" {
// for_each = child_objs
// content {
// dynamic "grandchild" {
// for_each = child.value.children
// labels = [grandchild.key]
// content {
// parent_key = child.key
// value = grandchild.value
// }
// }
// }
// }
// }
func Expand(body hcl.Body, ctx *hcl.EvalContext) hcl.Body {
return &expandBody{
// parent {
// dynamic "child" {
// for_each = child_objs
// content {
// dynamic "grandchild" {
// for_each = child.value.children
// labels = [grandchild.key]
// content {
// parent_key = child.key
// value = grandchild.value
// }
// }
// }
// }
// }
func Expand(body hcl.Body, ctx *hcl.EvalContext, opts ...ExpandOption) hcl.Body {
ret := &expandBody{
original: body,
forEachCtx: ctx,
}
for _, opt := range opts {
opt.applyExpandOption(ret)
}
return ret
}

0 comments on commit 0af4fe2

Please sign in to comment.