From 0af4fe25743d8b0126b745f3c78148b5ae95f5fe Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 12 Oct 2023 10:06:02 -0700 Subject: [PATCH] ext/dynblock: Allow callers to veto for_each values 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. --- ext/dynblock/expand_body.go | 4 ++ ext/dynblock/expand_body_test.go | 85 ++++++++++++++++++++++++++++++++ ext/dynblock/expand_spec.go | 10 ++++ ext/dynblock/options.go | 23 +++++++++ ext/dynblock/public.go | 38 +++++++------- 5 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 ext/dynblock/options.go diff --git a/ext/dynblock/expand_body.go b/ext/dynblock/expand_body.go index 0d870842..2734e937 100644 --- a/ext/dynblock/expand_body.go +++ b/ext/dynblock/expand_body.go @@ -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. // @@ -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), } @@ -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 } diff --git a/ext/dynblock/expand_body_test.go b/ext/dynblock/expand_body_test.go index ccc4d303..fd440783 100644 --- a/ext/dynblock/expand_body_test.go +++ b/ext/dynblock/expand_body_test.go @@ -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" ) @@ -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{ diff --git a/ext/dynblock/expand_spec.go b/ext/dynblock/expand_spec.go index 23295d33..9585172e 100644 --- a/ext/dynblock/expand_spec.go +++ b/ext/dynblock/expand_spec.go @@ -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 diff --git a/ext/dynblock/options.go b/ext/dynblock/options.go new file mode 100644 index 00000000..fc7dc0bd --- /dev/null +++ b/ext/dynblock/options.go @@ -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) +} diff --git a/ext/dynblock/public.go b/ext/dynblock/public.go index abee67c4..79b6144c 100644 --- a/ext/dynblock/public.go +++ b/ext/dynblock/public.go @@ -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 }