Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
82271c1
fix(output): normalize carriage-return progress updates
zack-is-cool Aug 3, 2026
d5d22cb
fix(terraform): coordinate concurrent terminal output
zack-is-cool Aug 3, 2026
c277448
fix(output): suppress concurrent terminal control output
zack-is-cool Aug 4, 2026
c84d434
fix(terraform): serialize concurrent UI output
zack-is-cool Aug 4, 2026
6ef9368
fix(output): honor late spinner suppression
zack-is-cool Aug 4, 2026
ad0b61d
fix(output): silence concurrent lookup progress
zack-is-cool Aug 4, 2026
fb9077c
Merge branch 'main' into fix/concurrent-terminal-output
osterman Aug 4, 2026
0ecff7a
test(terraform): cover concurrent spinner suppression
zack-is-cool Aug 4, 2026
f58d89c
fix(output): synchronize spinner line clearing
zack-is-cool Aug 4, 2026
29f7703
Merge branch 'main' into fix/concurrent-terminal-output
osterman Aug 4, 2026
450a75d
fix(terraform): silence concurrent workdir provisioning
zack-is-cool Aug 4, 2026
af63a4c
fix(terraform): silence source provisioning in parallel output
zack-is-cool Aug 4, 2026
8cf80fb
fix(terraform): serialize concurrent hook output
zack-is-cool Aug 4, 2026
f9953e5
test(terraform): strengthen concurrent hook output coverage
zack-is-cool Aug 4, 2026
48d1d95
Merge branch 'main' into fix/concurrent-terminal-output
osterman Aug 4, 2026
3139899
fix(hooks): serialize concurrent summary output
zack-is-cool Aug 4, 2026
e2317bf
Merge remote-tracking branch 'refs/remotes/fork/fix/concurrent-termin…
zack-is-cool Aug 4, 2026
66593b1
test(hooks): allow styled terminal summaries
zack-is-cool Aug 4, 2026
db34774
Merge branch 'main' into fix/concurrent-terminal-output
osterman Aug 5, 2026
220dace
Merge branch 'main' into fix/concurrent-terminal-output
aknysh Aug 5, 2026
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: 20 additions & 4 deletions cmd/helmfile/helmfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,28 @@ type helmfileNodeHooks struct {
}

