Skip to content

Commit 5f3b26f

Browse files
committed
feat: terragrunt hcl parse include
1 parent a8b0303 commit 5f3b26f

22 files changed

Lines changed: 3546 additions & 12 deletions

internal/discovery/helpers.go

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@ import (
99

1010
"github.com/gruntwork-io/terragrunt/internal/component"
1111
"github.com/gruntwork-io/terragrunt/internal/errors"
12+
intHclparse "github.com/gruntwork-io/terragrunt/internal/hclparse"
1213
"github.com/gruntwork-io/terragrunt/internal/util"
1314
"github.com/gruntwork-io/terragrunt/pkg/config"
15+
"github.com/hashicorp/hcl/v2"
16+
"github.com/hashicorp/hcl/v2/hclsyntax"
17+
"github.com/zclconf/go-cty/cty"
1418
)
1519

1620
const (
@@ -220,6 +224,8 @@ func sanitizeReadFiles(files []string) []string {
220224
}
221225

222226
// extractDependencyPaths extracts all dependency paths from a Terragrunt configuration.
227+
// It also checks for terragrunt.autoinclude.hcl in the same directory and extracts
228+
// dependency config_path values from it, so the DAG correctly orders units.
223229
func extractDependencyPaths(cfg *config.TerragruntConfig, c component.Component) ([]string, error) {
224230
if cfg == nil {
225231
return nil, nil
@@ -232,6 +238,12 @@ func extractDependencyPaths(cfg *config.TerragruntConfig, c component.Component)
232238

233239
deduped := make(map[string]struct{}, maxDedupLen)
234240

241+
// Check for autoinclude file and extract its dependency paths for the DAG.
242+
autoIncludeDeps := ExtractAutoIncludeDependencyPaths(c.Path())
243+
for _, dep := range autoIncludeDeps {
244+
deduped[dep] = struct{}{}
245+
}
246+
235247
errs := make([]error, 0, maxDedupLen)
236248

237249
for _, dependency := range cfg.TerragruntDependencies {
@@ -265,8 +277,16 @@ func extractDependencyPaths(cfg *config.TerragruntConfig, c component.Component)
265277
}
266278

267279
depPaths := make([]string, 0, len(deduped))
280+
268281
for depPath := range deduped {
269-
depPaths = append(depPaths, depPath)
282+
// When the stack-dependencies experiment is active and the dependency
283+
// path points to a stack (directory with terragrunt.stack.hcl), expand
284+
// it to all constituent unit paths so the DAG correctly blocks on each unit.
285+
if expandedPaths := ExpandStackDependency(depPath); len(expandedPaths) > 0 {
286+
depPaths = append(depPaths, expandedPaths...)
287+
} else {
288+
depPaths = append(depPaths, depPath)
289+
}
270290
}
271291

272292
if len(errs) > 0 {
@@ -275,3 +295,101 @@ func extractDependencyPaths(cfg *config.TerragruntConfig, c component.Component)
275295

276296
return depPaths, nil
277297
}
298+
299+
// ExpandStackDependency checks if a dependency path points to a stack directory.
300+
// If so, it reads the stack config and returns paths to all generated units
301+
// within .terragrunt-stack/. Returns nil if not a stack.
302+
func ExpandStackDependency(depPath string) []string {
303+
stackFile := filepath.Join(depPath, config.DefaultStackFile)
304+
305+
if !util.FileExists(stackFile) {
306+
return nil
307+
}
308+
309+
// Read the stack file to discover unit paths.
310+
// We only need the raw HCL to extract unit path attributes — no eval context needed.
311+
data, err := os.ReadFile(stackFile)
312+
if err != nil {
313+
return nil
314+
}
315+
316+
unitPaths := ExtractUnitPathsFromStackFile(data, depPath)
317+
318+
return unitPaths
319+
}
320+
321+
// ExtractUnitPathsFromStackFile parses a stack file and returns absolute paths
322+
// to each unit's generated directory under .terragrunt-stack/.
323+
func ExtractUnitPathsFromStackFile(data []byte, stackDir string) []string {
324+
// Use a minimal HCL parse to extract unit blocks and their path attributes.
325+
// We import the internal hclparse package which can handle this.
326+
result, err := intHclparse.ParseStackFile(data, filepath.Join(stackDir, config.DefaultStackFile), stackDir, nil)
327+
if err != nil {
328+
return nil
329+
}
330+
331+
paths := make([]string, 0, len(result.Units))
332+
333+
for _, unit := range result.Units {
334+
unitPath := filepath.Join(stackDir, config.StackDir, unit.Path)
335+
paths = append(paths, unitPath)
336+
}
337+
338+
return paths
339+
}
340+
341+
// ExtractAutoIncludeDependencyPaths checks for a terragrunt.autoinclude.hcl file
342+
// in the given unit directory and extracts dependency config_path values.
343+
// This ensures the DAG sees dependencies defined in autoinclude files during
344+
// graph construction, even though they're not in the main terragrunt.hcl.
345+
func ExtractAutoIncludeDependencyPaths(unitDir string) []string {
346+
autoIncludePath := filepath.Join(unitDir, config.DefaultAutoIncludeFile)
347+
348+
if !util.FileExists(autoIncludePath) {
349+
return nil
350+
}
351+
352+
data, err := os.ReadFile(autoIncludePath)
353+
if err != nil {
354+
return nil
355+
}
356+
357+
// Minimal HCL parse — just extract dependency blocks and their config_path.
358+
file, diags := hclsyntax.ParseConfig(data, autoIncludePath, hcl.Pos{Line: 1, Column: 1})
359+
if diags.HasErrors() {
360+
return nil
361+
}
362+
363+
body, ok := file.Body.(*hclsyntax.Body)
364+
if !ok {
365+
return nil
366+
}
367+
368+
var paths []string
369+
370+
for _, block := range body.Blocks {
371+
if block.Type != "dependency" || len(block.Labels) == 0 {
372+
continue
373+
}
374+
375+
configPathAttr, exists := block.Body.Attributes["config_path"]
376+
if !exists {
377+
continue
378+
}
379+
380+
// Evaluate config_path — it's already a resolved string literal in the generated file.
381+
val, valDiags := configPathAttr.Expr.Value(nil)
382+
if valDiags.HasErrors() || val.Type() != cty.String {
383+
continue
384+
}
385+
386+
depPath := val.AsString()
387+
if !filepath.IsAbs(depPath) {
388+
depPath = filepath.Clean(filepath.Join(unitDir, depPath))
389+
}
390+
391+
paths = append(paths, util.ResolvePath(depPath))
392+
}
393+
394+
return paths
395+
}

internal/discovery/helpers_test.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package discovery_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/gruntwork-io/terragrunt/internal/discovery"
9+
"github.com/gruntwork-io/terragrunt/pkg/config"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestExpandStackDependency_NotAStack(t *testing.T) {
15+
t.Parallel()
16+
17+
tmpDir := t.TempDir()
18+
19+
result := discovery.ExpandStackDependency(tmpDir)
20+
assert.Nil(t, result)
21+
}
22+
23+
func TestExpandStackDependency_NonexistentDir(t *testing.T) {
24+
t.Parallel()
25+
26+
result := discovery.ExpandStackDependency("/nonexistent/path")
27+
assert.Nil(t, result)
28+
}
29+
30+
func TestExpandStackDependency_StackWithUnits(t *testing.T) {
31+
t.Parallel()
32+
33+
tmpDir := t.TempDir()
34+
stackHCL := `
35+
unit "vpc" {
36+
source = "../units/vpc"
37+
path = "vpc"
38+
}
39+
40+
unit "db" {
41+
source = "../units/db"
42+
path = "db"
43+
}
44+
45+
unit "app" {
46+
source = "../units/app"
47+
path = "app"
48+
}
49+
`
50+
require.NoError(t, os.WriteFile(
51+
filepath.Join(tmpDir, config.DefaultStackFile),
52+
[]byte(stackHCL),
53+
0644,
54+
))
55+
56+
result := discovery.ExpandStackDependency(tmpDir)
57+
58+
require.Len(t, result, 3)
59+
60+
for _, p := range result {
61+
assert.Contains(t, p, config.StackDir)
62+
}
63+
64+
expected := map[string]bool{
65+
filepath.Join(tmpDir, config.StackDir, "vpc"): true,
66+
filepath.Join(tmpDir, config.StackDir, "db"): true,
67+
filepath.Join(tmpDir, config.StackDir, "app"): true,
68+
}
69+
70+
for _, p := range result {
71+
assert.True(t, expected[p], "unexpected path: %s", p)
72+
}
73+
}
74+
75+
func TestExpandStackDependency_EmptyStack(t *testing.T) {
76+
t.Parallel()
77+
78+
tmpDir := t.TempDir()
79+
80+
require.NoError(t, os.WriteFile(
81+
filepath.Join(tmpDir, config.DefaultStackFile),
82+
[]byte("# Empty stack\n"),
83+
0644,
84+
))
85+
86+
result := discovery.ExpandStackDependency(tmpDir)
87+
assert.Empty(t, result)
88+
}
89+
90+
func TestExtractUnitPathsFromStackFile(t *testing.T) {
91+
t.Parallel()
92+
93+
data := []byte(`
94+
unit "vpc" {
95+
source = "../units/vpc"
96+
path = "vpc"
97+
}
98+
99+
unit "db" {
100+
source = "../units/db"
101+
path = "db"
102+
}
103+
`)
104+
stackDir := "/project/live/infra"
105+
106+
paths := discovery.ExtractUnitPathsFromStackFile(data, stackDir)
107+
108+
require.Len(t, paths, 2)
109+
assert.Equal(t, filepath.Join(stackDir, config.StackDir, "vpc"), paths[0])
110+
assert.Equal(t, filepath.Join(stackDir, config.StackDir, "db"), paths[1])
111+
}
112+
113+
func TestExtractUnitPathsFromStackFile_InvalidHCL(t *testing.T) {
114+
t.Parallel()
115+
116+
paths := discovery.ExtractUnitPathsFromStackFile([]byte(`invalid {{{`), "/project")
117+
assert.Nil(t, paths)
118+
}
119+
120+
func TestExtractAutoIncludeDependencyPaths_NoFile(t *testing.T) {
121+
t.Parallel()
122+
123+
tmpDir := t.TempDir()
124+
125+
paths := discovery.ExtractAutoIncludeDependencyPaths(tmpDir)
126+
assert.Nil(t, paths)
127+
}
128+
129+
func TestExtractAutoIncludeDependencyPaths_WithDependency(t *testing.T) {
130+
t.Parallel()
131+
132+
tmpDir := t.TempDir()
133+
134+
// Write a generated autoinclude file with a relative config_path
135+
autoIncludeContent := `
136+
# Generated by Terragrunt. Do not edit manually.
137+
dependency "vpc" {
138+
config_path = "../unit-w-outputs"
139+
}
140+
141+
inputs = {
142+
val = dependency.vpc.outputs.val
143+
}
144+
`
145+
require.NoError(t, os.WriteFile(
146+
filepath.Join(tmpDir, config.DefaultAutoIncludeFile),
147+
[]byte(autoIncludeContent),
148+
0644,
149+
))
150+
151+
paths := discovery.ExtractAutoIncludeDependencyPaths(tmpDir)
152+
153+
require.Len(t, paths, 1)
154+
// ../unit-w-outputs resolved relative to tmpDir
155+
assert.Equal(t, filepath.Clean(filepath.Join(tmpDir, "..", "unit-w-outputs")), paths[0])
156+
}
157+
158+
func TestExtractAutoIncludeDependencyPaths_MultipleDeps(t *testing.T) {
159+
t.Parallel()
160+
161+
tmpDir := t.TempDir()
162+
163+
autoIncludeContent := `
164+
dependency "vpc" {
165+
config_path = "../vpc"
166+
}
167+
168+
dependency "db" {
169+
config_path = "../database"
170+
}
171+
`
172+
require.NoError(t, os.WriteFile(
173+
filepath.Join(tmpDir, config.DefaultAutoIncludeFile),
174+
[]byte(autoIncludeContent),
175+
0644,
176+
))
177+
178+
paths := discovery.ExtractAutoIncludeDependencyPaths(tmpDir)
179+
180+
require.Len(t, paths, 2)
181+
}
182+
183+
func TestExtractAutoIncludeDependencyPaths_AbsolutePath(t *testing.T) {
184+
t.Parallel()
185+
186+
tmpDir := t.TempDir()
187+
188+
autoIncludeContent := `
189+
dependency "vpc" {
190+
config_path = "/absolute/path/to/vpc"
191+
}
192+
`
193+
require.NoError(t, os.WriteFile(
194+
filepath.Join(tmpDir, config.DefaultAutoIncludeFile),
195+
[]byte(autoIncludeContent),
196+
0644,
197+
))
198+
199+
paths := discovery.ExtractAutoIncludeDependencyPaths(tmpDir)
200+
201+
require.Len(t, paths, 1)
202+
assert.Equal(t, "/absolute/path/to/vpc", paths[0])
203+
}

0 commit comments

Comments
 (0)