From 59de0a58db5811bfd6bd94a75de0a826191ef684 Mon Sep 17 00:00:00 2001 From: Yousif Akbar <11247449+yhakbar@users.noreply.github.com> Date: Wed, 13 May 2026 10:14:40 -0400 Subject: [PATCH 1/8] chore: Expanding pure testing through venv --- internal/runner/run/action_with_hooks_test.go | 228 ++++++++++++++++ internal/runner/run/hook_lifecycle_test.go | 188 +++++++++++++ internal/runner/run/run_e2e_test.go | 249 ++++++++++++++++++ pkg/config/config_helpers_hcl_mem_test.go | 148 +++++++++++ 4 files changed, 813 insertions(+) create mode 100644 internal/runner/run/action_with_hooks_test.go create mode 100644 internal/runner/run/hook_lifecycle_test.go create mode 100644 internal/runner/run/run_e2e_test.go create mode 100644 pkg/config/config_helpers_hcl_mem_test.go diff --git a/internal/runner/run/action_with_hooks_test.go b/internal/runner/run/action_with_hooks_test.go new file mode 100644 index 0000000000..a473bd87ba --- /dev/null +++ b/internal/runner/run/action_with_hooks_test.go @@ -0,0 +1,228 @@ +package run_test + +import ( + "context" + "errors" + "testing" + + "github.com/gruntwork-io/terragrunt/internal/report" + "github.com/gruntwork-io/terragrunt/internal/runner/run" + "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" + "github.com/gruntwork-io/terragrunt/internal/vexec" + "github.com/gruntwork-io/terragrunt/internal/vfs" + "github.com/gruntwork-io/terragrunt/test/helpers/logger" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRunActionWithHooks_FiresBeforeActionAfterInOrder pins the +// lifecycle contract: before_hooks → action → after_hooks for the +// happy path, with all three observable through their subprocess +// dispatches. +func TestRunActionWithHooks_FiresBeforeActionAfterInOrder(t *testing.T) { + t.Parallel() + + var order []string + + v := newHookVenv(func(_ context.Context, inv vexec.Invocation) vexec.Result { + order = append(order, inv.Name) + return vexec.Result{} + }) + l := logger.CreateLogger() + + cfg := &runcfg.RunConfig{ + Terraform: runcfg.TerraformConfig{ + BeforeHooks: []runcfg.Hook{ + {Name: "before-1", Commands: []string{"plan"}, Execute: []string{"step-before-1"}, If: true}, + {Name: "before-2", Commands: []string{"plan"}, Execute: []string{"step-before-2"}, If: true}, + }, + AfterHooks: []runcfg.Hook{ + {Name: "after-1", Commands: []string{"plan"}, Execute: []string{"step-after-1"}, If: true}, + }, + }, + } + + actionFired := false + action := func(_ context.Context) error { + order = append(order, "ACTION") + actionFired = true + + return nil + } + + require.NoError(t, run.RunActionWithHooks( + t.Context(), l, v, "plan", newHookOpts(), cfg, report.NewReport(), action, + )) + + assert.True(t, actionFired) + assert.Equal(t, []string{"step-before-1", "step-before-2", "ACTION", "step-after-1"}, order, + "hooks must fire before action, after hooks must fire after action, all in declaration order") +} + +// TestRunActionWithHooks_BeforeHookFailureSkipsAction pins the +// contract that a failing before_hook prevents the wrapped action +// from running entirely; after_hooks still fire because they don't +// have RunOnError set the same way, and error_hooks see the failure. +func TestRunActionWithHooks_BeforeHookFailureSkipsAction(t *testing.T) { + t.Parallel() + + var dispatched []string + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + dispatched = append(dispatched, inv.Name) + + if inv.Name == "bad-before" { + return vexec.Result{ExitCode: 2, Stderr: []byte("hook failed")} + } + + return vexec.Result{} + }) + + v := run.Venv{Exec: exec, FS: vfs.NewMemMapFS()} + l := logger.CreateLogger() + + cfg := &runcfg.RunConfig{ + Terraform: runcfg.TerraformConfig{ + BeforeHooks: []runcfg.Hook{ + {Name: "bad", Commands: []string{"plan"}, Execute: []string{"bad-before"}, If: true}, + }, + ErrorHooks: []runcfg.ErrorHook{ + { + Name: "on-bad", + Commands: []string{"plan"}, + OnErrors: []string{".*hook failed.*"}, + Execute: []string{"error-handler"}, + }, + }, + }, + } + + actionFired := false + action := func(_ context.Context) error { + actionFired = true + return nil + } + + err := run.RunActionWithHooks(t.Context(), l, v, "plan", newHookOpts(), cfg, report.NewReport(), action) + require.Error(t, err, "before-hook failure must propagate") + assert.False(t, actionFired, "action must be skipped when before_hooks fail") + + // before-hook fires, error-handler fires; action never does. + assert.Equal(t, []string{"bad-before", "error-handler"}, dispatched) +} + +// TestRunActionWithHooks_ActionFailureTriggersErrorHook pins the +// contract that an action error surfaces to error_hooks whose +// OnErrors regex matches the error message. +func TestRunActionWithHooks_ActionFailureTriggersErrorHook(t *testing.T) { + t.Parallel() + + var errorHookCalls []vexec.Invocation + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + if inv.Name == "panic-cleanup" { + errorHookCalls = append(errorHookCalls, inv) + } + + return vexec.Result{} + }) + + v := run.Venv{Exec: exec, FS: vfs.NewMemMapFS()} + l := logger.CreateLogger() + + cfg := &runcfg.RunConfig{ + Terraform: runcfg.TerraformConfig{ + ErrorHooks: []runcfg.ErrorHook{ + { + Name: "cleanup-on-lock-error", + Commands: []string{"plan"}, + OnErrors: []string{".*state lock.*"}, + Execute: []string{"panic-cleanup"}, + }, + { + Name: "cleanup-on-network", + Commands: []string{"plan"}, + OnErrors: []string{".*timeout.*"}, + Execute: []string{"network-handler"}, + }, + }, + }, + } + + action := func(_ context.Context) error { + return errors.New("Failed to acquire state lock on bucket") + } + + err := run.RunActionWithHooks(t.Context(), l, v, "plan", newHookOpts(), cfg, report.NewReport(), action) + require.Error(t, err, "action failure must propagate") + + require.Len(t, errorHookCalls, 1, "only the matching error_hook must fire") + assert.Equal(t, "panic-cleanup", errorHookCalls[0].Name) +} + +// TestRunActionWithHooks_AfterHooksSkipOnActionFailure pins the +// contract that after_hooks default to RunOnError=false, so an action +// failure suppresses them. error_hooks are the correct path for +// after-failure cleanup. +func TestRunActionWithHooks_AfterHooksSkipOnActionFailure(t *testing.T) { + t.Parallel() + + var dispatched []string + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + dispatched = append(dispatched, inv.Name) + return vexec.Result{} + }) + + v := run.Venv{Exec: exec, FS: vfs.NewMemMapFS()} + l := logger.CreateLogger() + + cfg := &runcfg.RunConfig{ + Terraform: runcfg.TerraformConfig{ + AfterHooks: []runcfg.Hook{ + {Name: "after-default", Commands: []string{"plan"}, Execute: []string{"normal-after"}, If: true}, + {Name: "after-roe", Commands: []string{"plan"}, Execute: []string{"roe-after"}, If: true, RunOnError: true}, + }, + }, + } + + action := func(_ context.Context) error { + return errors.New("action exploded") + } + + err := run.RunActionWithHooks(t.Context(), l, v, "plan", newHookOpts(), cfg, report.NewReport(), action) + require.Error(t, err) + + // normal-after is suppressed by the action failure; roe-after fires because RunOnError=true. + assert.Equal(t, []string{"roe-after"}, dispatched) +} + +// TestRunActionWithHooks_NoHooksRunsActionDirectly pins the trivial +// path: an empty hook config invokes the action exactly once. +func TestRunActionWithHooks_NoHooksRunsActionDirectly(t *testing.T) { + t.Parallel() + + var subprocessCalls int + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + subprocessCalls++ + return vexec.Result{} + }) + + v := run.Venv{Exec: exec, FS: vfs.NewMemMapFS()} + l := logger.CreateLogger() + + actionFired := 0 + action := func(_ context.Context) error { + actionFired++ + return nil + } + + require.NoError(t, run.RunActionWithHooks( + t.Context(), l, v, "plan", newHookOpts(), &runcfg.RunConfig{}, report.NewReport(), action, + )) + + assert.Equal(t, 1, actionFired, "action must fire exactly once") + assert.Zero(t, subprocessCalls, "no hooks configured: subprocess must not fire") +} diff --git a/internal/runner/run/hook_lifecycle_test.go b/internal/runner/run/hook_lifecycle_test.go new file mode 100644 index 0000000000..9cc3d925e2 --- /dev/null +++ b/internal/runner/run/hook_lifecycle_test.go @@ -0,0 +1,188 @@ +package run_test + +import ( + "context" + "testing" + + "github.com/gruntwork-io/terragrunt/internal/errors" + "github.com/gruntwork-io/terragrunt/internal/runner/run" + "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" + "github.com/gruntwork-io/terragrunt/internal/vexec" + "github.com/gruntwork-io/terragrunt/test/helpers/logger" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestProcessHooks_FiresHooksInDeclarationOrder pins the contract that +// ProcessHooks dispatches hooks in the order they appear in the config, +// not in any other deterministic-but-arbitrary order. Users expect +// before_hook { "a" } and before_hook { "b" } to run a-then-b. +func TestProcessHooks_FiresHooksInDeclarationOrder(t *testing.T) { + t.Parallel() + + rec := &recorder{} + v := newHookVenv(rec.handler(vexec.Result{})) + l := logger.CreateLogger() + + hooks := []runcfg.Hook{ + {Name: "first", Commands: []string{"plan"}, Execute: []string{"step", "1"}, If: true}, + {Name: "second", Commands: []string{"plan"}, Execute: []string{"step", "2"}, If: true}, + {Name: "third", Commands: []string{"plan"}, Execute: []string{"step", "3"}, If: true}, + } + + require.NoError(t, run.ProcessHooks(t.Context(), l, v, hooks, newHookOpts(), &runcfg.RunConfig{}, nil, nil)) + + calls := rec.snapshot() + require.Len(t, calls, 3) + assert.Equal(t, []string{"1"}, calls[0].Args) + assert.Equal(t, []string{"2"}, calls[1].Args) + assert.Equal(t, []string{"3"}, calls[2].Args) +} + +// TestProcessHooks_AccumulatesErrorsAcrossHooks pins the contract that +// when an earlier hook fails (but doesn't have RunOnError set), later +// hooks DO NOT run (the per-hook error becomes a prior error for the +// next iteration). RunOnError hooks DO run after a failure. +func TestProcessHooks_AccumulatesErrorsAcrossHooks(t *testing.T) { + t.Parallel() + + rec := &recorder{} + + h := func(_ context.Context, inv vexec.Invocation) vexec.Result { + rec.mu.Lock() + rec.calls = append(rec.calls, vexec.Invocation{Name: inv.Name, Args: append([]string(nil), inv.Args...)}) + rec.mu.Unlock() + + if inv.Name == "failer" { + return vexec.Result{ExitCode: 1, Stderr: []byte("boom")} + } + + return vexec.Result{} + } + + v := newHookVenv(h) + l := logger.CreateLogger() + + hooks := []runcfg.Hook{ + {Name: "first", Commands: []string{"plan"}, Execute: []string{"failer"}, If: true}, + // Default RunOnError=false: should NOT run because first hook failed. + {Name: "second", Commands: []string{"plan"}, Execute: []string{"second-cmd"}, If: true}, + // RunOnError=true: SHOULD run despite the prior failure. + {Name: "third", Commands: []string{"plan"}, Execute: []string{"third-cmd"}, If: true, RunOnError: true}, + } + + err := run.ProcessHooks(t.Context(), l, v, hooks, newHookOpts(), &runcfg.RunConfig{}, nil, nil) + require.Error(t, err, "hook failure must propagate") + + calls := rec.snapshot() + + names := make([]string, 0, len(calls)) + for _, c := range calls { + names = append(names, c.Name) + } + + assert.Equal(t, []string{"failer", "third-cmd"}, names, + "second-cmd must be skipped after prior failure (default RunOnError=false); third-cmd must run with RunOnError=true") +} + +// TestProcessHooks_PropagatesWorkingDir pins that the hook's WorkingDir +// is the directory the subprocess sees, not the surrounding opts' +// WorkingDir. This matters for hooks that operate in module-relative +// paths (e.g. `before_hook { working_dir = "infra" }`). +func TestProcessHooks_PropagatesWorkingDir(t *testing.T) { + t.Parallel() + + rec := &recorder{} + v := newHookVenv(rec.handler(vexec.Result{})) + l := logger.CreateLogger() + + opts := newHookOpts() + opts.WorkingDir = "/work/unit" + + hooks := []runcfg.Hook{ + { + Name: "hook-in-subdir", + Commands: []string{"plan"}, + Execute: []string{"do-something"}, + WorkingDir: "/work/unit/scripts", + If: true, + }, + } + + require.NoError(t, run.ProcessHooks(t.Context(), l, v, hooks, opts, &runcfg.RunConfig{}, nil, nil)) + + calls := rec.snapshot() + require.Len(t, calls, 1) + assert.Equal(t, "/work/unit/scripts", calls[0].Dir, "hook WorkingDir must be the subprocess CWD") +} + +// TestProcessErrorHooks_FiresAllMatchingHooks pins the contract that +// multiple error_hooks whose OnErrors regex matches all run when an +// error occurs. There is no first-match-wins semantics. +func TestProcessErrorHooks_FiresAllMatchingHooks(t *testing.T) { + t.Parallel() + + rec := &recorder{} + exec := vexec.NewMemExec(rec.handler(vexec.Result{})) + l := logger.CreateLogger() + + priorErrs := new(errors.MultiError).Append(errors.New("AWS: AccessDenied for action s3:GetObject")) + + hooks := []runcfg.ErrorHook{ + { + Name: "log-aws-error", + Commands: []string{"plan"}, + OnErrors: []string{".*AccessDenied.*"}, + Execute: []string{"logger", "aws"}, + }, + { + Name: "notify-s3", + Commands: []string{"plan"}, + OnErrors: []string{".*s3:.*"}, + Execute: []string{"notify", "s3"}, + }, + { + Name: "non-matching", + Commands: []string{"plan"}, + OnErrors: []string{".*throttl.*"}, + Execute: []string{"shouldnt", "run"}, + }, + } + + require.NoError(t, run.ProcessErrorHooks(t.Context(), l, exec, hooks, newHookOpts(), priorErrs)) + + calls := rec.snapshot() + require.Len(t, calls, 2, "both matching error_hooks must fire") + assert.Equal(t, "logger", calls[0].Name) + assert.Equal(t, "notify", calls[1].Name) +} + +// TestProcessErrorHooks_AccumulatesFailures pins the contract that when +// multiple error_hooks fire and some of them fail, ProcessErrorHooks +// returns a multi-error rather than short-circuiting on the first +// failure. +func TestProcessErrorHooks_AccumulatesFailures(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + if inv.Name == "failing-hook" { + return vexec.Result{ExitCode: 1, Stderr: []byte("hook broke")} + } + + return vexec.Result{} + }) + + l := logger.CreateLogger() + + priorErrs := new(errors.MultiError).Append(errors.New("triggering error")) + + hooks := []runcfg.ErrorHook{ + {Name: "fail-1", Commands: []string{"plan"}, OnErrors: []string{".*"}, Execute: []string{"failing-hook"}}, + {Name: "succeed", Commands: []string{"plan"}, OnErrors: []string{".*"}, Execute: []string{"ok"}}, + {Name: "fail-2", Commands: []string{"plan"}, OnErrors: []string{".*"}, Execute: []string{"failing-hook"}}, + } + + err := run.ProcessErrorHooks(t.Context(), l, exec, hooks, newHookOpts(), priorErrs) + require.Error(t, err, "any failing error_hook must surface as a returned error") +} diff --git a/internal/runner/run/run_e2e_test.go b/internal/runner/run/run_e2e_test.go new file mode 100644 index 0000000000..dafec3998f --- /dev/null +++ b/internal/runner/run/run_e2e_test.go @@ -0,0 +1,249 @@ +package run_test + +import ( + "context" + "io" + "os" + "path/filepath" + "slices" + "sync" + "sync/atomic" + "testing" + + "github.com/gruntwork-io/terragrunt/internal/experiment" + "github.com/gruntwork-io/terragrunt/internal/iacargs" + "github.com/gruntwork-io/terragrunt/internal/iam" + "github.com/gruntwork-io/terragrunt/internal/report" + "github.com/gruntwork-io/terragrunt/internal/runner/run" + "github.com/gruntwork-io/terragrunt/internal/runner/run/creds" + "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" + "github.com/gruntwork-io/terragrunt/internal/strict/controls" + "github.com/gruntwork-io/terragrunt/internal/telemetry" + "github.com/gruntwork-io/terragrunt/internal/vexec" + "github.com/gruntwork-io/terragrunt/internal/vfs" + "github.com/gruntwork-io/terragrunt/internal/writer" + "github.com/gruntwork-io/terragrunt/test/helpers/logger" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// runE2EScaffold builds the minimal real-filesystem scaffolding required +// for run.Run to traverse its pipeline without spawning real tofu. The +// terraform-data-dir markers (.terraform/providers, .terraform/modules, +// .terraform.lock.hcl) suppress the init path so run.Run goes straight +// to the configured terraform command. +// +// DownloadTerraformSource, CheckFolderContainsTerraformCode, and +// providersNeedInit still use the real filesystem; the mem-exec +// virtualization only intercepts subprocess spawns. Once util/file.go +// is threaded through vfs.FS this can become fs-pure. +type runE2EScaffold struct { + dir string + configPath string +} + +func setupRunE2EScaffold(t *testing.T) runE2EScaffold { + t.Helper() + + dir := t.TempDir() + + // Minimal terragrunt.hcl. run.Run doesn't parse it; the path is + // only used by Options.CloneWithConfigPath and to derive WorkingDir. + configPath := filepath.Join(dir, "terragrunt.hcl") + require.NoError(t, os.WriteFile(configPath, []byte(""), 0o644)) + + // .tf file: satisfies CheckFolderContainsTerraformCode. + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(""), 0o644)) + + // Provider/module markers: keep needsInitRunCfg from forcing an init + // recursion before the terraform command actually runs. + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".terraform", "providers"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".terraform", "modules"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".terraform.lock.hcl"), []byte(""), 0o644)) + + return runE2EScaffold{dir: dir, configPath: configPath} +} + +func newRunE2EOpts(t *testing.T, s runE2EScaffold, command string, extraArgs ...string) *run.Options { + t.Helper() + + args := iacargs.New(append([]string{command}, extraArgs...)...) + + return &run.Options{ + WorkingDir: s.dir, + RootWorkingDir: s.dir, + DownloadDir: filepath.Join(s.dir, ".terragrunt-cache"), + TerragruntConfigPath: s.configPath, + OriginalTerragruntConfigPath: s.configPath, + TerraformCommand: command, + TerraformCliArgs: args, + TFPath: "tofu", + Env: map[string]string{}, + SourceMap: map[string]string{}, + Experiments: experiment.NewExperiments(), + StrictControls: controls.New(), + MaxFoldersToCheck: 5, + Writers: writer.Writers{Writer: io.Discard, ErrWriter: io.Discard}, + Telemetry: &telemetry.Options{}, + OriginalIAMRoleOptions: iam.RoleOptions{}, + IAMRoleOptions: iam.RoleOptions{}, + } +} + +// invocationRecorder is a thread-safe accumulator for mem-exec +// invocations. It records the name and args of each call. +type invocationRecorder struct { + calls []vexec.Invocation + mu sync.Mutex +} + +func (r *invocationRecorder) record(inv *vexec.Invocation) { + r.mu.Lock() + defer r.mu.Unlock() + + r.calls = append(r.calls, vexec.Invocation{ + Name: inv.Name, + Dir: inv.Dir, + Args: slices.Clone(inv.Args), + }) +} + +func (r *invocationRecorder) snapshot() []vexec.Invocation { + r.mu.Lock() + defer r.mu.Unlock() + + return slices.Clone(r.calls) +} + +// TestRunPipelineEndToEndPlan exercises run.Run from auth all the way +// to the terraform plan invocation against a mem-backed exec. The mem +// backend captures the terraform subprocess args so we can assert that +// the full pipeline produces the expected `tofu plan` call. +// +// Before this work, the same assertion required a real `tofu` binary +// on PATH and a real terragrunt integration test. +func TestRunPipelineEndToEndPlan(t *testing.T) { + t.Parallel() + + s := setupRunE2EScaffold(t) + + var planCalls atomic.Int32 + + rec := &invocationRecorder{} + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + rec.record(&inv) + + if inv.Name == "tofu" && slices.Contains(inv.Args, "plan") { + planCalls.Add(1) + return vexec.Result{Stdout: []byte("Plan: 0 to add, 0 to change, 0 to destroy.\n")} + } + + // Any other invocation (e.g. a stray init) should fail loudly so + // the test surfaces a pipeline regression rather than silently + // passing. + return vexec.Result{ExitCode: 127, Stderr: []byte("unexpected invocation: " + inv.Name)} + }) + + // FS uses NewOSFS because DownloadTerraformSource still copies real + // files (the temp scaffolding). The mem backend is only for exec + // virtualization; fs virtualization remains a future item. + v := run.Venv{Exec: exec, FS: vfs.NewOSFS()} + l := logger.CreateLogger() + + opts := newRunE2EOpts(t, s, "plan") + cfg := &runcfg.RunConfig{} + credsGetter := creds.NewGetter() + + err := run.Run(t.Context(), l, v, opts, report.NewReport(), cfg, credsGetter) + require.NoError(t, err, "run.Run must succeed when the mem backend returns a clean plan") + + assert.Equal(t, int32(1), planCalls.Load(), "exactly one tofu plan call expected") + + // The plan invocation must use the configured TFPath as the binary + // name and carry `plan` as the first arg. + calls := rec.snapshot() + require.Len(t, calls, 1) + assert.Equal(t, "tofu", calls[0].Name) + assert.Contains(t, calls[0].Args, "plan") +} + +// TestRunPipelineEndToEndPropagatesPlanFailure pins the contract that +// a non-zero terraform exit causes run.Run to return an error. The mem +// backend lets us trigger the failure deterministically without +// terraform-specific state setup. +func TestRunPipelineEndToEndPropagatesPlanFailure(t *testing.T) { + t.Parallel() + + s := setupRunE2EScaffold(t) + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{ExitCode: 1, Stderr: []byte("Error: state lock acquired by another process\n")} + }) + + // FS uses NewOSFS because DownloadTerraformSource still copies real + // files (the temp scaffolding). The mem backend is only for exec + // virtualization; fs virtualization remains a future item. + v := run.Venv{Exec: exec, FS: vfs.NewOSFS()} + l := logger.CreateLogger() + + opts := newRunE2EOpts(t, s, "plan") + opts.AutoRetry = false + + err := run.Run(t.Context(), l, v, opts, report.NewReport(), &runcfg.RunConfig{}, creds.NewGetter()) + require.Error(t, err, "non-zero terraform exit must surface from run.Run") +} + +// TestRunPipelineEndToEndFiresHooks pins the contract that before and +// after hooks fire in the right order around the terraform command. +// Combined with the e2e plan test, this confirms the hook system is +// reachable through the full run.Run pipeline rather than only through +// the unit tests at the ProcessHooks layer. +func TestRunPipelineEndToEndFiresHooks(t *testing.T) { + t.Parallel() + + s := setupRunE2EScaffold(t) + + var dispatched []string + + mu := sync.Mutex{} + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + mu.Lock() + defer mu.Unlock() + + dispatched = append(dispatched, inv.Name) + + if inv.Name == "tofu" { + return vexec.Result{Stdout: []byte("plan ok\n")} + } + + return vexec.Result{} + }) + + // FS uses NewOSFS because DownloadTerraformSource still copies real + // files (the temp scaffolding). The mem backend is only for exec + // virtualization; fs virtualization remains a future item. + v := run.Venv{Exec: exec, FS: vfs.NewOSFS()} + l := logger.CreateLogger() + + opts := newRunE2EOpts(t, s, "plan") + cfg := &runcfg.RunConfig{ + Terraform: runcfg.TerraformConfig{ + BeforeHooks: []runcfg.Hook{ + {Name: "before-plan", Commands: []string{"plan"}, Execute: []string{"step-before"}, If: true}, + }, + AfterHooks: []runcfg.Hook{ + {Name: "after-plan", Commands: []string{"plan"}, Execute: []string{"step-after"}, If: true}, + }, + }, + } + + require.NoError(t, run.Run(t.Context(), l, v, opts, report.NewReport(), cfg, creds.NewGetter())) + + mu.Lock() + defer mu.Unlock() + + assert.Equal(t, []string{"step-before", "tofu", "step-after"}, dispatched, + "before hook must fire, then tofu plan, then after hook") +} diff --git a/pkg/config/config_helpers_hcl_mem_test.go b/pkg/config/config_helpers_hcl_mem_test.go new file mode 100644 index 0000000000..0b70cbbd73 --- /dev/null +++ b/pkg/config/config_helpers_hcl_mem_test.go @@ -0,0 +1,148 @@ +package config_test + +import ( + "context" + "testing" + + "github.com/gruntwork-io/terragrunt/internal/venv" + "github.com/gruntwork-io/terragrunt/internal/vexec" + "github.com/gruntwork-io/terragrunt/internal/vfs" + "github.com/gruntwork-io/terragrunt/pkg/config" + "github.com/gruntwork-io/terragrunt/test/helpers/logger" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHCLGetRepoRoot drives `get_repo_root()` through full HCL +// evaluation. The mem-backed exec stubs `git rev-parse --show-toplevel` +// so the test runs independently of any real git repository. +func TestHCLGetRepoRoot(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + assert.Equal(t, "git", inv.Name) + assert.Equal(t, []string{"rev-parse", "--show-toplevel"}, inv.Args) + + return vexec.Result{Stdout: []byte("/synthetic/repo/root\n")} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, "/synthetic/repo/root/unit/terragrunt.hcl") + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + + const hcl = `locals { + repo = get_repo_root() +} +terraform { + source = local.repo +}` + + out, err := config.ParseConfigString(ctx, pctx, l, "test.hcl", hcl, nil) + require.NoError(t, err) + require.NotNil(t, out) + require.NotNil(t, out.Locals) + assert.Equal(t, "/synthetic/repo/root", out.Locals["repo"]) +} + +// TestHCLGetPathFromRepoRoot drives `get_path_from_repo_root()` through +// full HCL evaluation. The function computes the working dir relative +// to the git top-level dir, so the test stubs git to return a path that +// is an ancestor of pctx.WorkingDir. +func TestHCLGetPathFromRepoRoot(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{Stdout: []byte("/repo\n")} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, "/repo/services/api/terragrunt.hcl") + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + pctx.WorkingDir = "/repo/services/api" + + const hcl = `locals { + rel = get_path_from_repo_root() +}` + + out, err := config.ParseConfigString(ctx, pctx, l, "test.hcl", hcl, nil) + require.NoError(t, err) + assert.Equal(t, "services/api", out.Locals["rel"]) +} + +// TestHCLGetPathToRepoRoot drives `get_path_to_repo_root()` through +// full HCL evaluation. It is the inverse of get_path_from_repo_root: +// the path from the working dir back up to the repo root. +func TestHCLGetPathToRepoRoot(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{Stdout: []byte("/repo\n")} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, "/repo/services/api/terragrunt.hcl") + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + pctx.WorkingDir = "/repo/services/api" + + const hcl = `locals { + up = get_path_to_repo_root() +}` + + out, err := config.ParseConfigString(ctx, pctx, l, "test.hcl", hcl, nil) + require.NoError(t, err) + assert.Equal(t, "../..", out.Locals["up"]) +} + +// TestHCLGetRepoRootPropagatesGitError pins the contract that a failing +// `git rev-parse` surfaces as an error from ParseConfigString rather +// than silently producing an empty string. +func TestHCLGetRepoRootPropagatesGitError(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{ExitCode: 128, Stderr: []byte("fatal: not a git repository\n")} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, "/not/a/repo/terragrunt.hcl") + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + + const hcl = `locals { + repo = get_repo_root() +}` + + _, err := config.ParseConfigString(ctx, pctx, l, "test.hcl", hcl, nil) + require.Error(t, err) +} + +// TestHCLRunCmd drives `run_cmd()` through full HCL evaluation, +// replacing the old TestRunCommand harness with the mem-backed exec. +// The local resolves to the subprocess stdout. +func TestHCLRunCmd(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + assert.Equal(t, "describe", inv.Name) + assert.Equal(t, []string{"--account", "prod"}, inv.Args) + + return vexec.Result{Stdout: []byte("account-1234\n")} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, t.TempDir()+"/terragrunt.hcl") + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + + const hcl = `locals { + account = run_cmd("--terragrunt-quiet", "describe", "--account", "prod") +}` + + out, err := config.ParseConfigString(ctx, pctx, l, "test.hcl", hcl, nil) + require.NoError(t, err) + assert.Equal(t, "account-1234", out.Locals["account"]) +} From e2b33c3e1ab7ab5986d618930f774bffdcb0bb52 Mon Sep 17 00:00:00 2001 From: Yousif Akbar <11247449+yhakbar@users.noreply.github.com> Date: Wed, 13 May 2026 10:25:23 -0400 Subject: [PATCH 2/8] chore: Reducing reliance on unpure testing --- internal/runner/run/download_source_test.go | 18 +++++++--- internal/runner/run/hook_internal_test.go | 39 +++++++-------------- 2 files changed, 26 insertions(+), 31 deletions(-) diff --git a/internal/runner/run/download_source_test.go b/internal/runner/run/download_source_test.go index d5aad03121..b6a436a9d3 100644 --- a/internal/runner/run/download_source_test.go +++ b/internal/runner/run/download_source_test.go @@ -1,6 +1,7 @@ package run_test import ( + "context" "errors" "fmt" "io" @@ -547,7 +548,16 @@ func createConfig( }, } - _, ver, impl, err := run.PopulateTFVersion(t.Context(), l, vexec.NewOSExec(), run.PopulateTFVersionInput{ + // Mem-backed exec: this helper only needs PopulateTFVersion to + // populate opts.TerraformVersion / TofuImplementation; the version + // probe behavior itself is covered by TestGetTFVersion* in + // version_check_mem_test.go. Forking real tofu here would make every + // download_source test depend on tofu being installed. + versionExec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{Stdout: []byte("OpenTofu v1.7.2\n")} + }) + + _, ver, impl, err := run.PopulateTFVersion(t.Context(), l, versionExec, run.PopulateTFVersionInput{ TFOpts: configbridge.TFRunOptsFromOpts(opts), WorkingDir: opts.WorkingDir, VersionFiles: opts.VersionManagerFileName, @@ -563,8 +573,8 @@ func createConfig( func testAlreadyHaveLatestCode(t *testing.T, canonicalURL string, downloadDir string, expected bool) { t.Helper() - logger := logger.CreateLogger() - logger.SetOptions(log.WithOutput(io.Discard)) + l := logger.CreateLogger() + l.SetOptions(log.WithOutput(io.Discard)) terraformSource := &tf.Source{ CanonicalSourceURL: parseURL(t, canonicalURL), @@ -576,7 +586,7 @@ func testAlreadyHaveLatestCode(t *testing.T, canonicalURL string, downloadDir st opts, err := options.NewTerragruntOptionsForTest("./should-not-be-used") require.NoError(t, err) - actual, err := run.AlreadyHaveLatestCode(logger, terraformSource, configbridge.NewRunOptions(opts)) + actual, err := run.AlreadyHaveLatestCode(l, terraformSource, configbridge.NewRunOptions(opts)) require.NoError(t, err) assert.Equal(t, expected, actual, "For terraform source %v", terraformSource) } diff --git a/internal/runner/run/hook_internal_test.go b/internal/runner/run/hook_internal_test.go index 90394148fa..d5a8409036 100644 --- a/internal/runner/run/hook_internal_test.go +++ b/internal/runner/run/hook_internal_test.go @@ -2,7 +2,6 @@ package run import ( "fmt" - "os/exec" "strings" "testing" @@ -11,7 +10,6 @@ import ( "github.com/gruntwork-io/terragrunt/internal/tflint" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestHookErrorMessage_WithStderr(t *testing.T) { @@ -21,7 +19,7 @@ func TestHookErrorMessage_WithStderr(t *testing.T) { output.Stderr.WriteString("resource missing required tags") err := util.ProcessExecutionError{ - Err: getExitError(t, 2), + Err: stubExitErr{code: 2}, Command: "tflint", Args: []string{"--config", ".tflint.hcl"}, WorkingDir: "/tmp", @@ -42,7 +40,7 @@ func TestHookErrorMessage_StdoutFallback(t *testing.T) { output.Stdout.WriteString("warning: deprecated feature") err := util.ProcessExecutionError{ - Err: getExitError(t, 1), + Err: stubExitErr{code: 1}, Command: "custom-lint", Args: []string{"--fix"}, WorkingDir: "/tmp", @@ -60,7 +58,7 @@ func TestHookErrorMessage_NoOutput(t *testing.T) { t.Parallel() err := util.ProcessExecutionError{ - Err: getExitError(t, 3), + Err: stubExitErr{code: 3}, Command: "check", Args: []string{"-strict"}, WorkingDir: "/tmp", @@ -79,7 +77,7 @@ func TestHookErrorMessage_TflintWrapped(t *testing.T) { output.Stderr.WriteString("3 issue(s) found") processErr := util.ProcessExecutionError{ - Err: getExitError(t, 2), + Err: stubExitErr{code: 2}, Command: "tflint", Args: []string{"--config", ".tflint.hcl"}, WorkingDir: "/tmp", @@ -152,7 +150,7 @@ func FuzzHookErrorMessage(f *testing.F) { output.Stdout.WriteString(stdout) feed = util.ProcessExecutionError{ - Err: fuzzExitErr{code: exitCode}, + Err: stubExitErr{code: exitCode}, Command: command, Args: args, WorkingDir: "/tmp", @@ -167,24 +165,11 @@ func FuzzHookErrorMessage(f *testing.F) { }) } -func getExitError(t *testing.T, exitCode int) *exec.ExitError { - t.Helper() +// stubExitErr is a stand-in error that exposes an ExitStatus() so +// [util.GetExitCode] resolves to the encoded code. Both unit tests and +// the fuzz target use it instead of shelling out to produce a real +// *exec.ExitError. +type stubExitErr struct{ code int } - cmd := exec.CommandContext(t.Context(), "sh", "-c", fmt.Sprintf("exit %d", exitCode)) - err := cmd.Run() - require.Error(t, err) - - var exitErr *exec.ExitError - - require.ErrorAs(t, err, &exitErr) - - return exitErr -} - -// fuzzExitErr is a stand-in error that exposes an ExitStatus() so -// [util.GetExitCode] resolves to the encoded code. It lets the fuzz target -// avoid the per-iteration cost of shelling out for a real *exec.ExitError. -type fuzzExitErr struct{ code int } - -func (e fuzzExitErr) Error() string { return fmt.Sprintf("exit status %d", e.code) } -func (e fuzzExitErr) ExitStatus() (int, error) { return e.code, nil } +func (e stubExitErr) Error() string { return fmt.Sprintf("exit status %d", e.code) } +func (e stubExitErr) ExitStatus() (int, error) { return e.code, nil } From 5028ea751f6d3924ac207d9a7d3a3f3b3667bb8e Mon Sep 17 00:00:00 2001 From: Yousif Akbar <11247449+yhakbar@users.noreply.github.com> Date: Wed, 13 May 2026 11:58:22 -0400 Subject: [PATCH 3/8] chore: Plumbing through v in catalog code --- internal/cli/commands/catalog/catalog.go | 13 +- internal/cli/commands/catalog/cli.go | 5 +- internal/cli/commands/catalog/tui/load.go | 11 +- internal/cli/commands/catalog/tui/model.go | 9 +- .../cli/commands/catalog/tui/model_test.go | 37 ++-- internal/cli/commands/catalog/tui/scaffold.go | 9 +- internal/cli/commands/catalog/tui/update.go | 12 +- .../cli/commands/catalog/tui/view_test.go | 29 +-- internal/cli/commands/catalog/tui/welcome.go | 17 +- .../cli/commands/catalog/tui/welcome_test.go | 21 ++- internal/cli/commands/commands.go | 2 +- internal/discovery/graph_target_test.go | 64 +++---- .../externalcmd/provider_mem_test.go | 10 +- internal/runner/run/run_e2e_test.go | 176 +++++++++--------- internal/runner/run/version_check_mem_test.go | 26 +-- internal/services/catalog/module/repo.go | 4 +- .../services/catalog/module/repo_tag_test.go | 138 ++++++++------ internal/shell/git_mem_test.go | 16 +- 18 files changed, 320 insertions(+), 279 deletions(-) diff --git a/internal/cli/commands/catalog/catalog.go b/internal/cli/commands/catalog/catalog.go index adb61e45f1..b3e8e0c1c3 100644 --- a/internal/cli/commands/catalog/catalog.go +++ b/internal/cli/commands/catalog/catalog.go @@ -12,6 +12,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui" "github.com/gruntwork-io/terragrunt/internal/configbridge" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" @@ -29,7 +30,7 @@ const urlChannelBufferSize = 10 // loaded; otherwise source discovery walks the configuration to find catalog // and source URLs. As components are found, the TUI transitions to the // component list, or shows a welcome screen when nothing is discovered. -func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, repoURL string) error { +func Run(ctx context.Context, l log.Logger, v venv.Venv, opts *options.TerragruntOptions, repoURL string) error { // Fail fast with a clear error when there is no terminal to attach the // TUI to, instead of surfacing bubbletea's raw TTY failure. if err := tui.EnsureOSTTY(l); err != nil { @@ -37,24 +38,24 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, rep } return tui.Run( - ctx, l, opts, opts.Writers.ErrWriter, + ctx, l, v, opts, opts.Writers.ErrWriter, func( ctx context.Context, status tui.StatusFunc, componentCh chan<- *tui.ComponentEntry, ) error { if repoURL != "" { status("Loading " + repoURL + "...") - return tui.LoadURL(ctx, l, opts, repoURL, componentCh) + return tui.LoadURL(ctx, l, v, opts, repoURL, componentCh) } - return discoverAndLoad(ctx, l, opts, status, componentCh) + return discoverAndLoad(ctx, l, v, opts, status, componentCh) }) } // discoverAndLoad runs the two concurrent URL discoverers and loads each // distinct repo URL they surface into componentCh, bounded by parallelism. func discoverAndLoad( - ctx context.Context, l log.Logger, opts *options.TerragruntOptions, + ctx context.Context, l log.Logger, v venv.Venv, opts *options.TerragruntOptions, status tui.StatusFunc, componentCh chan<- *tui.ComponentEntry, ) error { urlCh := make(chan string, urlChannelBufferSize) @@ -103,7 +104,7 @@ func discoverAndLoad( seen[repoURL] = struct{}{} loaders.Go(func() error { - err := tui.LoadURL(loadCtx, l, opts, repoURL, componentCh) + err := tui.LoadURL(loadCtx, l, v, opts, repoURL, componentCh) if err == nil { return nil } diff --git a/internal/cli/commands/catalog/cli.go b/internal/cli/commands/catalog/cli.go index 3a25cd4b78..acf99cf7b3 100644 --- a/internal/cli/commands/catalog/cli.go +++ b/internal/cli/commands/catalog/cli.go @@ -11,6 +11,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -68,7 +69,7 @@ func NewFlags(opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Fl return append(shared.NewScaffoldingFlags(opts, prefix), catalogFlags...) } -func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { +func NewCommand(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) *clihelper.Command { return &clihelper.Command{ Name: CommandName, Usage: "Launch the user interface for searching and managing your module catalog.", @@ -84,7 +85,7 @@ func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Comman opts.ScaffoldRootFileName = scaffold.GetDefaultRootFileName(ctx, opts) } - return Run(ctx, l, opts.OptionsFromContext(ctx), repoPath) + return Run(ctx, l, v, opts.OptionsFromContext(ctx), repoPath) }, } } diff --git a/internal/cli/commands/catalog/tui/load.go b/internal/cli/commands/catalog/tui/load.go index 131e59a44f..1f08a71778 100644 --- a/internal/cli/commands/catalog/tui/load.go +++ b/internal/cli/commands/catalog/tui/load.go @@ -9,7 +9,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" "github.com/gruntwork-io/terragrunt/internal/util" - "github.com/gruntwork-io/terragrunt/internal/vfs" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -36,6 +36,7 @@ func CatalogTempPath(repoURL string) string { func LoadURL( ctx context.Context, l log.Logger, + v venv.Venv, opts *options.TerragruntOptions, repoURL string, componentCh chan<- *ComponentEntry, @@ -53,9 +54,7 @@ func LoadURL( l.Debugf("Processing repository %s in temporary path %s", repoURL, tempPath) - fsys := vfs.NewOSFS() - - repo, err := module.NewRepo(ctx, l, fsys, &module.RepoOpts{ + repo, err := module.NewRepo(ctx, l, v.FS, &module.RepoOpts{ CloneURL: repoURL, Path: tempPath, WalkWithSymlinks: walkWithSymlinks, @@ -68,7 +67,7 @@ func LoadURL( return fmt.Errorf("failed to initialize repository %s: %w", repoURL, err) } - discovery := NewComponentDiscovery().WithFS(fsys).WithExtraIgnoreFile(opts.CatalogIgnoreFile) + discovery := NewComponentDiscovery().WithFS(v.FS).WithExtraIgnoreFile(opts.CatalogIgnoreFile) if walkWithSymlinks { discovery = discovery.WithWalkWithSymlinks() } @@ -87,7 +86,7 @@ func LoadURL( // Resolve the latest release tag once per repo. All components from the // same repo share the Repo, so the tag is set for everyone. - repo.ResolveLatestTag(ctx, l) + repo.ResolveLatestTag(ctx, l, v.Exec) source := ExtractRepoURL(repo.SourceURL()) diff --git a/internal/cli/commands/catalog/tui/model.go b/internal/cli/commands/catalog/tui/model.go index 1f2eac09a7..3922527539 100644 --- a/internal/cli/commands/catalog/tui/model.go +++ b/internal/cli/commands/catalog/tui/model.go @@ -17,6 +17,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui/components/buttonbar" "github.com/gruntwork-io/terragrunt/internal/cli/commands/scaffold" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -94,6 +95,7 @@ type Model struct { currentPagerButtons []button exitMessage string viewport viewport.Model + venv venv.Venv activeButton button State sessionState priorState sessionState @@ -119,10 +121,10 @@ type Model struct { // it can synthesize a DiscoveryCompleteMsg without racing the welcome model. // ctx is the cancellable context the welcome layer hands down so off-UI work // can observe Ctrl+C. -func NewModelStreaming(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, initial *ComponentEntry, componentCh chan *ComponentEntry, errCh chan error) Model { +func NewModelStreaming(ctx context.Context, l log.Logger, v venv.Venv, opts *options.TerragruntOptions, initial *ComponentEntry, componentCh chan *ComponentEntry, errCh chan error) Model { items := []list.Item{initial} - m := newModelWithItems(l, opts, items, componentCh) + m := newModelWithItems(l, v, opts, items, componentCh) m.ctx = ctx m.errCh = errCh m.loading = true @@ -331,7 +333,7 @@ func isDuplicate(items []list.Item, sourcePath string) bool { return false } -func newModelWithItems(l log.Logger, opts *options.TerragruntOptions, items []list.Item, componentCh chan *ComponentEntry) Model { +func newModelWithItems(l log.Logger, v venv.Venv, opts *options.TerragruntOptions, items []list.Item, componentCh chan *ComponentEntry) Model { listKeys := NewListKeyMap() delegateKeys := NewDelegateKeyMap() pagerKeys := NewPagerKeyMap() @@ -380,6 +382,7 @@ func newModelWithItems(l log.Logger, opts *options.TerragruntOptions, items []li terragruntOptions: opts, logger: l, componentCh: componentCh, + venv: v, // Matches lipgloss.HasDarkBackground's fallback. Corrected on the // first tea.BackgroundColorMsg. hasDarkBG: true, diff --git a/internal/cli/commands/catalog/tui/model_test.go b/internal/cli/commands/catalog/tui/model_test.go index c71dfca94b..3d255c30c9 100644 --- a/internal/cli/commands/catalog/tui/model_test.go +++ b/internal/cli/commands/catalog/tui/model_test.go @@ -10,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" @@ -72,7 +73,7 @@ func TestModelStreamingInsertsSortedWithRacing(t *testing.T) { require.GreaterOrEqual(t, len(components), 2, "need at least 2 components") componentCh := make(chan *tui.ComponentEntry, len(components)) - m := tui.NewModelStreaming(t.Context(), l, opts, components[len(components)-1], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[len(components)-1], componentCh, nil) close(componentCh) msgs := make([]tea.Msg, 0, len(components)) @@ -130,7 +131,7 @@ func TestModelTabsFilterByKindWithRacing(t *testing.T) { components := makeMixedComponents(t) componentCh := make(chan *tui.ComponentEntry, len(components)) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) close(componentCh) // Cycle: All -> Templates (first tab after All in the current order). @@ -163,7 +164,7 @@ func TestModelTabShiftTabCyclesWithRacing(t *testing.T) { components := makeMixedComponents(t) componentCh := make(chan *tui.ComponentEntry, len(components)) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) close(componentCh) // Starts on All. Shift+Tab wraps to the last tab (Stacks). @@ -215,7 +216,7 @@ func TestModelInteractiveScaffoldTransitionsToFormStateWithRacing(t *testing.T) componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), logger.CreateLogger(), opts, entry, componentCh, nil) + m := tui.NewModelStreaming(t.Context(), logger.CreateLogger(), venv.OSVenv(), opts, entry, componentCh, nil) // Lowercase s = interactive scaffold flow. msgs := []tea.Msg{tea.KeyPressMsg{Code: 's', Text: "s"}} @@ -262,7 +263,7 @@ func TestModelEnterOnPagerLaunchesInteractiveFormWithRacing(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), logger.CreateLogger(), opts, entry, componentCh, nil) + m := tui.NewModelStreaming(t.Context(), logger.CreateLogger(), venv.OSVenv(), opts, entry, componentCh, nil) // First enter: list → pager (opens the README). // Second enter: pager → form (the new behavior). @@ -292,7 +293,7 @@ func TestModelStreamingDeduplicatesWithRacing(t *testing.T) { require.NotEmpty(t, components) componentCh := make(chan *tui.ComponentEntry, len(components)) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) close(componentCh) msgs := []tea.Msg{ @@ -327,7 +328,7 @@ func TestModelCopyFinishedWritesValuesExitMessage(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) // Copy-written: 2 required TODOs (exercises the plural "entries" branch) // and 1 optional (exercises the singular "default" branch). @@ -366,7 +367,7 @@ func TestModelCopyFinishedSkippedValuesExitMessage(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) msg := copyFinishedFromNames(workingDir, []string{"zeta"}, @@ -405,7 +406,7 @@ func TestModelCopyFinishedEmptyReferencesLeavesNoExitMessage(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) msg := copyFinishedFromNames(opts.WorkingDir, nil, nil, false, false) @@ -433,7 +434,7 @@ func TestModelScaffoldFinishedSetsExitMessage(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) updated, _ := m.Update(tui.ScaffoldFinishedMsg{}) finalModel := updated.(tui.Model) @@ -460,7 +461,7 @@ func TestModelScaffoldFinishedEmptyOutputDirHasNoExitMessage(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) updated, _ := m.Update(tui.ScaffoldFinishedMsg{}) finalModel := updated.(tui.Model) @@ -500,7 +501,7 @@ func TestModelCopyFinishedDisplayPathEscapesBaseDir(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) msg := copyFinishedFromNames(baseTmp, []string{"a"}, nil, true, false) @@ -531,7 +532,7 @@ func TestModelScaffoldFailureQuitsWithError(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) scaffoldErr := errors.New("generate failed") @@ -564,7 +565,7 @@ func TestModelCopyFailureQuitsWithError(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) copyErr := errors.New("destination exists") @@ -593,7 +594,7 @@ func TestModelCleanQuitHasNoErrorWithRacing(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) msgs := []tea.Msg{tea.KeyPressMsg{Code: 'q', Text: "q"}} @@ -618,7 +619,7 @@ func TestModelRendererErrMsgSetsViewportAndPagerState(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) // Seed the viewport with a WindowSizeMsg so it has a positive size, // otherwise the pager view will produce a degenerate string. @@ -664,7 +665,7 @@ func TestModelPagerViewRendersAfterEnterWithRacing(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), l, opts, entry, componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, entry, componentCh, nil) msgs := []tea.Msg{ tea.KeyPressMsg{Code: tea.KeyEnter}, @@ -707,7 +708,7 @@ func TestModelPagerWToggleFlipsSoftWrapWithRacing(t *testing.T) { componentCh := make(chan *tui.ComponentEntry) close(componentCh) - m := tui.NewModelStreaming(t.Context(), l, opts, entry, componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, entry, componentCh, nil) // Enter pager, then toggle `w` twice. driveModel runs the // messages through Update in order and returns the final model. diff --git a/internal/cli/commands/catalog/tui/scaffold.go b/internal/cli/commands/catalog/tui/scaffold.go index d641da000a..c72a2098e7 100644 --- a/internal/cli/commands/catalog/tui/scaffold.go +++ b/internal/cli/commands/catalog/tui/scaffold.go @@ -22,10 +22,11 @@ type scaffoldCmd struct { logger log.Logger plan *scaffold.Plan values map[string]string + venv venv.Venv } -func newScaffoldCmd(l log.Logger, opts *options.TerragruntOptions, c *Component) *scaffoldCmd { - return &scaffoldCmd{component: c, opts: opts, logger: l} +func newScaffoldCmd(l log.Logger, v venv.Venv, opts *options.TerragruntOptions, c *Component) *scaffoldCmd { + return &scaffoldCmd{component: c, opts: opts, logger: l, venv: v} } // WithPlan attaches a prepared scaffold.Plan and the user-supplied HCL @@ -49,9 +50,7 @@ func (c *scaffoldCmd) Run() error { c.logger.Debugf("Scaffolding component: %q", c.component.TerraformSourcePath()) - // TODO: thread venv from the CLI entrypoint through the catalog TUI - // so this leaf participates in the root virtualized environment. - return scaffold.Run(context.Background(), c.logger, venv.OSVenv(), c.opts, c.component.TerraformSourcePath(), "") + return scaffold.Run(context.Background(), c.logger, c.venv, c.opts, c.component.TerraformSourcePath(), "") } func (c *scaffoldCmd) SetStdin(io.Reader) {} diff --git a/internal/cli/commands/catalog/tui/update.go b/internal/cli/commands/catalog/tui/update.go index f82b436f9c..b2d23b6c3d 100644 --- a/internal/cli/commands/catalog/tui/update.go +++ b/internal/cli/commands/catalog/tui/update.go @@ -623,7 +623,7 @@ func enterFormState(m Model, c *Component, priorState sessionState) (tea.Model, m.valuesRefs = nil m.selectedComponent = c - return m, discoverFormCmd(m.ctx, m.logger, m.terragruntOptions, c) + return m, discoverFormCmd(m.ctx, m.logger, m.venv, m.terragruntOptions, c) } // discoverFormCmd runs the kind-appropriate variable discovery off the UI @@ -632,13 +632,13 @@ func enterFormState(m Model, c *Component, priorState sessionState) (tea.Model, // the source HCL and walking it via CollectValuesReferences. ctx is the // model's cancellable context so a Ctrl+C during discovery aborts the // download instead of running it to completion. -func discoverFormCmd(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, c *Component) tea.Cmd { +func discoverFormCmd(ctx context.Context, l log.Logger, v venv.Venv, opts *options.TerragruntOptions, c *Component) tea.Cmd { return func() tea.Msg { if c.Kind.IsCopyable() { return discoverValuesFields(c) } - return discoverModuleFields(ctx, l, opts, c) + return discoverModuleFields(ctx, l, v, opts, c) } } @@ -652,10 +652,10 @@ func discoverFormCmd(ctx context.Context, l log.Logger, opts *options.Terragrunt // is discarded. Real failures still surface: they come back as errors and // turn into a formDiscoveryErrMsg, which the outer Update renders as a // styled exit message after tea tears down. -func discoverModuleFields(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, c *Component) tea.Msg { +func discoverModuleFields(ctx context.Context, l log.Logger, v venv.Venv, opts *options.TerragruntOptions, c *Component) tea.Msg { quiet := l.WithOptions(log.WithOutput(io.Discard)) - plan, err := scaffold.Prepare(ctx, quiet, venv.OSVenv(), opts, c.TerraformSourcePath(), "") + plan, err := scaffold.Prepare(ctx, quiet, v, opts, c.TerraformSourcePath(), "") if err != nil { return formDiscoveryErrMsg{err: err} } @@ -742,7 +742,7 @@ func (m *Model) abandonForm() { // generation (which formats HCL and writes files) runs outside the // bubbletea event loop. func scaffoldComponentWithPlanCmd(l log.Logger, m Model, c *Component, plan *scaffold.Plan, values map[string]string) tea.Cmd { - cmd := newScaffoldCmd(l, m.terragruntOptions, c).WithPlan(plan, values) + cmd := newScaffoldCmd(l, m.venv, m.terragruntOptions, c).WithPlan(plan, values) return tea.Exec(cmd, func(err error) tea.Msg { return ScaffoldFinishedMsg{Err: err, Interactive: true} diff --git a/internal/cli/commands/catalog/tui/view_test.go b/internal/cli/commands/catalog/tui/view_test.go index 922a860190..c5539bb498 100644 --- a/internal/cli/commands/catalog/tui/view_test.go +++ b/internal/cli/commands/catalog/tui/view_test.go @@ -11,6 +11,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" @@ -27,7 +28,7 @@ func TestWelcomeLoadingView_RendersSpinnerAndStatus(t *testing.T) { l := logger.CreateLogger() - m := tui.NewWelcomeModel(t.Context(), l, opts, blockingLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, blockingLoad) m = updateModel(m, windowSize).(tui.WelcomeModel) view := m.View() @@ -46,7 +47,7 @@ func TestWelcomeLoadingView_StatusTextUpdates(t *testing.T) { l := logger.CreateLogger() - m := tui.NewWelcomeModel(t.Context(), l, opts, blockingLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, blockingLoad) m = updateModel(m, windowSize).(tui.WelcomeModel) content := stripANSI(m.View().Content) @@ -75,7 +76,7 @@ func TestWelcomeNoSourcesView_RendersHelpText(t *testing.T) { return nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, noSourcesLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, noSourcesLoad) m = updateModel(m, windowSize).(tui.WelcomeModel) m = updateModel(m, tui.DiscoveryCompleteMsg{Err: nil}).(tui.WelcomeModel) @@ -110,7 +111,7 @@ func TestWelcomeDiscoveryErrorView_RendersErrorAndHint(t *testing.T) { return errors.New("network unreachable") } - m := tui.NewWelcomeModel(t.Context(), l, opts, erroringLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, erroringLoad) m = updateModel(m, windowSize).(tui.WelcomeModel) m = updateModel(m, tui.DiscoveryCompleteMsg{Err: errors.New("network unreachable")}).(tui.WelcomeModel) @@ -137,7 +138,7 @@ func TestWelcomeDiscoveryErrorView_AllSourcesFailedDetail(t *testing.T) { l := logger.CreateLogger() - m := tui.NewWelcomeModel(t.Context(), l, opts, blockingLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, blockingLoad) m = updateModel(m, windowSize).(tui.WelcomeModel) srcErr := &tui.SourceLoadError{ @@ -174,7 +175,7 @@ func TestComponentListView_LoadingTitle(t *testing.T) { require.NotEmpty(t, components) componentCh := make(chan *tui.ComponentEntry, 10) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) updated, _ := m.Update(windowSize) m = updated.(tui.Model) @@ -208,7 +209,7 @@ func TestComponentListView_PartialSourceFailureNotice(t *testing.T) { require.NotEmpty(t, components) componentCh := make(chan *tui.ComponentEntry, 10) - m := tui.NewModelStreaming(t.Context(), l, opts, components[0], componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, components[0], componentCh, nil) updated, _ := m.Update(windowSize) m = updated.(tui.Model) @@ -248,7 +249,7 @@ func TestComponentListView_MetadataRowRendered(t *testing.T) { entry := components[0].WithVersion("v1.10.2").WithSource("github.com/gruntwork-io/terragrunt-scale-catalog") componentCh := make(chan *tui.ComponentEntry, 10) - m := tui.NewModelStreaming(t.Context(), l, opts, entry, componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, entry, componentCh, nil) updated, _ := m.Update(windowSize) m = updated.(tui.Model) @@ -275,7 +276,7 @@ func TestComponentListView_TemplateKindRendered(t *testing.T) { )).WithSource("github.com/gruntwork-io/templates-repo") componentCh := make(chan *tui.ComponentEntry, 10) - m := tui.NewModelStreaming(t.Context(), l, opts, template, componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, template, componentCh, nil) updated, _ := m.Update(windowSize) m = updated.(tui.Model) @@ -297,7 +298,7 @@ func TestComponentListView_NoVersionOmitsVersionPill(t *testing.T) { entry := components[0].WithSource("github.com/gruntwork-io/terragrunt-scale-catalog") componentCh := make(chan *tui.ComponentEntry, 10) - m := tui.NewModelStreaming(t.Context(), l, opts, entry, componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, entry, componentCh, nil) updated, _ := m.Update(windowSize) m = updated.(tui.Model) @@ -331,7 +332,7 @@ func TestComponentListView_LongSourceAbbreviatesWithEllipsis(t *testing.T) { )).WithSource(longSource) componentCh := make(chan *tui.ComponentEntry, 1) - m := tui.NewModelStreaming(t.Context(), l, opts, entry, componentCh, nil) + m := tui.NewModelStreaming(t.Context(), l, venv.OSVenv(), opts, entry, componentCh, nil) // Narrow terminal forces the source column to shrink below the raw width, // which forces abbreviateMiddle to truncate. @@ -374,7 +375,7 @@ func TestWelcomeStreamingFlowWithRacing(t *testing.T) { return nil } - var m tea.Model = tui.NewWelcomeModel(t.Context(), l, opts, streamingLoad) + var m tea.Model = tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, streamingLoad) m = updateModel(m, windowSize) m = runUntilQuiet(t, m, m.Init(), 5*time.Second) @@ -493,7 +494,7 @@ func TestWelcomeStreamingFlow_LoadingIndicatorClearsAfterDiscoveryWithRacing(t * return nil } - var m tea.Model = tui.NewWelcomeModel(t.Context(), l, opts, streamingLoad) + var m tea.Model = tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, streamingLoad) m = updateModel(m, windowSize) @@ -580,7 +581,7 @@ func TestWelcomeLoadingSpinnerWithRacing(t *testing.T) { return nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, slowLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, slowLoad) finalModel := driveModel(t, m, 120, 40, []tea.Msg{ tea.KeyPressMsg{Code: 'q', Text: "q"}, diff --git a/internal/cli/commands/catalog/tui/welcome.go b/internal/cli/commands/catalog/tui/welcome.go index 9c177c678a..c6ea909635 100644 --- a/internal/cli/commands/catalog/tui/welcome.go +++ b/internal/cli/commands/catalog/tui/welcome.go @@ -10,6 +10,7 @@ import ( "charm.land/lipgloss/v2" "github.com/pkg/browser" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -86,6 +87,7 @@ type WelcomeModel struct { errCh chan error statusText string spinner spinner.Model + venv venv.Venv state welcomeState width int height int @@ -97,7 +99,13 @@ func ComponentMsg(entry *ComponentEntry) tea.Msg { } // NewWelcomeModel creates a WelcomeModel that immediately begins discovery. -func NewWelcomeModel(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, loadFunc LoadFunc) WelcomeModel { +func NewWelcomeModel( + ctx context.Context, + l log.Logger, + v venv.Venv, + opts *options.TerragruntOptions, + loadFunc LoadFunc, +) WelcomeModel { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#50FA7B")) @@ -112,6 +120,7 @@ func NewWelcomeModel(ctx context.Context, l log.Logger, opts *options.Terragrunt componentCh: make(chan *ComponentEntry, statusChannelSize), errCh: make(chan error, 1), spinner: s, + venv: v, statusText: "Discovering components from your infrastructure...", state: welcomeLoading, } @@ -121,11 +130,11 @@ func NewWelcomeModel(ctx context.Context, l log.Logger, opts *options.Terragrunt // while discovery runs in the background, then transitions to the component // list if components are found. Post-exit messages are written to errWriter // after the tea program restores the main terminal. -func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, errWriter io.Writer, loadFunc LoadFunc) error { +func Run(ctx context.Context, l log.Logger, v venv.Venv, opts *options.TerragruntOptions, errWriter io.Writer, loadFunc LoadFunc) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - model := NewWelcomeModel(ctx, l, opts, loadFunc) + model := NewWelcomeModel(ctx, l, v, opts, loadFunc) finalModel, err := tea.NewProgram(model, tea.WithContext(ctx)).Run() @@ -315,7 +324,7 @@ func (m WelcomeModel) discoveryErrorDetail() []string { func (m WelcomeModel) handleComponentMsg(msg componentMsg) (tea.Model, tea.Cmd) { // First component: transition to the catalog list immediately - newModel := NewModelStreaming(m.ctx, m.logger, m.opts, msg.entry, m.componentCh, m.errCh) + newModel := NewModelStreaming(m.ctx, m.logger, m.venv, m.opts, msg.entry, m.componentCh, m.errCh) width, height := m.width, m.height initCmds := []tea.Cmd{newModel.Init()} diff --git a/internal/cli/commands/catalog/tui/welcome_test.go b/internal/cli/commands/catalog/tui/welcome_test.go index 75e9ba1bf0..facaa2497c 100644 --- a/internal/cli/commands/catalog/tui/welcome_test.go +++ b/internal/cli/commands/catalog/tui/welcome_test.go @@ -12,6 +12,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/gruntwork-io/terragrunt/internal/cli/commands/catalog/tui" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" @@ -85,7 +86,7 @@ func TestWelcomeLoadingScreen_NoSourcesWithRacing(t *testing.T) { return nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, noSourcesLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, noSourcesLoad) msgs := []tea.Msg{tea.KeyPressMsg{Code: 'q', Text: "q"}} @@ -113,7 +114,7 @@ func TestWelcomeDiscoveryErrorQuitPropagatesErrorWithRacing(t *testing.T) { return discoveryErr } - m := tui.NewWelcomeModel(t.Context(), l, opts, erroringLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, erroringLoad) msgs := []tea.Msg{tea.KeyPressMsg{Code: 'q', Text: "q"}} @@ -148,7 +149,7 @@ func TestWelcomeAllSourcesFailedPropagatesTypedErrorWithRacing(t *testing.T) { } } - m := tui.NewWelcomeModel(t.Context(), l, opts, allFailedLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, allFailedLoad) msgs := []tea.Msg{tea.KeyPressMsg{Code: 'q', Text: "q"}} @@ -188,7 +189,7 @@ func TestWelcomeCleanQuitReturnsNoErrorWithRacing(t *testing.T) { return nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, noSourcesLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, noSourcesLoad) msgs := []tea.Msg{tea.KeyPressMsg{Code: 'q', Text: "q"}} @@ -225,7 +226,7 @@ func TestWelcomeLoadingScreen_TransitionsToComponentListWithRacing(t *testing.T) return nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, withComponentsLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, withComponentsLoad) msgs := []tea.Msg{tea.KeyPressMsg{Code: 'q', Text: "q"}} @@ -262,7 +263,7 @@ func TestWelcomeLoadingScreen_ComponentListNavigationWithRacing(t *testing.T) { return nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, withComponentsLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, withComponentsLoad) msgs := []tea.Msg{ tea.KeyPressMsg{Code: tea.KeyEnter}, @@ -298,7 +299,7 @@ func TestWelcomeLoadingScreen_QuitDuringLoadingWithRacing(t *testing.T) { return nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, slowLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, slowLoad) msgs := []tea.Msg{tea.KeyPressMsg{Code: 'q', Text: "q"}} @@ -326,7 +327,7 @@ func TestWelcomeNoSourcesScreen_HelpKeyOpensDocsWithRacing(t *testing.T) { var openedURL string - m := tui.NewWelcomeModel(t.Context(), l, opts, noSourcesLoad). + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, noSourcesLoad). WithOpenURL(func(url string) error { openedURL = url return nil @@ -360,7 +361,7 @@ func TestWelcomeNoSourcesScreen_UnhandledKeyWithRacing(t *testing.T) { return nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, noSourcesLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, noSourcesLoad) msgs := []tea.Msg{ tea.KeyPressMsg{Code: 'x', Text: "x"}, @@ -395,7 +396,7 @@ func TestWelcomeStreamingComponentsWithRacing(t *testing.T) { return nil } - m := tui.NewWelcomeModel(t.Context(), l, opts, streamingLoad) + m := tui.NewWelcomeModel(t.Context(), l, venv.OSVenv(), opts, streamingLoad) msgs := []tea.Msg{tea.KeyPressMsg{Code: 'q', Text: "q"}} diff --git a/internal/cli/commands/commands.go b/internal/cli/commands/commands.go index 07ba708049..09529792b8 100644 --- a/internal/cli/commands/commands.go +++ b/internal/cli/commands/commands.go @@ -83,7 +83,7 @@ func New(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) clihelper.C ) catalogCommands := clihelper.Commands{ - catalog.NewCommand(l, opts), // catalog + catalog.NewCommand(l, opts, v), // catalog scaffold.NewCommand(l, opts, v), // scaffold }.SetCategory( &clihelper.Category{ diff --git a/internal/discovery/graph_target_test.go b/internal/discovery/graph_target_test.go index fe24d3fa05..30d57e657e 100644 --- a/internal/discovery/graph_target_test.go +++ b/internal/discovery/graph_target_test.go @@ -1,8 +1,8 @@ package discovery_test import ( + "context" "os" - "os/exec" "path/filepath" "testing" @@ -12,6 +12,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/filter" + "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" @@ -23,12 +24,6 @@ func TestDiscoveryWithGraphTarget_RetainsTargetAndDependents(t *testing.T) { tmpDir := helpers.TmpDirWOSymlinks(t) - // Initialize a git repository in the temp directory so dependent discovery bounds traversal to the repo root. - cmd := exec.CommandContext(t.Context(), "git", "init") - cmd.Dir = tmpDir - cmd.Env = os.Environ() - require.NoError(t, cmd.Run()) - // Create dependency chain: vpc -> db -> app vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") @@ -58,6 +53,7 @@ dependency "db" { require.NoError(t, err) d := discovery.NewDiscovery(tmpDir). + WithExec(memGitTopLevelExec(t, tmpDir)). WithFilters(depsFilters). WithGraphTarget(vpcDir) @@ -74,12 +70,6 @@ func TestDiscoveryGraphTarget_ParityWithFilterQueries(t *testing.T) { tmpDir := helpers.TmpDirWOSymlinks(t) - // Initialize a git repository in the temp directory so dependent discovery bounds traversal to the repo root. - cmd := exec.CommandContext(t.Context(), "git", "init") - cmd.Dir = tmpDir - cmd.Env = os.Environ() - require.NoError(t, cmd.Run()) - // Create dependency chain: vpc -> db -> app vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") @@ -105,22 +95,21 @@ dependency "db" { opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir - // Path A: filter queries (experiment ON equivalent) - filters, err := filter.ParseFilterQueries(logger.CreateLogger(), []string{`...{` + vpcDir + `}`}) - require.NoError(t, err) - - depsFilters, err := filter.ParseFilterQueries(logger.CreateLogger(), []string{"{./**}..."}) + // Path A: via filter queries (`{./**}...`). + filtersA, err := filter.ParseFilterQueries(logger.CreateLogger(), []string{"{./**}..."}) require.NoError(t, err) configsA, err := discovery.NewDiscovery(tmpDir). - WithFilters(depsFilters). - WithFilters(filters). + WithExec(memGitTopLevelExec(t, tmpDir)). + WithFilters(filtersA). + WithGraphTarget(vpcDir). Discover(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) - // Path B: graph target marker + // Path B: via graphTarget marker path alone. configsB, err := discovery.NewDiscovery(tmpDir). - WithFilters(depsFilters). + WithExec(memGitTopLevelExec(t, tmpDir)). + WithRelationships(). WithGraphTarget(vpcDir). Discover(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) @@ -134,12 +123,6 @@ func TestDiscoveryWithGraphTarget_NoDependents(t *testing.T) { tmpDir := helpers.TmpDirWOSymlinks(t) - // Initialize a git repository - cmd := exec.CommandContext(t.Context(), "git", "init") - cmd.Dir = tmpDir - cmd.Env = os.Environ() - require.NoError(t, cmd.Run()) - // Create standalone units (no dependencies between them) vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") @@ -158,6 +141,7 @@ func TestDiscoveryWithGraphTarget_NoDependents(t *testing.T) { opts.RootWorkingDir = tmpDir d := discovery.NewDiscovery(tmpDir). + WithExec(memGitTopLevelExec(t, tmpDir)). WithRelationships(). WithGraphTarget(vpcDir) @@ -175,12 +159,6 @@ func TestDiscoveryWithOptions_GraphTarget(t *testing.T) { tmpDir := helpers.TmpDirWOSymlinks(t) - // Initialize a git repository - cmd := exec.CommandContext(t.Context(), "git", "init") - cmd.Dir = tmpDir - cmd.Env = os.Environ() - require.NoError(t, cmd.Run()) - // Create dependency chain: vpc -> db vpcDir := filepath.Join(tmpDir, "vpc") dbDir := filepath.Join(tmpDir, "db") @@ -203,6 +181,7 @@ dependency "vpc" { graphTargetOpt := &mockGraphTargetOption{target: vpcDir} d := discovery.NewDiscovery(tmpDir). + WithExec(memGitTopLevelExec(t, tmpDir)). WithRelationships(). WithOptions(graphTargetOpt) @@ -213,6 +192,23 @@ dependency "vpc" { assert.ElementsMatch(t, []string{vpcDir, dbDir}, paths) } +// memGitTopLevelExec returns a vexec.Exec whose `git rev-parse --show-toplevel` +// response is the supplied repoRoot. Any other invocation fails the test so +// a regression that fires unexpected git subcommands is caught here. +func memGitTopLevelExec(t *testing.T, repoRoot string) vexec.Exec { + t.Helper() + + return vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + if inv.Name == "git" && len(inv.Args) == 2 && inv.Args[0] == "rev-parse" && inv.Args[1] == "--show-toplevel" { + return vexec.Result{Stdout: []byte(repoRoot + "\n")} + } + + assert.Fail(t, "unexpected git invocation", "name=%q args=%v", inv.Name, inv.Args) + + return vexec.Result{ExitCode: 1} + }) +} + // mockGraphTargetOption implements the GraphTarget() interface for testing. type mockGraphTargetOption struct { target string diff --git a/internal/runner/run/creds/providers/externalcmd/provider_mem_test.go b/internal/runner/run/creds/providers/externalcmd/provider_mem_test.go index 4e33e2173e..bbd20646ae 100644 --- a/internal/runner/run/creds/providers/externalcmd/provider_mem_test.go +++ b/internal/runner/run/creds/providers/externalcmd/provider_mem_test.go @@ -16,11 +16,6 @@ import ( "github.com/stretchr/testify/require" ) -func newRunOpts() *shell.ShellOptions { - return shell.NewShellOptions(). - WithWriters(writer.Writers{Writer: io.Discard, ErrWriter: io.Discard}) -} - // TestProviderEmptyAuthProviderCmdIsNoop pins the contract that an unset // auth-provider command short-circuits without dispatching anything to // vexec. The previous OS implementation would have constructed @@ -152,3 +147,8 @@ func TestProviderCommandShellwordsParsing(t *testing.T) { _, err := p.GetCredentials(t.Context(), logger.CreateLogger(), exec) require.NoError(t, err) } + +func newRunOpts() *shell.ShellOptions { + return shell.NewShellOptions(). + WithWriters(writer.Writers{Writer: io.Discard, ErrWriter: io.Discard}) +} diff --git a/internal/runner/run/run_e2e_test.go b/internal/runner/run/run_e2e_test.go index dafec3998f..85a5c7641e 100644 --- a/internal/runner/run/run_e2e_test.go +++ b/internal/runner/run/run_e2e_test.go @@ -28,94 +28,6 @@ import ( "github.com/stretchr/testify/require" ) -// runE2EScaffold builds the minimal real-filesystem scaffolding required -// for run.Run to traverse its pipeline without spawning real tofu. The -// terraform-data-dir markers (.terraform/providers, .terraform/modules, -// .terraform.lock.hcl) suppress the init path so run.Run goes straight -// to the configured terraform command. -// -// DownloadTerraformSource, CheckFolderContainsTerraformCode, and -// providersNeedInit still use the real filesystem; the mem-exec -// virtualization only intercepts subprocess spawns. Once util/file.go -// is threaded through vfs.FS this can become fs-pure. -type runE2EScaffold struct { - dir string - configPath string -} - -func setupRunE2EScaffold(t *testing.T) runE2EScaffold { - t.Helper() - - dir := t.TempDir() - - // Minimal terragrunt.hcl. run.Run doesn't parse it; the path is - // only used by Options.CloneWithConfigPath and to derive WorkingDir. - configPath := filepath.Join(dir, "terragrunt.hcl") - require.NoError(t, os.WriteFile(configPath, []byte(""), 0o644)) - - // .tf file: satisfies CheckFolderContainsTerraformCode. - require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(""), 0o644)) - - // Provider/module markers: keep needsInitRunCfg from forcing an init - // recursion before the terraform command actually runs. - require.NoError(t, os.MkdirAll(filepath.Join(dir, ".terraform", "providers"), 0o755)) - require.NoError(t, os.MkdirAll(filepath.Join(dir, ".terraform", "modules"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, ".terraform.lock.hcl"), []byte(""), 0o644)) - - return runE2EScaffold{dir: dir, configPath: configPath} -} - -func newRunE2EOpts(t *testing.T, s runE2EScaffold, command string, extraArgs ...string) *run.Options { - t.Helper() - - args := iacargs.New(append([]string{command}, extraArgs...)...) - - return &run.Options{ - WorkingDir: s.dir, - RootWorkingDir: s.dir, - DownloadDir: filepath.Join(s.dir, ".terragrunt-cache"), - TerragruntConfigPath: s.configPath, - OriginalTerragruntConfigPath: s.configPath, - TerraformCommand: command, - TerraformCliArgs: args, - TFPath: "tofu", - Env: map[string]string{}, - SourceMap: map[string]string{}, - Experiments: experiment.NewExperiments(), - StrictControls: controls.New(), - MaxFoldersToCheck: 5, - Writers: writer.Writers{Writer: io.Discard, ErrWriter: io.Discard}, - Telemetry: &telemetry.Options{}, - OriginalIAMRoleOptions: iam.RoleOptions{}, - IAMRoleOptions: iam.RoleOptions{}, - } -} - -// invocationRecorder is a thread-safe accumulator for mem-exec -// invocations. It records the name and args of each call. -type invocationRecorder struct { - calls []vexec.Invocation - mu sync.Mutex -} - -func (r *invocationRecorder) record(inv *vexec.Invocation) { - r.mu.Lock() - defer r.mu.Unlock() - - r.calls = append(r.calls, vexec.Invocation{ - Name: inv.Name, - Dir: inv.Dir, - Args: slices.Clone(inv.Args), - }) -} - -func (r *invocationRecorder) snapshot() []vexec.Invocation { - r.mu.Lock() - defer r.mu.Unlock() - - return slices.Clone(r.calls) -} - // TestRunPipelineEndToEndPlan exercises run.Run from auth all the way // to the terraform plan invocation against a mem-backed exec. The mem // backend captures the terraform subprocess args so we can assert that @@ -247,3 +159,91 @@ func TestRunPipelineEndToEndFiresHooks(t *testing.T) { assert.Equal(t, []string{"step-before", "tofu", "step-after"}, dispatched, "before hook must fire, then tofu plan, then after hook") } + +// runE2EScaffold builds the minimal real-filesystem scaffolding required +// for run.Run to traverse its pipeline without spawning real tofu. The +// terraform-data-dir markers (.terraform/providers, .terraform/modules, +// .terraform.lock.hcl) suppress the init path so run.Run goes straight +// to the configured terraform command. +// +// DownloadTerraformSource, CheckFolderContainsTerraformCode, and +// providersNeedInit still use the real filesystem; the mem-exec +// virtualization only intercepts subprocess spawns. Once util/file.go +// is threaded through vfs.FS this can become fs-pure. +type runE2EScaffold struct { + dir string + configPath string +} + +func setupRunE2EScaffold(t *testing.T) runE2EScaffold { + t.Helper() + + dir := t.TempDir() + + // Minimal terragrunt.hcl. run.Run doesn't parse it; the path is + // only used by Options.CloneWithConfigPath and to derive WorkingDir. + configPath := filepath.Join(dir, "terragrunt.hcl") + require.NoError(t, os.WriteFile(configPath, []byte(""), 0o644)) + + // .tf file: satisfies CheckFolderContainsTerraformCode. + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(""), 0o644)) + + // Provider/module markers: keep needsInitRunCfg from forcing an init + // recursion before the terraform command actually runs. + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".terraform", "providers"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".terraform", "modules"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".terraform.lock.hcl"), []byte(""), 0o644)) + + return runE2EScaffold{dir: dir, configPath: configPath} +} + +func newRunE2EOpts(t *testing.T, s runE2EScaffold, command string, extraArgs ...string) *run.Options { + t.Helper() + + args := iacargs.New(append([]string{command}, extraArgs...)...) + + return &run.Options{ + WorkingDir: s.dir, + RootWorkingDir: s.dir, + DownloadDir: filepath.Join(s.dir, ".terragrunt-cache"), + TerragruntConfigPath: s.configPath, + OriginalTerragruntConfigPath: s.configPath, + TerraformCommand: command, + TerraformCliArgs: args, + TFPath: "tofu", + Env: map[string]string{}, + SourceMap: map[string]string{}, + Experiments: experiment.NewExperiments(), + StrictControls: controls.New(), + MaxFoldersToCheck: 5, + Writers: writer.Writers{Writer: io.Discard, ErrWriter: io.Discard}, + Telemetry: &telemetry.Options{}, + OriginalIAMRoleOptions: iam.RoleOptions{}, + IAMRoleOptions: iam.RoleOptions{}, + } +} + +// invocationRecorder is a thread-safe accumulator for mem-exec +// invocations. It records the name and args of each call. +type invocationRecorder struct { + calls []vexec.Invocation + mu sync.Mutex +} + +func (r *invocationRecorder) record(inv *vexec.Invocation) { + r.mu.Lock() + defer r.mu.Unlock() + + r.calls = append(r.calls, vexec.Invocation{ + Name: inv.Name, + Dir: inv.Dir, + Args: slices.Clone(inv.Args), + }) +} + +func (r *invocationRecorder) snapshot() []vexec.Invocation { + r.mu.Lock() + defer r.mu.Unlock() + + return slices.Clone(r.calls) +} diff --git a/internal/runner/run/version_check_mem_test.go b/internal/runner/run/version_check_mem_test.go index 8bc771aba8..be7902aea2 100644 --- a/internal/runner/run/version_check_mem_test.go +++ b/internal/runner/run/version_check_mem_test.go @@ -19,19 +19,6 @@ import ( "github.com/stretchr/testify/require" ) -// newVersionTFOptions returns a minimal *tf.TFOptions wired with the mem -// backend so GetTFVersion can dispatch `terraform -version` without spawning -// a real subprocess. -func newVersionTFOptions(tfPath string, env map[string]string) *tf.TFOptions { - return &tf.TFOptions{ - TerraformCliArgs: iacargs.New(), - ShellOptions: shell.NewShellOptions(). - WithTFPath(tfPath). - WithEnv(env). - WithWriters(writer.Writers{Writer: io.Discard, ErrWriter: io.Discard}), - } -} - func TestGetTFVersionOpenTofu(t *testing.T) { t.Parallel() @@ -128,3 +115,16 @@ func TestGetTFVersionStripsTFCLIArgs(t *testing.T) { assert.NotContains(t, kv, "TF_CLI_ARGS", "TF_CLI_ARGS* must be stripped before invoking the version probe; saw %q", kv) } } + +// newVersionTFOptions returns a minimal *tf.TFOptions wired with the mem +// backend so GetTFVersion can dispatch `terraform -version` without spawning +// a real subprocess. +func newVersionTFOptions(tfPath string, env map[string]string) *tf.TFOptions { + return &tf.TFOptions{ + TerraformCliArgs: iacargs.New(), + ShellOptions: shell.NewShellOptions(). + WithTFPath(tfPath). + WithEnv(env). + WithWriters(writer.Writers{Writer: io.Discard, ErrWriter: io.Discard}), + } +} diff --git a/internal/services/catalog/module/repo.go b/internal/services/catalog/module/repo.go index 897aceb0b5..66098a849a 100644 --- a/internal/services/catalog/module/repo.go +++ b/internal/services/catalog/module/repo.go @@ -236,7 +236,7 @@ func (repo *Repo) CloneURL() string { // semver tags, LatestTag is left empty. Local catalog sources skip the // lookup entirely so a stale or unreachable origin URL in a local working // copy can't stall discovery. -func (repo *Repo) ResolveLatestTag(ctx context.Context, l log.Logger) { +func (repo *Repo) ResolveLatestTag(ctx context.Context, l log.Logger, exec vexec.Exec) { if repo.isLocal { return } @@ -246,7 +246,7 @@ func (repo *Repo) ResolveLatestTag(ctx context.Context, l log.Logger) { return } - runner, err := gitpkg.NewGitRunner(vexec.NewOSExec()) + runner, err := gitpkg.NewGitRunner(exec) if err != nil { l.Debugf("catalog: skip tag lookup: %v", err) diff --git a/internal/services/catalog/module/repo_tag_test.go b/internal/services/catalog/module/repo_tag_test.go index 4c18040c58..4e7a1c1162 100644 --- a/internal/services/catalog/module/repo_tag_test.go +++ b/internal/services/catalog/module/repo_tag_test.go @@ -1,99 +1,129 @@ package module_test import ( - "os" - "os/exec" - "path/filepath" + "context" "testing" "github.com/gruntwork-io/terragrunt/internal/services/catalog/module" + "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/test/helpers/logger" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// initBareRepoWithTags creates a bare git repo with the given tags and -// returns its path. -func initBareRepoWithTags(t *testing.T, tags []string) string { - t.Helper() - - bareDir := filepath.Join(t.TempDir(), "remote.git") - require.NoError(t, os.MkdirAll(bareDir, 0755)) +func TestResolveLatestTag_FindsHighestSemver(t *testing.T) { + t.Parallel() - gitEnv := append(os.Environ(), - "GIT_AUTHOR_NAME=test", - "GIT_AUTHOR_EMAIL=test@test.com", - "GIT_COMMITTER_NAME=test", - "GIT_COMMITTER_EMAIL=test@test.com", - ) + exec := memGitExecForTags(t, []string{"v1.0.0", "v1.10.2", "v1.5.0", "not-semver"}) + l := logger.CreateLogger() - runIn := func(dir string, args ...string) { - t.Helper() + repo := &module.Repo{RemoteURL: "https://example.com/org/repo.git"} - cmd := exec.CommandContext(t.Context(), "git", args...) //nolint:gosec // test helper, args are hardcoded - cmd.Dir = dir - cmd.Env = gitEnv + repo.ResolveLatestTag(t.Context(), l, exec) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "git %v failed: %s", args, out) - } + assert.Equal(t, "v1.10.2", repo.LatestTag) +} - runIn(bareDir, "init", "--bare", "--initial-branch=main") +func TestResolveLatestTag_NoTags(t *testing.T) { + t.Parallel() - workDir := t.TempDir() - runIn(workDir, "clone", bareDir, ".") - require.NoError(t, os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test\nA test module."), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(workDir, "main.tf"), []byte("# terraform"), 0644)) - runIn(workDir, "add", ".") - runIn(workDir, "commit", "-m", "init") + // A `git ls-remote` with no matching refs prints nothing and exits + // successfully; the GitRunner surfaces ErrNoMatchingReference which + // ResolveLatestTag swallows, leaving LatestTag empty. + exec := memGitExecForTags(t, nil) + l := logger.CreateLogger() - for _, tag := range tags { - runIn(workDir, "tag", tag) - } + repo := &module.Repo{RemoteURL: "https://example.com/org/repo.git"} - runIn(workDir, "push", "origin", "main", "--tags") + repo.ResolveLatestTag(t.Context(), l, exec) - return bareDir + assert.Empty(t, repo.LatestTag) } -func TestResolveLatestTag_FindsHighestSemver(t *testing.T) { +func TestResolveLatestTag_EmptyRemoteURL(t *testing.T) { t.Parallel() - bareDir := initBareRepoWithTags(t, []string{"v1.0.0", "v1.10.2", "v1.5.0", "not-semver"}) + // No remote means no git fork; pass a handler that fails if reached. + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + assert.Fail(t, "ResolveLatestTag must not dispatch git when RemoteURL is empty") + return vexec.Result{} + }) + l := logger.CreateLogger() - repo := &module.Repo{ - RemoteURL: bareDir, - } + repo := &module.Repo{} - repo.ResolveLatestTag(t.Context(), l) + repo.ResolveLatestTag(t.Context(), l, exec) - assert.Equal(t, "v1.10.2", repo.LatestTag) + assert.Empty(t, repo.LatestTag) } -func TestResolveLatestTag_NoTags(t *testing.T) { +// TestResolveLatestTag_SkipsPrereleases pins the contract that prerelease +// tags (e.g. v2.0.0-rc1) are not considered "latest" releases. The +// original integration-style test couldn't isolate this branch cleanly; +// the mem-exec version makes the prerelease filter directly observable. +func TestResolveLatestTag_SkipsPrereleases(t *testing.T) { t.Parallel() - bareDir := initBareRepoWithTags(t, nil) + exec := memGitExecForTags(t, []string{"v1.0.0", "v2.0.0-rc1", "v1.5.0"}) l := logger.CreateLogger() - repo := &module.Repo{ - RemoteURL: bareDir, - } + repo := &module.Repo{RemoteURL: "https://example.com/org/repo.git"} - repo.ResolveLatestTag(t.Context(), l) + repo.ResolveLatestTag(t.Context(), l, exec) - assert.Empty(t, repo.LatestTag) + assert.Equal(t, "v1.5.0", repo.LatestTag, "v2.0.0-rc1 is a prerelease and must be skipped") } -func TestResolveLatestTag_EmptyRemoteURL(t *testing.T) { +// TestResolveLatestTag_GitFailureLeavesTagEmpty pins the contract that +// a failing git ls-remote (e.g. unreachable remote) does not propagate +// as an error; ResolveLatestTag swallows it and leaves LatestTag empty +// so discovery can continue. +func TestResolveLatestTag_GitFailureLeavesTagEmpty(t *testing.T) { t.Parallel() + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{ExitCode: 128, Stderr: []byte("fatal: unable to access remote\n")} + }) + l := logger.CreateLogger() - repo := &module.Repo{} + repo := &module.Repo{RemoteURL: "https://unreachable.example/repo.git"} - repo.ResolveLatestTag(t.Context(), l) + require.NotPanics(t, func() { + repo.ResolveLatestTag(t.Context(), l, exec) + }) - assert.Empty(t, repo.LatestTag) + assert.Empty(t, repo.LatestTag, "a failed ls-remote must leave LatestTag empty without panicking") +} + +// memGitExecForTags returns a vexec.Exec whose `git ls-remote` response is +// the supplied tags. Any other invocation fails the test so a regression +// that fires unexpected git subcommands is caught here. +func memGitExecForTags(t *testing.T, tags []string) vexec.Exec { + t.Helper() + + return vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + if inv.Name != "git" || len(inv.Args) == 0 || inv.Args[0] != "ls-remote" { + assert.Fail(t, "unexpected git invocation", "name=%q args=%v", inv.Name, inv.Args) + return vexec.Result{ExitCode: 1} + } + + var stdout []byte + for i, tag := range tags { + stdout = append(stdout, []byte(makeLsRemoteLine(i, tag))...) + } + + return vexec.Result{Stdout: stdout} + }) +} + +// makeLsRemoteLine renders one line of `git ls-remote --tags` output: +// "<40-char-hash>\trefs/tags/\n". +func makeLsRemoteLine(seed int, tag string) string { + hash := "0000000000000000000000000000000000000000" + hash = hash[:38] + string("0123456789abcdef"[seed%16]) + string("0123456789abcdef"[(seed+1)%16]) + + return hash + "\trefs/tags/" + tag + "\n" } diff --git a/internal/shell/git_mem_test.go b/internal/shell/git_mem_test.go index af0b50bf74..861823b9ce 100644 --- a/internal/shell/git_mem_test.go +++ b/internal/shell/git_mem_test.go @@ -15,14 +15,6 @@ import ( "github.com/stretchr/testify/require" ) -// gitMemCtx returns a context primed with the repo-root cache so -// GitTopLevelDir can satisfy its memoization invariants without hitting -// the OS. -func gitMemCtx(t *testing.T) context.Context { - t.Helper() - return cache.ContextWithCache(t.Context()) -} - // TestGitTopLevelDirDispatchesGitRevParse pins the exact subprocess // invocation GitTopLevelDir uses to resolve a repository root. The mem // backend asserts the command, args, and working directory so a refactor @@ -154,3 +146,11 @@ func TestGitLastReleaseTagEmptyOnNoSemver(t *testing.T) { require.NoError(t, err) assert.Empty(t, tag) } + +// gitMemCtx returns a context primed with the repo-root cache so +// GitTopLevelDir can satisfy its memoization invariants without hitting +// the OS. +func gitMemCtx(t *testing.T) context.Context { + t.Helper() + return cache.ContextWithCache(t.Context()) +} From 87315a9e90ffd3e0aaaaddfcb79ea1e076c4d788 Mon Sep 17 00:00:00 2001 From: Yousif Akbar <11247449+yhakbar@users.noreply.github.com> Date: Tue, 19 May 2026 13:13:53 -0400 Subject: [PATCH 4/8] fix: Clean-up from PR review --- internal/runner/run/download_source_test.go | 16 ++++++++++-- internal/runner/run/hook_lifecycle_test.go | 22 ++++++++++++----- internal/runner/run/hook_test.go | 23 ++++++++++-------- internal/runner/run/run_e2e_test.go | 27 +-------------------- 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/internal/runner/run/download_source_test.go b/internal/runner/run/download_source_test.go index b6a436a9d3..e0642cc548 100644 --- a/internal/runner/run/download_source_test.go +++ b/internal/runner/run/download_source_test.go @@ -552,8 +552,20 @@ func createConfig( // populate opts.TerraformVersion / TofuImplementation; the version // probe behavior itself is covered by TestGetTFVersion* in // version_check_mem_test.go. Forking real tofu here would make every - // download_source test depend on tofu being installed. - versionExec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + // download_source test depend on tofu being installed. Any invocation + // other than the version probe is a regression: fail loudly rather + // than silently absorb it. + versionExec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + // DefaultWrappedPath resolves to either tofu or terraform depending + // on what's on the host PATH; accept both so the assertion stays + // host-independent. + if (inv.Name != "tofu" && inv.Name != "terraform") || !slices.Contains(inv.Args, "-version") { + assert.Fail(t, "unexpected invocation during PopulateTFVersion", + "name=%q args=%v", inv.Name, inv.Args) + + return vexec.Result{ExitCode: 1} + } + return vexec.Result{Stdout: []byte("OpenTofu v1.7.2\n")} }) diff --git a/internal/runner/run/hook_lifecycle_test.go b/internal/runner/run/hook_lifecycle_test.go index 9cc3d925e2..1362c8cfcb 100644 --- a/internal/runner/run/hook_lifecycle_test.go +++ b/internal/runner/run/hook_lifecycle_test.go @@ -31,7 +31,11 @@ func TestProcessHooks_FiresHooksInDeclarationOrder(t *testing.T) { {Name: "third", Commands: []string{"plan"}, Execute: []string{"step", "3"}, If: true}, } - require.NoError(t, run.ProcessHooks(t.Context(), l, v, hooks, newHookOpts(), &runcfg.RunConfig{}, nil, nil)) + require.NoError(t, run.ProcessHooks(t.Context(), l, v, run.ProcessHooksParams{ + Hooks: hooks, + Opts: newHookOpts(), + Cfg: &runcfg.RunConfig{}, + })) calls := rec.snapshot() require.Len(t, calls, 3) @@ -50,9 +54,7 @@ func TestProcessHooks_AccumulatesErrorsAcrossHooks(t *testing.T) { rec := &recorder{} h := func(_ context.Context, inv vexec.Invocation) vexec.Result { - rec.mu.Lock() - rec.calls = append(rec.calls, vexec.Invocation{Name: inv.Name, Args: append([]string(nil), inv.Args...)}) - rec.mu.Unlock() + rec.record(&inv) if inv.Name == "failer" { return vexec.Result{ExitCode: 1, Stderr: []byte("boom")} @@ -72,7 +74,11 @@ func TestProcessHooks_AccumulatesErrorsAcrossHooks(t *testing.T) { {Name: "third", Commands: []string{"plan"}, Execute: []string{"third-cmd"}, If: true, RunOnError: true}, } - err := run.ProcessHooks(t.Context(), l, v, hooks, newHookOpts(), &runcfg.RunConfig{}, nil, nil) + err := run.ProcessHooks(t.Context(), l, v, run.ProcessHooksParams{ + Hooks: hooks, + Opts: newHookOpts(), + Cfg: &runcfg.RunConfig{}, + }) require.Error(t, err, "hook failure must propagate") calls := rec.snapshot() @@ -110,7 +116,11 @@ func TestProcessHooks_PropagatesWorkingDir(t *testing.T) { }, } - require.NoError(t, run.ProcessHooks(t.Context(), l, v, hooks, opts, &runcfg.RunConfig{}, nil, nil)) + require.NoError(t, run.ProcessHooks(t.Context(), l, v, run.ProcessHooksParams{ + Hooks: hooks, + Opts: opts, + Cfg: &runcfg.RunConfig{}, + })) calls := rec.snapshot() require.Len(t, calls, 1) diff --git a/internal/runner/run/hook_test.go b/internal/runner/run/hook_test.go index 7f5f453b29..59787f674f 100644 --- a/internal/runner/run/hook_test.go +++ b/internal/runner/run/hook_test.go @@ -340,18 +340,21 @@ type recorder struct { mu sync.Mutex } +func (r *recorder) record(inv *vexec.Invocation) { + r.mu.Lock() + defer r.mu.Unlock() + + r.calls = append(r.calls, vexec.Invocation{ + Name: inv.Name, + Dir: inv.Dir, + Args: slices.Clone(inv.Args), + Env: slices.Clone(inv.Env), + }) +} + func (r *recorder) handler(result vexec.Result) vexec.Handler { return func(_ context.Context, inv vexec.Invocation) vexec.Result { - r.mu.Lock() - defer r.mu.Unlock() - - r.calls = append(r.calls, vexec.Invocation{ - Name: inv.Name, - Dir: inv.Dir, - Args: slices.Clone(inv.Args), - Env: slices.Clone(inv.Env), - }) - + r.record(&inv) return result } } diff --git a/internal/runner/run/run_e2e_test.go b/internal/runner/run/run_e2e_test.go index 85a5c7641e..e31728f37b 100644 --- a/internal/runner/run/run_e2e_test.go +++ b/internal/runner/run/run_e2e_test.go @@ -42,7 +42,7 @@ func TestRunPipelineEndToEndPlan(t *testing.T) { var planCalls atomic.Int32 - rec := &invocationRecorder{} + rec := &recorder{} exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { rec.record(&inv) @@ -222,28 +222,3 @@ func newRunE2EOpts(t *testing.T, s runE2EScaffold, command string, extraArgs ... IAMRoleOptions: iam.RoleOptions{}, } } - -// invocationRecorder is a thread-safe accumulator for mem-exec -// invocations. It records the name and args of each call. -type invocationRecorder struct { - calls []vexec.Invocation - mu sync.Mutex -} - -func (r *invocationRecorder) record(inv *vexec.Invocation) { - r.mu.Lock() - defer r.mu.Unlock() - - r.calls = append(r.calls, vexec.Invocation{ - Name: inv.Name, - Dir: inv.Dir, - Args: slices.Clone(inv.Args), - }) -} - -func (r *invocationRecorder) snapshot() []vexec.Invocation { - r.mu.Lock() - defer r.mu.Unlock() - - return slices.Clone(r.calls) -} From 7b0ba191f898d8988e24467bbfe6a14cf058ff0b Mon Sep 17 00:00:00 2001 From: Yousif Akbar <11247449+yhakbar@users.noreply.github.com> Date: Tue, 19 May 2026 14:20:47 -0400 Subject: [PATCH 5/8] fix: Addressing codespell finding --- internal/runner/run/hook_lifecycle_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/runner/run/hook_lifecycle_test.go b/internal/runner/run/hook_lifecycle_test.go index 1362c8cfcb..0d2c8a6e1a 100644 --- a/internal/runner/run/hook_lifecycle_test.go +++ b/internal/runner/run/hook_lifecycle_test.go @@ -56,7 +56,7 @@ func TestProcessHooks_AccumulatesErrorsAcrossHooks(t *testing.T) { h := func(_ context.Context, inv vexec.Invocation) vexec.Result { rec.record(&inv) - if inv.Name == "failer" { + if inv.Name == "failure" { return vexec.Result{ExitCode: 1, Stderr: []byte("boom")} } @@ -67,7 +67,7 @@ func TestProcessHooks_AccumulatesErrorsAcrossHooks(t *testing.T) { l := logger.CreateLogger() hooks := []runcfg.Hook{ - {Name: "first", Commands: []string{"plan"}, Execute: []string{"failer"}, If: true}, + {Name: "first", Commands: []string{"plan"}, Execute: []string{"failure"}, If: true}, // Default RunOnError=false: should NOT run because first hook failed. {Name: "second", Commands: []string{"plan"}, Execute: []string{"second-cmd"}, If: true}, // RunOnError=true: SHOULD run despite the prior failure. @@ -88,7 +88,7 @@ func TestProcessHooks_AccumulatesErrorsAcrossHooks(t *testing.T) { names = append(names, c.Name) } - assert.Equal(t, []string{"failer", "third-cmd"}, names, + assert.Equal(t, []string{"failure", "third-cmd"}, names, "second-cmd must be skipped after prior failure (default RunOnError=false); third-cmd must run with RunOnError=true") } @@ -156,7 +156,7 @@ func TestProcessErrorHooks_FiresAllMatchingHooks(t *testing.T) { Name: "non-matching", Commands: []string{"plan"}, OnErrors: []string{".*throttl.*"}, - Execute: []string{"shouldnt", "run"}, + Execute: []string{"must-not", "run"}, }, } From 4caaea3048d52d865aacc5f1bd0b8c8ee342f071 Mon Sep 17 00:00:00 2001 From: Yousif Akbar <11247449+yhakbar@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:12:46 -0400 Subject: [PATCH 6/8] chore: Addressing drift from main --- internal/cli/commands/catalog/tui/welcome.go | 2 +- internal/runner/run/hook_lifecycle_test.go | 10 +++++----- internal/runner/run/run_e2e_test.go | 1 + 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/internal/cli/commands/catalog/tui/welcome.go b/internal/cli/commands/catalog/tui/welcome.go index c6ea909635..ca31836ab3 100644 --- a/internal/cli/commands/catalog/tui/welcome.go +++ b/internal/cli/commands/catalog/tui/welcome.go @@ -78,6 +78,7 @@ var ( type WelcomeModel struct { ctx context.Context logger log.Logger + venv venv.Venv lastDiscoveryErr error opts *options.TerragruntOptions loadFunc LoadFunc @@ -87,7 +88,6 @@ type WelcomeModel struct { errCh chan error statusText string spinner spinner.Model - venv venv.Venv state welcomeState width int height int diff --git a/internal/runner/run/hook_lifecycle_test.go b/internal/runner/run/hook_lifecycle_test.go index 0d2c8a6e1a..c1c172f2e8 100644 --- a/internal/runner/run/hook_lifecycle_test.go +++ b/internal/runner/run/hook_lifecycle_test.go @@ -2,9 +2,9 @@ package run_test import ( "context" + "errors" "testing" - "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/vexec" @@ -137,7 +137,7 @@ func TestProcessErrorHooks_FiresAllMatchingHooks(t *testing.T) { exec := vexec.NewMemExec(rec.handler(vexec.Result{})) l := logger.CreateLogger() - priorErrs := new(errors.MultiError).Append(errors.New("AWS: AccessDenied for action s3:GetObject")) + priorErrs := []error{errors.New("AWS: AccessDenied for action s3:GetObject")} hooks := []runcfg.ErrorHook{ { @@ -160,7 +160,7 @@ func TestProcessErrorHooks_FiresAllMatchingHooks(t *testing.T) { }, } - require.NoError(t, run.ProcessErrorHooks(t.Context(), l, exec, hooks, newHookOpts(), priorErrs)) + require.NoError(t, run.ProcessErrorHooks(t.Context(), l, exec, hooks, &runcfg.RunConfig{}, newHookOpts(), priorErrs)) calls := rec.snapshot() require.Len(t, calls, 2, "both matching error_hooks must fire") @@ -185,7 +185,7 @@ func TestProcessErrorHooks_AccumulatesFailures(t *testing.T) { l := logger.CreateLogger() - priorErrs := new(errors.MultiError).Append(errors.New("triggering error")) + priorErrs := []error{errors.New("triggering error")} hooks := []runcfg.ErrorHook{ {Name: "fail-1", Commands: []string{"plan"}, OnErrors: []string{".*"}, Execute: []string{"failing-hook"}}, @@ -193,6 +193,6 @@ func TestProcessErrorHooks_AccumulatesFailures(t *testing.T) { {Name: "fail-2", Commands: []string{"plan"}, OnErrors: []string{".*"}, Execute: []string{"failing-hook"}}, } - err := run.ProcessErrorHooks(t.Context(), l, exec, hooks, newHookOpts(), priorErrs) + err := run.ProcessErrorHooks(t.Context(), l, exec, hooks, &runcfg.RunConfig{}, newHookOpts(), priorErrs) require.Error(t, err, "any failing error_hook must surface as a returned error") } diff --git a/internal/runner/run/run_e2e_test.go b/internal/runner/run/run_e2e_test.go index e31728f37b..8634d54ea0 100644 --- a/internal/runner/run/run_e2e_test.go +++ b/internal/runner/run/run_e2e_test.go @@ -203,6 +203,7 @@ func newRunE2EOpts(t *testing.T, s runE2EScaffold, command string, extraArgs ... args := iacargs.New(append([]string{command}, extraArgs...)...) return &run.Options{ + FS: vfs.NewOSFS(), WorkingDir: s.dir, RootWorkingDir: s.dir, DownloadDir: filepath.Join(s.dir, ".terragrunt-cache"), From a436160d0665040011fd08e542599a447dbb2e78 Mon Sep 17 00:00:00 2001 From: Yousif Akbar <11247449+yhakbar@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:27:52 -0400 Subject: [PATCH 7/8] fix: Addressing drift from main --- internal/discovery/graph_target_test.go | 15 +++++++++------ internal/runner/run/action_with_hooks_test.go | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/discovery/graph_target_test.go b/internal/discovery/graph_target_test.go index 30d57e657e..1e8e71d3c1 100644 --- a/internal/discovery/graph_target_test.go +++ b/internal/discovery/graph_target_test.go @@ -95,21 +95,24 @@ dependency "db" { opts.WorkingDir = tmpDir opts.RootWorkingDir = tmpDir - // Path A: via filter queries (`{./**}...`). - filtersA, err := filter.ParseFilterQueries(logger.CreateLogger(), []string{"{./**}..."}) + // Path A: filter queries (experiment ON equivalent) + filters, err := filter.ParseFilterQueries(logger.CreateLogger(), []string{`...{` + vpcDir + `}`}) + require.NoError(t, err) + + depsFilters, err := filter.ParseFilterQueries(logger.CreateLogger(), []string{"{./**}..."}) require.NoError(t, err) configsA, err := discovery.NewDiscovery(tmpDir). WithExec(memGitTopLevelExec(t, tmpDir)). - WithFilters(filtersA). - WithGraphTarget(vpcDir). + WithFilters(depsFilters). + WithFilters(filters). Discover(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) - // Path B: via graphTarget marker path alone. + // Path B: graph target marker configsB, err := discovery.NewDiscovery(tmpDir). WithExec(memGitTopLevelExec(t, tmpDir)). - WithRelationships(). + WithFilters(depsFilters). WithGraphTarget(vpcDir). Discover(t.Context(), logger.CreateLogger(), opts) require.NoError(t, err) diff --git a/internal/runner/run/action_with_hooks_test.go b/internal/runner/run/action_with_hooks_test.go index a473bd87ba..4f6eeef9cc 100644 --- a/internal/runner/run/action_with_hooks_test.go +++ b/internal/runner/run/action_with_hooks_test.go @@ -62,8 +62,8 @@ func TestRunActionWithHooks_FiresBeforeActionAfterInOrder(t *testing.T) { // TestRunActionWithHooks_BeforeHookFailureSkipsAction pins the // contract that a failing before_hook prevents the wrapped action -// from running entirely; after_hooks still fire because they don't -// have RunOnError set the same way, and error_hooks see the failure. +// from running entirely, while error_hooks whose OnErrors regex +// matches the failure still fire. func TestRunActionWithHooks_BeforeHookFailureSkipsAction(t *testing.T) { t.Parallel() From 124f427123d8a0f5d401f532fe412745c22eafb3 Mon Sep 17 00:00:00 2001 From: Yousif Akbar <11247449+yhakbar@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:51:36 -0400 Subject: [PATCH 8/8] chore: Documenting fields in `Model` --- internal/cli/commands/catalog/tui/model.go | 123 ++++++++++++++++----- 1 file changed, 94 insertions(+), 29 deletions(-) diff --git a/internal/cli/commands/catalog/tui/model.go b/internal/cli/commands/catalog/tui/model.go index 3922527539..d7a517b0a0 100644 --- a/internal/cli/commands/catalog/tui/model.go +++ b/internal/cli/commands/catalog/tui/model.go @@ -69,19 +69,48 @@ type Model struct { // work (e.g. scaffold.Prepare downloading sources) propagates the // user's Ctrl+C through this context, so the call returns instead of // blocking on an abandoned download. - ctx context.Context - lists [numTabs]list.Model - logger log.Logger + ctx context.Context + // lists holds one list per tab; activeTab indexes it. A component is + // inserted into every list whose tab filter accepts it, so the same + // entry can appear under All and its kind-specific tab at once. + lists [numTabs]list.Model + // logger surfaces non-fatal diagnostics (e.g. an unknown button press) + // and is handed down to the scaffold/copy/form-discovery leaves. + logger log.Logger + // terragruntOptions carries the resolved CLI options that drive + // discovery and scaffolding; it is threaded into every leaf operation. terragruntOptions *options.TerragruntOptions + // selectedComponent is the entry the user acted on. It is carried into + // the pager and form so scaffold and "view source" know their target + // even after the list selection moves on. selectedComponent *Component - delegateKeys *DelegateKeyMap - buttonBar *buttonbar.ButtonBar - componentCh chan *ComponentEntry - errCh chan error - mdRenderer *glamour.TermRenderer - form *FormModel - scaffoldPlan *scaffold.Plan - valuesRefs *ValuesReferences + // delegateKeys are the list-row keybindings (choose, interactive + // scaffold) matched while in the list view. + delegateKeys *DelegateKeyMap + // buttonBar is the pager's action strip (Scaffold / View Source). It is + // rebuilt per component because the available actions depend on kind. + buttonBar *buttonbar.ButtonBar + // componentCh streams discovered components from the background loader. + // A nil channel disables the listener (used by the non-streaming test + // constructor). + componentCh chan *ComponentEntry + // errCh carries the loader's final result, drained after componentCh + // closes so completion is observed only after every component. + errCh chan error + // mdRenderer is the cached glamour renderer for README markdown. It is + // reused while mdRendererWidth and mdRendererDark still match the + // current width and background, and rebuilt otherwise. + mdRenderer *glamour.TermRenderer + // form is the variable-entry form, nil until a formReadyMsg arrives + // from scaffold-variable discovery. + form *FormModel + // scaffoldPlan is the prepared plan backing the active form. Its + // temporary source download is cleaned up when the form is abandoned + // or the scaffold is consumed. + scaffoldPlan *scaffold.Plan + // valuesRefs holds the `values.*` references collected from a copyable + // unit/stack, used to build that component's values form. + valuesRefs *ValuesReferences // terminalErr is the failure that ended the session (a scaffold, copy, // or form-discovery error). Run returns it so the catalog command exits // nonzero; a deliberate quit leaves it nil. @@ -89,25 +118,61 @@ type Model struct { // loadErr is a non-fatal discovery failure (some catalog sources failed // to load while others produced components). It renders as a notice in // the list view rather than ending the session. - loadErr error - pagerKeys PagerKeyMap - listKeys list.KeyMap + loadErr error + // venv is the root virtualized environment threaded from the CLI + // entrypoint, so scaffold and form-discovery leaves run against the + // same filesystem and exec handles as the rest of Terragrunt. + venv venv.Venv + // pagerKeys are the keybindings handled while reading a README in the + // pager view. + pagerKeys PagerKeyMap + // listKeys are the list-view keybindings, including quit. + listKeys list.KeyMap + // currentPagerButtons are the actions offered for the component in the + // pager; activeButton indexes into it. currentPagerButtons []button - exitMessage string - viewport viewport.Model - venv venv.Venv - activeButton button - State sessionState - priorState sessionState - activeTab TabKind - height int - width int - mdRendererWidth int - ready bool - loading bool - userNavigated bool - hasDarkBG bool - mdRendererDark bool + // exitMessage is the styled message stashed for printing after the alt + // screen tears down (a success callout or a failure notice), since + // writes during the alt screen are discarded. + exitMessage string + // viewport is the scrollable pager that renders README content. + viewport viewport.Model + // activeButton is the focused entry in currentPagerButtons. + activeButton button + // State is the current view (list, pager, form, or scaffold). + State sessionState + // priorState is the view to return to when a form is cancelled, + // recorded when the form is entered. + priorState sessionState + // activeTab is the focused tab (All / Modules / Templates / Stacks); + // it indexes lists. + activeTab TabKind + // height is the last terminal height, refreshed on each WindowSizeMsg + // and used to size the viewport and form. + height int + // width is the last terminal width, refreshed on each WindowSizeMsg and + // used to size the viewport, form, and markdown renderer. + width int + // mdRendererWidth is the width the cached mdRenderer was built for; a + // change invalidates it. + mdRendererWidth int + // ready reports whether the first WindowSizeMsg has sized the viewport, + // gating its one-time creation. + ready bool + // loading reports whether discovery is still streaming components; it + // drives the "(loading...)" suffix on the tab bar. + loading bool + // userNavigated reports whether the user has moved the list cursor. + // Until they do, newly streamed components keep the selection pinned to + // the top instead of shifting it. + userNavigated bool + // hasDarkBG is the detected terminal background brightness. It selects + // the markdown style and, when it changes, invalidates the cached + // renderer. + hasDarkBG bool + // mdRendererDark is the background brightness the cached mdRenderer was + // built for; a change invalidates it. + mdRendererDark bool // softWrap toggles glamour's word-wrap in the pager view. Default true // matches the prior behavior; the `w` key flips it so users reading a // README with intentionally long lines (ascii diagrams, wide tables)