Repository: gruntwork-io/terragrunt (language: go)

Issue:
Add option to override default terragrunt boilerplate template for terragrunt catalog
## Describe the enhancement

Currently `terragrunt scaffold` has three ways of consuming a boilerplate template:
1. The built-in default terragrunt template
2. Supplying a custom template as the second argument to the scaffold command
3. Adding a `.boilerplate` folder in the terraform module

When using `terragrunt catalog` option 2 is not available. Additionally, the default terragrunt boilerplate template is not particularly useful. That leaves only option 3. This is not ideal for reasons listed below.

The request would be to add an option to the catalog config that would allow overriding the default template:
```
catalog {
  boilerplate_template = "https://github.com/gruntwork-io/custom-boilerplate-template",
  urls = [
    "https://github.com/gruntwork-io/terragrunt-infrastructure-modules-example",
    "https://github.com/gruntwork-io/terraform-aws-utilities",
    "https://github.com/gruntwork-io/terraform-kubernetes-namespace"
  ]
}
```


## Additional context

The result of the current options for defining a custom boilerplate template in combination with `terragrunt catalog` is that we end up needing to maintain boilerplate templates in all our terraform modules. Since many of these templates are 99.9% identical, it would be much easier for us to maintain a single boilerplate template which can be in its own remote repository. Any updates to that template then automatically apply to all terraform modules, and do not require us to go and update hundreds of terraform modules.


Discussion / hints:
This seems like a totally sensible request, @tgeijg . I'm not sure when we'd be able to prioritize it, so I'll mark it as requesting community contributions. 

It seems like you have a good number of features you'd like to see in boilerplate/scaffold/catalog. If I haven't asked already, are you interested in becoming a Terragrunt contributor?
@yhakbar yes, I'd be interested.
@tgeijg ,

