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

Issue:
Source map might not work in stack run
I understand that `unit.source` of `terragrunt.stack.hcl`  doesn't support source-maps, but the `terraform.source` of `terragrunt.hcl` does support the source-map. However, when running `terragrunt stack apply`, the source-map is not used for `terraform.source` in `terragrunt.hcl` . Is this also as designed?

Alternatively, if I apply the stack with `terragrunt stack generate` followed by `terragrunt run-all apply`, the source-map is respected.

I've been trying out stacks, and even though I've configured my `unit.source` to point to a local directory, I'd like to be able to substitute the module's source on the fly.

_Originally posted by @mhulscher in https://github.com/gruntwork-io/terragrunt/issues/3313#issuecomment-2676119235_


Discussion / hints:

Fix patch:
diff --git a/cli/commands/stack/action.go b/cli/commands/stack/action.go
index f5b769e1a9..46e7e0d43e 100644
--- a/cli/commands/stack/action.go
+++ b/cli/commands/stack/action.go
@@ -26,7 +26,10 @@ func RunGenerate(ctx context.Context, opts *options.TerragruntOptions) error {
 		return err
 	}
 
-	return generateStack(ctx, opts)
+	opts.TerragruntStackConfigPath = filepath.Join(opts.WorkingDir, config.DefaultStackFile)
+	opts.Logger.Infof("Generating stack from %s", opts.TerragruntStackConfigPath)
+
+	return config.GenerateStacks(ctx, opts)
 }
 
 // Run execute stack command.
diff --git a/cli/commands/stack/command.go b/cli/commands/stack/command.go
index 8976c43232..4302dfca08 100644
--- a/cli/commands/stack/command.go
+++ b/cli/commands/stack/command.go
@@ -25,26 +25,20 @@ const (
 	jsonOutputFormat = "json"
 )
 
