diff --git a/cli/args.go b/cli/args.go index 012628f7d..3b1a7b468 100644 --- a/cli/args.go +++ b/cli/args.go @@ -156,6 +156,11 @@ func parseTerragruntOptionsFromArgs(terragruntVersion string, args []string, wri opts.Debug = true } + includeModulePrefix := parseBooleanArg(args, optTerragruntIncludeModulePrefix, os.Getenv("TERRAGRUNT_INCLUDE_MODULE_PREFIX") == "true" || os.Getenv("TERRAGRUNT_INCLUDE_MODULE_PREFIX") == "1") + if includeModulePrefix { + opts.IncludeModulePrefix = true + } + opts.RunAllAutoApprove = !parseBooleanArg(args, optTerragruntNoAutoApprove, os.Getenv("TERRAGRUNT_AUTO_APPROVE") == "false") var parallelism int diff --git a/cli/cli_app.go b/cli/cli_app.go index 3fa78e652..6e85a4eb1 100644 --- a/cli/cli_app.go +++ b/cli/cli_app.go @@ -62,6 +62,7 @@ const ( optTerragruntModulesThatInclude = "terragrunt-modules-that-include" optTerragruntFetchDependencyOutputFromState = "terragrunt-fetch-dependency-output-from-state" optTerragruntUsePartialParseConfigCache = "terragrunt-use-partial-parse-config-cache" + optTerragruntIncludeModulePrefix = "terragrunt-include-module-prefix" optTerragruntOutputWithMetadata = "with-metadata" ) @@ -81,6 +82,7 @@ var allTerragruntBooleanOpts = []string{ optTerragruntFetchDependencyOutputFromState, optTerragruntUsePartialParseConfigCache, optTerragruntOutputWithMetadata, + optTerragruntIncludeModulePrefix, } var allTerragruntStringOpts = []string{ optTerragruntConfig, @@ -263,6 +265,7 @@ GLOBAL OPTIONS: terragrunt-strict-validate Sets strict mode for the validate-inputs command. By default, strict mode is off. When this flag is passed, strict mode is turned on. When strict mode is turned off, the validate-inputs command will only return an error if required inputs are missing from all input sources (env vars, var files, etc). When strict mode is turned on, an error will be returned if required inputs are missing OR if unused variables are passed to Terragrunt. terragrunt-json-out The file path that terragrunt should use when rendering the terragrunt.hcl config as json. Only used in the render-json command. Defaults to terragrunt_rendered.json. terragrunt-use-partial-parse-config-cache Enables caching of includes during partial parsing operations. Will also be used for the --terragrunt-iam-role option if provided. + terragrunt-include-module-prefix When this flag is set output from Terraform sub-commands is prefixed with module path. VERSION: {{.Version}}{{if len .Authors}} diff --git a/configstack/module.go b/configstack/module.go index 29ca5428c..c44ea071f 100644 --- a/configstack/module.go +++ b/configstack/module.go @@ -377,6 +377,10 @@ func resolveTerraformModule(terragruntConfigPath string, terragruntOptions *opti return nil, nil } + if opts.IncludeModulePrefix { + opts.OutputPrefix = fmt.Sprintf("[%v] ", modulePath) + } + return &TerraformModule{Path: modulePath, Config: *terragruntConfig, TerragruntOptions: opts}, nil } diff --git a/docs/_docs/04_reference/cli-options.md b/docs/_docs/04_reference/cli-options.md index cc76ccb62..c51b8d5de 100644 --- a/docs/_docs/04_reference/cli-options.md +++ b/docs/_docs/04_reference/cli-options.md @@ -501,6 +501,7 @@ prefix `--terragrunt-` (e.g., `--terragrunt-config`). The currently available op - [terragrunt-modules-that-include](#terragrunt-modules-that-include) - [terragrunt-fetch-dependency-output-from-state](#terragrunt-fetch-dependency-output-from-state) - [terragrunt-use-partial-parse-config-cache](#terragrunt-use-partial-parse-config-cache) +- [terragrunt-include-module-prefix](#terragrunt-include-module-prefix) ### terragrunt-config @@ -912,3 +913,10 @@ Currently only AWS S3 backend is supported. This flag can be used to drastically decrease time required for parsing Terragrunt files. The effect will only show if a lot of similar includes are expected such as the root terragrunt.hcl include. NOTE: This is an experimental feature, use with caution. + +### terragrunt-include-module-prefix + +**CLI Arg**: `--terragrunt-include-module-prefix` +**Environment Variable**: `TERRAGRUNT_INCLUDE_MODULE_PREFIX` (set to `true`) + +When this flag is set output from Terraform sub-commands is prefixed with module path. diff --git a/options/options.go b/options/options.go index 1c20e1492..bf82fd7d5 100644 --- a/options/options.go +++ b/options/options.go @@ -198,6 +198,12 @@ type TerragruntOptions struct { // Include fields metadata in render-json RenderJsonWithMetadata bool + + // Prefix for shell commands' outputs + OutputPrefix string + + // Controls if a module prefix will be prepended to TF outputs + IncludeModulePrefix bool } // IAMOptions represents options that are used by Terragrunt to assume an IAM role. @@ -277,6 +283,8 @@ func NewTerragruntOptions(terragruntConfigPath string) (*TerragruntOptions, erro Check: false, FetchDependencyOutputFromState: false, UsePartialParseConfigCache: false, + OutputPrefix: "", + IncludeModulePrefix: false, RunTerragrunt: func(terragruntOptions *TerragruntOptions) error { return errors.WithStackTrace(RunTerragruntCommandNotSet) }, @@ -373,6 +381,8 @@ func (terragruntOptions *TerragruntOptions) Clone(terragruntConfigPath string) * CheckDependentModules: terragruntOptions.CheckDependentModules, FetchDependencyOutputFromState: terragruntOptions.FetchDependencyOutputFromState, UsePartialParseConfigCache: terragruntOptions.UsePartialParseConfigCache, + OutputPrefix: terragruntOptions.OutputPrefix, + IncludeModulePrefix: terragruntOptions.IncludeModulePrefix, } } diff --git a/shell/run_shell_cmd.go b/shell/run_shell_cmd.go index 4e55063af..748dcee8f 100644 --- a/shell/run_shell_cmd.go +++ b/shell/run_shell_cmd.go @@ -73,6 +73,7 @@ func RunShellCommandWithOutput( var errWriter = terragruntOptions.ErrWriter var outWriter = terragruntOptions.Writer + var prefix = terragruntOptions.OutputPrefix // Terragrunt can run some commands (such as terraform remote config) before running the actual terraform // command requested by the user. The output of these other commands should not end up on stdout as this // breaks scripts relying on terraform's output. @@ -87,10 +88,10 @@ func RunShellCommandWithOutput( } // Inspired by https://blog.kowalczyk.info/article/wOYk/advanced-command-execution-in-go-with-osexec.html - cmdStderr := io.MultiWriter(errWriter, &stderrBuf) + cmdStderr := io.MultiWriter(withPrefix(errWriter, prefix), &stderrBuf) var cmdStdout io.Writer if !suppressStdout { - cmdStdout = io.MultiWriter(outWriter, &stdoutBuf) + cmdStdout = io.MultiWriter(withPrefix(outWriter, prefix), &stdoutBuf) } else { cmdStdout = io.MultiWriter(&stdoutBuf) } @@ -172,6 +173,14 @@ func GetExitCode(err error) (int, error) { return 0, err } +func withPrefix(writer io.Writer, prefix string) io.Writer { + if prefix == "" { + return writer + } + + return util.PrefixedWriter(writer, prefix) +} + type SignalsForwarder chan os.Signal // Forwards signals to a command, waiting for the command to finish. diff --git a/shell/run_shell_cmd_output_test.go b/shell/run_shell_cmd_output_test.go index 34ee059d8..6cf3afbab 100644 --- a/shell/run_shell_cmd_output_test.go +++ b/shell/run_shell_cmd_output_test.go @@ -28,7 +28,32 @@ func TestCommandOutputOrder(t *testing.T) { }) } +func noop[T any](t T) {} + +var FULL_OUTPUT = []string{"stdout1", "stderr1", "stdout2", "stderr2", "stderr3"} +var STDOUT = []string{"stdout1", "stdout2"} +var STDERR = []string{"stderr1", "stderr2", "stderr3"} + func testCommandOutputOrder(t *testing.T, withPtty bool) { + testCommandOutput(t, noop[*options.TerragruntOptions], assertOutputs(t, FULL_OUTPUT, STDOUT, STDERR)) +} + +func TestCommandOutputPrefix(t *testing.T) { + prefix := "PREFIX> " + prefixedOutput := []string{} + for _, line := range FULL_OUTPUT { + prefixedOutput = append(prefixedOutput, prefix+line) + } + testCommandOutput(t, func(terragruntOptions *options.TerragruntOptions) { + terragruntOptions.OutputPrefix = prefix + }, assertOutputs(t, + prefixedOutput, + STDOUT, + STDERR, + )) +} + +func testCommandOutput(t *testing.T, withOptions func(*options.TerragruntOptions), assertResults func(string, *CmdOutput)) { terragruntOptions, err := options.NewTerragruntOptionsForTest("") require.NoError(t, err, "Unexpected error creating NewTerragruntOptionsForTest: %v", err) @@ -40,32 +65,32 @@ func testCommandOutputOrder(t *testing.T, withPtty bool) { terragruntOptions.TerraformCliArgs = append(terragruntOptions.TerraformCliArgs, "same") + withOptions(terragruntOptions) + out, err := RunShellCommandWithOutput(terragruntOptions, "", false, false, "../testdata/test_outputs.sh", "same") require.NotNil(t, out, "Should get output") assert.Nil(t, err, "Should have no error") - allOutputs := strings.Split(strings.TrimSpace(allOutputBuffer.String()), "\n") - - require.True(t, len(allOutputs) == 5, "Expected 5 entries, but got %d: %v", len(allOutputs), allOutputs) - assert.Equal(t, "stdout1", allOutputs[0], "First one from stdout") - assert.Equal(t, "stderr1", allOutputs[1], "First one from stderr") - assert.Equal(t, "stdout2", allOutputs[2], "Second one from stdout") - assert.Equal(t, "stderr2", allOutputs[3], "Second one from stderr") - assert.Equal(t, "stderr3", allOutputs[4], "Third one from stderr") - - stdOutputs := strings.Split(strings.TrimSpace(out.Stdout), "\n") - - require.True(t, len(stdOutputs) == 2, "Expected 2 entries, but got %d: %v", len(stdOutputs), stdOutputs) - assert.Equal(t, "stdout1", stdOutputs[0], "First one from stdout") - assert.Equal(t, "stdout2", stdOutputs[1], "Second one from stdout") - - stdErrs := strings.Split(strings.TrimSpace(out.Stderr), "\n") + assertResults(allOutputBuffer.String(), out) +} - require.True(t, len(stdErrs) == 3, "Expected 3 entries, but got %d: %v", len(stdErrs), stdErrs) - assert.Equal(t, "stderr1", stdErrs[0], "First one from stderr") - assert.Equal(t, "stderr2", stdErrs[1], "Second one from stderr") - assert.Equal(t, "stderr3", stdErrs[2], "Second one from stderr") +func assertOutputs( + t *testing.T, + expectedAllOutputs []string, + expectedStdOutputs []string, + expectedStdErrs []string, +) func(string, *CmdOutput) { + return func(allOutput string, out *CmdOutput) { + allOutputs := strings.Split(strings.TrimSpace(allOutput), "\n") + assert.Equal(t, expectedAllOutputs, allOutputs) + + stdOutputs := strings.Split(strings.TrimSpace(out.Stdout), "\n") + assert.Equal(t, expectedStdOutputs, stdOutputs) + + stdErrs := strings.Split(strings.TrimSpace(out.Stderr), "\n") + assert.Equal(t, expectedStdErrs, stdErrs) + } } // A goroutine-safe bytes.Buffer diff --git a/test/integration_include_test.go b/test/integration_include_test.go index facfa666e..60a016c96 100644 --- a/test/integration_include_test.go +++ b/test/integration_include_test.go @@ -115,6 +115,43 @@ func TestTerragruntRunAllModulesThatIncludeRestrictsSet(t *testing.T) { assert.NotContains(t, planOutput, "charlie") } +func TestTerragruntRunAllModulesWithPrefix(t *testing.T) { + t.Parallel() + + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + err := runTerragruntCommand( + t, + fmt.Sprintf( + "terragrunt run-all plan --terragrunt-non-interactive --terragrunt-include-module-prefix --terragrunt-working-dir %s", + includeRunAllFixturePath, + ), + &stdout, + &stderr, + ) + require.NoError(t, err) + logBufferContentsLineByLine(t, stdout, "stdout") + logBufferContentsLineByLine(t, stderr, "stderr") + + planOutput := stdout.String() + assert.Contains(t, planOutput, "alpha") + assert.Contains(t, planOutput, "beta") + assert.Contains(t, planOutput, "charlie") + + stdoutLines := strings.Split(planOutput, "\n") + for _, line := range stdoutLines { + if strings.Contains(line, "alpha") { + assert.Contains(t, line, includeRunAllFixturePath+"a") + } + if strings.Contains(line, "beta") { + assert.Contains(t, line, includeRunAllFixturePath+"b") + } + if strings.Contains(line, "charlie") { + assert.Contains(t, line, includeRunAllFixturePath+"c") + } + } +} + func TestTerragruntWorksWithIncludeDeepMerge(t *testing.T) { t.Parallel() diff --git a/util/prefix-writer.go b/util/prefix-writer.go new file mode 100644 index 000000000..f95422e0b --- /dev/null +++ b/util/prefix-writer.go @@ -0,0 +1,37 @@ +package util + +import ( + "bytes" + "io" +) + +func PrefixedWriter(writer io.Writer, prefix string) io.Writer { + return &prefixedWriter{writer: writer, prefix: prefix, beginningOfANewLine: true} +} + +func (pf *prefixedWriter) Write(p []byte) (int, error) { + buf := bytes.Buffer{} + + for _, b := range p { + if pf.beginningOfANewLine { + buf.WriteString(pf.prefix) + pf.beginningOfANewLine = false + } + + buf.WriteByte(b) + + pf.beginningOfANewLine = b == '\n' + } + + n, err := pf.writer.Write(buf.Bytes()) + if n > len(p) { + n = len(p) + } + return n, err +} + +type prefixedWriter struct { + writer io.Writer + prefix string + beginningOfANewLine bool +} diff --git a/util/prefix-writer_test.go b/util/prefix-writer_test.go new file mode 100644 index 000000000..f0c510ef4 --- /dev/null +++ b/util/prefix-writer_test.go @@ -0,0 +1,79 @@ +package util + +import ( + "bytes" + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPrefixWriter(t *testing.T) { + t.Parallel() + + testCases := []struct { + prefix string + values []string + expected string + }{ + {"p1 ", []string{"a", "b"}, "p1 ab"}, + {"p2 ", []string{"a", "b"}, "p2 ab"}, + + {"", []string{"a", "b"}, "ab"}, + + {"p1 ", []string{"a", "b\n"}, "p1 ab\n"}, + {"p1 ", []string{"a\n", "b"}, "p1 a\np1 b"}, + {"p1 ", []string{"a\n", "b\n"}, "p1 a\np1 b\n"}, + {"p1 ", []string{"a", "b", "c", "def"}, "p1 abcdef"}, + {"p1 ", []string{"a", "b\n", "c", "def"}, "p1 ab\np1 cdef"}, + {"p1 ", []string{"a", "b\nc", "def"}, "p1 ab\np1 cdef"}, + {"p1 ", []string{"ab", "cd", "ef", "gh\n"}, "p1 abcdefgh\n"}, + {"p1 ", []string{"ab", "cd\n", "ef", "gh\n"}, "p1 abcd\np1 efgh\n"}, + {"p1 ", []string{"ab", "cd", "e\nf", "gh\n"}, "p1 abcde\np1 fgh\n"}, + {"p1 ", []string{"ab", "cd", "ef\n", "gh\n"}, "p1 abcdef\np1 gh\n"}, + {"p1 ", []string{"ab\ncd\nef\ngh\n"}, "p1 ab\np1 cd\np1 ef\np1 gh\n"}, + {"p1 ", []string{"ab\n\n\ngh\n"}, "p1 ab\np1 \np1 \np1 gh\n"}, + + {"p1 ", []string{""}, ""}, + {"p1 ", []string{"\n"}, "p1 \n"}, + {"p1\n", []string{"\n"}, "p1\n\n"}, + } + + for _, testCase := range testCases { + var b bytes.Buffer + pw := PrefixedWriter(&b, testCase.prefix) + for _, input := range testCase.values { + written, err := pw.Write([]byte(input)) + assert.NoError(t, err) + assert.Equal(t, written, len(input)) + } + assert.Equal(t, testCase.expected, b.String()) + } +} + +type FailingWriter struct{} + +func (fw *FailingWriter) Write(b []byte) (int, error) { + return 0, errors.New("write failed") +} + +func TestPrefixWriterFail(t *testing.T) { + t.Parallel() + + testCases := []struct { + prefix string + values []string + expected string + }{ + {"p1 ", []string{"a", "b"}, "p1 ab"}, + } + + for _, testCase := range testCases { + pw := PrefixedWriter(&FailingWriter{}, testCase.prefix) + for _, input := range testCase.values { + written, err := pw.Write([]byte(input)) + assert.Error(t, err) + assert.Equal(t, written, 0) + } + } +}