Skip to content
25 changes: 25 additions & 0 deletions cmd/terraform/testmain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package terraform

import (
"os"
"testing"
)

// TestMain is the entry point for the cmd/terraform test binary.
// It intercepts subprocess-helper env vars before any test runs, enabling
// tests to use the test binary itself as a portable cross-platform subprocess
// (no Unix-only binaries required).
//
// Supported env vars:
//
// _ATMOS_TEST_EXIT_ONE=1 — if set, exit 1 immediately so the parent process
// observes a non-zero exit code without invoking
// any actual test. Used by the ExitCodeError
// wrapping-contract test in
// utils_exit_wrapping_test.go.
func TestMain(m *testing.M) {
if os.Getenv("_ATMOS_TEST_EXIT_ONE") == "1" {
os.Exit(1)
}
os.Exit(m.Run())
}
32 changes: 29 additions & 3 deletions cmd/terraform/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,18 @@ func runHooksOnErrorWithOutput(event h.HookEvent, cmd_ *cobra.Command, args []st
forceCIMode = viper.GetBool("ci")
}

if err := h.RunCIHooks(event, &atmosConfig, &info, output, forceCIMode, cmdErr); err != nil {
// Extract the exit code from the command error. errUtils.GetExitCode unwraps
// the error chain (exec.ExitError, ExecError, exitCoder, etc.) and returns 1
// by default for non-nil errors with no attached code (e.g., auth failures).
if err := h.RunCIHooks(&h.RunCIHooksOptions{
Event: event,
AtmosConfig: &atmosConfig,
Info: &info,
Output: output,
ForceCIMode: forceCIMode,
CommandError: cmdErr,
ExitCode: errUtils.GetExitCode(cmdErr),
}); err != nil {
Comment thread
osterman marked this conversation as resolved.
log.Warn("CI hook execution failed", "error", err)
}
}
Expand Down Expand Up @@ -129,7 +140,14 @@ func runHooksWithOutput(event h.HookEvent, cmd_ *cobra.Command, args []string, o

// Run CI hooks based on component provider bindings.
// This is separate from user-defined hooks and runs automatically when CI is enabled.
if err := h.RunCIHooks(event, &atmosConfig, &info, output, forceCIMode, nil); err != nil {
// Success path: cmdErr is nil and exit code is 0.
if err := h.RunCIHooks(&h.RunCIHooksOptions{
Event: event,
AtmosConfig: &atmosConfig,
Info: &info,
Output: output,
ForceCIMode: forceCIMode,
}); err != nil {
log.Warn("CI hook execution failed", "error", err)
// Don't fail the command on CI hook errors.
}
Expand All @@ -153,7 +171,15 @@ func runCIHooksForDeploy(event h.HookEvent, cmd_ *cobra.Command, _ []string, inf
forceCIMode = viper.GetBool("ci")
}

if err := h.RunCIHooks(event, &atmosConfig, info, output, forceCIMode, nil); err != nil {
// Before-event hook (e.g., before.terraform.deploy): no command has run yet,
// so there is no exit code or error to report.
if err := h.RunCIHooks(&h.RunCIHooksOptions{
Event: event,
AtmosConfig: &atmosConfig,
Info: info,
Output: output,
ForceCIMode: forceCIMode,
}); err != nil {
log.Warn("CI hook execution failed", "error", err)
}
}
Expand Down
108 changes: 108 additions & 0 deletions cmd/terraform/utils_exit_wrapping_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package terraform

// utils_exit_wrapping_test.go is a regression test for the ExitCodeError
// wrapping contract at the cmd/terraform → internal/exec boundary.
//
// runHooksOnErrorWithOutput (cmd/terraform/utils.go) extracts the exit code
// from the command error via errUtils.GetExitCode(cmdErr). The CI hook
// plumbing (RunCIHooksOptions.ExitCode + CommandError) depends on the wrapper
// being intact: a future refactor that swaps the error type or unwraps
// ExitCodeError before it reaches this layer would silently degrade CI
// summaries and check runs without tripping any of the existing tests
// downstream (those tests start *after* GetExitCode has already flattened the
// chain to an int).
//
// Cross-platform approach: uses the test binary (os.Executable) with
// _ATMOS_TEST_EXIT_ONE=1 — TestMain in testmain_test.go intercepts that env
// var and calls os.Exit(1). No Unix-only binaries are required.

import (
"errors"
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

errUtils "github.com/cloudposse/atmos/errors"
e "github.com/cloudposse/atmos/internal/exec"
h "github.com/cloudposse/atmos/pkg/hooks"
"github.com/cloudposse/atmos/pkg/schema"
)

// TestExecuteShellCommand_ErrorWrapsExitCodeError_AtCmdTerraformBoundary verifies
// that the cmdErr passed into runHooksOnErrorWithOutput preserves the
// ExitCodeError wrapper. This protects the contract:
//
// ExecuteShellCommand → returns errUtils.ExitCodeError (typed)
// → ExecuteTerraform/executeSingleComponent
// → terraform RunE catches as runErr
// → runHooksOnErrorWithOutput (this layer) calls
// errUtils.GetExitCode(cmdErr) — depends on the wrapper.
//
// We assert both ends: errors.As recovers the typed error AND
// errUtils.GetExitCode extracts the wrapped code, which is exactly what
// runHooksOnErrorWithOutput does in production.
func TestExecuteShellCommand_ErrorWrapsExitCodeError_AtCmdTerraformBoundary(t *testing.T) {
exePath, err := os.Executable()
require.NoError(t, err, "os.Executable() must succeed")

atmosConfig := schema.AtmosConfiguration{}
cmdErr := e.ExecuteShellCommand(
atmosConfig,
exePath,
[]string{"-test.run=^$"}, // no test matches; TestMain exits before any test runs.
"", // dir: current working directory.
[]string{"_ATMOS_TEST_EXIT_ONE=1"}, // env: makes TestMain call os.Exit(1).
false, // dryRun: false — actually run the subprocess.
"", // redirectStdErr.
)

require.Error(t, cmdErr, "subprocess exit 1 must surface as a non-nil error")

// The error reaching runHooksOnErrorWithOutput must remain wrapped as
// ExitCodeError — this is the contract the CI hook plumbing depends on.
var exitCodeErr errUtils.ExitCodeError
require.True(t,
errors.As(cmdErr, &exitCodeErr),
"cmdErr passed into runHooksOnErrorWithOutput must satisfy errors.As(err, &errUtils.ExitCodeError{}); got %T: %v", cmdErr, cmdErr,
)
assert.Equal(t, 1, exitCodeErr.Code, "ExitCodeError.Code must equal the subprocess exit code")

// Mirror what runHooksOnErrorWithOutput does in production: extract the
// exit code via errUtils.GetExitCode. This is the consumed half of the
// contract — if GetExitCode ever stops finding ExitCodeError in the
// chain, the CI summary/check-run flow regresses to ExitCode=1 by
// default, masking real exit codes (e.g., 2 for plan -detailed-exitcode).
assert.Equal(t, 1, errUtils.GetExitCode(cmdErr),
"errUtils.GetExitCode must extract the wrapped code — runHooksOnErrorWithOutput depends on this")

// End-to-end: feed the cmdErr into the same RunCIHooks plumbing that
// runHooksOnErrorWithOutput uses, and verify the wrapper survives in
// the options struct that plugins observe. ci.enabled=false short-circuits
// before any plugin runs, so this exercises only the option construction
// + extraction path that this PR introduced.
opts := &h.RunCIHooksOptions{
Event: h.AfterTerraformPlan,
AtmosConfig: &atmosConfig, // ci.enabled is the zero value (false) → short-circuits.
Info: &schema.ConfigAndStacksInfo{Stack: "dev", ComponentFromArg: "vpc"},
Output: "",
ForceCIMode: true,
CommandError: cmdErr,
ExitCode: errUtils.GetExitCode(cmdErr),
}

// The wrapper contract must hold AT the boundary plugins see, not just at
// the cmd/terraform layer. A future refactor that copies/wraps cmdErr
// before placing it in the options would lose this property.
require.True(t,
errors.As(opts.CommandError, &exitCodeErr),
"options.CommandError must still satisfy errors.As(err, &errUtils.ExitCodeError{}); got %T", opts.CommandError,
)
assert.Equal(t, 1, opts.ExitCode, "options.ExitCode must equal the wrapped subprocess exit code")
assert.Equal(t, 1, exitCodeErr.Code)

// Confirm the round-trip is harmless when CI is disabled.
require.NoError(t, h.RunCIHooks(opts),
"RunCIHooks must short-circuit cleanly when ci.enabled=false even with a non-nil CommandError")
}
128 changes: 128 additions & 0 deletions cmd/terraform/utils_hooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package terraform

// utils_hooks_test.go covers the hook-wrapper functions in utils.go that
// build RunCIHooksOptions and forward CommandError + ExitCode into the CI
// hook plumbing. These wrappers are thin glue (ProcessCommandLineArgs →
// InitCliConfig → RunCIHooks) but contain the new CommandError/ExitCode
// forwarding lines added in this PR. The demo-stacks fixture has no
// ci.enabled config, so RunCIHooks short-circuits cleanly — these tests
// exercise option construction without invoking real plugin handlers.

import (
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"

errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/pkg/hooks"
"github.com/cloudposse/atmos/pkg/schema"
)

// newHookTestCmd constructs a cobra.Command with all the flags
// ProcessCommandLineArgs reads (base-path, config, config-path, profile,
// stack, ci, verify-plan). This lets the wrapper functions in utils.go
// progress past argument parsing and into the option-construction code
// that this PR added.
func newHookTestCmd() *cobra.Command {
cmd := &cobra.Command{Use: "plan"}
cmd.Flags().String("base-path", "", "base path")
cmd.Flags().StringSlice("config", nil, "config")
cmd.Flags().StringSlice("config-path", nil, "config path")
cmd.Flags().StringSlice("profile", nil, "profile")
cmd.Flags().String("stack", "", "stack flag")
cmd.Flags().Bool("ci", false, "ci flag")
cmd.Flags().Bool("verify-plan", false, "verify-plan flag")
return cmd
}

// TestRunHooksOnError_PreservesCommandError verifies the failure-path
// wrapper (runHooksOnError → runHooksOnErrorWithOutput) accepts a non-nil
// cmdErr and forwards it into RunCIHooksOptions without mutating it. This
// exercises the new errUtils.GetExitCode(cmdErr) call and the
// CommandError/ExitCode field assignments added in this PR.
func TestRunHooksOnError_PreservesCommandError(t *testing.T) {
t.Chdir("../../examples/demo-stacks")

cmd := newHookTestCmd()

tests := []struct {
name string
cmdErr error
wantExt int
}{
{
name: "wrapped ExitCodeError code 1",
cmdErr: errUtils.ExitCodeError{Code: 1},
wantExt: 1,
},
{
name: "wrapped ExitCodeError code 2 (plan changes detected)",
cmdErr: errUtils.ExitCodeError{Code: 2},
wantExt: 2,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Pre-condition: GetExitCode must extract the wrapped code.
// runHooksOnErrorWithOutput depends on this — if it ever stops
// working, CI summaries silently regress to ExitCode=1 by default.
assert.Equal(t, tc.wantExt, errUtils.GetExitCode(tc.cmdErr),
"GetExitCode must extract the wrapped exit code")

// runHooksOnErrorWithOutput returns void; the test asserts no
// panic, no fatal error from the option construction or the
// ci-disabled short-circuit path.
runHooksOnError(hooks.AfterTerraformPlan, cmd, []string{"--stack", "dev", "myapp"}, tc.cmdErr)
})
}
}

// TestRunHooksOnErrorWithOutput_NilCmdErr verifies the wrapper handles a
// nil cmdErr cleanly (defensive: callers should always pass a non-nil error
// on the failure path, but the wrapper must not panic if they don't).
func TestRunHooksOnErrorWithOutput_NilCmdErr(t *testing.T) {
t.Chdir("../../examples/demo-stacks")

cmd := newHookTestCmd()

// errUtils.GetExitCode(nil) returns 0 — ExitCode forwarded to plugins
// will be 0, which RunCIHooks short-circuits cleanly via the
// ci.enabled=false demo-stacks fixture.
assert.Equal(t, 0, errUtils.GetExitCode(nil))

runHooksOnErrorWithOutput(hooks.AfterTerraformPlan, cmd, []string{"--stack", "dev", "myapp"}, nil, "captured output")
}

// TestRunHooks_DemoStacks exercises the success-path wrapper (runHooks →
// runHooksWithOutput). The demo-stacks fixture has ci.enabled=false so
// RunCIHooks short-circuits cleanly inside the wrapper — the test asserts
// the wrapper completes without error.
func TestRunHooks_DemoStacks(t *testing.T) {
t.Chdir("../../examples/demo-stacks")

cmd := newHookTestCmd()
err := runHooks(hooks.BeforeTerraformPlan, cmd, []string{"--stack", "dev", "myapp"})
assert.NoError(t, err)
}

// TestRunCIHooksForDeploy_DemoStacks exercises the deploy-specific wrapper
// (runCIHooksForDeploy). Unlike the other wrappers, this one takes a
// pre-resolved info struct and skips ProcessCommandLineArgs to avoid eager
// !store YAML function resolution. The demo-stacks fixture's
// ci.enabled=false makes RunCIHooks short-circuit cleanly.
func TestRunCIHooksForDeploy_DemoStacks(t *testing.T) {
t.Chdir("../../examples/demo-stacks")

cmd := newHookTestCmd()
info := &schema.ConfigAndStacksInfo{
Stack: "dev",
ComponentFromArg: "myapp",
ComponentType: "terraform",
}

// Function returns void — the test verifies no panic on the option
// construction path with a wired info struct.
runCIHooksForDeploy(hooks.BeforeTerraformDeploy, cmd, []string{"myapp"}, info, "")
}
6 changes: 6 additions & 0 deletions pkg/ci/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ type ExecuteOptions struct {
// CommandError is the error from the command execution, if any.
// When set, check runs are updated with failure status.
CommandError error

// ExitCode is the exit code from the command execution. See HookContext.ExitCode
// for semantics. Required for accurate success/failure reporting on commands
// that fail before producing parseable terraform output (e.g., auth errors).
ExitCode int
}

// Execute runs all CI actions for a hook event.
Expand Down Expand Up @@ -153,6 +158,7 @@ func buildHookContext(opts ExecuteOptions, platform provider.Provider) *plugin.H
Info: opts.Info,
Output: opts.Output,
CommandError: opts.CommandError,
ExitCode: opts.ExitCode,
Provider: platform,
CICtx: ciCtx,
TemplateLoader: loader,
Expand Down
10 changes: 10 additions & 0 deletions pkg/ci/internal/plugin/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ type HookContext struct {
// CommandError is the error from the command execution, if any.
CommandError error

// ExitCode is the exit code from the command execution. This is the
// authoritative signal for success/failure and (for `terraform plan` with
// -detailed-exitcode) for change detection:
// - apply/deploy: 0 = success; non-zero = error.
// - plan: 0 = no changes; 1 = error; 2 = changes detected.
// Plugins should prefer this over text parsing of Output, which is
// fragile against terraform/OpenTofu output drift and against errors
// that occur before terraform itself runs (e.g., authentication failures).
ExitCode int

// Provider is the detected CI platform provider.
Provider provider.Provider

Expand Down
Loading
Loading