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

feat: -detailed-exitcode with run-all commands #3585

Merged
merged 3 commits into from
Nov 21, 2024
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
24 changes: 15 additions & 9 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"os"

"github.com/gruntwork-io/terragrunt/cli"
Expand All @@ -14,30 +15,35 @@ import (

// The main entrypoint for Terragrunt
func main() {
var exitCode shell.DetailedExitCode

ctx := context.Background()
ctx = shell.ContextWithDetailedExitCode(ctx, &exitCode)

opts := options.NewTerragruntOptions()
parseAndSetLogEnvs(opts)

defer errors.Recover(checkForErrorsAndExit(opts.Logger))
defer errors.Recover(checkForErrorsAndExit(opts.Logger, exitCode.Get()))

app := cli.NewApp(opts)
err := app.Run(os.Args)
err := app.RunContext(ctx, os.Args)

checkForErrorsAndExit(opts.Logger)(err)
checkForErrorsAndExit(opts.Logger, exitCode.Get())(err)
}

// If there is an error, display it in the console and exit with a non-zero exit code. Otherwise, exit 0.
func checkForErrorsAndExit(logger log.Logger) func(error) {
func checkForErrorsAndExit(logger log.Logger, exitCode int) func(error) {
return func(err error) {
if err == nil {
os.Exit(0)
os.Exit(exitCode)
} else {
logger.Error(err.Error())
logger.Debug(errors.ErrorStack(err))

// exit with the underlying error code
exitCode, exitCodeErr := util.GetExitCode(err)
exitCoder, exitCodeErr := util.GetExitCode(err)
if exitCodeErr != nil {
exitCode = 1
exitCoder = 1

logger.Errorf("Unable to determine underlying exit code, so Terragrunt will exit with error code 1")
}
Expand All @@ -46,7 +52,7 @@ func checkForErrorsAndExit(logger log.Logger) func(error) {
logger.Errorf("Suggested fixes: \n%s", explain)
}

os.Exit(exitCode)
os.Exit(exitCoder)
}
}
}
Expand All @@ -56,7 +62,7 @@ func parseAndSetLogEnvs(opts *options.TerragruntOptions) {
level, err := log.ParseLevel(levelStr)
if err != nil {
err := errors.Errorf("Could not parse log level from environment variable %s=%s, %w", commands.TerragruntLogLevelEnvName, levelStr, err)
checkForErrorsAndExit(opts.Logger)(err)
checkForErrorsAndExit(opts.Logger, 0)(err)
}

opts.Logger.SetOptions(log.WithLevel(level))
Expand Down
19 changes: 18 additions & 1 deletion shell/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import (

const (
TerraformCommandContextKey ctxKey = iota
RunCmdCacheContextKey ctxKey = iota
RunCmdCacheContextKey
DetailedExitCodeContextKey

runCmdCacheName = "runCmdCache"
)
Expand All @@ -37,3 +38,19 @@ func TerraformCommandHookFromContext(ctx context.Context) RunShellCommandFunc {

return nil
}

// ContextWithDetailedExitCode returns a new context containing the given DetailedExitCode.
func ContextWithDetailedExitCode(ctx context.Context, detailedExitCode *DetailedExitCode) context.Context {
return context.WithValue(ctx, DetailedExitCodeContextKey, detailedExitCode)
}

// DetailedExitCodeFromContext returns DetailedExitCode if the give context contains it.
func DetailedExitCodeFromContext(ctx context.Context) *DetailedExitCode {
if val := ctx.Value(DetailedExitCodeContextKey); val != nil {
if val, ok := val.(*DetailedExitCode); ok {
return val
}
}

return nil
}
37 changes: 37 additions & 0 deletions shell/detailed_exitcode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package shell

import (
"sync"
)

const (
DetailedExitCodeError = 1
)

// DetailedExitCode is the TF detailed exit code. https://opentofu.org/docs/cli/commands/plan/
type DetailedExitCode struct {
Code int
mu sync.RWMutex
}

// Get returns exit code.
func (coder *DetailedExitCode) Get() int {
coder.mu.RLock()
defer coder.mu.RUnlock()

return coder.Code
}

// Set sets the newCode if the previous value is not 1 and the new value is greater than the previous one.
func (coder *DetailedExitCode) Set(newCode int) {
coder.mu.Lock()
defer coder.mu.Unlock()

if coder.Code == DetailedExitCodeError {
return
}

if coder.Code < newCode || newCode == DetailedExitCodeError {
coder.Code = newCode
}
}
15 changes: 14 additions & 1 deletion shell/run_shell_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,20 @@ func RunTerraformCommandWithOutput(ctx context.Context, opts *options.Terragrunt
return nil, err
}

return RunShellCommandWithOutput(ctx, opts, "", false, needsPTY, opts.TerraformPath, args...)
output, err := RunShellCommandWithOutput(ctx, opts, "", false, needsPTY, opts.TerraformPath, args...)

if err != nil && util.ListContainsElement(args, terraform.FlagNameDetailedExitCode) {
code, _ := util.GetExitCode(err)
if exitCode := DetailedExitCodeFromContext(ctx); exitCode != nil {
exitCode.Set(code)
}

if code != 1 {
return output, nil
}
}

return output, err
}

// RunShellCommand runs the given shell command.
Expand Down
11 changes: 6 additions & 5 deletions terraform/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ const (
CommandNameShow = "show"
CommandNameVersion = "version"

FlagNameHelpLong = "-help"
FlagNameHelpShort = "-h"
FlagNameVersion = "-version"
FlagNameJSON = "-json"
FlagNameNoColor = "-no-color"
FlagNameDetailedExitCode = "-detailed-exitcode"
FlagNameHelpLong = "-help"
FlagNameHelpShort = "-h"
FlagNameVersion = "-version"
FlagNameJSON = "-json"
FlagNameNoColor = "-no-color"
// `apply -destroy` is alias for `destroy`
FlagNameDestroy = "-destroy"

Expand Down
4 changes: 4 additions & 0 deletions test/fixtures/detailed-exitcode/changes/app1/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
resource "local_file" "example" {
content = "Test"
filename = "${path.module}/example.txt"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Intentionally empty
4 changes: 4 additions & 0 deletions test/fixtures/detailed-exitcode/changes/app2/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
resource "local_file" "example" {
content = "Test"
filename = "${path.module}/example.txt"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Intentionally empty
3 changes: 3 additions & 0 deletions test/fixtures/detailed-exitcode/error/app1/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
data "local_file" "read_not_existing_file" {
filename = "${path.module}/not-existing-file.txt"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Intentionally empty
4 changes: 4 additions & 0 deletions test/fixtures/detailed-exitcode/error/app2/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
resource "local_file" "example" {
content = "Test"
filename = "${path.module}/example.txt"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Intentionally empty
1 change: 0 additions & 1 deletion test/fixtures/exit-code/main.tf

This file was deleted.

1 change: 0 additions & 1 deletion test/fixtures/exit-code/terragrunt.hcl

This file was deleted.

22 changes: 17 additions & 5 deletions test/helpers/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,7 @@ func RemoveFolder(t *testing.T, path string) {
}
}

func RunTerragruntCommand(t *testing.T, command string, writer io.Writer, errwriter io.Writer) error {
func RunTerragruntCommandWithContext(t *testing.T, ctx context.Context, command string, writer io.Writer, errwriter io.Writer) error {
t.Helper()

args := splitCommand(command)
Expand All @@ -732,9 +732,15 @@ func RunTerragruntCommand(t *testing.T, command string, writer io.Writer, errwri
t.Log(args)

opts := options.NewTerragruntOptionsWithWriters(writer, errwriter)
app := cli.NewApp(opts)
app := cli.NewApp(opts) //nolint:contextcheck

return app.RunContext(ctx, args)
}

func RunTerragruntCommand(t *testing.T, command string, writer io.Writer, errwriter io.Writer) error {
t.Helper()

return app.Run(args)
return RunTerragruntCommandWithContext(t, context.Background(), command, writer, errwriter)
}

func RunTerragruntVersionCommand(t *testing.T, ver string, command string, writer io.Writer, errwriter io.Writer) error {
Expand All @@ -761,18 +767,24 @@ func LogBufferContentsLineByLine(t *testing.T, out bytes.Buffer, label string) {
}
}

func RunTerragruntCommandWithOutput(t *testing.T, command string) (string, string, error) {
func RunTerragruntCommandWithOutputWithContext(t *testing.T, ctx context.Context, command string) (string, string, error) {
t.Helper()

stdout := bytes.Buffer{}
stderr := bytes.Buffer{}
err := RunTerragruntCommand(t, command, &stdout, &stderr)
err := RunTerragruntCommandWithContext(t, ctx, command, &stdout, &stderr)
LogBufferContentsLineByLine(t, stdout, "stdout")
LogBufferContentsLineByLine(t, stderr, "stderr")

return stdout.String(), stderr.String(), err
}

func RunTerragruntCommandWithOutput(t *testing.T, command string) (string, string, error) {
t.Helper()

return RunTerragruntCommandWithOutputWithContext(t, context.Background(), command)
}

func RunTerragruntRedirectOutput(t *testing.T, command string, writer io.Writer, errwriter io.Writer) {
t.Helper()

Expand Down
96 changes: 81 additions & 15 deletions test/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package test_test

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
Expand Down Expand Up @@ -49,7 +50,6 @@ const (
testFixtureEmptyState = "fixtures/empty-state/"
testFixtureEnvVarsBlockPath = "fixtures/env-vars-block/"
testFixtureExcludesFile = "fixtures/excludes-file"
testFixtureExitCode = "fixtures/exit-code"
testFixtureExternalDependence = "fixtures/external-dependencies"
testFixtureExternalDependency = "fixtures/external-dependency/"
testFixtureExtraArgsPath = "fixtures/extra-args/"
Expand Down Expand Up @@ -98,6 +98,7 @@ const (
testFixtureErrorPrint = "fixtures/error-print"
testFixtureBufferModuleOutput = "fixtures/buffer-module-output"
testFixtureDependenciesOptimisation = "fixtures/dependency-optimisation"
testFixtureDetailedExitCode = "fixtures/detailed-exitcode"

terraformFolder = ".terraform"

Expand All @@ -107,6 +108,85 @@ const (
terragruntCache = ".terragrunt-cache"
)

func TestDetailedExitCodeError(t *testing.T) {
t.Parallel()

testFixturePath := filepath.Join(testFixtureDetailedExitCode, "error")

helpers.CleanupTerraformFolder(t, testFixturePath)
tmpEnvPath := helpers.CopyEnvironment(t, testFixturePath)
rootPath := util.JoinPath(tmpEnvPath, testFixturePath)

var exitCode shell.DetailedExitCode
ctx := context.Background()
ctx = shell.ContextWithDetailedExitCode(ctx, &exitCode)

_, stderr, err := helpers.RunTerragruntCommandWithOutputWithContext(t, ctx, "terragrunt run-all plan --terragrunt-log-level debug --terragrunt-non-interactive -detailed-exitcode --terragrunt-working-dir "+rootPath)
require.Error(t, err)
assert.Contains(t, stderr, "not-existing-file.txt: no such file or directory")
assert.Equal(t, 1, exitCode.Get())
}

func TestDetailedExitCodeChangesPresentAll(t *testing.T) {
t.Parallel()

testFixturePath := filepath.Join(testFixtureDetailedExitCode, "changes")

helpers.CleanupTerraformFolder(t, testFixturePath)
tmpEnvPath := helpers.CopyEnvironment(t, testFixturePath)
rootPath := util.JoinPath(tmpEnvPath, testFixturePath)

var exitCode shell.DetailedExitCode
ctx := context.Background()
ctx = shell.ContextWithDetailedExitCode(ctx, &exitCode)

_, _, err := helpers.RunTerragruntCommandWithOutputWithContext(t, ctx, "terragrunt run-all plan --terragrunt-log-level debug --terragrunt-non-interactive -detailed-exitcode --terragrunt-working-dir "+rootPath)
require.NoError(t, err)
assert.Equal(t, 2, exitCode.Get())
}

func TestDetailedExitCodeChangesPresentOne(t *testing.T) {
t.Parallel()

testFixturePath := filepath.Join(testFixtureDetailedExitCode, "changes")

helpers.CleanupTerraformFolder(t, testFixturePath)
tmpEnvPath := helpers.CopyEnvironment(t, testFixturePath)
rootPath := util.JoinPath(tmpEnvPath, testFixturePath)

var exitCode shell.DetailedExitCode
ctx := context.Background()
ctx = shell.ContextWithDetailedExitCode(ctx, &exitCode)

_, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all apply --terragrunt-log-level debug --terragrunt-non-interactive --terragrunt-working-dir "+filepath.Join(rootPath, "app1"))
require.NoError(t, err)

_, _, err = helpers.RunTerragruntCommandWithOutputWithContext(t, ctx, "terragrunt run-all plan --terragrunt-log-level debug --terragrunt-non-interactive -detailed-exitcode --terragrunt-working-dir "+rootPath)
require.NoError(t, err)
assert.Equal(t, 2, exitCode.Get())
}

func TestDetailedExitCodeNoChanges(t *testing.T) {
t.Parallel()

testFixturePath := filepath.Join(testFixtureDetailedExitCode, "changes")

helpers.CleanupTerraformFolder(t, testFixturePath)
tmpEnvPath := helpers.CopyEnvironment(t, testFixturePath)
rootPath := util.JoinPath(tmpEnvPath, testFixturePath)

var exitCode shell.DetailedExitCode
ctx := context.Background()
ctx = shell.ContextWithDetailedExitCode(ctx, &exitCode)

_, _, err := helpers.RunTerragruntCommandWithOutput(t, "terragrunt run-all apply --terragrunt-log-level debug --terragrunt-non-interactive --terragrunt-working-dir "+rootPath)
require.NoError(t, err)

_, _, err = helpers.RunTerragruntCommandWithOutputWithContext(t, ctx, "terragrunt run-all plan --terragrunt-log-level debug --terragrunt-non-interactive -detailed-exitcode --terragrunt-working-dir "+rootPath)
require.NoError(t, err)
assert.Equal(t, 0, exitCode.Get())
}

func TestLogCustomFormatOutput(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -795,20 +875,6 @@ func TestInvalidSource(t *testing.T) {
assert.True(t, ok)
}

// Run terragrunt plan -detailed-exitcode on a folder with some uncreated resources and make sure that you get an exit
// code of "2", which means there are changes to apply.
func TestExitCode(t *testing.T) {
t.Parallel()

rootPath := helpers.CopyEnvironment(t, testFixtureExitCode)
modulePath := util.JoinPath(rootPath, testFixtureExitCode)
err := helpers.RunTerragruntCommand(t, "terragrunt plan -detailed-exitcode --terragrunt-non-interactive --terragrunt-working-dir "+modulePath, os.Stdout, os.Stderr)

exitCode, exitCodeErr := util.GetExitCode(err)
require.NoError(t, exitCodeErr)
assert.Equal(t, 2, exitCode)
}

func TestPlanfileOrder(t *testing.T) {
t.Parallel()

Expand Down