Skip to content

Commit 03324a3

Browse files
committed
feat(catalog): add discovery block for custom module paths
Add experimental catalog-discovery feature allowing custom module directory paths via discovery blocks in catalog configuration. Previously, catalog only searched the hardcoded "modules/" directory. This change allows repositories with non-standard layouts (e.g., "tf-modules/", "infrastructure/") to be used in the catalog. Changes: - Add discovery block support to catalog configuration - Add ModulePaths field to config.Discovery struct - Add Repo.WithModulePaths() functional option for custom paths - Support multiple discovery blocks with different configurations - Maintain backward compatibility (naked URLs use default "modules/") - Update tests and documentation Closes #4632
1 parent 58904f3 commit 03324a3

File tree

21 files changed

+800
-73
lines changed

21 files changed

+800
-73
lines changed

cli/commands/catalog/catalog_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func TestCatalogCommandInitialization(t *testing.T) {
2525
require.NoError(t, err)
2626

2727
// Create mock repository function for testing
28-
mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool) (*module.Repo, error) {
28+
mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool, opts ...module.RepoOpt) (*module.Repo, error) {
2929
// Create a temporary directory structure for testing
3030
dummyRepoDir := filepath.Join(t.TempDir(), strings.ReplaceAll(repoURL, "github.com/gruntwork-io/", ""))
3131
os.MkdirAll(filepath.Join(dummyRepoDir, ".git"), 0755)

cli/commands/catalog/tui/model_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import (
3131
func createMockCatalogService(t *testing.T, opts *options.TerragruntOptions) catalog.CatalogService {
3232
t.Helper()
3333

34-
mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool) (*module.Repo, error) {
34+
mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool, opts ...module.RepoOpt) (*module.Repo, error) {
3535
// Create a temporary directory structure for testing
3636
dummyRepoDir := filepath.Join(t.TempDir(), strings.ReplaceAll(repoURL, "github.com/gruntwork-io/", ""))
3737

config/catalog.go

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,27 +38,42 @@ var (
3838
catalogBlockReg = regexp.MustCompile(fmt.Sprintf(hclBlockRegExprFmt, MetadataCatalog))
3939
)
4040

41+
type Discovery struct {
42+
URLs []string `hcl:"urls,attr" cty:"urls"`
43+
ModulePaths []string `hcl:"module_paths" cty:"module_paths"`
44+
}
45+
4146
type CatalogConfig struct {
42-
NoShell *bool `hcl:"no_shell,optional" cty:"no_shell"`
43-
NoHooks *bool `hcl:"no_hooks,optional" cty:"no_hooks"`
44-
DefaultTemplate string `hcl:"default_template,optional" cty:"default_template"`
45-
URLs []string `hcl:"urls,attr" cty:"urls"`
47+
NoShell *bool `hcl:"no_shell,optional" cty:"no_shell"`
48+
NoHooks *bool `hcl:"no_hooks,optional" cty:"no_hooks"`
49+
DefaultTemplate string `hcl:"default_template,optional" cty:"default_template"`
50+
URLs []string `hcl:"urls,attr" cty:"urls"`
51+
Discovery []Discovery `hcl:"discovery,block" cty:"discovery"`
4652
}
4753

4854
func (cfg *CatalogConfig) String() string {
49-
return fmt.Sprintf("Catalog{URLs = %v, DefaultTemplate = %v, NoShell = %v, NoHooks = %v}", cfg.URLs, cfg.DefaultTemplate, cfg.NoShell, cfg.NoHooks)
55+
discoveryInfo := "none"
56+
57+
if len(cfg.Discovery) > 0 {
58+
totalURLs := 0
59+
for _, discoveryBlock := range cfg.Discovery {
60+
totalURLs += len(discoveryBlock.URLs)
61+
}
62+
63+
discoveryInfo = fmt.Sprintf("%d discovery block(s) with %d URLs", len(cfg.Discovery), totalURLs)
64+
}
65+
66+
return fmt.Sprintf("Catalog{URLs = %v, DefaultTemplate = %v, NoShell = %v, NoHooks = %v, Discovery = %s}", cfg.URLs, cfg.DefaultTemplate, cfg.NoShell, cfg.NoHooks, discoveryInfo)
5067
}
5168

5269
func (cfg *CatalogConfig) normalize(configPath string) {
5370
configDir := filepath.Dir(configPath)
5471

5572
// transform relative paths to absolute ones
56-
for i, url := range cfg.URLs {
57-
url := filepath.Join(configDir, url)
73+
cfg.URLs = normalizeURLs(configDir, cfg.URLs)
5874

59-
if files.FileExists(url) {
60-
cfg.URLs[i] = url
61-
}
75+
for i := range cfg.Discovery {
76+
cfg.Discovery[i].URLs = normalizeURLs(configDir, cfg.Discovery[i].URLs)
6277
}
6378

6479
if cfg.DefaultTemplate != "" {
@@ -69,6 +84,27 @@ func (cfg *CatalogConfig) normalize(configPath string) {
6984
}
7085
}
7186

87+
func normalizeURLs(baseDir string, urls []string) []string {
88+
if len(urls) == 0 {
89+
return nil
90+
}
91+
92+
normalized := make([]string, 0, len(urls))
93+
94+
for _, url := range urls {
95+
absolutePath := filepath.Join(baseDir, url)
96+
97+
if !files.FileExists(absolutePath) {
98+
normalized = append(normalized, url)
99+
continue
100+
}
101+
102+
normalized = append(normalized, absolutePath)
103+
}
104+
105+
return normalized
106+
}
107+
72108
// ReadCatalogConfig reads the `catalog` block from the nearest `terragrunt.hcl` file in the parent directories.
73109
//
74110
// We want users to be able to browse to any folder in an `infra-live` repo, run `terragrunt catalog` (with no URL) arg.

config/catalog_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,25 @@ func TestCatalogParseConfigFile(t *testing.T) {
121121
DefaultTemplate: "/test/fixtures/scaffold/external-template",
122122
},
123123
},
124+
{
125+
configPath: filepath.Join(basePath, "config5.hcl"),
126+
expectedConfig: &config.CatalogConfig{
127+
URLs: []string{
128+
"github.com/gruntwork-io/terraform-aws-eks",
129+
"github.com/gruntwork-io/terraform-aws-vpc",
130+
},
131+
Discovery: []config.Discovery{
132+
{
133+
URLs: []string{"github.com/acme-corp/infrastructure"},
134+
ModulePaths: []string{"infra"},
135+
},
136+
{
137+
URLs: []string{"github.com/acme-corp/platform"},
138+
ModulePaths: []string{"terraform"},
139+
},
140+
},
141+
},
142+
},
124143
}
125144

126145
for i, tt := range testCases {

docs-starlight/src/content/docs/04-reference/04-experiments.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ The following experiments are available:
7070
- [symlinks](#symlinks)
7171
- [cas](#cas)
7272
- [filter-flag](#filter-flag)
73+
- [catalog-discovery](#catalog-discovery)
7374

7475
### symlinks
7576

@@ -186,6 +187,47 @@ When this experiment stabilizes, the following queue control flags will be depre
186187

187188
The current plan is to continue to support the flags as aliases for particular `--filter` patterns.
188189

190+
#### `catalog-discovery`
191+
192+
Support for configurable module discovery paths in Terragrunt Catalog.
193+
194+
#### `catalog-discovery` - What it does
195+
196+
By default, Terragrunt Catalog searches for modules only in the `modules/` directory of catalog repositories. This experiment enables the `discovery` block in catalog configuration, allowing you to specify custom directories where modules should be discovered. This is useful when your catalog repositories organize modules in non-standard directories (e.g., `tf-modules/`, `infrastructure/`, or organization-specific paths).
197+
198+
**Example usage:**
199+
```hcl
200+
catalog {
201+
# Standard URLs use default "modules/" path
202+
urls = [
203+
"github.com/gruntwork-io/repo-a.git"
204+
]
205+
206+
# Discovery block with custom paths
207+
discovery {
208+
urls = [
209+
"github.com/gruntwork-io/repo-b.git",
210+
"github.com/gruntwork-io/repo-c.git"
211+
]
212+
module_paths = ["tf-modules", "infrastructure", "terraform"]
213+
}
214+
215+
# Multiple discovery blocks with different configurations
216+
discovery {
217+
urls = ["github.com/acme/repo-d.git"]
218+
module_paths = ["infra-modules"]
219+
}
220+
}
221+
```
222+
223+
**Key features:**
224+
225+
- Configure custom module directory paths per repository or group of repositories
226+
- Multiple `discovery` blocks allow different path configurations for different repos
227+
- URLs in `catalog.urls` continue to use the default `modules/` path
228+
- The repository root directory is always checked for modules, regardless of configured paths
229+
230+
189231
## Completed Experiments
190232

191233
- [cli-redesign](#cli-redesign)

internal/experiment/experiment.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ const (
3434
AutoProviderCacheDir = "auto-provider-cache-dir"
3535
// FilterFlag is the experiment that enables usage of the filter flag for filtering components
3636
FilterFlag = "filter-flag"
37+
// CatalogDiscovery is the experiment that enables custom module discovery paths
38+
// via the discovery block in catalog configuration.
39+
CatalogDiscovery = "catalog-discovery"
3740
)
3841

3942
const (
@@ -79,6 +82,9 @@ func NewExperiments() Experiments {
7982
{
8083
Name: FilterFlag,
8184
},
85+
{
86+
Name: CatalogDiscovery,
87+
},
8288
}
8389
}
8490

internal/services/catalog/catalog.go

Lines changed: 90 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525

2626
// NewRepoFunc defines the signature for a function that creates a new repository.
2727
// This allows for mocking in tests.
28-
type NewRepoFunc func(ctx context.Context, l log.Logger, cloneURL, path string, walkWithSymlinks, allowCAS bool) (*module.Repo, error)
28+
type NewRepoFunc func(ctx context.Context, l log.Logger, cloneURL, path string, walkWithSymlinks, allowCAS bool, opts ...module.RepoOpt) (*module.Repo, error)
2929

3030
const (
3131
// tempDirFormat is used to create unique temporary directory names for catalog repositories.
@@ -90,10 +90,13 @@ func (s *catalogServiceImpl) WithRepoURL(repoURL string) CatalogService {
9090
return s
9191
}
9292

93-
// Load implements the CatalogService interface.
93+
// oad implements the CatalogService interface.
9494
// It contains the core logic for cloning/updating repositories and finding Terragrunt modules within them.
9595
func (s *catalogServiceImpl) Load(ctx context.Context, l log.Logger) error {
96-
repoURLs := []string{s.repoURL}
96+
var discoveries []config.Discovery
97+
98+
// Evaluate experimental feature for CatalogDiscovery
99+
catalogDiscovery := s.opts.Experiments.Evaluate(experiment.CatalogDiscovery)
97100

98101
// If no specific repoURL was provided to the service, try to read from catalog config.
99102
if s.repoURL == "" {
@@ -102,65 +105,44 @@ func (s *catalogServiceImpl) Load(ctx context.Context, l log.Logger) error {
102105
return errors.Errorf("failed to read catalog configuration: %w", err)
103106
}
104107

105-
if catalogCfg != nil && len(catalogCfg.URLs) > 0 {
106-
repoURLs = catalogCfg.URLs
107-
} else {
108+
if catalogCfg == nil {
108109
return errors.Errorf("no catalog URLs provided")
109110
}
110-
}
111-
112-
// Remove duplicates
113-
repoURLs = util.RemoveDuplicatesFromList(repoURLs)
114-
if len(repoURLs) == 0 || (len(repoURLs) == 1 && repoURLs[0] == "") {
115-
return errors.Errorf("no valid repository URLs specified after configuration and flag processing")
116-
}
117111

118-
var allModules module.Modules
112+
// Check if we have any valid configuration
113+
hasNakedURLs := len(catalogCfg.URLs) > 0
114+
hasDiscoveryBlocks := len(catalogCfg.Discovery) > 0
119115

120-
// Evaluate experimental features for symlinks and content-addressable storage.
121-
walkWithSymlinks := s.opts.Experiments.Evaluate(experiment.Symlinks)
122-
allowCAS := s.opts.Experiments.Evaluate(experiment.CAS)
123-
124-
var errs []error
125-
126-
for _, currentRepoURL := range repoURLs {
127-
if currentRepoURL == "" {
128-
l.Warnf("Empty repository URL encountered, skipping.")
129-
continue
116+
// Warn if discovery blocks exist but feature is disabled
117+
if !catalogDiscovery && hasDiscoveryBlocks {
118+
l.Warn("Skipping catalog discovery — discovery block detected, but the CatalogDiscovery experiment is disabled")
130119
}
131120

132-
// Create a unique path in the system's temporary directory for this repository.
133-
// The path is based on a SHA1 hash of the repository URL to ensure uniqueness and idempotency.
134-
encodedRepoURL := util.EncodeBase64Sha1(currentRepoURL)
135-
tempPath := filepath.Join(os.TempDir(), fmt.Sprintf(tempDirFormat, encodedRepoURL))
136-
137-
l.Debugf("Processing repository %s in temporary path %s", currentRepoURL, tempPath)
138-
139-
// Initialize the repository. This might involve cloning or updating.
140-
// Use the newRepo function stored in the service instance.
141-
repo, err := s.newRepo(ctx, l, currentRepoURL, tempPath, walkWithSymlinks, allowCAS)
142-
if err != nil {
143-
l.Errorf("Failed to initialize repository %s: %v", currentRepoURL, err)
144-
145-
errs = append(errs, err)
146-
147-
continue
121+
if !hasNakedURLs && (!catalogDiscovery || !hasDiscoveryBlocks) {
122+
return errors.Errorf("no catalog URLs provided")
148123
}
149124

150-
// Find modules within the initialized repository.
151-
repoModules, err := repo.FindModules(ctx)
152-
if err != nil {
153-
l.Errorf("Failed to find modules in repository %s: %v", currentRepoURL, err)
154-
155-
errs = append(errs, err)
125+
if hasNakedURLs {
126+
discoveries = append(discoveries, config.Discovery{URLs: catalogCfg.URLs})
127+
}
156128

157-
continue
129+
if catalogDiscovery && hasDiscoveryBlocks {
130+
discoveries = append(discoveries, catalogCfg.Discovery...)
131+
}
132+
} else {
133+
discoveries = []config.Discovery{
134+
{
135+
URLs: []string{s.repoURL},
136+
},
158137
}
138+
}
159139

160-
l.Infof("Found %d module(s) in repository %q", len(repoModules), currentRepoURL)
161-
allModules = append(allModules, repoModules...)
140+
// De-duplicate URLs within each discovery
141+
for i, discovery := range discoveries {
142+
discoveries[i].URLs = util.RemoveDuplicatesFromList(discovery.URLs)
162143
}
163144

145+
allModules, errs := s.loadModulesFromDiscoveries(ctx, l, discoveries)
164146
s.modules = allModules
165147

166148
if len(errs) > 0 {
@@ -174,6 +156,65 @@ func (s *catalogServiceImpl) Load(ctx context.Context, l log.Logger) error {
174156
return nil
175157
}
176158

159+
func (s *catalogServiceImpl) loadModulesFromDiscoveries(ctx context.Context, l log.Logger, discoveries []config.Discovery) (module.Modules, []error) {
160+
var (
161+
errs []error
162+
allModules module.Modules
163+
)
164+
165+
// Evaluate experimental features for symlinks and content-addressable storage.
166+
walkWithSymlinks := s.opts.Experiments.Evaluate(experiment.Symlinks)
167+
allowCAS := s.opts.Experiments.Evaluate(experiment.CAS)
168+
169+
for _, discovery := range discoveries {
170+
for _, currentRepoURL := range discovery.URLs {
171+
if currentRepoURL == "" {
172+
l.Warnf("Empty repository URL encountered, skipping.")
173+
continue
174+
}
175+
176+
// Create a unique path in the system's temporary directory for this repository.
177+
// The path is based on a SHA1 hash of the repository URL to ensure uniqueness and idempotency.
178+
encodedRepoURL := util.EncodeBase64Sha1(currentRepoURL)
179+
tempPath := filepath.Join(os.TempDir(), fmt.Sprintf(tempDirFormat, encodedRepoURL))
180+
181+
l.Debugf("Processing repository %s in temporary path %s", currentRepoURL, tempPath)
182+
183+
var repoOpts []module.RepoOpt
184+
185+
if len(discovery.ModulePaths) > 0 {
186+
repoOpts = append(repoOpts, module.WithModulePaths(discovery.ModulePaths))
187+
}
188+
189+
// Initialize the repository. This might involve cloning or updating.
190+
// Use the newRepo function stored in the service instance.
191+
repo, err := s.newRepo(ctx, l, currentRepoURL, tempPath, walkWithSymlinks, allowCAS, repoOpts...)
192+
if err != nil {
193+
l.Errorf("Failed to initialize repository %s: %v", currentRepoURL, err)
194+
195+
errs = append(errs, err)
196+
197+
continue
198+
}
199+
200+
// Find modules within the initialized repository.
201+
repoModules, err := repo.FindModules(ctx)
202+
if err != nil {
203+
l.Errorf("Failed to find modules in repository %s: %v", currentRepoURL, err)
204+
205+
errs = append(errs, err)
206+
207+
continue
208+
}
209+
210+
l.Infof("Found %d module(s) in repository %q", len(repoModules), currentRepoURL)
211+
allModules = append(allModules, repoModules...)
212+
}
213+
}
214+
215+
return allModules, errs
216+
}
217+
177218
func (s *catalogServiceImpl) Modules() module.Modules {
178219
return s.modules
179220
}

0 commit comments

Comments
 (0)