diff --git a/internal/cli/app.go b/internal/cli/app.go index 9affee6351..3c09101f34 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -22,6 +22,7 @@ import ( "errors" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/internal/version" "github.com/gruntwork-io/terragrunt/pkg/config" @@ -45,9 +46,12 @@ type App struct { l log.Logger } -// NewApp creates the Terragrunt CLI App. -func NewApp(l log.Logger, opts *options.TerragruntOptions) *App { - terragruntCommands := commands.New(l, opts) +// NewApp creates the Terragrunt CLI App. The supplied [venv.Venv] is the +// root virtualized environment; it is threaded through to the command +// constructors and captured by their Action closures rather than held on +// the App, so virtualized handlers stay function parameters. +func NewApp(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) *App { + terragruntCommands := commands.New(l, opts, v) app := clihelper.NewApp() app.Name = AppName @@ -57,7 +61,7 @@ func NewApp(l log.Logger, opts *options.TerragruntOptions) *App { app.Writer = opts.Writers.Writer app.ErrWriter = opts.Writers.ErrWriter app.Flags = global.NewFlags(l, opts, nil) - app.Commands = terragruntCommands.WrapAction(commands.WrapWithTelemetry(l, opts)) + app.Commands = terragruntCommands.WrapAction(commands.WrapWithTelemetry(l, opts, v)) app.Before = beforeAction(opts) app.OsExiter = OSExiter app.ExitErrHandler = ExitErrHandler diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index c8b5d515a7..fa85b8975f 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -23,6 +23,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/iacargs" "github.com/gruntwork-io/terragrunt/internal/tf" + "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/log/format" @@ -739,7 +740,7 @@ func TestTerragruntVersion(t *testing.T) { for _, tc := range testCases { output := &bytes.Buffer{} opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr) - app := cli.NewApp(logger.CreateLogger(), opts) + app := cli.NewApp(logger.CreateLogger(), opts, venv.OSVenv()) app.Version = version err := app.Run(tc.args) @@ -755,7 +756,7 @@ func TestTerragruntHelp(t *testing.T) { terragruntPrefix := flags.Prefix{flags.TerragruntPrefix} opts := options.NewTerragruntOptions() - app := cli.NewApp(logger.CreateLogger(), opts) + app := cli.NewApp(logger.CreateLogger(), opts, venv.OSVenv()) testCases := []struct { expected string @@ -794,7 +795,7 @@ func TestTerragruntHelp(t *testing.T) { output := &bytes.Buffer{} opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr) - app := cli.NewApp(logger.CreateLogger(), opts) + app := cli.NewApp(logger.CreateLogger(), opts, venv.OSVenv()) err := app.Run(tc.args) require.NoError(t, err, tc) @@ -822,7 +823,7 @@ func TestTerraformHelp(t *testing.T) { for _, tc := range testCases { output := &bytes.Buffer{} opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr) - app := cli.NewApp(logger.CreateLogger(), opts) + app := cli.NewApp(logger.CreateLogger(), opts, venv.OSVenv()) err := app.Run(tc.args) require.NoError(t, err) @@ -836,7 +837,7 @@ func TestTerraformHelp_wrongHelpFlag(t *testing.T) { output := &bytes.Buffer{} opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr) - app := cli.NewApp(logger.CreateLogger(), opts) + app := cli.NewApp(logger.CreateLogger(), opts, venv.OSVenv()) err := app.Run([]string{"terragrunt", "plan", "help"}) require.Error(t, err) @@ -852,7 +853,7 @@ func setCommandAction(action clihelper.ActionFunc, cmds ...*clihelper.Command) { func runAppTest(l log.Logger, args []string, opts *options.TerragruntOptions) (*options.TerragruntOptions, error) { emptyAction := func(ctx context.Context, cliCtx *clihelper.Context) error { return nil } - terragruntCommands := commands.New(l, opts) + terragruntCommands := commands.New(l, opts, venv.OSVenv()) setCommandAction(emptyAction, terragruntCommands...) app := clihelper.NewApp() @@ -860,7 +861,7 @@ func runAppTest(l log.Logger, args []string, opts *options.TerragruntOptions) (* app.ErrWriter = &bytes.Buffer{} app.Flags = append(global.NewFlags(l, opts, nil), run.NewFlags(l, opts, nil)...) - app.Commands = terragruntCommands.WrapAction(commands.WrapWithTelemetry(l, opts)) + app.Commands = terragruntCommands.WrapAction(commands.WrapWithTelemetry(l, opts, venv.OSVenv())) app.OsExiter = cli.OSExiter app.Action = func(ctx context.Context, cliCtx *clihelper.Context) error { for _, arg := range cliCtx.Args() { @@ -921,7 +922,7 @@ func TestAutocomplete(t *testing.T) { //nolint:paralleltest output := &bytes.Buffer{} opts := options.NewTerragruntOptionsWithWriters(output, os.Stderr) - app := cli.NewApp(logger.CreateLogger(), opts) + app := cli.NewApp(logger.CreateLogger(), opts, venv.OSVenv()) app.Commands = app.Commands.FilterByNames([]string{"hcl", "render", "run"}) diff --git a/internal/cli/commands/aws-provider-patch/aws-provider-patch.go b/internal/cli/commands/aws-provider-patch/aws-provider-patch.go index 60909f32c1..87d8432833 100644 --- a/internal/cli/commands/aws-provider-patch/aws-provider-patch.go +++ b/internal/cli/commands/aws-provider-patch/aws-provider-patch.go @@ -18,6 +18,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/prepare" "github.com/gruntwork-io/terragrunt/internal/report" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -26,42 +27,42 @@ import ( const defaultKeyParts = 2 -func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { +func Run(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) error { if opts.RunAll { - return runAll(ctx, l, opts) + return runAll(ctx, l, v, opts) } - return runSingle(ctx, l, opts) + return runSingle(ctx, l, v, opts) } -func runSingle(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { - prepared, err := prepare.PrepareConfig(ctx, l, opts) +func runSingle(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) error { + prepared, err := prepare.PrepareConfig(ctx, l, v, opts) if err != nil { return err } r := report.NewReport() - updatedOpts, err := prepare.PrepareSource(ctx, l, prepared.Opts, prepared.Cfg, r) + updatedOpts, err := prepare.PrepareSource(ctx, l, v, prepared.Opts, prepared.Cfg, r) if err != nil { return err } runCfg := prepared.Cfg.ToRunConfig(l) - if err := prepare.PrepareGenerate(l, updatedOpts, runCfg); err != nil { + if err := prepare.PrepareGenerate(l, v, updatedOpts, runCfg); err != nil { return err } - if err := prepare.PrepareInit(ctx, l, opts, updatedOpts, runCfg, r); err != nil { + if err := prepare.PrepareInit(ctx, l, v, opts, updatedOpts, runCfg, r); err != nil { return err } return runAwsProviderPatch(l, updatedOpts) } -func runAll(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { - d := discovery.NewDiscovery(opts.WorkingDir) +func runAll(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) error { + d := discovery.NewDiscovery(opts.WorkingDir).WithExec(v.Exec) components, err := d.Discover(ctx, l, opts) if err != nil { @@ -83,7 +84,7 @@ func runAll(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) unitOpts.TerragruntConfigPath = filepath.Join(unit.Path(), configFilename) - if err := runSingle(ctx, l, unitOpts); err != nil { + if err := runSingle(ctx, l, v, unitOpts); err != nil { if opts.FailFast { return err } diff --git a/internal/cli/commands/aws-provider-patch/cli.go b/internal/cli/commands/aws-provider-patch/cli.go index 070cab92ba..26a80bc77b 100644 --- a/internal/cli/commands/aws-provider-patch/cli.go +++ b/internal/cli/commands/aws-provider-patch/cli.go @@ -36,7 +36,9 @@ 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/runner/run" "github.com/gruntwork-io/terragrunt/internal/strict/controls" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -62,7 +64,7 @@ func NewFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix } } -func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { +func NewCommand(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) *clihelper.Command { control := controls.NewDeprecatedCommand(CommandName) opts.StrictControls.FilterByNames(controls.DeprecatedCommands, controls.CLIRedesign, CommandName).AddSubcontrols(control) @@ -82,7 +84,7 @@ func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Comman return nil }, Action: func(ctx context.Context, _ *clihelper.Context) error { - return Run(ctx, l, opts.OptionsFromContext(ctx)) + return Run(ctx, l, run.FromRoot(v), opts.OptionsFromContext(ctx)) }, DisabledErrorOnUndefinedFlag: true, } diff --git a/internal/cli/commands/backend/cli.go b/internal/cli/commands/backend/cli.go index 20b2c5aadb..683ca5619b 100644 --- a/internal/cli/commands/backend/cli.go +++ b/internal/cli/commands/backend/cli.go @@ -6,20 +6,21 @@ import ( "github.com/gruntwork-io/terragrunt/internal/cli/commands/backend/delete" "github.com/gruntwork-io/terragrunt/internal/cli/commands/backend/migrate" "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" ) const CommandName = "backend" -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: "Interact with OpenTofu/Terraform backend infrastructure.", Subcommands: clihelper.Commands{ bootstrap.NewCommand(l, opts), delete.NewCommand(l, opts), - migrate.NewCommand(l, opts), + migrate.NewCommand(l, opts, v), }, Action: clihelper.ShowCommandHelp, } diff --git a/internal/cli/commands/backend/migrate/cli.go b/internal/cli/commands/backend/migrate/cli.go index 8863815496..81508b1f83 100644 --- a/internal/cli/commands/backend/migrate/cli.go +++ b/internal/cli/commands/backend/migrate/cli.go @@ -8,6 +8,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" ) @@ -40,7 +41,7 @@ func NewFlags(l log.Logger, opts *options.TerragruntOptions, prefix flags.Prefix ) } -func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { +func NewCommand(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) *clihelper.Command { cmd := &clihelper.Command{ Name: CommandName, Usage: "Migrate OpenTofu/Terraform state from one location to another.", @@ -57,7 +58,7 @@ func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Comman return errors.New(usageText) } - return Run(ctx, l, srcPath, dstPath, opts.OptionsFromContext(ctx)) + return Run(ctx, l, v, srcPath, dstPath, opts.OptionsFromContext(ctx)) }, } diff --git a/internal/cli/commands/backend/migrate/migrate.go b/internal/cli/commands/backend/migrate/migrate.go index 730bc1a606..a85d885d63 100644 --- a/internal/cli/commands/backend/migrate/migrate.go +++ b/internal/cli/commands/backend/migrate/migrate.go @@ -7,6 +7,8 @@ import ( "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/runner" + "github.com/gruntwork-io/terragrunt/internal/runner/run" + "github.com/gruntwork-io/terragrunt/internal/venv" "errors" @@ -17,7 +19,16 @@ import ( "github.com/gruntwork-io/terragrunt/pkg/options" ) -func Run(ctx context.Context, l log.Logger, srcPath, dstPath string, opts *options.TerragruntOptions) error { +// Run migrates Terraform/OpenTofu state from srcPath to dstPath. v is the +// virtualized environment used for the underlying stack runner build and +// the state pull/push terraform invocations. +func Run( + ctx context.Context, + l log.Logger, + v venv.Venv, + srcPath, dstPath string, + opts *options.TerragruntOptions, +) error { var err error srcPath, err = util.CanonicalPath(srcPath, opts.WorkingDir) @@ -34,7 +45,7 @@ func Run(ctx context.Context, l log.Logger, srcPath, dstPath string, opts *optio l.Debugf("Destination unit path %s", dstPath) - rnr, err := runner.NewStackRunner(ctx, l, opts) + rnr, err := runner.NewStackRunner(ctx, l, run.FromRoot(v), opts) if err != nil { return err } @@ -60,6 +71,7 @@ func Run(ctx context.Context, l log.Logger, srcPath, dstPath string, opts *optio } _, srcPctx := configbridge.NewParsingContext(ctx, l, srcOpts) + srcPctx.Venv = v srcRemoteState, err := config.ParseRemoteState(ctx, l, srcPctx) if err != nil { @@ -76,6 +88,7 @@ func Run(ctx context.Context, l log.Logger, srcPath, dstPath string, opts *optio srcOpts.WorkingDir = srcPctx.WorkingDir _, dstPctx := configbridge.NewParsingContext(ctx, l, dstOpts) + dstPctx.Venv = v dstRemoteState, err := config.ParseRemoteState(ctx, l, dstPctx) if err != nil { @@ -105,6 +118,7 @@ func Run(ctx context.Context, l log.Logger, srcPath, dstPath string, opts *optio return srcRemoteState.Migrate( ctx, l, + v.Exec, configbridge.RemoteStateOptsFromOpts(srcOpts), configbridge.RemoteStateOptsFromOpts(dstOpts), dstRemoteState, diff --git a/internal/cli/commands/catalog/tui/redesign/scaffold.go b/internal/cli/commands/catalog/tui/redesign/scaffold.go index e289179188..55f5b0abca 100644 --- a/internal/cli/commands/catalog/tui/redesign/scaffold.go +++ b/internal/cli/commands/catalog/tui/redesign/scaffold.go @@ -5,6 +5,7 @@ import ( "io" "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" ) @@ -48,7 +49,9 @@ func (c *scaffoldCmd) Run() error { c.logger.Debugf("Scaffolding component: %q", c.component.TerraformSourcePath()) - return scaffold.Run(context.Background(), c.logger, c.opts, 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(), "") } func (c *scaffoldCmd) SetStdin(io.Reader) {} diff --git a/internal/cli/commands/catalog/tui/redesign/update.go b/internal/cli/commands/catalog/tui/redesign/update.go index 19ee6962c4..7dc3808275 100644 --- a/internal/cli/commands/catalog/tui/redesign/update.go +++ b/internal/cli/commands/catalog/tui/redesign/update.go @@ -16,6 +16,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/internal/vfs" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -611,7 +612,7 @@ func discoverFormCmd(ctx context.Context, l log.Logger, opts *options.Terragrunt func discoverModuleFields(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, c *Component) tea.Msg { quiet := l.WithOptions(log.WithOutput(io.Discard)) - plan, err := scaffold.Prepare(ctx, quiet, opts, c.TerraformSourcePath(), "") + plan, err := scaffold.Prepare(ctx, quiet, venv.OSVenv(), opts, c.TerraformSourcePath(), "") if err != nil { return formDiscoveryErrMsg{err: err} } diff --git a/internal/cli/commands/commands.go b/internal/cli/commands/commands.go index 9819240895..07ba708049 100644 --- a/internal/cli/commands/commands.go +++ b/internal/cli/commands/commands.go @@ -19,6 +19,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/util" + "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/log" @@ -68,12 +69,12 @@ const ( // New returns the set of Terragrunt commands, grouped into categories. // Categories are ordered in increments of 10 for easy insertion of new categories. -func New(l log.Logger, opts *options.TerragruntOptions) clihelper.Commands { +func New(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) clihelper.Commands { mainCommands := clihelper.Commands{ - runcmd.NewCommand(l, opts), // run - stack.NewCommand(l, opts), // stack - execcmd.NewCommand(l, opts), // exec - backend.NewCommand(l, opts), // backend + runcmd.NewCommand(l, opts, v), // run + stack.NewCommand(l, opts, v), // stack + execcmd.NewCommand(l, opts, v), // exec + backend.NewCommand(l, opts, v), // backend }.SetCategory( &clihelper.Category{ Name: MainCommandsCategoryName, @@ -82,8 +83,8 @@ func New(l log.Logger, opts *options.TerragruntOptions) clihelper.Commands { ) catalogCommands := clihelper.Commands{ - catalog.NewCommand(l, opts), // catalog - scaffold.NewCommand(l, opts), // scaffold + catalog.NewCommand(l, opts), // catalog + scaffold.NewCommand(l, opts, v), // scaffold }.SetCategory( &clihelper.Category{ Name: CatalogCommandsCategoryName, @@ -102,13 +103,13 @@ func New(l log.Logger, opts *options.TerragruntOptions) clihelper.Commands { ) configurationCommands := clihelper.Commands{ - hcl.NewCommand(l, opts), // hcl - info.NewCommand(l, opts), // info - dag.NewCommand(l, opts), // dag - render.NewCommand(l, opts), // render - helpcmd.NewCommand(l, opts), // help (hidden) - versioncmd.NewCommand(), // version (hidden) - awsproviderpatch.NewCommand(l, opts), // aws-provider-patch (hidden) + hcl.NewCommand(l, opts, v), // hcl + info.NewCommand(l, opts, v), // info + dag.NewCommand(l, opts), // dag + render.NewCommand(l, opts, v), // render + helpcmd.NewCommand(l, opts), // help (hidden) + versioncmd.NewCommand(), // version (hidden) + awsproviderpatch.NewCommand(l, opts, v), // aws-provider-patch (hidden) }.SetCategory( &clihelper.Category{ Name: ConfigurationCommandsCategoryName, @@ -116,7 +117,7 @@ func New(l log.Logger, opts *options.TerragruntOptions) clihelper.Commands { }, ) - shortcutsCommands := NewShortcutsCommands(l, opts).SetCategory( + shortcutsCommands := NewShortcutsCommands(l, opts, v).SetCategory( &clihelper.Category{ Name: ShortcutsCommandsCategoryName, Order: 50, //nolint: mnd @@ -137,6 +138,7 @@ func New(l log.Logger, opts *options.TerragruntOptions) clihelper.Commands { func WrapWithTelemetry( l log.Logger, opts *options.TerragruntOptions, + v venv.Venv, ) func(ctx context.Context, cliCtx *clihelper.Context, action clihelper.ActionFunc) error { return func( ctx context.Context, @@ -156,7 +158,7 @@ func WrapWithTelemetry( return err } - if err := RunAction(childCtx, cliCtx, l, opts, action); err != nil { + if err := RunAction(childCtx, cliCtx, l, opts, v, action); err != nil { opts.Tips.Find(tips.DebuggingDocs).Evaluate(l) return err } @@ -247,6 +249,7 @@ func RunAction( cliCtx *clihelper.Context, l log.Logger, opts *options.TerragruntOptions, + v venv.Venv, action clihelper.ActionFunc, ) error { ctx, cancel := context.WithCancel(ctx) @@ -256,14 +259,14 @@ func RunAction( // Set up automatic provider caching if enabled if !opts.NoAutoProviderCacheDir { - if err := setupAutoProviderCacheDir(ctx, l, opts); err != nil { + if err := setupAutoProviderCacheDir(ctx, l, opts, v.Exec); err != nil { l.Debugf("Auto provider cache dir setup failed: %v", err) } } GiveWindowsSymlinksTip( l, - vfs.NewOSFS(), + v.FS, runtime.GOOS, opts.Tips, opts.Env, @@ -322,7 +325,7 @@ const minTofuVersionForAutoProviderCacheDir = "1.10.0" // setupAutoProviderCacheDir configures native provider caching by setting TF_PLUGIN_CACHE_DIR. // // Only works with OpenTofu version >= 1.10. Returns error if conditions aren't met. -func setupAutoProviderCacheDir(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { +func setupAutoProviderCacheDir(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, exec vexec.Exec) error { // Set TF_PLUGIN_CACHE_DIR environment variable if opts.Env[tf.EnvNameTFPluginCacheDir] != "" { l.Debugf( @@ -334,7 +337,7 @@ func setupAutoProviderCacheDir(ctx context.Context, l log.Logger, opts *options. } if opts.TerraformVersion == nil { - _, ver, impl, err := run.PopulateTFVersion(ctx, l, vexec.NewOSExec(), run.PopulateTFVersionInput{ + _, ver, impl, err := run.PopulateTFVersion(ctx, l, exec, run.PopulateTFVersionInput{ TFOpts: configbridge.TFRunOptsFromOpts(opts), WorkingDir: opts.WorkingDir, VersionFiles: opts.VersionManagerFileName, diff --git a/internal/cli/commands/exec/cli.go b/internal/cli/commands/exec/cli.go index c955d77c9f..345a3d6f31 100644 --- a/internal/cli/commands/exec/cli.go +++ b/internal/cli/commands/exec/cli.go @@ -8,6 +8,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" ) @@ -42,7 +43,7 @@ func NewFlags(l log.Logger, opts *options.TerragruntOptions, cmdOpts *Options, p ) } -func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { +func NewCommand(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) *clihelper.Command { cmdOpts := NewOptions() return &clihelper.Command{ @@ -68,7 +69,7 @@ func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Comman return clihelper.ShowCommandHelp(ctx, cliCtx) } - return Run(ctx, l, opts, cmdOpts, cmdArgs) + return Run(ctx, l, v, opts, cmdOpts, cmdArgs) }, } } diff --git a/internal/cli/commands/exec/exec.go b/internal/cli/commands/exec/exec.go index 1a795eea17..817cf7e974 100644 --- a/internal/cli/commands/exec/exec.go +++ b/internal/cli/commands/exec/exec.go @@ -11,7 +11,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" "github.com/gruntwork-io/terragrunt/internal/shell" - "github.com/gruntwork-io/terragrunt/internal/vexec" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -19,11 +19,14 @@ import ( func Run( ctx context.Context, l log.Logger, + v venv.Venv, opts *options.TerragruntOptions, cmdOpts *Options, args clihelper.Args, ) error { - prepared, err := prepare.PrepareConfig(ctx, l, opts) + rv := run.FromRoot(v) + + prepared, err := prepare.PrepareConfig(ctx, l, rv, opts) if err != nil { return err } @@ -31,7 +34,7 @@ func Run( r := report.NewReport() // Download source - updatedOpts, err := prepare.PrepareSource(ctx, l, prepared.Opts, prepared.Cfg, r) + updatedOpts, err := prepare.PrepareSource(ctx, l, rv, prepared.Opts, prepared.Cfg, r) if err != nil { return err } @@ -39,13 +42,13 @@ func Run( runCfg := prepared.Cfg.ToRunConfig(l) // Generate config - if err := prepare.PrepareGenerate(l, updatedOpts, runCfg); err != nil { + if err := prepare.PrepareGenerate(l, rv, updatedOpts, runCfg); err != nil { return err } if cmdOpts.InDownloadDir { // Run terraform init - if err := prepare.PrepareInit(ctx, l, opts, updatedOpts, runCfg, r); err != nil { + if err := prepare.PrepareInit(ctx, l, rv, opts, updatedOpts, runCfg, r); err != nil { return err } } else { @@ -57,12 +60,13 @@ func Run( } } - return runTargetCommand(ctx, l, updatedOpts, runCfg, r, cmdOpts, args) + return runTargetCommand(ctx, l, v, updatedOpts, runCfg, r, cmdOpts, args) } func runTargetCommand( ctx context.Context, l log.Logger, + v venv.Venv, opts *options.TerragruntOptions, cfg *runcfg.RunConfig, r *report.Report, @@ -80,10 +84,11 @@ func runTargetCommand( } runOpts := configbridge.NewRunOptions(opts) + rv := run.FromRoot(v) - return run.RunActionWithHooks(ctx, l, command, runOpts, cfg, r, func(ctx context.Context) error { + return run.RunActionWithHooks(ctx, l, rv, command, runOpts, cfg, r, func(ctx context.Context) error { _, err := shell.RunCommandWithOutput( - ctx, l, vexec.NewOSExec(), configbridge.ShellRunOptsFromOpts(opts), dir, false, false, command, cmdArgs..., + ctx, l, v.Exec, configbridge.ShellRunOptsFromOpts(opts), dir, false, false, command, cmdArgs..., ) if err != nil { return fmt.Errorf("failed to run command in directory %s: %w", dir, err) diff --git a/internal/cli/commands/hcl/cli.go b/internal/cli/commands/hcl/cli.go index 6b28ea4ab2..0ff8ab58b3 100644 --- a/internal/cli/commands/hcl/cli.go +++ b/internal/cli/commands/hcl/cli.go @@ -5,20 +5,21 @@ import ( "github.com/gruntwork-io/terragrunt/internal/cli/commands/hcl/format" "github.com/gruntwork-io/terragrunt/internal/cli/commands/hcl/validate" "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" ) const CommandName = "hcl" -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: "Interact with HCL files.", Description: "Interact with Terragrunt files written in HashiCorp Configuration Language (HCL).", Subcommands: clihelper.Commands{ format.NewCommand(l, opts), - validate.NewCommand(l, opts), + validate.NewCommand(l, opts, v), }, Action: clihelper.ShowCommandHelp, } diff --git a/internal/cli/commands/hcl/validate/cli.go b/internal/cli/commands/hcl/validate/cli.go index 15e01f866c..35b7ede632 100644 --- a/internal/cli/commands/hcl/validate/cli.go +++ b/internal/cli/commands/hcl/validate/cli.go @@ -6,6 +6,8 @@ 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/runner/run" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -79,14 +81,14 @@ func NewFlags(l log.Logger, opts *options.TerragruntOptions) clihelper.Flags { return flagSet } -func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { +func NewCommand(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) *clihelper.Command { cmd := &clihelper.Command{ Name: CommandName, Usage: "Recursively find HashiCorp Configuration Language (HCL) files and validate them.", Flags: NewFlags(l, opts), DisabledErrorOnUndefinedFlag: true, Action: func(ctx context.Context, _ *clihelper.Context) error { - return Run(ctx, l, opts.OptionsFromContext(ctx)) + return Run(ctx, l, run.FromRoot(v), opts.OptionsFromContext(ctx)) }, } diff --git a/internal/cli/commands/hcl/validate/validate.go b/internal/cli/commands/hcl/validate/validate.go index 47683510bb..35acff136d 100644 --- a/internal/cli/commands/hcl/validate/validate.go +++ b/internal/cli/commands/hcl/validate/validate.go @@ -28,6 +28,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/prepare" "github.com/gruntwork-io/terragrunt/internal/report" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/internal/view" @@ -40,7 +41,7 @@ import ( const splitCount = 2 -func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { +func Run(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) error { if opts.HCLValidateInputs { if opts.HCLValidateShowConfigPath { return fmt.Errorf("specifying both -%s and -%s is invalid", ShowConfigPathFlagName, InputsFlagName) @@ -50,17 +51,17 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) err return fmt.Errorf("specifying both -%s and -%s is invalid", JSONFlagName, InputsFlagName) } - return RunValidateInputs(ctx, l, opts) + return RunValidateInputs(ctx, l, v, opts) } if opts.HCLValidateStrict { return fmt.Errorf("specifying -%s without -%s is invalid", StrictFlagName, InputsFlagName) } - return RunValidate(ctx, l, opts) + return RunValidate(ctx, l, v, opts) } -func RunValidate(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { +func RunValidate(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) error { var diags diagnostic.Diagnostics // Diagnostics handler to collect validation errors @@ -95,6 +96,10 @@ func RunValidate(ctx context.Context, l log.Logger, opts *options.TerragruntOpti Filters: opts.Filters, Experiments: opts.Experiments, }) + if d != nil { + d = d.WithExec(v.Exec) + } + if err != nil { return processDiagnostics(l, opts, diags, err) } @@ -135,6 +140,7 @@ func RunValidate(ctx context.Context, l log.Logger, opts *options.TerragruntOpti parseOpts.TerragruntConfigPath = stackFilePath ctx, parser := configbridge.NewParsingContext(ctx, l, parseOpts) + parser.Venv = v.ToRoot() values, err := config.ReadValues(ctx, parser, l, c.Path()) if err != nil { @@ -168,6 +174,8 @@ func RunValidate(ctx context.Context, l log.Logger, opts *options.TerragruntOpti parseOpts.TerragruntConfigPath = filepath.Join(c.Path(), configFilename) _, pctx := configbridge.NewParsingContext(ctx, l, parseOpts) + pctx.Venv = v.ToRoot() + if _, err := config.ReadTerragruntConfig(ctx, l, pctx, parseOptions); err != nil { parseErrs = append(parseErrs, err) } @@ -231,7 +239,7 @@ func writeDiagnostics(l log.Logger, opts *options.TerragruntOptions, diags diagn return writer.Diagnostics(diags) } -func RunValidateInputs(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { +func RunValidateInputs(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) error { opts = opts.Clone() opts.SkipOutput = true @@ -242,6 +250,10 @@ func RunValidateInputs(ctx context.Context, l log.Logger, opts *options.Terragru Filters: opts.Filters, Experiments: opts.Experiments, }) + if d != nil { + d = d.WithExec(v.Exec) + } + if err != nil { return err } @@ -289,21 +301,21 @@ func RunValidateInputs(ctx context.Context, l log.Logger, opts *options.Terragru unitOpts.TerragruntConfigPath = filepath.Join(c.Path(), configFilename) - prepared, err := prepare.PrepareConfig(ctx, l, unitOpts) + prepared, err := prepare.PrepareConfig(ctx, l, v, unitOpts) if err != nil { errs = append(errs, err) continue } // Download source - updatedOpts, err := prepare.PrepareSource(ctx, l, prepared.Opts, prepared.Cfg, r) + updatedOpts, err := prepare.PrepareSource(ctx, l, v, prepared.Opts, prepared.Cfg, r) if err != nil { errs = append(errs, err) continue } // Generate config - if err := prepare.PrepareGenerate(l, updatedOpts, prepared.Cfg.ToRunConfig(l)); err != nil { + if err := prepare.PrepareGenerate(l, v, updatedOpts, prepared.Cfg.ToRunConfig(l)); err != nil { errs = append(errs, err) continue } diff --git a/internal/cli/commands/info/cli.go b/internal/cli/commands/info/cli.go index 9eec7d79df..4c20f6816a 100644 --- a/internal/cli/commands/info/cli.go +++ b/internal/cli/commands/info/cli.go @@ -5,6 +5,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/cli/commands/info/print" "github.com/gruntwork-io/terragrunt/internal/cli/commands/info/strict" "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" ) @@ -13,13 +14,13 @@ const ( CommandName = "info" ) -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: "List of commands to display Terragrunt settings.", Subcommands: clihelper.Commands{ strict.NewCommand(l, opts), - print.NewCommand(l, opts), + print.NewCommand(l, opts, v), }, Action: clihelper.ShowCommandHelp, } diff --git a/internal/cli/commands/info/print/cli.go b/internal/cli/commands/info/print/cli.go index a74fde0fd5..1bb0320b93 100644 --- a/internal/cli/commands/info/print/cli.go +++ b/internal/cli/commands/info/print/cli.go @@ -6,6 +6,8 @@ import ( runcmd "github.com/gruntwork-io/terragrunt/internal/cli/commands/run" "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" + "github.com/gruntwork-io/terragrunt/internal/runner/run" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -14,7 +16,7 @@ const ( CommandName = "print" ) -func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { +func NewCommand(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) *clihelper.Command { cmdFlags := runcmd.NewFlags(l, opts, nil) cmdFlags = append(cmdFlags, shared.NewAllFlag(opts, nil)) @@ -24,7 +26,7 @@ func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Comman UsageText: "terragrunt info print", Flags: cmdFlags, Action: func(ctx context.Context, _ *clihelper.Context) error { - return Run(ctx, l, opts.OptionsFromContext(ctx)) + return Run(ctx, l, run.FromRoot(v), opts.OptionsFromContext(ctx)) }, } diff --git a/internal/cli/commands/info/print/print.go b/internal/cli/commands/info/print/print.go index f3cfbd4279..c9abaede48 100644 --- a/internal/cli/commands/info/print/print.go +++ b/internal/cli/commands/info/print/print.go @@ -16,23 +16,24 @@ import ( "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/prepare" "github.com/gruntwork-io/terragrunt/internal/report" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) -func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { +func Run(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) error { // If --all flag is set, use discovery to find all units and print info for each one if opts.RunAll { - return runAll(ctx, l, opts) + return runAll(ctx, l, v, opts) } - return runPrint(ctx, l, opts) + return runPrint(ctx, l, v, opts) } -func runPrint(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { - prepared, err := prepare.PrepareConfig(ctx, l, opts) +func runPrint(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) error { + prepared, err := prepare.PrepareConfig(ctx, l, v, opts) if err != nil { // Even on error, try to print what info we have l.Debugf("Fetching info with error: %v", err) @@ -45,7 +46,7 @@ func runPrint(ctx context.Context, l log.Logger, opts *options.TerragruntOptions } // Download source - updatedOpts, err := prepare.PrepareSource(ctx, l, prepared.Opts, prepared.Cfg, report.NewReport()) + updatedOpts, err := prepare.PrepareSource(ctx, l, v, prepared.Opts, prepared.Cfg, report.NewReport()) if err != nil { // Even on error, try to print what info we have l.Debugf("Fetching info with error: %v", err) @@ -60,8 +61,8 @@ func runPrint(ctx context.Context, l log.Logger, opts *options.TerragruntOptions return printTerragruntContext(l, updatedOpts) } -func runAll(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { - d := discovery.NewDiscovery(opts.WorkingDir) +func runAll(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) error { + d := discovery.NewDiscovery(opts.WorkingDir).WithExec(v.Exec) components, err := d.Discover(ctx, l, opts) if err != nil { @@ -83,7 +84,7 @@ func runAll(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) unitOpts.TerragruntConfigPath = filepath.Join(unit.Path(), configFilename) - if err := runPrint(ctx, l, unitOpts); err != nil { + if err := runPrint(ctx, l, v, unitOpts); err != nil { if opts.FailFast { return err } diff --git a/internal/cli/commands/render/cli.go b/internal/cli/commands/render/cli.go index 0c59924c90..6f2372f066 100644 --- a/internal/cli/commands/render/cli.go +++ b/internal/cli/commands/render/cli.go @@ -10,7 +10,9 @@ 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/runner/run" "github.com/gruntwork-io/terragrunt/internal/strict/controls" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -121,7 +123,7 @@ func NewFlags(opts *Options, prefix flags.Prefix) clihelper.Flags { } } -func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { +func NewCommand(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) *clihelper.Command { prefix := flags.Prefix{CommandName} renderOpts := NewOptions(opts) @@ -139,7 +141,7 @@ func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Comman clonedOpts := renderOpts.Clone() clonedOpts.TerragruntOptions = tgOpts - return Run(ctx, l, clonedOpts) + return Run(ctx, l, run.FromRoot(v), clonedOpts) }, } diff --git a/internal/cli/commands/render/render.go b/internal/cli/commands/render/render.go index 69796d5192..0245c1bc0f 100644 --- a/internal/cli/commands/render/render.go +++ b/internal/cli/commands/render/render.go @@ -15,6 +15,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/ctyhelper" "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/prepare" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/util" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -22,16 +23,16 @@ import ( ctyjson "github.com/zclconf/go-cty/cty/json" ) -func Run(ctx context.Context, l log.Logger, opts *Options) error { +func Run(ctx context.Context, l log.Logger, v run.Venv, opts *Options) error { if err := opts.Validate(); err != nil { return err } if opts.RunAll { - return runAll(ctx, l, opts) + return runAll(ctx, l, v, opts) } - prepared, err := prepare.PrepareConfig(ctx, l, opts.TerragruntOptions) + prepared, err := prepare.PrepareConfig(ctx, l, v, opts.TerragruntOptions) if err != nil { return err } @@ -39,8 +40,8 @@ func Run(ctx context.Context, l log.Logger, opts *Options) error { return runRender(l, opts, prepared.Cfg) } -func runAll(ctx context.Context, l log.Logger, opts *Options) error { - d := discovery.NewDiscovery(opts.WorkingDir) +func runAll(ctx context.Context, l log.Logger, v run.Venv, opts *Options) error { + d := discovery.NewDiscovery(opts.WorkingDir).WithExec(v.Exec) components, err := d.Discover(ctx, l, opts.TerragruntOptions) if err != nil { @@ -62,7 +63,7 @@ func runAll(ctx context.Context, l log.Logger, opts *Options) error { unitOpts.TerragruntConfigPath = filepath.Join(unit.Path(), configFilename) - prepared, err := prepare.PrepareConfig(ctx, l, unitOpts.TerragruntOptions) + prepared, err := prepare.PrepareConfig(ctx, l, v, unitOpts.TerragruntOptions) if err != nil { errs = append(errs, err) continue diff --git a/internal/cli/commands/render/render_test.go b/internal/cli/commands/render/render_test.go index 2974ad83f9..c26ec253ea 100644 --- a/internal/cli/commands/render/render_test.go +++ b/internal/cli/commands/render/render_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/gruntwork-io/terragrunt/internal/cli/commands/render" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" @@ -27,7 +28,7 @@ func TestRenderJSON_Basic(t *testing.T) { opts.RenderMetadata = false opts.Write = false - err := render.Run(t.Context(), logger.CreateLogger(), opts) + err := render.Run(t.Context(), logger.CreateLogger(), run.OSVenv(), opts) require.NoError(t, err) var result map[string]any @@ -51,7 +52,7 @@ func TestRenderJSON_WithMetadata(t *testing.T) { opts.RenderMetadata = true opts.Write = false - err := render.Run(t.Context(), logger.CreateLogger(), opts) + err := render.Run(t.Context(), logger.CreateLogger(), run.OSVenv(), opts) require.NoError(t, err) var result map[string]any @@ -73,7 +74,7 @@ func TestRenderJSON_WriteToFile(t *testing.T) { opts.Write = true opts.OutputPath = outputPath - err := render.Run(t.Context(), logger.CreateLogger(), opts) + err := render.Run(t.Context(), logger.CreateLogger(), run.OSVenv(), opts) require.NoError(t, err) // Verify the file was created and contains valid JSON @@ -95,7 +96,7 @@ func TestRenderJSON_InvalidFormat(t *testing.T) { opts, _ := setupTest(t) opts.Format = "invalid" - err := render.Run(t.Context(), logger.CreateLogger(), opts) + err := render.Run(t.Context(), logger.CreateLogger(), run.OSVenv(), opts) require.Error(t, err) assert.Contains(t, err.Error(), "invalid format") } @@ -110,7 +111,7 @@ func TestRenderJSON_HCLFormat(t *testing.T) { opts.Writers.Writer = &renderedBuffer - err := render.Run(t.Context(), logger.CreateLogger(), opts) + err := render.Run(t.Context(), logger.CreateLogger(), run.OSVenv(), opts) require.NoError(t, err) assert.Equal(t, testTerragruntConfigFixture, renderedBuffer.String()) diff --git a/internal/cli/commands/run/cli.go b/internal/cli/commands/run/cli.go index dad1ac2dfc..785c4d05e3 100644 --- a/internal/cli/commands/run/cli.go +++ b/internal/cli/commands/run/cli.go @@ -7,8 +7,10 @@ import ( "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/runner/graph" + innerrun "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/runall" "github.com/gruntwork-io/terragrunt/internal/tf" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -17,7 +19,7 @@ const ( CommandName = "run" ) -func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { +func NewCommand(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) *clihelper.Command { cmdFlags := NewFlags(l, opts, nil) cmdFlags = append(cmdFlags, shared.NewAllFlag(opts, nil), shared.NewGraphFlag(opts, nil)) @@ -31,30 +33,31 @@ func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Comman "# Run output with -json flag\nterragrunt run -- output -json\n# Shortcut:\n# terragrunt output -json", }, Flags: cmdFlags, - Subcommands: NewSubcommands(l, opts), + Subcommands: NewSubcommands(l, opts, v), Action: func(ctx context.Context, cliCtx *clihelper.Context) error { tgOpts := opts.OptionsFromContext(ctx) + rv := innerrun.FromRoot(v) if tgOpts.RunAll { - return runall.Run(ctx, l, tgOpts) + return runall.Run(ctx, l, rv, tgOpts) } if tgOpts.Graph { - return graph.Run(ctx, l, tgOpts) + return graph.Run(ctx, l, rv, tgOpts) } if len(cliCtx.Args()) == 0 { return clihelper.ShowCommandHelp(ctx, cliCtx) } - return Action(l, opts)(ctx, cliCtx) + return Action(l, opts, v)(ctx, cliCtx) }, } return cmd } -func NewSubcommands(l log.Logger, opts *options.TerragruntOptions) clihelper.Commands { +func NewSubcommands(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) clihelper.Commands { var subcommands = make(clihelper.Commands, len(tf.CommandNames)) for i, name := range tf.CommandNames { @@ -66,7 +69,7 @@ func NewSubcommands(l log.Logger, opts *options.TerragruntOptions) clihelper.Com Hidden: !visible, CustomHelp: ShowTFHelp(l, opts), Action: func(ctx context.Context, cliCtx *clihelper.Context) error { - return Action(l, opts)(ctx, cliCtx) + return Action(l, opts, v)(ctx, cliCtx) }, } subcommands[i] = subcommand @@ -75,8 +78,8 @@ func NewSubcommands(l log.Logger, opts *options.TerragruntOptions) clihelper.Com return subcommands } -func Action(l log.Logger, opts *options.TerragruntOptions) clihelper.ActionFunc { +func Action(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) clihelper.ActionFunc { return func(ctx context.Context, _ *clihelper.Context) error { - return Run(ctx, l, opts) + return Run(ctx, l, opts, v) } } diff --git a/internal/cli/commands/run/run.go b/internal/cli/commands/run/run.go index eb2792cdc5..78b81ec218 100644 --- a/internal/cli/commands/run/run.go +++ b/internal/cli/commands/run/run.go @@ -18,16 +18,15 @@ import ( "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/tips" "github.com/gruntwork-io/terragrunt/internal/util" - "github.com/gruntwork-io/terragrunt/internal/vexec" - "github.com/gruntwork-io/terragrunt/internal/vfs" + "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" ) // Run runs the run command. -func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { - tips.GiveStackTargetTip(l, vfs.NewOSFS(), opts.WorkingDir, opts.Filters, opts.Tips) +func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, v venv.Venv) error { + tips.GiveStackTargetTip(l, v.FS, opts.WorkingDir, opts.Filters, opts.Tips) if opts.TerraformCommand == tf.CommandNameDestroy { opts.CheckDependentUnits = opts.DestroyDependenciesCheck @@ -49,13 +48,14 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) err } tgOpts := opts.OptionsFromContext(ctx) + rv := run.FromRoot(v) if tgOpts.RunAll { - return runall.Run(ctx, l, tgOpts) + return runall.Run(ctx, l, rv, tgOpts) } if tgOpts.Graph { - return graph.Run(ctx, l, tgOpts) + return graph.Run(ctx, l, rv, tgOpts) } if opts.ReportSchemaFile != "" { @@ -72,7 +72,7 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) err // Early exit for version command to avoid expensive setup if opts.TerraformCommand == tf.CommandNameVersion { - return runVersionCommand(ctx, l, opts) + return runVersionCommand(ctx, l, opts, v) } // We need to get the credentials from auth-provider-cmd at the very beginning, @@ -81,18 +81,20 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) err if err := credsGetter.ObtainAndUpdateEnvIfNecessary( ctx, l, + rv.Exec, opts.Env, externalcmd.NewProvider(l, opts.AuthProviderCmd, configbridge.ShellRunOptsFromOpts(opts)), ); err != nil { return err } - l, err := checkVersionConstraints(ctx, l, opts) + l, err := checkVersionConstraints(ctx, l, opts, v) if err != nil { return err } parseCtx, pctx := configbridge.NewParsingContext(ctx, l, opts) + pctx.Venv = rv.ToRoot() cfg, err := config.ReadTerragruntConfig(parseCtx, l, pctx, pctx.ParserOptions) if err != nil { @@ -100,7 +102,7 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) err } if opts.CheckDependentUnits { - allowDestroy := confirmActionWithDependentUnits(ctx, l, opts, cfg) + allowDestroy := confirmActionWithDependentUnits(ctx, l, rv, opts, cfg) if !allowDestroy { return nil } @@ -140,7 +142,7 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) err } }() - runErr = run.Run(ctx, l, configbridge.NewRunOptions(tgOpts), r, runCfg, credsGetter) + runErr = run.Run(ctx, l, rv, configbridge.NewRunOptions(tgOpts), r, runCfg, credsGetter) return runErr } @@ -153,26 +155,26 @@ func isTerraformPath(opts *options.TerragruntOptions) bool { // runVersionCommand runs the version command. We do this instead of going through the normal run flow because // we can resolve `version` a lot more cheaply. -func runVersionCommand(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { +func runVersionCommand(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, v venv.Venv) error { if !opts.TFPathExplicitlySet { - if tfPath, err := getTFPathFromConfig(ctx, l, opts); err != nil { + if tfPath, err := getTFPathFromConfig(ctx, l, v, opts); err != nil { return err } else if tfPath != "" { opts.TFPath = tfPath } } - return tf.RunCommand(ctx, l, vexec.NewOSExec(), configbridge.TFRunOptsFromOpts(opts), opts.TerraformCliArgs.Slice()...) + return tf.RunCommand(ctx, l, v.Exec, configbridge.TFRunOptsFromOpts(opts), opts.TerraformCliArgs.Slice()...) } -func getTFPathFromConfig(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (string, error) { +func getTFPathFromConfig(ctx context.Context, l log.Logger, v venv.Venv, opts *options.TerragruntOptions) (string, error) { if !util.FileExists(opts.TerragruntConfigPath) { l.Debugf("Did not find the config file %s", opts.TerragruntConfigPath) return "", nil } - cfg, err := getTerragruntConfig(ctx, l, opts) + cfg, err := getTerragruntConfig(ctx, l, v, opts) if err != nil { return "", err } @@ -186,8 +188,8 @@ func getTFPathFromConfig(ctx context.Context, l log.Logger, opts *options.Terrag // - TerraformVersion // - FeatureFlags // TODO: Look into a way to refactor this function to avoid the side effect. -func checkVersionConstraints(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (log.Logger, error) { - partialTerragruntConfig, err := getTerragruntConfig(ctx, l, opts) +func checkVersionConstraints(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, v venv.Venv) (log.Logger, error) { + partialTerragruntConfig, err := getTerragruntConfig(ctx, l, v, opts) if err != nil { return l, err } @@ -197,7 +199,7 @@ func checkVersionConstraints(ctx context.Context, l log.Logger, opts *options.Te opts.TFPath = partialTerragruntConfig.TerraformBinary } - l, ver, impl, err := run.PopulateTFVersion(ctx, l, vexec.NewOSExec(), run.PopulateTFVersionInput{ + l, ver, impl, err := run.PopulateTFVersion(ctx, l, v.Exec, run.PopulateTFVersionInput{ TFOpts: configbridge.TFRunOptsFromOpts(opts), WorkingDir: opts.WorkingDir, VersionFiles: opts.VersionManagerFileName, @@ -246,12 +248,9 @@ func checkVersionConstraints(ctx context.Context, l log.Logger, opts *options.Te return l, nil } -func getTerragruntConfig( - ctx context.Context, - l log.Logger, - opts *options.TerragruntOptions, -) (*config.TerragruntConfig, error) { +func getTerragruntConfig(ctx context.Context, l log.Logger, v venv.Venv, opts *options.TerragruntOptions) (*config.TerragruntConfig, error) { ctx, configCtx := configbridge.NewParsingContext(ctx, l, opts) + configCtx.Venv = v configCtx = configCtx.WithDecodeList( config.TerragruntVersionConstraints, config.FeatureFlagsBlock, @@ -270,10 +269,11 @@ func getTerragruntConfig( func confirmActionWithDependentUnits( ctx context.Context, l log.Logger, + v run.Venv, opts *options.TerragruntOptions, cfg *config.TerragruntConfig, ) bool { - units := findDependentUnits(ctx, l, opts, cfg) + units := findDependentUnits(ctx, l, v, opts, cfg) if len(units) != 0 { if _, err := opts.Writers.ErrWriter.Write([]byte("Detected dependent units:\n")); err != nil { l.Error(err) @@ -305,10 +305,11 @@ func confirmActionWithDependentUnits( func findDependentUnits( ctx context.Context, l log.Logger, + v run.Venv, opts *options.TerragruntOptions, cfg *config.TerragruntConfig, ) []string { - units := runner.FindDependentUnits(ctx, l, opts, cfg) + units := runner.FindDependentUnits(ctx, l, v, opts, cfg) paths := make([]string, len(units)) for i, unit := range units { diff --git a/internal/cli/commands/run_action_test.go b/internal/cli/commands/run_action_test.go index e5b8b900dc..4cf11cee87 100644 --- a/internal/cli/commands/run_action_test.go +++ b/internal/cli/commands/run_action_test.go @@ -7,6 +7,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/internal/cli/commands" "github.com/gruntwork-io/terragrunt/internal/clihelper" + "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" @@ -37,7 +38,7 @@ func TestRunActionInstallsRunScopedCache(t *testing.T) { l := logger.CreateLogger() - require.NoError(t, commands.RunAction(t.Context(), nil, l, opts, action)) + require.NoError(t, commands.RunAction(t.Context(), nil, l, opts, venv.OSVenv(), action)) assert.True(t, hasRunCmd, "RunCmdCacheContextKey missing from action context") assert.True(t, hasRepoRoots, "RepoRootCacheContextKey missing from action context") } diff --git a/internal/cli/commands/scaffold/cli.go b/internal/cli/commands/scaffold/cli.go index e2154047ad..b455ec5566 100644 --- a/internal/cli/commands/scaffold/cli.go +++ b/internal/cli/commands/scaffold/cli.go @@ -10,6 +10,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/cli/flags/shared" "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/strict/controls" + "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" @@ -63,7 +64,7 @@ func NewFlags(opts *options.TerragruntOptions, prefix flags.Prefix) clihelper.Fl return scaffoldFlags } -func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Command { +func NewCommand(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) *clihelper.Command { flags := NewFlags(opts, nil) // Accept backend and feature flags for scaffold as well flags = append(flags, shared.NewBackendFlags(opts, nil)...) @@ -88,7 +89,7 @@ func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Comman opts.ScaffoldRootFileName = GetDefaultRootFileName(ctx, opts) } - return Run(ctx, l, opts.OptionsFromContext(ctx), moduleURL, templateURL) + return Run(ctx, l, v, opts.OptionsFromContext(ctx), moduleURL, templateURL) }, } } diff --git a/internal/cli/commands/scaffold/scaffold.go b/internal/cli/commands/scaffold/scaffold.go index a69708d90f..819b035fd9 100644 --- a/internal/cli/commands/scaffold/scaffold.go +++ b/internal/cli/commands/scaffold/scaffold.go @@ -16,6 +16,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/configbridge" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/telemetry" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -173,9 +174,12 @@ func (p *Plan) Cleanup() { // variable blocks, and returns a Plan ready for rendering. The caller is // responsible for invoking Plan.Cleanup() (typically via defer) once it has // either rendered the result via Generate or decided to abandon the work. +// v is the virtualized environment threaded through the module download and +// boilerplate preparation. func Prepare( ctx context.Context, l log.Logger, + v venv.Venv, opts *options.TerragruntOptions, moduleURL, templateURL string, ) (*Plan, error) { @@ -236,7 +240,7 @@ func Prepare( plan.originalModuleURL = moduleURL // parse module url (transforms for go-getter download) - resolvedURL, err := parseModuleURL(ctx, l, opts, vars, moduleURL) + resolvedURL, err := parseModuleURL(ctx, l, v, opts, vars, moduleURL) if err != nil { return nil, err } @@ -269,7 +273,7 @@ func Prepare( l.Debugf("Parsed %d required variables and %d optional variables", len(requiredVariables), len(optionalVariables)) // prepare boilerplate files to render Terragrunt files - boilerplateDir, err := prepareBoilerplateFiles(ctx, l, opts, templateURL, tempDir) + boilerplateDir, err := prepareBoilerplateFiles(ctx, l, v, opts, templateURL, tempDir) if err != nil { return nil, err } @@ -380,8 +384,14 @@ func applyUserValues(vars []*config.ParsedVariable, values map[string]string) { // terragrunt.hcl with `# TODO: fill in value` placeholders for every input. // It is the non-interactive entry point used by the CLI scaffold command and // the catalog TUI's `S` (placeholder) keybind. -func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, moduleURL, templateURL string) error { - plan, err := Prepare(ctx, l, opts, moduleURL, templateURL) +func Run( + ctx context.Context, + l log.Logger, + v venv.Venv, + opts *options.TerragruntOptions, + moduleURL, templateURL string, +) error { + plan, err := Prepare(ctx, l, v, opts, moduleURL, templateURL) if err != nil { return err } @@ -509,6 +519,7 @@ func generateDefaultTemplate(boilerplateDir string) (string, error) { func downloadTemplate( ctx context.Context, l log.Logger, + v venv.Venv, opts *options.TerragruntOptions, templateURL, tempDir string, @@ -529,7 +540,7 @@ func downloadTemplate( baseURL.Path = filepath.ToSlash(strings.TrimSuffix(baseURL.Path, "/")) + "//." } - baseURL, err = rewriteTemplateURL(ctx, l, opts, baseURL) + baseURL, err = rewriteTemplateURL(ctx, l, v, opts, baseURL) if err != nil { return "", err } @@ -575,6 +586,7 @@ func downloadTemplate( func prepareBoilerplateFiles( ctx context.Context, l log.Logger, + v venv.Venv, opts *options.TerragruntOptions, templateURL, tempDir string, @@ -584,7 +596,7 @@ func prepareBoilerplateFiles( // process template url if it was passed. This overrides the .boilerplate folder in the OpenTofu/Terraform module if templateURL != "" { // process template url if it was passed - tempTemplateDir, err := downloadTemplate(ctx, l, opts, templateURL, tempDir) + tempTemplateDir, err := downloadTemplate(ctx, l, v, opts, templateURL, tempDir) if err != nil { return "", err } @@ -604,7 +616,7 @@ func prepareBoilerplateFiles( // use defaultTemplateURL if defined in config, otherwise use basic default template if config != nil && config.DefaultTemplate != "" { // process template url if available - tempTemplateDir, err := downloadTemplate(ctx, l, opts, config.DefaultTemplate, tempDir) + tempTemplateDir, err := downloadTemplate(ctx, l, v, opts, config.DefaultTemplate, tempDir) if err != nil { return "", err } @@ -660,6 +672,7 @@ func parseVariables( func parseModuleURL( ctx context.Context, l log.Logger, + v venv.Venv, opts *options.TerragruntOptions, vars map[string]any, moduleURL string, @@ -678,7 +691,7 @@ func parseModuleURL( } // add ref to module url, if required - parsedModuleURL, err = addRefToModuleURL(ctx, l, opts, parsedModuleURL, vars) + parsedModuleURL, err = addRefToModuleURL(ctx, l, v, opts, parsedModuleURL, vars) if err != nil { return "", err } @@ -743,6 +756,7 @@ func rewriteModuleURL( func rewriteTemplateURL( ctx context.Context, l log.Logger, + v venv.Venv, opts *options.TerragruntOptions, parsedTemplateURL *url.URL, ) (*url.URL, error) { @@ -763,7 +777,7 @@ func rewriteTemplateURL( return updatedTemplateURL, nil } - tag, err := shell.GitLastReleaseTag(ctx, l, opts.Env, opts.WorkingDir, rootSourceURL) + tag, err := shell.GitLastReleaseTag(ctx, l, v.Exec, opts.Env, opts.WorkingDir, rootSourceURL) if err != nil || tag == "" { l.Warnf("Failed to find last release tag for URL %s, so will not add a ref param to the URL", rootSourceURL) } else { @@ -779,6 +793,7 @@ func rewriteTemplateURL( func addRefToModuleURL( ctx context.Context, l log.Logger, + v venv.Venv, opts *options.TerragruntOptions, parsedModuleURL *url.URL, vars map[string]any, @@ -803,7 +818,7 @@ func addRefToModuleURL( return nil, err } - tag, err := shell.GitLastReleaseTag(ctx, l, opts.Env, opts.WorkingDir, rootSourceURL) + tag, err := shell.GitLastReleaseTag(ctx, l, v.Exec, opts.Env, opts.WorkingDir, rootSourceURL) if err != nil || tag == "" { l.Warnf("Failed to find last release tag for %s", rootSourceURL) } else { diff --git a/internal/cli/commands/shortcuts.go b/internal/cli/commands/shortcuts.go index 42a0f5c1c6..c90664135d 100644 --- a/internal/cli/commands/shortcuts.go +++ b/internal/cli/commands/shortcuts.go @@ -5,6 +5,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/clihelper" "github.com/gruntwork-io/terragrunt/internal/tf" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" @@ -28,9 +29,9 @@ var ( } ) -func NewShortcutsCommands(l log.Logger, opts *options.TerragruntOptions) clihelper.Commands { +func NewShortcutsCommands(l log.Logger, opts *options.TerragruntOptions, v venv.Venv) clihelper.Commands { var ( - runCmd = run.NewCommand(l, opts) + runCmd = run.NewCommand(l, opts, v) cmds = make(clihelper.Commands, 0, len(runCmd.Subcommands)) ) diff --git a/internal/cli/commands/stack/cli.go b/internal/cli/commands/stack/cli.go index 6e08064dff..abf450d08d 100644 --- a/internal/cli/commands/stack/cli.go +++ b/internal/cli/commands/stack/cli.go @@ -7,6 +7,8 @@ import ( runcmd "github.com/gruntwork-io/terragrunt/internal/cli/commands/run" "github.com/gruntwork-io/terragrunt/internal/cli/flags" "github.com/gruntwork-io/terragrunt/internal/clihelper" + "github.com/gruntwork-io/terragrunt/internal/runner/run" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -29,7 +31,7 @@ const ( ) // NewCommand builds the command for stack. -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: "Terragrunt stack commands.", @@ -46,7 +48,7 @@ func NewCommand(l log.Logger, opts *options.TerragruntOptions) *clihelper.Comman Name: runCommandName, Usage: "Run a command on the stack generated from the current directory", Action: func(ctx context.Context, _ *clihelper.Context) error { - return Run(ctx, l, opts.OptionsFromContext(ctx)) + return Run(ctx, l, run.FromRoot(v), opts.OptionsFromContext(ctx)) }, Flags: defaultFlags(l, opts, nil), }, diff --git a/internal/cli/commands/stack/stack.go b/internal/cli/commands/stack/stack.go index 5efc0abaaf..3111f9c1d2 100644 --- a/internal/cli/commands/stack/stack.go +++ b/internal/cli/commands/stack/stack.go @@ -9,6 +9,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/zclconf/go-cty/cty" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/runall" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -86,8 +87,8 @@ func RunGenerate(ctx context.Context, l log.Logger, opts *options.TerragruntOpti }) } -// Run execute stack command. -func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) error { +// Run executes the stack command. +func Run(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) error { opts.StackAction = "run" err := telemetry.TelemeterFromContext(ctx).Collect(ctx, "stack_run", map[string]any{ @@ -100,7 +101,7 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) err return err } - return runall.Run(ctx, l, opts) + return runall.Run(ctx, l, v, opts) } // RunOutput stack output. diff --git a/internal/discovery/constructor.go b/internal/discovery/constructor.go index fcbd39bd26..7d9fd34ba5 100644 --- a/internal/discovery/constructor.go +++ b/internal/discovery/constructor.go @@ -7,6 +7,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/filter" + "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/mattn/go-shellwords" ) @@ -123,7 +124,10 @@ func NewForStackGenerate(l log.Logger, opts StackGenerateOptions) (*Discovery, e return d, nil } -// NewDiscovery creates a new Discovery with sensible defaults. +// NewDiscovery creates a new Discovery with sensible defaults. The +// process-execution handle defaults to the OS-backed one and is overridden +// via [Discovery.WithExec] when a caller has the threaded root virtualized +// environment. func NewDiscovery(dir string) *Discovery { // Clamp worker count between defaultDiscoveryWorkers and maxDiscoveryWorkers, bounded by available CPUs. numWorkers := max(min(runtime.GOMAXPROCS(0), maxDiscoveryWorkers), defaultDiscoveryWorkers) @@ -133,6 +137,7 @@ func NewDiscovery(dir string) *Discovery { maxDependencyDepth: defaultMaxDependencyDepth, workingDir: dir, configFilenames: DefaultConfigFilenames, + exec: vexec.NewOSExec(), discoveryContext: &component.DiscoveryContext{ WorkingDir: dir, }, diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index e3f4a15d76..b5e513fa90 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -99,7 +99,7 @@ func (d *Discovery) Discover( if d.classifier.HasGraphFilters() { if d.classifier.HasDependentFilters() && d.gitRoot == "" { - if gitRootPath, gitErr := shell.GitTopLevelDir(ctx, l, opts.Env, d.workingDir); gitErr == nil { + if gitRootPath, gitErr := shell.GitTopLevelDir(ctx, l, d.exec, opts.Env, d.workingDir); gitErr == nil { d.gitRoot = gitRootPath l.Debugf("Set gitRoot for dependent discovery: %s", d.gitRoot) } diff --git a/internal/discovery/options.go b/internal/discovery/options.go index ea8f88cc2b..fc08a04d70 100644 --- a/internal/discovery/options.go +++ b/internal/discovery/options.go @@ -5,6 +5,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/filter" + "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" ) @@ -22,6 +23,14 @@ func (d *Discovery) WithDiscoveryContext(ctx *component.DiscoveryContext) *Disco return d } +// WithExec sets the process-execution handle used by the git top-level +// probe and the auth-provider-command credentials fetch. Callers with a +// threaded root virtualized environment override the OS-backed default. +func (d *Discovery) WithExec(exec vexec.Exec) *Discovery { + d.exec = exec + return d +} + // WithWorktrees sets the worktrees for Git-based filters. func (d *Discovery) WithWorktrees(w *worktrees.Worktrees) *Discovery { d.worktrees = w diff --git a/internal/discovery/phase_parse.go b/internal/discovery/phase_parse.go index e20c74cfed..da81975e4a 100644 --- a/internal/discovery/phase_parse.go +++ b/internal/discovery/phase_parse.go @@ -352,7 +352,9 @@ func parseComponent( shellOpts := configbridge.ShellRunOptsFromOpts(parseOpts) if parseOpts.DiscoveryAuthProviderCmd { - if _, err := creds.ObtainCredsForParsing(ctx, l, parseOpts.AuthProviderCmd, parseOpts.Env, shellOpts); err != nil { + if _, err := creds.ObtainCredsForParsing( + ctx, l, discovery.exec, parseOpts.AuthProviderCmd, parseOpts.Env, shellOpts, + ); err != nil { return fmt.Errorf("obtaining auth provider credentials for %s: %w", parseOpts.TerragruntConfigPath, err) } } diff --git a/internal/discovery/types.go b/internal/discovery/types.go index c943b88c8f..d2031bb5b7 100644 --- a/internal/discovery/types.go +++ b/internal/discovery/types.go @@ -6,6 +6,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/filter" + "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -125,6 +126,12 @@ type Discovery struct { // worktrees is the worktrees created for Git-based filters. worktrees *worktrees.Worktrees + // exec is the process-execution handle used by the git top-level probe + // and the auth-provider-command credentials fetch. It defaults to the + // OS-backed handle and is overridden via [Discovery.WithExec] when a + // caller has the threaded root virtualized environment. + exec vexec.Exec + // workingDir is the directory to search for Terragrunt configurations. workingDir string diff --git a/internal/getter/casgetter_tfr_test.go b/internal/getter/casgetter_tfr_test.go index 00cb521396..60194b1aa0 100644 --- a/internal/getter/casgetter_tfr_test.go +++ b/internal/getter/casgetter_tfr_test.go @@ -12,6 +12,7 @@ import ( tgcas "github.com/gruntwork-io/terragrunt/internal/cas" "github.com/gruntwork-io/terragrunt/internal/getter" "github.com/gruntwork-io/terragrunt/internal/tfimpl" + "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" gogetter "github.com/hashicorp/go-getter/v2" @@ -35,7 +36,7 @@ func TestCASGetter_TFRRoutesThroughCAS(t *testing.T) { l := logger.CreateLogger() httpClient := server.Client() - tfr := getter.NewRegistryGetter(l). + tfr := getter.NewRegistryGetter(l, vfs.NewOSFS()). WithHTTPClient(httpClient). WithTofuImplementation(tfimpl.Terraform) diff --git a/internal/getter/client_test.go b/internal/getter/client_test.go index e6f4a4ee9b..707360196a 100644 --- a/internal/getter/client_test.go +++ b/internal/getter/client_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "testing" + "github.com/gruntwork-io/terragrunt/internal/vfs" + "github.com/gruntwork-io/terragrunt/internal/getter" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/stretchr/testify/assert" @@ -46,7 +48,7 @@ func TestGetAnyConvenience(t *testing.T) { dst := filepath.Join(helpers.TmpDirWOSymlinks(t), "copy") _, err := getter.GetAny(t.Context(), dst, "file://"+src, - getter.WithFileCopy(getter.NewFileCopyGetter()), + getter.WithFileCopy(getter.NewFileCopyGetter(vfs.NewOSFS())), ) require.NoError(t, err) @@ -64,7 +66,7 @@ func TestGetConvenience(t *testing.T) { dst := filepath.Join(helpers.TmpDirWOSymlinks(t), "copy") _, err := getter.Get(t.Context(), dst, "file://"+src, - getter.WithFileCopy(getter.NewFileCopyGetter()), + getter.WithFileCopy(getter.NewFileCopyGetter(vfs.NewOSFS())), ) require.NoError(t, err) diff --git a/internal/getter/defaults.go b/internal/getter/defaults.go index 2cadfe4395..b90f0d9004 100644 --- a/internal/getter/defaults.go +++ b/internal/getter/defaults.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/gruntwork-io/terragrunt/internal/tfimpl" + "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/gruntwork-io/terragrunt/pkg/log" gcs "github.com/hashicorp/go-getter/gcs/v2" s3 "github.com/hashicorp/go-getter/s3/v2" @@ -33,6 +34,7 @@ type genericFetcherConfig struct { httpsExtra http.Header tfrLogger log.Logger tfrImpl tfimpl.Type + tfrFS vfs.FS } // WithHTTPExtraHeaders attaches header to the bare http getter so @@ -53,10 +55,11 @@ func WithHTTPSExtraHeaders(header http.Header) GenericFetcherOption { // maps so [CASGetter] cannot route tfr:// through CAS. The standard // (non-CAS) client registers its own [RegistryGetter] via // [WithTFRegistry] and is unaffected. -func WithTFRConfig(l log.Logger, impl tfimpl.Type) GenericFetcherOption { +func WithTFRConfig(l log.Logger, impl tfimpl.Type, fs vfs.FS) GenericFetcherOption { return func(c *genericFetcherConfig) { c.tfrLogger = l c.tfrImpl = impl + c.tfrFS = fs } } @@ -80,7 +83,7 @@ func DefaultGenericFetchers(opts ...GenericFetcherOption) map[string]getter.Gett } if cfg.tfrLogger != nil { - m[SchemeTFR] = NewRegistryGetter(cfg.tfrLogger).WithTofuImplementation(cfg.tfrImpl) + m[SchemeTFR] = NewRegistryGetter(cfg.tfrLogger, cfg.tfrFS).WithTofuImplementation(cfg.tfrImpl) } return m @@ -144,7 +147,7 @@ func buildGetters(b *builder) []Getter { if b.tfRegistry != nil { fetchers[SchemeTFR] = b.tfRegistry - resolverOpts = append(resolverOpts, WithTFRConfig(b.logger, b.tfRegistry.TofuImplementation)) + resolverOpts = append(resolverOpts, WithTFRConfig(b.logger, b.tfRegistry.TofuImplementation, b.tfRegistry.FS)) } out = append(out, diff --git a/internal/getter/defaults_test.go b/internal/getter/defaults_test.go index dffb27b0db..d14f14e2a9 100644 --- a/internal/getter/defaults_test.go +++ b/internal/getter/defaults_test.go @@ -6,6 +6,8 @@ import ( "slices" "testing" + "github.com/gruntwork-io/terragrunt/internal/vfs" + "github.com/gruntwork-io/terragrunt/internal/getter" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -42,7 +44,7 @@ func TestDefaultClientCoversCanonicalProtocols(t *testing.T) { func TestWithFileCopyReplacesFileGetter(t *testing.T) { t.Parallel() - client := getter.NewClient(getter.WithFileCopy(getter.NewFileCopyGetter())) + client := getter.NewClient(getter.WithFileCopy(getter.NewFileCopyGetter(vfs.NewOSFS()))) assert.True(t, hasGetter[*getter.FileCopyGetter](client.Getters), "FileCopyGetter must be registered") assert.False(t, hasGetter[*getter.FileGetter](client.Getters), "stock FileGetter must be replaced") diff --git a/internal/getter/filecopy.go b/internal/getter/filecopy.go index 42c0b8ca20..7599d19824 100644 --- a/internal/getter/filecopy.go +++ b/internal/getter/filecopy.go @@ -92,10 +92,10 @@ func (g *FileCopyGetter) Detect(req *getter.Request) (bool, error) { return (&getter.FileGetter{}).Detect(req) } -// NewFileCopyGetter returns a FileCopyGetter with FS populated to the OS +// NewFileCopyGetter returns a FileCopyGetter backed by the supplied // filesystem. Use the With* methods to customize other behavior. -func NewFileCopyGetter() *FileCopyGetter { - return &FileCopyGetter{FS: vfs.NewOSFS()} +func NewFileCopyGetter(fs vfs.FS) *FileCopyGetter { + return &FileCopyGetter{FS: fs} } // WithLogger sets the logger used by [util.CopyFolderContents] during a copy. diff --git a/internal/getter/options_test.go b/internal/getter/options_test.go index c4703e703c..24e724ce77 100644 --- a/internal/getter/options_test.go +++ b/internal/getter/options_test.go @@ -191,7 +191,7 @@ func TestFileCopyGetIncludeExcludeFiltersHonor(t *testing.T) { require.NoError(t, writeFile(filepath.Join(src, "secret.txt"), "shh\n")) dst := filepath.Join(helpers.TmpDirWOSymlinks(t), "out") - fcg := getter.NewFileCopyGetter(). + fcg := getter.NewFileCopyGetter(vfs.NewOSFS()). WithLogger(logger.CreateLogger()). WithExcludeFromCopy("*.txt") @@ -215,7 +215,7 @@ func TestFileCopyGetMissingPath(t *testing.T) { missing := filepath.Join(helpers.TmpDirWOSymlinks(t), "does-not-exist") - client := getter.NewClient(getter.WithFileCopy(getter.NewFileCopyGetter())) + client := getter.NewClient(getter.WithFileCopy(getter.NewFileCopyGetter(vfs.NewOSFS()))) _, err := client.Get(t.Context(), &getter.Request{ Src: "file://" + missing, Dst: filepath.Join(helpers.TmpDirWOSymlinks(t), "out"), @@ -233,7 +233,7 @@ func TestFileCopyGetSourceIsFile(t *testing.T) { srcFile := filepath.Join(helpers.TmpDirWOSymlinks(t), "main.tf") require.NoError(t, writeFile(srcFile, "# main\n")) - client := getter.NewClient(getter.WithFileCopy(getter.NewFileCopyGetter())) + client := getter.NewClient(getter.WithFileCopy(getter.NewFileCopyGetter(vfs.NewOSFS()))) _, err := client.Get(t.Context(), &getter.Request{ Src: "file://" + srcFile, Dst: filepath.Join(helpers.TmpDirWOSymlinks(t), "out"), @@ -254,7 +254,7 @@ func TestFileCopyGetFileDelegates(t *testing.T) { dst := filepath.Join(helpers.TmpDirWOSymlinks(t), "out.tf") - client := getter.NewClient(getter.WithFileCopy(getter.NewFileCopyGetter())) + client := getter.NewClient(getter.WithFileCopy(getter.NewFileCopyGetter(vfs.NewOSFS()))) _, err := client.Get(t.Context(), &getter.Request{ Src: "file://" + srcFile, Dst: dst, @@ -299,7 +299,7 @@ func TestFileCopyGetterWithFSPanicsOnNonOSFS(t *testing.T) { assert.PanicsWithValue(t, "getter.FileCopyGetter.WithFS: requires an OS-backed filesystem", - func() { getter.NewFileCopyGetter().WithFS(vfs.NewMemMapFS()) }, + func() { getter.NewFileCopyGetter(vfs.NewOSFS()).WithFS(vfs.NewMemMapFS()) }, ) } @@ -310,6 +310,6 @@ func TestRegistryGetterWithFSPanicsOnNonOSFS(t *testing.T) { assert.PanicsWithValue(t, "getter.RegistryGetter.WithFS: requires an OS-backed filesystem", - func() { getter.NewRegistryGetter(logger.CreateLogger()).WithFS(vfs.NewMemMapFS()) }, + func() { getter.NewRegistryGetter(logger.CreateLogger(), vfs.NewOSFS()).WithFS(vfs.NewMemMapFS()) }, ) } diff --git a/internal/getter/tfr.go b/internal/getter/tfr.go index 061bfac0e0..5fae99a566 100644 --- a/internal/getter/tfr.go +++ b/internal/getter/tfr.go @@ -50,16 +50,17 @@ type RegistryGetter struct { // NewRegistryGetter returns a [RegistryGetter] configured with sensible // defaults: a [github.com/hashicorp/go-cleanhttp.DefaultClient] for -// registry-protocol requests, the supplied logger for diagnostic output, and -// [tfimpl.OpenTofu] as the default implementation. A logger is required -// because this package does not consistently guard against a nil logger, so -// requiring one at construction time prevents nil-pointer panics at call time. -// Use the With* methods to customize other behavior. -func NewRegistryGetter(l log.Logger) *RegistryGetter { +// registry-protocol requests, the supplied logger for diagnostic output, +// the supplied filesystem for archive expansion, and [tfimpl.OpenTofu] +// as the default implementation. A logger is required because this package +// does not consistently guard against a nil logger, so requiring one at +// construction time prevents nil-pointer panics at call time. Use the +// With* methods to customize other behavior. +func NewRegistryGetter(l log.Logger, fs vfs.FS) *RegistryGetter { return &RegistryGetter{ HTTPClient: cleanhttp.DefaultClient(), Logger: l, - FS: vfs.NewOSFS(), + FS: fs, TofuImplementation: tfimpl.OpenTofu, } } diff --git a/internal/getter/tfr_test.go b/internal/getter/tfr_test.go index 17168a149f..d042dab84d 100644 --- a/internal/getter/tfr_test.go +++ b/internal/getter/tfr_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "testing" + "github.com/gruntwork-io/terragrunt/internal/vfs" + "github.com/gruntwork-io/terragrunt/internal/getter" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/util" @@ -184,7 +186,7 @@ func newRegistryTestClient(t *testing.T, httpClient *http.Client, impl tfimpl.Ty l := logger.CreateLogger() - tfr := getter.NewRegistryGetter(l). + tfr := getter.NewRegistryGetter(l, vfs.NewOSFS()). WithHTTPClient(httpClient). WithTofuImplementation(impl) diff --git a/internal/getter/types_test.go b/internal/getter/types_test.go index 5390c657d6..afa1d89a2a 100644 --- a/internal/getter/types_test.go +++ b/internal/getter/types_test.go @@ -18,7 +18,7 @@ import ( func TestRegistryGetterMode(t *testing.T) { t.Parallel() - r := getter.NewRegistryGetter(logger.CreateLogger()) + r := getter.NewRegistryGetter(logger.CreateLogger(), vfs.NewOSFS()) mode, err := r.Mode(t.Context(), &url.URL{Scheme: "tfr"}) require.NoError(t, err) @@ -30,7 +30,7 @@ func TestRegistryGetterMode(t *testing.T) { func TestRegistryGetterGetFile(t *testing.T) { t.Parallel() - r := getter.NewRegistryGetter(logger.CreateLogger()) + r := getter.NewRegistryGetter(logger.CreateLogger(), vfs.NewOSFS()) err := r.GetFile(t.Context(), &getter.Request{}) require.Error(t, err) @@ -41,7 +41,7 @@ func TestRegistryGetterGetFile(t *testing.T) { func TestRegistryGetterDetect(t *testing.T) { t.Parallel() - r := getter.NewRegistryGetter(logger.CreateLogger()) + r := getter.NewRegistryGetter(logger.CreateLogger(), vfs.NewOSFS()) tests := []struct { req *getter.Request diff --git a/internal/prepare/prepare.go b/internal/prepare/prepare.go index da8fd92eae..e8736450ec 100644 --- a/internal/prepare/prepare.go +++ b/internal/prepare/prepare.go @@ -37,17 +37,18 @@ type Config struct { // PrepareConfig reads and parses the terragrunt configuration, fetches credentials, // and performs version constraint checks. This is the first stage of preparation. -func PrepareConfig(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (*Config, error) { +func PrepareConfig(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) (*Config, error) { // We need to get the credentials from auth-provider-cmd at the very beginning, // since the locals block may contain `get_aws_account_id()` func. credsGetter := creds.NewGetter() provider := externalcmd.NewProvider(l, opts.AuthProviderCmd, configbridge.ShellRunOptsFromOpts(opts)) - if err := credsGetter.ObtainAndUpdateEnvIfNecessary(ctx, l, opts.Env, provider); err != nil { + if err := credsGetter.ObtainAndUpdateEnvIfNecessary(ctx, l, v.Exec, opts.Env, provider); err != nil { return nil, err } ctx, pctx := configbridge.NewParsingContext(ctx, l, opts) + pctx.Venv = v.ToRoot() terragruntConfig, err := config.ReadTerragruntConfig(ctx, l, pctx, pctx.ParserOptions) if err != nil { @@ -65,6 +66,7 @@ func PrepareConfig(ctx context.Context, l log.Logger, opts *options.TerragruntOp func PrepareSource( ctx context.Context, l log.Logger, + v run.Venv, opts *options.TerragruntOptions, cfg *config.TerragruntConfig, r *report.Report, @@ -97,7 +99,7 @@ func PrepareSource( optsClone.TerraformCommand = run.CommandNameTerragruntReadConfig if err = optsClone.RunWithErrorHandling(ctx, l, r, func() error { - return run.ProcessHooks(ctx, l, run.OSVenv(), run.ProcessHooksParams{ + return run.ProcessHooks(ctx, l, v, run.ProcessHooksParams{ Hooks: runCfg.Terraform.AfterHooks, Opts: configbridge.NewRunOptions(optsClone), Cfg: runCfg, @@ -118,7 +120,7 @@ func PrepareSource( if err = opts.RunWithErrorHandling(ctx, l, r, func() error { return credsGetter.ObtainAndUpdateEnvIfNecessary( - ctx, l, opts.Env, amazonsts.NewProvider(l, opts.IAMRoleOptions, opts.Env), + ctx, l, v.Exec, opts.Env, amazonsts.NewProvider(l, opts.IAMRoleOptions, opts.Env), ) }); err != nil { return nil, err @@ -146,7 +148,7 @@ func PrepareSource( err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "download_terraform_source", map[string]any{ "sourceUrl": sourceURL, }, func(ctx context.Context) error { - updatedRunOpts, err = run.DownloadTerraformSource(ctx, l, sourceURL, runOpts, runCfg, r) + updatedRunOpts, err = run.DownloadTerraformSource(ctx, l, v, sourceURL, runOpts, runCfg, r) return err }) if err != nil { @@ -167,8 +169,8 @@ func PrepareSource( // PrepareGenerate handles code generation configs, both generate blocks and generate attribute of remote_state. // It requires PrepareSource to have been called first. -func PrepareGenerate(l log.Logger, opts *options.TerragruntOptions, cfg *runcfg.RunConfig) error { - return run.GenerateConfig(l, configbridge.NewRunOptions(opts), cfg) +func PrepareGenerate(l log.Logger, v run.Venv, opts *options.TerragruntOptions, cfg *runcfg.RunConfig) error { + return run.GenerateConfig(l, v.FS, configbridge.NewRunOptions(opts), cfg) } // PrepareInputsAsEnvVars sets terragrunt inputs as environment variables. @@ -189,6 +191,7 @@ func PrepareInputsAsEnvVars(l log.Logger, opts *options.TerragruntOptions, cfg * func PrepareInit( ctx context.Context, l log.Logger, + v run.Venv, originalOpts, opts *options.TerragruntOptions, cfg *runcfg.RunConfig, r *report.Report, @@ -204,6 +207,6 @@ func PrepareInit( return err } - // Run terraform init via the non-init command preparation path - return run.PrepareNonInitCommand(ctx, l, configbridge.NewRunOptions(originalOpts), runOpts, cfg, r) + // Run terraform init via the non-init command preparation path. + return run.PrepareNonInitCommand(ctx, l, v, configbridge.NewRunOptions(originalOpts), runOpts, cfg, r) } diff --git a/internal/remotestate/remote_state.go b/internal/remotestate/remote_state.go index b49283e793..d9df166b0c 100644 --- a/internal/remotestate/remote_state.go +++ b/internal/remotestate/remote_state.go @@ -93,14 +93,14 @@ func (remote *RemoteState) Bootstrap(ctx context.Context, l log.Logger, opts *Op } // Migrate determines where the remote state resources exist for source backend config and migrate them to dest backend config. -func (remote *RemoteState) Migrate(ctx context.Context, l log.Logger, opts, dstOpts *Options, dstRemote *RemoteState) error { +func (remote *RemoteState) Migrate(ctx context.Context, l log.Logger, exec vexec.Exec, opts, dstOpts *Options, dstRemote *RemoteState) error { l.Debugf("Migrate remote state for the %s backend", remote.BackendName) if remote.BackendName == dstRemote.BackendName { return remote.backend.Migrate(ctx, l, remote.BackendConfig, dstRemote.BackendConfig, &opts.Options) } - stateFile, err := remote.pullState(ctx, l, opts.TFRunOpts) + stateFile, err := remote.pullState(ctx, l, exec, opts.TFRunOpts) if err != nil { return err } @@ -111,7 +111,7 @@ func (remote *RemoteState) Migrate(ctx context.Context, l log.Logger, opts, dstO } }() - return dstRemote.pushState(ctx, l, dstOpts.TFRunOpts, stateFile) + return dstRemote.pushState(ctx, l, exec, dstOpts.TFRunOpts, stateFile) } // NeedsBootstrap returns true if remote state needs to be configured. This will be the case when: @@ -175,12 +175,12 @@ func (remote *RemoteState) GenerateOpenTofuCode(l log.Logger, workingDir string) return remote.Config.GenerateOpenTofuCode(l, workingDir, backendConfig) } -func (remote *RemoteState) pullState(ctx context.Context, l log.Logger, tfOpts *tf.TFOptions) (string, error) { +func (remote *RemoteState) pullState(ctx context.Context, l log.Logger, exec vexec.Exec, tfOpts *tf.TFOptions) (string, error) { l.Debugf("Pulling state from %s backend", remote.BackendName) args := []string{tf.CommandNameState, tf.CommandNamePull} - output, err := tf.RunCommandWithOutput(ctx, l, vexec.NewOSExec(), tfOpts, args...) + output, err := tf.RunCommandWithOutput(ctx, l, exec, tfOpts, args...) if err != nil { return "", err } @@ -203,10 +203,10 @@ func (remote *RemoteState) pullState(ctx context.Context, l log.Logger, tfOpts * return file.Name(), nil } -func (remote *RemoteState) pushState(ctx context.Context, l log.Logger, tfOpts *tf.TFOptions, stateFile string) error { +func (remote *RemoteState) pushState(ctx context.Context, l log.Logger, exec vexec.Exec, tfOpts *tf.TFOptions, stateFile string) error { l.Debugf("Pushing state to %s backend", remote.BackendName) args := []string{tf.CommandNameState, tf.CommandNamePush, stateFile} - return tf.RunCommand(ctx, l, vexec.NewOSExec(), tfOpts, args...) + return tf.RunCommand(ctx, l, exec, tfOpts, args...) } diff --git a/internal/runner/common/runner.go b/internal/runner/common/runner.go index 622d4e1192..2e04c73369 100644 --- a/internal/runner/common/runner.go +++ b/internal/runner/common/runner.go @@ -7,6 +7,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/report" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) @@ -15,7 +16,7 @@ import ( // Implemented by runnerpool.Runner and any alternate runner implementations. type StackRunner interface { // Run executes all units in the stack according to the specified Terraform command and options. - Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, r *report.Report) error + Run(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions, r *report.Report) error // LogUnitDeployOrder logs the order in which units will be deployed. LogUnitDeployOrder(l log.Logger, isDestroy bool, showAbsPaths bool, experiments experiment.Experiments) error // JSONUnitDeployOrder returns the deployment order of units as a JSON string. diff --git a/internal/runner/common/unit_runner.go b/internal/runner/common/unit_runner.go index b4abcb7557..d9ea026c1d 100644 --- a/internal/runner/common/unit_runner.go +++ b/internal/runner/common/unit_runner.go @@ -46,6 +46,7 @@ func NewUnitRunner(unit *component.Unit) *UnitRunner { func (runner *UnitRunner) runTerragrunt( ctx context.Context, l log.Logger, + v run.Venv, opts *options.TerragruntOptions, r *report.Report, cfg *runcfg.RunConfig, @@ -90,7 +91,7 @@ func (runner *UnitRunner) runTerragrunt( ctx = tf.ContextWithDetailedExitCode(ctx, unitExitCode) - runErr := run.Run(ctx, l, configbridge.NewRunOptions(opts), r, cfg, credsGetter) + runErr := run.Run(ctx, l, v, configbridge.NewRunOptions(opts), r, cfg, credsGetter) // Store the unit exit code in the global map using the unit path as key. if globalExitCode != nil { @@ -132,6 +133,7 @@ func (runner *UnitRunner) runTerragrunt( func (runner *UnitRunner) Run( ctx context.Context, l log.Logger, + v run.Venv, opts *options.TerragruntOptions, r *report.Report, cfg *runcfg.RunConfig, @@ -143,7 +145,7 @@ func (runner *UnitRunner) Run( return nil } - if err := runner.runTerragrunt(ctx, l, opts, r, cfg, credsGetter); err != nil { + if err := runner.runTerragrunt(ctx, l, v, opts, r, cfg, credsGetter); err != nil { return err } @@ -171,7 +173,7 @@ func (runner *UnitRunner) Run( adhocReport := report.NewReport() runOpts := configbridge.NewRunOptions(jsonOptions) - if err := run.Run(ctx, jsonLogger, runOpts, adhocReport, cfg, credsGetter); err != nil { + if err := run.Run(ctx, jsonLogger, v, runOpts, adhocReport, cfg, credsGetter); err != nil { return err } diff --git a/internal/runner/graph/graph.go b/internal/runner/graph/graph.go index 32db69949f..84139b4917 100644 --- a/internal/runner/graph/graph.go +++ b/internal/runner/graph/graph.go @@ -11,6 +11,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/filter" "github.com/gruntwork-io/terragrunt/internal/runner" "github.com/gruntwork-io/terragrunt/internal/runner/common" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds" "github.com/gruntwork-io/terragrunt/internal/runner/runall" @@ -23,7 +24,9 @@ import ( "github.com/gruntwork-io/terragrunt/pkg/options" ) -func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (err error) { +// Run executes the configured terraform command against the dependency +// graph of the unit in the working directory. +func Run(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) error { // Get credentials BEFORE config parsing — sops_decrypt_file() and // get_aws_account_id() in locals need auth-provider credentials // available in opts.Env during HCL evaluation. @@ -31,11 +34,12 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (er // Per-unit creds are re-fetched in runnerpool task (intentional: each unit may have // different opts after clone). shellOpts := configbridge.ShellRunOptsFromOpts(opts) - if _, err := creds.ObtainCredsForParsing(ctx, l, opts.AuthProviderCmd, opts.Env, shellOpts); err != nil { + if _, err := creds.ObtainCredsForParsing(ctx, l, v.Exec, opts.AuthProviderCmd, opts.Env, shellOpts); err != nil { return err } ctx, pctx := configbridge.NewParsingContext(ctx, l, opts) + pctx.Venv = v.ToRoot() cfg, err := config.ReadTerragruntConfig(ctx, l, pctx, pctx.ParserOptions) if err != nil { @@ -55,7 +59,7 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (er // if destroy-graph-root is empty, use git to find top level dir. // may cause issues if in the same repo exist unrelated modules which will generate errors when scanning. if rootDir == "" { - gitRoot, gitRootErr := shell.GitTopLevelDir(ctx, l, opts.Env, opts.WorkingDir) + gitRoot, gitRootErr := shell.GitTopLevelDir(ctx, l, v.Exec, opts.Env, opts.WorkingDir) if gitRootErr != nil { return gitRootErr } @@ -122,10 +126,10 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (er }() } - rnr, err := runner.NewStackRunner(ctx, l, graphOpts, runnerOpts...) + rnr, err := runner.NewStackRunner(ctx, l, v, graphOpts, runnerOpts...) if err != nil { return err } - return runall.RunAllOnStack(ctx, l, graphOpts, rnr, r) + return runall.RunAllOnStack(ctx, l, v, graphOpts, rnr, r) } diff --git a/internal/runner/run/creds/getter.go b/internal/runner/run/creds/getter.go index 249464ae7c..45cbac5b2a 100644 --- a/internal/runner/run/creds/getter.go +++ b/internal/runner/run/creds/getter.go @@ -8,6 +8,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/externalcmd" "github.com/gruntwork-io/terragrunt/internal/shell" + "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/log" ) @@ -25,11 +26,12 @@ func NewGetter() *Getter { func (getter *Getter) ObtainAndUpdateEnvIfNecessary( ctx context.Context, l log.Logger, + exec vexec.Exec, env map[string]string, authProviders ...providers.Provider, ) error { for _, provider := range authProviders { - creds, err := provider.GetCredentials(ctx, l) + creds, err := provider.GetCredentials(ctx, l, exec) if err != nil { return err } @@ -60,6 +62,7 @@ func (getter *Getter) ObtainAndUpdateEnvIfNecessary( func ObtainCredsForParsing( ctx context.Context, l log.Logger, + exec vexec.Exec, authProviderCmd string, env map[string]string, shellOpts *shell.ShellOptions, @@ -67,7 +70,7 @@ func ObtainCredsForParsing( g := NewGetter() provider := externalcmd.NewProvider(l, authProviderCmd, shellOpts) - if err := g.ObtainAndUpdateEnvIfNecessary(ctx, l, env, provider); err != nil { + if err := g.ObtainAndUpdateEnvIfNecessary(ctx, l, exec, env, provider); err != nil { return nil, err } diff --git a/internal/runner/run/creds/providers/amazonsts/provider.go b/internal/runner/run/creds/providers/amazonsts/provider.go index 00a01556fc..8c688e632b 100644 --- a/internal/runner/run/creds/providers/amazonsts/provider.go +++ b/internal/runner/run/creds/providers/amazonsts/provider.go @@ -12,6 +12,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/iam" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers" "github.com/gruntwork-io/terragrunt/internal/telemetry" + "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/log" ) @@ -34,8 +35,14 @@ func (provider *Provider) Name() string { return "API calls to Amazon STS" } -// GetCredentials implements providers.GetCredentials -func (provider *Provider) GetCredentials(ctx context.Context, l log.Logger) (*providers.Credentials, error) { +// GetCredentials implements providers.GetCredentials. exec is unused for +// amazonsts because it talks to AWS via the SDK, not a subprocess; it is +// accepted to satisfy the providers.Provider interface contract. +func (provider *Provider) GetCredentials( + ctx context.Context, + l log.Logger, + _ vexec.Exec, +) (*providers.Credentials, error) { iamRoleOpts := provider.iamRoleOpts if iamRoleOpts.RoleARN == "" { return nil, nil diff --git a/internal/runner/run/creds/providers/externalcmd/provider.go b/internal/runner/run/creds/providers/externalcmd/provider.go index 35c801202c..544a4b8892 100644 --- a/internal/runner/run/creds/providers/externalcmd/provider.go +++ b/internal/runner/run/creds/providers/externalcmd/provider.go @@ -41,7 +41,11 @@ func (provider *Provider) Name() string { // GetCredentials implements providers.GetCredentials. When no auth provider command is // configured the call is a no-op short-circuit; we skip emitting the obtain_creds span // in that case so the trace isn't polluted with zero-duration spans. -func (provider *Provider) GetCredentials(ctx context.Context, l log.Logger) (*providers.Credentials, error) { +func (provider *Provider) GetCredentials( + ctx context.Context, + l log.Logger, + exec vexec.Exec, +) (*providers.Credentials, error) { if provider.authProviderCmd == "" { return nil, nil } @@ -54,7 +58,7 @@ func (provider *Provider) GetCredentials(ctx context.Context, l log.Logger) (*pr }, func(credsCtx context.Context) error { var fetchErr error - creds, fetchErr = provider.fetchCredentials(credsCtx, l) + creds, fetchErr = provider.fetchCredentials(credsCtx, l, exec) return fetchErr }) @@ -65,7 +69,11 @@ func (provider *Provider) GetCredentials(ctx context.Context, l log.Logger) (*pr // fetchCredentials runs the configured auth-provider command and decodes its JSON // response into providers.Credentials. Callers go through GetCredentials, which adds // the obtain_creds telemetry span around this work. -func (provider *Provider) fetchCredentials(ctx context.Context, l log.Logger) (*providers.Credentials, error) { +func (provider *Provider) fetchCredentials( + ctx context.Context, + l log.Logger, + exec vexec.Exec, +) (*providers.Credentials, error) { parser := shellwords.NewParser() // Normalize Windows paths before parsing - shellwords treats backslashes as escape characters @@ -82,7 +90,7 @@ func (provider *Provider) fetchCredentials(ctx context.Context, l log.Logger) (* } output, err := shell.RunCommandWithOutput( - ctx, l, vexec.NewOSExec(), provider.runOpts, + ctx, l, exec, provider.runOpts, "", true, false, command, args..., ) if err != nil { @@ -116,7 +124,7 @@ func (provider *Provider) fetchCredentials(ctx context.Context, l log.Logger) (* } if resp.AWSRole != nil { - if envs := resp.AWSRole.Envs(ctx, l, provider.authProviderCmd); envs != nil { + if envs := resp.AWSRole.Envs(ctx, l, exec, provider.authProviderCmd); envs != nil { l.Debugf("Assuming AWS role %s using the %s.", resp.AWSRole.RoleARN, provider.Name()) maps.Copy(creds.Envs, envs) } @@ -159,7 +167,12 @@ type AWSRole struct { Duration int64 `json:"duration,omitempty" jsonschema:"minimum=0"` } -func (role *AWSRole) Envs(ctx context.Context, l log.Logger, authProviderCmd string) map[string]string { +func (role *AWSRole) Envs( + ctx context.Context, + l log.Logger, + exec vexec.Exec, + authProviderCmd string, +) map[string]string { if role.RoleARN == "" { l.Warnf("The command %s completed successfully, but AWS role assumption"+ " contains empty required value: roleARN, nothing is being done.", authProviderCmd) @@ -189,7 +202,7 @@ func (role *AWSRole) Envs(ctx context.Context, l log.Logger, authProviderCmd str provider := amazonsts.NewProvider(l, iamRoleOpts, nil) - creds, err := provider.GetCredentials(ctx, l) + creds, err := provider.GetCredentials(ctx, l, exec) if err != nil { l.Warnf("Failed to assume role %s: %v", role.RoleARN, err) return nil diff --git a/internal/runner/run/creds/providers/externalcmd/provider_mem_test.go b/internal/runner/run/creds/providers/externalcmd/provider_mem_test.go new file mode 100644 index 0000000000..4e33e2173e --- /dev/null +++ b/internal/runner/run/creds/providers/externalcmd/provider_mem_test.go @@ -0,0 +1,154 @@ +package externalcmd_test + +import ( + "context" + "io" + "testing" + + "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers" + "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/externalcmd" + "github.com/gruntwork-io/terragrunt/internal/shell" + "github.com/gruntwork-io/terragrunt/internal/vexec" + "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" +) + +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 +// vexec.NewOSExec() unconditionally; the in-memory backend lets the test +// assert zero invocations. +func TestProviderEmptyAuthProviderCmdIsNoop(t *testing.T) { + t.Parallel() + + var calls int + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + calls++ + return vexec.Result{} + }) + + p := externalcmd.NewProvider(logger.CreateLogger(), "", newRunOpts()) + + creds, err := p.GetCredentials(t.Context(), logger.CreateLogger(), exec) + require.NoError(t, err) + assert.Nil(t, creds) + assert.Zero(t, calls, "expected no subprocess invocations for an empty auth-provider command") +} + +// TestProviderDirectAWSCredentials covers the awsCredentials branch of the +// auth-provider response schema: returned env vars are surfaced verbatim +// and mapped to all four AWS_* destinations. +func TestProviderDirectAWSCredentials(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + assert.Equal(t, "/usr/local/bin/auth", inv.Name) + assert.Equal(t, []string{"--account", "prod"}, inv.Args) + + return vexec.Result{Stdout: []byte(`{ + "awsCredentials": { + "ACCESS_KEY_ID": "AKIA111", + "SECRET_ACCESS_KEY": "secret-xyz", + "SESSION_TOKEN": "session-abc" + } + }`)} + }) + + p := externalcmd.NewProvider(logger.CreateLogger(), "/usr/local/bin/auth --account prod", newRunOpts()) + + creds, err := p.GetCredentials(t.Context(), logger.CreateLogger(), exec) + require.NoError(t, err) + require.NotNil(t, creds) + assert.Equal(t, providers.AWSCredentials, creds.Name) + assert.Equal(t, "AKIA111", creds.Envs["AWS_ACCESS_KEY_ID"]) + assert.Equal(t, "secret-xyz", creds.Envs["AWS_SECRET_ACCESS_KEY"]) + assert.Equal(t, "session-abc", creds.Envs["AWS_SESSION_TOKEN"]) + assert.Equal(t, "session-abc", creds.Envs["AWS_SECURITY_TOKEN"], "AWS_SECURITY_TOKEN must mirror AWS_SESSION_TOKEN") +} + +// TestProviderArbitraryEnvs covers the envs-only branch: arbitrary +// environment variables on the response are surfaced without any AWS +// specific mapping. +func TestProviderArbitraryEnvs(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{Stdout: []byte(`{"envs": {"FOO": "bar", "BAZ": "qux"}}`)} + }) + + p := externalcmd.NewProvider(logger.CreateLogger(), "auth-cmd", newRunOpts()) + + creds, err := p.GetCredentials(t.Context(), logger.CreateLogger(), exec) + require.NoError(t, err) + require.NotNil(t, creds) + assert.Equal(t, "bar", creds.Envs["FOO"]) + assert.Equal(t, "qux", creds.Envs["BAZ"]) +} + +func TestProviderEmptyResponseErrors(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{Stdout: []byte("")} + }) + + p := externalcmd.NewProvider(logger.CreateLogger(), "auth-cmd", newRunOpts()) + + _, err := p.GetCredentials(t.Context(), logger.CreateLogger(), exec) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not contain JSON") +} + +func TestProviderInvalidJSONErrors(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{Stdout: []byte("not json at all")} + }) + + p := externalcmd.NewProvider(logger.CreateLogger(), "auth-cmd", newRunOpts()) + + _, err := p.GetCredentials(t.Context(), logger.CreateLogger(), exec) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid JSON") +} + +func TestProviderCommandFailurePropagates(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{ExitCode: 2, Stderr: []byte("permission denied\n")} + }) + + p := externalcmd.NewProvider(logger.CreateLogger(), "auth-cmd", newRunOpts()) + + _, err := p.GetCredentials(t.Context(), logger.CreateLogger(), exec) + require.Error(t, err) +} + +// TestProviderCommandShellwordsParsing pins that quoted arguments survive +// the shellwords parse the provider applies before dispatch. +func TestProviderCommandShellwordsParsing(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + assert.Equal(t, "auth", inv.Name) + assert.Equal(t, []string{"--profile", "with space", "--region", "us-east-1"}, inv.Args) + + return vexec.Result{Stdout: []byte(`{"envs": {}}`)} + }) + + p := externalcmd.NewProvider(logger.CreateLogger(), `auth --profile "with space" --region us-east-1`, newRunOpts()) + + _, err := p.GetCredentials(t.Context(), logger.CreateLogger(), exec) + require.NoError(t, err) +} diff --git a/internal/runner/run/creds/providers/externalcmd/provider_test.go b/internal/runner/run/creds/providers/externalcmd/provider_test.go index 6534e91068..bfa8faf100 100644 --- a/internal/runner/run/creds/providers/externalcmd/provider_test.go +++ b/internal/runner/run/creds/providers/externalcmd/provider_test.go @@ -6,6 +6,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/runner/run/creds/providers/externalcmd" "github.com/gruntwork-io/terragrunt/internal/shell" + "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/require" @@ -24,7 +25,7 @@ func TestGetCredentialsHandlesJSONNullResponse(t *testing.T) { provider := externalcmd.NewProvider(l, "printf null", opts) - creds, err := provider.GetCredentials(t.Context(), l) + creds, err := provider.GetCredentials(t.Context(), l, vexec.NewOSExec()) require.NoError(t, err) require.NotNil(t, creds) diff --git a/internal/runner/run/creds/providers/provider.go b/internal/runner/run/creds/providers/provider.go index 0a661e715b..00c851cd4c 100644 --- a/internal/runner/run/creds/providers/provider.go +++ b/internal/runner/run/creds/providers/provider.go @@ -4,6 +4,7 @@ package providers import ( "context" + "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/log" ) @@ -22,5 +23,5 @@ type Provider interface { // Name returns the name of the provider. Name() string // GetCredentials returns a set of credentials. - GetCredentials(ctx context.Context, l log.Logger) (*Credentials, error) + GetCredentials(ctx context.Context, l log.Logger, exec vexec.Exec) (*Credentials, error) } diff --git a/internal/runner/run/download_source.go b/internal/runner/run/download_source.go index f82afc3b9b..bbbff709ce 100644 --- a/internal/runner/run/download_source.go +++ b/internal/runner/run/download_source.go @@ -51,6 +51,7 @@ const ( func DownloadTerraformSource( ctx context.Context, l log.Logger, + v Venv, source string, opts *Options, cfg *runcfg.RunConfig, @@ -77,7 +78,7 @@ func DownloadTerraformSource( dirLock.Lock() defer dirLock.Unlock() - downloaded, err := DownloadTerraformSourceIfNecessary(ctx, l, terraformSource, opts, cfg, r) + downloaded, err := DownloadTerraformSourceIfNecessary(ctx, l, v, terraformSource, opts, cfg, r) if err != nil { return nil, err } @@ -151,6 +152,7 @@ func DownloadTerraformSource( func DownloadTerraformSourceIfNecessary( ctx context.Context, l log.Logger, + v Venv, terraformSource *tf.Source, opts *Options, cfg *runcfg.RunConfig, @@ -219,6 +221,7 @@ func DownloadTerraformSourceIfNecessary( downloadErr := RunActionWithHooks( ctx, l, + v, "download source", optsForDownload, cfg, @@ -231,11 +234,11 @@ func DownloadTerraformSourceIfNecessary( Spinner: "Downloading source from " + sourceURL + "...", Done: "Downloaded source from " + sourceURL, }, func() error { - return downloadSource(childCtx, l, terraformSource, opts, cfg, r) + return downloadSource(childCtx, l, v, terraformSource, opts, cfg, r) }) } - return downloadSource(childCtx, l, terraformSource, opts, cfg, r) + return downloadSource(childCtx, l, v, terraformSource, opts, cfg, r) }, ) if downloadErr != nil { @@ -332,6 +335,7 @@ func readVersionFile(terraformSource *tf.Source) (string, error) { func downloadSource( ctx context.Context, l log.Logger, + v Venv, src *tf.Source, opts *Options, cfg *runcfg.RunConfig, @@ -370,7 +374,7 @@ func downloadSource( } return opts.RunWithErrorHandling(ctx, l, r, func() error { - client, err := BuildDownloadClient(l, opts, cfg) + client, err := BuildDownloadClient(l, v, opts, cfg) if err != nil { return err } @@ -431,7 +435,7 @@ func tryCASDownload(ctx context.Context, l log.Logger, src *tf.Source, opts *Opt Getters: []getter.Getter{ casProtocol, getter.NewCASGetter(l, c, venv, &cloneOpts, getter.WithDefaultGenericDispatch( - getter.WithTFRConfig(l, opts.TofuImplementation), + getter.WithTFRConfig(l, opts.TofuImplementation, venv.FS), )), }, } @@ -463,26 +467,25 @@ func tryCASDownload(ctx context.Context, l log.Logger, src *tf.Source, opts *Opt // protocol set are: FileCopyGetter (copies local sources instead of // symlinking) and RegistryGetter (resolves tfr:// sources). // -// opts.FS must be the OS-backed filesystem from [vfs.NewOSFS]; the returned -// client shells out to go-getter and other libraries that bypass the vfs +// v.FS must be the OS-backed filesystem from [vfs.NewOSFS]; it backs the +// file-copy getter and the registry getter's archive expansion, both of +// which shell out to go-getter and other libraries that bypass the vfs // abstraction. Returns [ErrNonOSFilesystem] otherwise. // // Exported so tests can assert the protocol set directly. -func BuildDownloadClient(l log.Logger, opts *Options, cfg *runcfg.RunConfig) (*getter.Client, error) { - if !vfs.IsOSFS(opts.FS) { +func BuildDownloadClient(l log.Logger, v Venv, opts *Options, cfg *runcfg.RunConfig) (*getter.Client, error) { + if !vfs.IsOSFS(v.FS) { return nil, ErrNonOSFilesystem } return getter.NewClient( getter.WithLogger(l), - getter.WithFileCopy(getter.NewFileCopyGetter(). + getter.WithFileCopy(getter.NewFileCopyGetter(v.FS). WithLogger(l). - WithFS(opts.FS). WithIncludeInCopy(cfg.Terraform.IncludeInCopy...). WithExcludeFromCopy(cfg.Terraform.ExcludeFromCopy...). WithFastCopy(controls.IsFastCopyEnabled(opts.StrictControls))), - getter.WithTFRegistry(getter.NewRegistryGetter(l). - WithFS(opts.FS). + getter.WithTFRegistry(getter.NewRegistryGetter(l, v.FS). WithTofuImplementation(opts.TofuImplementation)), ), nil } diff --git a/internal/runner/run/download_source_test.go b/internal/runner/run/download_source_test.go index b04b9adf36..fd74edfbfb 100644 --- a/internal/runner/run/download_source_test.go +++ b/internal/runner/run/download_source_test.go @@ -297,6 +297,7 @@ func TestDownloadTerraformSourceIfNecessaryInvalidTerraformSource(t *testing.T) _, err = run.DownloadTerraformSourceIfNecessary( t.Context(), logger.CreateLogger(), + run.OSVenv(), terraformSource, configbridge.NewRunOptions(opts), cfg, @@ -459,6 +460,7 @@ func testDownloadTerraformSourceIfNecessary( _, err = run.DownloadTerraformSourceIfNecessary( t.Context(), logger.CreateLogger(), + run.OSVenv(), terraformSource, configbridge.NewRunOptions(opts), cfg, @@ -631,7 +633,7 @@ func TestUpdateGettersExcludeFromCopy(t *testing.T) { terragruntOptions, err := options.NewTerragruntOptionsForTest("./test") require.NoError(t, err) - client, err := run.BuildDownloadClient(logger.CreateLogger(), configbridge.NewRunOptions(terragruntOptions), tc.cfg) + client, err := run.BuildDownloadClient(logger.CreateLogger(), run.OSVenv(), configbridge.NewRunOptions(terragruntOptions), tc.cfg) require.NoError(t, err) fileGetter, ok := findGetter[*getter.FileCopyGetter](client.Getters) @@ -657,6 +659,7 @@ func TestBuildDownloadClientHTTPNetrc(t *testing.T) { client, err := run.BuildDownloadClient( logger.CreateLogger(), + run.OSVenv(), configbridge.NewRunOptions(terragruntOptions), &runcfg.RunConfig{Terraform: runcfg.TerraformConfig{}}, ) @@ -680,6 +683,7 @@ func TestBuildDownloadClientCoversDefaultSchemes(t *testing.T) { client, err := run.BuildDownloadClient( logger.CreateLogger(), + run.OSVenv(), configbridge.NewRunOptions(terragruntOptions), &runcfg.RunConfig{Terraform: runcfg.TerraformConfig{}}, ) @@ -742,7 +746,7 @@ func TestDownloadWithNoSourceCreatesCache(t *testing.T) { r := report.NewReport() // sourceURL "." represents the current directory (no terraform.source specified) - updatedOpts, err := run.DownloadTerraformSource(t.Context(), l, ".", configbridge.NewRunOptions(opts), cfg, r) + updatedOpts, err := run.DownloadTerraformSource(t.Context(), l, run.OSVenv(), ".", configbridge.NewRunOptions(opts), cfg, r) require.NoError(t, err) // Verify that the working directory was changed to the cache directory (inside downloadDir) @@ -791,7 +795,7 @@ func TestDownloadSourceWithCASExperimentDisabled(t *testing.T) { // Mock the download source function call r := report.NewReport() - _, err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, src, configbridge.NewRunOptions(opts), cfg, r) + _, err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, run.OSVenv(), src, configbridge.NewRunOptions(opts), cfg, r) require.NoError(t, err) @@ -834,7 +838,7 @@ func TestDownloadSourceWithCASExperimentEnabled(t *testing.T) { r := report.NewReport() - _, err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, src, configbridge.NewRunOptions(opts), cfg, r) + _, err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, run.OSVenv(), src, configbridge.NewRunOptions(opts), cfg, r) require.NoError(t, err) expectedFilePath := filepath.Join(tmpDir, "main.tf") @@ -876,7 +880,7 @@ func TestDownloadSourceWithCASGitSource(t *testing.T) { r := report.NewReport() - _, err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, src, configbridge.NewRunOptions(opts), cfg, r) + _, err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, run.OSVenv(), src, configbridge.NewRunOptions(opts), cfg, r) require.NoError(t, err) // Verify the file was downloaded @@ -917,7 +921,7 @@ func TestDownloadSourceCASInitializationFailure(t *testing.T) { r := report.NewReport() - _, err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, src, configbridge.NewRunOptions(opts), cfg, r) + _, err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, run.OSVenv(), src, configbridge.NewRunOptions(opts), cfg, r) require.NoError(t, err) expectedFilePath := filepath.Join(tmpDir, "main.tf") @@ -974,7 +978,7 @@ func TestDownloadSourceUpdateSourceWithCASRequiresCAS(t *testing.T) { l.SetOptions(log.WithOutput(io.Discard)) _, err = run.DownloadTerraformSourceIfNecessary( - t.Context(), l, src, + t.Context(), l, run.OSVenv(), src, configbridge.NewRunOptions(opts), cfg, report.NewReport(), ) @@ -1043,7 +1047,7 @@ func TestDownloadSourceWithCASMultipleSources(t *testing.T) { VersionFile: filepath.Join(tmpDir, "version-file.txt"), } - _, err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, src, configbridge.NewRunOptions(opts), cfg, r) + _, err = run.DownloadTerraformSourceIfNecessary(t.Context(), l, run.OSVenv(), src, configbridge.NewRunOptions(opts), cfg, r) if tc.name == "Local file source" { require.NoError(t, err) @@ -1096,7 +1100,7 @@ func TestHTTPGetterNetrcAuthentication(t *testing.T) { dst := filepath.Join(t.TempDir(), "module.tf") - client, err := run.BuildDownloadClient(logger.CreateLogger(), configbridge.NewRunOptions(opts), cfg) + client, err := run.BuildDownloadClient(logger.CreateLogger(), run.OSVenv(), configbridge.NewRunOptions(opts), cfg) require.NoError(t, err) _, err = client.Get(t.Context(), &getter.Request{ @@ -1129,6 +1133,7 @@ func TestDownloadTerraformSourceRejectsNonOSFilesystem(t *testing.T) { _, err = run.DownloadTerraformSource( t.Context(), l, + run.OSVenv(), ".", runOpts, &runcfg.RunConfig{Terraform: runcfg.TerraformConfig{}}, @@ -1154,6 +1159,7 @@ func TestDownloadTerraformSourceIfNecessaryRejectsNonOSFilesystem(t *testing.T) _, err = run.DownloadTerraformSourceIfNecessary( t.Context(), logger.CreateLogger(), + run.OSVenv(), src, runOpts, &runcfg.RunConfig{Terraform: runcfg.TerraformConfig{}}, @@ -1172,10 +1178,13 @@ func TestBuildDownloadClientRejectsNonOSFilesystem(t *testing.T) { require.NoError(t, err) runOpts := configbridge.NewRunOptions(opts) - runOpts.FS = vfs.NewMemMapFS() + + v := run.OSVenv() + v.FS = vfs.NewMemMapFS() client, err := run.BuildDownloadClient( logger.CreateLogger(), + v, runOpts, &runcfg.RunConfig{Terraform: runcfg.TerraformConfig{}}, ) diff --git a/internal/runner/run/run.go b/internal/runner/run/run.go index 37e9051685..4d07fe32fc 100644 --- a/internal/runner/run/run.go +++ b/internal/runner/run/run.go @@ -30,7 +30,6 @@ import ( "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" - "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -82,6 +81,7 @@ var sourceChangeLocks = sync.Map{} func Run( ctx context.Context, l log.Logger, + v Venv, opts *Options, r *report.Report, cfg *runcfg.RunConfig, @@ -113,7 +113,7 @@ func Run( terragruntOptionsClone.TerraformCommand = CommandNameTerragruntReadConfig if err = terragruntOptionsClone.RunWithErrorHandling(ctx, l, r, func() error { - return ProcessHooks(ctx, l, OSVenv(), ProcessHooksParams{ + return ProcessHooks(ctx, l, v, ProcessHooksParams{ Hooks: cfg.Terraform.AfterHooks, Opts: terragruntOptionsClone, Cfg: cfg, @@ -131,7 +131,7 @@ func Run( ) if err = opts.RunWithErrorHandling(ctx, l, r, func() error { - return credsGetter.ObtainAndUpdateEnvIfNecessary(ctx, l, opts.Env, amazonsts.NewProvider(l, opts.IAMRoleOptions, opts.Env)) + return credsGetter.ObtainAndUpdateEnvIfNecessary(ctx, l, v.Exec, opts.Env, amazonsts.NewProvider(l, opts.IAMRoleOptions, opts.Env)) }); err != nil { return err } @@ -157,7 +157,7 @@ func Run( err = telemetry.TelemeterFromContext(ctx).Collect(ctx, "download_terraform_source", map[string]any{ "sourceUrl": sourceURL, }, func(ctx context.Context) error { - updatedOpts, err = DownloadTerraformSource(ctx, l, sourceURL, opts, cfg, r) + updatedOpts, err = DownloadTerraformSource(ctx, l, v, sourceURL, opts, cfg, r) return err }) if err != nil { @@ -166,7 +166,7 @@ func Run( // Handle code generation configs, both generate blocks and generate attribute of remote_state. // Note that relative paths are relative to the terragrunt working dir (where terraform is called). - if err = GenerateConfig(l, updatedOpts, cfg); err != nil { + if err = GenerateConfig(l, v.FS, updatedOpts, cfg); err != nil { return err } @@ -183,7 +183,7 @@ func Run( } if err := opts.RunWithErrorHandling(ctx, l, r, func() error { - return runTerragruntWithConfig(ctx, l, opts, updatedOpts, cfg, r) + return runTerragruntWithConfig(ctx, l, v, opts, updatedOpts, cfg, r) }); err != nil { return err } @@ -192,7 +192,7 @@ func Run( } // GenerateConfig handles code generation using config types (for backwards compatibility). -func GenerateConfig(l log.Logger, opts *Options, cfg *runcfg.RunConfig) error { +func GenerateConfig(l log.Logger, fs vfs.FS, opts *Options, cfg *runcfg.RunConfig) error { rawActualLock, _ := sourceChangeLocks.LoadOrStore(opts.DownloadDir, &sync.Mutex{}) actualLock := rawActualLock.(*sync.Mutex) @@ -212,7 +212,7 @@ func GenerateConfig(l log.Logger, opts *Options, cfg *runcfg.RunConfig) error { } else if cfg.RemoteState.Config != nil { // We use else if here because we don't need to check the backend configuration is defined when the remote state // block has a `generate` attribute configured. - if err := checkTerraformCodeDefinesBackend(opts, cfg.RemoteState.BackendName); err != nil { + if err := checkTerraformCodeDefinesBackend(fs, opts, cfg.RemoteState.BackendName); err != nil { return err } } @@ -228,6 +228,7 @@ func GenerateConfig(l log.Logger, opts *Options, cfg *runcfg.RunConfig) error { func runTerragruntWithConfig( ctx context.Context, l log.Logger, + v Venv, originalOpts *Options, opts *Options, cfg *runcfg.RunConfig, @@ -256,7 +257,7 @@ func runTerragruntWithConfig( return err } } else { - if err := PrepareNonInitCommand(ctx, l, originalOpts, opts, cfg, r); err != nil { + if err := PrepareNonInitCommand(ctx, l, v, originalOpts, opts, cfg, r); err != nil { return err } } @@ -280,9 +281,9 @@ func runTerragruntWithConfig( return err } - return RunActionWithHooks(ctx, l, "terraform", opts, cfg, r, func(childCtx context.Context) error { + return RunActionWithHooks(ctx, l, v, "terraform", opts, cfg, r, func(childCtx context.Context) error { // Execute the underlying command once; retries and ignores are handled by outer RunWithErrorHandling - out, runTerraformError := tf.RunCommandWithOutput(childCtx, l, vexec.NewOSExec(), opts.tfRunOptions(), opts.TerraformCliArgs.Slice()...) + out, runTerraformError := tf.RunCommandWithOutput(childCtx, l, v.Exec, opts.tfRunOptions(), opts.TerraformCliArgs.Slice()...) var lockFileError error if ShouldCopyLockFile(opts.TerraformCliArgs, &cfg.Terraform) { @@ -346,6 +347,7 @@ func ShouldCopyLockFile(args *iacargs.IacArgs, terraformConfig *runcfg.Terraform func RunActionWithHooks( ctx context.Context, l log.Logger, + v Venv, description string, opts *Options, cfg *runcfg.RunConfig, @@ -354,8 +356,6 @@ func RunActionWithHooks( ) error { var allErrors []error - v := OSVenv() - beforeHookErrors := ProcessHooks(ctx, l, v, ProcessHooksParams{ Hooks: cfg.Terraform.BeforeHooks, Opts: opts, @@ -428,8 +428,8 @@ func CheckFolderContainsTerraformCode(opts *Options) error { return nil } -// Check that the specified Terraform code defines a backend { ... } block and return an error if doesn't -func checkTerraformCodeDefinesBackend(opts *Options, backendType string) error { +// Check that the specified Terraform code defines a backend { ... } block and return an error if doesn't. +func checkTerraformCodeDefinesBackend(fs vfs.FS, opts *Options, backendType string) error { terraformBackendRegexp, err := regexp.Compile(fmt.Sprintf(`backend[[:blank:]]+"%s"`, backendType)) if err != nil { return err @@ -450,7 +450,7 @@ func checkTerraformCodeDefinesBackend(opts *Options, backendType string) error { return err } - definesJSONBackend, err := util.GrepFilesWithSuffix(vfs.NewOSFS(), terraformJSONBackendRegexp, opts.WorkingDir, ".tf.json") + definesJSONBackend, err := util.GrepFilesWithSuffix(fs, terraformJSONBackendRegexp, opts.WorkingDir, ".tf.json") if err != nil { return err } @@ -656,6 +656,7 @@ func prepareInitCommandRunCfg(ctx context.Context, l log.Logger, opts *Options, func PrepareNonInitCommand( ctx context.Context, l log.Logger, + v Venv, originalOpts *Options, opts *Options, cfg *runcfg.RunConfig, @@ -667,7 +668,7 @@ func PrepareNonInitCommand( } if needsInit { - if err := runTerraformInitRunCfg(ctx, l, originalOpts, opts, cfg, r); err != nil { + if err := runTerraformInitRunCfg(ctx, l, v, originalOpts, opts, cfg, r); err != nil { return err } } @@ -711,6 +712,7 @@ func needsInitRunCfg(ctx context.Context, l log.Logger, opts *Options, cfg *runc func runTerraformInitRunCfg( ctx context.Context, l log.Logger, + v Venv, originalOpts *Options, opts *Options, cfg *runcfg.RunConfig, @@ -726,7 +728,7 @@ func runTerraformInitRunCfg( return err } - if err := runTerragruntWithConfig(ctx, l, originalOpts, initOptions, cfg, r); err != nil { + if err := runTerragruntWithConfig(ctx, l, v, originalOpts, initOptions, cfg, r); err != nil { return err } diff --git a/internal/runner/run/venv.go b/internal/runner/run/venv.go index b7f41aaeb4..7c2880cf30 100644 --- a/internal/runner/run/venv.go +++ b/internal/runner/run/venv.go @@ -2,6 +2,7 @@ package run import ( "github.com/gruntwork-io/terragrunt/internal/tflint" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/internal/vfs" ) @@ -25,6 +26,20 @@ func OSVenv() Venv { return Venv{Exec: vexec.NewOSExec(), FS: vfs.NewOSFS()} } +// FromRoot projects the root [venv.Venv] threaded from the CLI entrypoint +// into the run package's local Venv. The two carry the same handles but +// are distinct types so the run package owns its own contract. +func FromRoot(v venv.Venv) Venv { + return Venv{Exec: v.Exec, FS: v.FS} +} + +// ToRoot is the inverse of [FromRoot]: it projects a run.Venv back into +// the root [venv.Venv] for callers (notably config.ParsingContext) that +// hold the root type. +func (v Venv) ToRoot() venv.Venv { + return venv.Venv{FS: v.FS, Exec: v.Exec} +} + // tflintVenv translates a run.Venv into the tflint package's Venv. The // two carry the same handles but are distinct types so each package owns // its own contract. diff --git a/internal/runner/run/version_check_mem_test.go b/internal/runner/run/version_check_mem_test.go new file mode 100644 index 0000000000..8bc771aba8 --- /dev/null +++ b/internal/runner/run/version_check_mem_test.go @@ -0,0 +1,130 @@ +package run_test + +import ( + "context" + "io" + "sync/atomic" + "testing" + + "github.com/gruntwork-io/terragrunt/internal/iacargs" + "github.com/gruntwork-io/terragrunt/internal/runner/run" + "github.com/gruntwork-io/terragrunt/internal/shell" + "github.com/gruntwork-io/terragrunt/internal/tf" + "github.com/gruntwork-io/terragrunt/internal/tfimpl" + "github.com/gruntwork-io/terragrunt/internal/vexec" + "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" +) + +// 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() + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + assert.Equal(t, "tofu", inv.Name) + assert.Equal(t, []string{tf.FlagNameVersion}, inv.Args) + + return vexec.Result{Stdout: []byte("OpenTofu v1.7.2\non darwin_arm64\n")} + }) + + _, ver, impl, err := run.GetTFVersion(t.Context(), logger.CreateLogger(), exec, newVersionTFOptions("tofu", nil)) + require.NoError(t, err) + assert.Equal(t, tfimpl.OpenTofu, impl) + assert.Equal(t, "1.7.2", ver.String()) +} + +func TestGetTFVersionTerraform(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{Stdout: []byte("Terraform v1.5.7\non linux_amd64\n")} + }) + + _, ver, impl, err := run.GetTFVersion(t.Context(), logger.CreateLogger(), exec, newVersionTFOptions("terraform", nil)) + require.NoError(t, err) + assert.Equal(t, tfimpl.Terraform, impl) + assert.Equal(t, "1.5.7", ver.String()) +} + +// TestGetTFVersionUnknownImplFallsBackToTerraform pins the +// "fallback to terraform when impl line is unrecognized" branch in +// GetTFVersion. The implementation is required to never surface +// tfimpl.Unknown to callers. +func TestGetTFVersionUnknownImplFallsBackToTerraform(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{Stdout: []byte("Custom-Fork v0.42.0\n")} + }) + + _, ver, impl, err := run.GetTFVersion(t.Context(), logger.CreateLogger(), exec, newVersionTFOptions("custom-fork", nil)) + require.NoError(t, err) + assert.Equal(t, tfimpl.Terraform, impl, "unknown impl must fall back to Terraform, not surface Unknown") + assert.Equal(t, "0.42.0", ver.String()) +} + +func TestGetTFVersionInvalidOutput(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{Stdout: []byte("not a version line\n")} + }) + + _, _, _, err := run.GetTFVersion(t.Context(), logger.CreateLogger(), exec, newVersionTFOptions("tofu", nil)) + require.Error(t, err) +} + +func TestGetTFVersionPropagatesExecError(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{ExitCode: 1, Stderr: []byte("binary missing\n")} + }) + + _, _, _, err := run.GetTFVersion(t.Context(), logger.CreateLogger(), exec, newVersionTFOptions("tofu", nil)) + require.Error(t, err) +} + +// TestGetTFVersionStripsTFCLIArgs pins the contract that TF_CLI_ARGS* env +// vars are removed from the spawned environment, so user-configured +// arguments like `TF_CLI_ARGS_plan=-refresh=false` cannot interfere with +// the version probe. +func TestGetTFVersionStripsTFCLIArgs(t *testing.T) { + t.Parallel() + + var observed atomic.Value // []string + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + observed.Store(append([]string(nil), inv.Env...)) + return vexec.Result{Stdout: []byte("OpenTofu v1.7.2\n")} + }) + + env := map[string]string{ + "PATH": "/usr/bin", + "TF_CLI_ARGS": "-no-color", + "TF_CLI_ARGS_plan": "-refresh=false", + } + + _, _, _, err := run.GetTFVersion(t.Context(), logger.CreateLogger(), exec, newVersionTFOptions("tofu", env)) + require.NoError(t, err) + + got, _ := observed.Load().([]string) + for _, kv := range got { + assert.NotContains(t, kv, "TF_CLI_ARGS", "TF_CLI_ARGS* must be stripped before invoking the version probe; saw %q", kv) + } +} diff --git a/internal/runner/runall/runall.go b/internal/runner/runall/runall.go index b7cb3b49e5..d871bf789e 100644 --- a/internal/runner/runall/runall.go +++ b/internal/runner/runall/runall.go @@ -8,10 +8,10 @@ import ( "github.com/gruntwork-io/terragrunt/internal/runner" "github.com/gruntwork-io/terragrunt/internal/runner/common" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/stacks/clean" "github.com/gruntwork-io/terragrunt/internal/stacks/generate" "github.com/gruntwork-io/terragrunt/internal/tips" - "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/gruntwork-io/terragrunt/internal/worktrees" "github.com/gruntwork-io/terragrunt/pkg/config" @@ -44,11 +44,14 @@ var runAllDisabledCommands = map[string]string{ " and thus should not be run with run --all.", } -func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (err error) { +// Run executes the configured terraform command across every unit in the +// stack. v is the virtualized environment threaded through the runner pool +// into each unit's run pipeline. +func Run(ctx context.Context, l log.Logger, v run.Venv, opts *options.TerragruntOptions) (err error) { // --filter sets RunAll, so the CLI layer dispatches here without going // through the single-unit run path. Emit the tip here as well; the // underlying sync.Once dedupes if both paths fire. - tips.GiveStackTargetTip(l, vfs.NewOSFS(), opts.WorkingDir, opts.Filters, opts.Tips) + tips.GiveStackTargetTip(l, v.FS, opts.WorkingDir, opts.Filters, opts.Tips) if opts.TerraformCommand == "" { return MissingCommand{} @@ -167,17 +170,19 @@ func Run(ctx context.Context, l log.Logger, opts *options.TerragruntOptions) (er runnerOpts = append(runnerOpts, common.WithWorktrees(wts)) } - rnr, err := runner.NewStackRunner(ctx, l, opts, runnerOpts...) + rnr, err := runner.NewStackRunner(ctx, l, v, opts, runnerOpts...) if err != nil { return err } - return RunAllOnStack(ctx, l, opts, rnr, r) + return RunAllOnStack(ctx, l, v, opts, rnr, r) } +// RunAllOnStack drives the supplied [common.StackRunner] to completion. func RunAllOnStack( ctx context.Context, l log.Logger, + v run.Venv, opts *options.TerragruntOptions, rnr common.StackRunner, r *report.Report, @@ -224,7 +229,7 @@ func RunAllOnStack( "terraform_command": opts.TerraformCommand, "working_dir": opts.WorkingDir, }, func(ctx context.Context) error { - err := rnr.Run(ctx, l, opts, r) + err := rnr.Run(ctx, l, v, opts, r) if err != nil { // At this stage, we can't handle the error any further, so we just log it and return nil. // After this point, we'll need to report on what happened, and we want that to happen diff --git a/internal/runner/runall/runall_test.go b/internal/runner/runall/runall_test.go index bff87f0710..07675370a2 100644 --- a/internal/runner/runall/runall_test.go +++ b/internal/runner/runall/runall_test.go @@ -5,6 +5,7 @@ import ( "fmt" "testing" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/runall" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" @@ -20,7 +21,7 @@ func TestMissingRunAllArguments(t *testing.T) { tgOptions.TerraformCommand = "" - err = runall.Run(t.Context(), logger.CreateLogger(), tgOptions) + err = runall.Run(t.Context(), logger.CreateLogger(), run.OSVenv(), tgOptions) require.Error(t, err) var missingCommand runall.MissingCommand diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 2d1bbddc90..83e934c434 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -9,22 +9,27 @@ import ( "github.com/gruntwork-io/terragrunt/internal/component" "github.com/gruntwork-io/terragrunt/internal/runner/common" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/runnerpool" "github.com/gruntwork-io/terragrunt/internal/shell" + "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) // NewStackRunner discovers all Terragrunt units under the working directory and -// assembles them into a StackRunner that can apply or destroy them. +// assembles them into a StackRunner that can apply or destroy them. v is the +// virtualized environment used by the version-constraint probe during build +// and threaded to the StackRunner for unit execution. func NewStackRunner( ctx context.Context, l log.Logger, + v run.Venv, opts *options.TerragruntOptions, runnerOpts ...common.Option, ) (common.StackRunner, error) { - return runnerpool.Build(ctx, l, opts, runnerOpts...) + return runnerpool.Build(ctx, l, v, opts, runnerOpts...) } // BuildUnitOpts is a facade for runnerpool.BuildUnitOpts. @@ -39,11 +44,12 @@ func BuildUnitOpts(l log.Logger, stackOpts *options.TerragruntOptions, unit *com func FindDependentUnits( ctx context.Context, l log.Logger, + v run.Venv, opts *options.TerragruntOptions, cfg *config.TerragruntConfig, ) []*component.Unit { matchedUnitsMap := make(map[string]*component.Unit) - pathsToCheck := discoverPathsToCheck(ctx, l, opts, cfg) + pathsToCheck := discoverPathsToCheck(ctx, l, v.Exec, opts, cfg) for _, dir := range pathsToCheck { maps.Copy( @@ -51,6 +57,7 @@ func FindDependentUnits( findMatchingUnitsInPath( ctx, l, + v, dir, opts, ), @@ -66,10 +73,10 @@ func FindDependentUnits( } // discoverPathsToCheck finds root git top level directory and builds list of units, or iterates over includes if git detection fails. -func discoverPathsToCheck(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) []string { +func discoverPathsToCheck(ctx context.Context, l log.Logger, exec vexec.Exec, opts *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) []string { var pathsToCheck []string - if gitTopLevelDir, err := shell.GitTopLevelDir(ctx, l, opts.Env, opts.WorkingDir); err == nil { + if gitTopLevelDir, err := shell.GitTopLevelDir(ctx, l, exec, opts.Env, opts.WorkingDir); err == nil { pathsToCheck = append(pathsToCheck, gitTopLevelDir) } else { uniquePaths := make(map[string]bool) @@ -86,7 +93,7 @@ func discoverPathsToCheck(ctx context.Context, l log.Logger, opts *options.Terra } // findMatchingUnitsInPath builds the stack from the config directory and filters units by working dir dependencies. -func findMatchingUnitsInPath(ctx context.Context, l log.Logger, dir string, opts *options.TerragruntOptions) map[string]*component.Unit { +func findMatchingUnitsInPath(ctx context.Context, l log.Logger, v run.Venv, dir string, opts *options.TerragruntOptions) map[string]*component.Unit { matchedUnitsMap := make(map[string]*component.Unit) // Construct the full path to terragrunt.hcl in the directory @@ -107,7 +114,7 @@ func findMatchingUnitsInPath(ctx context.Context, l log.Logger, dir string, opts l.Infof("Discovering dependent units for %s", opts.TerragruntConfigPath) - rnr, err := NewStackRunner(ctx, l, cfgOpts) + rnr, err := NewStackRunner(ctx, l, v, cfgOpts) if err != nil { l.Debugf("Failed to build unit stack %v", err) return matchedUnitsMap diff --git a/internal/runner/runnerpool/builder.go b/internal/runner/runnerpool/builder.go index f3bd6b9aa5..477a96f345 100644 --- a/internal/runner/runnerpool/builder.go +++ b/internal/runner/runnerpool/builder.go @@ -4,14 +4,17 @@ import ( "context" "github.com/gruntwork-io/terragrunt/internal/runner/common" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" ) -// Build stack runner using discovery and queueing mechanisms. +// Build stack runner using discovery and queueing mechanisms. v is the +// virtualized environment used by the per-unit version-constraint probe. func Build( ctx context.Context, l log.Logger, + v run.Venv, opts *options.TerragruntOptions, runnerOpts ...common.Option, ) (common.StackRunner, error) { @@ -25,7 +28,7 @@ func Build( return nil, err } - if err := checkVersionConstraints(ctx, l, opts, rnr.GetStack().Units); err != nil { + if err := checkVersionConstraints(ctx, l, v.Exec, opts, rnr.GetStack().Units); err != nil { return nil, err } diff --git a/internal/runner/runnerpool/builder_helpers.go b/internal/runner/runnerpool/builder_helpers.go index 8b40d4c119..569d8213ba 100644 --- a/internal/runner/runnerpool/builder_helpers.go +++ b/internal/runner/runnerpool/builder_helpers.go @@ -180,6 +180,7 @@ func createRunner( func checkVersionConstraints( ctx context.Context, l log.Logger, + exec vexec.Exec, opts *options.TerragruntOptions, units []*component.Unit, ) error { @@ -199,6 +200,7 @@ func checkVersionConstraints( return checkUnitVersionConstraints( checkCtx, l, + exec, unitOpts, unitLogger, unit, @@ -214,6 +216,7 @@ func checkVersionConstraints( func checkUnitVersionConstraints( ctx context.Context, l log.Logger, + exec vexec.Exec, unitOpts *options.TerragruntOptions, unitLogger log.Logger, unit *component.Unit, @@ -250,7 +253,7 @@ func checkUnitVersionConstraints( l = unitLogger } - _, ver, impl, err := run.PopulateTFVersion(ctx, l, vexec.NewOSExec(), run.PopulateTFVersionInput{ + _, ver, impl, err := run.PopulateTFVersion(ctx, l, exec, run.PopulateTFVersionInput{ TFOpts: configbridge.TFRunOptsFromOpts(unitOpts), WorkingDir: unitOpts.WorkingDir, VersionFiles: unitOpts.VersionManagerFileName, diff --git a/internal/runner/runnerpool/graph_fallback_test.go b/internal/runner/runnerpool/graph_fallback_test.go index 5e229d6c18..ff29cdbeed 100644 --- a/internal/runner/runnerpool/graph_fallback_test.go +++ b/internal/runner/runnerpool/graph_fallback_test.go @@ -11,6 +11,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/discovery" "github.com/gruntwork-io/terragrunt/internal/filter" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/runnerpool" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers" @@ -71,7 +72,7 @@ dependency "db" { optsOn.Filters = parsedFilters // Build runner - runnerOn, err := runnerpool.Build(ctx, l, optsOn) + runnerOn, err := runnerpool.Build(ctx, l, run.OSVenv(), optsOn) require.NoError(t, err) // Collect unit paths onPaths := make([]string, 0, len(runnerOn.GetStack().Units)) @@ -84,7 +85,7 @@ dependency "db" { optsOff.WorkingDir = vpcDir optsOff.RootWorkingDir = tmpDir // No filter queries; rely on fallback graph target option - runnerOff, err := runnerpool.Build(ctx, l, optsOff, discovery.WithGraphTarget(vpcDir)) + runnerOff, err := runnerpool.Build(ctx, l, run.OSVenv(), optsOff, discovery.WithGraphTarget(vpcDir)) require.NoError(t, err) offPaths := make([]string, 0, len(runnerOff.GetStack().Units)) diff --git a/internal/runner/runnerpool/runner.go b/internal/runner/runnerpool/runner.go index 68232fe09e..535bdadd7c 100644 --- a/internal/runner/runnerpool/runner.go +++ b/internal/runner/runnerpool/runner.go @@ -24,6 +24,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/queue" "github.com/gruntwork-io/terragrunt/internal/report" "github.com/gruntwork-io/terragrunt/internal/runner/common" + "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/runner/run/creds" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/view/dag" @@ -312,7 +313,7 @@ func filterUnitsToComponents(units []*component.Unit) component.Components { // Run executes the stack according to TerragruntOptions and returns the first // error (or a joined error) once execution is finished. -func (rnr *Runner) Run(ctx context.Context, l log.Logger, stackOpts *options.TerragruntOptions, r *report.Report) error { +func (rnr *Runner) Run(ctx context.Context, l log.Logger, v run.Venv, stackOpts *options.TerragruntOptions, r *report.Report) error { terraformCmd := stackOpts.TerraformCommand if stackOpts.OutputFolder != "" { @@ -453,7 +454,7 @@ func (rnr *Runner) Run(ctx context.Context, l log.Logger, stackOpts *options.Ter // // The obtain_creds span is emitted by externalcmd.Provider.GetCredentials // only when an auth provider is configured, so no conditional is needed here. - credsGetter, err := creds.ObtainCredsForParsing(childCtx, unitLogger, unitOpts.AuthProviderCmd, unitOpts.Env, configbridge.ShellRunOptsFromOpts(unitOpts)) + credsGetter, err := creds.ObtainCredsForParsing(childCtx, unitLogger, v.Exec, unitOpts.AuthProviderCmd, unitOpts.Env, configbridge.ShellRunOptsFromOpts(unitOpts)) if err != nil { logTaskOutcome(childCtx, l, unitPath, unitOpts.TerraformCommand, err) @@ -468,6 +469,7 @@ func (rnr *Runner) Run(ctx context.Context, l log.Logger, stackOpts *options.Ter "terragrunt_config_path": unitOpts.TerragruntConfigPath, }, func(readCtx context.Context) error { parseCtx, pctx := configbridge.NewParsingContext(readCtx, unitLogger, unitOpts) + pctx.Venv = v.ToRoot() var readErr error @@ -496,6 +498,7 @@ func (rnr *Runner) Run(ctx context.Context, l log.Logger, stackOpts *options.Ter return unitRunner.Run( runCtx, unitLogger, + v, unitOpts, r, runCfg, diff --git a/internal/services/catalog/catalog.go b/internal/services/catalog/catalog.go index 19310f9e1e..0a15bbcc57 100644 --- a/internal/services/catalog/catalog.go +++ b/internal/services/catalog/catalog.go @@ -21,6 +21,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/venv" "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -275,7 +276,9 @@ func (s *catalogServiceImpl) Modules() module.Modules { func (s *catalogServiceImpl) Scaffold(ctx context.Context, l log.Logger, opts *options.TerragruntOptions, module *module.Module) error { l.Debugf("Scaffolding module: %q", module.TerraformSourcePath()) - return scaffold.Run(ctx, l, opts, module.TerraformSourcePath(), "") + // TODO: thread venv from the CLI entrypoint through catalog service + // so this leaf participates in the root virtualized environment. + return scaffold.Run(ctx, l, venv.OSVenv(), opts, module.TerraformSourcePath(), "") } // catalogTempPath returns the local cache directory for repoURL's clone. It diff --git a/internal/shell/git.go b/internal/shell/git.go index 5ee3965437..a4280eaf34 100644 --- a/internal/shell/git.go +++ b/internal/shell/git.go @@ -45,7 +45,7 @@ func (e *NestedGitScanDepthExceededError) Error() string { // ancestor keeps the answer correct when a nested repository sits below an // already-cached outer root. Concurrent misses for the same repo collapse to // a single fork via the cache's resolve lock and a re-check after acquiring it. -func GitTopLevelDir(ctx context.Context, l log.Logger, env map[string]string, path string) (string, error) { +func GitTopLevelDir(ctx context.Context, l log.Logger, exec vexec.Exec, env map[string]string, path string) (string, error) { repoRoots := cache.ContextRepoRootCache(ctx, cache.RepoRootCacheContextKey) normalized := normalizeRepoPath(path) @@ -72,7 +72,7 @@ func GitTopLevelDir(ctx context.Context, l log.Logger, env map[string]string, pa WithEnv(env). WithWriters(writer.Writers{Writer: &stdout, ErrWriter: &stderr}) - cmd, err := RunCommandWithOutput(ctx, l, vexec.NewOSExec(), gitRunOpts, path, true, false, "git", "rev-parse", "--show-toplevel") + cmd, err := RunCommandWithOutput(ctx, l, exec, gitRunOpts, path, true, false, "git", "rev-parse", "--show-toplevel") if err != nil { return "", err } @@ -171,7 +171,7 @@ func normalizeRepoPath(path string) string { } // GitRepoTags fetches git repository tags from passed url. -func GitRepoTags(ctx context.Context, l log.Logger, env map[string]string, workingDir string, gitRepo *url.URL) ([]string, error) { +func GitRepoTags(ctx context.Context, l log.Logger, exec vexec.Exec, env map[string]string, workingDir string, gitRepo *url.URL) ([]string, error) { repoPath := gitRepo.String() // remove git:: part if present repoPath = strings.TrimPrefix(repoPath, gitPrefix) @@ -184,7 +184,7 @@ func GitRepoTags(ctx context.Context, l log.Logger, env map[string]string, worki WithEnv(env). WithWriters(writer.Writers{Writer: &stdout, ErrWriter: &stderr}) - output, err := RunCommandWithOutput(ctx, l, vexec.NewOSExec(), gitRunOpts, workingDir, true, false, "git", "ls-remote", "--tags", repoPath) + output, err := RunCommandWithOutput(ctx, l, exec, gitRunOpts, workingDir, true, false, "git", "ls-remote", "--tags", repoPath) if err != nil { return nil, err } @@ -204,8 +204,8 @@ func GitRepoTags(ctx context.Context, l log.Logger, env map[string]string, worki } // GitLastReleaseTag fetches git repository last release tag. -func GitLastReleaseTag(ctx context.Context, l log.Logger, env map[string]string, workingDir string, gitRepo *url.URL) (string, error) { - tags, err := GitRepoTags(ctx, l, env, workingDir, gitRepo) +func GitLastReleaseTag(ctx context.Context, l log.Logger, exec vexec.Exec, env map[string]string, workingDir string, gitRepo *url.URL) (string, error) { + tags, err := GitRepoTags(ctx, l, exec, env, workingDir, gitRepo) if err != nil { return "", err } diff --git a/internal/shell/git_mem_test.go b/internal/shell/git_mem_test.go new file mode 100644 index 0000000000..af0b50bf74 --- /dev/null +++ b/internal/shell/git_mem_test.go @@ -0,0 +1,156 @@ +package shell_test + +import ( + "context" + "net/url" + "sync/atomic" + "testing" + + "github.com/gruntwork-io/terragrunt/internal/cache" + "github.com/gruntwork-io/terragrunt/internal/shell" + "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" +) + +// 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 +// that drops or reorders any of them is caught. +func TestGitTopLevelDirDispatchesGitRevParse(t *testing.T) { + t.Parallel() + + var calls int + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + calls++ + + assert.Equal(t, "git", inv.Name) + assert.Equal(t, []string{"rev-parse", "--show-toplevel"}, inv.Args) + assert.Equal(t, "/tmp/repo", inv.Dir) + + return vexec.Result{Stdout: []byte("/tmp/repo\n")} + }) + + root, err := shell.GitTopLevelDir(gitMemCtx(t), logger.CreateLogger(), exec, nil, "/tmp/repo") + require.NoError(t, err) + assert.Equal(t, "/tmp/repo", root) + assert.Equal(t, 1, calls) +} + +// TestGitTopLevelDirNormalizesWindowsSlashes pins the contract that +// git's forward-slash output is normalized to OS-native separators so +// downstream path-equality checks see consistent paths regardless of +// platform. +func TestGitTopLevelDirNormalizesWindowsSlashes(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + // Git always emits forward slashes on Windows from rev-parse --show-toplevel. + return vexec.Result{Stdout: []byte("/c/Users/dev/repo\n")} + }) + + root, err := shell.GitTopLevelDir(gitMemCtx(t), logger.CreateLogger(), exec, nil, "/c/Users/dev/repo") + require.NoError(t, err) + // On unix this is a no-op; the assertion verifies trim-and-normalize ran. + assert.NotContains(t, root, "\n") + assert.NotContains(t, root, "\r") +} + +// TestGitTopLevelDirCacheHits verifies repeated lookups of the same path +// collapse to a single subprocess fork via the run-scoped repo-root cache. +func TestGitTopLevelDirCacheHits(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + calls.Add(1) + return vexec.Result{Stdout: []byte("/repo\n")} + }) + + ctx := gitMemCtx(t) + l := logger.CreateLogger() + + for range 5 { + _, err := shell.GitTopLevelDir(ctx, l, exec, nil, "/repo") + require.NoError(t, err) + } + + assert.Equal(t, int32(1), calls.Load(), "repeated GitTopLevelDir calls must reuse the cached answer") +} + +// TestGitRepoTagsParsesLsRemote pins the parse of `git ls-remote --tags` +// output: each non-empty line yields a tag in the second column. +func TestGitRepoTagsParsesLsRemote(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{"ls-remote", "--tags", "https://github.com/example/repo.git"}, inv.Args) + + return vexec.Result{Stdout: []byte( + "abc123\trefs/tags/v1.0.0\n" + + "def456\trefs/tags/v1.1.0\n" + + "ghi789\trefs/tags/v2.0.0\n", + )} + }) + + u, err := url.Parse("https://github.com/example/repo.git") + require.NoError(t, err) + + tags, err := shell.GitRepoTags(t.Context(), logger.CreateLogger(), exec, nil, "/work", u) + require.NoError(t, err) + assert.Equal(t, []string{"refs/tags/v1.0.0", "refs/tags/v1.1.0", "refs/tags/v2.0.0"}, tags) +} + +// TestGitLastReleaseTagSelectsHighestSemver pins the contract that +// GitLastReleaseTag returns the highest semver tag, ignoring non-semver +// tag names that ls-remote may include. +func TestGitLastReleaseTagSelectsHighestSemver(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{Stdout: []byte( + "a\trefs/tags/v0.9.0\n" + + "b\trefs/tags/v1.0.0\n" + + "c\trefs/tags/v1.10.0\n" + // higher than v1.2.0 under semver + "d\trefs/tags/v1.2.0\n" + + "e\trefs/tags/some-non-semver-name\n", + )} + }) + + u, err := url.Parse("https://github.com/example/repo.git") + require.NoError(t, err) + + tag, err := shell.GitLastReleaseTag(t.Context(), logger.CreateLogger(), exec, nil, "/work", u) + require.NoError(t, err) + assert.Equal(t, "v1.10.0", tag) +} + +// TestGitLastReleaseTagEmptyOnNoSemver pins the contract that a tag list +// with zero parseable semver entries returns "" rather than an error. +func TestGitLastReleaseTagEmptyOnNoSemver(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{Stdout: []byte("a\trefs/tags/release-candidate\nb\trefs/tags/draft\n")} + }) + + u, err := url.Parse("https://github.com/example/repo.git") + require.NoError(t, err) + + tag, err := shell.GitLastReleaseTag(t.Context(), logger.CreateLogger(), exec, nil, "/work", u) + require.NoError(t, err) + assert.Empty(t, tag) +} diff --git a/internal/shell/git_windows_test.go b/internal/shell/git_windows_test.go index 0738d81b43..a82f1dce37 100644 --- a/internal/shell/git_windows_test.go +++ b/internal/shell/git_windows_test.go @@ -8,6 +8,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/cache" "github.com/gruntwork-io/terragrunt/internal/shell" + "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" "github.com/stretchr/testify/assert" @@ -28,7 +29,7 @@ func TestGitTopLevelDirReturnsOSNativePathOnWindows(t *testing.T) { l := logger.CreateLogger() - repoRoot, err := shell.GitTopLevelDir(ctx, l, terragruntOptions.Env, ".") + repoRoot, err := shell.GitTopLevelDir(ctx, l, vexec.NewOSExec(), terragruntOptions.Env, ".") require.NoError(t, err) require.NotEmpty(t, repoRoot) diff --git a/internal/shell/run_cmd_mem_test.go b/internal/shell/run_cmd_mem_test.go index 37e192ee8e..0c71ecb0c2 100644 --- a/internal/shell/run_cmd_mem_test.go +++ b/internal/shell/run_cmd_mem_test.go @@ -56,3 +56,41 @@ func TestRunCommandMemBackendWithRacing(t *testing.T) { assert.Equal(t, int32(2), calls.Load(), "expected exactly two intercepted invocations") } + +// TestRunCommandRoutesStdoutAndStderrSeparately pins the contract that +// subprocess stdout writes hit the configured Writer and stderr writes +// hit the configured ErrWriter when the two are distinct buffers, and +// that both stream to the same buffer when Writer == ErrWriter. The +// previous OS-backed test exercised this by spawning real tofu; the +// mem backend lets us inject canned stdout/stderr directly. +func TestRunCommandRoutesStdoutAndStderrSeparately(t *testing.T) { + t.Parallel() + + e := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{ + Stdout: []byte("out-line\n"), + Stderr: []byte("err-line\n"), + } + }) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + opts := shell.NewShellOptions(). + WithWriters(writer.Writers{Writer: stdout, ErrWriter: stderr}) + + require.NoError(t, shell.RunCommand(t.Context(), logger.CreateLogger(), e, opts, "tool")) + assert.Contains(t, stdout.String(), "out-line", "subprocess stdout must reach Writer") + assert.Contains(t, stderr.String(), "err-line", "subprocess stderr must reach ErrWriter") + assert.NotContains(t, stdout.String(), "err-line", "stderr must not leak into Writer when ErrWriter is separate") + assert.NotContains(t, stderr.String(), "out-line", "stdout must not leak into ErrWriter when Writer is separate") + + // Same buffer for both writers: each line still appears, both in the shared buffer. + merged := &bytes.Buffer{} + mergedOpts := shell.NewShellOptions(). + WithWriters(writer.Writers{Writer: merged, ErrWriter: merged}) + + require.NoError(t, shell.RunCommand(t.Context(), logger.CreateLogger(), e, mergedOpts, "tool")) + assert.Contains(t, merged.String(), "out-line") + assert.Contains(t, merged.String(), "err-line") +} diff --git a/internal/shell/run_cmd_test.go b/internal/shell/run_cmd_test.go index ff2122478d..5a60b9e210 100644 --- a/internal/shell/run_cmd_test.go +++ b/internal/shell/run_cmd_test.go @@ -1,16 +1,11 @@ package shell_test import ( - "bytes" - "context" "os" "path/filepath" "testing" "github.com/gruntwork-io/terragrunt/internal/cache" - "github.com/gruntwork-io/terragrunt/internal/configbridge" - "github.com/gruntwork-io/terragrunt/internal/iacargs" - osexec "github.com/gruntwork-io/terragrunt/internal/os/exec" "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/test/helpers" @@ -21,78 +16,6 @@ import ( "github.com/gruntwork-io/terragrunt/pkg/options" ) -// TestRunCommandPTYRequiresOSBackedExec verifies the OSCmder unwrap pattern: -// requesting PTY mode with an in-memory backend returns vexec.ErrNotOSBacked -// rather than attempting to spawn a real subprocess through the mock. -func TestRunCommandPTYRequiresOSBackedExec(t *testing.T) { - t.Parallel() - - memExec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { - return vexec.Result{} - }) - - terragruntOptions, err := options.NewTerragruntOptionsForTest("") - require.NoError(t, err) - - l := logger.CreateLogger() - _, runErr := shell.RunCommandWithOutput( - t.Context(), l, memExec, configbridge.ShellRunOptsFromOpts(terragruntOptions), - "", false, true, "echo", "hi", - ) - require.Error(t, runErr) - assert.ErrorIs(t, runErr, osexec.ErrPTYRequiresOSBackend) -} - -func TestRunShellCommand(t *testing.T) { - t.Parallel() - - terragruntOptions, err := options.NewTerragruntOptionsForTest("") - require.NoError(t, err, "Unexpected error creating NewTerragruntOptionsForTest: %v", err) - - l := logger.CreateLogger() - - cmd := shell.RunCommand(t.Context(), l, vexec.NewOSExec(), configbridge.ShellRunOptsFromOpts(terragruntOptions), "tofu", "--version") - require.NoError(t, cmd) - - cmd = shell.RunCommand(t.Context(), l, vexec.NewOSExec(), configbridge.ShellRunOptsFromOpts(terragruntOptions), "tofu", "not-a-real-command") - require.Error(t, cmd) -} - -func TestRunShellOutputToStderrAndStdout(t *testing.T) { - t.Parallel() - - terragruntOptions, err := options.NewTerragruntOptionsForTest("") - require.NoError(t, err, "Unexpected error creating NewTerragruntOptionsForTest: %v", err) - - stdout := new(bytes.Buffer) - stderr := new(bytes.Buffer) - - terragruntOptions.TerraformCliArgs.AppendFlag("--version") - terragruntOptions.Writers.Writer = stdout - terragruntOptions.Writers.ErrWriter = stderr - - l := logger.CreateLogger() - - cmd := shell.RunCommand(t.Context(), l, vexec.NewOSExec(), configbridge.ShellRunOptsFromOpts(terragruntOptions), "tofu", "--version") - require.NoError(t, cmd) - - assert.Contains(t, stdout.String(), "OpenTofu", "Output directed to stdout") - assert.Empty(t, stderr.String(), "No output to stderr") - - stdout = new(bytes.Buffer) - stderr = new(bytes.Buffer) - - terragruntOptions.TerraformCliArgs = iacargs.New() - terragruntOptions.Writers.Writer = stderr - terragruntOptions.Writers.ErrWriter = stderr - - cmd = shell.RunCommand(t.Context(), l, vexec.NewOSExec(), configbridge.ShellRunOptsFromOpts(terragruntOptions), "tofu", "--version") - require.NoError(t, cmd) - - assert.Contains(t, stderr.String(), "OpenTofu", "Output directed to stderr") - assert.Empty(t, stdout.String(), "No output to stdout") -} - func TestLastReleaseTag(t *testing.T) { t.Parallel() @@ -111,27 +34,6 @@ func TestLastReleaseTag(t *testing.T) { assert.Equal(t, "v20.1.2", lastTag) } -func TestGitLevelTopDirCaching(t *testing.T) { - t.Parallel() - ctx := t.Context() - ctx = cache.ContextWithCache(ctx) - c := cache.ContextRepoRootCache(ctx, cache.RepoRootCacheContextKey) - assert.NotNil(t, c) - assert.Equal(t, 0, c.Len()) - - terragruntOptions, err := options.NewTerragruntOptionsForTest("") - require.NoError(t, err) - - l := logger.CreateLogger() - path := "." - path1, err := shell.GitTopLevelDir(ctx, l, terragruntOptions.Env, path) - require.NoError(t, err) - path2, err := shell.GitTopLevelDir(ctx, l, terragruntOptions.Env, path) - require.NoError(t, err) - assert.Equal(t, path1, path2) - assert.Equal(t, 1, c.Len()) -} - // TestGitTopLevelDirPrefixHit asserts that a descendant query is served from // the cache. The seeded root is synthetic, so a non-cached answer would have // to come from `git rev-parse` and would not equal the seeded root. @@ -149,7 +51,7 @@ func TestGitTopLevelDirPrefixHit(t *testing.T) { terragruntOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) - got, err := shell.GitTopLevelDir(ctx, logger.CreateLogger(), terragruntOptions.Env, subdir) + got, err := shell.GitTopLevelDir(ctx, logger.CreateLogger(), vexec.NewOSExec(), terragruntOptions.Env, subdir) require.NoError(t, err) assert.Equal(t, root, got) } @@ -175,7 +77,7 @@ func TestGitTopLevelDirNestedRepoBypass(t *testing.T) { terragruntOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err) - got, err := shell.GitTopLevelDir(ctx, logger.CreateLogger(), terragruntOptions.Env, deep) + got, err := shell.GitTopLevelDir(ctx, logger.CreateLogger(), vexec.NewOSExec(), terragruntOptions.Env, deep) if err == nil { assert.NotEqual(t, root, got, "guard should not return the outer root when a nested .git exists") } diff --git a/internal/tflint/venv.go b/internal/tflint/venv.go index a2c86d7942..729a8cebc9 100644 --- a/internal/tflint/venv.go +++ b/internal/tflint/venv.go @@ -1,6 +1,7 @@ package tflint import ( + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/internal/vfs" ) @@ -24,3 +25,10 @@ type Venv struct { func OSVenv() Venv { return Venv{Exec: vexec.NewOSExec(), FS: vfs.NewOSFS()} } + +// FromRoot projects the root [venv.Venv] threaded from the CLI entrypoint +// into the tflint package's local Venv. The two carry the same handles but +// are distinct types so the tflint package owns its own contract. +func FromRoot(v venv.Venv) Venv { + return Venv{Exec: v.Exec, FS: v.FS} +} diff --git a/internal/venv/venv.go b/internal/venv/venv.go new file mode 100644 index 0000000000..7ffebbd637 --- /dev/null +++ b/internal/venv/venv.go @@ -0,0 +1,34 @@ +// Package venv defines the root virtualized environment threaded from the +// Terragrunt binary entrypoint down through the CLI and its commands. +// +// A [Venv] bundles the two side-effect handles every layer below the CLI +// needs to do its work: [vfs.FS] for filesystem reads and writes, and +// [vexec.Exec] for spawning subprocesses. Production code constructs the +// real bundle once at the top via [OSVenv]; tests construct an in-memory +// bundle and drive the full CLI through it. +// +// Downstream packages (for example internal/runner/run and internal/tflint) +// keep their own package-local Venv types so each owns its own contract. +// They convert from this root via a FromRoot constructor. +package venv + +import ( + "github.com/gruntwork-io/terragrunt/internal/vexec" + "github.com/gruntwork-io/terragrunt/internal/vfs" +) + +// Venv is the root virtualized environment. It carries the filesystem +// and process-execution handles that every Terragrunt operation needs. +type Venv struct { + // FS backs every filesystem read and write. + FS vfs.FS + // Exec spawns every subprocess: tofu, terraform, git, hooks, + // external auth providers, tflint. + Exec vexec.Exec +} + +// OSVenv builds the production [Venv]: the real OS filesystem and the +// real OS process executor. +func OSVenv() Venv { + return Venv{FS: vfs.NewOSFS(), Exec: vexec.NewOSExec()} +} diff --git a/main.go b/main.go index e5a7fc2834..ea55813469 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/shell" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/log/format" "github.com/gruntwork-io/terragrunt/pkg/options" @@ -71,7 +72,7 @@ func run() (exitCode int) { exitCode = resolveExitCode(l, detailedExitCode.GetFinalExitCode(), err) }() - app := cli.NewApp(l, opts) + app := cli.NewApp(l, opts, venv.OSVenv()) ctx := setupContext(l, detailedExitCode) err := app.RunContext(ctx, os.Args) diff --git a/pkg/config/config.go b/pkg/config/config.go index 95ca79db14..94c132e2f4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -30,7 +30,6 @@ import ( "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/strict/controls" - "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/internal/getter" "github.com/hashicorp/hcl/v2" @@ -1378,7 +1377,7 @@ func ParseConfig( pctx.DecodedDependencies = retrievedOutputs } - evalContext, err := createTerragruntEvalContext(ctx, pctx, l, vexec.NewOSExec(), file.ConfigPath) + evalContext, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { errs = append(errs, err) } diff --git a/pkg/config/config_helpers.go b/pkg/config/config_helpers.go index 76ac4c50ee..4cdb47e6b9 100644 --- a/pkg/config/config_helpers.go +++ b/pkg/config/config_helpers.go @@ -44,7 +44,6 @@ import ( "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" - "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -179,24 +178,21 @@ type TrackInclude struct { // Create an EvalContext for the HCL2 parser. We can define functions and variables in this ctx that the HCL2 parser // will make available to the Terragrunt configuration during parsing. // -// The vexec.Exec parameter is captured by the run_cmd HCL function closure so -// that subprocess execution flows through the caller-supplied backend (real -// os/exec in production, in-memory mock in tests). It is not used by any -// other HCL function. -func createTerragruntEvalContext(ctx context.Context, pctx *ParsingContext, l log.Logger, e vexec.Exec, configPath string) (*hcl.EvalContext, error) { +// The subprocess backend for run_cmd is taken from pctx.Venv.Exec so that +// execution flows through the threaded virtualized environment (real os/exec +// in production, in-memory mock in tests). It is not used by any other HCL +// function. +func createTerragruntEvalContext(ctx context.Context, pctx *ParsingContext, l log.Logger, configPath string) (*hcl.EvalContext, error) { tfscope := tflang.Scope{ BaseDir: filepath.Dir(configPath), } terragruntFunctions := map[string]function.Function{ - FuncNameFindInParentFolders: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, FindInParentFolders), - FuncNamePathRelativeToInclude: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, PathRelativeToInclude), - FuncNamePathRelativeFromInclude: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, PathRelativeFromInclude), - FuncNameGetEnv: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, getEnvironmentVariable), - FuncNameRunCmd: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, - func(ctx context.Context, pctx *ParsingContext, l log.Logger, params []string) (string, error) { - return RunCommand(ctx, pctx, l, e, params) - }), + FuncNameFindInParentFolders: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, FindInParentFolders), + FuncNamePathRelativeToInclude: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, PathRelativeToInclude), + FuncNamePathRelativeFromInclude: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, PathRelativeFromInclude), + FuncNameGetEnv: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, getEnvironmentVariable), + FuncNameRunCmd: wrapStringSliceToStringAsFuncImpl(ctx, pctx, l, RunCommand), FuncNameReadTerragruntConfig: readTerragruntConfigAsFuncImpl(ctx, pctx, l), FuncNameGetPlatform: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getPlatform), FuncNameGetRepoRoot: wrapVoidToStringAsFuncImpl(ctx, pctx, l, getRepoRoot), @@ -291,12 +287,12 @@ func getPlatform(ctx context.Context, pctx *ParsingContext, l log.Logger) (strin // Return the repository root as an absolute path func getRepoRoot(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { - return shell.GitTopLevelDir(ctx, l, pctx.Env, pctx.WorkingDir) + return shell.GitTopLevelDir(ctx, l, pctx.Venv.Exec, pctx.Env, pctx.WorkingDir) } // Return the path from the repository root func getPathFromRepoRoot(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { - repoAbsPath, err := shell.GitTopLevelDir(ctx, l, pctx.Env, pctx.WorkingDir) + repoAbsPath, err := shell.GitTopLevelDir(ctx, l, pctx.Venv.Exec, pctx.Env, pctx.WorkingDir) if err != nil { return "", fmt.Errorf("getting git top level dir: %w", err) } @@ -311,7 +307,7 @@ func getPathFromRepoRoot(ctx context.Context, pctx *ParsingContext, l log.Logger // Return the path to the repository root func getPathToRepoRoot(ctx context.Context, pctx *ParsingContext, l log.Logger) (string, error) { - repoAbsPath, err := shell.GitTopLevelDir(ctx, l, pctx.Env, pctx.WorkingDir) + repoAbsPath, err := shell.GitTopLevelDir(ctx, l, pctx.Venv.Exec, pctx.Env, pctx.WorkingDir) if err != nil { return "", fmt.Errorf("getting git top level dir: %w", err) } @@ -373,15 +369,16 @@ func parseGetEnvParameters(parameters []string) (EnvVar, error) { // RunCommand is a helper function that runs a command and returns the stdout as the interpolation // for each `run_cmd` in locals section, function is called twice result. // -// The vexec.Exec parameter selects the subprocess backend. Production callers -// pass vexec.NewOSExec() (or the value threaded through createTerragruntEvalContext); -// tests can pass a vexec.NewMemExec mock to intercept subprocess invocations. -func RunCommand(ctx context.Context, pctx *ParsingContext, l log.Logger, e vexec.Exec, args []string) (string, error) { - return runCommandImpl(ctx, pctx, l, e, args) +// The subprocess backend is taken from pctx.Venv.Exec so execution flows +// through the threaded virtualized environment. Production callers see the +// real os/exec backend; tests can inject vexec.NewMemExec via the parsing +// context to intercept subprocess invocations. +func RunCommand(ctx context.Context, pctx *ParsingContext, l log.Logger, args []string) (string, error) { + return runCommandImpl(ctx, pctx, l, args) } // runCommandImpl contains the actual implementation of RunCommand -func runCommandImpl(ctx context.Context, pctx *ParsingContext, l log.Logger, e vexec.Exec, args []string) (string, error) { +func runCommandImpl(ctx context.Context, pctx *ParsingContext, l log.Logger, args []string) (string, error) { // runCommandCache - cache of evaluated `run_cmd` invocations // see: https://github.com/gruntwork-io/terragrunt/issues/1427 runCommandCache := cache.ContextCache[*RunCmdCacheEntry](ctx, RunCmdCacheContextKey) @@ -390,6 +387,12 @@ func runCommandImpl(ctx context.Context, pctx *ParsingContext, l log.Logger, e v return "", EmptyStringNotAllowedError("parameter to the run_cmd function") } + // Clone the caller's slice before flag stripping. slices.Delete + // reuses the backing array, so without this the caller's args would + // be mutated in place and subsequent calls (or shared state in the + // HCL evaluator) would see post-strip residue. + args = slices.Clone(args) + suppressOutput := false disableCache := false useGlobalCache := false @@ -467,7 +470,7 @@ func runCommandImpl(ctx context.Context, pctx *ParsingContext, l log.Logger, e v cmdOutput, err := shell.RunCommandWithOutput( ctx, l, - e, + pctx.Venv.Exec, shellRunOptsFromPctx(pctx), currentPath, true, diff --git a/pkg/config/config_helpers_mem_test.go b/pkg/config/config_helpers_mem_test.go new file mode 100644 index 0000000000..a865c15bc7 --- /dev/null +++ b/pkg/config/config_helpers_mem_test.go @@ -0,0 +1,264 @@ +package config_test + +import ( + "context" + "slices" + "sync/atomic" + "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" +) + +// TestRunCommandMemExec exercises the run_cmd HCL helper end-to-end on a +// mem-backed exec. The existing TestRunCommand skips on Windows because +// it shells out to /bin/bash; this variant runs everywhere because the +// subprocess is intercepted by the mem backend. +func TestRunCommandMemExec(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + assert.Equal(t, "echoer", inv.Name) + assert.Equal(t, []string{"hello"}, inv.Args) + + return vexec.Result{Stdout: []byte("hello\n")} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, t.TempDir()) + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + + out, err := config.RunCommand(ctx, pctx, l, []string{"echoer", "hello"}) + require.NoError(t, err) + assert.Equal(t, "hello", out, "trailing newline must be trimmed from run_cmd output") +} + +// TestRunCommandCacheHitsCollapseSubprocessForks pins the run_cmd cache +// invariant: a repeated call with identical args (and the default cache +// scope) must reuse the prior result rather than re-fork the subprocess. +// Without the threaded Venv this was previously testable only by +// scripting an external process to observe its own invocation count. +func TestRunCommandCacheHitsCollapseSubprocessForks(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + calls.Add(1) + return vexec.Result{Stdout: []byte("computed\n")} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, t.TempDir()) + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + + args := []string{"expensive-cmd", "--flag"} + + for range 4 { + out, err := config.RunCommand(ctx, pctx, l, args) + require.NoError(t, err) + assert.Equal(t, "computed", out) + } + + assert.Equal(t, int32(1), calls.Load(), "run_cmd cache must collapse repeated invocations to a single subprocess fork") +} + +// TestRunCommandNoCacheRefuses pins the contract that +// --terragrunt-no-cache forces re-execution on every call. +func TestRunCommandNoCacheRefuses(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + calls.Add(1) + return vexec.Result{Stdout: []byte("fresh\n")} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, t.TempDir()) + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + + for range 3 { + _, err := config.RunCommand(ctx, pctx, l, []string{"--terragrunt-no-cache", "cmd"}) + require.NoError(t, err) + } + + assert.Equal(t, int32(3), calls.Load(), "--terragrunt-no-cache must force a subprocess fork every call") +} + +// TestRunCommandSurfacesSubprocessFailure pins the contract that a +// non-zero subprocess exit translates to an error from run_cmd. +func TestRunCommandSurfacesSubprocessFailure(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{ExitCode: 2, Stderr: []byte("nope\n")} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, t.TempDir()) + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + + _, err := config.RunCommand(ctx, pctx, l, []string{"failing-cmd"}) + require.Error(t, err) +} + +// TestRunCommandGlobalCacheSharesAcrossWorkingDirs pins that the +// --terragrunt-global-cache flag makes the cache scope path-agnostic: +// two RunCommand calls in different working dirs with the same args +// collapse to a single subprocess fork. +func TestRunCommandGlobalCacheSharesAcrossWorkingDirs(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + calls.Add(1) + return vexec.Result{Stdout: []byte("shared\n")} + }) + + l := logger.CreateLogger() + ctx, pctxA := newTestParsingContext(t, t.TempDir()) + ctx = config.WithConfigValues(ctx) + pctxA.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + + _, pctxB := newTestParsingContext(t, t.TempDir()) + pctxB.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + + args := []string{"--terragrunt-global-cache", "cmd"} + + _, err := config.RunCommand(ctx, pctxA, l, args) + require.NoError(t, err) + + _, err = config.RunCommand(ctx, pctxB, l, args) + require.NoError(t, err) + + assert.Equal(t, int32(1), calls.Load(), "--terragrunt-global-cache must collapse calls across distinct working dirs") +} + +// TestRunCommandConflictingCacheFlags pins the validation error returned +// when --terragrunt-no-cache and --terragrunt-global-cache are combined. +// The error is surfaced before any subprocess fork, so the test wires +// a Handler that fails if it is ever called. +func TestRunCommandConflictingCacheFlags(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + }{ + { + name: "no-cache before global-cache", + args: []string{"--terragrunt-no-cache", "--terragrunt-global-cache", "cmd"}, + }, + { + name: "global-cache before no-cache", + args: []string{"--terragrunt-global-cache", "--terragrunt-no-cache", "cmd"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + assert.Fail(t, "conflicting cache flags must error before any subprocess fork") + return vexec.Result{} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, t.TempDir()) + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + + _, err := config.RunCommand(ctx, pctx, l, tc.args) + require.Error(t, err) + require.ErrorAs(t, err, new(config.ConflictingRunCmdCacheOptionsError)) + }) + } +} + +// TestRunCommandDoesNotMutateCallerArgs pins the contract that +// runCommandImpl clones its args before stripping terragrunt-prefixed +// flags. Without the clone, slices.Delete would shift the caller's +// backing array and a subsequent call (or HCL evaluator re-entry) would +// see post-strip residue. +func TestRunCommandDoesNotMutateCallerArgs(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + return vexec.Result{Stdout: []byte("ok\n")} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, t.TempDir()) + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + + args := []string{"--terragrunt-quiet", "--terragrunt-global-cache", "cmd", "subarg"} + want := slices.Clone(args) + + _, err := config.RunCommand(ctx, pctx, l, args) + require.NoError(t, err) + + assert.Equal(t, want, args, "RunCommand must not mutate the caller's args slice") +} + +// TestRunCommandEmptyParamsErrors pins the validation that run_cmd with +// no arguments returns EmptyStringNotAllowedError, again before any +// subprocess fork. +func TestRunCommandEmptyParamsErrors(t *testing.T) { + t.Parallel() + + exec := vexec.NewMemExec(func(_ context.Context, _ vexec.Invocation) vexec.Result { + assert.Fail(t, "empty run_cmd args must error before any subprocess fork") + return vexec.Result{} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, t.TempDir()) + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + + _, err := config.RunCommand(ctx, pctx, l, nil) + require.Error(t, err) + require.ErrorAs(t, err, new(config.EmptyStringNotAllowedError)) +} + +// TestRunCommandReceivesPctxEnv pins that pctx.Env propagates into the +// subprocess environment via shellRunOptsFromPctx. The mem backend +// exposes the Env slice directly, so a regression that drops env +// propagation is observable here. +func TestRunCommandReceivesPctxEnv(t *testing.T) { + t.Parallel() + + var got atomic.Value // []string + + exec := vexec.NewMemExec(func(_ context.Context, inv vexec.Invocation) vexec.Result { + got.Store(append([]string(nil), inv.Env...)) + return vexec.Result{Stdout: []byte("ok\n")} + }) + + l := logger.CreateLogger() + ctx, pctx := newTestParsingContext(t, t.TempDir()) + ctx = config.WithConfigValues(ctx) + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: exec} + pctx.Env = map[string]string{"TG_TEST_TOKEN": "abc123"} + + _, err := config.RunCommand(ctx, pctx, l, []string{"reader"}) + require.NoError(t, err) + + env, _ := got.Load().([]string) + assert.Contains(t, env, "TG_TEST_TOKEN=abc123", "pctx.Env must propagate to the spawned subprocess environment") +} diff --git a/pkg/config/config_helpers_test.go b/pkg/config/config_helpers_test.go index 6c3c446bab..1551bb62d9 100644 --- a/pkg/config/config_helpers_test.go +++ b/pkg/config/config_helpers_test.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "runtime" "strings" "testing" @@ -18,7 +17,6 @@ import ( "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/util" - "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/config" "github.com/gruntwork-io/terragrunt/test/helpers" "github.com/gruntwork-io/terragrunt/test/helpers/logger" @@ -158,116 +156,6 @@ func TestPathRelativeFromInclude(t *testing.T) { } } -func TestRunCommand(t *testing.T) { - t.Parallel() - - if runtime.GOOS == "windows" { - t.Skip("Skipping test on Windows because it doesn't support bash") - } - - homeDir := os.Getenv("HOME") - - testCases := []struct { - expectErr func(t *testing.T, err error) - configPath string - expectedOutput string - params []string - }{ - { - params: []string{"/bin/bash", "-c", "echo -n foo"}, - configPath: homeDir, - expectedOutput: "foo", - }, - { - params: []string{"/bin/bash", "-c", "echo foo"}, - configPath: homeDir, - expectedOutput: "foo", - }, - { - params: []string{"--terragrunt-quiet", "/bin/bash", "-c", "echo -n foo"}, - configPath: homeDir, - expectedOutput: "foo", - }, - { - params: []string{"--terragrunt-quiet", "/bin/bash", "-c", "echo foo"}, - configPath: homeDir, - expectedOutput: "foo", - }, - { - params: []string{"--terragrunt-global-cache", "/bin/bash", "-c", "echo foo"}, - configPath: homeDir, - expectedOutput: "foo", - }, - { - params: []string{"--terragrunt-global-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"}, - configPath: homeDir, - expectedOutput: "foo", - }, - { - params: []string{"--terragrunt-quiet", "--terragrunt-global-cache", "/bin/bash", "-c", "echo foo"}, - configPath: homeDir, - expectedOutput: "foo", - }, - { - params: []string{"--terragrunt-no-cache", "/bin/bash", "-c", "echo foo"}, - configPath: homeDir, - expectedOutput: "foo", - }, - { - params: []string{"--terragrunt-no-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"}, - configPath: homeDir, - expectedOutput: "foo", - }, - { - params: []string{"--terragrunt-quiet", "--terragrunt-no-cache", "/bin/bash", "-c", "echo foo"}, - configPath: homeDir, - expectedOutput: "foo", - }, - { - params: []string{"--terragrunt-no-cache", "--terragrunt-global-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"}, - configPath: homeDir, - expectErr: func(t *testing.T, err error) { - t.Helper() - require.ErrorAs(t, err, new(config.ConflictingRunCmdCacheOptionsError)) - }, - }, - { - params: []string{"--terragrunt-global-cache", "--terragrunt-no-cache", "--terragrunt-quiet", "/bin/bash", "-c", "echo foo"}, - configPath: homeDir, - expectErr: func(t *testing.T, err error) { - t.Helper() - require.ErrorAs(t, err, new(config.ConflictingRunCmdCacheOptionsError)) - }, - }, - { - configPath: homeDir, - expectErr: func(t *testing.T, err error) { - t.Helper() - require.ErrorAs(t, err, new(config.EmptyStringNotAllowedError)) - }, - }, - } - for _, tc := range testCases { - t.Run(tc.configPath, func(t *testing.T) { - t.Parallel() - - l := logger.CreateLogger() - ctx, pctx := newTestParsingContext(t, tc.configPath) - - actualOutput, actualErr := config.RunCommand(ctx, pctx, l, vexec.NewOSExec(), tc.params) - if tc.expectErr != nil { - require.Error(t, actualErr) - tc.expectErr(t, actualErr) - - return - } - - require.NoError(t, actualErr) - assert.Equal(t, tc.expectedOutput, actualOutput) - }) - } -} - func absPath(t *testing.T, path string) string { t.Helper() @@ -1711,7 +1599,7 @@ func TestRunCommandOptionsOnlyArityRegression(t *testing.T) { ctx, pctx := newTestParsingContext(t, "") require.NotPanics(t, func() { - _, err := config.RunCommand(ctx, pctx, l, vexec.NewOSExec(), tc.params) + _, err := config.RunCommand(ctx, pctx, l, tc.params) require.Error(t, err, "must return error when only option flags are supplied (%v)", tc.params) require.ErrorAs(t, err, new(config.EmptyStringNotAllowedError)) }, "run_cmd with options-only %v must not panic", tc.params) diff --git a/pkg/config/config_partial.go b/pkg/config/config_partial.go index f65eef7a79..09e1408a89 100644 --- a/pkg/config/config_partial.go +++ b/pkg/config/config_partial.go @@ -10,7 +10,6 @@ import ( "github.com/gruntwork-io/terragrunt/internal/experiment" "github.com/gruntwork-io/terragrunt/internal/remotestate" "github.com/gruntwork-io/terragrunt/internal/util" - "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/huandu/go-clone" @@ -128,7 +127,7 @@ type terragruntEngine struct { func DecodeBaseBlocks(ctx context.Context, pctx *ParsingContext, l log.Logger, file *hclparse.File, includeFromChild *IncludeConfig) (*DecodedBaseBlocks, error) { var errs []error - evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, vexec.NewOSExec(), file.ConfigPath) + evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { return nil, err } @@ -438,7 +437,7 @@ func PartialParseConfig(ctx context.Context, pctx *ParsingContext, l log.Logger, pctx.DecodedDependencies = &dynamicVal } - evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, vexec.NewOSExec(), file.ConfigPath) + evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { return nil, err } diff --git a/pkg/config/dependency.go b/pkg/config/dependency.go index 8978488aa7..5450476b0f 100644 --- a/pkg/config/dependency.go +++ b/pkg/config/dependency.go @@ -41,7 +41,6 @@ import ( "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tf" "github.com/gruntwork-io/terragrunt/internal/util" - "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "go.opentelemetry.io/otel/attribute" @@ -230,7 +229,7 @@ func outputLocksFromContext(ctx context.Context) *util.KeyLocks { // // consider whether or not the implementation of the cyclic dependency detection still makes sense. func decodeAndRetrieveOutputs(ctx context.Context, pctx *ParsingContext, l log.Logger, file *hclparse.File) (*cty.Value, error) { - evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, vexec.NewOSExec(), file.ConfigPath) + evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { return nil, err } @@ -1147,6 +1146,7 @@ func resolveOutputJSON(ctx context.Context, pctx *ParsingContext, l log.Logger, if err = creds.NewGetter().ObtainAndUpdateEnvIfNecessary( ctx, l, + pctx.Venv.Exec, pctx.Env, externalcmd.NewProvider(l, pctx.AuthProviderCmd, shellRunOptsFromPctx(pctx)), amazonsts.NewProvider(l, mergedIAM, pctx.Env), @@ -1244,7 +1244,7 @@ func getTerragruntOutputJSONFromInitFolder( bareCtx := tf.ContextWithTerraformCommandHook(ctx, nil) - out, err := tf.RunCommandWithOutput(bareCtx, l, vexec.NewOSExec(), tfRunOpts, tf.CommandNameOutput, "-json") + out, err := tf.RunCommandWithOutput(bareCtx, l, pctx.Venv.Exec, tfRunOpts, tf.CommandNameOutput, "-json") if err != nil { return nil, err } @@ -1309,6 +1309,7 @@ func getTerragruntOutputJSONFromRemoteState( if err = creds.NewGetter().ObtainAndUpdateEnvIfNecessary( ctx, l, + pctx.Venv.Exec, pctx.Env, externalcmd.NewProvider(l, pctx.AuthProviderCmd, shellRunOptsFromPctx(pctx)), amazonsts.NewProvider(l, mergedIAM, pctx.Env), @@ -1373,7 +1374,7 @@ func getTerragruntOutputJSONFromRemoteState( // Now that the backend is initialized, run terraform output to get the data and return it. bareCtx := tf.ContextWithTerraformCommandHook(ctx, nil) - out, err := tf.RunCommandWithOutput(bareCtx, l, vexec.NewOSExec(), tfRunOpts, tf.CommandNameOutput, "-json") + out, err := tf.RunCommandWithOutput(bareCtx, l, pctx.Venv.Exec, tfRunOpts, tf.CommandNameOutput, "-json") if err != nil { return nil, err } @@ -1493,6 +1494,7 @@ func runTerragruntOutputJSON(ctx context.Context, pctx *ParsingContext, l log.Lo if err = credsGetter.ObtainAndUpdateEnvIfNecessary( ctx, l, + pctx.Venv.Exec, pctx.Env, externalcmd.NewProvider(l, pctx.AuthProviderCmd, shellRunOptsFromPctx(pctx)), ); err != nil { @@ -1534,7 +1536,7 @@ func runTerragruntOutputJSON(ctx context.Context, pctx *ParsingContext, l log.Lo runOpts.AuthProviderCmd = pctx.AuthProviderCmd runOpts.CASCloneDepth = pctx.CASCloneDepth - err = run.Run(ctx, l, runOpts, report.NewReport(), runCfg, credsGetter) + err = run.Run(ctx, l, run.FromRoot(pctx.Venv), runOpts, report.NewReport(), runCfg, credsGetter) if err != nil { return nil, err } @@ -1628,7 +1630,7 @@ func runTerraformInitForDependencyOutput(ctx context.Context, pctx *ParsingConte bareCtx := tf.ContextWithTerraformCommandHook(ctx, nil) - if err := tf.RunCommand(bareCtx, l, vexec.NewOSExec(), initRunOpts, tf.CommandNameInit, "-get=false"); err != nil { + if err := tf.RunCommand(bareCtx, l, pctx.Venv.Exec, initRunOpts, tf.CommandNameInit, "-get=false"); err != nil { l.Debugf("Ignoring expected error from dependency init call") l.Debugf("Init call stderr:") l.Debugf("%s", stderr.String()) @@ -1676,7 +1678,7 @@ func foldSiblingAutoIncludeDeps(ctx context.Context, pctx *ParsingContext, l log autoPctx = autoPctx.WithLocals(baseBlocks.Locals) } - evalCtx, err := createTerragruntEvalContext(ctx, autoPctx, l, vexec.NewOSExec(), autoIncludePath) + evalCtx, err := createTerragruntEvalContext(ctx, autoPctx, l, autoIncludePath) if err != nil { return nil, err } diff --git a/pkg/config/early_stack_eval.go b/pkg/config/early_stack_eval.go index 6c0f16ce63..5037a28bb6 100644 --- a/pkg/config/early_stack_eval.go +++ b/pkg/config/early_stack_eval.go @@ -7,7 +7,6 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/function" - "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/log" ) @@ -36,7 +35,7 @@ func EarlyStackParseFunctions(ctx context.Context, l log.Logger, baseDir string, return nil, err } - evalCtx, err := createTerragruntEvalContext(ctx, scoped, l, vexec.NewOSExec(), stackFilePath) + evalCtx, err := createTerragruntEvalContext(ctx, scoped, l, stackFilePath) if err != nil { return nil, err } diff --git a/pkg/config/exclude.go b/pkg/config/exclude.go index 14fd558e5a..562f8f70d0 100644 --- a/pkg/config/exclude.go +++ b/pkg/config/exclude.go @@ -8,7 +8,6 @@ import ( "errors" "github.com/gruntwork-io/terragrunt/internal/runner/runcfg" - "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/zclconf/go-cty/cty" @@ -79,7 +78,7 @@ func evaluateExcludeBlocks(ctx context.Context, pctx *ParsingContext, l log.Logg return nil, err } - evalCtx, err := createTerragruntEvalContext(ctx, pctx, l, vexec.NewOSExec(), file.ConfigPath) + evalCtx, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { l.Errorf("Failed to create eval context %s", file.ConfigPath) return nil, err diff --git a/pkg/config/fuzz_test.go b/pkg/config/fuzz_test.go index 55c13907f6..d636956fec 100644 --- a/pkg/config/fuzz_test.go +++ b/pkg/config/fuzz_test.go @@ -7,7 +7,9 @@ import ( "sync/atomic" "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/require" @@ -126,9 +128,10 @@ func FuzzHCLRunCommand(f *testing.F) { ctx, pctx := newTestParsingContext(t, "") pctx.Writers.Writer = io.Discard pctx.Writers.ErrWriter = io.Discard + pctx.Venv = venv.Venv{FS: vfs.NewMemMapFS(), Exec: memExec} l := logger.CreateLogger() - out, err := config.RunCommand(ctx, pctx, l, memExec, argsForCall) + out, err := config.RunCommand(ctx, pctx, l, argsForCall) stripped, conflict := strippedRunCmdArgs(original) diff --git a/pkg/config/locals.go b/pkg/config/locals.go index b46b152612..db1018a7a7 100644 --- a/pkg/config/locals.go +++ b/pkg/config/locals.go @@ -13,7 +13,6 @@ import ( "errors" "github.com/gruntwork-io/terragrunt/internal/util" - "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" ) @@ -120,7 +119,7 @@ func attemptEvaluateLocals( pctx.Locals = &localsAsCtyVal - evalCtx, err := createTerragruntEvalContext(ctx, pctx, l, vexec.NewOSExec(), file.ConfigPath) + evalCtx, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { l.Errorf("Could not convert include to the execution ctx to evaluate additional locals in file %s", file.ConfigPath) return nil, evaluatedLocals, false, err diff --git a/pkg/config/parsing_context.go b/pkg/config/parsing_context.go index cd1872ac47..6308aa4932 100644 --- a/pkg/config/parsing_context.go +++ b/pkg/config/parsing_context.go @@ -21,6 +21,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tfimpl" "github.com/gruntwork-io/terragrunt/internal/util" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/internal/writer" "github.com/gruntwork-io/terragrunt/pkg/config/hclparse" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -39,6 +40,12 @@ const ( type ParsingContext struct { Writers writer.Writers + // Venv is the virtualized environment used by HCL helper functions + // that shell out (e.g. get_repo_root) or evaluate dependency outputs. + // Defaults to the OS-backed environment when [NewParsingContext] is + // called; callers with a threaded root Venv set it before parsing. + Venv venv.Venv + TerraformCliArgs *iacargs.IacArgs TrackInclude *TrackInclude EngineConfig *engine.EngineConfig @@ -112,6 +119,7 @@ func NewParsingContext(ctx context.Context, l log.Logger, opts ...Option) (conte pctx := &ParsingContext{ TerraformCliArgs: iacargs.New(), FilesRead: NewFilesRead(), + Venv: venv.OSVenv(), } for _, opt := range opts { diff --git a/pkg/config/stack.go b/pkg/config/stack.go index 7b515d8575..360acadb1c 100644 --- a/pkg/config/stack.go +++ b/pkg/config/stack.go @@ -15,7 +15,6 @@ import ( "github.com/gruntwork-io/terragrunt/internal/strict" "github.com/gruntwork-io/terragrunt/internal/telemetry" "github.com/gruntwork-io/terragrunt/internal/tf" - "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/internal/vfs" "github.com/gruntwork-io/terragrunt/pkg/log" @@ -150,7 +149,7 @@ func GenerateStackFile(ctx context.Context, l log.Logger, pctx *ParsingContext, } // Production eval context (functions + caller variables) for the phased parser. The parser populates `local.*`, `unit.*`, `stack.*` itself. - prodEvalCtx, evalCtxErr := createTerragruntEvalContext(ctx, scopedPctx, scopedLogger, vexec.NewOSExec(), stackFilePath) + prodEvalCtx, evalCtxErr := createTerragruntEvalContext(ctx, scopedPctx, scopedLogger, stackFilePath) if evalCtxErr != nil { return AutoIncludeParserStageError{Stage: "eval-context", File: stackFilePath, Err: evalCtxErr} } @@ -791,7 +790,7 @@ func ParseStackConfig(ctx context.Context, l log.Logger, parser *ParsingContext, return nil, err } - evalParsingContext, err := createTerragruntEvalContext(ctx, parser, l, vexec.NewOSExec(), file.ConfigPath) + evalParsingContext, err := createTerragruntEvalContext(ctx, parser, l, file.ConfigPath) if err != nil { return nil, err } @@ -1288,7 +1287,7 @@ func ReadValues(ctx context.Context, pctx *ParsingContext, l log.Logger, directo return nil, err } - evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, vexec.NewOSExec(), file.ConfigPath) + evalParsingContext, err := createTerragruntEvalContext(ctx, pctx, l, file.ConfigPath) if err != nil { return nil, err } diff --git a/test/benchmarks/helpers/helpers.go b/test/benchmarks/helpers/helpers.go index a409e258a1..d5954f059d 100644 --- a/test/benchmarks/helpers/helpers.go +++ b/test/benchmarks/helpers/helpers.go @@ -10,6 +10,7 @@ import ( "time" "github.com/gruntwork-io/terragrunt/internal/cli" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/pkg/log" "github.com/gruntwork-io/terragrunt/pkg/options" "github.com/gruntwork-io/terragrunt/test/helpers/logger" @@ -36,7 +37,7 @@ func RunTerragruntCommand(b *testing.B, args ...string) { l := logger.CreateLogger().WithOptions(log.WithOutput(io.Discard)) - app := cli.NewApp(l, opts) + app := cli.NewApp(l, opts, venv.OSVenv()) ctx := log.ContextWithLogger(b.Context(), l) diff --git a/test/helpers/package.go b/test/helpers/package.go index 8402cafc3e..5220fe811d 100644 --- a/test/helpers/package.go +++ b/test/helpers/package.go @@ -48,6 +48,7 @@ import ( "github.com/gruntwork-io/terragrunt/internal/cli" "github.com/gruntwork-io/terragrunt/internal/runner/run" "github.com/gruntwork-io/terragrunt/internal/util" + "github.com/gruntwork-io/terragrunt/internal/venv" "github.com/gruntwork-io/terragrunt/internal/version" "github.com/gruntwork-io/terragrunt/internal/vexec" "github.com/gruntwork-io/terragrunt/pkg/options" @@ -1132,7 +1133,7 @@ func RunTerragruntCommandWithContext( log.WithFormatter(format.NewFormatter(format.NewPrettyFormatPlaceholders())), ) - app := cli.NewApp(l, opts) + app := cli.NewApp(l, opts, venv.OSVenv()) ctx = log.ContextWithLogger(ctx, l)