Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/commands/catalog/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func TestCatalogCommandInitialization(t *testing.T) {
require.NoError(t, err)

// Create mock repository function for testing
mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool) (*module.Repo, error) {
mockNewRepo := func(ctx context.Context, logger log.Logger, repoURL, path string, walkWithSymlinks, allowCAS bool, opts ...module.RepoOpt) (*module.Repo, error) {
// Create a temporary directory structure for testing
dummyRepoDir := filepath.Join(t.TempDir(), strings.ReplaceAll(repoURL, "github.com/gruntwork-io/", ""))
os.MkdirAll(filepath.Join(dummyRepoDir, ".git"), 0755)
Expand Down
2 changes: 1 addition & 1 deletion cli/commands/catalog/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import (
func createMockCatalogService(t *testing.T, opts *options.TerragruntOptions) catalog.CatalogService {
t.Helper()

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

Expand Down
56 changes: 46 additions & 10 deletions config/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,27 +38,42 @@ var (
catalogBlockReg = regexp.MustCompile(fmt.Sprintf(hclBlockRegExprFmt, MetadataCatalog))
)

type Discovery struct {
URLs []string `hcl:"urls,attr" cty:"urls"`
ModulePaths []string `hcl:"module_paths" cty:"module_paths"`
}

type CatalogConfig struct {
NoShell *bool `hcl:"no_shell,optional" cty:"no_shell"`
NoHooks *bool `hcl:"no_hooks,optional" cty:"no_hooks"`
DefaultTemplate string `hcl:"default_template,optional" cty:"default_template"`
URLs []string `hcl:"urls,attr" cty:"urls"`
NoShell *bool `hcl:"no_shell,optional" cty:"no_shell"`
NoHooks *bool `hcl:"no_hooks,optional" cty:"no_hooks"`
DefaultTemplate string `hcl:"default_template,optional" cty:"default_template"`
URLs []string `hcl:"urls,attr" cty:"urls"`
Discovery []Discovery `hcl:"discovery,block" cty:"discovery"`
}

func (cfg *CatalogConfig) String() string {
return fmt.Sprintf("Catalog{URLs = %v, DefaultTemplate = %v, NoShell = %v, NoHooks = %v}", cfg.URLs, cfg.DefaultTemplate, cfg.NoShell, cfg.NoHooks)
discoveryInfo := "none"

if len(cfg.Discovery) > 0 {
totalURLs := 0
for _, discoveryBlock := range cfg.Discovery {
totalURLs += len(discoveryBlock.URLs)
}

discoveryInfo = fmt.Sprintf("%d discovery block(s) with %d URLs", len(cfg.Discovery), totalURLs)
}

return fmt.Sprintf("Catalog{URLs = %v, DefaultTemplate = %v, NoShell = %v, NoHooks = %v, Discovery = %s}", cfg.URLs, cfg.DefaultTemplate, cfg.NoShell, cfg.NoHooks, discoveryInfo)
}

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

// transform relative paths to absolute ones
for i, url := range cfg.URLs {
url := filepath.Join(configDir, url)
cfg.URLs = normalizeURLs(configDir, cfg.URLs)

if files.FileExists(url) {
cfg.URLs[i] = url
}
for i := range cfg.Discovery {
cfg.Discovery[i].URLs = normalizeURLs(configDir, cfg.Discovery[i].URLs)
}

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

func normalizeURLs(baseDir string, urls []string) []string {
if len(urls) == 0 {
return nil
}

normalized := make([]string, 0, len(urls))

for _, url := range urls {
absolutePath := filepath.Join(baseDir, url)

if !files.FileExists(absolutePath) {
normalized = append(normalized, url)
continue
}

normalized = append(normalized, absolutePath)
}

return normalized
}

// ReadCatalogConfig reads the `catalog` block from the nearest `terragrunt.hcl` file in the parent directories.
//
// We want users to be able to browse to any folder in an `infra-live` repo, run `terragrunt catalog` (with no URL) arg.
Expand Down
19 changes: 19 additions & 0 deletions config/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,25 @@ func TestCatalogParseConfigFile(t *testing.T) {
DefaultTemplate: "/test/fixtures/scaffold/external-template",
},
},
{
configPath: filepath.Join(basePath, "config5.hcl"),
expectedConfig: &config.CatalogConfig{
URLs: []string{
"github.com/gruntwork-io/terraform-aws-eks",
"github.com/gruntwork-io/terraform-aws-vpc",
},
Discovery: []config.Discovery{
{
URLs: []string{"github.com/acme-corp/infrastructure"},
ModulePaths: []string{"infra"},
},
{
URLs: []string{"github.com/acme-corp/platform"},
ModulePaths: []string{"terraform"},
},
},
},
},
}

for i, tt := range testCases {
Expand Down
42 changes: 42 additions & 0 deletions docs-starlight/src/content/docs/04-reference/04-experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ The following experiments are available:
- [symlinks](#symlinks)
- [cas](#cas)
- [filter-flag](#filter-flag)
- [catalog-discovery](#catalog-discovery)

### symlinks

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

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

#### `catalog-discovery`

Support for configurable module discovery paths in Terragrunt Catalog.

#### `catalog-discovery` - What it does

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).

**Example usage:**
```hcl
catalog {
# Standard URLs use default "modules/" path
urls = [
"github.com/gruntwork-io/repo-a.git"
]

# Discovery block with custom paths
discovery {
urls = [
"github.com/gruntwork-io/repo-b.git",
"github.com/gruntwork-io/repo-c.git"
]
module_paths = ["tf-modules", "infrastructure", "terraform"]
}

# Multiple discovery blocks with different configurations
discovery {
urls = ["github.com/acme/repo-d.git"]
module_paths = ["infra-modules"]
}
}
```

**Key features:**

- Configure custom module directory paths per repository or group of repositories
- Multiple `discovery` blocks allow different path configurations for different repos
- URLs in `catalog.urls` continue to use the default `modules/` path
- The repository root directory is always checked for modules, regardless of configured paths


## Completed Experiments

- [cli-redesign](#cli-redesign)
Expand Down
6 changes: 6 additions & 0 deletions internal/experiment/experiment.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const (
AutoProviderCacheDir = "auto-provider-cache-dir"
// FilterFlag is the experiment that enables usage of the filter flag for filtering components
FilterFlag = "filter-flag"
// CatalogDiscovery is the experiment that enables custom module discovery paths
// via the discovery block in catalog configuration.
CatalogDiscovery = "catalog-discovery"
)

const (
Expand Down Expand Up @@ -79,6 +82,9 @@ func NewExperiments() Experiments {
{
Name: FilterFlag,
},
{
Name: CatalogDiscovery,
},
}
}

Expand Down
137 changes: 89 additions & 48 deletions internal/services/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import (

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

const (
// tempDirFormat is used to create unique temporary directory names for catalog repositories.
Expand Down Expand Up @@ -93,7 +93,10 @@ func (s *catalogServiceImpl) WithRepoURL(repoURL string) CatalogService {
// Load implements the CatalogService interface.
// It contains the core logic for cloning/updating repositories and finding Terragrunt modules within them.
func (s *catalogServiceImpl) Load(ctx context.Context, l log.Logger) error {
repoURLs := []string{s.repoURL}
var discoveries []config.Discovery

// Evaluate experimental feature for CatalogDiscovery
catalogDiscovery := s.opts.Experiments.Evaluate(experiment.CatalogDiscovery)

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

if catalogCfg != nil && len(catalogCfg.URLs) > 0 {
repoURLs = catalogCfg.URLs
} else {
if catalogCfg == nil {
return errors.Errorf("no catalog URLs provided")
}
}

// Remove duplicates
repoURLs = util.RemoveDuplicatesFromList(repoURLs)
if len(repoURLs) == 0 || (len(repoURLs) == 1 && repoURLs[0] == "") {
return errors.Errorf("no valid repository URLs specified after configuration and flag processing")
}

var allModules module.Modules
// Check if we have any valid configuration
hasNakedURLs := len(catalogCfg.URLs) > 0
hasDiscoveryBlocks := len(catalogCfg.Discovery) > 0

// Evaluate experimental features for symlinks and content-addressable storage.
walkWithSymlinks := s.opts.Experiments.Evaluate(experiment.Symlinks)
allowCAS := s.opts.Experiments.Evaluate(experiment.CAS)

var errs []error

for _, currentRepoURL := range repoURLs {
if currentRepoURL == "" {
l.Warnf("Empty repository URL encountered, skipping.")
continue
// Warn if discovery blocks exist but feature is disabled
if !catalogDiscovery && hasDiscoveryBlocks {
l.Warn("Skipping catalog discovery — discovery block detected, but the CatalogDiscovery experiment is disabled")
}

// Create a unique path in the system's temporary directory for this repository.
// The path is based on a SHA1 hash of the repository URL to ensure uniqueness and idempotency.
encodedRepoURL := util.EncodeBase64Sha1(currentRepoURL)
tempPath := filepath.Join(os.TempDir(), fmt.Sprintf(tempDirFormat, encodedRepoURL))

l.Debugf("Processing repository %s in temporary path %s", currentRepoURL, tempPath)

// Initialize the repository. This might involve cloning or updating.
// Use the newRepo function stored in the service instance.
repo, err := s.newRepo(ctx, l, currentRepoURL, tempPath, walkWithSymlinks, allowCAS)
if err != nil {
l.Errorf("Failed to initialize repository %s: %v", currentRepoURL, err)

errs = append(errs, err)

continue
if !hasNakedURLs && (!catalogDiscovery || !hasDiscoveryBlocks) {
return errors.Errorf("no catalog URLs provided")
}

// Find modules within the initialized repository.
repoModules, err := repo.FindModules(ctx)
if err != nil {
l.Errorf("Failed to find modules in repository %s: %v", currentRepoURL, err)

errs = append(errs, err)
if hasNakedURLs {
discoveries = append(discoveries, config.Discovery{URLs: catalogCfg.URLs})
}

continue
if catalogDiscovery && hasDiscoveryBlocks {
discoveries = append(discoveries, catalogCfg.Discovery...)
}
} else {
discoveries = []config.Discovery{
{
URLs: []string{s.repoURL},
},
}
}

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

allModules, errs := s.loadModulesFromDiscoveries(ctx, l, discoveries)
s.modules = allModules

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

func (s *catalogServiceImpl) loadModulesFromDiscoveries(ctx context.Context, l log.Logger, discoveries []config.Discovery) (module.Modules, []error) {
var (
errs []error
allModules module.Modules
)

// Evaluate experimental features for symlinks and content-addressable storage.
walkWithSymlinks := s.opts.Experiments.Evaluate(experiment.Symlinks)
allowCAS := s.opts.Experiments.Evaluate(experiment.CAS)

for _, discovery := range discoveries {
for _, currentRepoURL := range discovery.URLs {
if currentRepoURL == "" {
l.Warnf("Empty repository URL encountered, skipping.")
continue
}

// Create a unique path in the system's temporary directory for this repository.
// The path is based on a SHA1 hash of the repository URL to ensure uniqueness and idempotency.
encodedRepoURL := util.EncodeBase64Sha1(currentRepoURL)
tempPath := filepath.Join(os.TempDir(), fmt.Sprintf(tempDirFormat, encodedRepoURL))

l.Debugf("Processing repository %s in temporary path %s", currentRepoURL, tempPath)

var repoOpts []module.RepoOpt

if len(discovery.ModulePaths) > 0 {
repoOpts = append(repoOpts, module.WithModulePaths(discovery.ModulePaths))
}

// Initialize the repository. This might involve cloning or updating.
// Use the newRepo function stored in the service instance.
repo, err := s.newRepo(ctx, l, currentRepoURL, tempPath, walkWithSymlinks, allowCAS, repoOpts...)
if err != nil {
l.Errorf("Failed to initialize repository %s: %v", currentRepoURL, err)

errs = append(errs, err)

continue
}

// Find modules within the initialized repository.
repoModules, err := repo.FindModules(ctx)
if err != nil {
l.Errorf("Failed to find modules in repository %s: %v", currentRepoURL, err)

errs = append(errs, err)

continue
}

l.Infof("Found %d module(s) in repository %q", len(repoModules), currentRepoURL)
allModules = append(allModules, repoModules...)
}
}

return allModules, errs
}
Comment on lines +159 to +216
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

LGTM! Clean helper function with proper error handling.

The loadModulesFromDiscoveries function is well-designed:

  • Accumulates errors while continuing to process all repositories
  • Conditionally applies ModulePaths via RepoOpt when configured (lines 185-187)
  • Provides clear debug and error logging
  • Handles empty URLs defensively (lines 171-174)

Note: Temporary directories are created at line 179 but not explicitly cleaned up in this code. Verify that cleanup is handled elsewhere (e.g., OS temp directory cleanup, or cleanup in a calling function/defer).


🏁 Script executed:

#!/bin/bash
# Search for cleanup logic related to catalog temp directories
rg -n -C3 'catalog-.*EncodeBase64Sha1|TempDir.*catalog|RemoveAll.*catalog' --type=go

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find all calls to loadModulesFromDiscoveries to see how temp dirs might be cleaned up
rg -n 'loadModulesFromDiscoveries' --type=go -C5

Length of output: 1492


🏁 Script executed:

#!/bin/bash
# Search for RemoveAll, Remove, or os.Temp cleanup patterns in the catalog service
rg -n 'RemoveAll|os\.Remove|defer.*Remove' internal/services/catalog/ --type=go -C3

Length of output: 582


🏁 Script executed:

#!/bin/bash
# Look for defer statements in the service that might handle cleanup
rg -n 'defer' internal/services/catalog/ --type=go -C3

Length of output: 1363


🏁 Script executed:

#!/bin/bash
# Check the repo struct definition and see if it has cleanup/Close methods
rg -n 'type.*Repo.*struct|func.*\(.*\*Repo\).*Close|func.*\(.*\*Repo\).*Cleanup' internal/services/catalog/module/ --type=go -A5

Length of output: 847


🏁 Script executed:

#!/bin/bash
# Search for calls to the Load function to see caller context
rg -n '\.Load\(' --type=go -C4

Length of output: 14737


🏁 Script executed:

#!/bin/bash
# Check the complete Load function to see if there's any cleanup logic we missed
rg -n 'func.*Load' internal/services/catalog/catalog.go --type=go -A30

Length of output: 1234


Add explicit cleanup of temporary directories to prevent resource leak.

The temporary directories created at line 179 are never cleaned up. While the code correctly accumulates errors and continues processing, it leaves behind orphaned directories in the system's temp folder.

These temp directories should be cleaned up after processing completes. Either:

  • Add a defer os.RemoveAll(tempPath) after the temp path is created and before repository operations begin, or
  • Add cleanup logic to the repo's Close/Cleanup method and call it after FindModules completes

This prevents accumulation of unused temporary directories and ensures resources are properly released.

🤖 Prompt for AI Agents
In internal/services/catalog/catalog.go around lines 159 to 216, the temporary
directory created at tempPath (line ~179) is never cleaned up, leaking temp
directories; fix by ensuring tempPath is removed after processing each
repository: either call os.RemoveAll(tempPath) in all exit paths (after errors
and after successful FindModules) or invoke the repo cleanup/Close method (which
should remove the tempPath) immediately after you finish using repo (and ensure
you call it on every continue/error path); add the cleanup calls so every loop
iteration always removes the temporary directory before proceeding to the next
URL.


func (s *catalogServiceImpl) Modules() module.Modules {
return s.modules
}
Expand Down
Loading