Skip to content

Commit 6ef7cc3

Browse files
authored
Merge branch 'main' into dependabot/github_actions/cicd-83381794f3
2 parents 02aec21 + 13bce50 commit 6ef7cc3

23 files changed

Lines changed: 825 additions & 126 deletions

cmd/helmfile/helmfile.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,18 +103,28 @@ type helmfileNodeHooks struct {
103103
}
104104

105105
// Before implements schema.ComponentNodeHooks.
106-
func (n *helmfileNodeHooks) Before(_ context.Context, info *schema.ConfigAndStacksInfo) error {
106+
func (n *helmfileNodeHooks) Before(ctx context.Context, info *schema.ConfigAndStacksInfo) error {
107+
return n.BeforeWithWriters(ctx, info, schema.ComponentNodeHookWriters{})
108+
}
109+
110+
// BeforeWithWriters implements schema.ComponentNodeHooksWithOutput.
111+
func (n *helmfileNodeHooks) BeforeWithWriters(_ context.Context, info *schema.ConfigAndStacksInfo, writers schema.ComponentNodeHookWriters) error {
107112
n.called = true
108113
atmosConfig, err := cfg.InitCliConfig(*info, true)
109114
if err != nil {
110115
log.Warn("CI hook config init failed", "component", info.ComponentFromArg, "error", err)
111116
return nil // Config errors surface on the real execution path, not here.
112117
}
113-
return n.runUserHooks(&atmosConfig, info, n.beforeEvent, h.Outcome{Status: h.RunSuccess})
118+
return n.runUserHooksWithWriters(&atmosConfig, info, n.beforeEvent, h.Outcome{Status: h.RunSuccess}, writers)
114119
}
115120

116121
// After implements schema.ComponentNodeHooks.
117-
func (n *helmfileNodeHooks) After(_ context.Context, info *schema.ConfigAndStacksInfo, output string, execErr error) error {
122+
func (n *helmfileNodeHooks) After(ctx context.Context, info *schema.ConfigAndStacksInfo, output string, execErr error) error {
123+
return n.AfterWithWriters(ctx, info, output, execErr, schema.ComponentNodeHookWriters{})
124+
}
125+
126+
// AfterWithWriters implements schema.ComponentNodeHooksWithOutput.
127+
func (n *helmfileNodeHooks) AfterWithWriters(_ context.Context, info *schema.ConfigAndStacksInfo, output string, execErr error, writers schema.ComponentNodeHookWriters) error {
118128
n.called = true
119129
atmosConfig, err := cfg.InitCliConfig(*info, true)
120130
if err != nil {
@@ -126,7 +136,7 @@ func (n *helmfileNodeHooks) After(_ context.Context, info *schema.ConfigAndStack
126136
if execErr != nil {
127137
outcome = h.Outcome{Status: h.RunFailure, Err: execErr, ExitCode: errUtils.GetExitCode(execErr)}
128138
}
129-
hookErr := n.runUserHooks(&atmosConfig, info, n.afterEvent, outcome)
139+
hookErr := n.runUserHooksWithWriters(&atmosConfig, info, n.afterEvent, outcome, writers)
130140

131141
if err := h.RunCIHooks(&h.RunCIHooksOptions{
132142
Event: n.afterEvent,
@@ -147,6 +157,10 @@ func (n *helmfileNodeHooks) After(_ context.Context, info *schema.ConfigAndStack
147157
// verbatim: RunAll already resolves each hook's on_failure mode internally
148158
// (applyOnFailure) — a non-nil return specifically means on_failure: fail.
149159
func (n *helmfileNodeHooks) runUserHooks(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, event h.HookEvent, outcome h.Outcome) error {
160+
return n.runUserHooksWithWriters(atmosConfig, info, event, outcome, schema.ComponentNodeHookWriters{})
161+
}
162+
163+
func (n *helmfileNodeHooks) runUserHooksWithWriters(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, event h.HookEvent, outcome h.Outcome, writers schema.ComponentNodeHookWriters) error {
150164
if event == "" {
151165
return nil
152166
}
@@ -157,6 +171,8 @@ func (n *helmfileNodeHooks) runUserHooks(atmosConfig *schema.AtmosConfiguration,
157171
Cmd: n.cmd,
158172
Args: n.args,
159173
Outcome: outcome,
174+
Stdout: writers.Stdout,
175+
Stderr: writers.Stderr,
160176
})
161177
}
162178

cmd/terraform/utils.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,12 @@ type terraformNodeHooks struct {
512512
}
513513

514514
// Before implements schema.ComponentNodeHooks.
515-
func (n *terraformNodeHooks) Before(_ context.Context, info *schema.ConfigAndStacksInfo) error {
515+
func (n *terraformNodeHooks) Before(ctx context.Context, info *schema.ConfigAndStacksInfo) error {
516+
return n.BeforeWithWriters(ctx, info, schema.ComponentNodeHookWriters{})
517+
}
518+
519+
// BeforeWithWriters implements schema.ComponentNodeHooksWithOutput.
520+
func (n *terraformNodeHooks) BeforeWithWriters(_ context.Context, info *schema.ConfigAndStacksInfo, writers schema.ComponentNodeHookWriters) error {
516521
defer perf.Track(nil, "terraform.terraformNodeHooks.Before")()
517522

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

532537
// After implements schema.ComponentNodeHooks.
533-
func (n *terraformNodeHooks) After(_ context.Context, info *schema.ConfigAndStacksInfo, output string, execErr error) error {
538+
func (n *terraformNodeHooks) After(ctx context.Context, info *schema.ConfigAndStacksInfo, output string, execErr error) error {
539+
return n.AfterWithWriters(ctx, info, output, execErr, schema.ComponentNodeHookWriters{})
540+
}
541+
542+
// AfterWithWriters implements schema.ComponentNodeHooksWithOutput.
543+
func (n *terraformNodeHooks) AfterWithWriters(_ context.Context, info *schema.ConfigAndStacksInfo, output string, execErr error, writers schema.ComponentNodeHookWriters) error {
534544
defer perf.Track(nil, "terraform.terraformNodeHooks.After")()
535545

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

552562
if !n.skipPerNodeCI {
553563
n.runCIHooksForNode(&atmosConfig, info, output, execErr)
@@ -575,6 +585,10 @@ func injectLastAuthContext(info *schema.ConfigAndStacksInfo) {
575585
// verbatim: RunAll already resolves each hook's on_failure mode internally
576586
// (applyOnFailure) — a non-nil return specifically means on_failure: fail.
577587
func (n *terraformNodeHooks) runUserHooksForNode(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, event h.HookEvent, outcome h.Outcome) error {
588+
return n.runUserHooksForNodeWithWriters(atmosConfig, info, event, outcome, schema.ComponentNodeHookWriters{})
589+
}
590+
591+
func (n *terraformNodeHooks) runUserHooksForNodeWithWriters(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, event h.HookEvent, outcome h.Outcome, writers schema.ComponentNodeHookWriters) error {
578592
if event == "" {
579593
return nil
580594
}
@@ -585,6 +599,8 @@ func (n *terraformNodeHooks) runUserHooksForNode(atmosConfig *schema.AtmosConfig
585599
Cmd: n.cmd,
586600
Args: n.args,
587601
Outcome: outcome,
602+
Stdout: writers.Stdout,
603+
Stderr: writers.Stderr,
588604
})
589605
}
590606

pkg/hooks/command_engine.go

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"errors"
77
"fmt"
8+
"io"
89
"os"
910
"os/exec"
1011
"path/filepath"
@@ -157,6 +158,8 @@ type subprocessPrep struct {
157158
binary string
158159
args []string
159160
env []string
161+
stdout io.Writer
162+
stderr io.Writer
160163
// dir is the component directory the hook runs from. It is deliberately
161164
// separate from ATMOS_COMPONENT_PATH so tools that use relative paths also
162165
// operate on the same component Terraform uses.
@@ -228,6 +231,8 @@ func prepareSubprocess(ctx *ExecContext, tmpDir, outputFile string) (*subprocess
228231
binary: resolved,
229232
args: args,
230233
env: env,
234+
stdout: ctx.Stdout,
235+
stderr: ctx.Stderr,
231236
dir: existingComponentDir(ctx),
232237
captureStdoutPath: captureStdoutPath,
233238
}, nil
@@ -259,7 +264,10 @@ func existingComponentDir(ctx *ExecContext) string {
259264
func runSubprocess(p *subprocessPrep) error {
260265
cmd := exec.Command(p.binary, p.args...) // #nosec G204 -- intentional: this is the whole point of a hook
261266
cmd.Stdin = os.Stdin
262-
cmd.Stderr = os.Stderr
267+
cmd.Stderr = p.stderr
268+
if cmd.Stderr == nil {
269+
cmd.Stderr = os.Stderr
270+
}
263271
cmd.Env = p.env
264272
cmd.Dir = p.dir
265273

@@ -271,7 +279,10 @@ func runSubprocess(p *subprocessPrep) error {
271279
defer f.Close()
272280
cmd.Stdout = f
273281
} else {
274-
cmd.Stdout = os.Stdout
282+
cmd.Stdout = p.stdout
283+
if cmd.Stdout == nil {
284+
cmd.Stdout = os.Stdout
285+
}
275286
}
276287

277288
return cmd.Run()
@@ -323,26 +334,35 @@ func captureOutput(ctx *ExecContext, outputFile string) *Output {
323334
return out
324335
}
325336

326-
// renderTerminal emits the hook's user-facing output: a styled
327-
// markdown block via ui.MarkdownMessage when there's a summary body or
328-
// a markdown-formatted artifact. The leading blank line visually
329-
// separates the rendered block from preceding output (terraform plan,
330-
// the hook log line, the tool's own stdout). MarkdownMessage's renderer
331-
// (glamour) trims leading whitespace, so we emit the blank line as a
332-
// separate UI write rather than relying on a `\n` prefix in the body.
337+
// renderTerminal emits a styled markdown block for a hook summary or
338+
// markdown-formatted artifact. When a node writer is supplied, it writes the
339+
// rendered block through that writer so concurrent hook output stays prefixed
340+
// and serialized.
333341
func renderTerminal(ctx *ExecContext, out *Output) {
334342
if out == nil {
335343
return
336344
}
337345
if out.Summary != nil && out.Summary.Body != "" {
338-
ui.Writeln("")
339-
ui.MarkdownMessage(out.Summary.Body)
346+
renderTerminalMarkdown(ctx, out.Summary.Body)
340347
return
341348
}
342349
if out.Artifact != nil && ctx.Hook.Format == FormatMarkdown {
350+
renderTerminalMarkdown(ctx, string(out.Artifact.Body))
351+
}
352+
}
353+
354+
func renderTerminalMarkdown(ctx *ExecContext, content string) {
355+
if ctx == nil || ctx.Stderr == nil || ui.Format == nil {
343356
ui.Writeln("")
344-
ui.MarkdownMessage(string(out.Artifact.Body))
357+
ui.MarkdownMessage(content)
358+
return
359+
}
360+
361+
rendered, err := ui.Format.Markdown(content)
362+
if err != nil {
363+
rendered = content
345364
}
365+
_, _ = fmt.Fprint(ctx.Stderr, "\n"+rendered)
346366
}
347367

348368
func startHookLogGroup(ctx *ExecContext) func() {

pkg/hooks/command_engine_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
package hooks
22

33
import (
4+
"bytes"
45
"os"
56
"path/filepath"
67
"runtime"
78
"strings"
9+
"sync"
810
"testing"
911

1012
"github.com/stretchr/testify/assert"
1113
"github.com/stretchr/testify/require"
1214

1315
errUtils "github.com/cloudposse/atmos/errors"
1416
"github.com/cloudposse/atmos/pkg/ci"
17+
iolib "github.com/cloudposse/atmos/pkg/io"
1518
"github.com/cloudposse/atmos/pkg/schema"
1619
)
1720

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

193+
func TestCommandEngine_RoutesSubprocessOutputToContextWriters(t *testing.T) {
194+
exe := testExePath(t)
195+
terraformDir := t.TempDir()
196+
require.NoError(t, os.Mkdir(filepath.Join(terraformDir, "test-component"), 0o755))
197+
198+
var stdout bytes.Buffer
199+
var stderr bytes.Buffer
200+
kind := &Kind{Name: "command", OnFailure: OnFailureWarn, Engine: &CommandEngine{}}
201+
ctx := &ExecContext{
202+
Hook: kind.ResolveDefaults(&Hook{
203+
Kind: "command",
204+
Command: exe,
205+
Args: []string{"-test.run", "^$"},
206+
Env: map[string]string{
207+
"_ATMOS_TEST_ECHO_STDOUT": "1",
208+
"_ATMOS_TEST_STDOUT_BODY": "hook progress\rhook complete\n",
209+
"_ATMOS_TEST_ECHO_STDERR": "1",
210+
"_ATMOS_TEST_STDERR_BODY": "hook warning\n",
211+
},
212+
}),
213+
Kind: kind,
214+
AtmosConfig: &schema.AtmosConfiguration{
215+
TerraformDirAbsolutePath: terraformDir,
216+
},
217+
Info: &schema.ConfigAndStacksInfo{Stack: "test-stack", ComponentFromArg: "test-component"},
218+
Stdout: &stdout,
219+
Stderr: &stderr,
220+
}
221+
222+
_, err := ctx.Kind.Engine.Run(ctx)
223+
require.NoError(t, err)
224+
assert.Equal(t, "hook progress\rhook complete\n", stdout.String())
225+
assert.Equal(t, "hook warning\n", stderr.String())
226+
}
227+
228+
func TestRenderTerminalRoutesSummaryToContextStderr(t *testing.T) {
229+
var stderr bytes.Buffer
230+
writer := iolib.NewLinePrefixWriter("test/component", &stderr, &sync.Mutex{})
231+
232+
renderTerminal(&ExecContext{Stderr: writer}, &Output{
233+
Summary: &Summary{Body: "**hook summary**\n"},
234+
})
235+
require.NoError(t, writer.Flush())
236+
237+
assert.Contains(t, stderr.String(), "[test/component] ")
238+
assert.Contains(t, stderr.String(), "hook summary")
239+
}
240+
190241
func TestRunSubprocess_CaptureStdoutCreateFailurePropagates(t *testing.T) {
191242
exe := testExePath(t)
192243
prep := &subprocessPrep{

pkg/hooks/hooks.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package hooks
33
import (
44
"errors"
55
"fmt"
6+
"io"
67
"strings"
78

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

6467
// outcome is the lifecycle operation result (success/failure) for the next
6568
// RunAll, set by SetOutcome. Zero value defaults to success.
@@ -253,6 +256,8 @@ func (h *Hooks) runResolvedHook(name string, kind *Kind, executionHook *Hook, ct
253256
HookName: name,
254257
Outcome: ctx.outcome,
255258
ToolchainPATH: h.toolchainPATH,
259+
Stdout: h.stdout,
260+
Stderr: h.stderr,
256261
}
257262
return runHookLogGroup(ctx.atmosConfig, ci.DimensionPhase, hookLogGroupLabel(name, ctx.event), func() error {
258263
_, err := kind.Engine.Run(execCtx)
@@ -965,6 +970,11 @@ type RunPerComponentHooksOptions struct {
965970
// Outcome is the lifecycle outcome (success/failure) used to filter `when:`
966971
// and expose status to hook engines. Zero value defaults to success.
967972
Outcome Outcome
973+
974+
// Stdout and Stderr receive hook subprocess output. Nil preserves the
975+
// process streams used by single-component execution.
976+
Stdout io.Writer
977+
Stderr io.Writer
968978
}
969979

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

9931003
hooksForComponent.SetOutcome(opts.Outcome)
1004+
hooksForComponent.stdout = opts.Stdout
1005+
hooksForComponent.stderr = opts.Stderr
9941006
log.Info("Running hooks", "event", opts.Event, logKeyStatus, opts.Outcome.Status,
9951007
"component", opts.Info.ComponentFromArg, "stack", opts.Info.Stack)
9961008
return hooksForComponent.RunAll(opts.Event, opts.AtmosConfig, opts.Info, opts.Cmd, opts.Args)

pkg/hooks/kind.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package hooks
22

33
import (
4+
"io"
45
"sort"
56
"sync"
67

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

6166
// OutputFile is the temp file path the tool wrote structured output to.
6267
// Populated by CommandEngine before calling ResultHandler.

pkg/hooks/kinds/tfmigrate/kind.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,14 @@ func (e *Engine) Run(ctx *hooks.ExecContext) (*hooks.Output, error) {
5353
}
5454
cmd := exec.Command(atmosBin, args...) // #nosec G204,G702 -- intentional nested Atmos invocation.
5555
cmd.Stdin = os.Stdin
56-
cmd.Stdout = os.Stdout
57-
cmd.Stderr = os.Stderr
56+
cmd.Stdout = ctx.Stdout
57+
cmd.Stderr = ctx.Stderr
58+
if cmd.Stdout == nil {
59+
cmd.Stdout = os.Stdout
60+
}
61+
if cmd.Stderr == nil {
62+
cmd.Stderr = os.Stderr
63+
}
5864
cmd.Env = append(os.Environ(), "ATMOS_SKIP_HOOKS=*")
5965
if err := cmd.Run(); err != nil {
6066
// ApplyOnFailure resolves ctx.Hook.OnFailure ("warn"/"ignore"/"fail",

pkg/hooks/main_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222
// os.Stdout, then exit 0. Lets tests simulate a tool that emits structured
2323
// output to stdout (e.g. tflint --format=sarif) so the engine's
2424
// CaptureStdout redirect can be verified cross-platform via os.Executable().
25+
// - _ATMOS_TEST_ECHO_STDERR: write the value of _ATMOS_TEST_STDERR_BODY to
26+
// os.Stderr, then exit 0. Lets tests verify subprocess stderr routing.
2527
// - _ATMOS_TEST_WRITE_CWD: write the subprocess working directory and
2628
// ATMOS_COMPONENT_PATH to ATMOS_OUTPUT_FILE, separated by a newline.
2729
func TestMain(m *testing.M) {
@@ -41,6 +43,11 @@ func TestMain(m *testing.M) {
4143
}
4244
if os.Getenv("_ATMOS_TEST_ECHO_STDOUT") == "1" {
4345
fmt.Fprint(os.Stdout, os.Getenv("_ATMOS_TEST_STDOUT_BODY"))
46+
}
47+
if os.Getenv("_ATMOS_TEST_ECHO_STDERR") == "1" {
48+
fmt.Fprint(os.Stderr, os.Getenv("_ATMOS_TEST_STDERR_BODY"))
49+
}
50+
if os.Getenv("_ATMOS_TEST_ECHO_STDOUT") == "1" || os.Getenv("_ATMOS_TEST_ECHO_STDERR") == "1" {
4451
os.Exit(0)
4552
}
4653
if os.Getenv("_ATMOS_TEST_WRITE_CWD") == "1" {

0 commit comments

Comments
 (0)