From 8b4c89bdaff4e2f45af476144bd33f923fe18934 Mon Sep 17 00:00:00 2001 From: James Bardin Date: Fri, 8 Apr 2022 15:24:40 -0400 Subject: [PATCH] evaluate replace_triggered_by expressions Evaluate the expressions stored in replace_triggered_by into the *addrs.Reference needed to lookup changes in the plan. --- internal/terraform/evaluate_triggers.go | 143 +++++++++++++++++++ internal/terraform/evaluate_triggers_test.go | 94 ++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 internal/terraform/evaluate_triggers.go create mode 100644 internal/terraform/evaluate_triggers_test.go diff --git a/internal/terraform/evaluate_triggers.go b/internal/terraform/evaluate_triggers.go new file mode 100644 index 0000000000..31fd80e16b --- /dev/null +++ b/internal/terraform/evaluate_triggers.go @@ -0,0 +1,143 @@ +package terraform + +import ( + "strings" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/instances" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +func evalReplaceTriggeredByExpr(expr hcl.Expression, keyData instances.RepetitionData) (*addrs.Reference, tfdiags.Diagnostics) { + var ref *addrs.Reference + var diags tfdiags.Diagnostics + + traversal, diags := triggersExprToTraversal(expr, keyData) + if diags.HasErrors() { + return nil, diags + } + + // We now have a static traversal, so we can just turn it into an addrs.Reference. + ref, ds := addrs.ParseRef(traversal) + diags = diags.Append(ds) + + return ref, diags +} + +// trggersExprToTraversal takes an hcl expression limited to the syntax allowed +// in replace_triggered_by, and converts it to a static traversal. The +// RepetitionData contains the data necessary to evaluate the only allowed +// variables in the expression, count.index and each.key. +func triggersExprToTraversal(expr hcl.Expression, keyData instances.RepetitionData) (hcl.Traversal, tfdiags.Diagnostics) { + var trav hcl.Traversal + var diags tfdiags.Diagnostics + + switch e := expr.(type) { + case *hclsyntax.RelativeTraversalExpr: + t, d := triggersExprToTraversal(e.Source, keyData) + diags = diags.Append(d) + trav = append(trav, t...) + trav = append(trav, e.Traversal...) + + case *hclsyntax.ScopeTraversalExpr: + // a static reference, we can just append the traversal + trav = append(trav, e.Traversal...) + + case *hclsyntax.IndexExpr: + // Get the collection from the index expression + t, d := triggersExprToTraversal(e.Collection, keyData) + diags = diags.Append(d) + if diags.HasErrors() { + return nil, diags + } + trav = append(trav, t...) + + // The index key is the only place where we could have variables that + // reference count and each, so we need to parse those independently. + idx, hclDiags := parseIndexKeyExpr(e.Key, keyData) + diags = diags.Append(hclDiags) + + trav = append(trav, idx) + + default: + // Something unexpected got through config validation. We're not sure + // what it is, but we'll point it out in the diagnostics for the user + // to fix. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid replace_triggered_by expression", + Detail: "Unexpected expression found in replace_triggered_by.", + Subject: e.Range().Ptr(), + }) + } + + return trav, diags +} + +// parseIndexKeyExpr takes an hcl.Expression and parses it as an index key, while +// evaluating any references to count.index or each.key. +func parseIndexKeyExpr(expr hcl.Expression, keyData instances.RepetitionData) (hcl.TraverseIndex, hcl.Diagnostics) { + idx := hcl.TraverseIndex{ + SrcRange: expr.Range(), + } + + trav, diags := hcl.RelTraversalForExpr(expr) + if diags.HasErrors() { + return idx, diags + } + + keyParts := []string{} + + for _, t := range trav { + attr, ok := t.(hcl.TraverseAttr) + if !ok { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid index expression", + Detail: "Only constant values, count.index or each.key are allowed in index expressions.", + Subject: expr.Range().Ptr(), + }) + return idx, diags + } + keyParts = append(keyParts, attr.Name) + } + + switch strings.Join(keyParts, ".") { + case "count.index": + if keyData.CountIndex == cty.NilVal { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Reference to "count" in non-counted context`, + Detail: `The "count" object can only be used in "resource" blocks when the "count" argument is set.`, + Subject: expr.Range().Ptr(), + }) + } + idx.Key = keyData.CountIndex + + case "each.key": + if keyData.EachKey == cty.NilVal { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: `Reference to "each" in context without for_each`, + Detail: `The "each" object can be used only in "resource" blocks when the "for_each" argument is set.`, + Subject: expr.Range().Ptr(), + }) + } + idx.Key = keyData.EachKey + default: + // Something may have slipped through validation, probably from a json + // configuration. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid index expression", + Detail: "Only constant values, count.index or each.key are allowed in index expressions.", + Subject: expr.Range().Ptr(), + }) + } + + return idx, diags + +} diff --git a/internal/terraform/evaluate_triggers_test.go b/internal/terraform/evaluate_triggers_test.go new file mode 100644 index 0000000000..d51b1c2be6 --- /dev/null +++ b/internal/terraform/evaluate_triggers_test.go @@ -0,0 +1,94 @@ +package terraform + +import ( + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/instances" + "github.com/zclconf/go-cty/cty" +) + +func TestEvalReplaceTriggeredBy(t *testing.T) { + tests := map[string]struct { + // Raw config expression from within replace_triggered_by list. + // If this does not contains any count or each references, it should + // directly parse into the same *addrs.Reference. + expr string + + // If the expression contains count or each, then we need to add + // repetition data, and the static string to parse into the desired + // *addrs.Reference + repData instances.RepetitionData + reference string + }{ + "single resource": { + expr: "test_resource.a", + }, + + "resource instance attr": { + expr: "test_resource.a.attr", + }, + + "resource instance index attr": { + expr: "test_resource.a[0].attr", + }, + + "resource instance count": { + expr: "test_resource.a[count.index]", + repData: instances.RepetitionData{ + CountIndex: cty.NumberIntVal(0), + }, + reference: "test_resource.a[0]", + }, + "resource instance for_each": { + expr: "test_resource.a[each.key].attr", + repData: instances.RepetitionData{ + EachKey: cty.StringVal("k"), + }, + reference: `test_resource.a["k"].attr`, + }, + "resource instance for_each map attr": { + expr: "test_resource.a[each.key].attr[each.key]", + repData: instances.RepetitionData{ + EachKey: cty.StringVal("k"), + }, + reference: `test_resource.a["k"].attr["k"]`, + }, + } + + for name, tc := range tests { + pos := hcl.Pos{Line: 1, Column: 1} + t.Run(name, func(t *testing.T) { + expr, hclDiags := hclsyntax.ParseExpression([]byte(tc.expr), "", pos) + if hclDiags.HasErrors() { + t.Fatal(hclDiags) + } + + got, diags := evalReplaceTriggeredByExpr(expr, tc.repData) + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + want := tc.reference + if want == "" { + want = tc.expr + } + + // create the desired reference + traversal, travDiags := hclsyntax.ParseTraversalAbs([]byte(want), "", pos) + if travDiags.HasErrors() { + t.Fatal(travDiags) + } + ref, diags := addrs.ParseRef(traversal) + if diags.HasErrors() { + t.Fatal(diags.Err()) + } + + if got.DisplayString() != ref.DisplayString() { + t.Fatalf("expected %q: got %q", ref.DisplayString(), got.DisplayString()) + } + }) + } +}