Skip to content
Merged
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
12 changes: 12 additions & 0 deletions docs/src/data/changelog/v1.0.4/exclude-include-inheritance.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
version: "v1.0.4"
category: "bug-fixes"
---

#### Fixed `exclude` block being dropped when defined only in an included parent

A unit that pulled in an `exclude` block from an include that did not declare its own `exclude` block saw the include's `exclude` configurations ignored.

Included `exclude` blocks now get properly merged into unit configurations.

Reported in [#5089](https://github.com/gruntwork-io/terragrunt/issues/5089). Thanks to [@HeikoNeblung](https://github.com/HeikoNeblung) for contributing this fix!
7 changes: 6 additions & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1448,8 +1448,13 @@ func ParseConfig(
// - Locals are deliberately not merged in so that they remain local in scope. Here, we directly set it to the
// original locals for the current config being handled, as that is the locals list that is in scope for this
// config.
// - Exclude, in contrast, is inherited from included configs. Only override the merged value when the current
// config defines its own exclude block, otherwise the parent's exclude would be clobbered with nil.
mergedConfig.Locals = config.Locals
mergedConfig.Exclude = config.Exclude

if config.Exclude != nil {
mergedConfig.Exclude = config.Exclude
}

return mergedConfig, errs.ErrorOrNil()
}
Expand Down
323 changes: 323 additions & 0 deletions pkg/config/exclude_include_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
package config_test

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Missing test for empty exclude {} block on child, it may trigger different nil/zero errors


import (
"os"
"path/filepath"
"testing"

"github.com/gruntwork-io/terragrunt/pkg/config"
"github.com/gruntwork-io/terragrunt/test/helpers/logger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Regression test for https://github.com/gruntwork-io/terragrunt/issues/5089:
// an exclude block defined in an included parent config must survive into the
// child's merged config when the child does not define its own exclude block.
func TestParseConfig_InheritsExcludeFromIncludedConfig(t *testing.T) {
t.Parallel()

tests := []struct {
name string
includeBody string
}{
{
name: "default merge",
includeBody: ``,
},
{
name: "shallow merge",
includeBody: `merge_strategy = "shallow"`,
},
{
name: "deep merge",
includeBody: `merge_strategy = "deep"`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()

parentPath := filepath.Join(tmpDir, "root.hcl")
require.NoError(t, os.WriteFile(parentPath, []byte(`
exclude {
if = true
actions = ["plan", "apply"]
no_run = true
}
`), 0644))

childDir := filepath.Join(tmpDir, "unit")
require.NoError(t, os.MkdirAll(childDir, 0755))

childPath := filepath.Join(childDir, config.DefaultTerragruntConfigPath)
require.NoError(t, os.WriteFile(childPath, []byte(`
include "root" {
path = "`+parentPath+`"
`+tt.includeBody+`
Comment on lines +59 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'path = "`\+parentPath\+`"' pkg/config/exclude_include_test.go
rg -n 'ToSlash\(parentPath\)' pkg/config/exclude_include_test.go

Repository: gruntwork-io/terragrunt

Length of output: 216


🏁 Script executed:

# View the actual test code sections with interpolations
sed -n '55,65p' pkg/config/exclude_include_test.go
sed -n '120,130p' pkg/config/exclude_include_test.go
sed -n '190,200p' pkg/config/exclude_include_test.go

# Check if filepath.ToSlash is used elsewhere in the codebase
rg 'filepath\.ToSlash' --type go
rg 'ToSlash.*path' --type go

# Check for backslash handling patterns in Go files
rg '\\\\' pkg/config/exclude_include_test.go

Repository: gruntwork-io/terragrunt

Length of output: 3450


🏁 Script executed:

# Check context around the config_helpers.go comment about filepath.ToSlash
sed -n '1,50p' pkg/config/config_helpers.go | tail -30

# Look for any Windows-specific test handling or bug reports
rg -i 'windows|backslash|escape.*path' pkg/config/exclude_include_test.go

# Check if this test file has any platform-specific build tags
head -20 pkg/config/exclude_include_test.go

# Search for HCL parsing logic to understand escape handling
rg 'hcl.*parse|hclparse' --type go -A 3 | head -40

# Check if there are any existing issues or TODOs about path escaping
rg -i 'TODO|FIXME|BUG.*path|path.*escape' pkg/config/exclude_include_test.go

Repository: gruntwork-io/terragrunt

Length of output: 4416


🏁 Script executed:

# Look at the full test context to understand what's being tested
sed -n '38,70p' pkg/config/exclude_include_test.go

# Check the hclparse package to understand how it handles paths
find . -name "hclparse*" -type f | grep -E '\.(go|mod)$'
cat pkg/config/hclparse/parser.go | head -50

# Verify how the include path is actually used in config parsing
rg -A 5 'include.*path.*parentPath' pkg/config/exclude_include_test.go | head -20

# Check if there are any Windows-specific test failures or known issues
rg -i 'windows|os\.sep' pkg/config/exclude_include_test.go

Repository: gruntwork-io/terragrunt

Length of output: 2360


🏁 Script executed:

# Check if this test file has been run on Windows or if there are any Windows-specific issues
rg -i 'windows|skip.*windows|build.*windows' pkg/config/exclude_include_test.go

# Look at how HCL2 parser handles escape sequences in strings
# Check if there's any documentation or known issues
rg -i 'escape|backslash' pkg/config/hclparse/

# Check the actual go-cty/hcl/v2 parsing behavior by looking at similar cases in the codebase
rg -B 3 -A 3 'filepath.ToSlash.*hcl|include.*path.*ToSlash' --type go | head -30

# Verify if the include path parsing actually uses the string as-is or processes it
sed -n '500,600p' pkg/config/config.go | grep -A 10 -B 10 'include.*path'

Repository: gruntwork-io/terragrunt

Length of output: 456


🏁 Script executed:

# Check if tests have Windows build tags or are skipped on Windows
head -30 pkg/config/exclude_include_test.go | grep -i "build\|skip\|windows"

# Check what the actual parsed result looks like by examining the ParseConfigFile function
rg -A 10 'func ParseConfigFile' pkg/config/ | head -30

# Look for any existing issues or PRs related to Windows path handling
rg -i 'issue.*path|github.*windows.*path' pkg/config/ | head -10

# Check if filepath.ToSlash is conditionally applied or always applied in similar contexts
rg -B 5 'filepath.ToSlash.*path' pkg/config/config_helpers.go | head -20

Repository: gruntwork-io/terragrunt

Length of output: 539


🏁 Script executed:

# Check how the include path attribute is processed after HCL parsing
rg -A 20 'include.*config.*path' pkg/config/config.go | head -40

# Look for the actual attribute parsing of include paths
rg -B 5 -A 10 'func.*parseInclude|processInclude' pkg/config/ --type go | head -50

# Check if there's any string unquoting or processing of the path value
rg 'unquote|quote.*path|path.*quote' pkg/config/ --type go

# Verify if the issue is actually triggered in practice - check for any test failures or issues
find . -name "*.md" -type f -exec grep -l "Windows.*path\|path.*Windows" {} \; | head -5

Repository: gruntwork-io/terragrunt

Length of output: 1504


🏁 Script executed:

# Check if the tests are platform-specific or have skip conditions
grep -n "skip\|build.*windows\|GOOS" pkg/config/exclude_include_test.go

# Look at how HCL2 library handles string escaping
# Check if there are comments in the code about this
rg -i 'hcl.*escape|escape.*hcl|string.*escape' pkg/config/ -A 2 | head -20

# Check if the issue has been reported in the codebase
rg 'windows.*path|path.*windows|backslash.*hcl' -i . --type md --type go | head -10

# Try to understand the actual behavior by looking at a real example
# Find if there are any error messages or tests that validate path handling
rg 'path.*escape|escape.*path' --type go | head -10

# Check if there's any documentation on how HCL2 handles paths
rg 'hashicorp/hcl' pkg/config/hclparse/ -A 5 | head -20

Repository: gruntwork-io/terragrunt

Length of output: 3277


🏁 Script executed:

# Look at the full context of the config_helpers.go comment about filepath.ToSlash and escapes
sed -n '200,250p' pkg/config/config_helpers.go

# Check the specific issue about HCL2 parsing with backslashes
# Look for how include paths are actually resolved
sed -n '1,100p' pkg/config/include.go

# Verify if the parsed include path value is used as a filesystem path or as a string
rg -A 15 'includePath :=' pkg/config/include.go | head -25

# Check if there are any test failures or issues reported about Windows
find . -name "*.go" -type f -exec grep -l 'exclude_include\|exclude.*include' {} \; | xargs grep -l windows

# Check the actual HCL2 specification or how paths are handled
rg 'path.*=.*"' pkg/config/exclude_include_test.go | head -5

Repository: gruntwork-io/terragrunt

Length of output: 8131


🏁 Script executed:

# Check if the test has any build constraints that skip it on Windows
head -40 pkg/config/exclude_include_test.go | grep -E "^//go:build|^// +build"

# Check if the tests actually run on Windows in CI
find . -name "*.yml" -o -name "*.yaml" | xargs grep -l "windows" | head -5

# Look at how the include path is actually parsed by the HCL parser
# The key is whether the string value is unquoted and processed
rg -B 10 -A 10 'DecodeAsString.*includePath|getStringAttributeValue.*include' pkg/config/ --type go | head -50

# Check the actual attribute decoding logic
rg 'DecodeAsString|getAttribute.*include' pkg/config/ -B 3 -A 3 | head -40

Repository: gruntwork-io/terragrunt

Length of output: 260


🏁 Script executed:

# Check the integration test workflow to see if Windows is tested
grep -A 20 "windows" ./.github/workflows/integration-test.yml | head -30

# Check if the exclude_include_test is included in Windows tests
grep -i "exclude_include" ./.github/workflows/*.yml

# Look at the actual include attribute parsing code
rg -B 5 -A 15 'func.*decodeInclude|func.*parseInclude' pkg/config/ --type go | head -60

# Check how string attributes are decoded from HCL
rg -B 3 -A 10 'getStringAttribute|DecodeAsString' pkg/config/include.go | head -40

Repository: gruntwork-io/terragrunt

Length of output: 2656


🏁 Script executed:

# Check if the exclude_include_test matches the Windows test pattern in CI
grep -E "TestParseConfig.*Exclude|TestParseConfig.*Include" pkg/config/exclude_include_test.go | head -5

# Check what the Windows test pattern actually matches
grep -B 5 -A 5 "TestWindows" .github/workflows/integration-test.yml | head -20

# Look for any HCL string escape handling or unquoting logic
rg 'unquote|strconv.*Unquote|cty.*String' pkg/config/hclparse/ --type go -B 2 -A 5 | head -50

# Check if there's any decoding of HCL attributes that handles path values
rg -A 20 'func.*decodeIncludeAttributes|func.*decodeIncludeBlock' pkg/config/ --type go | head -60

Repository: gruntwork-io/terragrunt

Length of output: 820


🏁 Script executed:

# The key test functions don't start with "TestWindows", so they don't run on Windows CI
# Let's verify this by checking what pattern Windows tests actually use
grep -i "TestWindows\|windows" pkg/config/exclude_include_test.go

# Check if there's a general parsing test or config test that runs on Windows
find pkg/config -name "*test.go" -type f -exec grep -l "TestWindows" {} \;

# Now let's understand how HCL2 actually handles quoted strings with backslashes
# by looking at real usage patterns in the codebase
rg -B 3 -A 3 'include.*path.*=' pkg/config/config.go | head -30

# Check if the include path string is actually unquoted by the HCL parser
# Look for how cty values are converted to strings
rg 'asString|cty.*String|gocty.*FromCtyValue' pkg/config/ --type go | head -20

Repository: gruntwork-io/terragrunt

Length of output: 1920


🏁 Script executed:

# Find where the include block's path attribute is actually decoded
rg -B 5 -A 15 'decodeIncludeBlock\|parseIncludeAttribute' pkg/config/ --type go | head -80

# Look for the actual function that extracts the path value from HCL attributes
rg 'func.*Include.*decode\|func.*decodeInclude' pkg/config/ --type go -A 20 | head -60

# Check how attributes are extracted from HCL in this file
rg -B 2 -A 10 'includeBlock\|include.*block' pkg/config/include.go --type go | head -50

Repository: gruntwork-io/terragrunt

Length of output: 49


🏁 Script executed:

# Find the actual include block parsing in config.go
sed -n '1,200p' pkg/config/config.go | grep -A 30 -B 5 'include'

# Look specifically for how include attributes are decoded
rg -B 10 -A 20 'func decodeIncludeAttribute\|decodeIncludeBlock\|parseIncludeBlock' pkg/config/ --type go

# Check the actual include.go file for attribute parsing
head -200 pkg/config/include.go | tail -100

# Search for where the path attribute value is actually extracted and used
rg 'includedConfig.Path\s*=' pkg/config/ --type go -B 3 -A 3 | head -40

Repository: gruntwork-io/terragrunt

Length of output: 6388


🏁 Script executed:

# Look for where the include attributes are actually decoded from HCL
rg -B 5 -A 20 'gohcl.*Decode\|DecodeBody.*include' pkg/config/ --type go | head -80

# Check the IncludeConfig struct definition
rg -B 3 -A 10 'type IncludeConfig struct' pkg/config/ --type go

# Look for how the path attribute is decoded
rg -B 5 -A 15 'Path.*string\|ConfigPath.*string' pkg/config/ --type go | grep -A 10 -B 5 "IncludeConfig\|Include struct"

# Check the actual attribute decoding in config.go
sed -n '600,900p' pkg/config/config.go | grep -B 10 -A 10 -i 'include\|path'

Repository: gruntwork-io/terragrunt

Length of output: 7710


🏁 Script executed:

# Look for where IncludeConfig is actually decoded from HCL
rg -B 10 -A 10 'gohcl.*Decode.*include\|Decode.*terragruntInclude' pkg/config/ --type go | head -60

# Check if there's any code that handles the include blocks decoding
rg -B 5 -A 15 'decodeLocalsAndInclude\|parseInclude' pkg/config/ --type go | head -100

# Look at the actual HCL2 gohcl package usage
rg 'gohcl.DecodeBody' pkg/config/ --type go -B 3 -A 5

Repository: gruntwork-io/terragrunt

Length of output: 479


Escape interpolated include paths for Windows-safe HCL fixtures.

These fixtures embed parentPath directly into quoted HCL strings. On Windows, filepath.Join produces backslashes, which HCL2's string parser interprets as escape sequences, causing path mangling or parse errors. The pattern of using filepath.ToSlash() for similar path-in-HCL scenarios is established throughout the codebase.

💡 Proposed fix (apply to each test block)
 parentPath := filepath.Join(tmpDir, "root.hcl")
+parentPathHCL := filepath.ToSlash(parentPath)
 require.NoError(t, os.WriteFile(parentPath, []byte(`
 exclude {
   if      = true
@@
 include "root" {
-  path = "`+parentPath+`"
+  path = "`+parentPathHCL+`"
   `+tt.includeBody+`
 }
 `), 0644))

Applies to lines: 59-60, 125-126, 195-196, 250-251, 304-305

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/config/exclude_include_test.go` around lines 59 - 60, Test fixtures embed
parentPath into quoted HCL via expressions like "path = \"`+parentPath+`\""
which breaks on Windows because backslashes are treated as escapes; update each
test block that interpolates parentPath (e.g., where parentPath and
tt.includeBody are concatenated into HCL fixtures) to call
filepath.ToSlash(parentPath) before embedding so the HCL strings use forward
slashes; ensure all occurrences mentioned in the comment (the five test blocks)
are updated consistently.

}
`), 0644))

ctx, pctx := newTestParsingContext(t, childPath)

l := logger.CreateLogger()

parsed, err := config.ParseConfigFile(ctx, pctx, l, childPath, nil)
require.NoError(t, err)
require.NotNil(t, parsed)

require.NotNil(t, parsed.Exclude, "expected exclude block to be inherited from included parent")
assert.True(t, parsed.Exclude.If)
assert.Equal(t, []string{"plan", "apply"}, parsed.Exclude.Actions)
require.NotNil(t, parsed.Exclude.NoRun)
assert.True(t, *parsed.Exclude.NoRun)
})
}
}

// Child-defined exclude blocks must still take precedence over the included
// parent's exclude block, regardless of merge strategy.
func TestParseConfig_ChildExcludeOverridesIncludedConfig(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Makes sense to add a test for child with exclude { if = false }

t.Parallel()

tests := []struct {
name string
includeBody string
}{
{
name: "default merge",
includeBody: ``,
},
{
name: "shallow merge",
includeBody: `merge_strategy = "shallow"`,
},
{
name: "deep merge",
includeBody: `merge_strategy = "deep"`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()

parentPath := filepath.Join(tmpDir, "root.hcl")
require.NoError(t, os.WriteFile(parentPath, []byte(`
exclude {
if = true
actions = ["plan"]
no_run = false
}
`), 0644))

childDir := filepath.Join(tmpDir, "unit")
require.NoError(t, os.MkdirAll(childDir, 0755))

childPath := filepath.Join(childDir, config.DefaultTerragruntConfigPath)
require.NoError(t, os.WriteFile(childPath, []byte(`
include "root" {
path = "`+parentPath+`"
`+tt.includeBody+`
}

exclude {
if = true
actions = ["destroy"]
no_run = true
}
`), 0644))

ctx, pctx := newTestParsingContext(t, childPath)

l := logger.CreateLogger()

parsed, err := config.ParseConfigFile(ctx, pctx, l, childPath, nil)
require.NoError(t, err)
require.NotNil(t, parsed)

require.NotNil(t, parsed.Exclude)
assert.Equal(t, []string{"destroy"}, parsed.Exclude.Actions)
require.NotNil(t, parsed.Exclude.NoRun)
assert.True(t, *parsed.Exclude.NoRun)
})
}
}

// Regression coverage for sibling top-level blocks that flow through the same
// include-merge code path as exclude. These are not affected by the 5089 bug,
// but pinning the inheritance behavior keeps a future post-merge override from
// silently regressing them the way it did for exclude.
//
// See pkg/config/include.go's Merge / DeepMerge for the sites these tests
// exercise.

func TestParseConfig_InheritsErrorsFromIncludedConfig(t *testing.T) {
t.Parallel()

tests := []struct {
name string
includeBody string
}{
{name: "default merge", includeBody: ``},
{name: "shallow merge", includeBody: `merge_strategy = "shallow"`},
{name: "deep merge", includeBody: `merge_strategy = "deep"`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()

parentPath := filepath.Join(tmpDir, "root.hcl")
require.NoError(t, os.WriteFile(parentPath, []byte(`
errors {
retry "transient" {
retryable_errors = ["(?s).*timeout.*"]
max_attempts = 3
sleep_interval_sec = 5
}
}
`), 0644))

childDir := filepath.Join(tmpDir, "unit")
require.NoError(t, os.MkdirAll(childDir, 0755))

childPath := filepath.Join(childDir, config.DefaultTerragruntConfigPath)
require.NoError(t, os.WriteFile(childPath, []byte(`
include "root" {
path = "`+parentPath+`"
`+tt.includeBody+`
}
`), 0644))

ctx, pctx := newTestParsingContext(t, childPath)

l := logger.CreateLogger()

parsed, err := config.ParseConfigFile(ctx, pctx, l, childPath, nil)
require.NoError(t, err)
require.NotNil(t, parsed)

require.NotNil(t, parsed.Errors, "expected errors block to be inherited from included parent")
require.Len(t, parsed.Errors.Retry, 1)
assert.Equal(t, "transient", parsed.Errors.Retry[0].Label)
assert.Equal(t, 3, parsed.Errors.Retry[0].MaxAttempts)
assert.Equal(t, 5, parsed.Errors.Retry[0].SleepIntervalSec)
})
}
}

func TestParseConfig_InheritsEngineFromIncludedConfig(t *testing.T) {
t.Parallel()

tests := []struct {
name string
includeBody string
}{
{name: "default merge", includeBody: ``},
{name: "shallow merge", includeBody: `merge_strategy = "shallow"`},
{name: "deep merge", includeBody: `merge_strategy = "deep"`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()

parentPath := filepath.Join(tmpDir, "root.hcl")
require.NoError(t, os.WriteFile(parentPath, []byte(`
engine {
source = "engine-from-parent"
version = "1.2.3"
type = "rpc"
}
`), 0644))

childDir := filepath.Join(tmpDir, "unit")
require.NoError(t, os.MkdirAll(childDir, 0755))

childPath := filepath.Join(childDir, config.DefaultTerragruntConfigPath)
require.NoError(t, os.WriteFile(childPath, []byte(`
include "root" {
path = "`+parentPath+`"
`+tt.includeBody+`
}
`), 0644))

ctx, pctx := newTestParsingContext(t, childPath)

l := logger.CreateLogger()

parsed, err := config.ParseConfigFile(ctx, pctx, l, childPath, nil)
require.NoError(t, err)
require.NotNil(t, parsed)

require.NotNil(t, parsed.Engine, "expected engine block to be inherited from included parent")
assert.Equal(t, "engine-from-parent", parsed.Engine.Source)
require.NotNil(t, parsed.Engine.Version)
assert.Equal(t, "1.2.3", *parsed.Engine.Version)
require.NotNil(t, parsed.Engine.Type)
assert.Equal(t, "rpc", *parsed.Engine.Type)
})
}
}

func TestParseConfig_InheritsFeatureFlagsFromIncludedConfig(t *testing.T) {
t.Parallel()

tests := []struct {
name string
includeBody string
}{
{name: "default merge", includeBody: ``},
{name: "shallow merge", includeBody: `merge_strategy = "shallow"`},
{name: "deep merge", includeBody: `merge_strategy = "deep"`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()

parentPath := filepath.Join(tmpDir, "root.hcl")
require.NoError(t, os.WriteFile(parentPath, []byte(`
feature "from_parent" {
default = "parent_value"
}
`), 0644))

childDir := filepath.Join(tmpDir, "unit")
require.NoError(t, os.MkdirAll(childDir, 0755))

childPath := filepath.Join(childDir, config.DefaultTerragruntConfigPath)
require.NoError(t, os.WriteFile(childPath, []byte(`
include "root" {
path = "`+parentPath+`"
`+tt.includeBody+`
}
`), 0644))

ctx, pctx := newTestParsingContext(t, childPath)

l := logger.CreateLogger()

parsed, err := config.ParseConfigFile(ctx, pctx, l, childPath, nil)
require.NoError(t, err)
require.NotNil(t, parsed)

require.Len(t, parsed.FeatureFlags, 1, "expected feature flag to be inherited from included parent")
assert.Equal(t, "from_parent", parsed.FeatureFlags[0].Name)
require.NotNil(t, parsed.FeatureFlags[0].Default)
assert.Equal(t, "parent_value", parsed.FeatureFlags[0].Default.AsString())
})
}
}
Loading