Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add --terragrunt-include-module-prefix option #2493

Merged
merged 3 commits into from
Apr 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cli/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions cli/cli_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -81,6 +82,7 @@ var allTerragruntBooleanOpts = []string{
optTerragruntFetchDependencyOutputFromState,
optTerragruntUsePartialParseConfigCache,
optTerragruntOutputWithMetadata,
optTerragruntIncludeModulePrefix,
}
var allTerragruntStringOpts = []string{
optTerragruntConfig,
Expand Down Expand Up @@ -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}}
Expand Down
4 changes: 4 additions & 0 deletions configstack/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
8 changes: 8 additions & 0 deletions docs/_docs/04_reference/cli-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noticed that terragrunt-include-module-prefix is not listed in the help output

$ terragrunt --help | grep terragrunt-include-module-prefix
$

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Included the info in help output. Is there any other place the help should be updated?

**Environment Variable**: `TERRAGRUNT_INCLUDE_MODULE_PREFIX` (set to `true`)

When this flag is set output from Terraform sub-commands is prefixed with module path.
10 changes: 10 additions & 0 deletions options/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
},
Expand Down Expand Up @@ -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,
}
}

Expand Down
13 changes: 11 additions & 2 deletions shell/run_shell_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
}
Expand Down Expand Up @@ -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.
Expand Down
65 changes: 45 additions & 20 deletions shell/run_shell_cmd_output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
37 changes: 37 additions & 0 deletions test/integration_include_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
37 changes: 37 additions & 0 deletions util/prefix-writer.go
Original file line number Diff line number Diff line change
@@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering why output should be buffered and not directly written to pf.writer?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not want to call the underlying pf.writer for each character as it may degrade performance.

This buffering does not cross single .Write() call span so it will not create a "typical" buffered i/o effect.

Still, it needs an additional memory and one copying of the input []byte. If you feel there is more efficient way I would be more than happy to apply it.


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
}
79 changes: 79 additions & 0 deletions util/prefix-writer_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}