Files
trivy/pkg/iac/scanners/terraform/parser/parser_test.go
2025-07-29 07:13:54 +00:00

2815 lines
64 KiB
Go

package parser
import (
"bytes"
"io/fs"
"log/slog"
"os"
"path/filepath"
"slices"
"sort"
"testing"
"testing/fstest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zclconf/go-cty/cty"
"github.com/aquasecurity/trivy/internal/testutil"
"github.com/aquasecurity/trivy/pkg/iac/terraform"
"github.com/aquasecurity/trivy/pkg/log"
"github.com/aquasecurity/trivy/pkg/set"
)
func Test_BasicParsing(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"test.tf": `
locals {
proxy = var.cats_mother
}
variable "cats_mother" {
default = "boots"
}
provider "cats" {
}
moved {
}
import {
to = cats_cat.mittens
id = "mittens"
}
resource "cats_cat" "mittens" {
name = "mittens"
special = true
}
resource "cats_kitten" "the-great-destroyer" {
name = "the great destroyer"
parent = cats_cat.mittens.name
}
data "cats_cat" "the-cats-mother" {
name = local.proxy
}
check "cats_mittens_is_special" {
data "cats_cat" "mittens" {
name = "mittens"
}
assert {
condition = data.cats_cat.mittens.special == true
error_message = "${data.cats_cat.mittens.name} must be special"
}
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
blocks := modules[0].GetBlocks()
// variable
variables := blocks.OfType("variable")
require.Len(t, variables, 1)
assert.Equal(t, "variable", variables[0].Type())
require.Len(t, variables[0].Labels(), 1)
assert.Equal(t, "cats_mother", variables[0].TypeLabel())
defaultVal := variables[0].GetAttribute("default")
require.NotNil(t, defaultVal)
assert.Equal(t, cty.String, defaultVal.Value().Type())
assert.Equal(t, "boots", defaultVal.Value().AsString())
// provider
providerBlocks := blocks.OfType("provider")
require.Len(t, providerBlocks, 1)
assert.Equal(t, "provider", providerBlocks[0].Type())
require.Len(t, providerBlocks[0].Labels(), 1)
assert.Equal(t, "cats", providerBlocks[0].TypeLabel())
// resources
resourceBlocks := blocks.OfType("resource")
sort.Slice(resourceBlocks, func(i, j int) bool {
return resourceBlocks[i].TypeLabel() < resourceBlocks[j].TypeLabel()
})
require.Len(t, resourceBlocks, 2)
require.Len(t, resourceBlocks[0].Labels(), 2)
assert.Equal(t, "resource", resourceBlocks[0].Type())
assert.Equal(t, "cats_cat", resourceBlocks[0].TypeLabel())
assert.Equal(t, "mittens", resourceBlocks[0].NameLabel())
assert.Equal(t, "mittens", resourceBlocks[0].GetAttribute("name").Value().AsString())
assert.True(t, resourceBlocks[0].GetAttribute("special").Value().True())
assert.Equal(t, "resource", resourceBlocks[1].Type())
assert.Equal(t, "cats_kitten", resourceBlocks[1].TypeLabel())
assert.Equal(t, "the great destroyer", resourceBlocks[1].GetAttribute("name").Value().AsString())
assert.Equal(t, "mittens", resourceBlocks[1].GetAttribute("parent").Value().AsString())
// import
importBlocks := blocks.OfType("import")
assert.Equal(t, "import", importBlocks[0].Type())
require.NotNil(t, importBlocks[0].GetAttribute("to"))
assert.Equal(t, "mittens", importBlocks[0].GetAttribute("id").Value().AsString())
// data
dataBlocks := blocks.OfType("data")
require.Len(t, dataBlocks, 1)
require.Len(t, dataBlocks[0].Labels(), 2)
assert.Equal(t, "data", dataBlocks[0].Type())
assert.Equal(t, "cats_cat", dataBlocks[0].TypeLabel())
assert.Equal(t, "the-cats-mother", dataBlocks[0].NameLabel())
assert.Equal(t, "boots", dataBlocks[0].GetAttribute("name").Value().AsString())
// check
checkBlocks := blocks.OfType("check")
require.Len(t, checkBlocks, 1)
require.Len(t, checkBlocks[0].Labels(), 1)
assert.Equal(t, "check", checkBlocks[0].Type())
assert.Equal(t, "cats_mittens_is_special", checkBlocks[0].TypeLabel())
require.NotNil(t, checkBlocks[0].GetBlock("data"))
require.NotNil(t, checkBlocks[0].GetBlock("assert"))
}
func Test_Modules(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"code/test.tf": `
module "my-mod" {
source = "../module"
input = "ok"
}
output "result" {
value = module.my-mod.mod_result
}
`,
"module/module.tf": `
variable "input" {
default = "?"
}
output "mod_result" {
value = var.input
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "code"))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 2)
rootModule := modules[0]
childModule := modules[1]
moduleBlocks := rootModule.GetBlocks().OfType("module")
require.Len(t, moduleBlocks, 1)
assert.Equal(t, "module", moduleBlocks[0].Type())
assert.Equal(t, "module.my-mod", moduleBlocks[0].FullName())
inputAttr := moduleBlocks[0].GetAttribute("input")
require.NotNil(t, inputAttr)
require.Equal(t, cty.String, inputAttr.Value().Type())
assert.Equal(t, "ok", inputAttr.Value().AsString())
rootOutputs := rootModule.GetBlocks().OfType("output")
require.Len(t, rootOutputs, 1)
assert.Equal(t, "output.result", rootOutputs[0].FullName())
valAttr := rootOutputs[0].GetAttribute("value")
require.NotNil(t, valAttr)
require.Equal(t, cty.String, valAttr.Type())
assert.Equal(t, "ok", valAttr.Value().AsString())
childOutputs := childModule.GetBlocks().OfType("output")
require.Len(t, childOutputs, 1)
assert.Equal(t, "module.my-mod.output.mod_result", childOutputs[0].FullName())
childValAttr := childOutputs[0].GetAttribute("value")
require.NotNil(t, childValAttr)
require.Equal(t, cty.String, childValAttr.Type())
assert.Equal(t, "ok", childValAttr.Value().AsString())
}
func Test_NestedParentModule(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"code/test.tf": `
module "my-mod" {
source = "../."
input = "ok"
}
output "result" {
value = module.my-mod.mod_result
}
`,
"root.tf": `
variable "input" {
default = "?"
}
output "mod_result" {
value = var.input
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "code"))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 2)
rootModule := modules[0]
childModule := modules[1]
moduleBlocks := rootModule.GetBlocks().OfType("module")
require.Len(t, moduleBlocks, 1)
assert.Equal(t, "module", moduleBlocks[0].Type())
assert.Equal(t, "module.my-mod", moduleBlocks[0].FullName())
inputAttr := moduleBlocks[0].GetAttribute("input")
require.NotNil(t, inputAttr)
require.Equal(t, cty.String, inputAttr.Value().Type())
assert.Equal(t, "ok", inputAttr.Value().AsString())
rootOutputs := rootModule.GetBlocks().OfType("output")
require.Len(t, rootOutputs, 1)
assert.Equal(t, "output.result", rootOutputs[0].FullName())
valAttr := rootOutputs[0].GetAttribute("value")
require.NotNil(t, valAttr)
require.Equal(t, cty.String, valAttr.Type())
assert.Equal(t, "ok", valAttr.Value().AsString())
childOutputs := childModule.GetBlocks().OfType("output")
require.Len(t, childOutputs, 1)
assert.Equal(t, "module.my-mod.output.mod_result", childOutputs[0].FullName())
childValAttr := childOutputs[0].GetAttribute("value")
require.NotNil(t, childValAttr)
require.Equal(t, cty.String, childValAttr.Type())
assert.Equal(t, "ok", childValAttr.Value().AsString())
}
func Test_UndefinedModuleOutputReference(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"code/test.tf": `
resource "something" "blah" {
value = module.x.y
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "code"))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("something")
require.Len(t, blocks, 1)
block := blocks[0]
attr := block.GetAttribute("value")
require.NotNil(t, attr)
assert.False(t, attr.IsResolvable())
}
func Test_UndefinedModuleOutputReferenceInSlice(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"code/test.tf": `
resource "something" "blah" {
value = ["first", module.x.y, "last"]
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "code"))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("something")
require.Len(t, blocks, 1)
block := blocks[0]
attr := block.GetAttribute("value")
require.NotNil(t, attr)
assert.True(t, attr.IsResolvable())
values := attr.AsStringValueSliceOrEmpty()
require.Len(t, values, 3)
assert.Equal(t, "first", values[0].Value())
assert.True(t, values[0].GetMetadata().IsResolvable())
assert.False(t, values[1].GetMetadata().IsResolvable())
assert.Equal(t, "last", values[2].Value())
assert.True(t, values[2].GetMetadata().IsResolvable())
}
func Test_TemplatedSliceValue(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"code/test.tf": `
variable "x" {
default = "hello"
}
resource "something" "blah" {
value = ["first", "${var.x}-${var.x}", "last"]
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "code"))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("something")
require.Len(t, blocks, 1)
block := blocks[0]
attr := block.GetAttribute("value")
require.NotNil(t, attr)
assert.True(t, attr.IsResolvable())
values := attr.AsStringValueSliceOrEmpty()
require.Len(t, values, 3)
assert.Equal(t, "first", values[0].Value())
assert.True(t, values[0].GetMetadata().IsResolvable())
assert.Equal(t, "hello-hello", values[1].Value())
assert.True(t, values[1].GetMetadata().IsResolvable())
assert.Equal(t, "last", values[2].Value())
assert.True(t, values[2].GetMetadata().IsResolvable())
}
func Test_SliceOfVars(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"code/test.tf": `
variable "x" {
default = "1"
}
variable "y" {
default = "2"
}
resource "something" "blah" {
value = [var.x, var.y]
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "code"))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("something")
require.Len(t, blocks, 1)
block := blocks[0]
attr := block.GetAttribute("value")
require.NotNil(t, attr)
assert.True(t, attr.IsResolvable())
values := attr.AsStringValueSliceOrEmpty()
require.Len(t, values, 2)
assert.Equal(t, "1", values[0].Value())
assert.True(t, values[0].GetMetadata().IsResolvable())
assert.Equal(t, "2", values[1].Value())
assert.True(t, values[1].GetMetadata().IsResolvable())
}
func Test_VarSlice(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"code/test.tf": `
variable "x" {
default = ["a", "b", "c"]
}
resource "something" "blah" {
value = var.x
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "code"))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("something")
require.Len(t, blocks, 1)
block := blocks[0]
attr := block.GetAttribute("value")
require.NotNil(t, attr)
assert.True(t, attr.IsResolvable())
values := attr.AsStringValueSliceOrEmpty()
require.Len(t, values, 3)
assert.Equal(t, "a", values[0].Value())
assert.True(t, values[0].GetMetadata().IsResolvable())
assert.Equal(t, "b", values[1].Value())
assert.True(t, values[1].GetMetadata().IsResolvable())
assert.Equal(t, "c", values[2].Value())
assert.True(t, values[2].GetMetadata().IsResolvable())
}
func Test_LocalSliceNested(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"code/test.tf": `
variable "x" {
default = "a"
}
locals {
y = [var.x, "b", "c"]
}
resource "something" "blah" {
value = local.y
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "code"))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("something")
require.Len(t, blocks, 1)
block := blocks[0]
attr := block.GetAttribute("value")
require.NotNil(t, attr)
assert.True(t, attr.IsResolvable())
values := attr.AsStringValueSliceOrEmpty()
require.Len(t, values, 3)
assert.Equal(t, "a", values[0].Value())
assert.True(t, values[0].GetMetadata().IsResolvable())
assert.Equal(t, "b", values[1].Value())
assert.True(t, values[1].GetMetadata().IsResolvable())
assert.Equal(t, "c", values[2].Value())
assert.True(t, values[2].GetMetadata().IsResolvable())
}
func Test_FunctionCall(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"code/test.tf": `
variable "x" {
default = ["a", "b"]
}
resource "something" "blah" {
value = concat(var.x, ["c"])
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "code"))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("something")
require.Len(t, blocks, 1)
block := blocks[0]
attr := block.GetAttribute("value")
require.NotNil(t, attr)
assert.True(t, attr.IsResolvable())
values := attr.AsStringValueSliceOrEmpty()
require.Len(t, values, 3)
assert.Equal(t, "a", values[0].Value())
assert.True(t, values[0].GetMetadata().IsResolvable())
assert.Equal(t, "b", values[1].Value())
assert.True(t, values[1].GetMetadata().IsResolvable())
assert.Equal(t, "c", values[2].Value())
assert.True(t, values[2].GetMetadata().IsResolvable())
}
func Test_NullDefaultValueForVar(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"test.tf": `
variable "bucket_name" {
type = string
default = null
}
resource "aws_s3_bucket" "default" {
bucket = var.bucket_name != null ? var.bucket_name : "default"
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("aws_s3_bucket")
require.Len(t, blocks, 1)
block := blocks[0]
attr := block.GetAttribute("bucket")
require.NotNil(t, attr)
assert.Equal(t, "default", attr.Value().AsString())
}
func Test_MultipleInstancesOfSameResource(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"test.tf": `
resource "aws_kms_key" "key1" {
description = "Key #1"
enable_key_rotation = true
}
resource "aws_kms_key" "key2" {
description = "Key #2"
enable_key_rotation = true
}
resource "aws_s3_bucket" "this" {
bucket = "test"
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this1" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
kms_master_key_id = aws_kms_key.key1.arn
sse_algorithm = "aws:kms"
}
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this2" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
kms_master_key_id = aws_kms_key.key2.arn
sse_algorithm = "aws:kms"
}
}
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("aws_s3_bucket_server_side_encryption_configuration")
assert.Len(t, blocks, 2)
for _, block := range blocks {
attr, parent := block.GetNestedAttribute("rule.apply_server_side_encryption_by_default.kms_master_key_id")
assert.Equal(t, "apply_server_side_encryption_by_default", parent.Type())
assert.NotNil(t, attr)
assert.NotEmpty(t, attr.Value().AsString())
}
}
func Test_IfConfigFsIsNotSet_ThenUseModuleFsForVars(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"main.tf": `
variable "bucket_name" {
type = string
}
resource "aws_s3_bucket" "main" {
bucket = var.bucket_name
}
`,
"main.tfvars": `bucket_name = "test_bucket"`,
})
parser := New(fs, "",
OptionStopOnHCLError(true),
OptionWithTFVarsPaths("main.tfvars"),
)
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("aws_s3_bucket")
require.Len(t, blocks, 1)
block := blocks[0]
assert.Equal(t, "test_bucket", block.GetAttribute("bucket").AsStringValueOrDefault("", block).Value())
}
func Test_ForEachRefToLocals(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"main.tf": `
locals {
buckets = toset([
"foo",
"bar",
])
}
resource "aws_s3_bucket" "this" {
for_each = local.buckets
bucket = each.key
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("aws_s3_bucket")
assert.Len(t, blocks, 2)
for _, block := range blocks {
attr := block.GetAttribute("bucket")
require.NotNil(t, attr)
assert.Contains(t, []string{"foo", "bar"}, attr.AsStringValueOrDefault("", block).Value())
}
}
func Test_ForEachRefToVariableWithDefault(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"main.tf": `
variable "buckets" {
type = set(string)
default = ["foo", "bar"]
}
resource "aws_s3_bucket" "this" {
for_each = var.buckets
bucket = each.key
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("aws_s3_bucket")
assert.Len(t, blocks, 2)
for _, block := range blocks {
attr := block.GetAttribute("bucket")
require.NotNil(t, attr)
assert.Contains(t, []string{"foo", "bar"}, attr.AsStringValueOrDefault("", block).Value())
}
}
func Test_ForEachRefToVariableFromFile(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"main.tf": `
variable "policy_rules" {
type = object({
secure_tags = optional(map(object({
session_matcher = optional(string)
priority = number
enabled = optional(bool, true)
})), {})
})
}
resource "google_network_security_gateway_security_policy_rule" "secure_tag_rules" {
for_each = var.policy_rules.secure_tags
provider = google-beta
project = "test"
name = each.key
enabled = each.value.enabled
priority = each.value.priority
session_matcher = each.value.session_matcher
}
`,
"main.tfvars": `
policy_rules = {
secure_tags = {
secure-tag-1 = {
session_matcher = "host() != 'google.com'"
priority = 1001
}
}
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true), OptionWithTFVarsPaths("main.tfvars"))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("google_network_security_gateway_security_policy_rule")
assert.Len(t, blocks, 1)
block := blocks[0]
assert.Equal(t, "secure-tag-1", block.GetAttribute("name").AsStringValueOrDefault("", block).Value())
assert.True(t, block.GetAttribute("enabled").AsBoolValueOrDefault(false, block).Value())
assert.Equal(t, "host() != 'google.com'", block.GetAttribute("session_matcher").AsStringValueOrDefault("", block).Value())
assert.Equal(t, 1001, block.GetAttribute("priority").AsIntValueOrDefault(0, block).Value())
}
func Test_ForEachRefersToMapThatContainsSameStringValues(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"main.tf": `locals {
buckets = {
bucket1 = "test1"
bucket2 = "test1"
}
}
resource "aws_s3_bucket" "this" {
for_each = local.buckets
bucket = each.key
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 1)
bucketBlocks := modules.GetResourcesByType("aws_s3_bucket")
assert.Len(t, bucketBlocks, 2)
var labels []string
for _, b := range bucketBlocks {
labels = append(labels, b.Label())
}
expectedLabels := []string{
`aws_s3_bucket.this["bucket1"]`,
`aws_s3_bucket.this["bucket2"]`,
}
assert.Equal(t, expectedLabels, labels)
}
func TestDataSourceWithCountMetaArgument(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"main.tf": `
data "http" "example" {
count = 2
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 1)
rootModule := modules[0]
httpDataSources := rootModule.GetDatasByType("http")
assert.Len(t, httpDataSources, 2)
var labels []string
for _, b := range httpDataSources {
labels = append(labels, b.Label())
}
expectedLabels := []string{
`http.example[0]`,
`http.example[1]`,
}
assert.Equal(t, expectedLabels, labels)
}
func TestDataSourceWithForEachMetaArgument(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"main.tf": `
locals {
ports = ["80", "8080"]
}
data "http" "example" {
for_each = toset(local.ports)
url = "localhost:${each.key}"
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 1)
rootModule := modules[0]
httpDataSources := rootModule.GetDatasByType("http")
assert.Len(t, httpDataSources, 2)
}
func TestForEach(t *testing.T) {
tests := []struct {
name string
src string
expectedBucketName string
expectedNameLabel string
}{
{
name: "arg is set and ref to each.key",
src: `locals {
buckets = ["bucket1"]
}
resource "aws_s3_bucket" "this" {
for_each = toset(local.buckets)
bucket = each.key
}`,
expectedBucketName: "bucket1",
expectedNameLabel: `this["bucket1"]`,
},
{
name: "arg is set and ref to each.value",
src: `locals {
buckets = ["bucket1"]
}
resource "aws_s3_bucket" "this" {
for_each = toset(local.buckets)
bucket = each.value
}`,
expectedBucketName: "bucket1",
expectedNameLabel: `this["bucket1"]`,
},
{
name: "arg is map and ref to each.key",
src: `locals {
buckets = {
bucket1key = "bucket1value"
}
}
resource "aws_s3_bucket" "this" {
for_each = local.buckets
bucket = each.key
}`,
expectedBucketName: "bucket1key",
expectedNameLabel: `this["bucket1key"]`,
},
{
name: "arg is map and ref to each.value",
src: `locals {
buckets = {
bucket1key = "bucket1value"
}
}
resource "aws_s3_bucket" "this" {
for_each = local.buckets
bucket = each.value
}`,
expectedBucketName: "bucket1value",
expectedNameLabel: `this["bucket1key"]`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
modules := parse(t, map[string]string{
"main.tf": tt.src,
})
require.Len(t, modules, 1)
buckets := modules.GetResourcesByType("aws_s3_bucket")
assert.Len(t, buckets, 1)
bucket := buckets[0]
bucketName := bucket.GetAttribute("bucket").Value().AsString()
assert.Equal(t, tt.expectedBucketName, bucketName)
assert.Equal(t, tt.expectedNameLabel, bucket.NameLabel())
})
}
}
func TestForEachCountExpanded(t *testing.T) {
tests := []struct {
name string
source string
expectedCount int
}{
{
name: "arg is list of strings",
source: `locals {
buckets = ["bucket1", "bucket2"]
}
resource "aws_s3_bucket" "this" {
for_each = local.buckets
bucket = each.key
}`,
expectedCount: 2,
},
{
name: "arg is empty list",
source: `locals {
buckets = []
}
resource "aws_s3_bucket" "this" {
for_each = local.buckets
bucket = each.value
}`,
expectedCount: 0,
},
{
name: "arg is empty set",
source: `locals {
buckets = toset([])
}
resource "aws_s3_bucket" "this" {
for_each = local.buckets
bucket = each.key
}`,
expectedCount: 0,
},
{
name: "argument set with the same values",
source: `locals {
buckets = ["true", "true"]
}
resource "aws_s3_bucket" "this" {
for_each = toset(local.buckets)
bucket = each.key
}`,
expectedCount: 1,
},
{
name: "arg is non-valid set",
source: `locals {
buckets = [{
bucket1key = "bucket1value"
}]
}
resource "aws_s3_bucket" "this" {
for_each = toset(local.buckets)
bucket = each.value
}`,
expectedCount: 0,
},
{
name: "arg is set of strings",
source: `locals {
buckets = ["bucket1", "bucket2"]
}
resource "aws_s3_bucket" "this" {
for_each = toset(local.buckets)
bucket = each.key
}`,
expectedCount: 2,
},
{
name: "arg is map",
source: `locals {
buckets = {
1 = {}
2 = {}
}
}
resource "aws_s3_bucket" "this" {
for_each = local.buckets
bucket = each.key
}`,
expectedCount: 2,
},
{
name: "arg is empty map",
source: `locals {
buckets = {}
}
resource "aws_s3_bucket" "this" {
for_each = local.buckets
bucket = each.value
}
`,
expectedCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
modules := parse(t, map[string]string{
"main.tf": tt.source,
})
assert.Len(t, modules, 1)
bucketBlocks := modules.GetResourcesByType("aws_s3_bucket")
assert.Len(t, bucketBlocks, tt.expectedCount)
})
}
}
func TestForEachRefToResource(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"main.tf": `
locals {
vpcs = {
"test1" = {
cidr_block = "192.168.0.0/28"
}
"test2" = {
cidr_block = "192.168.1.0/28"
}
}
}
resource "aws_vpc" "example" {
for_each = local.vpcs
cidr_block = each.value.cidr_block
}
resource "aws_internet_gateway" "example" {
for_each = aws_vpc.example
vpc_id = each.key
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
blocks := modules.GetResourcesByType("aws_internet_gateway")
require.Len(t, blocks, 2)
var vpcIds []string
for _, b := range blocks {
vpcIds = append(vpcIds, b.GetAttribute("vpc_id").Value().AsString())
}
expectedVpcIds := []string{"test1", "test2"}
assert.Equal(t, expectedVpcIds, vpcIds)
}
func TestArnAttributeOfBucketIsCorrect(t *testing.T) {
t.Run("the bucket doesn't have a name", func(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"main.tf": `resource "aws_s3_bucket" "this" {}`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
blocks := modules.GetResourcesByType("aws_s3_bucket")
assert.Len(t, blocks, 1)
bucket := blocks[0]
values := bucket.Values()
arnVal := values.GetAttr("arn")
assert.True(t, arnVal.Type().Equals(cty.String))
id := values.GetAttr("id").AsString()
arn := arnVal.AsString()
assert.Equal(t, "arn:aws:s3:::"+id, arn)
})
t.Run("the bucket has a name", func(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"main.tf": `resource "aws_s3_bucket" "this" {
bucket = "test"
}
resource "aws_iam_role" "this" {
name = "test_role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Sid = ""
Principal = {
Service = "s3.amazonaws.com"
}
},
]
})
}
resource "aws_iam_role_policy" "this" {
name = "test_policy"
role = aws_iam_role.this.id
policy = data.aws_iam_policy_document.this.json
}
data "aws_iam_policy_document" "this" {
statement {
effect = "Allow"
actions = [
"s3:GetObject"
]
resources = ["${aws_s3_bucket.this.arn}/*"]
}
}`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
blocks := modules[0].GetDatasByType("aws_iam_policy_document")
assert.Len(t, blocks, 1)
policyDoc := blocks[0]
statement := policyDoc.GetBlock("statement")
resources := statement.GetAttribute("resources").AsStringValueSliceOrEmpty()
assert.Len(t, resources, 1)
assert.True(t, resources[0].EqualTo("arn:aws:s3:::test/*"))
})
}
func TestForEachWithObjectsOfDifferentTypes(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"main.tf": `module "backups" {
bucket_name = each.key
client = each.value.client
path_writers = each.value.path_writers
for_each = {
"bucket1" = {
client = "client1"
path_writers = ["writer1"] // tuple with string
},
"bucket2" = {
client = "client2"
path_writers = [] // empty tuple
}
}
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 1)
}
func TestCountMetaArgument(t *testing.T) {
tests := []struct {
name string
src string
expected int
}{
{
name: "zero resources",
src: `resource "test" "this" {
count = 0
}`,
expected: 0,
},
{
name: "several resources",
src: `resource "test" "this" {
count = 2
}`,
expected: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fsys := testutil.CreateFS(t, map[string]string{
"main.tf": tt.src,
})
parser := New(fsys, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 1)
resources := modules.GetResourcesByType("test")
assert.Len(t, resources, tt.expected)
})
}
}
func TestCountMetaArgumentInModule(t *testing.T) {
tests := []struct {
name string
files map[string]string
expectedCountModules int
expectedCountResources int
}{
{
name: "zero modules",
files: map[string]string{
"main.tf": `module "this" {
count = 0
source = "./modules/test"
}`,
"modules/test/main.tf": `resource "test" "this" {}`,
},
expectedCountModules: 1,
expectedCountResources: 0,
},
{
name: "several modules",
files: map[string]string{
"main.tf": `module "this" {
count = 2
source = "./modules/test"
}`,
"modules/test/main.tf": `resource "test" "this" {}`,
},
expectedCountModules: 3,
expectedCountResources: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fsys := testutil.CreateFS(t, tt.files)
parser := New(fsys, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, tt.expectedCountModules)
resources := modules.GetResourcesByType("test")
assert.Len(t, resources, tt.expectedCountResources)
})
}
}
func TestDynamicBlocks(t *testing.T) {
tests := []struct {
name string
src string
expected []any
}{
{
name: "for-each use tuple of int",
src: `resource "test_resource" "test" {
dynamic "foo" {
for_each = [80, 443]
content {
bar = foo.value
}
}
}`,
expected: []any{float64(80), float64(443)},
},
{
name: "for-each use list of int",
src: `resource "test_resource" "test" {
dynamic "foo" {
for_each = tolist([80, 443])
content {
bar = foo.value
}
}
}`,
expected: []any{float64(80), float64(443)},
},
{
name: "for-each use set of int",
src: `resource "test_resource" "test" {
dynamic "foo" {
for_each = toset([80, 443])
content {
bar = foo.value
}
}
}`,
expected: []any{float64(80), float64(443)},
},
{
name: "for-each use list of bool",
src: `resource "test_resource" "test" {
dynamic "foo" {
for_each = tolist([true])
content {
bar = foo.value
}
}
}`,
expected: []any{true},
},
{
name: "empty for-each",
src: `resource "test_resource" "test" {
dynamic "foo" {
for_each = []
content {}
}
}`,
expected: []any{},
},
{
name: "for-each use tuple of objects",
src: `variable "test_var" {
type = list(object({ enabled = bool }))
default = [{ enabled = true }]
}
resource "test_resource" "test" {
dynamic "foo" {
for_each = var.test_var
content {
bar = foo.value.enabled
}
}
}`,
expected: []any{true},
},
{
name: "attribute ref to object key",
src: `variable "some_var" {
type = map(
object({
tag = string
})
)
default = {
ssh = { "tag" = "login" }
http = { "tag" = "proxy" }
https = { "tag" = "proxy" }
}
}
resource "test_resource" "test" {
dynamic "foo" {
for_each = { for name, values in var.some_var : name => values }
content {
bar = foo.key
}
}
}`,
expected: []any{"ssh", "http", "https"},
},
{
name: "attribute ref to object value",
src: `variable "some_var" {
type = map(
object({
tag = string
})
)
default = {
ssh = { "tag" = "login" }
http = { "tag" = "proxy" }
https = { "tag" = "proxy" }
}
}
resource "test_resource" "test" {
dynamic "foo" {
for_each = { for name, values in var.some_var : name => values }
content {
bar = foo.value.tag
}
}
}`,
expected: []any{"login", "proxy", "proxy"},
},
{
name: "attribute ref to map key",
src: `variable "some_var" {
type = map
default = {
ssh = { "tag" = "login" }
http = { "tag" = "proxy" }
https = { "tag" = "proxy" }
}
}
resource "test_resource" "test" {
dynamic "foo" {
for_each = var.some_var
content {
bar = foo.key
}
}
}`,
expected: []any{"ssh", "http", "https"},
},
{
name: "attribute ref to map value",
src: `variable "some_var" {
type = map
default = {
ssh = { "tag" = "login" }
http = { "tag" = "proxy" }
https = { "tag" = "proxy" }
}
}
resource "test_resource" "test" {
dynamic "foo" {
for_each = var.some_var
content {
bar = foo.value.tag
}
}
}`,
expected: []any{"login", "proxy", "proxy"},
},
{
name: "dynamic block with iterator",
src: `resource "test_resource" "test" {
dynamic "foo" {
for_each = ["foo", "bar"]
iterator = some_iterator
content {
bar = some_iterator.value
}
}
}`,
expected: []any{"foo", "bar"},
},
{
name: "iterator and parent block with same name",
src: `resource "test_resource" "test" {
dynamic "foo" {
for_each = ["foo", "bar"]
iterator = foo
content {
bar = foo.value
}
}
}`,
expected: []any{"foo", "bar"},
},
{
name: "for-each use null value",
src: `resource "test_resource" "test" {
dynamic "foo" {
for_each = null
content {
bar = foo.value
}
}
}`,
expected: []any{},
},
{
name: "no for-each attribute",
src: `resource "test_resource" "test" {
dynamic "foo" {
content {
bar = foo.value
}
}
}`,
expected: []any{},
},
{
name: "unknown for-each",
src: `resource "test_resource" "test" {
dynamic "foo" {
for_each = lookup(foo, "") ? [] : []
}
}`,
expected: []any{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
modules := parse(t, map[string]string{
"main.tf": tt.src,
})
require.Len(t, modules, 1)
resource := modules.GetResourcesByType("test_resource")
require.Len(t, resource, 1)
blocks := resource[0].GetBlocks("foo")
var vals []any
for _, attr := range blocks {
vals = append(vals, attr.GetAttribute("bar").GetRawValue())
}
assert.ElementsMatch(t, tt.expected, vals)
})
}
}
func TestNestedDynamicBlock(t *testing.T) {
modules := parse(t, map[string]string{
"main.tf": `resource "test_resource" "test" {
dynamic "foo" {
for_each = ["1", "1"]
content {
dynamic "bar" {
for_each = [true, true]
content {
baz = foo.value
qux = bar.value
}
}
}
}
}`,
})
require.Len(t, modules, 1)
testResources := modules.GetResourcesByType("test_resource")
assert.Len(t, testResources, 1)
blocks := testResources[0].GetBlocks("foo")
assert.Len(t, blocks, 2)
var nested []*terraform.Block
for _, block := range blocks {
nested = append(nested, block.GetBlocks("bar")...)
for _, b := range nested {
assert.Equal(t, "1", b.GetAttribute("baz").GetRawValue())
assert.Equal(t, true, b.GetAttribute("qux").GetRawValue())
}
}
assert.Len(t, nested, 4)
}
func parse(t *testing.T, files map[string]string, opts ...Option) terraform.Modules {
fs := testutil.CreateFS(t, files)
opts = append(opts, OptionStopOnHCLError(true))
parser := New(fs, "", opts...)
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
return modules
}
func TestModuleRefersToOutputOfAnotherModule(t *testing.T) {
files := map[string]string{
"main.tf": `
module "module2" {
source = "./modules/foo"
}
module "module1" {
source = "./modules/bar"
test_var = module.module2.test_out
}
`,
"modules/foo/main.tf": `
output "test_out" {
value = "test_value"
}
`,
"modules/bar/main.tf": `
variable "test_var" {}
resource "test_resource" "this" {
dynamic "dynamic_block" {
for_each = [var.test_var]
content {
some_attr = dynamic_block.value
}
}
}
`,
}
modules := parse(t, files)
require.Len(t, modules, 3)
resources := modules.GetResourcesByType("test_resource")
require.Len(t, resources, 1)
attr, _ := resources[0].GetNestedAttribute("dynamic_block.some_attr")
require.NotNil(t, attr)
assert.Equal(t, "test_value", attr.GetRawValue())
}
func TestPopulateContextWithBlockInstances(t *testing.T) {
tests := []struct {
name string
blockType string
files map[string]string
}{
{
name: "data blocks with count",
blockType: "data",
files: map[string]string{
"main.tf": `data "d" "foo" {
count = 1
value = "Index ${count.index}"
}
data "b" "foo" {
count = 1
value = data.d.foo[0].value
}
data "c" "foo" {
count = 1
value = data.b.foo[0].value
}`,
},
},
{
name: "resource blocks with count",
blockType: "resource",
files: map[string]string{
"main.tf": `resource "d" "foo" {
count = 1
value = "Index ${count.index}"
}
resource "b" "foo" {
count = 1
value = d.foo[0].value
}
resource "c" "foo" {
count = 1
value = b.foo[0].value
}`,
},
},
{
name: "module block with count",
blockType: "data",
files: map[string]string{
"main.tf": `module "a" {
source = "./modules/a"
count = 2
inp = "Index ${count.index}"
}
data "b" "foo" {
count = 1
value = module.a[0].value
}
data "c" "foo" {
count = 1
value = data.b.foo[0].value
}`,
"modules/a/main.tf": `variable "inp" {}
output "value" {
value = var.inp
}`,
},
},
{
name: "data blocks with for_each",
blockType: "data",
files: map[string]string{
"main.tf": `data "d" "foo" {
for_each = toset([0])
value = "Index ${each.key}"
}
data "b" "foo" {
for_each = data.d.foo
value = each.value.value
}
data "c" "foo" {
for_each = data.b.foo
value = each.value.value
}`,
},
},
{
name: "resource blocks with for_each",
blockType: "resource",
files: map[string]string{
"main.tf": `resource "d" "foo" {
for_each = toset([0])
value = "Index ${each.key}"
}
resource "b" "foo" {
for_each = d.foo
value = each.value.value
}
resource "c" "foo" {
for_each = b.foo
value = each.value.value
}`,
},
},
{
name: "module block with for_each",
blockType: "data",
files: map[string]string{
"main.tf": `module "a" {
for_each = toset([0])
source = "./modules/a"
inp = "Index ${each.key}"
}
data "b" "foo" {
value = module.a["0"].value
}`,
"modules/a/main.tf": `variable "inp" {}
output "value" {
value = var.inp
}`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
modules := parse(t, tt.files)
require.GreaterOrEqual(t, len(modules), 1)
for _, b := range modules.GetBlocks().OfType(tt.blockType) {
attr := b.GetAttribute("value")
assert.Equal(t, "Index 0", attr.Value().AsString())
}
})
}
}
// TestNestedModulesOptions ensures parser options are carried to the nested
// submodule evaluators.
// The test will include an invalid module that will fail to download
// if it is attempted.
func TestNestedModulesOptions(t *testing.T) {
// reset the previous default logger
prevLog := slog.Default()
defer slog.SetDefault(prevLog)
var buf bytes.Buffer
slog.SetDefault(slog.New(log.NewHandler(&buf, nil)))
// Folder structure
// ./
// ├── main.tf
// └── modules
// ├── city
// │ └── main.tf
// ├── queens
// │ └── main.tf
// └── brooklyn
// └── main.tf
//
// Modules referenced
// main -> city ├─> brooklyn
// └─> queens
files := map[string]string{
"main.tf": `
module "city" {
source = "./modules/city"
}
resource "city" "neighborhoods" {
names = module.city.neighborhoods
}
`,
"modules/city/main.tf": `
module "brooklyn" {
source = "./brooklyn"
}
module "queens" {
source = "./queens"
}
output "neighborhoods" {
value = [module.brooklyn.name, module.queens.name]
}
`,
"modules/city/brooklyn/main.tf": `
output "name" {
value = "Brooklyn"
}
`,
"modules/city/queens/main.tf": `
output "name" {
value = "Queens"
}
module "invalid" {
source = "https://example.invaliddomain"
}
`,
}
// Using the OptionWithDownloads(false) option will prevent the invalid
// module from being downloaded. If the log exists "failed to download"
// then the submodule evaluator attempted to download, which was disallowed.
modules := parse(t, files, OptionWithDownloads(false))
require.Len(t, modules, 4)
resources := modules.GetResourcesByType("city")
require.Len(t, resources, 1)
for _, res := range resources {
attr, _ := res.GetNestedAttribute("names")
require.NotNil(t, attr, res.FullName())
assert.Equal(t, []string{"Brooklyn", "Queens"}, attr.GetRawValue())
}
require.NotContains(t, buf.String(), "failed to download")
// Verify module parents are set correctly.
expectedParents := map[string]string{
".": "",
"modules/city": ".",
"modules/city/brooklyn": "modules/city",
"modules/city/queens": "modules/city",
}
for _, mod := range modules {
expected, exists := expectedParents[mod.ModulePath()]
require.Truef(t, exists, "module %s does not exist in assertion", mod.ModulePath())
if expected == "" {
require.Nil(t, mod.Parent())
} else {
require.Equal(t, expected, mod.Parent().ModulePath(), "parent of module %q", mod.ModulePath())
}
}
}
// TestModuleParents sets up a nested module structure and verifies the
// parent-child relationships are correctly set.
func TestModuleParents(t *testing.T) {
// The setup is a list of continents, some countries, some cities, etc.
dirfs := os.DirFS("./testdata/nested")
parser := New(dirfs, "",
OptionStopOnHCLError(true),
OptionWithDownloads(false),
)
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
// modules only have 'parent'. They do not have children, so create
// a structure that allows traversal from the root to the leafs.
modChildren := make(map[*terraform.Module][]*terraform.Module)
// Keep track of every module that exists
modSet := set.New[*terraform.Module]()
var root *terraform.Module
for _, mod := range modules {
modChildren[mod] = make([]*terraform.Module, 0)
modSet.Append(mod)
if mod.Parent() == nil {
// Only 1 root should exist
require.Nil(t, root, "root module already set")
root = mod
}
modChildren[mod.Parent()] = append(modChildren[mod.Parent()], mod)
}
type node struct {
prefix string
modulePath string
children []node
}
// expectedTree is the full module tree structure.
expectedTree := node{
modulePath: ".",
children: []node{
{
modulePath: "north-america",
children: []node{
{
modulePath: "north-america/united-states",
children: []node{
{modulePath: "north-america/united-states/springfield", prefix: "illinois-"},
{modulePath: "north-america/united-states/springfield", prefix: "idaho-"},
{modulePath: "north-america/united-states/new-york", children: []node{
{modulePath: "north-america/united-states/new-york/new-york-city"},
}},
},
},
{
modulePath: "north-america/canada",
children: []node{
{modulePath: "north-america/canada/springfield", prefix: "ontario-"},
},
},
},
},
},
}
var assertChild func(t *testing.T, n node, mod *terraform.Module)
assertChild = func(t *testing.T, n node, mod *terraform.Module) {
modSet.Remove(mod)
children := modChildren[mod]
t.Run(n.modulePath, func(t *testing.T) {
if !assert.Len(t, children, len(n.children), "modChildren count for %s", n.modulePath) {
return
}
for _, child := range children {
// Find the child module that we are expecting.
idx := slices.IndexFunc(n.children, func(node node) bool {
outputBlocks := child.GetBlocks().OfType("output")
outIdx := slices.IndexFunc(outputBlocks, func(outputBlock *terraform.Block) bool {
return outputBlock.Labels()[0] == "name"
})
if outIdx == -1 {
return false
}
output := outputBlocks[outIdx]
outVal := output.GetAttribute("value").Value()
if !outVal.Type().Equals(cty.String) {
return false
}
modName := filepath.Base(node.modulePath)
if outVal.AsString() != node.prefix+modName {
return false
}
return node.modulePath == child.ModulePath()
})
if !assert.NotEqualf(t, -1, idx, "module prefix=%s path=%s not found in %s", n.prefix, child.ModulePath(), n.modulePath) {
continue
}
assertChild(t, n.children[idx], child)
}
})
}
assertChild(t, expectedTree, root)
// If any module was not asserted, the test will fail. This ensures the
// entire module tree is checked.
require.Equal(t, 0, modSet.Size(), "all modules asserted")
}
func TestCyclicModules(t *testing.T) {
files := map[string]string{
"main.tf": `
module "module2" {
source = "./modules/foo"
test_var = passthru.handover.from_1
}
// Demonstrates need for evaluateSteps between submodule evaluations.
resource "passthru" "handover" {
from_1 = module.module1.test_out
from_2 = module.module2.test_out
}
module "module1" {
source = "./modules/bar"
test_var = passthru.handover.from_2
}
`,
"modules/foo/main.tf": `
variable "test_var" {}
resource "test_resource" "this" {
dynamic "dynamic_block" {
for_each = [var.test_var]
content {
some_attr = dynamic_block.value
}
}
}
output "test_out" {
value = "test_value"
}
`,
"modules/bar/main.tf": `
variable "test_var" {}
resource "test_resource" "this" {
dynamic "dynamic_block" {
for_each = [var.test_var]
content {
some_attr = dynamic_block.value
}
}
}
output "test_out" {
value = test_resource.this.dynamic_block.some_attr
}
`,
}
modules := parse(t, files)
require.Len(t, modules, 3)
resources := modules.GetResourcesByType("test_resource")
require.Len(t, resources, 2)
for _, res := range resources {
attr, _ := res.GetNestedAttribute("dynamic_block.some_attr")
require.NotNil(t, attr, res.FullName())
assert.Equal(t, "test_value", attr.GetRawValue())
}
}
func TestExtractSetValue(t *testing.T) {
files := map[string]string{
"main.tf": `
resource "test" "set-value" {
value = toset(["x", "y", "x"])
}
`,
}
resources := parse(t, files).GetResourcesByType("test")
require.Len(t, resources, 1)
attr := resources[0].GetAttribute("value")
require.NotNil(t, attr)
assert.Equal(t, []string{"x", "y"}, attr.GetRawValue())
}
func TestFunc_fileset(t *testing.T) {
files := map[string]string{
"main.tf": `
resource "test" "fileset-func" {
value = fileset(path.module, "**/*.py")
}
`,
"a.py": ``,
"path/b.py": ``,
}
resources := parse(t, files).GetResourcesByType("test")
require.Len(t, resources, 1)
attr := resources[0].GetAttribute("value")
require.NotNil(t, attr)
assert.Equal(t, []string{"a.py", "path/b.py"}, attr.GetRawValue())
}
func TestExprWithMissingVar(t *testing.T) {
files := map[string]string{
"main.tf": `
variable "v" {
type = string
}
resource "test" "values" {
s = "foo-${var.v}"
l1 = ["foo", var.v]
l2 = concat(["foo"], [var.v])
d1 = {foo = var.v}
d2 = merge({"foo": "bar"}, {"baz": var.v})
}
`,
}
resources := parse(t, files).GetResourcesByType("test")
require.Len(t, resources, 1)
s_attr := resources[0].GetAttribute("s")
require.NotNil(t, s_attr)
assert.Equal(t, "foo-", s_attr.Value().Range().StringPrefix())
for _, name := range []string{"l1", "l2", "d1", "d2"} {
attr := resources[0].GetAttribute(name)
require.NotNil(t, attr)
}
}
func TestVarTypeShortcut(t *testing.T) {
files := map[string]string{
"main.tf": `
variable "magic_list" {
type = list
default = ["x", "y"]
}
variable "magic_map" {
type = map
default = {a = 1, b = 2}
}
resource "test" "values" {
l = var.magic_list
m = var.magic_map
}
`,
}
resources := parse(t, files).GetResourcesByType("test")
require.Len(t, resources, 1)
list_attr := resources[0].GetAttribute("l")
require.NotNil(t, list_attr)
assert.Equal(t, []string{"x", "y"}, list_attr.GetRawValue())
map_attr := resources[0].GetAttribute("m")
require.NotNil(t, map_attr)
assert.True(t, map_attr.Value().RawEquals(cty.MapVal(map[string]cty.Value{
"a": cty.NumberIntVal(1), "b": cty.NumberIntVal(2),
})))
}
func Test_LoadLocalCachedModule(t *testing.T) {
fsys := os.DirFS(filepath.Join("testdata", "cached-modules"))
parser := New(
fsys, "",
OptionStopOnHCLError(true),
OptionWithDownloads(false),
)
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 2)
buckets := modules.GetResourcesByType("aws_s3_bucket")
assert.Len(t, buckets, 1)
assert.Equal(t, "my-private-module/s3-bucket/aws/.terraform/modules/s3-bucket/main.tf", buckets[0].GetMetadata().Range().GetFilename())
bucketName := buckets[0].GetAttribute("bucket").Value().AsString()
assert.Equal(t, "my-s3-bucket", bucketName)
}
func TestTFVarsFileDoesNotExist(t *testing.T) {
fsys := fstest.MapFS{
"main.tf": &fstest.MapFile{
Data: []byte(``),
},
}
parser := New(
fsys, "",
OptionStopOnHCLError(true),
OptionWithDownloads(false),
OptionWithTFVarsPaths("main.tfvars"),
)
require.NoError(t, parser.ParseFS(t.Context(), "."))
_, err := parser.EvaluateAll(t.Context())
assert.ErrorContains(t, err, "file does not exist")
}
func Test_OptionsWithTfVars(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"main.tf": `resource "test" "this" {
foo = var.foo
}
variable "foo" {}
`})
parser := New(fs, "", OptionsWithTfVars(
map[string]cty.Value{
"foo": cty.StringVal("bar"),
},
))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("test")
assert.Len(t, blocks, 1)
assert.Equal(t, "bar", blocks[0].GetAttribute("foo").Value().AsString())
}
func Test_AWSRegionNameDefined(t *testing.T) {
fs := testutil.CreateFS(t, map[string]string{
"code/test.tf": `
data "aws_region" "current" {}
data "aws_region" "other" {
name = "us-east-2"
}
resource "something" "blah" {
r1 = data.aws_region.current.name
r2 = data.aws_region.other.name
}
`,
})
parser := New(fs, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "code"))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
rootModule := modules[0]
blocks := rootModule.GetResourcesByType("something")
require.Len(t, blocks, 1)
block := blocks[0]
r1 := block.GetAttribute("r1")
require.NotNil(t, r1)
assert.True(t, r1.IsResolvable())
assert.Equal(t, "current-region", r1.Value().AsString())
r2 := block.GetAttribute("r2")
require.NotNil(t, r2)
assert.True(t, r2.IsResolvable())
assert.Equal(t, "us-east-2", r2.Value().AsString())
}
func TestLogAboutMissingVariableValues(t *testing.T) {
var buf bytes.Buffer
slog.SetDefault(slog.New(log.NewHandler(&buf, nil)))
fsys := fstest.MapFS{
"main.tf": &fstest.MapFile{
Data: []byte(`
variable "foo" {}
variable "bar" {
default = "bar"
}
variable "baz" {}
`),
},
"main.tfvars": &fstest.MapFile{
Data: []byte(`baz = "baz"`),
},
}
parser := New(
fsys, "",
OptionStopOnHCLError(true),
OptionWithTFVarsPaths("main.tfvars"),
)
require.NoError(t, parser.ParseFS(t.Context(), "."))
_, err := parser.Load(t.Context())
require.NoError(t, err)
assert.Contains(t, buf.String(), "Variable values were not found in the environment or variable files.")
assert.Contains(t, buf.String(), "variables=\"foo\"")
}
func TestLoadChildModulesFromLocalCache(t *testing.T) {
fsys := fstest.MapFS{
"main.tf": &fstest.MapFile{Data: []byte(`module "level_1" {
source = "./modules/level_1"
}`)},
"modules/level_1/main.tf": &fstest.MapFile{Data: []byte(`module "level_2" {
source = "../level_2"
}`)},
"modules/level_2/main.tf": &fstest.MapFile{Data: []byte(`module "level_3" {
count = 2
source = "../level_3"
}`)},
"modules/level_3/main.tf": &fstest.MapFile{Data: []byte(`resource "foo" "bar" {}`)},
".terraform/modules/modules.json": &fstest.MapFile{Data: []byte(`{
"Modules": [
{ "Key": "", "Source": "", "Dir": "." },
{
"Key": "level_1",
"Source": "./modules/level_1",
"Dir": "modules/level_1"
},
{
"Key": "level_1.level_2",
"Source": "../level_2",
"Dir": "modules/level_2"
},
{
"Key": "level_1.level_2.level_3",
"Source": "../level_3",
"Dir": "modules/level_3"
}
]
}`)},
}
var buf bytes.Buffer
logger := slog.New(log.NewHandler(&buf, &log.Options{Level: log.LevelDebug}))
parser := New(
fsys, "",
OptionStopOnHCLError(true),
OptionWithLogger(logger),
)
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
assert.Len(t, modules, 5)
assert.Contains(t, buf.String(), "Using module from Terraform cache .terraform/modules\tmodule=\"root\" source=\"./modules/level_1\"")
assert.Contains(t, buf.String(), "Using module from Terraform cache .terraform/modules\tmodule=\"level_1\" source=\"../level_2\"")
assert.Contains(t, buf.String(), "Using module from Terraform cache .terraform/modules\tmodule=\"level_2\" source=\"../level_3\"")
assert.Contains(t, buf.String(), "Using module from Terraform cache .terraform/modules\tmodule=\"level_2\" source=\"../level_3\"")
}
func TestNilParser(t *testing.T) {
parser := New(
nil, "",
)
err := parser.ParseFS(t.Context(), ".")
require.Error(t, err)
}
func TestLogParseErrors(t *testing.T) {
var buf bytes.Buffer
slog.SetDefault(slog.New(log.NewHandler(&buf, nil)))
src := `resource "aws-s3-bucket" "name" {
bucket = <
}`
fsys := fstest.MapFS{
"main.tf": &fstest.MapFile{
Data: []byte(src),
},
}
parser := New(fsys, "")
err := parser.ParseFS(t.Context(), ".")
require.NoError(t, err)
assert.Contains(t, buf.String(), `cause=" bucket = <"`)
}
func Test_PassingNullToChildModule_DoesNotEraseType(t *testing.T) {
tests := []struct {
name string
fsys fs.FS
}{
{
name: "typed variable",
fsys: fstest.MapFS{
"main.tf": &fstest.MapFile{Data: []byte(`module "test" {
source = "./modules/test"
test_var = null
}`)},
"modules/test/main.tf": &fstest.MapFile{Data: []byte(`variable "test_var" {
type = number
}
resource "foo" "this" {
bar = var.test_var != null ? 1 : 2
}`)},
},
},
{
name: "typed variable with default",
fsys: fstest.MapFS{
"main.tf": &fstest.MapFile{Data: []byte(`module "test" {
source = "./modules/test"
test_var = null
}`)},
"modules/test/main.tf": &fstest.MapFile{Data: []byte(`variable "test_var" {
type = number
default = null
}
resource "foo" "this" {
bar = var.test_var != null ? 1 : 2
}`)},
},
},
{
name: "empty variable",
fsys: fstest.MapFS{
"main.tf": &fstest.MapFile{Data: []byte(`module "test" {
source = "./modules/test"
test_var = null
}`)},
"modules/test/main.tf": &fstest.MapFile{Data: []byte(`variable "test_var" {}
resource "foo" "this" {
bar = var.test_var != null ? 1 : 2
}`)},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := New(
tt.fsys, "",
OptionStopOnHCLError(true),
)
require.NoError(t, parser.ParseFS(t.Context(), "."))
_, err := parser.Load(t.Context())
require.NoError(t, err)
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
res := modules.GetResourcesByType("foo")[0]
attr := res.GetAttribute("bar")
val, _ := attr.Value().AsBigFloat().Int64()
assert.Equal(t, int64(2), val)
})
}
}
func TestAttrRefToNullVariable(t *testing.T) {
fsys := fstest.MapFS{
"main.tf": &fstest.MapFile{Data: []byte(`variable "name" {
type = string
default = null
}
resource "aws_s3_bucket" "example" {
bucket = var.name
}`)},
}
parser := New(fsys, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
_, err := parser.Load(t.Context())
require.NoError(t, err)
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
val := modules.GetResourcesByType("aws_s3_bucket")[0].GetAttribute("bucket").GetRawValue()
assert.Nil(t, val)
}
func Test_AttrIsRefToOtherBlock(t *testing.T) {
fsys := testutil.CreateFS(t, map[string]string{
"main.tf": `locals {
baz_idx = 0
}
resource "foo" "bar" {
attr = baz.qux[local.baz_idx].attr
}
resource "baz" "qux" {
count = 1
attr = "test"
}
`,
})
parser := New(fsys, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
root := modules[0]
foo := root.GetResourcesByType("foo")[0]
fooAttr := foo.GetAttribute("attr")
b, err := root.GetReferencedBlock(fooAttr, foo)
require.NoError(t, err)
require.NotNil(t, b)
}
func TestConfigWithEphemeralBlock(t *testing.T) {
fsys := fstest.MapFS{
"main.tf": &fstest.MapFile{Data: []byte(`ephemeral "random_password" "password" {
length = 16
}`)},
}
parser := New(fsys, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
_, err := parser.Load(t.Context())
require.NoError(t, err)
}
func TestConvertObjectWithUnknownAndNullValuesToMap(t *testing.T) {
fsys := fstest.MapFS{
"main.tf": &fstest.MapFile{Data: []byte(`module "foo" {
source = "./modules/foo"
}
locals {
known = "test"
}
module "bar" {
source = "./modules/bar"
outputs = {
"key1" : { "Value" : module.foo.test },
"key2" : { "Value" : local.known },
"key3" : { "Value" : local.unknown },
}
}`)},
"modules/foo/main.tf": &fstest.MapFile{Data: []byte(`output "test" {
value = ref_to_unknown
}`)},
"modules/bar/main.tf": &fstest.MapFile{Data: []byte(`variable "outputs" {
type = map(any)
}`)},
}
parser := New(fsys, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
_, err := parser.Load(t.Context())
require.NoError(t, err)
_, err = parser.EvaluateAll(t.Context())
require.NoError(t, err)
}
func TestAttributeWithMissingVarIsUnresolvable(t *testing.T) {
fsys := fstest.MapFS{
"main.tf": &fstest.MapFile{Data: []byte(`variable "inp" {
type = string
}
resource "foo" "bar" {
attr = "${var.inp}-test"
}
`)},
}
parser := New(fsys, "", OptionStopOnHCLError(true))
require.NoError(t, parser.ParseFS(t.Context(), "."))
_, err := parser.Load(t.Context())
require.NoError(t, err)
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
foo := modules[0].GetResourcesByType("foo")[0]
attr := foo.GetAttribute("attr")
assert.False(t, attr.IsResolvable())
}
// TestInstancedLogger checks if any global logs are generated by the terraform
// parser + evaluation. All logs should be attached to the instanced logger. This
// test does not run all error cases, so it will not capture all log output.
func TestInstancedLogger(t *testing.T) {
// reset global logger
prevLog := slog.Default()
defer slog.SetDefault(prevLog)
// capture logs to the global logger
var buf bytes.Buffer
slog.SetDefault(slog.New(log.NewHandler(&buf, &log.Options{
Level: slog.LevelDebug,
})))
// create a new logger for the parser
var instance bytes.Buffer
logger := slog.New(log.NewHandler(&instance, &log.Options{
Level: slog.LevelDebug,
}))
opts := []Option{
OptionWithLogger(logger),
OptionStopOnHCLError(false),
OptionWithDownloads(false),
}
// Run some scenarios that will trigger logs
t.Run("ParserLogs", func(t *testing.T) {
fsys := fstest.MapFS{
"main.tf": &fstest.MapFile{
Data: []byte(`
bare words
`)},
}
parser := New(fsys, "", opts...)
// No error is returned, but some parser logs are expected
err := parser.ParseFS(t.Context(), ".")
require.NoError(t, err)
require.NotEmpty(t, instance.String())
instance.Reset()
})
t.Run("Evaluator", func(t *testing.T) {
fsys := fstest.MapFS{
"main.tf": &fstest.MapFile{
Data: []byte(`
locals {
phrase = "hello world"
}
output "phrase" {
value = locals.phrase
}
`)},
}
parser := New(fsys, "", opts...)
err := parser.ParseFS(t.Context(), ".")
require.NoError(t, err)
_, err = parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.NotEmpty(t, instance.String())
instance.Reset()
})
t.Run("ModuleFetching", func(t *testing.T) {
fsys := testutil.CreateFS(t, map[string]string{
"main.tf": `
module "invalid" {
source = "totally.invalid"
}
module "echo" {
source = "./echo"
input = "hello"
}
locals {
foo = module.echo.value
}
`,
"echo/main.tf": `
variable "input" {
type = string
}
output "value" {
value = var.input
}
`,
})
parser := New(fsys, "", opts...)
err := parser.ParseFS(t.Context(), ".")
require.NoError(t, err)
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.NotEmpty(t, instance.String())
require.Len(t, modules, 2)
instance.Reset()
})
//nolint:testifylint // linter wants `emptyf`, but the output is not legible
if !assert.Emptyf(t, buf.Bytes(), "logs detected in global logger, all logs should be using the instanced logger") {
t.Log(buf.String()) // Helpful for debugging
}
}
func TestProvidedWorkingDirectory(t *testing.T) {
const fakeCwd = "/some/path"
fsys := testutil.CreateFS(t, map[string]string{
"main.tf": `
resource "foo" "bar" {
cwd = path.cwd
}
`,
})
parser := New(fsys, "", OptionWithWorkingDirectoryPath(fakeCwd))
err := parser.ParseFS(t.Context(), ".")
require.NoError(t, err)
modules, err := parser.EvaluateAll(t.Context())
require.NoError(t, err)
require.Len(t, modules, 1)
foo := modules[0].GetResourcesByType("foo")[0]
attr := foo.GetAttribute("cwd")
require.Equal(t, fakeCwd, attr.Value().AsString())
}