Skip to content

fix(terraform): evaluateStep to correctly set EvalContext for multiple instances of blocks #8555

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions pkg/iac/scanners/terraform/parser/ctylist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package parser

import "github.com/zclconf/go-cty/cty"

// insertTupleElement inserts a value into a tuple at the specified index.
// If the idx is outside the bounds of the list, it grows the tuple to
// the new size, and fills in `cty.NilVal` for the missing elements.
//
// This function will not panic. If the list value is not a list, it will
// be replaced with an empty list.
func insertTupleElement(list cty.Value, idx int, val cty.Value) cty.Value {
if list.IsNull() || !list.Type().IsTupleType() {
// better than a panic
list = cty.EmptyTupleVal
}

if idx < 0 {
// Nothing to do?
return list
}

newList := make([]cty.Value, max(idx+1, list.LengthInt()))
for i := 0; i < len(newList); i++ {
newList[i] = cty.NilVal // Always insert a nil by default

if i < list.LengthInt() { // keep the original
newList[i] = list.Index(cty.NumberIntVal(int64(i)))
}

if i == idx { // add the new value
newList[i] = val
}
}

return cty.TupleVal(newList)
}
104 changes: 104 additions & 0 deletions pkg/iac/scanners/terraform/parser/ctylist_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package parser

import (
"testing"

"github.com/stretchr/testify/require"
"github.com/zclconf/go-cty/cty"
)

