Skip to content

Commit ceef029

Browse files
committed
stack depednencies
1 parent f737d7c commit ceef029

13 files changed

Lines changed: 818 additions & 35 deletions

File tree

internal/hclparse/generate.go

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -109,35 +109,26 @@ func writeDependencyBlock(outBody *hclwrite.Body, dep AutoIncludeDependency, ori
109109
}
110110

111111
// writeNonDependencyContent writes non-dependency attributes and blocks from
112-
// the autoinclude body. For each attribute:
113-
// - If it references dependency.* variables, copy verbatim from source bytes
114-
// - Otherwise, evaluate using evalCtx and write the literal value
115-
// - Fallback to source bytes if evaluation fails
112+
// the autoinclude body. Each attribute expression is partially evaluated:
113+
// resolvable parts (locals, pure refs) become literals, while deferred parts
114+
// (dependency.*) keep their original source text. This enables mixed
115+
// expressions like "${local.env}-${dependency.vpc.outputs.vpc_id}" to be
116+
// partially resolved.
116117
//
117118
// Non-dependency blocks are always copied verbatim from source bytes.
118119
func writeNonDependencyContent(outBody *hclwrite.Body, body *hclsyntax.Body, srcBytes []byte, evalCtx *hcl.EvalContext) {
119120
for _, attr := range sortedAttributes(body.Attributes) {
120-
if hasDeferredVarRefs(attr.Expr) {
121-
// Has dependency.* refs — copy verbatim.
122-
exprBytes := rangeBytes(srcBytes, attr.Expr.Range())
123-
outBody.SetAttributeRaw(attr.Name, rawTokens(exprBytes))
124-
} else if evalCtx != nil {
125-
// No deferred refs — try to evaluate.
126-
val, diags := attr.Expr.Value(evalCtx)
127-
if !diags.HasErrors() {
128-
outBody.SetAttributeValue(attr.Name, val)
129-
} else {
130-
// Fallback to source bytes.
131-
exprBytes := rangeBytes(srcBytes, attr.Expr.Range())
132-
outBody.SetAttributeRaw(attr.Name, rawTokens(exprBytes))
133-
}
134-
} else {
121+
if evalCtx == nil {
135122
exprBytes := rangeBytes(srcBytes, attr.Expr.Range())
136123
outBody.SetAttributeRaw(attr.Name, rawTokens(exprBytes))
124+
125+
continue
137126
}
127+
128+
result := PartialEval(attr.Expr, srcBytes, evalCtx, deferredRoots)
129+
outBody.SetAttributeRaw(attr.Name, rawTokens(result))
138130
}
139131

140-
// Non-dependency blocks are still copied verbatim.
141132
for _, block := range body.Blocks {
142133
if block.Type == "dependency" {
143134
continue
@@ -147,18 +138,6 @@ func writeNonDependencyContent(outBody *hclwrite.Body, body *hclsyntax.Body, src
147138
}
148139
}
149140

150-
// hasDeferredVarRefs returns true if the expression references any
151-
// dependency.* variables that cannot be evaluated at generation time.
152-
func hasDeferredVarRefs(expr hclsyntax.Expression) bool {
153-
for _, traversal := range expr.Variables() {
154-
if traversal.RootName() == "dependency" {
155-
return true
156-
}
157-
}
158-
159-
return false
160-
}
161-
162141
// copyBlockFromSource copies a block from the original AST to hclwrite output,
163142
// using source bytes for all attribute expressions.
164143
func copyBlockFromSource(outBody *hclwrite.Body, block *hclsyntax.Block, srcBytes []byte) {

internal/hclparse/parse.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package hclparse
22

33
import (
4+
"fmt"
5+
"os"
46
"path/filepath"
57

68
"github.com/hashicorp/hcl/v2"
@@ -50,6 +52,11 @@ func ParseStackFile(src []byte, filename string, stackDir string, values *cty.Va
5052
return nil, diags
5153
}
5254

55+
// Process includes — merge included units/stacks.
56+
if err := processStackIncludes(stackFile, stackDir); err != nil {
57+
return nil, err
58+
}
59+
5360
// Build component refs with absolute paths for the eval context.
5461
// stackDir is the directory containing the terragrunt.stack.hcl file.
5562
// Generated units go to stackDir/.terragrunt-stack/{unit.path}.
@@ -150,6 +157,37 @@ func evaluateLocals(body hcl.Body, evalCtx *hcl.EvalContext) {
150157
}
151158
}
152159

160+
// processStackIncludes resolves include blocks by parsing the included files
161+
// and merging their unit/stack blocks into the main stack file.
162+
func processStackIncludes(stackFile *StackFileHCL, stackDir string) error {
163+
for _, inc := range stackFile.Includes {
164+
includePath := inc.Path
165+
if !filepath.IsAbs(includePath) {
166+
includePath = filepath.Join(stackDir, includePath)
167+
}
168+
169+
data, err := os.ReadFile(includePath)
170+
if err != nil {
171+
return fmt.Errorf("failed to read include %q: %w", inc.Name, err)
172+
}
173+
174+
incFile, diags := hclsyntax.ParseConfig(data, includePath, hcl.Pos{Line: 1, Column: 1})
175+
if diags.HasErrors() {
176+
return fmt.Errorf("failed to parse include %q: %s", inc.Name, diags.Error())
177+
}
178+
179+
included := &StackFileHCL{}
180+
if decodeDiags := gohcl.DecodeBody(incFile.Body, nil, included); decodeDiags.HasErrors() {
181+
return fmt.Errorf("failed to decode include %q: %s", inc.Name, decodeDiags.Error())
182+
}
183+
184+
stackFile.Units = append(stackFile.Units, included.Units...)
185+
stackFile.Stacks = append(stackFile.Stacks, included.Stacks...)
186+
}
187+
188+
return nil
189+
}
190+
153191
// buildRefsWithAbsPath creates ComponentRef values with paths resolved
154192
// to the absolute location under .terragrunt-stack/.
155193
func buildRefsWithAbsPath(stackTargetDir string, units []*UnitBlockHCL) []ComponentRef {

internal/hclparse/parse_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,89 @@ unit "app" {
576576
assert.Contains(t, content, "dependency.vpc.outputs.vpc_id")
577577
}
578578

579+
func TestGenerateAutoIncludeFile_PartialEval(t *testing.T) {
580+
t.Parallel()
581+
582+
// Mixed expressions: inputs object has both pure local refs and deferred
583+
// dependency refs. The partial evaluator should resolve locals to literals
584+
// while preserving dependency refs verbatim. A mixed template string tests
585+
// per-part partial evaluation.
586+
src := `
587+
locals {
588+
env = "production"
589+
region = "us-east-1"
590+
}
591+
592+
unit "vpc" {
593+
source = "../catalog/units/vpc"
594+
path = "vpc"
595+
}
596+
597+
unit "app" {
598+
source = "../catalog/units/app"
599+
path = "app"
600+
601+
autoinclude {
602+
dependency "vpc" {
603+
config_path = unit.vpc.path
604+
605+
mock_outputs_allowed_terraform_commands = ["plan"]
606+
mock_outputs = {
607+
vpc_id = "mock-vpc-id"
608+
}
609+
}
610+
611+
# Mixed object: env is pure local, vpc_id is deferred dependency
612+
inputs = {
613+
env = local.env
614+
region = local.region
615+
vpc_id = dependency.vpc.outputs.vpc_id
616+
}
617+
618+
# Pure local attribute — fully evaluated
619+
env_label = local.env
620+
621+
# Mixed template — partial eval per interpolation part
622+
name_tag = "${local.env}-${dependency.vpc.outputs.vpc_id}-app"
623+
}
624+
}
625+
`
626+
srcBytes := []byte(src)
627+
628+
result, err := hclparse.ParseStackFile(srcBytes, "terragrunt.stack.hcl", "/project", nil)
629+
require.NoError(t, err)
630+
631+
resolved, ok := result.AutoIncludes["app"]
632+
require.True(t, ok)
633+
634+
tmpDir := t.TempDir()
635+
appDir := filepath.Join(tmpDir, ".terragrunt-stack", "app")
636+
637+
err = hclparse.GenerateAutoIncludeFile(resolved, appDir, srcBytes, resolved.EvalCtx)
638+
require.NoError(t, err)
639+
640+
generated, err := os.ReadFile(filepath.Join(appDir, hclparse.AutoIncludeFile))
641+
require.NoError(t, err)
642+
643+
content := string(generated)
644+
645+
// env_label: pure local ref -> evaluated to literal
646+
assert.Contains(t, content, `"production"`)
647+
assert.NotContains(t, content, "local.env")
648+
649+
// inputs object: env and region resolved, vpc_id deferred
650+
assert.Contains(t, content, `"us-east-1"`)
651+
assert.NotContains(t, content, "local.region")
652+
assert.Contains(t, content, "dependency.vpc.outputs.vpc_id")
653+
654+
// name_tag: mixed template -> "production-${dependency.vpc.outputs.vpc_id}-app"
655+
assert.Contains(t, content, "production-${dependency.vpc.outputs.vpc_id}-app")
656+
657+
// Dependency block preserved
658+
assert.Contains(t, content, `dependency "vpc"`)
659+
assert.Contains(t, content, "mock_outputs")
660+
}
661+
579662
func TestParseStackFile_StackChildUnitPath(t *testing.T) {
580663
t.Parallel()
581664

0 commit comments

Comments
 (0)