-// NewFlags builds the flags for stack.
-func NewFlags(_ *options.TerragruntOptions) cli.Flags {
-	return cli.Flags{}
-}
-
 // NewCommand builds the command for stack.
 func NewCommand(opts *options.TerragruntOptions) *cli.Command {
 	return &cli.Command{
 		Name:                 CommandName,
 		Usage:                "Terragrunt stack commands.",
 		ErrorOnUndefinedFlag: true,
-		Flags:                NewFlags(opts).Sort(),
 		Subcommands: cli.Commands{
 			&cli.Command{
 				Name:  generateCommandName,
 				Usage: "Generate a stack from a terragrunt.stack.hcl file",
 				Action: func(ctx *cli.Context) error {
 					return RunGenerate(ctx.Context, opts.OptionsFromContext(ctx))
-
 				},
+				Flags: defaultFlags(opts).Sort(),
 			},
 			&cli.Command{
 				Name:  runCommandName,
@@ -52,7 +46,7 @@ func NewCommand(opts *options.TerragruntOptions) *cli.Command {
 				Action: func(ctx *cli.Context) error {
 					return Run(ctx.Context, opts.OptionsFromContext(ctx))
 				},
-				Flags: runFlags(opts).Sort(),
+				Flags: defaultFlags(opts).Sort(),
 			},
 			&cli.Command{
 				Name:  outputCommandName,
@@ -81,7 +75,7 @@ func NewCommand(opts *options.TerragruntOptions) *cli.Command {
 func outputFlags(opts *options.TerragruntOptions, prefix flags.Prefix) cli.Flags {
 	tgPrefix := prefix.Prepend(flags.TgPrefix)
 
-	return cli.Flags{
+	flags := cli.Flags{
 		flags.NewFlag(&cli.GenericFlag[string]{
 			Name:        OutputFormatFlagName,
 			EnvVars:     tgPrefix.EnvVars(OutputFormatFlagName),
@@ -105,9 +99,11 @@ func outputFlags(opts *options.TerragruntOptions, prefix flags.Prefix) cli.Flags
 			},
 		}),
 	}
+
+	return append(defaultFlags(opts), flags...)
 }
 
-func runFlags(opts *options.TerragruntOptions) cli.Flags {
+func defaultFlags(opts *options.TerragruntOptions) cli.Flags {
 	flags := runall.NewFlags(opts, runCommandName, nil).Filter(runall.OutDirFlagName, runall.JSONOutDirFlagName)
 	flags = append(flags, run.NewFlags(opts, nil)...)
 
diff --git a/cli/commands/stack/generate.go b/cli/commands/stack/generate.go
deleted file mode 100644
index 90620e6f8f..0000000000
--- a/cli/commands/stack/generate.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package stack
-
-import (
-	"context"
-	"path/filepath"
-
-	"github.com/gruntwork-io/terragrunt/config"
-	"github.com/gruntwork-io/terragrunt/options"
-)
-
-func generateStack(ctx context.Context, opts *options.TerragruntOptions) error {
-	opts.TerragruntStackConfigPath = filepath.Join(opts.WorkingDir, config.DefaultStackFile)
-	opts.Logger.Infof("Generating stack from %s", opts.TerragruntStackConfigPath)
-
-	return config.GenerateStacks(ctx, opts)
-}
diff --git a/config/stack.go b/config/stack.go
index 770cd43de4..7c5d600d75 100644
--- a/config/stack.go
+++ b/config/stack.go
@@ -195,7 +195,7 @@ func generateUnits(ctx context.Context, opts *options.TerragruntOptions, pool *u
 		unitCopy := unit // Create a copy to avoid capturing the loop variable reference
 
 		pool.Submit(func() error {
-			item := itemToProcess{
+			item := componentToProcess{
 				sourceDir: sourceDir,
 				targetDir: targetDir,
 				name:      unitCopy.Name,
@@ -206,7 +206,7 @@ func generateUnits(ctx context.Context, opts *options.TerragruntOptions, pool *u
 
 			opts.Logger.Infof("Processing unit %s", unitCopy.Name)
 
-			if err := processItem(ctx, opts, &item); err != nil {
+			if err := processComponent(ctx, opts, &item); err != nil {
 				return err
 			}
 
@@ -224,7 +224,7 @@ func generateStacks(ctx context.Context, opts *options.TerragruntOptions, pool *
 		stackCopy := stack // Create a copy to avoid capturing the loop variable reference
 
 		pool.Submit(func() error {
-			item := itemToProcess{
+			item := componentToProcess{
 				sourceDir: sourceDir,
 				targetDir: targetDir,
 				name:      stackCopy.Name,
@@ -235,7 +235,7 @@ func generateStacks(ctx context.Context, opts *options.TerragruntOptions, pool *
 
 			opts.Logger.Infof("Processing stack %s", stackCopy.Name)
 
-			if err := processItem(ctx, opts, &item); err != nil {
+			if err := processComponent(ctx, opts, &item); err != nil {
 				return err
 			}
 
@@ -246,7 +246,10 @@ func generateStacks(ctx context.Context, opts *options.TerragruntOptions, pool *
 	return nil
 }
 
-type itemToProcess struct {
+// componentToProcess represents an item of work for processing a stack or unit.
+// It contains information about the source and target directories, the name and path of the item, the source URL or path,
+// and any associated values that need to be processed.
+type componentToProcess struct {
 	sourceDir string
 	targetDir string
 	name      string
@@ -255,24 +258,31 @@ type itemToProcess struct {
 	values    *cty.Value
 }
 
-// processItem copies files from the source directory to the target destination and generates a corresponding values file.
-func processItem(ctx context.Context, opts *options.TerragruntOptions, item *itemToProcess) error {
-	destPath := filepath.Join(item.targetDir, item.path)
-	dest, err := filepath.Abs(destPath)
+// processComponent copies files from the source directory to the target destination and generates a corresponding values file.
+func processComponent(ctx context.Context, opts *options.TerragruntOptions, cmp *componentToProcess) error {
+	source := cmp.source
+	// Adjust source path using the provided source mapping configuration if available
+	source, err := adjustSourceWithMap(opts.SourceMap, source, opts.TerragruntStackConfigPath)
 
 	if err != nil {
-		return errors.Errorf("failed to get absolute path for destination '%s': %v", dest, err)
+		return errors.Errorf("failed to adjust source %s: %v", cmp.source, err)
 	}
 
-	opts.Logger.Debugf("Processing: %s (%s) to %s", item.name, item.source, dest)
+	dest := cmp.path
+	// if destination is an absolute path, use as is
+	if !filepath.IsAbs(cmp.path) {
+		dest = filepath.Join(cmp.targetDir, cmp.path)
+	}
+
+	opts.Logger.Debugf("Processing: %s (%s) to %s", cmp.name, source, dest)
 
-	if err := copyFiles(ctx, opts, item.name, item.sourceDir, item.source, dest); err != nil {
+	if err := copyFiles(ctx, opts, cmp.name, cmp.sourceDir, source, dest); err != nil {
 		return err
 	}
 
 	// generate values file
-	if err := writeValues(opts, item.values, dest); err != nil {
-		return errors.Errorf("failed to write values %v %v", item.name, err)
+	if err := writeValues(opts, cmp.values, dest); err != nil {
+		return errors.Errorf("failed to write values %v %v", cmp.name, err)
 	}
 
 	return nil
diff --git a/docs/_docs/04_reference/04-config-blocks-and-attributes.md b/docs/_docs/04_reference/04-config-blocks-and-attributes.md
index 1398580163..3ea8aad083 100644
--- a/docs/_docs/04_reference/04-config-blocks-and-attributes.md
+++ b/docs/_docs/04_reference/04-config-blocks-and-attributes.md
@@ -1653,7 +1653,7 @@ The `unit` block supports the following arguments:
 
 - `name` (label): A unique identifier for the unit. This is used to reference the unit elsewhere in your configuration.
 - `source` (attribute): Specifies where to find the Terragrunt configuration files for this unit. This follows the same syntax as the `source` parameter in the `terraform` block.
-- `path` (attribute): The relative path where this unit should be deployed within the stack directory (`.terragrunt-stack`).
+- `path` (attribute): The relative path where this unit should be deployed within the stack directory (`.terragrunt-stack`). If an absolute path is provided here, Terragrunt will generate the stack in that location, instead of generating it in a path relative to the `.terragrunt-stack` directory.
 - `values` (attribute, optional): A map of values that will be passed to the unit as inputs.
 
 Example:
@@ -1703,6 +1703,9 @@ inputs = {
 }
 ```
 
+**Note:**  
+The `source` value can be updated dynamically using the `--source-map` flag, just like `terraform.source`.
+
 ### stack
 
 > **Note:**
@@ -1748,6 +1751,9 @@ In this example, the `services` stack is defined with path `services`, which wil
 The stack is also provided with custom values for `project` and `cidr`, which can be used within the stack's configuration files.
 Terragrunt will recursively generate a stack using the contents of the `.terragrunt-stack/services/terragrunt.stack.hcl` file until the entire stack is fully generated.
 
+**Note:**  
+The `source` value can be updated dynamically using the `--source-map` flag, just like `terraform.source`.
+
 ## Attributes
 
 - [Blocks](#blocks)


Test patch:
diff --git a/test/fixtures/stacks/absolute-path/terragrunt.stack.hcl b/test/fixtures/stacks/absolute-path/terragrunt.stack.hcl
new file mode 100644
index 0000000000..8a2a95c2a7
--- /dev/null
+++ b/test/fixtures/stacks/absolute-path/terragrunt.stack.hcl
@@ -0,0 +1,24 @@
+
+unit "app1" {
+	source = "units/app"
+	path   = "${get_repo_root()}/app1"
+	values = {
+		input = "app1"
+	}
+}
+
+unit "app2" {
+	source = "units/app"
+	path   = "${get_repo_root()}/app2"
+	values = {
+		input = "app2"
+	}
+}
+
+unit "app3" {
+	source = "units/app"
+	path   = "app3"
+	values = {
+		input = "app3"
+	}
+}
diff --git a/test/fixtures/stacks/absolute-path/units/app/main.tf b/test/fixtures/stacks/absolute-path/units/app/main.tf
new file mode 100644
index 0000000000..2cad5cf211
--- /dev/null
+++ b/test/fixtures/stacks/absolute-path/units/app/main.tf
@@ -0,0 +1,13 @@
+variable "input" {
+  description = "Project input"
+  type        = string
+}
+
+resource "local_file" "file" {
+  content  = var.input
+  filename = "${path.module}/data.txt"
+}
+
+output "result" {
+  value = var.input
+}
diff --git a/test/fixtures/stacks/absolute-path/units/app/terragrunt.hcl b/test/fixtures/stacks/absolute-path/units/app/terragrunt.hcl
new file mode 100644
index 0000000000..a86ab87b00
--- /dev/null
+++ b/test/fixtures/stacks/absolute-path/units/app/terragrunt.hcl
@@ -0,0 +1,4 @@
+
+inputs = {
+  input = values.input
+}
\ No newline at end of file
diff --git a/test/fixtures/stacks/source-map/terragrunt.stack.hcl b/test/fixtures/stacks/source-map/terragrunt.stack.hcl
new file mode 100644
index 0000000000..0a993ded58
--- /dev/null
+++ b/test/fixtures/stacks/source-map/terragrunt.stack.hcl
@@ -0,0 +1,16 @@
+
+unit "app1" {
+	source = "git::https://git-host.com/not-existing-repo.git//fixtures/stacks/source-map/units/app"
+	path   = "app1"
+	values = {
+		input = "app1"
+	}
+}
+
+unit "app2" {
+	source = "git::https://git-host.com/not-existing-repo.git//fixtures/stacks/source-map/units/app"
+	path   = "app2"
+	values = {
+		input = "app2"
+	}
+}
diff --git a/test/fixtures/stacks/source-map/tf/modules/main.tf b/test/fixtures/stacks/source-map/tf/modules/main.tf
new file mode 100644
index 0000000000..92435bc8c7
--- /dev/null
+++ b/test/fixtures/stacks/source-map/tf/modules/main.tf
@@ -0,0 +1,15 @@
+
+
+variable "input" {
+  description = "Project input"
+  type        = string
+}
+
+resource "local_file" "file" {
+  content  = var.input
+  filename = "${path.module}/data.txt"
+}
+
+output "result" {
+  value = var.input
+}
diff --git a/test/fixtures/stacks/source-map/units/app/terragrunt.hcl b/test/fixtures/stacks/source-map/units/app/terragrunt.hcl
new file mode 100644
index 0000000000..933bec4868
--- /dev/null
+++ b/test/fixtures/stacks/source-map/units/app/terragrunt.hcl
@@ -0,0 +1,7 @@
+terraform {
+  source = "git::https://git-host.com/not-existing-repo.git//fixtures/stacks/source-map/tf/modules"
+}
+
+inputs = {
+  input = values.input
+}
\ No newline at end of file
diff --git a/test/integration_stacks_test.go b/test/integration_stacks_test.go
index c0737a33f2..f2f87e0e2f 100644
--- a/test/integration_stacks_test.go
+++ b/test/integration_stacks_test.go
@@ -6,6 +6,8 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/gruntwork-io/terratest/modules/files"
+
 	"github.com/gruntwork-io/terragrunt/config/hclparse"
 
 	"github.com/stretchr/testify/assert"
@@ -29,6 +31,8 @@ const (
 	testFixtureNestedStacks        = "fixtures/stacks/nested"
 	testFixtureStackValues         = "fixtures/stacks/stack-values"
 	testFixtureStackDependencies   = "fixtures/stacks/dependencies"
+	testFixtureStackAbsolutePath   = "fixtures/stacks/absolute-path"
+	testFixtureStackSourceMap      = "fixtures/stacks/source-map"
 )
 
 func TestStacksGenerateBasic(t *testing.T) {
@@ -171,9 +175,11 @@ func TestStacksApplyRemote(t *testing.T) {
 	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStacksRemote)
 	rootPath := util.JoinPath(tmpEnvPath, testFixtureStacksRemote)
 
-	stdout, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack run apply --experiment stacks --terragrunt-non-interactive --terragrunt-working-dir "+rootPath)
+	stdout, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack run apply --terragrunt-log-level debug --experiment stacks --terragrunt-non-interactive --terragrunt-working-dir "+rootPath)
 	require.NoError(t, err)
 
+	assert.Contains(t, stderr, "app1 (git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/stacks/basic/units/chick?ref=main&depth=1)")
+	assert.Contains(t, stderr, "app2 (git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/stacks/basic/units/chick?ref=main&depth=1)")
 	assert.Contains(t, stdout, "Apply complete! Resources: 1 added, 0 changed, 0 destroyed")
 	assert.Contains(t, stdout, "local_file.file: Creation complete")
 	path := util.JoinPath(rootPath, ".terragrunt-stack")
@@ -713,6 +719,96 @@ func TestStackApplyStrictInclude(t *testing.T) {
 	assert.True(t, util.FileNotExists(dataPath))
 }
 
+func TestStacksSourceMap(t *testing.T) {
+	t.Parallel()
+
+	// prepare local path to do override of source url
+	helpers.CleanupTerraformFolder(t, testFixtureStacksBasic)
+	localTmpEnvPath := helpers.CopyEnvironment(t, testFixtureStacksBasic)
+	localTmpTest := filepath.Join(localTmpEnvPath, "test", "fixtures")
+	if err := os.MkdirAll(localTmpTest, 0755); err != nil {
+		assert.NoError(t, err)
+	}
+
+	if err := files.CopyFolderContentsWithFilter(filepath.Join(localTmpEnvPath, "fixtures"), localTmpTest, func(path string) bool {
+		return true
+	}); err != nil {
+		assert.NoError(t, err)
+	}
+
+	// prepare local environment with remote to use source map to replace
+	helpers.CleanupTerraformFolder(t, testFixtureStacksRemote)
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStacksRemote)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureStacksRemote)
+
+	// generate path with replacement of local source with local path
+	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack generate --experiment stacks --source-map git::https://github.com/gruntwork-io/terragrunt.git="+localTmpEnvPath+" --terragrunt-working-dir "+rootPath)
+	require.NoError(t, err)
+
+	assert.Contains(t, stderr, "Processing unit app1")
+	assert.Contains(t, stderr, "Processing unit app2")
+
+	path := util.JoinPath(rootPath, ".terragrunt-stack")
+	validateStackDir(t, path)
+
+	_, stderr, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack run apply --terragrunt-log-level debug --experiment stacks --source-map git::https://github.com/gruntwork-io/terragrunt.git="+localTmpEnvPath+" --terragrunt-non-interactive --terragrunt-working-dir "+rootPath)
+	require.NoError(t, err)
+
+	// validate that the source map was used to replace the source
+	assert.NotContains(t, stderr, "app1 (git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/stacks/basic/units/chick?ref=main&depth=1)")
+	assert.NotContains(t, stderr, "app2 (git::https://github.com/gruntwork-io/terragrunt.git//test/fixtures/stacks/basic/units/chick?ref=main&depth=1)")
+
+	assert.Contains(t, stderr, "Processing unit app1")
+	assert.Contains(t, stderr, "Processing unit app2")
+}
+
+func TestStacksSourceMapModule(t *testing.T) {
+	t.Parallel()
+	// prepare local environment with remote to use source map to replace
+	helpers.CleanupTerraformFolder(t, testFixtureStackSourceMap)
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackSourceMap)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureStackSourceMap)
+
+	_, stderr, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack generate --experiment stacks --source-map git::https://git-host.com/not-existing-repo.git="+tmpEnvPath+" --terragrunt-log-level debug --terragrunt-working-dir "+rootPath)
+	require.NoError(t, err)
+	assert.NotContains(t, stderr, "git-host.com/not-existing-repo.git")
+	path := util.JoinPath(rootPath, ".terragrunt-stack")
+	validateStackDir(t, path)
+
+	_, stderr, err = helpers.RunTerragruntCommandWithOutput(t, "terragrunt stack run apply --terragrunt-log-level debug --experiment stacks --source-map git::https://git-host.com/not-existing-repo.git="+tmpEnvPath+"  --terragrunt-non-interactive --terragrunt-working-dir "+rootPath)
+	require.NoError(t, err)
+
+	assert.NotContains(t, stderr, "git-host.com/not-existing-repo.git")
+	assert.Contains(t, stderr, "Module ./.terragrunt-stack/app1")
+	assert.Contains(t, stderr, "Module ./.terragrunt-stack/app2")
+}
+
+func TestStacksGenerateAbsolutePath(t *testing.T) {
+	t.Parallel()
+
+	helpers.CleanupTerraformFolder(t, testFixtureStackAbsolutePath)
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackAbsolutePath)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureStackAbsolutePath)
+	helpers.CreateGitRepo(t, rootPath)
+	helpers.RunTerragrunt(t, "terragrunt stack generate --terragrunt-log-level debug  --experiment stacks --terragrunt-working-dir "+rootPath)
+
+	path := util.JoinPath(rootPath, ".terragrunt-stack")
+	validateStackDir(t, path)
+
+	// check that apps directories are generated
+	app3 := util.JoinPath(rootPath, ".terragrunt-stack", "app3")
+	assert.DirExists(t, app3)
+
+	app1 := util.JoinPath(rootPath, "app1")
+	assert.DirExists(t, app1)
+
+	app2 := util.JoinPath(rootPath, "app2")
+	assert.DirExists(t, app2)
+
+	app1 = util.JoinPath(rootPath, ".terragrunt-stack", "app1")
+	assert.NoDirExists(t, app1)
+}
+
 // check if the stack directory is created and contains files.
 func validateStackDir(t *testing.T, path string) {
 	t.Helper()