Skip to content

Commit d3da69f

Browse files
authored
Merge branch 'main' into fix-5810-feature-default-isolation
2 parents 008b072 + 81854ea commit d3da69f

25 files changed

Lines changed: 1206 additions & 82 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
version: "v1.0.4"
3+
category: "experiments-updated"
4+
---
5+
6+
#### `stack-dependencies` — Nested stack paths and discovery integration
7+
8+
The `stack-dependencies` experiment gains two improvements: nested stack path references at arbitrary depth, and integration with the `find` and `list` discovery commands.
9+
10+
**Nested stack path references**
11+
12+
`stack.<name>.<nested_stack>.path` now resolves at arbitrary nesting depth. Previously, only units within a stack were reachable via `stack.<name>.<unit_name>.path`; nested stacks are now first-class references too.
13+
14+
```hcl
15+
# terragrunt.stack.hcl
16+
17+
stack "infra" {
18+
source = "../catalog/stacks/infra"
19+
path = "infra"
20+
}
21+
22+
unit "app" {
23+
source = "../catalog/units/app"
24+
path = "app"
25+
26+
autoinclude {
27+
dependency "deep" {
28+
# infra contains a nested "deep" stack; reference it directly.
29+
config_path = stack.infra.deep.path
30+
}
31+
32+
inputs = {
33+
val = dependency.deep.outputs.val
34+
}
35+
}
36+
}
37+
```
38+
39+
**Discovery commands surface stack dependencies**
40+
41+
The `terragrunt find` and `terragrunt list` discovery commands now reflect stack dependencies generated by the `autoinclude` block. The DAG output correctly orders units by their autoinclude dependencies and shows dependency relationships in JSON, tree, and long formats.
42+
43+
```bash
44+
# JSON output includes dependency relationships from autoinclude
45+
$ terragrunt find --json --dag --dependencies --experiment stack-dependencies
46+
47+
# Long list format shows a Dependencies column
48+
$ terragrunt list --long --dependencies --dag --experiment stack-dependencies
49+
50+
# Tree format visualizes the dependency hierarchy
51+
$ terragrunt list --tree --dag --experiment stack-dependencies
52+
```
53+
54+
Multi-level dependency trees (for example, `A → B,C` where `B → D,E`) are ordered correctly in DAG mode: leaf units appear first, parents appear after all their dependencies.
55+
56+
To learn more, see the [experiment documentation](/reference/experiments/active#stack-dependencies).

docs/src/data/experiments/stack-dependencies.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,22 @@ Provide your feedback on the [Stack Dependencies RFC](https://github.com/gruntwo
143143
- [ ] Performance validation at scale
144144

145145
</Since>
146+
147+
<Since version="1.0.4">
148+
149+
- [x] Create `internal/hclparse` package with two-phase stack file parser and autoinclude data structures
150+
- [x] Implement `terragrunt.autoinclude.hcl` file generator with relative path resolution
151+
- [x] Implement AST-walking partial evaluator for mixed `local.*` + `dependency.*` expressions
152+
- [x] Integrate autoinclude parsing and generation into `pkg/config/stack.go` stack generation pipeline
153+
- [x] Auto-merge `terragrunt.autoinclude.hcl` into unit config during `ParseConfig()` with deep merge
154+
- [x] Add `tryGetStackOutput()` for dependency blocks targeting stack directories
155+
- [x] Add `ExtractAutoIncludeDependencyPaths()` so DAG sees dependencies from autoinclude files
156+
- [x] Add integration tests and test fixtures for end-to-end validation
157+
- [x] E2E validation with `stack run apply` and `stack run destroy`
158+
- [x] Resolve `stack.<name>.<nested_stack>.path` at arbitrary nesting depth
159+
- [x] Surface autoinclude dependencies in `terragrunt find` and `terragrunt list` (JSON, tree, and long formats)
160+
- [ ] Community feedback on `autoinclude` syntax and merge behavior
161+
- [ ] Integration with CAS for generated files
162+
- [ ] Performance validation at scale
163+
164+
</Since>

internal/discovery/helpers.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,11 @@ func stackDependencyPaths(fs vfs.FS, depPaths []string, c component.Component) (
290290
expanded := make([]string, 0, len(depPaths))
291291

292292
for _, depPath := range depPaths {
293-
unitPaths := inthclparse.UnitPathsFromStackDir(fs, depPath)
293+
unitPaths, err := inthclparse.UnitPathsFromStackDir(fs, depPath)
294+
if err != nil {
295+
return nil, err
296+
}
297+
294298
if len(unitPaths) > 0 {
295299
expanded = append(expanded, unitPaths...)
296300

internal/hclparse/autoinclude.go

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package hclparse
22

33
import (
4+
"fmt"
45
iofs "io/fs"
56
"path/filepath"
67

@@ -179,53 +180,89 @@ func BuildAutoIncludeEvalContext(unitRefs, stackRefs []ComponentRef) *hcl.EvalCo
179180
// AutoIncludeDependencyPaths reads the terragrunt.autoinclude.hcl file in
180181
// unitDir and returns resolved dependency config_path values.
181182
// Returns (nil, nil) if the file does not exist or has no dependencies.
183+
// Panics when fs is nil (programmer error). Returns EmptyArgError when unitDir
184+
// is empty so callers can distinguish bad input from a missing file.
182185
func AutoIncludeDependencyPaths(fs vfs.FS, unitDir string) ([]string, error) {
186+
if fs == nil {
187+
panic(fmt.Sprintf("hclparse.AutoIncludeDependencyPaths: fs is nil (unitDir=%q)", unitDir))
188+
}
189+
190+
if unitDir == "" {
191+
return nil, EmptyArgError{Func: "AutoIncludeDependencyPaths", Arg: "unitDir"}
192+
}
193+
183194
unitDir = util.ResolvePath(unitDir)
184195
autoIncludePath := filepath.Join(unitDir, AutoIncludeFile)
185196

186-
data, err := vfs.ReadFile(fs, autoIncludePath)
197+
body, err := readAutoIncludeBody(fs, autoIncludePath)
198+
if err != nil || body == nil {
199+
return nil, err
200+
}
201+
202+
paths := make([]string, 0, len(body.Blocks))
203+
204+
for _, block := range body.Blocks {
205+
if depPath, ok := extractDependencyConfigPath(block, unitDir); ok {
206+
paths = append(paths, depPath)
207+
}
208+
}
209+
210+
return paths, nil
211+
}
212+
213+
// readAutoIncludeBody reads and parses the autoinclude file at path.
214+
// Returns (nil, nil) when the file does not exist.
215+
func readAutoIncludeBody(fs vfs.FS, path string) (*hclsyntax.Body, error) {
216+
data, err := vfs.ReadFile(fs, path)
187217
if errors.Is(err, iofs.ErrNotExist) {
188218
return nil, nil
189219
}
190220

191221
if err != nil {
192-
return nil, FileReadError{FilePath: autoIncludePath, Err: err}
222+
return nil, FileReadError{FilePath: path, Err: err}
193223
}
194224

195-
file, diags := hclsyntax.ParseConfig(data, autoIncludePath, hcl.Pos{Line: 1, Column: 1})
225+
file, diags := hclsyntax.ParseConfig(data, path, hcl.Pos{Line: 1, Column: 1})
196226
if diags.HasErrors() {
197-
return nil, FileParseError{FilePath: autoIncludePath, Detail: diags.Error()}
227+
return nil, FileParseError{FilePath: path, Detail: diags.Error()}
198228
}
199229

200230
body, ok := file.Body.(*hclsyntax.Body)
201231
if !ok {
202-
return nil, UnexpectedBodyTypeError{FilePath: autoIncludePath}
232+
return nil, UnexpectedBodyTypeError{FilePath: path}
203233
}
204234

205-
var paths []string
206-
207-
for _, block := range body.Blocks {
208-
if block.Type != blockDependency || len(block.Labels) == 0 {
209-
continue
210-
}
235+
return body, nil
236+
}
211237

212-
configPathAttr, exists := block.Body.Attributes[attrConfigPath]
213-
if !exists {
214-
continue
215-
}
238+
// extractDependencyConfigPath returns the resolved absolute config_path for a
239+
// dependency block, or ("", false) if the block is not a valid dependency or
240+
// config_path cannot be evaluated to a string.
241+
//
242+
// The nil eval context passed to Expr.Value is intentional: GenerateAutoIncludeFile
243+
// always writes config_path as a literal quoted string via writeDependencyBlock
244+
// (see generate.go), so no variable resolution is required. If that contract is
245+
// ever relaxed to emit interpolations, callers must pass a real eval context
246+
// here or the dependency will be silently dropped from the DAG.
247+
func extractDependencyConfigPath(block *hclsyntax.Block, unitDir string) (string, bool) {
248+
if block.Type != blockDependency || len(block.Labels) == 0 {
249+
return "", false
250+
}
216251

217-
val, valDiags := configPathAttr.Expr.Value(nil)
218-
if valDiags.HasErrors() || !val.IsKnown() || val.IsNull() || val.Type() != cty.String {
219-
continue
220-
}
252+
configPathAttr, exists := block.Body.Attributes[attrConfigPath]
253+
if !exists {
254+
return "", false
255+
}
221256

222-
depPath := val.AsString()
223-
if !filepath.IsAbs(depPath) {
224-
depPath = filepath.Clean(filepath.Join(unitDir, depPath))
225-
}
257+
val, diags := configPathAttr.Expr.Value(nil)
258+
if diags.HasErrors() || !val.IsKnown() || val.IsNull() || val.Type() != cty.String {
259+
return "", false
260+
}
226261

227-
paths = append(paths, util.ResolvePath(depPath))
262+
depPath := val.AsString()
263+
if !filepath.IsAbs(depPath) {
264+
depPath = filepath.Clean(filepath.Join(unitDir, depPath))
228265
}
229266

230-
return paths, nil
267+
return util.ResolvePath(depPath), true
231268
}

internal/hclparse/errors.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,13 @@ type LocalsMaxIterError struct {
133133
func (e LocalsMaxIterError) Error() string {
134134
return fmt.Sprintf("locals evaluation exceeded %d iterations with %d unresolved locals", e.MaxIterations, e.Remaining)
135135
}
136+
137+
// EmptyArgError indicates that a required string argument was empty.
138+
type EmptyArgError struct {
139+
Func string
140+
Arg string
141+
}
142+
143+
func (e EmptyArgError) Error() string {
144+
return fmt.Sprintf("hclparse.%s: %s is empty", e.Func, e.Arg)
145+
}

0 commit comments

Comments
 (0)