// Before implements schema.ComponentNodeHooks.
func (n *helmfileNodeHooks) Before(_ context.Context, info *schema.ConfigAndStacksInfo) error {
func (n *helmfileNodeHooks) Before(ctx context.Context, info *schema.ConfigAndStacksInfo) error {
return n.BeforeWithWriters(ctx, info, schema.ComponentNodeHookWriters{})
}

// BeforeWithWriters implements schema.ComponentNodeHooksWithOutput.
func (n *helmfileNodeHooks) BeforeWithWriters(_ context.Context, info *schema.ConfigAndStacksInfo, writers schema.ComponentNodeHookWriters) error {
n.called = true
atmosConfig, err := cfg.InitCliConfig(*info, true)
if err != nil {
log.Warn("CI hook config init failed", "component", info.ComponentFromArg, "error", err)
return nil // Config errors surface on the real execution path, not here.
}
return n.runUserHooks(&atmosConfig, info, n.beforeEvent, h.Outcome{Status: h.RunSuccess})
return n.runUserHooksWithWriters(&atmosConfig, info, n.beforeEvent, h.Outcome{Status: h.RunSuccess}, writers)
}

// After implements schema.ComponentNodeHooks.
func (n *helmfileNodeHooks) After(_ context.Context, info *schema.ConfigAndStacksInfo, output string, execErr error) error {
func (n *helmfileNodeHooks) After(ctx context.Context, info *schema.ConfigAndStacksInfo, output string, execErr error) error {
return n.AfterWithWriters(ctx, info, output, execErr, schema.ComponentNodeHookWriters{})
}

// AfterWithWriters implements schema.ComponentNodeHooksWithOutput.
func (n *helmfileNodeHooks) AfterWithWriters(_ context.Context, info *schema.ConfigAndStacksInfo, output string, execErr error, writers schema.ComponentNodeHookWriters) error {
n.called = true
atmosConfig, err := cfg.InitCliConfig(*info, true)
if err != nil {
Expand All @@ -126,7 +136,7 @@ func (n *helmfileNodeHooks) After(_ context.Context, info *schema.ConfigAndStack
if execErr != nil {
outcome = h.Outcome{Status: h.RunFailure, Err: execErr, ExitCode: errUtils.GetExitCode(execErr)}
}
hookErr := n.runUserHooks(&atmosConfig, info, n.afterEvent, outcome)
hookErr := n.runUserHooksWithWriters(&atmosConfig, info, n.afterEvent, outcome, writers)

if err := h.RunCIHooks(&h.RunCIHooksOptions{
Event: n.afterEvent,
Expand All @@ -147,6 +157,10 @@ func (n *helmfileNodeHooks) After(_ context.Context, info *schema.ConfigAndStack
// verbatim: RunAll already resolves each hook's on_failure mode internally
// (applyOnFailure) — a non-nil return specifically means on_failure: fail.
func (n *helmfileNodeHooks) runUserHooks(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, event h.HookEvent, outcome h.Outcome) error {
return n.runUserHooksWithWriters(atmosConfig, info, event, outcome, schema.ComponentNodeHookWriters{})
}

func (n *helmfileNodeHooks) runUserHooksWithWriters(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, event h.HookEvent, outcome h.Outcome, writers schema.ComponentNodeHookWriters) error {
if event == "" {
return nil
}
Expand All @@ -157,6 +171,8 @@ func (n *helmfileNodeHooks) runUserHooks(atmosConfig *schema.AtmosConfiguration,
Cmd: n.cmd,
Args: n.args,
Outcome: outcome,
Stdout: writers.Stdout,
Stderr: writers.Stderr,
})
}

Expand Down
24 changes: 20 additions & 4 deletions cmd/terraform/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,12 @@ type terraformNodeHooks struct {
}

// Before implements schema.ComponentNodeHooks.
func (n *terraformNodeHooks) Before(_ context.Context, info *schema.ConfigAndStacksInfo) error {
func (n *terraformNodeHooks) Before(ctx context.Context, info *schema.ConfigAndStacksInfo) error {
return n.BeforeWithWriters(ctx, info, schema.ComponentNodeHookWriters{})
}

// BeforeWithWriters implements schema.ComponentNodeHooksWithOutput.
func (n *terraformNodeHooks) BeforeWithWriters(_ context.Context, info *schema.ConfigAndStacksInfo, writers schema.ComponentNodeHookWriters) error {
defer perf.Track(nil, "terraform.terraformNodeHooks.Before")()

injectLastAuthContext(info)
Expand All @@ -526,11 +531,16 @@ func (n *terraformNodeHooks) Before(_ context.Context, info *schema.ConfigAndSta
// identity-aware store hooks (for example, after-apply output publishing)
// do not fall back to ambient credentials.
injectHookStoreAuthResolver(&atmosConfig, info)
return n.runUserHooksForNode(&atmosConfig, info, n.beforeEvent, h.Outcome{Status: h.RunSuccess})
return n.runUserHooksForNodeWithWriters(&atmosConfig, info, n.beforeEvent, h.Outcome{Status: h.RunSuccess}, writers)
}

// After implements schema.ComponentNodeHooks.
func (n *terraformNodeHooks) After(_ context.Context, info *schema.ConfigAndStacksInfo, output string, execErr error) error {
func (n *terraformNodeHooks) After(ctx context.Context, info *schema.ConfigAndStacksInfo, output string, execErr error) error {
return n.AfterWithWriters(ctx, info, output, execErr, schema.ComponentNodeHookWriters{})
}

// AfterWithWriters implements schema.ComponentNodeHooksWithOutput.
func (n *terraformNodeHooks) AfterWithWriters(_ context.Context, info *schema.ConfigAndStacksInfo, output string, execErr error, writers schema.ComponentNodeHookWriters) error {
defer perf.Track(nil, "terraform.terraformNodeHooks.After")()

injectLastAuthContext(info)
Expand All @@ -547,7 +557,7 @@ func (n *terraformNodeHooks) After(_ context.Context, info *schema.ConfigAndStac
if execErr != nil {
outcome = h.Outcome{Status: h.RunFailure, Err: execErr, ExitCode: errUtils.GetExitCode(execErr)}
}
hookErr := n.runUserHooksForNode(&atmosConfig, info, n.afterEvent, outcome)
hookErr := n.runUserHooksForNodeWithWriters(&atmosConfig, info, n.afterEvent, outcome, writers)

if !n.skipPerNodeCI {
n.runCIHooksForNode(&atmosConfig, info, output, execErr)
Expand Down Expand Up @@ -575,6 +585,10 @@ func injectLastAuthContext(info *schema.ConfigAndStacksInfo) {
// verbatim: RunAll already resolves each hook's on_failure mode internally
// (applyOnFailure) — a non-nil return specifically means on_failure: fail.
func (n *terraformNodeHooks) runUserHooksForNode(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, event h.HookEvent, outcome h.Outcome) error {
return n.runUserHooksForNodeWithWriters(atmosConfig, info, event, outcome, schema.ComponentNodeHookWriters{})
}

func (n *terraformNodeHooks) runUserHooksForNodeWithWriters(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, event h.HookEvent, outcome h.Outcome, writers schema.ComponentNodeHookWriters) error {
if event == "" {
return nil
}
Expand All @@ -585,6 +599,8 @@ func (n *terraformNodeHooks) runUserHooksForNode(atmosConfig *schema.AtmosConfig
Cmd: n.cmd,
Args: n.args,
Outcome: outcome,
Stdout: writers.Stdout,
Stderr: writers.Stderr,
})
}

Expand Down
44 changes: 32 additions & 12 deletions pkg/hooks/command_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -157,6 +158,8 @@ type subprocessPrep struct {
binary string
args []string
env []string
stdout io.Writer
stderr io.Writer
// dir is the component directory the hook runs from. It is deliberately
// separate from ATMOS_COMPONENT_PATH so tools that use relative paths also
// operate on the same component Terraform uses.
Expand Down Expand Up @@ -228,6 +231,8 @@ func prepareSubprocess(ctx *ExecContext, tmpDir, outputFile string) (*subprocess
binary: resolved,
args: args,
env: env,
stdout: ctx.Stdout,
stderr: ctx.Stderr,
dir: existingComponentDir(ctx),
captureStdoutPath: captureStdoutPath,
}, nil
Expand Down Expand Up @@ -259,7 +264,10 @@ func existingComponentDir(ctx *ExecContext) string {
func runSubprocess(p *subprocessPrep) error {
cmd := exec.Command(p.binary, p.args...) // #nosec G204 -- intentional: this is the whole point of a hook
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
cmd.Stderr = p.stderr
if cmd.Stderr == nil {
cmd.Stderr = os.Stderr
}
cmd.Env = p.env
cmd.Dir = p.dir

Expand All @@ -271,7 +279,10 @@ func runSubprocess(p *subprocessPrep) error {
defer f.Close()
cmd.Stdout = f
} else {
cmd.Stdout = os.Stdout
cmd.Stdout = p.stdout
if cmd.Stdout == nil {
cmd.Stdout = os.Stdout
}
}

return cmd.Run()
Expand Down Expand Up @@ -323,26 +334,35 @@ func captureOutput(ctx *ExecContext, outputFile string) *Output {
return out
}

// renderTerminal emits the hook's user-facing output: a styled
// markdown block via ui.MarkdownMessage when there's a summary body or
// a markdown-formatted artifact. The leading blank line visually
// separates the rendered block from preceding output (terraform plan,
// the hook log line, the tool's own stdout). MarkdownMessage's renderer
// (glamour) trims leading whitespace, so we emit the blank line as a
// separate UI write rather than relying on a `\n` prefix in the body.
// renderTerminal emits a styled markdown block for a hook summary or
// markdown-formatted artifact. When a node writer is supplied, it writes the
// rendered block through that writer so concurrent hook output stays prefixed
// and serialized.
func renderTerminal(ctx *ExecContext, out *Output) {
if out == nil {
return
}
if out.Summary != nil && out.Summary.Body != "" {
ui.Writeln("")
ui.MarkdownMessage(out.Summary.Body)
renderTerminalMarkdown(ctx, out.Summary.Body)
return
}
if out.Artifact != nil && ctx.Hook.Format == FormatMarkdown {
renderTerminalMarkdown(ctx, string(out.Artifact.Body))
}
}

func renderTerminalMarkdown(ctx *ExecContext, content string) {
if ctx == nil || ctx.Stderr == nil || ui.Format == nil {
ui.Writeln("")
ui.MarkdownMessage(string(out.Artifact.Body))
ui.MarkdownMessage(content)
return
}

rendered, err := ui.Format.Markdown(content)
if err != nil {
rendered = content
}
_, _ = fmt.Fprint(ctx.Stderr, "\n"+rendered)
}

func startHookLogGroup(ctx *ExecContext) func() {
Expand Down
51 changes: 51 additions & 0 deletions pkg/hooks/command_engine_test.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
package hooks

import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"

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

errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/pkg/ci"
iolib "github.com/cloudposse/atmos/pkg/io"
"github.com/cloudposse/atmos/pkg/schema"
)

Expand Down Expand Up @@ -187,6 +190,54 @@ func TestCommandEngine_NoCaptureStdoutLeavesOutputFileEmpty(t *testing.T) {
assert.Nil(t, out.Artifact, "without CaptureStdout, stdout must not be written to the output file")
}

func TestCommandEngine_RoutesSubprocessOutputToContextWriters(t *testing.T) {
exe := testExePath(t)
terraformDir := t.TempDir()
require.NoError(t, os.Mkdir(filepath.Join(terraformDir, "test-component"), 0o755))

var stdout bytes.Buffer
var stderr bytes.Buffer
kind := &Kind{Name: "command", OnFailure: OnFailureWarn, Engine: &CommandEngine{}}
ctx := &ExecContext{
Hook: kind.ResolveDefaults(&Hook{
Kind: "command",
Command: exe,
Args: []string{"-test.run", "^$"},
Env: map[string]string{
"_ATMOS_TEST_ECHO_STDOUT": "1",
"_ATMOS_TEST_STDOUT_BODY": "hook progress\rhook complete\n",
"_ATMOS_TEST_ECHO_STDERR": "1",
"_ATMOS_TEST_STDERR_BODY": "hook warning\n",
},
}),
Kind: kind,
AtmosConfig: &schema.AtmosConfiguration{
TerraformDirAbsolutePath: terraformDir,
},
Info: &schema.ConfigAndStacksInfo{Stack: "test-stack", ComponentFromArg: "test-component"},
Stdout: &stdout,
Stderr: &stderr,
}

_, err := ctx.Kind.Engine.Run(ctx)
require.NoError(t, err)
assert.Equal(t, "hook progress\rhook complete\n", stdout.String())
assert.Equal(t, "hook warning\n", stderr.String())
}

func TestRenderTerminalRoutesSummaryToContextStderr(t *testing.T) {
var stderr bytes.Buffer
writer := iolib.NewLinePrefixWriter("test/component", &stderr, &sync.Mutex{})

renderTerminal(&ExecContext{Stderr: writer}, &Output{
Summary: &Summary{Body: "**hook summary**\n"},
})
require.NoError(t, writer.Flush())

assert.Contains(t, stderr.String(), "[test/component] ")
assert.Contains(t, stderr.String(), "hook summary")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestRunSubprocess_CaptureStdoutCreateFailurePropagates(t *testing.T) {
exe := testExePath(t)
prep := &subprocessPrep{
Expand Down
12 changes: 12 additions & 0 deletions pkg/hooks/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package hooks
import (
"errors"
"fmt"
"io"
"strings"

log "github.com/cloudposse/atmos/pkg/logger"
Expand Down Expand Up @@ -60,6 +61,8 @@ type Hooks struct {
// toolchainPATH is the PATH fragment containing toolchain-installed
// binary directories. Populated by preflight; consumed by CommandEngine.
toolchainPATH string
stdout io.Writer
stderr io.Writer

// outcome is the lifecycle operation result (success/failure) for the next
// RunAll, set by SetOutcome. Zero value defaults to success.
Expand Down Expand Up @@ -253,6 +256,8 @@ func (h *Hooks) runResolvedHook(name string, kind *Kind, executionHook *Hook, ct
HookName: name,
Outcome: ctx.outcome,
ToolchainPATH: h.toolchainPATH,
Stdout: h.stdout,
Stderr: h.stderr,
}
return runHookLogGroup(ctx.atmosConfig, ci.DimensionPhase, hookLogGroupLabel(name, ctx.event), func() error {
_, err := kind.Engine.Run(execCtx)
Expand Down Expand Up @@ -965,6 +970,11 @@ type RunPerComponentHooksOptions struct {
// Outcome is the lifecycle outcome (success/failure) used to filter `when:`
// and expose status to hook engines. Zero value defaults to success.
Outcome Outcome

// Stdout and Stderr receive hook subprocess output. Nil preserves the
// process streams used by single-component execution.
Stdout io.Writer
Stderr io.Writer
}

// RunPerComponentHooks resolves and runs one component's user-defined hooks
Expand All @@ -991,6 +1001,8 @@ func RunPerComponentHooks(opts *RunPerComponentHooksOptions) error {
}

hooksForComponent.SetOutcome(opts.Outcome)
hooksForComponent.stdout = opts.Stdout
hooksForComponent.stderr = opts.Stderr
log.Info("Running hooks", "event", opts.Event, logKeyStatus, opts.Outcome.Status,
"component", opts.Info.ComponentFromArg, "stack", opts.Info.Stack)
return hooksForComponent.RunAll(opts.Event, opts.AtmosConfig, opts.Info, opts.Cmd, opts.Args)
Expand Down
5 changes: 5 additions & 0 deletions pkg/hooks/kind.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package hooks

import (
"io"
"sort"
"sync"

Expand Down Expand Up @@ -57,6 +58,10 @@ type ExecContext struct {
// so the installed pinned versions take precedence over the operator's
// PATH. Empty when the component declares no hook dependencies.
ToolchainPATH string
// Stdout and Stderr receive subprocess output when a concurrent caller
// supplies serialized component writers. Nil uses the process streams.
Stdout io.Writer
Stderr io.Writer

// OutputFile is the temp file path the tool wrote structured output to.
// Populated by CommandEngine before calling ResultHandler.
Expand Down
10 changes: 8 additions & 2 deletions pkg/hooks/kinds/tfmigrate/kind.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,14 @@ func (e *Engine) Run(ctx *hooks.ExecContext) (*hooks.Output, error) {
}
cmd := exec.Command(atmosBin, args...) // #nosec G204,G702 -- intentional nested Atmos invocation.
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdout = ctx.Stdout
cmd.Stderr = ctx.Stderr
if cmd.Stdout == nil {
cmd.Stdout = os.Stdout
}
if cmd.Stderr == nil {
cmd.Stderr = os.Stderr
}
cmd.Env = append(os.Environ(), "ATMOS_SKIP_HOOKS=*")
if err := cmd.Run(); err != nil {
// ApplyOnFailure resolves ctx.Hook.OnFailure ("warn"/"ignore"/"fail",
Expand Down
7 changes: 7 additions & 0 deletions pkg/hooks/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
// os.Stdout, then exit 0. Lets tests simulate a tool that emits structured
// output to stdout (e.g. tflint --format=sarif) so the engine's
// CaptureStdout redirect can be verified cross-platform via os.Executable().
// - _ATMOS_TEST_ECHO_STDERR: write the value of _ATMOS_TEST_STDERR_BODY to
// os.Stderr, then exit 0. Lets tests verify subprocess stderr routing.
// - _ATMOS_TEST_WRITE_CWD: write the subprocess working directory and
// ATMOS_COMPONENT_PATH to ATMOS_OUTPUT_FILE, separated by a newline.
func TestMain(m *testing.M) {
Expand All @@ -41,6 +43,11 @@ func TestMain(m *testing.M) {
}
if os.Getenv("_ATMOS_TEST_ECHO_STDOUT") == "1" {
fmt.Fprint(os.Stdout, os.Getenv("_ATMOS_TEST_STDOUT_BODY"))
}
if os.Getenv("_ATMOS_TEST_ECHO_STDERR") == "1" {
fmt.Fprint(os.Stderr, os.Getenv("_ATMOS_TEST_STDERR_BODY"))
}
if os.Getenv("_ATMOS_TEST_ECHO_STDOUT") == "1" || os.Getenv("_ATMOS_TEST_ECHO_STDERR") == "1" {
os.Exit(0)
}
if os.Getenv("_ATMOS_TEST_WRITE_CWD") == "1" {
Expand Down
Loading
Loading