Skip to content

Commit 53eca0b

Browse files
committed
chore: Adding duplicate-dependency-labels strict control
1 parent 5255d7a commit 53eca0b

10 files changed

Lines changed: 285 additions & 10 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
version: "v1.1.4"
3+
category: "new-features"
4+
---
5+
6+
#### `duplicate-dependency-labels` strict control
7+
8+
Declaring two `dependency` blocks with the same label in one `terragrunt.hcl` configuration file parsed without error, and then quietly resolved every reference to that label to whichever block came last. The blocks before it were silently overridden:
9+
10+
```hcl
11+
dependency "vpc" {
12+
config_path = "../vpc-us-east-1"
13+
}
14+
15+
dependency "vpc" {
16+
config_path = "../vpc-us-west-2"
17+
}
18+
19+
inputs = {
20+
# Reads ../vpc-us-west-2.
21+
vpc_id = dependency.vpc.outputs.vpc_id
22+
}
23+
```
24+
25+
Terragrunt now warns when it finds this. With the new [`duplicate-dependency-labels`](/reference/strict-controls/active#duplicate-dependency-labels) strict control enabled, the warning becomes an error naming the address the blocks share:
26+
27+
```bash
28+
terragrunt run plan --strict-control duplicate-dependency-labels
29+
```
30+
31+
```text
32+
/path/to/terragrunt.hcl: dependency vpc is declared more than once; every dependency needs an address of its own
33+
```
34+
35+
Give each block a label of its own. A configuration that was relying on the shadowing to pick the last block should keep only that block.

docs/src/data/commands/render.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,6 @@ dependency "shard" {
138138
# }
139139
```
140140
141-
The expanded blocks are in comments because expanded blocks are not valid HCL configurations (Terragrunt disallows having multiple `dependency` blocks with the same label). They are present as comments to help you predict how Terragrunt will expand the block with an `expansion` block.
141+
The expanded blocks are in comments because they are not a valid Terragrunt configuration: they all carry one label, and only the last block with a given label can be referenced. Terragrunt warns about that, and rejects it outright under the `duplicate-dependency-labels` [strict control](/reference/strict-controls/active#duplicate-dependency-labels). They are present as comments to help you predict how Terragrunt will expand the block with an `expansion` block.
142142
143143
A `dependency` block with no `expansion` block renders as it always has.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
name: duplicate-dependency-labels
3+
status: active
4+
---
5+
6+
Throw an error when two `dependency` blocks in one configuration claim the same address.
7+
8+
### `duplicate-dependency-labels` - Reason
9+
10+
Two `dependency` blocks written with the same label both parse, and Terragrunt then resolves every reference to that label to whichever block came last. The blocks before it are unreachable, with nothing to say so:
11+
12+
```hcl
13+
dependency "vpc" {
14+
config_path = "../vpc-us-east-1"
15+
}
16+
17+
dependency "vpc" {
18+
config_path = "../vpc-us-west-2"
19+
}
20+
21+
inputs = {
22+
# Reads ../vpc-us-west-2. The first block may as well not be there.
23+
vpc_id = dependency.vpc.outputs.vpc_id
24+
}
25+
```
26+
27+
Terragrunt warns about this by default. Enabling the control turns it into an error naming the address the blocks share:
28+
29+
```text
30+
/path/to/terragrunt.hcl: dependency vpc is declared more than once; every dependency needs an address of its own
31+
```
32+
33+
Give each block a label of its own. A configuration that was relying on the shadowing to pick the last block should keep only that block.
34+
35+
Blocks are compared by the address they resolve to rather than by label alone, so the elements of a block expanded with the [`block-iteration`](/reference/experiments/active#block-iteration) experiment, which all carry the label the block was written with, remain valid.

internal/strict/controls/controls.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ const (
9494
LegacyGCSPublicPrefix = "legacy-gcs-public-prefix"
9595

9696
OptionalHooks = "optional-hooks"
97+
98+
// DuplicateDependencyLabels is the control that prevents two `dependency` blocks in one
99+
// configuration from claiming the same address.
100+
DuplicateDependencyLabels = "duplicate-dependency-labels"
97101
)
98102

99103
// LegacyGCSDeprecationWarning is the warning text emitted when a plain
@@ -234,6 +238,15 @@ func New() strict.Controls {
234238
Warning: "Using an `include` block without a label is deprecated. Please use the `include` block with a label instead. For more information, see https://docs.terragrunt.com/migrate/bare-include/",
235239
},
236240

241+
&Control{
242+
Name: DuplicateDependencyLabels,
243+
Description: "Prevents two `dependency` blocks in one configuration from claiming the same address.",
244+
Error: errors.New( //nolint:staticcheck // user-facing message intentionally written as full sentences
245+
"Two `dependency` blocks address the same dependency. Give each block a label of its own.",
246+
),
247+
Warning: "Two `dependency` blocks address the same dependency, so only the last of them can be referenced and the rest are unreachable. Give each block a label of its own. In a future version of Terragrunt, this will result in an error.",
248+
},
249+
237250
&Control{
238251
Name: DoubleStar,
239252
Description: "Use the `**` glob pattern to select all files in a directory and its subdirectories.",

internal/strict/controls/controls_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,16 @@ func TestControlEvaluate(t *testing.T) {
248248
require.ErrorIs(t, err, bootErr)
249249
})
250250
}
251+
252+
// TestDuplicateDependencyLabelsControlIsRegistered pins that the control the dependency
253+
// decoder looks up by name is one the registry hands back.
254+
func TestDuplicateDependencyLabelsControlIsRegistered(t *testing.T) {
255+
t.Parallel()
256+
257+
ctrl := controls.New().Find(controls.DuplicateDependencyLabels)
258+
259+
if assert.NotNil(t, ctrl, "duplicate-dependency-labels must be registered") {
260+
assert.Equal(t, strict.ActiveStatus, ctrl.GetStatus())
261+
assert.Error(t, ctrl.(*controls.Control).Error)
262+
}
263+
}

pkg/config/config.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -767,10 +767,9 @@ func groupExpandedDependencies(deps Dependencies) []dependencyGroup {
767767
// appendExpansionPreview writes an expanded block as it was written, followed by the
768768
// elements it expanded into, commented out and with every reference resolved.
769769
//
770-
// The elements stay comments because they all repeat the block's label. Terragrunt reads
771-
// repeated labels without complaint, but every reference to one resolves to the last
772-
// block carrying it, so uncommented elements would render a config that quietly drops all
773-
// but one of them.
770+
// The elements stay comments because they all repeat the block's label, which
771+
// [validateUniqueDependencies] warns about and, under its strict control, rejects.
772+
// Rendering them as configuration would produce a file that reads back as one dependency.
774773
func appendExpansionPreview(body *hclwrite.Body, group dependencyGroup) error {
775774
source, diags := hclwrite.ParseConfig(
776775
[]byte(group.source.Text),
@@ -1731,7 +1730,7 @@ func ParseConfig(
17311730

17321731
// Decode the rest of the config, passing in this config's `include` block or the child's `include` block, whichever
17331732
// is appropriate
1734-
terragruntConfigFile, err := decodeAsTerragruntConfigFile(pctx, l, file, evalContext)
1733+
terragruntConfigFile, err := decodeAsTerragruntConfigFile(ctx, pctx, l, file, evalContext)
17351734
if err != nil {
17361735
errs = append(errs, err)
17371736
}
@@ -1978,6 +1977,7 @@ func setIAMRole(
19781977
}
19791978

19801979
func decodeAsTerragruntConfigFile(
1980+
ctx context.Context,
19811981
pctx *ParsingContext,
19821982
l log.Logger,
19831983
file *hclparse.File,
@@ -2001,7 +2001,7 @@ func decodeAsTerragruntConfigFile(
20012001
l.Debugf("Deferred attribute access error to autoinclude merge: %v", diagErr)
20022002
}
20032003

2004-
dependencies, err := decodeDependencyBlocks(file, evalContext, pctx.Experiments)
2004+
dependencies, err := decodeDependencyBlocks(ctx, pctx, l, file, evalContext)
20052005
if err != nil {
20062006
return &terragruntConfig, err
20072007
}

pkg/config/config_partial.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -735,9 +735,11 @@ func PartialParseConfig(
735735

736736
case DependencyBlock:
737737
decodedDeps, err := decodeDependencyBlocks(
738+
ctx,
739+
pctx,
740+
l,
738741
file,
739742
evalParsingContext,
740-
pctx.Experiments,
741743
)
742744
if err != nil {
743745
return nil, err

pkg/config/dependency.go

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/gruntwork-io/terragrunt/internal/experiment"
2323
"github.com/gruntwork-io/terragrunt/internal/iacargs"
2424
"github.com/gruntwork-io/terragrunt/internal/remotestate"
25+
"github.com/gruntwork-io/terragrunt/internal/strict/controls"
2526
"github.com/gruntwork-io/terragrunt/pkg/log"
2627

2728
s3backend "github.com/gruntwork-io/terragrunt/internal/remotestate/backend/s3"
@@ -270,10 +271,14 @@ func outputLocksFromContext(ctx context.Context) *util.KeyLocks {
270271
// decodeDependencyBlocks decodes a config's dependency blocks, returning one Dependency
271272
// per iteration element. A block that declares no expansion yields a single Dependency.
272273
func decodeDependencyBlocks(
274+
ctx context.Context,
275+
pctx *ParsingContext,
276+
l log.Logger,
273277
file *hclparse.File,
274278
evalContext *hcl.EvalContext,
275-
experiments experiment.Experiments,
276279
) (Dependencies, error) {
280+
experiments := pctx.Experiments
281+
277282
instances, err := file.ExpandBlocks(MetadataDependency, &Dependency{}, evalContext)
278283
if err != nil {
279284
return nil, err
@@ -303,9 +308,63 @@ func decodeDependencyBlocks(
303308
dependencies = append(dependencies, *dep)
304309
}
305310

311+
if err := validateUniqueDependencies(ctx, pctx, l, file.ConfigPath, dependencies); err != nil {
312+
return nil, err
313+
}
314+
306315
return dependencies, nil
307316
}
308317

318+
// validateUniqueDependencies reports two dependency blocks in one config that address the
319+
// same dependency. HCL allows the repeated label, and nothing downstream reports it: the
320+
// dependency map is keyed by address, so the last block silently wins and the ones before
321+
// it become unreachable.
322+
//
323+
// Whether that is an error or a warning is left to the duplicate-dependency-labels strict
324+
// control, since configs carrying a shadowed block have always run.
325+
func validateUniqueDependencies(
326+
ctx context.Context,
327+
pctx *ParsingContext,
328+
l log.Logger,
329+
configPath string,
330+
deps Dependencies,
331+
) error {
332+
address, found := duplicateDependencyAddress(deps)
333+
if !found {
334+
return nil
335+
}
336+
337+
control := pctx.StrictControls.Find(controls.DuplicateDependencyLabels)
338+
if control == nil {
339+
return errors.New("failed to find control " + controls.DuplicateDependencyLabels)
340+
}
341+
342+
if control.GetEnabled() {
343+
return DuplicateDependencyError{ConfigPath: configPath, Address: address}
344+
}
345+
346+
return control.Evaluate(log.ContextWithLogger(ctx, l))
347+
}
348+
349+
// duplicateDependencyAddress returns the first address that two dependency blocks both
350+
// claim. Blocks are compared by address rather than by label, so the elements of an
351+
// expanded block, which all carry the label the block was written with, stay distinct.
352+
func duplicateDependencyAddress(deps Dependencies) (string, bool) {
353+
seen := make(map[string]struct{}, len(deps))
354+
355+
for i := range deps {
356+
address := deps[i].mergeKey()
357+
358+
if _, duplicate := seen[address]; duplicate {
359+
return address, true
360+
}
361+
362+
seen[address] = struct{}{}
363+
}
364+
365+
return "", false
366+
}
367+
309368
// Decode the dependency blocks from the file, and then retrieve all the outputs from the remote state. Then encode the
310369
// resulting map as a cty.Value object.
311370
// TODO: In the future, consider allowing importing dependency blocks from included config
@@ -323,7 +382,7 @@ func decodeAndRetrieveOutputs(
323382
return nil, err
324383
}
325384

326-
dependencies, err := decodeDependencyBlocks(file, evalParsingContext, pctx.Experiments)
385+
dependencies, err := decodeDependencyBlocks(ctx, pctx, l, file, evalParsingContext)
327386
if err != nil {
328387
return nil, err
329388
}

pkg/config/dependency_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"testing"
66

77
"github.com/gruntwork-io/terragrunt/internal/experiment"
8+
"github.com/gruntwork-io/terragrunt/internal/strict/controls"
89
"github.com/gruntwork-io/terragrunt/pkg/config"
910
"github.com/gruntwork-io/terragrunt/pkg/config/hclparse"
1011
"github.com/gruntwork-io/terragrunt/test/helpers/logger"
@@ -359,3 +360,105 @@ func TestDependencyDeepMergeExpansion(t *testing.T) {
359360
})
360361
}
361362
}
363+
364+
// parseDependencyStringStrict parses cfg with the duplicate-dependency-labels strict
365+
// control enabled.
366+
func parseDependencyStringStrict(tb testing.TB, cfg string) (*config.TerragruntConfig, error) {
367+
tb.Helper()
368+
369+
ctx, pctx := newExpansionParsingContext(tb, config.DefaultTerragruntConfigPath)
370+
371+
control := pctx.StrictControls.Find(controls.DuplicateDependencyLabels)
372+
require.NotNil(tb, control)
373+
control.Enable()
374+
375+
return config.PartialParseConfigString(
376+
ctx,
377+
pctx.WithDecodeList(config.DependencyBlock),
378+
logger.CreateLogger(),
379+
config.DefaultTerragruntConfigPath,
380+
cfg,
381+
nil,
382+
)
383+
}
384+
385+
const duplicateDependencyLabels = `
386+
dependency "foo" {
387+
config_path = "../a"
388+
}
389+
390+
dependency "foo" {
391+
config_path = "../b"
392+
}
393+
`
394+
395+
// TestDuplicateDependencyLabelsWarnByDefault pins that a config whose blocks shadow each
396+
// other still parses, since such configs have always run.
397+
func TestDuplicateDependencyLabelsWarnByDefault(t *testing.T) {
398+
t.Parallel()
399+
400+
cfg, err := parseDependencyString(t, duplicateDependencyLabels)
401+
402+
require.NoError(t, err)
403+
assert.Len(t, cfg.TerragruntDependencies, 2)
404+
}
405+
406+
// TestDuplicateDependencyLabelsRejectedWhenStrict pins that the strict control turns the
407+
// shadowing into a parse failure naming the address two blocks claim.
408+
func TestDuplicateDependencyLabelsRejectedWhenStrict(t *testing.T) {
409+
t.Parallel()
410+
411+
_, err := parseDependencyStringStrict(t, duplicateDependencyLabels)
412+
413+
var typed config.DuplicateDependencyError
414+
require.ErrorAs(t, err, &typed)
415+
assert.Equal(t, "foo", typed.Address)
416+
}
417+
418+
// TestExpandedDependencyLabelsAccepted pins that the elements of one expanded block, which
419+
// all carry the label the block was written with, are not read as duplicates even under
420+
// the strict control.
421+
func TestExpandedDependencyLabelsAccepted(t *testing.T) {
422+
t.Parallel()
423+
424+
cfg, err := parseDependencyStringStrict(t, `
425+
dependency "foo" {
426+
expansion {
427+
for_each = toset(["a", "b"])
428+
}
429+
430+
config_path = "../${each.key}"
431+
}
432+
`)
433+
434+
require.NoError(t, err)
435+
assert.Len(t, cfg.TerragruntDependencies, 2)
436+
}
437+
438+
// TestExpandedDependencyLabelCollisionRejectedWhenStrict pins that two expanded blocks
439+
// sharing a label collide on the elements whose keys they both produce.
440+
func TestExpandedDependencyLabelCollisionRejectedWhenStrict(t *testing.T) {
441+
t.Parallel()
442+
443+
_, err := parseDependencyStringStrict(t, `
444+
dependency "foo" {
445+
expansion {
446+
for_each = toset(["a"])
447+
}
448+
449+
config_path = "../first-${each.key}"
450+
}
451+
452+
dependency "foo" {
453+
expansion {
454+
for_each = toset(["a"])
455+
}
456+
457+
config_path = "../second-${each.key}"
458+
}
459+
`)
460+
461+
var typed config.DuplicateDependencyError
462+
require.ErrorAs(t, err, &typed)
463+
assert.Equal(t, "foo[a]", typed.Address)
464+
}

0 commit comments

Comments
 (0)