func Test_insertTupleElement(t *testing.T) {
t.Parallel()

tests := []struct {
name string
start cty.Value
index int
value cty.Value
want cty.Value
}{
{
name: "empty",
start: cty.Value{},
index: 0,
value: cty.NilVal,
want: cty.TupleVal([]cty.Value{cty.NilVal}),
},
{
name: "empty to length",
start: cty.Value{},
index: 2,
value: cty.NilVal,
want: cty.TupleVal([]cty.Value{cty.NilVal, cty.NilVal, cty.NilVal}),
},
{
name: "insert to empty",
start: cty.EmptyTupleVal,
index: 1,
value: cty.NumberIntVal(5),
want: cty.TupleVal([]cty.Value{cty.NilVal, cty.NumberIntVal(5)}),
},
{
name: "insert to existing",
start: cty.TupleVal([]cty.Value{cty.NumberIntVal(1), cty.NumberIntVal(2), cty.NumberIntVal(3)}),
index: 1,
value: cty.NumberIntVal(5),
want: cty.TupleVal([]cty.Value{cty.NumberIntVal(1), cty.NumberIntVal(5), cty.NumberIntVal(3)}),
},
{
name: "insert to existing, extends",
start: cty.TupleVal([]cty.Value{cty.NumberIntVal(1), cty.NumberIntVal(2), cty.NumberIntVal(3)}),
index: 4,
value: cty.NumberIntVal(5),
want: cty.TupleVal([]cty.Value{
cty.NumberIntVal(1), cty.NumberIntVal(2),
cty.NumberIntVal(3), cty.NilVal,
cty.NumberIntVal(5),
}),
},
{
name: "mixed list",
start: cty.TupleVal([]cty.Value{cty.StringVal("a"), cty.NumberIntVal(2), cty.NumberIntVal(3)}),
index: 1,
value: cty.BoolVal(true),
want: cty.TupleVal([]cty.Value{
cty.StringVal("a"), cty.BoolVal(true), cty.NumberIntVal(3),
}),
},
{
name: "replace end",
start: cty.TupleVal([]cty.Value{cty.StringVal("a"), cty.NumberIntVal(2), cty.NumberIntVal(3)}),
index: 2,
value: cty.StringVal("end"),
want: cty.TupleVal([]cty.Value{
cty.StringVal("a"), cty.NumberIntVal(2), cty.StringVal("end"),
}),
},

// Some bad arguments
{
name: "negative index",
start: cty.TupleVal([]cty.Value{cty.StringVal("a"), cty.NumberIntVal(2), cty.NumberIntVal(3)}),
index: -1,
value: cty.BoolVal(true),
want: cty.TupleVal([]cty.Value{cty.StringVal("a"), cty.NumberIntVal(2), cty.NumberIntVal(3)}),
},
{
name: "non-list",
start: cty.BoolVal(true),
index: 1,
value: cty.BoolVal(true),
want: cty.TupleVal([]cty.Value{cty.NilVal, cty.BoolVal(true)}),
},
}

for _, tt := range tests {
tt := tt
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

capturing loop variable is no longer required https://go.dev/blog/loopvar-preview

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL ❤️

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Emyrk can we remove this then?

t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got := insertTupleElement(tt.start, tt.index, tt.value)
require.True(t, got.Equals(tt.want).True(), "expected %s, got %s", tt.want, got)
})
}
}
28 changes: 27 additions & 1 deletion pkg/iac/scanners/terraform/parser/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,6 @@ func (e *evaluator) getValuesByBlockType(blockType string) cty.Value {
values := make(map[string]cty.Value)

for _, b := range blocksOfType {

switch b.Type() {
case "variable": // variables are special in that their value comes from the "default" attribute
val, err := e.evaluateVariable(b)
Expand Down Expand Up @@ -552,6 +551,11 @@ func (e *evaluator) getValuesByBlockType(blockType string) cty.Value {
continue
}

// Data blocks should all be loaded into the top level 'values'
// object. The hierarchy of the map is:
// values = map[<type>]map[<name>] = Block
// Where Block is the block's attributes as a cty.Object.

blockMap, ok := values[b.Labels()[0]]
if !ok {
values[b.Labels()[0]] = cty.ObjectVal(make(map[string]cty.Value))
Expand All @@ -563,7 +567,29 @@ func (e *evaluator) getValuesByBlockType(blockType string) cty.Value {
valueMap = make(map[string]cty.Value)
}

// If the block reference has a key, that means there is multiple instances of
// this block. Example if `count` is used on a block, key will be a 'cty.Number',
// and the saved value should be a tuple.
ref := b.Reference()
rawKey := ref.RawKey()
if rawKey.Type().Equals(cty.Number) {
existing := valueMap[ref.NameLabel()]
asInt, _ := rawKey.AsBigFloat().Int64()

valueMap[ref.NameLabel()] = insertTupleElement(existing, int(asInt), b.Values())
}

// The default behavior, that being no key or an unimplemented key type, is to
// save the block as the name value. The name label value contains the key.
//
// Eg: If the block has the index '4', the string [4] is contained
// within 'b.Labels()[1]'
// TODO: This is always done to maintain backwards compatibility.
// I think this can be omitted if the switch statement above
// sets the appropriate tuple value in the valueMap.
valueMap[b.Labels()[1]] = b.Values()
Copy link
Contributor Author

@Emyrk Emyrk Mar 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really think if this if statement branch is executed, we should not do valueMap[b.Labels()[1]] = b.Values().

In an abundance of caution, I kept the previous context setting as well. The previous context is incorrect for an hcl traversal though. So I feel like it was never being hit.

How the code would look if we remove the current behavior entirely.

if rawKey.Type().Equals(cty.Number) {
  existing := valueMap[ref.NameLabel()]
  asInt, _ := rawKey.AsBigFloat().Int64()

  valueMap[ref.NameLabel()] = insertTupleElement(existing, int(asInt), b.Values())
} else {
  valueMap[b.Labels()[1]] = b.Values()
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the old behavior should be removed after adding the handling of all cases.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nikpivkin by "all the cases", do you mean handling for_each and possibly dynamic cases as well? (TBD if dynamic needs any changes, I am unsure off the top of my head)


// Update the map of all blocks with the same type.
values[b.Labels()[0]] = cty.ObjectVal(valueMap)
}
}
Expand Down
32 changes: 32 additions & 0 deletions pkg/iac/scanners/terraform/parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1704,6 +1704,38 @@ resource "test_resource" "this" {
assert.Equal(t, "test_value", attr.GetRawValue())
}

func TestCountArguments(t *testing.T) {
fsys := os.DirFS(filepath.Join("testdata", "countarguments"))

parser := New(
fsys, "",
OptionStopOnHCLError(true),
OptionWithDownloads(false),
)
require.NoError(t, parser.ParseFS(t.Context(), "."))

modules, _, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)

for _, b := range modules.GetBlocks() {
if b.Type() != "data" {
continue
}
val := b.GetAttribute("value").Value()
ty := "?"
if !val.IsNull() {
ty = val.Type().FriendlyName()
}
if !assert.Truef(t, val.Type().Equals(cty.String),
"%q is not a string, type=%s", b.FullName(), ty) {
continue
}

assert.Equal(t, "Index 0", val.AsString())
}
}

// TestNestedModulesOptions ensures parser options are carried to the nested
// submodule evaluators.
// The test will include an invalid module that will fail to download
Expand Down
21 changes: 21 additions & 0 deletions pkg/iac/scanners/terraform/parser/testdata/countarguments/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// The order of references and their alphabetical order is important.
// d -> b -> c

data "d" "foo"{
count = 1
value = "Index ${count.index}"
}

data "b" "foo" {
count = 1
value = data.d.foo[0].value
}

data "c" "cfoo" {
count = 1
value = data.b.foo[0].value
}




Loading