I recommend reading through the [Contribution docs](https://terragrunt.gruntwork.io/docs/community/contributing/) to get a handle on how to contribute to Terragrunt if you haven't before.

At a minimum, you'll want to be able to:
1. Build Terragrunt yourself locally.
2. Write a test that verifies the adjustment to the behavior you're looking for (Given that this is TUI behavior, it might be harder to set this up. We can help you with that).
3. Make a change in Terragrunt, and run the test that you wrote to verify it's working right (You don't need to run all the tests. Just the test you wrote to validate what you're trying to achieve).

I also want to highly encourage you to join our [Discord community](https://discord.gg/SPu4Degs5f) if you're not already a member so that you can ask questions about best practices and get help from maintainers and other contributors if you need it. Feel free to reach out to me personally if you need any guidance.

Fix patch:
diff --git a/cli/commands/catalog/module/module.go b/cli/commands/catalog/module/module.go
index 43f5397e75..17b6d4bbd5 100644
--- a/cli/commands/catalog/module/module.go
+++ b/cli/commands/catalog/module/module.go
@@ -12,7 +12,7 @@ import (
 
 const (
 	defaultDescription   = "(no description found)"
-	maxDescriptionLenght = 200
+	maxDescriptionLength = 200
 )
 
 var (
@@ -86,7 +86,7 @@ func (module *Module) Title() string {
 
 // Description implements /github.com/charmbracelet/bubbles.list.DefaultItem.Description
 func (module *Module) Description() string {
-	if desc := module.Doc.Description(maxDescriptionLenght); desc != "" {
+	if desc := module.Doc.Description(maxDescriptionLength); desc != "" {
 		return desc
 	}
 
diff --git a/cli/commands/scaffold/scaffold.go b/cli/commands/scaffold/scaffold.go
index a88827e62f..6b97e4ae75 100644
--- a/cli/commands/scaffold/scaffold.go
+++ b/cli/commands/scaffold/scaffold.go
@@ -221,62 +221,93 @@ func Run(ctx context.Context, opts *options.TerragruntOptions, moduleURL, templa
 	return nil
 }
 
-// prepareBoilerplateFiles prepares boilerplate files.
-func prepareBoilerplateFiles(ctx context.Context, opts *options.TerragruntOptions, templateURL string, tempDir string) (string, error) {
-	// identify template url
-	templateDir := ""
+// generateDefaultTemplate - write default template to provided dir
+func generateDefaultTemplate(boilerplateDir string) (string, error) {
+	const ownerWriteGlobalReadPerms = 0644
+	if err := os.WriteFile(util.JoinPath(boilerplateDir, config.DefaultTerragruntConfigPath), []byte(DefaultTerragruntTemplate), ownerWriteGlobalReadPerms); err != nil {
+		return "", errors.New(err)
+	}
 
-	if templateURL != "" {
-		// process template url if was passed
-		parsedTemplateURL, err := tf.ToSourceURL(templateURL, tempDir)
-		if err != nil {
-			return "", errors.New(err)
-		}
+	if err := os.WriteFile(util.JoinPath(boilerplateDir, "boilerplate.yml"), []byte(DefaultBoilerplateConfig), ownerWriteGlobalReadPerms); err != nil {
+		return "", errors.New(err)
+	}
 
-		parsedTemplateURL, err = rewriteTemplateURL(ctx, opts, parsedTemplateURL)
-		if err != nil {
-			return "", errors.New(err)
-		}
-		// regenerate template url with all changes
-		templateURL = parsedTemplateURL.String()
+	return boilerplateDir, nil
+}
 
-		// prepare temporary directory for template
-		templateDir, err = os.MkdirTemp("", "template")
-		if err != nil {
-			return "", errors.New(err)
-		}
+// downloadTemplate - parse URL and download files
+func downloadTemplate(ctx context.Context, opts *options.TerragruntOptions, templateURL string, tempDir string) (string, error) {
+	parsedTemplateURL, err := tf.ToSourceURL(templateURL, tempDir)
+	if err != nil {
+		return "", errors.New(err)
+	}
 
-		// downloading template
-		opts.Logger.Infof("Using template from %s", templateURL)
+	parsedTemplateURL, err = rewriteTemplateURL(ctx, opts, parsedTemplateURL)
+	if err != nil {
+		return "", errors.New(err)
+	}
+	// regenerate template url with all changes
+	templateURL = parsedTemplateURL.String()
 
-		if _, err := getter.GetAny(ctx, templateDir, templateURL); err != nil {
-			return "", errors.New(err)
-		}
+	// prepare temporary directory for template
+	templateDir, err := os.MkdirTemp("", "template")
+	if err != nil {
+		return "", errors.New(err)
+	}
+
+	// downloading templateURL
+	opts.Logger.Infof("Using template from %s", templateURL)
+
+	if _, err := getter.GetAny(ctx, templateDir, templateURL); err != nil {
+		return "", errors.New(err)
 	}
-	// prepare boilerplate dir
+
+	return templateDir, nil
+}
+
+// prepareBoilerplateFiles - prepare boilerplate files from provided template, tf module, or (custom) default template
+func prepareBoilerplateFiles(ctx context.Context, opts *options.TerragruntOptions, templateURL string, tempDir string) (string, error) {
 	boilerplateDir := util.JoinPath(tempDir, util.DefaultBoilerplateDir)
-	// use template dir as boilerplate dir
-	if templateDir != "" {
-		boilerplateDir = templateDir
+
+	// process template url if it was passed. This overrides the .boilerplate folder in the OpenTofu/Terraform module
+	if templateURL != "" {
+		// process template url if it was passed
+		tempTemplateDir, err := downloadTemplate(ctx, opts, templateURL, tempDir)
+		if err != nil {
+			return "", errors.New(err)
+		}
+
+		boilerplateDir = tempTemplateDir
 	}
 
 	// if boilerplate dir is not found, create one with default template
 	if !files.IsExistingDir(boilerplateDir) {
-		// no default boilerplate dir, create one
-		defaultTempDir, err := os.MkdirTemp("", "boilerplate")
+		config, err := config.ReadCatalogConfig(ctx, opts)
 		if err != nil {
 			return "", errors.New(err)
 		}
 
-		boilerplateDir = defaultTempDir
+		// use defaultTemplateURL if defined in config, otherwise use basic default template
+		if config != nil && config.DefaultTemplate != "" {
+			// process template url if available
+			tempTemplateDir, err := downloadTemplate(ctx, opts, config.DefaultTemplate, tempDir)
+			if err != nil {
+				return "", errors.New(err)
+			}
 
-		const ownerWriteGlobalReadPerms = 0644
-		if err := os.WriteFile(util.JoinPath(boilerplateDir, "terragrunt.hcl"), []byte(DefaultTerragruntTemplate), ownerWriteGlobalReadPerms); err != nil {
-			return "", errors.New(err)
-		}
+			boilerplateDir = tempTemplateDir
+		} else {
+			defaultTempDir, err := os.MkdirTemp("", "boilerplate")
+			if err != nil {
+				return "", errors.New(err)
+			}
 
-		if err := os.WriteFile(util.JoinPath(boilerplateDir, "boilerplate.yml"), []byte(DefaultBoilerplateConfig), ownerWriteGlobalReadPerms); err != nil {
-			return "", errors.New(err)
+			boilerplateDir = defaultTempDir
+
+			boilerplateDir, err = generateDefaultTemplate(boilerplateDir)
+			if err != nil {
+				return "", errors.New(err)
+			}
 		}
 	}
 
diff --git a/config/catalog.go b/config/catalog.go
index 309c5a2f5b..540b6100f5 100644
--- a/config/catalog.go
+++ b/config/catalog.go
@@ -38,11 +38,12 @@ var (
 )
 
 type CatalogConfig struct {
-	URLs []string `hcl:"urls,attr" cty:"urls"`
+	DefaultTemplate string   `hcl:"default_template,attr" cty:"default_template"`
+	URLs            []string `hcl:"urls,attr" cty:"urls"`
 }
 
 func (cfg *CatalogConfig) String() string {
-	return fmt.Sprintf("Catalog{URLs = %v}", cfg.URLs)
+	return fmt.Sprintf("Catalog{URLs = %v, DefaultTemplate = %v}", cfg.URLs, cfg.DefaultTemplate)
 }
 
 func (cfg *CatalogConfig) normalize(configPath string) {
@@ -56,6 +57,13 @@ func (cfg *CatalogConfig) normalize(configPath string) {
 			cfg.URLs[i] = url
 		}
 	}
+
+	if cfg.DefaultTemplate != "" {
+		path := filepath.Join(configDir, cfg.DefaultTemplate)
+		if files.FileExists(path) {
+			cfg.DefaultTemplate = path
+		}
+	}
 }
 
 // ReadCatalogConfig reads the `catalog` block from the nearest `terragrunt.hcl` file in the parent directories.
diff --git a/docs/_docs/02_features/05-catalog.md b/docs/_docs/02_features/05-catalog.md
index 633e4f290c..e0094f6231 100644
--- a/docs/_docs/02_features/05-catalog.md
+++ b/docs/_docs/02_features/05-catalog.md
@@ -23,13 +23,31 @@ terragrunt catalog <repo-url> [--no-include-root] [--root-file-name] [--output-f
 
 If `<repo-url>` is provided, the repository will be cloned into a temporary directory, otherwise:
 
-1. The repository list are searched in the config file `terragrunt.hcl`. if `terragrunt.hcl` does not exist in the current directory, the config are searched in the parent directories.
+1. The `urls` listed in the `catalog` configuration of your [parent configuration file](#configuration) are used. If the root configuration file does not exist in the current directory, parent directories are recursively searched.
 1. If the repository list is not found in the configuration file, the modules are looked for in the current directory.
 
-An example of how to define the list of repositories for the `catalog` command in the `terragrunt.hcl` configuration file:
+For each of the provided repositories, Terragrunt will recursively search for OpenTofu/Terraform modules from the root of the repo and the `modules` directory. A table with all the discovered OpenTofu/Terraform modules will subsequently be displayed.
+
+You can then:
+1. Search and filter the table: `/` and start typing.
+1. Select a module in the table: use the arrow keys to go up and down and next/previous page.
+1. See the docs for a selected module: `ENTER`.
+1. Use [`terragrunt scaffold`]({{site.baseurl}}/docs/features/scaffold/) to render a `terragrunt.hcl` file that uses the module: `S`.
+
+### Custom templates for scaffolding
+
+Terragrunt has a basic template built-in for rendering `terragrunt.hcl` files, but you can provide your own templates to customize how code is generated! Scaffolding is done via an integration with [boilerplate](https://github.com/gruntwork-io/boilerplate), and Terragrunt allows you to specify custom boilerplate templates via two mechanisms while using catalog:
+
+1. You can define a custom Boilerplate template in a `.boilerplate` sub-directory of any OpenTofu/Terraform module.
+1. You can specify a custom Boilerplate template in the [catalog config](#configuration).
+
+### Configuration
+
+An example of how to define the optional default template, and the list of repositories for the `catalog` command in a `root.hcl` configuration file:
 
 ``` hcl
 catalog {
+  default_template = "git@github.com/acme/example.git//path/to/template"
   urls = [
     "relative/path/to/repo", # will be converted to the absolute path, relative to the path of the configuration file.
     "/absolute/path/to/repo",
@@ -39,17 +57,10 @@ catalog {
 }
 ```
 
-This will recursively search for OpenTofu/Terraform modules in the root of the repo and the `modules` directory and show a table with all the modules. You can then:
-
-1. Search and filter the table: `/` and start typing.
-1. Select a module in the table: use the arrow keys to go up and down and next/previous page.
-1. See the docs for a selected module: `ENTER`.
-1. Use [`terragrunt scaffold`](https://terragrunt.gruntwork.io/docs/features/scaffold/) to render a `terragrunt.hcl` for using the module: `S`.
-
 ## Scaffolding Flags
 
 The following `catalog` flags control behavior of the underlying `scaffold` command when the `S` key is pressed in a catalog entry:
 
 - `--no-include-root` - Do not include the root configuration file in any generated `terragrunt.hcl` during scaffolding.
 - `--root-file-name` - The name of the root configuration file to include in any generated `terragrunt.hcl` during scaffolding. This value also controls the name of the root configuration file to search for when trying to determine Catalog urls.
-- `--output-folder` - Location for generated `terragrunt.hcl`. If flag is not provided current working directory is selected.
+- `--output-folder` - Location for the scaffolded configurations. If flag is not provided current working directory is selected.
\ No newline at end of file
diff --git a/docs/_docs/02_features/06-scaffold.md b/docs/_docs/02_features/06-scaffold.md
index cc0a8b75b3..489f4023c1 100644
--- a/docs/_docs/02_features/06-scaffold.md
+++ b/docs/_docs/02_features/06-scaffold.md
@@ -68,10 +68,11 @@ Important notes:
 
 ## Custom templates for scaffolding
 
-Terragrunt has a basic template built-in for rendering `terragrunt.hcl` files, but you can provide your own templates to customize what code is generated! Scaffolding is done via [boilerplate](https://github.com/gruntwork-io/boilerplate), and Terragrunt allows you to specify custom boilerplate templates via two mechanisms:
+Terragrunt has a basic template built-in for rendering `terragrunt.hcl` files, but you can provide your own templates to customize what code is generated! Scaffolding is done via [boilerplate](https://github.com/gruntwork-io/boilerplate), and Terragrunt allows you to specify custom boilerplate templates via three mechanisms - listed in order of priority:
 
-1. You can specify a custom boilerplate template to use as the second argument of the `scaffold` command.
-1. You can define a custom boilerplate template in a `.boilerplate` subfolder of your module.
+1. You can specify a custom Boilerplate template to use as the second argument of the `scaffold` command.
+1. You can define a custom Boilerplate template in a `.boilerplate` sub-directory of any OpenTofu/Terraform module.
+1. You can define a default custom Boilerplate template in the [catalog config]({{site.baseurl}}/docs/features/catalog/#configuration)
 
 If you define input variables in your boilerplate template, Terragrunt will prompt users for the values. Those values can also be passed in via `--var` and `--var-file` arguments.
 There are also a set of variables that Terragrunt will automatically expose to your boilerplate templates for rendering:


Test patch:
diff --git a/config/catalog_test.go b/config/catalog_test.go
index 8d606fd6bf..adacad0a6b 100644
--- a/config/catalog_test.go
+++ b/config/catalog_test.go
@@ -115,6 +115,12 @@ func TestCatalogParseConfigFile(t *testing.T) {
 				},
 			},
 		},
+		{
+			configPath: filepath.Join(basePath, "config4.hcl"),
+			expectedConfig: &config.CatalogConfig{
+				DefaultTemplate: "/test/fixtures/scaffold/external-template",
+			},
+		},
 	}
 
 	for i, tt := range testCases {
diff --git a/test/fixtures/catalog/config4.hcl b/test/fixtures/catalog/config4.hcl
new file mode 100644
index 0000000000..462d40f9e8
--- /dev/null
+++ b/test/fixtures/catalog/config4.hcl
@@ -0,0 +1,7 @@
+locals {
+  baseRepo = "github.com/gruntwork-io"
+}
+
+catalog {
+  default_template = "/test/fixtures/scaffold/external-template"
+}
diff --git a/test/fixtures/scaffold/custom-default-template/root.hcl b/test/fixtures/scaffold/custom-default-template/root.hcl
new file mode 100644
index 0000000000..87758c50d4
--- /dev/null
+++ b/test/fixtures/scaffold/custom-default-template/root.hcl
@@ -0,0 +1,3 @@
+catalog {
+  default_template = "git@github.com:gruntwork-io/terragrunt.git//test/fixtures/scaffold/external-template"
+}
diff --git a/test/fixtures/scaffold/custom-default-template/unit/.gitkeep b/test/fixtures/scaffold/custom-default-template/unit/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/test/integration_scaffold_test.go b/test/integration_scaffold_test.go
index e988a44b9c..dda8b7d429 100644
--- a/test/integration_scaffold_test.go
+++ b/test/integration_scaffold_test.go
@@ -14,14 +14,15 @@ import (
 )
 
 const (
-	testScaffoldModuleURL              = "https://github.com/gruntwork-io/terragrunt.git//test/fixtures/scaffold/scaffold-module"
-	testScaffoldModuleGit              = "git@github.com:gruntwork-io/terragrunt.git//test/fixtures/scaffold/scaffold-module"
-	testScaffoldModuleShort            = "github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs"
-	testScaffoldTemplateModule         = "git@github.com:gruntwork-io/terragrunt.git//test/fixtures/scaffold/module-with-template"
-	testScaffoldExternalTemplateModule = "git@github.com:gruntwork-io/terragrunt.git//test/fixtures/scaffold/external-template"
-	testScaffoldLocalModulePath        = "fixtures/scaffold/scaffold-module"
-	testScaffoldWithRootHCL            = "fixtures/scaffold/root-hcl"
-	testScaffold3rdPartyModulePath     = "git::https://github.com/Azure/terraform-azurerm-avm-res-compute-virtualmachine.git//.?ref=v0.15.0"
+	testScaffoldModuleURL                 = "https://github.com/gruntwork-io/terragrunt.git//test/fixtures/scaffold/scaffold-module"
+	testScaffoldModuleGit                 = "git@github.com:gruntwork-io/terragrunt.git//test/fixtures/scaffold/scaffold-module"
+	testScaffoldModuleShort               = "github.com/gruntwork-io/terragrunt.git//test/fixtures/inputs"
+	testScaffoldTemplateModule            = "git@github.com:gruntwork-io/terragrunt.git//test/fixtures/scaffold/module-with-template"
+	testScaffoldExternalTemplateModule    = "git@github.com:gruntwork-io/terragrunt.git//test/fixtures/scaffold/external-template"
+	testScaffoldLocalModulePath           = "fixtures/scaffold/scaffold-module"
+	testScaffoldWithRootHCL               = "fixtures/scaffold/root-hcl"
+	testScaffold3rdPartyModulePath        = "git::https://github.com/Azure/terraform-azurerm-avm-res-compute-virtualmachine.git//.?ref=v0.15.0"
+	testScaffoldWithCustomDefaultTemplate = "fixtures/scaffold/custom-default-template"
 )
 
 func TestScaffoldModule(t *testing.T) {
@@ -211,7 +212,7 @@ func TestScaffoldWithRootHCL(t *testing.T) {
 	testPath := util.JoinPath(tmpEnvPath, testScaffoldWithRootHCL)
 
 	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, fmt.Sprintf(
-		"terragrunt --terragrunt-non-interactive --terragrunt-working-dir %s scaffold %s",
+		"terragrunt --non-interactive --working-dir %s scaffold %s",
 		filepath.Join(testPath, "unit"),
 		testScaffoldModuleURL,
 	))
@@ -225,3 +226,23 @@ func TestScaffoldWithRootHCL(t *testing.T) {
 	require.NoError(t, err)
 	assert.Contains(t, content, `path = find_in_parent_folders("root.hcl")`)
 }
+
+func TestScaffoldWithCustomDefaultTemplate(t *testing.T) {
+	t.Parallel()
+
+	tmpEnvPath := helpers.CopyEnvironment(t, testScaffoldWithCustomDefaultTemplate)
+	helpers.CleanupTerraformFolder(t, tmpEnvPath)
+	testPath := util.JoinPath(tmpEnvPath, testScaffoldWithCustomDefaultTemplate)
+
+	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, fmt.Sprintf(
+		"terragrunt --non-interactive --working-dir %s scaffold %s",
+		filepath.Join(testPath, "unit"),
+		testScaffoldModuleURL,
+	))
+
+	require.NoError(t, err)
+	assert.Contains(t, stderr, "Scaffolding completed")
+
+	assert.FileExists(t, filepath.Join(testPath, "unit", "terragrunt.hcl"))
+	assert.FileExists(t, filepath.Join(testPath, "unit", "external-template.txt"))
+}