Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
800bbdd
bug: HCL parsing errors handling
denis256 Apr 27, 2026
b57157d
bug: handling of auto include parsing errors
denis256 Apr 27, 2026
690900f
chore: auto include fixes
denis256 Apr 27, 2026
d9497b4
chore: simplified parsing errors
denis256 Apr 28, 2026
974725b
chore: stack parsing fixes
denis256 Apr 28, 2026
d4b5ca3
chore: internal tests
denis256 Apr 28, 2026
64b10cc
chore: statck parsing fixes
denis256 Apr 28, 2026
e7974b4
chore: stack dependencies autoinclude
denis256 Apr 28, 2026
2e607a8
chore: internal test fix
denis256 Apr 28, 2026
3de9124
chore: internal test fix
denis256 Apr 28, 2026
99e65e5
chore: improved discovery errors
denis256 Apr 28, 2026
5c4aa49
Merge branch '5980-auto-include-parse-errors' of github.com:gruntwork…
denis256 Apr 28, 2026
b7dd23a
chore: fuzz test
denis256 Apr 28, 2026
a5c3a38
chore: fuzzy tests improvements
denis256 Apr 28, 2026
458941c
Merge branch 'main' into 5980-auto-include-parse-errors
denis256 Apr 28, 2026
4988363
chore: auto include fix
denis256 Apr 28, 2026
e08b089
chore: stacks simplification
denis256 Apr 29, 2026
ae8ed13
chore: simplified auto include errors
denis256 Apr 29, 2026
15e9793
Merge remote-tracking branch 'origin/main' into 5980-auto-include-par…
denis256 Apr 29, 2026
64a58f9
chore: PR comments
denis256 Apr 29, 2026
0ef28a9
Merge remote-tracking branch 'origin/main' into 5980-auto-include-par…
denis256 Apr 29, 2026
6369be8
chore: failing tests fixes
denis256 Apr 30, 2026
971c091
Merge branch 'main' into 5980-auto-include-parse-errors
denis256 Apr 30, 2026
b3106a6
chore: discovery helpers
denis256 Apr 30, 2026
0e16b31
chore: symlinks handling
denis256 Apr 30, 2026
ed623fe
chore: parser cleanup
denis256 Apr 30, 2026
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
19 changes: 19 additions & 0 deletions internal/discovery/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,22 @@ func NewCoexistenceError(a, b component.Component) error {
ConfigFileB: b.ConfigFile(),
})
}

// StackDependencyExpansionError indicates that a stack dependency path could not be expanded into its constituent unit paths. Wraps the underlying parse error so callers can extract typed details via errors.As.
type StackDependencyExpansionError struct {
Wrapped error
DepPath string
}

func (e StackDependencyExpansionError) Error() string {
return fmt.Sprintf("failed to expand stack dependency path %s: %s", e.DepPath, e.Wrapped)
}

func (e StackDependencyExpansionError) Unwrap() error {
return e.Wrapped
}

