Skip to content

Commit 35a9b85

Browse files
authored
feat: Tip how to run a stack's units when a stack filter matches no units (#6387)
* chore: filtering fixes * chore: docs sfor netsed recursion * chore: pr review * changelog cleanup * chore: cleanup * chore: cleanup * chore: add tip for proposed appraoch * chore: runner fix * chore: nested stacks cleanup * chore: tip cleanup * chore: fatal issues * chore: PR comments
1 parent 1cb608f commit 35a9b85

15 files changed

Lines changed: 479 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.1.0"
3+
category: "tips-added"
4+
---
5+
6+
#### Tip when filtering a stack leaves nested stacks ungenerated
7+
8+
`terragrunt stack generate --filter './my-stack | type=stack'` generates only the selected
9+
stack, not the nested stacks it contains, which can be surprising for a stack of stacks.
10+
When a non-glob `| type=stack` filter leaves a stack's nested stacks ungenerated, Terragrunt
11+
now prints a tip showing how to generate them too, for example
12+
`--filter './my-stack | type=stack' --filter './my-stack/** | type=stack'`.

internal/cli/commands/stack/stack.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"path/filepath"
77
"strings"
88

9+
"github.com/gruntwork-io/terragrunt/internal/configbridge"
910
"github.com/gruntwork-io/terragrunt/internal/telemetry"
1011
"github.com/zclconf/go-cty/cty"
1112

@@ -79,12 +80,21 @@ func RunGenerate(ctx context.Context, l log.Logger, opts *options.TerragruntOpti
7980

8081
gen := generate.NewGenerator()
8182

82-
return telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_generate", map[string]any{
83+
err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_generate", map[string]any{
8384
"stack_config_path": opts.TerragruntStackConfigPath,
8485
"working_dir": opts.WorkingDir,
8586
}, func(ctx context.Context) error {
8687
return gen.GenerateStacks(ctx, l, opts, wts)
8788
})
89+
if err != nil {
90+
return err
91+
}
92+
93+
// After generation, hint when a literal stack filter left nested stacks ungenerated.
94+
funcsFor := configbridge.StackFuncFactory(ctx, l, opts)
95+
tips.GiveStackNestedGenerateTip(l, vfs.NewOSFS(), funcsFor, opts.WorkingDir, opts.Filters, opts.Tips)
96+
97+
return nil
8898
}
8999

90100
// Run executes the stack command.

