Skip to content

Commit d5a8a0c

Browse files
committed
chore: autoinclude config path fixes
1 parent dbfe380 commit d5a8a0c

6 files changed

Lines changed: 339 additions & 4 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
version: "v1.1.4"
3+
category: "bug-fixes"
4+
---
5+
6+
#### Autoinclude overrides `config_path` set to a `values.*` attribute
7+
8+
When a unit source declared `config_path = values.vpc_path` in a `dependency` block and the stack file's `autoinclude` replaced that dependency with a different `config_path`, Terragrunt raised an "Unsupported attribute" error because it tried to evaluate `values.vpc_path` before applying the autoinclude override. The `values` block no longer needed `vpc_path` since the autoinclude provided the path, but the evaluation order meant the error surfaced anyway.
9+
10+
Terragrunt now detects dependency blocks overridden by a sibling autoinclude and bypasses their initial decode error, allowing the autoinclude replacement to resolve dependencies cleanly.

pkg/config/autoinclude_test.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/gruntwork-io/terragrunt/test/helpers/venvtest"
1616
"github.com/stretchr/testify/assert"
1717
"github.com/stretchr/testify/require"
18+
"github.com/zclconf/go-cty/cty"
1819
)
1920

2021
// tfInitCommand is the terraform command these autoinclude tests resolve dependency mock outputs against.
@@ -1404,3 +1405,195 @@ inputs = {
14041405
assert.Equal(t, "from-autoinclude", parsed.Inputs["vpc_id"], "autoinclude must win for vpc_id")
14051406
assert.Equal(t, "10.0.0.0/16", parsed.Inputs["cidr"], "original values.cidr must survive")
14061407
}
1408+
1409+
// When a unit source references values.vpc_path in a dependency config_path and the autoinclude overrides that entire dependency, the parse must succeed even though values.vpc_path is absent.
1410+
func TestFoldSiblingAutoIncludeDeps_OverridesConfigPathFromValues(t *testing.T) {
1411+
t.Parallel()
1412+
1413+
tmpDir := t.TempDir()
1414+
cfgPath := filepath.Join(tmpDir, config.DefaultTerragruntConfigPath)
1415+
1416+
// Dependency target referenced by the autoinclude override.
1417+
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "vpc"), 0755))
1418+
require.NoError(t, os.WriteFile(
1419+
filepath.Join(tmpDir, "vpc", config.DefaultTerragruntConfigPath),
1420+
[]byte(``), 0644,
1421+
))
1422+
1423+
const marker = "autoinclude-overrides-values-config-path"
1424+
1425+
// Unit references values.vpc_path, which does NOT exist in the values.
1426+
require.NoError(t, os.WriteFile(cfgPath, []byte(`
1427+
dependency "vpc" {
1428+
config_path = values.vpc_path
1429+
skip_outputs = true
1430+
mock_outputs = {
1431+
id = "from-unit"
1432+
}
1433+
mock_outputs_allowed_terraform_commands = ["init"]
1434+
}
1435+
1436+
inputs = {
1437+
vpc_id = dependency.vpc.outputs.id
1438+
}
1439+
`), 0644))
1440+
1441+
// Autoinclude overrides the dependency with a valid config_path.
1442+
autoIncludePath := filepath.Join(tmpDir, config.DefaultAutoIncludeFile)
1443+
require.NoError(t, os.WriteFile(autoIncludePath, []byte(`
1444+
dependency "vpc" {
1445+
config_path = "./vpc"
1446+
skip_outputs = true
1447+
mock_outputs = {
1448+
id = "`+marker+`"
1449+
}
1450+
mock_outputs_allowed_terraform_commands = ["init"]
1451+
}
1452+
`), 0644))
1453+
1454+
// Values that intentionally omit vpc_path.
1455+
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "terragrunt.values.hcl"), []byte(`
1456+
region = "us-east-1"
1457+
`), 0644))
1458+
1459+
ctx, pctx := newTestParsingContext(t, venvtest.NewOSWithEmptyEnv(), cfgPath)
1460+
pctx.Experiments.EnableExperiment(experiment.StackDependencies)
1461+
pctx.OriginalTerraformCommand = tfInitCommand
1462+
1463+
values := cty.ObjectVal(map[string]cty.Value{
1464+
"region": cty.StringVal("us-east-1"),
1465+
})
1466+
pctx.Values = &values
1467+
1468+
l := logger.CreateLogger()
1469+
1470+
parsed, err := config.ParseConfigFile(ctx, pctx, l, cfgPath, nil)
1471+
require.NoError(t, err, "autoinclude dependency override must suppress the unresolvable values.vpc_path")
1472+
require.NotNil(t, parsed)
1473+
assert.Equal(t, marker, parsed.Inputs["vpc_id"], "autoinclude's mock output must win")
1474+
}
1475+
1476+
// A unit with two deps—one overridden by autoinclude and one valid—must resolve both.
1477+
func TestFoldSiblingAutoIncludeDeps_OverridesConfigPathMixedDeps(t *testing.T) {
1478+
t.Parallel()
1479+
1480+
tmpDir := t.TempDir()
1481+
cfgPath := filepath.Join(tmpDir, config.DefaultTerragruntConfigPath)
1482+
1483+
for _, sub := range []string{"vpc", "db"} {
1484+
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, sub), 0755))
1485+
require.NoError(t, os.WriteFile(
1486+
filepath.Join(tmpDir, sub, config.DefaultTerragruntConfigPath),
1487+
[]byte(``), 0644,
1488+
))
1489+
}
1490+
1491+
// "vpc" references an absent values.vpc_path; "db" has a valid literal path.
1492+
require.NoError(t, os.WriteFile(cfgPath, []byte(`
1493+
dependency "vpc" {
1494+
config_path = values.vpc_path
1495+
skip_outputs = true
1496+
mock_outputs = {
1497+
id = "unit-vpc"
1498+
}
1499+
mock_outputs_allowed_terraform_commands = ["init"]
1500+
}
1501+
1502+
dependency "db" {
1503+
config_path = "./db"
1504+
skip_outputs = true
1505+
mock_outputs = {
1506+
id = "unit-db"
1507+
}
1508+
mock_outputs_allowed_terraform_commands = ["init"]
1509+
}
1510+
1511+
inputs = {
1512+
vpc_id = dependency.vpc.outputs.id
1513+
db_id = dependency.db.outputs.id
1514+
}
1515+
`), 0644))
1516+
1517+
// Autoinclude overrides only "vpc".
1518+
autoIncludePath := filepath.Join(tmpDir, config.DefaultAutoIncludeFile)
1519+
require.NoError(t, os.WriteFile(autoIncludePath, []byte(`
1520+
dependency "vpc" {
1521+
config_path = "./vpc"
1522+
skip_outputs = true
1523+
mock_outputs = {
1524+
id = "autoinclude-vpc"
1525+
}
1526+
mock_outputs_allowed_terraform_commands = ["init"]
1527+
}
1528+
`), 0644))
1529+
1530+
ctx, pctx := newTestParsingContext(t, venvtest.NewOSWithEmptyEnv(), cfgPath)
1531+
pctx.Experiments.EnableExperiment(experiment.StackDependencies)
1532+
pctx.OriginalTerraformCommand = tfInitCommand
1533+
1534+
values := cty.ObjectVal(map[string]cty.Value{
1535+
"region": cty.StringVal("us-east-1"),
1536+
})
1537+
pctx.Values = &values
1538+
1539+
l := logger.CreateLogger()
1540+
1541+
parsed, err := config.ParseConfigFile(ctx, pctx, l, cfgPath, nil)
1542+
require.NoError(t, err, "mixed deps must resolve when autoinclude overrides the unresolvable one")
1543+
require.NotNil(t, parsed)
1544+
assert.Equal(t, "autoinclude-vpc", parsed.Inputs["vpc_id"], "autoinclude must win for vpc")
1545+
assert.Equal(t, "unit-db", parsed.Inputs["db_id"], "unit's own db dep must survive")
1546+
}
1547+
1548+
// When an autoinclude overrides a different dep than the one that fails, the parse must still fail.
1549+
func TestFoldSiblingAutoIncludeDeps_NonOverriddenFailureStillErrors(t *testing.T) {
1550+
t.Parallel()
1551+
1552+
tmpDir := t.TempDir()
1553+
cfgPath := filepath.Join(tmpDir, config.DefaultTerragruntConfigPath)
1554+
1555+
require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "other"), 0755))
1556+
require.NoError(t, os.WriteFile(
1557+
filepath.Join(tmpDir, "other", config.DefaultTerragruntConfigPath),
1558+
[]byte(``), 0644,
1559+
))
1560+
1561+
// "vpc" references an absent values.vpc_path; no autoinclude overrides it.
1562+
require.NoError(t, os.WriteFile(cfgPath, []byte(`
1563+
dependency "vpc" {
1564+
config_path = values.vpc_path
1565+
skip_outputs = true
1566+
mock_outputs = {
1567+
id = "unit-vpc"
1568+
}
1569+
mock_outputs_allowed_terraform_commands = ["init"]
1570+
}
1571+
`), 0644))
1572+
1573+
// Autoinclude overrides only "other", not "vpc".
1574+
autoIncludePath := filepath.Join(tmpDir, config.DefaultAutoIncludeFile)
1575+
require.NoError(t, os.WriteFile(autoIncludePath, []byte(`
1576+
dependency "other" {
1577+
config_path = "./other"
1578+
skip_outputs = true
1579+
mock_outputs = {
1580+
id = "autoinclude-other"
1581+
}
1582+
mock_outputs_allowed_terraform_commands = ["init"]
1583+
}
1584+
`), 0644))
1585+
1586+
ctx, pctx := newTestParsingContext(t, venvtest.NewOSWithEmptyEnv(), cfgPath)
1587+
pctx.Experiments.EnableExperiment(experiment.StackDependencies)
1588+
pctx.OriginalTerraformCommand = tfInitCommand
1589+
1590+
values := cty.ObjectVal(map[string]cty.Value{
1591+
"region": cty.StringVal("us-east-1"),
1592+
})
1593+
pctx.Values = &values
1594+
1595+
l := logger.CreateLogger()
1596+
1597+
_, err := config.ParseConfigFile(ctx, pctx, l, cfgPath, nil)
1598+
require.Error(t, err, "a non-overridden dep with unresolvable config_path must still fail")
1599+
}

