Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
75 changes: 75 additions & 0 deletions libs/digger_config/digger_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,81 @@ generate_projects:
assert.Equal(t, 3, len(dg.Projects))
}

func TestDiggerGenerateProjectsWithDynamicProviders(t *testing.T) {
tempDir, teardown := setUp()
defer teardown()

diggerCfg := `
generate_projects:
blocks:
- include: opentofu/*
opentofu: true
`
deleteFile := createFile(path.Join(tempDir, "digger.yml"), diggerCfg)
defer deleteFile()

// Create OpenTofu project directory with dynamic provider using for_each
projectDir := path.Join(tempDir, "opentofu/dynamic-provider")
err := os.MkdirAll(projectDir, os.ModePerm)
assert.NoError(t, err, "expected error to be nil")

// Create a main.tf with dynamic provider configuration (for_each in provider block)
// and resources that reference the dynamic provider
mainTfContent := `
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}

variable "regions" {
type = set(string)
default = ["us-east-1", "us-west-2"]
}

provider "aws" {
for_each = var.regions
region = each.key
alias = each.key
}

module "vpc" {
source = "./modules/vpc"
}

resource "aws_s3_bucket" "example" {
for_each = var.regions
provider = aws[each.key]
bucket = "my-bucket-${each.key}"
}
`
defer createFile(path.Join(projectDir, "main.tf"), mainTfContent)()

// Create a local module directory
moduleDir := path.Join(projectDir, "modules/vpc")
err = os.MkdirAll(moduleDir, os.ModePerm)
assert.NoError(t, err, "expected error to be nil")

moduleContent := `
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
`
defer createFile(path.Join(moduleDir, "main.tf"), moduleContent)()

// Load config and verify it parses successfully despite the dynamic provider
dg, _, _, _, err := LoadDiggerConfig(tempDir, true, nil, nil)
assert.NoError(t, err, "expected error to be nil - dynamic providers with for_each should be parseable")
assert.NotNil(t, dg, "expected digger config to be not nil")
assert.Equal(t, 1, len(dg.Projects), "expected 1 project to be generated")
assert.Equal(t, "opentofu_dynamic-provider", dg.Projects[0].Name)
assert.Equal(t, true, dg.Projects[0].OpenTofu)
assert.Equal(t, "opentofu/dynamic-provider", dg.Projects[0].Dir)
}

// TestDiggerGenerateProjectsEmptyParameters test if missing parameters for generate_projects are handled correctly
func TestDiggerGenerateProjectsEmptyParameters(t *testing.T) {
_, teardown := setUp()
Expand Down
129 changes: 108 additions & 21 deletions libs/digger_config/terragrunt/tac/parse_tf.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ package tac
import (
"errors"
"github.com/gruntwork-io/terragrunt/util"
"github.com/hashicorp/terraform-config-inspect/tfconfig"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
"os"
"path/filepath"
"strings"
)

Expand All @@ -14,32 +19,82 @@ var localModuleSourcePrefixes = []string{
"..\\",
}

// moduleCall represents a module block with its source
type moduleCall struct {
Source string
}

// parseTerraformLocalModuleSource parses Terraform files using hclparse to extract local module sources.
// This replaces the use of terraform-config-inspect which doesn't handle for_each in provider blocks.
func parseTerraformLocalModuleSource(path string) ([]string, error) {
module, diags := tfconfig.LoadModule(path)
// modules, diags := parser.loadConfigDir(path)
if diags.HasErrors() {
return nil, errors.New(diags.Error())
var sourceMap = map[string]bool{}

// Read all .tf and .tf.json files in the directory
files, err := os.ReadDir(path)
if err != nil {
return nil, err
}

var sourceMap = map[string]bool{}
for _, mc := range module.ModuleCalls {
if isLocalTerraformModuleSource(mc.Source) {
modulePath := util.JoinPath(path, mc.Source)
modulePathGlob := util.JoinPath(modulePath, "*.tf*")
parser := hclparse.NewParser()

if _, exists := sourceMap[modulePathGlob]; exists {
continue
}
sourceMap[modulePathGlob] = true
for _, file := range files {
if file.IsDir() {
continue
}

// find local module source recursively
subSources, err := parseTerraformLocalModuleSource(modulePath)
if err != nil {
return nil, err
}
filename := file.Name()
ext := filepath.Ext(filename)

// Only process Terraform files
if ext != ".tf" && ext != ".json" {
continue
}

fullPath := filepath.Join(path, filename)

// Read file content
content, err := os.ReadFile(fullPath)
if err != nil {
continue // Skip files we can't read
}

// Parse the HCL file
var hclFile *hcl.File
var diags hcl.Diagnostics

for _, subSource := range subSources {
sourceMap[subSource] = true
if ext == ".json" {
hclFile, diags = parser.ParseJSON(content, fullPath)
} else {
hclFile, diags = parser.ParseHCL(content, fullPath)
}

// If there are errors, return them
if diags.HasErrors() {
return nil, errors.New(diags.Error())
}

// Extract module calls from the parsed file
moduleCalls := extractModuleCalls(hclFile.Body)

for _, mc := range moduleCalls {
if isLocalTerraformModuleSource(mc.Source) {
modulePath := util.JoinPath(path, mc.Source)
modulePathGlob := util.JoinPath(modulePath, "*.tf*")

if _, exists := sourceMap[modulePathGlob]; exists {
continue
}
sourceMap[modulePathGlob] = true

// find local module source recursively
subSources, err := parseTerraformLocalModuleSource(modulePath)
if err != nil {
return nil, err
}

for _, subSource := range subSources {
sourceMap[subSource] = true
}
}
}
}
Expand All @@ -52,6 +107,38 @@ func parseTerraformLocalModuleSource(path string) ([]string, error) {
return sources, nil
}

// extractModuleCalls extracts module blocks from an HCL body
func extractModuleCalls(body hcl.Body) []moduleCall {
var modules []moduleCall

// We need to work with the native syntax to extract blocks
content, ok := body.(*hclsyntax.Body)
if !ok {
return modules
}

for _, block := range content.Blocks {
if block.Type == "module" {
// Look for the source attribute in the block
if attr, exists := block.Body.Attributes["source"]; exists {
// Try to extract the literal value
val, diags := attr.Expr.Value(nil)
if diags.HasErrors() || val.IsNull() {
continue
}

if val.Type() == cty.String {
modules = append(modules, moduleCall{
Source: val.AsString(),
})
}
}
}
}

return modules
}

func isLocalTerraformModuleSource(raw string) bool {
for _, prefix := range localModuleSourcePrefixes {
if strings.HasPrefix(raw, prefix) {
Expand Down