Skip to content

Commit dfa0096

Browse files
authored
feat: lets block for bundles (#2355)
<!-- Thanks for sending a pull request! Here are some tips for you: 1. Please read our contribution policy: https://github.com/terramate-io/terramate/blob/main/CONTRIBUTING.md Note: All code contributions are managed exclusively by the Terramate team. 2. If the PR is unfinished, mark it as draft: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-stage-of-a-pull-request 3. Please update the PR title using the Conventional Commits convention: https://www.conventionalcommits.org/en/v1.0.0/ Example: feat: add support for XYZ. --> ## What this PR does / why we need it: This PR adds support for a lets block in bundle definitions. The are evaluated after bundle inputs, but before exports and bundle stacks. The lets defined expressions are available as `bundle.let.<name>` outside of the lets block. Note: The PR also contains some opportunistic fixes in `evalBundleExports`. ## Which issue(s) this PR fixes: <!-- *Automatically closes linked issue when PR is merged. Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`. _If PR is about `failing-tests or flakes`, please post the related issues/tests in a comment and do not use `Fixes`_* Keep it empty if not applicable. --> Implements #2299 ## Does this PR introduce a user-facing change? <!-- If no, just write "no" in the block below. If yes, please explain the change and update documentation and the CHANGELOG.md file accordingly. --> ``` yes, see changelog ``` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a new evaluation phase for bundle configuration (`lets`) that affects how bundle inputs/scaffolding/exports/stacks are computed, so regressions could change bundle rendering and UI behavior. Also refactors `preempt.Run` scheduling/drain logic, which is concurrency-sensitive but covered by new deadlock-focused tests. > > **Overview** > **Bundles now support a `lets` block** in `define bundle`, merged across files and exposed to expressions as `bundle.let.<name>`, evaluated *after inputs* but *before exports and bundle stacks*. > > Bundle evaluation is updated to populate `bundle.file.path.*` on the `bundle` namespace and to call `config.LoadBundleLets()` from core bundle eval as well as the scaffold and UI change flows so `lets` are available when computing scaffolding name/path and other derived fields. > > Separately, `preempt.Run` is refactored to track in-flight goroutines and drain/resume waiters without deadlocking when a goroutine issues additional `Await` calls after receiving `ErrUnresolvable`, with new tests covering these late-await scenarios. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit bf75b08. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
2 parents 87ebdb0 + bf75b08 commit dfa0096

11 files changed

Lines changed: 449 additions & 66 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ Given a version number `MAJOR.MINOR.PATCH`, we increment the:
2626

2727
- Hide bundles in the `terrmate ui` promote list, if they reference other bundles that would not exist in the target environment.
2828
- This prevents errors that would otherwise occur once the bundle has been promoted.
29+
- Add support for `lets` block in bundles. Outside the `lets` block, expressions can be referenced as `bundle.let.<name>`.
30+
- The block is evaluated after bundle inputs, but before exports and bundle stacks.
2931

3032
## 0.17.0
3133

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.17.0
1+
0.17.1-dev

commands/scaffold/scaffold.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,12 @@ func (s *Spec) Exec(ctx context.Context, cli commands.CLI) error {
331331
return err
332332
}
333333

334+
// Load bundle lets so `bundle.let.<name>` is available
335+
// when evaluating scaffolding.path and scaffolding.name.
336+
if err := config.LoadBundleLets(evalctx, selectedBundle.Define.Lets); err != nil {
337+
return err
338+
}
339+
334340
// Eval the bundle definition itself after the inputs have been collected to evaluate default label and path.
335341
bundleDef, err := config.EvalBundleDefinition(evalctx, selectedBundle.Define)
336342
if err != nil {

commands/ui/change.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ func NewCreateChange(
9595
return Change{}, err
9696
}
9797

98+
if err := config.LoadBundleLets(schemactx.Evalctx, bde.Define.Lets); err != nil {
99+
return Change{}, err
100+
}
101+
98102
// We check if the bundle has an explicit alias and add it to the context if yes.
99103
alias, err := setupExplicitBundleAlias(schemactx.Evalctx, bde.Define)
100104
if err != nil {
@@ -202,6 +206,10 @@ func NewReconfigChange(
202206
return Change{}, err
203207
}
204208

209+
if err := config.LoadBundleLets(schemactx.Evalctx, bde.Define.Lets); err != nil {
210+
return Change{}, err
211+
}
212+
205213
// This will only be set if there is an explicit alias.
206214
newAlias, err := setupExplicitBundleAlias(schemactx.Evalctx, bde.Define)
207215
if err != nil {
@@ -278,6 +286,10 @@ func NewPromoteChange(
278286
return Change{}, err
279287
}
280288

289+
if err := config.LoadBundleLets(schemactx.Evalctx, bde.Define.Lets); err != nil {
290+
return Change{}, err
291+
}
292+
281293
// This will only be set if there is an explicit alias.
282294
newAlias, err := setupExplicitBundleAlias(schemactx.Evalctx, bde.Define)
283295
if err != nil {

config/bundle.go

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/terramate-io/terramate/hcl/ast"
2525
"github.com/terramate-io/terramate/hcl/eval"
2626
"github.com/terramate-io/terramate/hcl/info"
27+
"github.com/terramate-io/terramate/lets"
2728
"github.com/terramate-io/terramate/project"
2829
"github.com/terramate-io/terramate/typeschema"
2930
)
@@ -400,10 +401,18 @@ func EvalBundle(ctx context.Context, root *Root, resolveAPI resolve.API, evalctx
400401

401402
evalctx = evalctx.ChildContext()
402403

404+
filePath := inst.Info.Path()
403405
bundleNS := map[string]cty.Value{
404406
"class": cty.StringVal(evaluated.DefinitionMetadata.Class),
405407
"uuid": uuidVal,
406408
"environment": MakeEnvObject(evaluated.Environment),
409+
"file": cty.ObjectVal(map[string]cty.Value{
410+
"path": cty.ObjectVal(map[string]cty.Value{
411+
"absolute": cty.StringVal(inst.Info.HostPath()),
412+
"basename": cty.StringVal(path.Base(filePath.String())),
413+
"relative": cty.StringVal(filePath.String()),
414+
}),
415+
}),
407416
}
408417

409418
evalctx.SetNamespace("bundle", bundleNS)
@@ -428,6 +437,10 @@ func EvalBundle(ctx context.Context, root *Root, resolveAPI resolve.API, evalctx
428437
return nil, err
429438
}
430439

440+
if err := LoadBundleLets(evalctx, defineBundle.Lets); err != nil {
441+
return nil, err
442+
}
443+
431444
if defineBundle.Alias != nil {
432445
evaluated.Alias, err = EvalString(evalctx, defineBundle.Alias.Expr, "alias")
433446
if err != nil {
@@ -438,7 +451,7 @@ func EvalBundle(ctx context.Context, root *Root, resolveAPI resolve.API, evalctx
438451
evaluated.Alias = fmt.Sprintf("%s:%s", inst.Workdir.String(), inst.Name)
439452
}
440453

441-
evaluated.Exports, err = evalBundleExports(evalctx, inst, defineBundle, evaluated.Inputs)
454+
evaluated.Exports, err = evalBundleExports(evalctx, defineBundle)
442455
if err != nil {
443456
return nil, err
444457
}
@@ -625,33 +638,9 @@ func extractInputVars(rootNS string, attr *ast.Attribute) []string {
625638
return results
626639
}
627640

628-
func evalBundleExports(evalctx *eval.Context, inst *hcl.Bundle, def *hcl.DefineBundle, inputs map[string]cty.Value) (map[string]cty.Value, error) {
629-
evalctx = evalctx.ChildContext()
630-
641+
func evalBundleExports(evalctx *eval.Context, def *hcl.DefineBundle) (map[string]cty.Value, error) {
631642
exports := map[string]cty.Value{}
632-
633643
errs := errors.L()
634-
filePath := inst.Info.Path()
635-
636-
filePathNS := cty.ObjectVal(map[string]cty.Value{
637-
"absolute": cty.StringVal(inst.Info.HostPath()),
638-
"basename": cty.StringVal(path.Base(filePath.String())),
639-
"relative": cty.StringVal(filePath.String()),
640-
})
641-
fileNS := cty.ObjectVal(map[string]cty.Value{
642-
"path": filePathNS,
643-
})
644-
645-
// For the exports evaluation, move namespace "global" to "bundle.global".
646-
globalsNamespace, _ := evalctx.GetNamespace("global")
647-
648-
bundleInputsNamespace := map[string]cty.Value{
649-
"input": cty.ObjectVal(inputs),
650-
"global": globalsNamespace,
651-
"file": fileNS,
652-
}
653-
evalctx.SetNamespace("bundle", bundleInputsNamespace)
654-
evalctx.SetNamespace("global", map[string]cty.Value{})
655644

656645
for name, exportDef := range def.Exports {
657646
val, err := evalctx.Eval(exportDef.Value.Expr)
@@ -1096,3 +1085,27 @@ func tryEvaluateExpr(evalctx *eval.Context, expr hhcl.Expression) (cty.Value, bo
10961085
tokens := ast.TokensForExpression(expr).Bytes()
10971086
return cty.StringVal(strings.TrimSpace(string(tokens))), false
10981087
}
1088+
1089+
// LoadBundleLets evaluates the bundle's lets block and exposes the result as `bundle.let.<name>`.
1090+
// Any inherited "let" namespace is discarded.
1091+
func LoadBundleLets(evalctx *eval.Context, letBlock *ast.MergedBlock) error {
1092+
evalctx.SetNamespace("let", map[string]cty.Value{})
1093+
1094+
if err := lets.Load(letBlock, evalctx); err != nil {
1095+
return err
1096+
}
1097+
1098+
letsVal, _ := evalctx.GetNamespace("let")
1099+
1100+
var bundleMap map[string]cty.Value
1101+
if bundleNS, ok := evalctx.GetNamespace("bundle"); ok {
1102+
bundleMap = bundleNS.AsValueMap()
1103+
} else {
1104+
bundleMap = map[string]cty.Value{}
1105+
}
1106+
bundleMap["let"] = letsVal
1107+
evalctx.SetNamespace("bundle", bundleMap)
1108+
1109+
evalctx.DeleteNamespace("let")
1110+
return nil
1111+
}

hcl/block_component_parser.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type Component struct {
3737
FromBundleSource string
3838

3939
// In this case component comes from a bundle, this stores the values of the `bundle.` object.
40+
// Bundle-level lets are exposed as `bundle.lets.<name>` within this object.
4041
BundleObject *cty.Value
4142
}
4243

hcl/block_define_parser.go

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,9 @@ type DefineComponent struct {
5858

5959
// DefineBundle represents a bundle defined in the "define" block.
6060
type DefineBundle struct {
61-
Alias *ast.Attribute
62-
61+
Alias *ast.Attribute
6362
Metadata Metadata
63+
Lets *ast.MergedBlock
6464
Stacks map[string]*DefineStack
6565
Inputs map[string]*DefineInput
6666
Exports map[string]*DefineExport
@@ -213,6 +213,7 @@ func newDefineComponent() *DefineComponent {
213213

214214
func newDefineBundle() *DefineBundle {
215215
return &DefineBundle{
216+
Lets: ast.NewMergedBlock("lets", []string{}),
216217
Stacks: make(map[string]*DefineStack),
217218
Inputs: make(map[string]*DefineInput),
218219
Exports: make(map[string]*DefineExport),
@@ -301,11 +302,16 @@ func (d *DefineBlockParser) Parse(p *TerramateParser, label ast.LabelBlockType,
301302
return err
302303
}
303304

305+
case "lets":
306+
if err := setLets(define.Bundle.Lets, block); err != nil {
307+
return err
308+
}
309+
304310
default:
305311
return errors.E(
306312
ErrUnrecognizedDefineSubBlock,
307313
block.RawOrigins[0].LabelRanges(),
308-
`unexpected label %q, expected "metadata", "scaffolding" or "environments"`,
314+
`unexpected label %q, expected "metadata", "scaffolding", "environments" or "lets"`,
309315
label.Labels[1],
310316
)
311317
}
@@ -679,11 +685,22 @@ func parseDefineBundleBlock(label ast.LabelBlockType, block *ast.MergedBlock, re
679685
if err := parseDefineEnvironmentsBlock(subBlock, &ret.Environments); err != nil {
680686
return err
681687
}
688+
case "lets":
689+
if labels.NumLabels != 0 {
690+
return errors.E(
691+
subBlock.RawOrigins[0].LabelRanges(),
692+
`unexpected label %q, expected no labels`,
693+
labels.Labels[0],
694+
)
695+
}
696+
if err := setLets(ret.Lets, subBlock); err != nil {
697+
return err
698+
}
682699
default:
683700
return errors.E(
684701
ErrUnrecognizedDefineSubBlock,
685702
subBlock.RawOrigins[0].DefRange(),
686-
`unexpected block type %q, expected "metadata", "stack", "input", "export", "scaffolding", "environments" or "uses schemas"`,
703+
`unexpected block type %q, expected "metadata", "stack", "input", "export", "scaffolding", "environments", "lets" or "uses schemas"`,
687704
subBlock.Type,
688705
)
689706
}
@@ -702,6 +719,10 @@ func parseDefineBundleBlock(label ast.LabelBlockType, block *ast.MergedBlock, re
702719
if err := parseDefineEnvironmentsBlock(block, &ret.Environments); err != nil {
703720
return err
704721
}
722+
case "lets":
723+
if err := setLets(ret.Lets, block); err != nil {
724+
return err
725+
}
705726
case "stack":
706727
return errors.E(
707728
block.RawOrigins[0].DefRange(),
@@ -720,7 +741,7 @@ func parseDefineBundleBlock(label ast.LabelBlockType, block *ast.MergedBlock, re
720741
default:
721742
return errors.E(
722743
block.RawOrigins[0].DefRange(),
723-
"unexpected block type %q, expected 'metadata', 'input', 'export', 'stack', 'scaffolding' or 'environments'",
744+
"unexpected block type %q, expected 'metadata', 'input', 'export', 'stack', 'lets', 'scaffolding' or 'environments'",
724745
block.Type,
725746
)
726747
}
@@ -766,6 +787,36 @@ func parseDefineBundleBlock(label ast.LabelBlockType, block *ast.MergedBlock, re
766787
return nil
767788
}
768789

790+
func setLets(target *ast.MergedBlock, source *ast.MergedBlock) error {
791+
if err := validateLets(source); err != nil {
792+
return err
793+
}
794+
errs := errors.L()
795+
for _, attr := range source.Attributes {
796+
if existing, ok := target.Attributes[attr.Name]; ok {
797+
errs.Append(errors.E(
798+
ErrTerramateSchema,
799+
attr.NameRange,
800+
`duplicate lets attribute %q (first defined at %s)`,
801+
attr.Name, existing.Range.String(),
802+
))
803+
continue
804+
}
805+
target.Attributes[attr.Name] = attr
806+
}
807+
for lb, sub := range source.Blocks {
808+
existing, ok := target.Blocks[lb]
809+
if !ok {
810+
target.Blocks[lb] = sub
811+
continue
812+
}
813+
for _, raw := range sub.RawOrigins {
814+
errs.Append(existing.MergeBlock(raw, true))
815+
}
816+
}
817+
return errs.AsError()
818+
}
819+
769820
func parseBlockAttributes(block *ast.MergedBlock, validAttrs map[string]**ast.Attribute, errKind errors.Kind) error {
770821
errs := errors.L()
771822
for _, attr := range block.Attributes {

0 commit comments

Comments
 (0)