Skip to content

Commit d699435

Browse files
yhakbarHeikoNeblung
authored andcommitted
* fix(config): prevent overwriting Exclude when not set in current config (gruntwork-io#5089) * fix: Adding some testing that wasn't addressed in gruntwork-io#5232 * fix: Increasing coverage of other included blocks * docs: Documenting exclude fix in changelog --------- Co-authored-by: HeikoNeblung <Heiko.Neblung@telekom.de>
1 parent fc5e150 commit d699435

3 files changed

Lines changed: 341 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
version: "v1.0.4"
3+
category: "bug-fixes"
4+
---
5+
6+
#### Fixed `exclude` block being dropped when defined only in an included parent
7+
8+
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.
9+
10+
Included `exclude` blocks now get properly merged into unit configurations.
11+
12+
Reported in [#5089](https://github.com/gruntwork-io/terragrunt/issues/5089). Thanks to [@HeikoNeblung](https://github.com/HeikoNeblung) for contributing this fix!

pkg/config/config.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1448,8 +1448,13 @@ func ParseConfig(
14481448
// - Locals are deliberately not merged in so that they remain local in scope. Here, we directly set it to the
14491449
// original locals for the current config being handled, as that is the locals list that is in scope for this
14501450
// config.
1451+
// - Exclude, in contrast, is inherited from included configs. Only override the merged value when the current
1452+
// config defines its own exclude block, otherwise the parent's exclude would be clobbered with nil.
14511453
mergedConfig.Locals = config.Locals
1452-
mergedConfig.Exclude = config.Exclude
1454+
1455+
if config.Exclude != nil {
1456+
mergedConfig.Exclude = config.Exclude
1457+
}
14531458

14541459
return mergedConfig, errs.ErrorOrNil()
14551460
}

pkg/config/exclude_include_test.go

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
package config_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/gruntwork-io/terragrunt/pkg/config"
9+
"github.com/gruntwork-io/terragrunt/test/helpers/logger"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// Regression test for https://github.com/gruntwork-io/terragrunt/issues/5089:
15+
// an exclude block defined in an included parent config must survive into the
16+
// child's merged config when the child does not define its own exclude block.
17+
func TestParseConfig_InheritsExcludeFromIncludedConfig(t *testing.T) {
18+
t.Parallel()
19+
20+
tests := []struct {
21+
name string
22+
includeBody string
23+
}{
24+
{
25+
name: "default merge",
26+
includeBody: ``,
27+
},
28+
{
29+
name: "shallow merge",
30+
includeBody: `merge_strategy = "shallow"`,
31+
},
32+
{
33+
name: "deep merge",
34+
includeBody: `merge_strategy = "deep"`,
35+
},
36+
}
37+
38+
for _, tt := range tests {
39+
t.Run(tt.name, func(t *testing.T) {
40+
t.Parallel()
41+
42+
tmpDir := t.TempDir()
43+
44+
parentPath := filepath.Join(tmpDir, "root.hcl")
45+
require.NoError(t, os.WriteFile(parentPath, []byte(`
46+
exclude {
47+
if = true
48+
actions = ["plan", "apply"]
49+
no_run = true
50+
}
51+
`), 0644))
52+
53+
childDir := filepath.Join(tmpDir, "unit")
54+
require.NoError(t, os.MkdirAll(childDir, 0755))
55+
56+
childPath := filepath.Join(childDir, config.DefaultTerragruntConfigPath)
57+
require.NoError(t, os.WriteFile(childPath, []byte(`
58+
include "root" {
59+
path = "`+parentPath+`"
60+
`+tt.includeBody+`
61+
}
62+
`), 0644))
63+
64+
ctx, pctx := newTestParsingContext(t, childPath)
65+
66+
l := logger.CreateLogger()
67+
68+
parsed, err := config.ParseConfigFile(ctx, pctx, l, childPath, nil)
69+
require.NoError(t, err)
70+
require.NotNil(t, parsed)
71+
72+
require.NotNil(t, parsed.Exclude, "expected exclude block to be inherited from included parent")
73+
assert.True(t, parsed.Exclude.If)
74+
assert.Equal(t, []string{"plan", "apply"}, parsed.Exclude.Actions)
75+
require.NotNil(t, parsed.Exclude.NoRun)
76+
assert.True(t, *parsed.Exclude.NoRun)
77+
})
78+
}
79+
}
80+
81+
// Child-defined exclude blocks must still take precedence over the included
82+
// parent's exclude block, regardless of merge strategy.
83+
func TestParseConfig_ChildExcludeOverridesIncludedConfig(t *testing.T) {
84+
t.Parallel()
85+
86+
tests := []struct {
87+
name string
88+
includeBody string
89+
}{
90+
{
91+
name: "default merge",
92+
includeBody: ``,
93+
},
94+
{
95+
name: "shallow merge",
96+
includeBody: `merge_strategy = "shallow"`,
97+
},
98+
{
99+
name: "deep merge",
100+
includeBody: `merge_strategy = "deep"`,
101+
},
102+
}
103+
104+
for _, tt := range tests {
105+
t.Run(tt.name, func(t *testing.T) {
106+
t.Parallel()
107+
108+
tmpDir := t.TempDir()
109+
110+
parentPath := filepath.Join(tmpDir, "root.hcl")
111+
require.NoError(t, os.WriteFile(parentPath, []byte(`
112+
exclude {
113+
if = true
114+
actions = ["plan"]
115+
no_run = false
116+
}
117+
`), 0644))
118+
119+
childDir := filepath.Join(tmpDir, "unit")
120+
require.NoError(t, os.MkdirAll(childDir, 0755))
121+
122+
childPath := filepath.Join(childDir, config.DefaultTerragruntConfigPath)
123+
require.NoError(t, os.WriteFile(childPath, []byte(`
124+
include "root" {
125+
path = "`+parentPath+`"
126+
`+tt.includeBody+`
127+
}
128+
129+
exclude {
130+
if = true
131+
actions = ["destroy"]
132+
no_run = true
133+
}
134+
`), 0644))
135+
136+
ctx, pctx := newTestParsingContext(t, childPath)
137+
138+
l := logger.CreateLogger()
139+
140+
parsed, err := config.ParseConfigFile(ctx, pctx, l, childPath, nil)
141+
require.NoError(t, err)
142+
require.NotNil(t, parsed)
143+
144+
require.NotNil(t, parsed.Exclude)
145+
assert.Equal(t, []string{"destroy"}, parsed.Exclude.Actions)
146+
require.NotNil(t, parsed.Exclude.NoRun)
147+
assert.True(t, *parsed.Exclude.NoRun)
148+
})
149+
}
150+
}
151+
152+
// Regression coverage for sibling top-level blocks that flow through the same
153+
// include-merge code path as exclude. These are not affected by the 5089 bug,
154+
// but pinning the inheritance behavior keeps a future post-merge override from
155+
// silently regressing them the way it did for exclude.
156+
//
157+
// See pkg/config/include.go's Merge / DeepMerge for the sites these tests
158+
// exercise.
159+
160+
func TestParseConfig_InheritsErrorsFromIncludedConfig(t *testing.T) {
161+
t.Parallel()
162+
163+
tests := []struct {
164+
name string
165+
includeBody string
166+
}{
167+
{name: "default merge", includeBody: ``},
168+
{name: "shallow merge", includeBody: `merge_strategy = "shallow"`},
169+
{name: "deep merge", includeBody: `merge_strategy = "deep"`},
170+
}
171+
172+
for _, tt := range tests {
173+
t.Run(tt.name, func(t *testing.T) {
174+
t.Parallel()
175+
176+
tmpDir := t.TempDir()
177+
178+
parentPath := filepath.Join(tmpDir, "root.hcl")
179+
require.NoError(t, os.WriteFile(parentPath, []byte(`
180+
errors {
181+
retry "transient" {
182+
retryable_errors = ["(?s).*timeout.*"]
183+
max_attempts = 3
184+
sleep_interval_sec = 5
185+
}
186+
}
187+
`), 0644))
188+
189+
childDir := filepath.Join(tmpDir, "unit")
190+
require.NoError(t, os.MkdirAll(childDir, 0755))
191+
192+
childPath := filepath.Join(childDir, config.DefaultTerragruntConfigPath)
193+
require.NoError(t, os.WriteFile(childPath, []byte(`
194+
include "root" {
195+
path = "`+parentPath+`"
196+
`+tt.includeBody+`
197+
}
198+
`), 0644))
199+
200+
ctx, pctx := newTestParsingContext(t, childPath)
201+
202+
l := logger.CreateLogger()
203+
204+
parsed, err := config.ParseConfigFile(ctx, pctx, l, childPath, nil)
205+
require.NoError(t, err)
206+
require.NotNil(t, parsed)
207+
208+
require.NotNil(t, parsed.Errors, "expected errors block to be inherited from included parent")
209+
require.Len(t, parsed.Errors.Retry, 1)
210+
assert.Equal(t, "transient", parsed.Errors.Retry[0].Label)
211+
assert.Equal(t, 3, parsed.Errors.Retry[0].MaxAttempts)
212+
assert.Equal(t, 5, parsed.Errors.Retry[0].SleepIntervalSec)
213+
})
214+
}
215+
}
216+
217+
func TestParseConfig_InheritsEngineFromIncludedConfig(t *testing.T) {
218+
t.Parallel()
219+
220+
tests := []struct {
221+
name string
222+
includeBody string
223+
}{
224+
{name: "default merge", includeBody: ``},
225+
{name: "shallow merge", includeBody: `merge_strategy = "shallow"`},
226+
{name: "deep merge", includeBody: `merge_strategy = "deep"`},
227+
}
228+
229+
for _, tt := range tests {
230+
t.Run(tt.name, func(t *testing.T) {
231+
t.Parallel()
232+
233+
tmpDir := t.TempDir()
234+
235+
parentPath := filepath.Join(tmpDir, "root.hcl")
236+
require.NoError(t, os.WriteFile(parentPath, []byte(`
237+
engine {
238+
source = "engine-from-parent"
239+
version = "1.2.3"
240+
type = "rpc"
241+
}
242+
`), 0644))
243+
244+
childDir := filepath.Join(tmpDir, "unit")
245+
require.NoError(t, os.MkdirAll(childDir, 0755))
246+
247+
childPath := filepath.Join(childDir, config.DefaultTerragruntConfigPath)
248+
require.NoError(t, os.WriteFile(childPath, []byte(`
249+
include "root" {
250+
path = "`+parentPath+`"
251+
`+tt.includeBody+`
252+
}
253+
`), 0644))
254+
255+
ctx, pctx := newTestParsingContext(t, childPath)
256+
257+
l := logger.CreateLogger()
258+
259+
parsed, err := config.ParseConfigFile(ctx, pctx, l, childPath, nil)
260+
require.NoError(t, err)
261+
require.NotNil(t, parsed)
262+
263+
require.NotNil(t, parsed.Engine, "expected engine block to be inherited from included parent")
264+
assert.Equal(t, "engine-from-parent", parsed.Engine.Source)
265+
require.NotNil(t, parsed.Engine.Version)
266+
assert.Equal(t, "1.2.3", *parsed.Engine.Version)
267+
require.NotNil(t, parsed.Engine.Type)
268+
assert.Equal(t, "rpc", *parsed.Engine.Type)
269+
})
270+
}
271+
}
272+
273+
func TestParseConfig_InheritsFeatureFlagsFromIncludedConfig(t *testing.T) {
274+
t.Parallel()
275+
276+
tests := []struct {
277+
name string
278+
includeBody string
279+
}{
280+
{name: "default merge", includeBody: ``},
281+
{name: "shallow merge", includeBody: `merge_strategy = "shallow"`},
282+
{name: "deep merge", includeBody: `merge_strategy = "deep"`},
283+
}
284+
285+
for _, tt := range tests {
286+
t.Run(tt.name, func(t *testing.T) {
287+
t.Parallel()
288+
289+
tmpDir := t.TempDir()
290+
291+
parentPath := filepath.Join(tmpDir, "root.hcl")
292+
require.NoError(t, os.WriteFile(parentPath, []byte(`
293+
feature "from_parent" {
294+
default = "parent_value"
295+
}
296+
`), 0644))
297+
298+
childDir := filepath.Join(tmpDir, "unit")
299+
require.NoError(t, os.MkdirAll(childDir, 0755))
300+
301+
childPath := filepath.Join(childDir, config.DefaultTerragruntConfigPath)
302+
require.NoError(t, os.WriteFile(childPath, []byte(`
303+
include "root" {
304+
path = "`+parentPath+`"
305+
`+tt.includeBody+`
306+
}
307+
`), 0644))
308+
309+
ctx, pctx := newTestParsingContext(t, childPath)
310+
311+
l := logger.CreateLogger()
312+
313+
parsed, err := config.ParseConfigFile(ctx, pctx, l, childPath, nil)
314+
require.NoError(t, err)
315+
require.NotNil(t, parsed)
316+
317+
require.Len(t, parsed.FeatureFlags, 1, "expected feature flag to be inherited from included parent")
318+
assert.Equal(t, "from_parent", parsed.FeatureFlags[0].Name)
319+
require.NotNil(t, parsed.FeatureFlags[0].Default)
320+
assert.Equal(t, "parent_value", parsed.FeatureFlags[0].Default.AsString())
321+
})
322+
}
323+
}

0 commit comments

Comments
 (0)