Skip to content

Commit bc61536

Browse files
authored
Migrate changes from Catalyst (#2285)
<!-- Thanks for sending a pull request! Here are some tips for you: 1. If this is your first time, please read our contributor guidelines: https://github.com/terramate-io/terramate/blob/main/CONTRIBUTING.md 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 migrates the changes from the Catalyst repo back to Terramate. In short, this includes * An updated `generate` to support bundles, components, etc. * A new command `scaffold` that provides a UI to add bundles. * A new command `component create` to auto-generate a component from a TF module. ## 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 ``` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Large cross-cutting change to core generation and engine loading (new bundle stack creation + YAML config merging + DI wiring), which can affect stack selection/generation behavior and project loading across commands. > > **Overview** > Introduces a new bundles/components framework and bumps version to `0.16.0-rc1`, including release tweaks (skip Fury uploads for prereleases) and changelog updates. > > Adds three new CLI capabilities: `scaffold` (interactive TUI to pick bundles from local or package manifests, prompt for inputs, write bundle instances as HCL/YAML, and optionally run `generate`), `component create` (scan a Terraform module for `variable` blocks and emit a `component.tm.hcl` definition + generated `generate_hcl` stub with repo-derived metadata), and `package create` (build a package manifest + optionally copy local bundle/component definitions into an output directory). > > Overhauls `generate` and engine boot/reload to support bundles/components end-to-end: bundle instances can be defined via `.tm.yml/.tm.yaml` (merged into config on load), bundle stack metadata can auto-create missing stacks and merge metadata into existing stacks, component sources are preloaded to avoid races, and generate APIs are now DI-backed (`generate.API.Load`) with updated call sites and context-aware engine loading/reload. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 66ced14. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
2 parents a22c73f + 66ced14 commit bc61536

File tree

101 files changed

+16083
-472
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

101 files changed

+16083
-472
lines changed

.goreleaser.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ furies:
5555
-
5656
account: terramate
5757
secret_name: FURY_TOKEN
58+
skip: "{{ .Prerelease }}"
5859
formats:
5960
- deb
6061
- rpm

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ Given a version number `MAJOR.MINOR.PATCH`, we increment the:
2424

2525
### Added
2626

27+
- Add new bundles and components framework.
28+
- Update `generate` to bundle/component code generation.
29+
- Add new `scaffold` command that provides an interactive UI to instantiate bundles.
30+
- Add new `component create` to auto-create component defintions from existing Terraform modules.
2731
- Add orchestration metadata fields to `terramate debug show metadata` command output.
2832
- The command now displays `before`, `after`, `wants`, and `wanted_by` fields for each stack.
2933

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.15.5
1+
0.16.0-rc1
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// TERRAMATE: GENERATED AUTOMATICALLY DO NOT EDIT
2+
3+
resource "local_file" "create" {
4+
content = <<-EOT
5+
package create // import "github.com/terramate-io/terramate/commands/component/create"
6+
7+
Package create provides the component create command.
8+
9+
type Metadata struct{ ... }
10+
type Spec struct{ ... }
11+
type Variable struct{ ... }
12+
EOT
13+
14+
filename = "${path.module}/mock-create.ignore"
15+
}
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
// Copyright 2025 Terramate GmbH
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
// Package create provides the component create command.
5+
package create
6+
7+
import (
8+
"context"
9+
"fmt"
10+
"io/fs"
11+
"os"
12+
"path/filepath"
13+
"slices"
14+
"strings"
15+
16+
"github.com/apparentlymart/go-versions/versions"
17+
"github.com/rs/zerolog/log"
18+
"github.com/zclconf/go-cty/cty"
19+
20+
"github.com/terramate-io/hcl/v2"
21+
"github.com/terramate-io/hcl/v2/hclparse"
22+
"github.com/terramate-io/hcl/v2/hclsyntax"
23+
"github.com/terramate-io/hcl/v2/hclwrite"
24+
"github.com/terramate-io/terramate/commands"
25+
"github.com/terramate-io/terramate/engine"
26+
"github.com/terramate-io/terramate/errors"
27+
"github.com/terramate-io/terramate/git"
28+
"github.com/terramate-io/terramate/hcl/ast"
29+
"github.com/terramate-io/terramate/printer"
30+
)
31+
32+
// Spec is the command specification for the package publish command.
33+
type Spec struct {
34+
Path string
35+
workingDir string
36+
engine *engine.Engine
37+
printers printer.Printers
38+
}
39+
40+
// Name returns the name of the command.
41+
func (s *Spec) Name() string { return "component create" }
42+
43+
// Requirements returns the requirements of the command.
44+
func (s *Spec) Requirements(context.Context, commands.CLI) any { return commands.RequireEngine() }
45+
46+
// Exec executes the create-component command.
47+
func (s *Spec) Exec(_ context.Context, cli commands.CLI) error {
48+
s.engine = cli.Engine()
49+
s.printers = cli.Printers()
50+
51+
// Get the project root directory - this is not affected by -C flag
52+
root := s.engine.Config()
53+
rootDir := root.HostDir()
54+
55+
workingDir := cli.WorkingDir()
56+
log.Debug().
57+
Str("cli.WorkingDir()", workingDir).
58+
Str("root.HostDir()", rootDir).
59+
Str("s.Path", s.Path).
60+
Msg("component create: path resolution")
61+
62+
// Resolve the target path relative to the working directory.
63+
// Absolute paths (e.g. /modules/s3-module) are project-relative and joined with rootDir.
64+
// Relative paths are joined with workingDir.
65+
var targetPath string
66+
if s.Path == "" || s.Path == "." {
67+
targetPath = workingDir
68+
} else if filepath.IsAbs(s.Path) {
69+
targetPath = filepath.Clean(filepath.Join(rootDir, s.Path))
70+
} else {
71+
targetPath = filepath.Clean(filepath.Join(workingDir, s.Path))
72+
}
73+
74+
// Ensure the resolved path is within the project root.
75+
if targetPath != rootDir && !strings.HasPrefix(targetPath, rootDir+string(filepath.Separator)) {
76+
return errors.E("target path %s is outside the project root %s", targetPath, rootDir)
77+
}
78+
79+
log.Debug().
80+
Str("final_targetPath", targetPath).
81+
Msg("component create: final resolved path")
82+
83+
// Validate that the path exists and is a directory
84+
info, err := os.Stat(targetPath)
85+
if err != nil {
86+
return errors.E(err, "path does not exist: %s", targetPath)
87+
}
88+
if !info.IsDir() {
89+
return errors.E("path is not a directory: %s", targetPath)
90+
}
91+
92+
s.workingDir = targetPath
93+
94+
logger := log.With().Logger()
95+
96+
parser := hclparse.NewParser()
97+
98+
var vs []Variable
99+
100+
err = filepath.WalkDir(s.workingDir, func(path string, d fs.DirEntry, err error) error {
101+
if err != nil {
102+
return err
103+
}
104+
if path == s.workingDir {
105+
return nil
106+
}
107+
if d.IsDir() {
108+
return filepath.SkipDir
109+
}
110+
if !d.Type().IsRegular() || !strings.HasSuffix(path, ".tf") {
111+
return nil
112+
}
113+
114+
hclFile, diags := parser.ParseHCLFile(path)
115+
if diags.HasErrors() {
116+
return errors.E(diags)
117+
}
118+
119+
body := hclFile.Body.(*hclsyntax.Body)
120+
121+
for _, block := range body.Blocks {
122+
if block.Type != "variable" {
123+
continue
124+
}
125+
var v Variable
126+
127+
if len(block.Labels) == 1 {
128+
v.Name = block.Labels[0]
129+
} else {
130+
logger.Debug().Msgf("ignoring variable block with %d labels", len(block.Labels))
131+
continue
132+
}
133+
134+
typeAttr, ok := block.Body.Attributes["type"]
135+
if ok {
136+
v.Type = ast.TokensForExpression(typeAttr.Expr)
137+
}
138+
descAttr, ok := block.Body.Attributes["description"]
139+
if ok {
140+
v.Description = ast.TokensForExpression(descAttr.Expr)
141+
}
142+
defaultAttr, ok := block.Body.Attributes["default"]
143+
if ok {
144+
v.Default = ast.TokensForExpression(defaultAttr.Expr)
145+
}
146+
147+
vs = append(vs, v)
148+
}
149+
return nil
150+
})
151+
if err != nil {
152+
return err
153+
}
154+
155+
md, err := s.detectComponentMetadata()
156+
if err != nil {
157+
return err
158+
}
159+
160+
fileData, err := buildComponentData(md, vs)
161+
if err != nil {
162+
return err
163+
}
164+
165+
outfile := filepath.Join(s.workingDir, "component.tm.hcl")
166+
167+
if _, err := os.Stat(outfile); err == nil || !os.IsNotExist(err) {
168+
return errors.E("output fil already exists: %s", outfile)
169+
}
170+
171+
if err := os.WriteFile(outfile, fileData, 0644); err != nil {
172+
return errors.E(err, "failed to write manifest file")
173+
}
174+
175+
return nil
176+
}
177+
178+
// Metadata holds the auto-detected metadata for a component definition.
179+
type Metadata struct {
180+
Name string
181+
Class string
182+
Description string
183+
Version string
184+
Source string
185+
}
186+
187+
// Variable represents a Terraform variable extracted from a module.
188+
type Variable struct {
189+
Name string
190+
Type hclwrite.Tokens
191+
Description hclwrite.Tokens
192+
Default hclwrite.Tokens
193+
}
194+
195+
func (s *Spec) detectComponentMetadata() (*Metadata, error) {
196+
md := &Metadata{}
197+
198+
g := s.engine.Project().Git.Wrapper
199+
200+
rootDir, err := g.Root()
201+
if err != nil {
202+
return nil, err
203+
}
204+
205+
repoURL, err := g.URL("origin")
206+
if err != nil {
207+
return nil, err
208+
}
209+
210+
repo, err := git.NormalizeGitURI(repoURL)
211+
if err != nil {
212+
return nil, err
213+
}
214+
215+
repoDir, err := filepath.Rel(rootDir, s.workingDir)
216+
if err != nil {
217+
return nil, err
218+
}
219+
repoDir = filepath.ToSlash(repoDir)
220+
221+
if repoDir != "" {
222+
md.Source = fmt.Sprintf("%s//%s", repo.Repo, repoDir)
223+
} else {
224+
md.Source = repo.Repo
225+
}
226+
227+
dirNames := strings.Split(repoDir, "/")
228+
229+
var classParts []string
230+
231+
ignoredDirNames := []string{
232+
"github.com",
233+
}
234+
235+
for _, dir := range slices.Backward(dirNames) {
236+
if slices.Contains(ignoredDirNames, dir) {
237+
continue
238+
}
239+
// If we reach a modules folder, we stop, asssuming we leave the range
240+
// of anything relevant for module naming.
241+
if dir == "modules" {
242+
break
243+
}
244+
245+
// Is this a version?
246+
if v, found := detectVersion(dir); found {
247+
if md.Version == "" {
248+
md.Version = v
249+
}
250+
continue
251+
}
252+
253+
// First folder that is not a version is the name.
254+
if md.Name == "" {
255+
md.Name = dir
256+
}
257+
classParts = append(classParts, dir)
258+
}
259+
260+
slices.Reverse(classParts)
261+
md.Class = strings.Join(classParts, "/")
262+
263+
// Defaults
264+
if md.Version == "" {
265+
md.Version = "1.0.0"
266+
}
267+
if md.Name == "" {
268+
md.Name = "unnamed-component"
269+
}
270+
if md.Class == "" {
271+
md.Class = "unnamed-component-class"
272+
}
273+
274+
md.Description = fmt.Sprintf("Auto-generated from Terraform module at %s", md.Source)
275+
276+
return md, nil
277+
}
278+
279+
func detectVersion(dir string) (string, bool) {
280+
dir, _ = strings.CutPrefix(dir, "v")
281+
v, err := versions.ParseVersion(dir)
282+
if err == nil {
283+
return v.String(), true
284+
}
285+
return "", false
286+
}
287+
288+
func buildComponentData(md *Metadata, vs []Variable) ([]byte, error) {
289+
file := hclwrite.NewEmptyFile()
290+
fileBody := file.Body()
291+
292+
metadataBlock := fileBody.AppendNewBlock("define", []string{"component", "metadata"}).Body()
293+
metadataBlock.SetAttributeValue("class", cty.StringVal(md.Class))
294+
metadataBlock.SetAttributeValue("version", cty.StringVal(md.Version))
295+
metadataBlock.SetAttributeValue("name", cty.StringVal(md.Name))
296+
metadataBlock.SetAttributeValue("description", cty.StringVal(md.Description))
297+
metadataBlock.SetAttributeValue("technologies", cty.TupleVal([]cty.Value{cty.StringVal("terraform")}))
298+
299+
inputsBlock := fileBody.AppendNewBlock("define", []string{"component"}).Body()
300+
301+
for _, v := range vs {
302+
inputBlocks := inputsBlock.AppendNewBlock("input", []string{v.Name}).Body()
303+
if v.Type != nil {
304+
inputBlocks.SetAttributeRaw("type", v.Type)
305+
}
306+
if v.Description != nil {
307+
inputBlocks.SetAttributeRaw("description", v.Description)
308+
}
309+
if v.Default != nil {
310+
inputBlocks.SetAttributeRaw("default", v.Default)
311+
}
312+
}
313+
314+
genhclBlock := fileBody.AppendNewBlock("generate_hcl", []string{"main.tf"}).Body()
315+
contentBlock := genhclBlock.AppendNewBlock("content", []string{}).Body()
316+
moduleBlock := contentBlock.AppendNewBlock("module", []string{"name"}).Body()
317+
318+
moduleBlock.SetAttributeValue("source", cty.StringVal(md.Source))
319+
320+
for _, v := range vs {
321+
moduleBlock.SetAttributeTraversal(v.Name, hcl.Traversal{
322+
hcl.TraverseRoot{Name: "component"},
323+
hcl.TraverseAttr{Name: "input"},
324+
hcl.TraverseAttr{Name: v.Name},
325+
hcl.TraverseAttr{Name: "value"},
326+
})
327+
}
328+
329+
return hclwrite.Format(file.Bytes()), nil
330+
}

0 commit comments

Comments
 (0)