// NewStackDependencyExpansionError wraps err with the dependency path that triggered the expansion.
func NewStackDependencyExpansionError(depPath string, err error) error {
return errors.New(StackDependencyExpansionError{DepPath: depPath, Wrapped: err})
}
8 changes: 7 additions & 1 deletion internal/discovery/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,9 +290,15 @@ func stackDependencyPaths(fs vfs.FS, depPaths []string, c component.Component) (
expanded := make([]string, 0, len(depPaths))

for _, depPath := range depPaths {
// Only expand directories: dependency paths can also point at non-default-named config files (e.g. another-name.hcl), which the stack-file parser would otherwise reject as "not a directory".
if info, statErr := fs.Stat(depPath); statErr != nil || !info.IsDir() {
expanded = append(expanded, depPath)
continue
}

unitPaths, err := inthclparse.UnitPathsFromStackDir(fs, depPath)
if err != nil {
return nil, err
return nil, NewStackDependencyExpansionError(depPath, err)
}

if len(unitPaths) > 0 {
Expand Down
38 changes: 38 additions & 0 deletions internal/discovery/stack_dependency_expansion_error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package discovery_test

import (
"testing"

"github.com/gruntwork-io/terragrunt/internal/discovery"
"github.com/gruntwork-io/terragrunt/internal/hclparse"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Direct typed-error contract test: StackDependencyExpansionError must carry the depPath and unwrap cleanly to the original parser error.
func TestStackDependencyExpansionError_Unwrap(t *testing.T) {
t.Parallel()

innerErr := hclparse.MalformedDependencyError{
FilePath: "/some/path/terragrunt.autoinclude.hcl",
Name: "vpc",
Reason: "missing config_path attribute",
}

wrapped := discovery.NewStackDependencyExpansionError("/path/to/dep", innerErr)
require.Error(t, wrapped)
assert.Contains(t, wrapped.Error(), "/path/to/dep")
assert.Contains(t, wrapped.Error(), "missing config_path")

// errors.As must reach both the wrapper and the underlying typed error.
var expansion discovery.StackDependencyExpansionError
require.ErrorAs(t, wrapped, &expansion)
assert.Equal(t, "/path/to/dep", expansion.DepPath)

var malformed hclparse.MalformedDependencyError
require.ErrorAs(t, wrapped, &malformed)
assert.Equal(t, "vpc", malformed.Name)

// errors.Is must reach the leaf via Unwrap chain.
require.ErrorIs(t, wrapped, innerErr)
}
105 changes: 77 additions & 28 deletions internal/hclparse/autoinclude.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
iofs "io/fs"
"path/filepath"
"strings"

"github.com/gruntwork-io/terragrunt/internal/errors"
"github.com/gruntwork-io/terragrunt/internal/util"
Expand Down Expand Up @@ -49,7 +50,9 @@ type AutoIncludeResolved struct {
// RawBody is the original autoinclude HCL body, preserved so
// the generator can write non-dependency content (inputs, etc.)
// directly from the AST without evaluating dependency.* references.
RawBody hcl.Body
RawBody hcl.Body
// SourceBytes are the bytes of the file RawBody was parsed from. Generation slices expressions by HCL byte ranges and must use these bytes, not the root stack file's bytes, when the autoinclude originated in an included file.
SourceBytes []byte
Dependencies []AutoIncludeDependency
}

Expand All @@ -67,6 +70,10 @@ type AutoIncludeDependency struct {
// Resolve evaluates the autoinclude body using the provided eval context,
// which must contain unit.* and stack.* variables for path resolution.
//
// Callers that need to record the originating file's bytes on the returned
// AutoIncludeResolved (so generation can slice expressions from the correct
// source after include merging) should set SourceBytes on the result.
//
// The resolution follows three levels:
//
// 1. First parse: autoinclude body captured as Remain (unit.*.path not yet available)
Expand All @@ -92,7 +99,18 @@ func (a *AutoIncludeHCL) Resolve(evalCtx *hcl.EvalContext) (*AutoIncludeResolved
)

for _, block := range body.Blocks {
if block.Type != blockDependency || len(block.Labels) == 0 {
if block.Type != blockDependency {
continue
}

if len(block.Labels) != 1 {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid dependency block labels",
Detail: fmt.Sprintf("dependency block requires exactly one label, got %d", len(block.Labels)),
Subject: block.DefRange().Ptr(),
})

continue
}

Expand Down Expand Up @@ -177,11 +195,7 @@ func BuildAutoIncludeEvalContext(unitRefs, stackRefs []ComponentRef) *hcl.EvalCo
}
}

// AutoIncludeDependencyPaths reads the terragrunt.autoinclude.hcl file in
// unitDir and returns resolved dependency config_path values.
// Returns (nil, nil) if the file does not exist or has no dependencies.
// Panics when fs is nil (programmer error). Returns EmptyArgError when unitDir
// is empty so callers can distinguish bad input from a missing file.
// AutoIncludeDependencyPaths reads the autoinclude file in unitDir and returns resolved dependency config_path values. Returns EmptyArgError when unitDir is empty so callers can distinguish bad input from a missing file.
func AutoIncludeDependencyPaths(fs vfs.FS, unitDir string) ([]string, error) {
if fs == nil {
panic(fmt.Sprintf("hclparse.AutoIncludeDependencyPaths: fs is nil (unitDir=%q)", unitDir))
Expand All @@ -201,17 +215,41 @@ func AutoIncludeDependencyPaths(fs vfs.FS, unitDir string) ([]string, error) {

paths := make([]string, 0, len(body.Blocks))

var errs []error

for _, block := range body.Blocks {
if depPath, ok := extractDependencyConfigPath(block, unitDir); ok {
paths = append(paths, depPath)
if block.Type != blockDependency {
continue
}

if len(block.Labels) != 1 {
errs = append(errs, MalformedDependencyError{
FilePath: autoIncludePath,
Name: blockLabelsString(block),
Reason: fmt.Sprintf("dependency block requires exactly one label, got %d", len(block.Labels)),
})

continue
}

depPath, extractErr := extractDepPath(block, autoIncludePath, unitDir)
if extractErr != nil {
errs = append(errs, extractErr)

continue
}

paths = append(paths, depPath)
}

if len(errs) > 0 {
return nil, errors.Join(errs...)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

NIT: In situations like these, we can return the paths we were able to discover.

}

return paths, nil
}

// readAutoIncludeBody reads and parses the autoinclude file at path.
// Returns (nil, nil) when the file does not exist.
// readAutoIncludeBody reads and parses an autoinclude file, returning (nil, nil) when the file does not exist.
func readAutoIncludeBody(fs vfs.FS, path string) (*hclsyntax.Body, error) {
data, err := vfs.ReadFile(fs, path)
if errors.Is(err, iofs.ErrNotExist) {
Expand All @@ -235,34 +273,45 @@ func readAutoIncludeBody(fs vfs.FS, path string) (*hclsyntax.Body, error) {
return body, nil
}

// extractDependencyConfigPath returns the resolved absolute config_path for a
// dependency block, or ("", false) if the block is not a valid dependency or
// config_path cannot be evaluated to a string.
//
// The nil eval context passed to Expr.Value is intentional: GenerateAutoIncludeFile
// always writes config_path as a literal quoted string via writeDependencyBlock
// (see generate.go), so no variable resolution is required. If that contract is
// ever relaxed to emit interpolations, callers must pass a real eval context
// here or the dependency will be silently dropped from the DAG.
func extractDependencyConfigPath(block *hclsyntax.Block, unitDir string) (string, bool) {
if block.Type != blockDependency || len(block.Labels) == 0 {
return "", false
// blockLabelsString joins a block's labels for error messages; returns "(unlabeled)" when there are none.
func blockLabelsString(block *hclsyntax.Block) string {
if len(block.Labels) == 0 {
return "(unlabeled)"
}

return strings.Join(block.Labels, " ")
}

// extractDepPath returns the resolved config_path for a dependency block. Caller must ensure the block has exactly one label.
func extractDepPath(block *hclsyntax.Block, autoIncludePath, unitDir string) (string, error) {
name := block.Labels[0]

configPathAttr, exists := block.Body.Attributes[attrConfigPath]
if !exists {
return "", false
return "", MalformedDependencyError{FilePath: autoIncludePath, Name: name, Reason: "missing config_path attribute"}
}

val, diags := configPathAttr.Expr.Value(nil)
if diags.HasErrors() || !val.IsKnown() || val.IsNull() || val.Type() != cty.String {
return "", false
val, valDiags := configPathAttr.Expr.Value(nil)
if valDiags.HasErrors() {
return "", MalformedDependencyError{FilePath: autoIncludePath, Name: name, Reason: "config_path: " + valDiags.Error(), Err: valDiags}
}

if !val.IsKnown() {
return "", MalformedDependencyError{FilePath: autoIncludePath, Name: name, Reason: "config_path is unknown"}
}

if val.IsNull() {
return "", MalformedDependencyError{FilePath: autoIncludePath, Name: name, Reason: "config_path is null"}
}

if val.Type() != cty.String {
return "", MalformedDependencyError{FilePath: autoIncludePath, Name: name, Reason: "config_path must be a string, got " + val.Type().FriendlyName()}
}

depPath := val.AsString()
if !filepath.IsAbs(depPath) {
depPath = filepath.Clean(filepath.Join(unitDir, depPath))
}

return util.ResolvePath(depPath), true
return util.ResolvePath(depPath), nil
}
Loading
Loading