internal/configbridge/bridge.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package configbridge
66
import (
77
"context"
88

9+
inthclparse "github.com/gruntwork-io/terragrunt/internal/hclparse"
910
"github.com/gruntwork-io/terragrunt/internal/remotestate"
1011
"github.com/gruntwork-io/terragrunt/internal/remotestate/backend"
1112
"github.com/gruntwork-io/terragrunt/internal/runner/run"
@@ -14,6 +15,7 @@ import (
1415
"github.com/gruntwork-io/terragrunt/pkg/config"
1516
"github.com/gruntwork-io/terragrunt/pkg/log"
1617
"github.com/gruntwork-io/terragrunt/pkg/options"
18+
"github.com/zclconf/go-cty/cty/function"
1719
)
1820

1921
// NewParsingContext creates a config.ParsingContext populated from TerragruntOptions.
@@ -28,6 +30,17 @@ func NewParsingContext(
2830
return ctx, pctx
2931
}
3032

33+
// StackFuncFactory returns a dir-scoped HCL function factory for early stack
34+
// discovery parsing, built from TerragruntOptions. Each call rebuilds the
35+
// function map for the given stack dir so dir-sensitive functions resolve there.
36+
func StackFuncFactory(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) inthclparse.StackFuncFactory {
37+
_, pctx := NewParsingContext(ctx, l, opts)
38+
39+
return func(stackDir string) (map[string]function.Function, error) {
40+
return config.EarlyStackParseFunctions(ctx, l, stackDir, pctx)
41+
}
42+
}
43+
3144
// populateFromOpts copies fields from TerragruntOptions into ParsingContext flat fields.
3245
func populateFromOpts(pctx *config.ParsingContext, opts *options.TerragruntOptions) {
3346
pctx.TerragruntConfigPath = opts.TerragruntConfigPath

internal/hclparse/stack.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,52 @@ func UnitPathsFromStackDir(fs vfs.FS, stackDir string, funcsFor StackFuncFactory
225225
return unitPathsFromStackDir(fs, stackDir, funcsFor, make(map[string]struct{}), 0)
226226
}
227227

228+
// DirectComponentPaths returns the generated on-disk paths of the direct unit and
229+
// stack components declared in stackDir's terragrunt.stack.hcl, honoring
230+
// no_dot_terragrunt_stack. It does not recurse into nested stacks; an absent stack
231+
// file yields empty slices and a nil error. funcsFor must be non-nil and return a
232+
// non-nil map.
233+
func DirectComponentPaths(fs vfs.FS, stackDir string, funcsFor StackFuncFactory) (unitPaths, stackPaths []string, err error) {
234+
if fs == nil {
235+
panic(fmt.Sprintf("hclparse.DirectComponentPaths: fs is nil (stackDir=%q)", stackDir))
236+
}
237+
238+
if stackDir == "" {
239+
panic("hclparse.DirectComponentPaths: stackDir is empty")
240+
}
241+
242+
if funcsFor == nil {
243+
panic(fmt.Sprintf("hclparse.DirectComponentPaths: funcsFor is nil (stackDir=%q)", stackDir))
244+
}
245+
246+
stackDir = util.ResolvePath(stackDir)
247+
stackFile := filepath.Join(stackDir, stackFileName)
248+
249+
funcs, err := funcsFor(stackDir)
250+
if err != nil {
251+
return nil, nil, err
252+
}
253+
254+
if funcs == nil {
255+
panic(fmt.Sprintf("hclparse.DirectComponentPaths: funcsFor returned a nil map (stackDir=%q)", stackDir))
256+
}
257+
258+
units, stacks, err := decodeDiscovery(fs, stackDir, stackFile, funcs)
259+
if err != nil {
260+
return nil, nil, err
261+
}
262+
263+
for _, unit := range units {
264+
unitPaths = append(unitPaths, unit.GeneratedPath(stackDir))
265+
}
266+
267+
for _, stack := range stacks {
268+
stackPaths = append(stackPaths, stack.GeneratedPath(stackDir))
269+
}
270+
271+
return unitPaths, stackPaths, nil
272+
}
273+
228274
// unitPathsFromStackDir is the bounded recursive worker. Termination is guaranteed two ways:
229275
// visited skips any stack dir already expanded on this traversal (catches "." / ".." and
230276
// ancestor symlink loops), and depth caps the chain length (backstop for symlink cycles

internal/runner/runall/runall.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"path/filepath"
88

9+
"github.com/gruntwork-io/terragrunt/internal/configbridge"
910
"github.com/gruntwork-io/terragrunt/internal/runner"
1011
"github.com/gruntwork-io/terragrunt/internal/runner/common"
1112
"github.com/gruntwork-io/terragrunt/internal/runner/run"
@@ -169,6 +170,10 @@ func Run(ctx context.Context, l log.Logger, v run.Venv, opts *options.Terragrunt
169170
if err != nil {
170171
return fmt.Errorf("failed to generate stack file: %w", err)
171172
}
173+
174+
// After generation, hint when a literal stack filter left nested stacks ungenerated.
175+
funcsFor := configbridge.StackFuncFactory(ctx, l, opts)
176+
tips.GiveStackNestedGenerateTip(l, v.FS, funcsFor, opts.WorkingDir, opts.Filters, opts.Tips)
172177
} else {
173178
l.Debugf("Skipping stack generation in %s", opts.WorkingDir)
174179
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package tips
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"strings"
7+
8+
"github.com/gruntwork-io/terragrunt/internal/filter"
9+
inthclparse "github.com/gruntwork-io/terragrunt/internal/hclparse"
10+
"github.com/gruntwork-io/terragrunt/internal/vfs"
11+
"github.com/gruntwork-io/terragrunt/pkg/log"
12+
)
13+
14+
// GiveStackNestedGenerateTip emits the StackNestedStacksNotGenerated tip after
15+
// generation when a literal (non-glob) path with `| type=stack` targets a stack
16+
// whose nested stacks were not themselves recursively generated.
17+
// The user likely expected the whole subtree, so the tip shows how to include it.
18+
func GiveStackNestedGenerateTip(
19+
l log.Logger,
20+
fs vfs.FS,
21+
funcsFor inthclparse.StackFuncFactory,
22+
workingDir string,
23+
filters filter.Filters,
24+
allTips Tips,
25+
) {
26+
if len(filters) == 0 || allTips == nil || funcsFor == nil {
27+
return
28+
}
29+
30+
tip := allTips.Find(StackNestedStacksNotGenerated)
31+
if tip == nil {
32+
return
33+
}
34+
35+
var paths []string
36+
37+
for _, f := range filters.RestrictToStacks() {
38+
path := literalStackFilterPath(f)
39+
if path == "" {
40+
continue
41+
}
42+
43+
dir := path
44+
if !filepath.IsAbs(dir) {
45+
dir = filepath.Join(workingDir, dir)
46+
}
47+
48+
if !stackHasUngeneratedNestedStacks(l, fs, funcsFor, dir) {
49+
continue
50+
}
51+
52+
paths = append(paths, path)
53+
}
54+
55+
if len(paths) == 0 {
56+
return
57+
}
58+
59+
tip.EvaluateWith(l, buildStackNestedGenerateMessage(paths))
60+
}
61+
62+
// SuggestRecursiveStackFilter returns the recursive stack filter that selects
63+
// the nested stacks beneath path.
64+
func SuggestRecursiveStackFilter(path string) string {
65+
return path + "/** | type=stack"
66+
}
67+
68+
// literalStackFilterPath returns the first literal (non-glob) path targeted by
69+
// the filter, or "" if it has none.
70+
func literalStackFilterPath(f *filter.Filter) string {
71+
var found string
72+
73+
filter.WalkExpressions(f.Expression(), func(e filter.Expression) bool {
74+
pe, ok := e.(*filter.PathExpression)
75+
if !ok {
76+
return true
77+
}
78+
79+
if containsGlobMeta(pe.Value) {
80+
return true
81+
}
82+
83+
found = pe.Value
84+
85+
return false
86+
})
87+
88+
return found
89+
}
90+
91+
// stackHasUngeneratedNestedStacks reports whether the stack generated at dir has
92+
// nested stacks that were not themselves recursively generated. For each nested
93+
// stack the parent generated, it checks whether that nested stack's own components
94+
// exist on disk (honoring no_dot_terragrunt_stack).
95+
func stackHasUngeneratedNestedStacks(l log.Logger, fs vfs.FS, funcsFor inthclparse.StackFuncFactory, dir string) bool {
96+
_, nestedStackDirs, err := inthclparse.DirectComponentPaths(fs, dir, funcsFor)
97+
if err != nil {
98+
l.Debugf("stack-nested-generate tip: skipping %q: %v", dir, err)
99+
return false
100+
}
101+
102+
for _, nestedDir := range nestedStackDirs {
103+
if !nestedStackGenerated(l, fs, funcsFor, nestedDir) {
104+
return true
105+
}
106+
}
107+
108+
return false
109+
}
110+
111+
// nestedStackGenerated reports whether every direct component of the nested stack
112+
// generated at nestedDir exists on disk, i.e. the nested stack was itself generated.
113+
func nestedStackGenerated(l log.Logger, fs vfs.FS, funcsFor inthclparse.StackFuncFactory, nestedDir string) bool {
114+
unitPaths, stackPaths, err := inthclparse.DirectComponentPaths(fs, nestedDir, funcsFor)
115+
if err != nil {
116+
l.Debugf("stack-nested-generate tip: skipping %q: %v", nestedDir, err)
117+
return true
118+
}
119+
120+
return !anyPathMissing(l, fs, unitPaths) && !anyPathMissing(l, fs, stackPaths)
121+
}
122+
123+
// anyPathMissing reports whether any of paths does not exist on fs.
124+
func anyPathMissing(l log.Logger, fs vfs.FS, paths []string) bool {
125+
for _, p := range paths {
126+
exists, err := vfs.FileExists(fs, p)
127+
if err != nil {
128+
l.Debugf("stack-nested-generate tip: cannot stat %q: %v", p, err)
129+
continue
130+
}
131+
132+
if !exists {
133+
return true
134+
}
135+
}
136+
137+
return false
138+
}
139+
140+
func buildStackNestedGenerateMessage(paths []string) string {
141+
var b strings.Builder
142+
143+
b.WriteString(StackNestedStacksNotGeneratedMessage)
144+
b.WriteString(" For example:")
145+
146+
for _, p := range paths {
147+
fmt.Fprintf(&b, "\n --filter %q --filter %q", p+" | type=stack", SuggestRecursiveStackFilter(p))
148+
}
149+
150+
return b.String()
151+
}

0 commit comments

Comments
 (0)