pkg/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1838,6 +1838,16 @@ func decodeAsTerragruntConfigFile(
18381838
}
18391839

18401840
dependencies, err := decodeDependencyBlocks(file, evalContext, pctx.Experiments)
1841+
if err != nil && hasSiblingAutoInclude(pctx) {
1842+
overrides := siblingAutoIncludeDepNames(pctx)
1843+
if len(overrides) > 0 {
1844+
dependencies, err = decodeDependencyBlocks(
1845+
file, evalContext, pctx.Experiments,
1846+
hclparse.WithSkipLabelsOnError(overrides),
1847+
)
1848+
}
1849+
}
1850+
18411851
if err != nil {
18421852
return &terragruntConfig, err
18431853
}

pkg/config/dependency.go

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929

3030
"github.com/gruntwork-io/terragrunt/internal/getter"
3131
"github.com/hashicorp/hcl/v2"
32+
"github.com/hashicorp/hcl/v2/hclsyntax"
3233
"github.com/zclconf/go-cty/cty"
3334
"github.com/zclconf/go-cty/cty/gocty"
3435
ctyjson "github.com/zclconf/go-cty/cty/json"
@@ -263,8 +264,9 @@ func decodeDependencyBlocks(
263264
file *hclparse.File,
264265
evalContext *hcl.EvalContext,
265266
experiments experiment.Experiments,
267+
opts ...hclparse.ExpandOption,
266268
) (Dependencies, error) {
267-
instances, err := file.ExpandBlocks(MetadataDependency, &Dependency{}, evalContext)
269+
instances, err := file.ExpandBlocks(MetadataDependency, &Dependency{}, evalContext, opts...)
268270
if err != nil {
269271
return nil, err
270272
}
@@ -313,8 +315,21 @@ func decodeAndRetrieveOutputs(
313315
}
314316

315317
dependencies, err := decodeDependencyBlocks(file, evalParsingContext, pctx.Experiments)
316-
if err != nil {
317-
return nil, err
318+
if err != nil && hasSiblingAutoInclude(pctx) {
319+
// When a sibling autoinclude overrides dependency blocks, the unit source may
320+
// reference values.* attributes the override makes unnecessary. Skip overridden
321+
// blocks on retry; foldSiblingAutoIncludeDeps fills them in below.
322+
overrides := siblingAutoIncludeDepNames(pctx)
323+
if len(overrides) > 0 {
324+
dependencies, err = decodeDependencyBlocks(
325+
file, evalParsingContext, pctx.Experiments,
326+
hclparse.WithSkipLabelsOnError(overrides),
327+
)
328+
}
329+
330+
if err != nil {
331+
return nil, err
332+
}
318333
}
319334

320335
decodedDependency := TerragruntDependency{Dependencies: dependencies}
@@ -2205,6 +2220,44 @@ func parseAutoIncludeFileCached(
22052220
return file, nil
22062221
}
22072222

2223+
// siblingAutoIncludeDepNames extracts dependency block labels from the sibling autoinclude file via a lightweight HCL parse. Returns nil when no autoinclude is registered, the file is absent, or it cannot be parsed.
2224+
func siblingAutoIncludeDepNames(pctx *ParsingContext) map[string]bool {
2225+
if !hasSiblingAutoInclude(pctx) {
2226+
return nil
2227+
}
2228+
2229+
autoPath := pctx.TrackInclude.AutoIncludeOverride.Path
2230+
2231+
data, err := vfs.ReadFile(pctx.Venv.FS, autoPath)
2232+
if err != nil {
2233+
return nil
2234+
}
2235+
2236+
file, diags := hclsyntax.ParseConfig(data, autoPath, hcl.Pos{Line: 1, Column: 1})
2237+
if diags.HasErrors() {
2238+
return nil
2239+
}
2240+
2241+
body, ok := file.Body.(*hclsyntax.Body)
2242+
if !ok {
2243+
return nil
2244+
}
2245+
2246+
names := make(map[string]bool)
2247+
2248+
for _, block := range body.Blocks {
2249+
if block.Type == MetadataDependency && len(block.Labels) > 0 {
2250+
names[block.Labels[0]] = true
2251+
}
2252+
}
2253+
2254+
if len(names) == 0 {
2255+
return nil
2256+
}
2257+
2258+
return names
2259+
}
2260+
22082261
// IsValidConfigPath checks if a cty.Value is a valid, usable config path.
22092262
func IsValidConfigPath(v cty.Value) bool {
22102263
if v.IsNull() || !v.IsWhollyKnown() || !v.Type().Equals(cty.String) {

pkg/config/hclparse/expansion.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ const (
3838
const DefaultMaxInstances = 1_000_000
3939

4040
type expandConfig struct {
41-
maxInstances int
41+
skipLabelsOnError map[string]bool
42+
maxInstances int
4243
}
4344

4445
// ExpandOption adjusts how [ExpandBlock] expands a block.
@@ -52,6 +53,13 @@ func WithMaxInstances(maxInstances int) ExpandOption {
5253
}
5354
}
5455

56+
// WithSkipLabelsOnError silently drops a block whose first label is in the set when its decode fails, instead of propagating the error. This lets callers skip blocks whose attributes are unresolvable because an autoinclude will replace them.
57+
func WithSkipLabelsOnError(labels map[string]bool) ExpandOption {
58+
return func(cfg *expandConfig) {
59+
cfg.skipLabelsOnError = labels
60+
}
61+
}
62+
5563
// ExpansionBlock is the decoded expansion sub-block of a dependency, unit, or stack
5664
// block. It stays nil unless the block declares expansion.
5765
type ExpansionBlock struct {
@@ -111,6 +119,11 @@ func (file *File) ExpandBlocks(
111119
}
112120
}
113121

122+
cfg := expandConfig{maxInstances: DefaultMaxInstances}
123+
for _, opt := range opts {
124+
opt(&cfg)
125+
}
126+
114127
labels := labelFields(reflect.TypeOf(out).Elem())
115128
labelNames := make([]string, 0, len(labels))
116129

@@ -134,6 +147,11 @@ func (file *File) ExpandBlocks(
134147
continue
135148
}
136149

150+
// Drop blocks whose label matches the skip set on decode failure.
151+
if cfg.skipLabelsOnError != nil && len(block.Labels) > 0 && cfg.skipLabelsOnError[block.Labels[0]] {
152+
continue
153+
}
154+
137155
var blockDiags hcl.Diagnostics
138156
if !errors.As(err, &blockDiags) {
139157
return nil, err

0 commit comments

Comments
 (0)