Repository: gruntwork-io/terragrunt

Issue:
terragrunt stack output is not covering the case where inside unit the no_dot_terragrunt_stack is set to true.
## Describe the bug

I tried to run the output command for stack in order to get the outputs for generating a config file. All my unit have **no_dot_terragrunt_stack = true** so no .terragrunt-stack to be generated for each unit. My issue is that instead of taking the path to the unit, the output commands is not taking into account that i do not have .terragrunt-stack directory and its throwing me an error:

You attempted to run terragrunt in a folder that does not contain a terragrunt.hcl file. Please add a terragrunt.hcl file and try again.
Unable to determine underlying exit code, so Terragrunt will exit with error code 1

## Steps To Reproduce

Create a unit where no_dot_terragrunt_stack = true is set.
Create a stack where to reference the unit
Run terragrunt stack output command

## Expected behavior

I expect that when building the path to unit based on where the command is ran to take into account the value of no_dot_terragrunt_stack. currently only the default scenarios is working.

## Versions

- Terragrunt version: terragrunt version v0.83.2
- OpenTofu/Terraform version: Terraform v1.11.4 on linux_amd64
- Environment details (Ubuntu 20.04, Windows 10, etc.): Ubuntu 22.04



Discussion:

Solution patch:
diff --git a/config/stack.go b/config/stack.go
index 01ea09d4..fb0071f3 100644
--- a/config/stack.go
+++ b/config/stack.go
@@ -214,7 +214,7 @@ func StackOutput(ctx context.Context, l log.Logger, opts *options.TerragruntOpti
 		}
 
 		for _, unit := range stackFile.Units {
-			unitDir := filepath.Join(dir, StackDir, unit.Path)
+			unitDir := getUnitDir(dir, unit)
 
 			var output map[string]cty.Value
 
@@ -1027,3 +1027,12 @@ func CleanStacks(_ context.Context, l log.Logger, opts *options.TerragruntOption
 
 	return errs.ErrorOrNil()
 }
+
+// getUnitDir returns the directory path for a unit based on its no_dot_terragrunt_stack setting.
+func getUnitDir(dir string, unit *Unit) string {
+	if unit.NoStack != nil && *unit.NoStack {
+		return filepath.Join(dir, unit.Path)
+	}
+
+	return filepath.Join(dir, StackDir, unit.Path)
+}
diff --git a/docs-starlight/astro.config.mjs b/docs-starlight/astro.config.mjs
index a1f668cd..7b834dd0 100644
--- a/docs-starlight/astro.config.mjs
+++ b/docs-starlight/astro.config.mjs
@@ -79,6 +79,10 @@ export default defineConfig({
   }),
   integrations: [starlight({
     title: "Terragrunt",
+    editLink: {
+      // TODO: update this once the docs live in `docs`.
+      baseUrl: "https://github.com/gruntwork-io/terragrunt/edit/main/docs-starlight",
+    },
     customCss: ["./src/styles/global.css"],
     head: [
       {
diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go
index d6c1ed08..efaa3ef7 100644
--- a/internal/discovery/discovery.go
+++ b/internal/discovery/discovery.go
@@ -353,6 +353,14 @@ func (d *Discovery) Discover(ctx context.Context, l log.Logger, opts *options.Te
 	// e.g. dependencies, exclude, etc.
 	if d.requiresParse {
 		for _, cfg := range cfgs {
+			// Stack configurations don't need to be parsed for discovery purposes.
+			// They don't have exclude blocks or dependencies.
+			//
+			// This might change in the future, but for now we'll just skip parsing.
+			if cfg.Type == ConfigTypeStack {
+				continue
+			}
+
 			err := cfg.Parse(ctx, l, opts, d.suppressParseErrors)
 			if err != nil {
 				errs = append(errs, errors.New(err))
diff --git a/internal/runner/runnerpool/builder.go b/internal/runner/runnerpool/builder.go
index 530d6ea6..08815f9d 100644
--- a/internal/runner/runnerpool/builder.go
+++ b/internal/runner/runnerpool/builder.go
@@ -8,6 +8,7 @@ import (
 	"github.com/gruntwork-io/terragrunt/internal/runner/common"
 	"github.com/gruntwork-io/terragrunt/options"
 	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/telemetry"
 )
 
 // Build stack runner using discovery and queueing mechanisms.
@@ -23,12 +24,36 @@ func Build(ctx context.Context, l log.Logger, terragruntOptions *options.Terragr
 		WithConfigFilenames([]string{config.DefaultTerragruntConfigPath}).
 		WithDiscoveryContext(&discovery.DiscoveryContext{Cmd: terragruntOptions.TerraformCommand})
 
-	discovered, err := d.Discover(ctx, l, terragruntOptions)
+	// Wrap discovery with telemetry
+	var discovered discovery.DiscoveredConfigs
+
+	err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "runner_pool_discovery", map[string]any{
+		"working_dir":       terragruntOptions.WorkingDir,
+		"terraform_command": terragruntOptions.TerraformCommand,
+	}, func(childCtx context.Context) error {
+		var discoveryErr error
+		discovered, discoveryErr = d.Discover(childCtx, l, terragruntOptions)
+
+		return discoveryErr
+	})
+
 	if err != nil {
 		return nil, err
 	}
 
-	runner, err := NewRunnerPoolStack(ctx, l, terragruntOptions, discovered, opts...)
+	// Wrap runner pool creation with telemetry
+	var runner common.StackRunner
+
+	err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "runner_pool_creation", map[string]any{
+		"discovered_configs": len(discovered),
+		"terraform_command":  terragruntOptions.TerraformCommand,
+	}, func(childCtx context.Context) error {
+		var runnerErr error
+		runner, runnerErr = NewRunnerPoolStack(childCtx, l, terragruntOptions, discovered, opts...)
+
+		return runnerErr
+	})
+
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/runner/runnerpool/controller.go b/internal/runner/runnerpool/controller.go
index 85c7233d..604657e1 100644
--- a/internal/runner/runnerpool/controller.go
+++ b/internal/runner/runnerpool/controller.go
@@ -12,6 +12,7 @@ import (
 
 	"github.com/gruntwork-io/terragrunt/internal/queue"
 	"github.com/gruntwork-io/terragrunt/internal/runner/common"
+	"github.com/gruntwork-io/terragrunt/telemetry"
 
 	"github.com/puzpuzpuz/xsync/v3"
 )
@@ -80,111 +81,118 @@ func NewController(q *queue.Queue, units []*common.Unit, opts ...ControllerOptio
 
 // Run executes the Queue return error summarizing all entries that failed to run.
 func (dr *Controller) Run(ctx context.Context, l log.Logger) error {
-	var (
-		wg      sync.WaitGroup
-		sem     = make(chan struct{}, dr.concurrency)
-		results = xsync.NewMapOf[string, error]()
-	)
-
-	if dr.runner == nil {
-		return errors.Errorf("Runner Pool Controller: runner is not set, cannot run")
-	}
+	return telemetry.TelemeterFromContext(ctx).Collect(ctx, "runner_pool_controller", map[string]any{
+		"total_tasks":             len(dr.q.Entries),
+		"concurrency":             dr.concurrency,
+		"fail_fast":               dr.q.FailFast,
+		"ignore_dependency_order": dr.q.IgnoreDependencyOrder,
+	}, func(childCtx context.Context) error {
+		var (
+			wg      sync.WaitGroup
+			sem     = make(chan struct{}, dr.concurrency)
+			results = xsync.NewMapOf[string, error]()
+		)
+
+		if dr.runner == nil {
+			return errors.Errorf("Runner Pool Controller: runner is not set, cannot run")
+		}
 
-	l.Debugf("Runner Pool Controller: starting with %d tasks, concurrency %d",
-		len(dr.q.Entries), dr.concurrency)
+		l.Debugf("Runner Pool Controller: starting with %d tasks, concurrency %d",
+			len(dr.q.Entries), dr.concurrency)
 
-	// Initial signal to start scheduling
-	select {
-	case dr.readyCh <- struct{}{}:
-	default:
-	}
+		// Initial signal to start scheduling
+		select {
+		case dr.readyCh <- struct{}{}:
+		default:
+		}
 
-	for {
-		readyEntries := dr.q.GetReadyWithDependencies()
-		l.Debugf("Runner Pool Controller: found %d readyEntries tasks", len(readyEntries))
-
-		for _, e := range readyEntries {
-			// log debug which entry is running
-			l.Debugf("Runner Pool Controller: running %s", e.Config.Path)
-			e.Status = queue.StatusRunning
-			sem <- struct{}{}
-
-			wg.Add(1)
-
-			go func(ent *queue.Entry) {
-				defer func() {
-					<-sem
-					wg.Done()
-					select {
-					case dr.readyCh <- struct{}{}:
-					default:
+		for {
+			readyEntries := dr.q.GetReadyWithDependencies()
+			l.Debugf("Runner Pool Controller: found %d readyEntries tasks", len(readyEntries))
+
+			for _, e := range readyEntries {
+				// log debug which entry is running
+				l.Debugf("Runner Pool Controller: running %s", e.Config.Path)
+				e.Status = queue.StatusRunning
+				sem <- struct{}{}
+
+				wg.Add(1)
+
+				go func(ent *queue.Entry) {
+					defer func() {
+						<-sem
+						wg.Done()
+						select {
+						case dr.readyCh <- struct{}{}:
+						default:
+						}
+					}()
+
+					unit := dr.unitsMap[ent.Config.Path]
+					if unit == nil {
+						err := errors.Errorf("unit for path %s not found in discovered units", ent.Config.Path)
+						l.Errorf("Runner Pool Controller: unit for path %s not found in discovered units, skipping execution", ent.Config.Path)
+						dr.q.FailEntry(ent)
+						results.Store(ent.Config.Path, err)
+
+						return
 					}
-				}()
 
-				unit := dr.unitsMap[ent.Config.Path]
-				if unit == nil {
-					err := errors.Errorf("unit for path %s not found in discovered units", ent.Config.Path)
-					l.Errorf("Runner Pool Controller: unit for path %s not found in discovered units, skipping execution", ent.Config.Path)
-					dr.q.FailEntry(ent)
+					err := dr.runner(childCtx, unit)
 					results.Store(ent.Config.Path, err)
 
-					return
-				}
+					if err != nil {
+						l.Debugf("Runner Pool Controller: %s failed", ent.Config.Path)
+						dr.q.FailEntry(ent)
 
-				err := dr.runner(ctx, unit)
-				results.Store(ent.Config.Path, err)
+						return
+					}
 
-				if err != nil {
-					l.Debugf("Runner Pool Controller: %s failed", ent.Config.Path)
-					dr.q.FailEntry(ent)
+					l.Debugf("Runner Pool Controller: %s succeeded", ent.Config.Path)
+					ent.Status = queue.StatusSucceeded
+				}(e)
+			}
 
-					return
+			if len(readyEntries) == 0 {
+				// If no goroutines are running, break
+				if len(sem) == 0 {
+					break
 				}
+			}
 
-				l.Debugf("Runner Pool Controller: %s succeeded", ent.Config.Path)
-				ent.Status = queue.StatusSucceeded
-			}(e)
-		}
-
-		if len(readyEntries) == 0 {
-			// If no goroutines are running, break
-			if len(sem) == 0 {
-				break
+			select {
+			case <-dr.readyCh:
+			case <-childCtx.Done():
+				wg.Wait()
+				return nil
 			}
 		}
 
-		select {
-		case <-dr.readyCh:
-		case <-ctx.Done():
-			wg.Wait()
-			return nil
-		}
-	}
+		wg.Wait()
+
+		// Collect errors from results map and check for errors
+		errCollector := &errors.MultiError{}
 
-	wg.Wait()
+		for _, entry := range dr.q.Entries {
+			if err, ok := results.Load(entry.Config.Path); ok {
+				if err == nil {
+					continue
+				}
 
-	// Collect errors from results map and check for errors
-	errCollector := &errors.MultiError{}
+				errCollector = errCollector.Append(err)
 
-	for _, entry := range dr.q.Entries {
-		if err, ok := results.Load(entry.Config.Path); ok {
-			if err == nil {
 				continue
 			}
 
-			errCollector = errCollector.Append(err)
-
-			continue
-		}
-
-		if entry.Status == queue.StatusEarlyExit {
-			errCollector = errCollector.Append(errors.Errorf("unit %s did not run due to early exit", entry.Config.Path))
-		}
+			if entry.Status == queue.StatusEarlyExit {
+				errCollector = errCollector.Append(errors.Errorf("unit %s did not run due to early exit", entry.Config.Path))
+			}
 
-		if entry.Status == queue.StatusFailed {
-			errCollector = errCollector.Append(errors.Errorf("unit %s failed to run", entry.Config.Path))
+			if entry.Status == queue.StatusFailed {
+				errCollector = errCollector.Append(errors.Errorf("unit %s failed to run", entry.Config.Path))
+			}
 		}
-	}
 
-	return errCollector.ErrorOrNil()
+		return errCollector.ErrorOrNil()
+	})
 }
diff --git a/internal/runner/runnerpool/runner.go b/internal/runner/runnerpool/runner.go
index b7d6532a..1890a548 100644
--- a/internal/runner/runnerpool/runner.go
+++ b/internal/runner/runnerpool/runner.go
@@ -24,6 +24,7 @@ import (
 	"github.com/gruntwork-io/terragrunt/internal/report"
 	"github.com/gruntwork-io/terragrunt/options"
 	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/telemetry"
 )
 
 // Runner implements the Stack interface for runner pool execution.
@@ -112,15 +113,17 @@ func (r *Runner) Run(ctx context.Context, l log.Logger, opts *options.Terragrunt
 	}
 
 	task := func(ctx context.Context, u *common.Unit) error {
-		unitRunner := common.NewUnitRunner(u)
-
-		err := unitRunner.Run(ctx, u.TerragruntOptions, r.Stack.Report)
-		if err != nil {
-			return err
-		}
-
-		return nil
+		return telemetry.TelemeterFromContext(ctx).Collect(ctx, "runner_pool_task", map[string]any{
+			"terraform_command":      u.TerragruntOptions.TerraformCommand,
+			"terraform_cli_args":     u.TerragruntOptions.TerraformCliArgs,
+			"working_dir":            u.TerragruntOptions.WorkingDir,
+			"terragrunt_config_path": u.TerragruntOptions.TerragruntConfigPath,
+		}, func(childCtx context.Context) error {
+			unitRunner := common.NewUnitRunner(u)
+			return unitRunner.Run(childCtx, u.TerragruntOptions, r.Stack.Report)
+		})
 	}
+
 	r.queue.FailFast = opts.FailFast
 	r.queue.IgnoreDependencyOrder = opts.IgnoreDependencyOrder
 	controller := NewController(
@@ -168,7 +171,7 @@ func (r *Runner) LogUnitDeployOrder(l log.Logger, terraformCommand string) error
 }
 
 // JSONUnitDeployOrder returns the order of units to be processed for a given Terraform command in JSON format.
-func (r *Runner) JSONUnitDeployOrder(terraformCommand string) (string, error) {
+func (r *Runner) JSONUnitDeployOrder(_ string) (string, error) {
 	orderedUnits := make([]string, 0, len(r.queue.Entries))
 	for _, unit := range r.queue.Entries {
 		orderedUnits = append(orderedUnits, unit.Config.Path)



Test patch:
diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go
index f6e1ce10..64c136bc 100644
--- a/internal/discovery/discovery_test.go
+++ b/internal/discovery/discovery_test.go
@@ -494,13 +494,93 @@ func TestDiscoveryWithSingleCustomConfigFilename(t *testing.T) {
 	err = os.WriteFile(filepath.Join(unit1Dir, "custom1.hcl"), []byte(""), 0644)
 	require.NoError(t, err)
 
-	discoveryObj := discovery.NewDiscovery(tmpDir).WithConfigFilenames([]string{"custom1.hcl"})
+	d := discovery.NewDiscovery(tmpDir).WithConfigFilenames([]string{"custom1.hcl"})
 	opts, err := options.NewTerragruntOptionsForTest(tmpDir)
 	require.NoError(t, err)
 
-	configs, err := discoveryObj.Discover(t.Context(), logger.CreateLogger(), opts)
+	configs, err := d.Discover(t.Context(), logger.CreateLogger(), opts)
 	require.NoError(t, err)
 
 	units := configs.Filter(discovery.ConfigTypeUnit).Paths()
 	assert.ElementsMatch(t, []string{unit1Dir}, units)
 }
+
+func TestDiscoveryWithStackConfigParsing(t *testing.T) {
+	t.Parallel()
+
+	// Create a temporary directory for testing
+	tmpDir := t.TempDir()
+
+	// Create test directory structure
+	stackDir := filepath.Join(tmpDir, "stack")
+	unitDir := filepath.Join(tmpDir, "unit")
+
+	testDirs := []string{
+		stackDir,
+		unitDir,
+	}
+
+	for _, dir := range testDirs {
+		err := os.MkdirAll(dir, 0755)
+		require.NoError(t, err)
+	}
+
+	// Create a stack file with unit blocks (which would cause parsing errors if parsed as unit config)
+	stackContent := `
+unit "unit_a" {
+  source = "${get_repo_root()}/unit_a"
+  path   = "unit_a"
+}
+
+unit "unit_b" {
+  source = "${get_repo_root()}/unit_b"
+  path   = "unit_b"
+}
+`
+
+	// Create a unit file with valid unit configuration
+	unitContent := `
+terraform {
+  source = "."
+}
+
+inputs = {
+  test = "value"
+}
+`
+
+	// Create test files
+	testFiles := map[string]string{
+		filepath.Join(stackDir, "terragrunt.stack.hcl"): stackContent,
+		filepath.Join(unitDir, "terragrunt.hcl"):        unitContent,
+	}
+
+	for path, content := range testFiles {
+		err := os.WriteFile(path, []byte(content), 0644)
+		require.NoError(t, err)
+	}
+
+	// Test that d with parsing enabled doesn't fail on stack files
+	d := discovery.NewDiscovery(tmpDir).WithDiscoverDependencies()
+
+	opts, err := options.NewTerragruntOptionsForTest(tmpDir)
+	require.NoError(t, err)
+
+	configs, err := d.Discover(t.Context(), logger.CreateLogger(), opts)
+	require.NoError(t, err)
+
+	// Verify that both stack and unit configurations are discovered
+	units := configs.Filter(discovery.ConfigTypeUnit)
+	stacks := configs.Filter(discovery.ConfigTypeStack)
+
+	assert.Len(t, units, 1)
+	assert.Len(t, stacks, 1)
+
+	// Verify that stack configuration is not parsed (Parsed should be nil)
+	stackConfig := stacks[0]
+	assert.Nil(t, stackConfig.Parsed, "Stack configuration should not be parsed")
+
+	// Verify that unit configuration is parsed (Parsed should not be nil)
+	unitConfig := units[0]
+	assert.NotNil(t, unitConfig.Parsed, "Unit configuration should be parsed")
+}
diff --git a/test/fixtures/stacks/no-dot-terragrunt-stack-output/live/terragrunt.stack.hcl b/test/fixtures/stacks/no-dot-terragrunt-stack-output/live/terragrunt.stack.hcl
new file mode 100644
index 00000000..7a9679f6
--- /dev/null
+++ b/test/fixtures/stacks/no-dot-terragrunt-stack-output/live/terragrunt.stack.hcl
@@ -0,0 +1,5 @@
+unit "app1" {
+  source                  = "../units/app1"
+  path                    = "app1"
+  no_dot_terragrunt_stack = true
+}
diff --git a/test/fixtures/stacks/no-dot-terragrunt-stack-output/units/app1/main.tf b/test/fixtures/stacks/no-dot-terragrunt-stack-output/units/app1/main.tf
new file mode 100644
index 00000000..f415479b
--- /dev/null
+++ b/test/fixtures/stacks/no-dot-terragrunt-stack-output/units/app1/main.tf
@@ -0,0 +1,12 @@
+terraform {
+  required_version = ">= 0.13"
+}
+
+variable "name" {
+  description = "Name of the application"
+  type        = string
+}
+
+output "name" {
+  value = var.name
+}
diff --git a/test/fixtures/stacks/no-dot-terragrunt-stack-output/units/app1/terragrunt.hcl b/test/fixtures/stacks/no-dot-terragrunt-stack-output/units/app1/terragrunt.hcl
new file mode 100644
index 00000000..efc28a81
--- /dev/null
+++ b/test/fixtures/stacks/no-dot-terragrunt-stack-output/units/app1/terragrunt.hcl
@@ -0,0 +1,7 @@
+terraform {
+  source = "."
+}
+
+inputs = {
+  name = "app1"
+}
diff --git a/test/integration_serial_test.go b/test/integration_serial_test.go
index a1956814..3b39bd80 100644
--- a/test/integration_serial_test.go
+++ b/test/integration_serial_test.go
@@ -790,3 +790,19 @@ func TestTerragruntTelemetryPassTraceParentEnvVariable(t *testing.T) {
 	assert.NotEmpty(t, traceparent.Value)
 	assert.Equal(t, envParentTrace, traceparent.Value)
 }
+
+func TestRunnerPoolTelemetry(t *testing.T) {
+	t.Setenv("TG_TELEMETRY_TRACE_EXPORTER", "console")
+
+	helpers.CleanupTerraformFolder(t, testFixtureTraceParent)
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureTraceParent)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureTraceParent)
+
+	telemetryOutput, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run --all --non-interactive --experiment runner-pool --working-dir "+rootPath+"  -- apply")
+	require.NoError(t, err)
+
+	assert.Contains(t, telemetryOutput, "\"Name\":\"runner_pool_discovery\"")
+	assert.Contains(t, telemetryOutput, "\"Name\":\"runner_pool_creation\"")
+	assert.Contains(t, telemetryOutput, "\"Name\":\"runner_pool_controller\"")
+	assert.Contains(t, telemetryOutput, "\"Name\":\"runner_pool_task\"")
+}
diff --git a/test/integration_stacks_test.go b/test/integration_stacks_test.go
index f601f01b..1b9481c2 100644
--- a/test/integration_stacks_test.go
+++ b/test/integration_stacks_test.go
@@ -48,6 +48,7 @@ const (
 	testFixtureStackNestedOutputs              = "fixtures/stacks/nested-outputs"
 	testFixtureStackNoValidation               = "fixtures/stacks/no-validation"
 	testFixtureStackTerragruntDir              = "fixtures/stacks/terragrunt-dir"
+	testFixtureStackNoDotTerragruntStackOutput = "fixtures/stacks/no-dot-terragrunt-stack-output"
 )
 
 func TestStacksGenerateBasic(t *testing.T) {
@@ -1314,3 +1315,24 @@ func TestStackTerragruntDir(t *testing.T) {
 	require.NoError(t, err)
 	assert.Contains(t, out, `terragrunt_dir = "./tennant_1"`)
 }
+
+func TestStackOutputWithNoDotTerragruntStack(t *testing.T) {
+	t.Parallel()
+
+	helpers.CleanupTerraformFolder(t, testFixtureStackNoDotTerragruntStackOutput)
+	tmpEnvPath := helpers.CopyEnvironment(t, testFixtureStackNoDotTerragruntStackOutput)
+	rootPath := util.JoinPath(tmpEnvPath, testFixtureStackNoDotTerragruntStackOutput, "live")
+
+	helpers.RunTerragrunt(t, "terragrunt stack generate --working-dir "+rootPath)
+
+	unitPath := util.JoinPath(rootPath, "app1")
+	helpers.RunTerragrunt(t, "terragrunt apply -auto-approve --non-interactive --working-dir "+unitPath)
+
+	stdout, _, err := helpers.RunTerragruntCommandWithOutput(
+		t,
+		"terragrunt stack output --non-interactive --working-dir "+rootPath,
+	)
+	require.NoError(t, err)
+
+	assert.Contains(t, stdout, "name = \"app1\"")
+}