Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e7bc1ba
fix(terraform): suppress concurrent lifecycle output
zack-is-cool Aug 6, 2026
8b384bb
fix(terraform): preserve explicit init context
zack-is-cool Aug 6, 2026
662d8eb
fix(terraform): wrap backend provision errors
zack-is-cool Aug 7, 2026
6071a69
fix(terraform): route concurrent lifecycle output
zack-is-cool Aug 7, 2026
962b9f8
fix(terraform): preserve concurrent provisioning warnings
zack-is-cool Aug 7, 2026
f5d1b0d
fix(terraform): retain suppressed hash warnings
zack-is-cool Aug 7, 2026
59ca28c
fix(terraform): route concurrent provisioner status
zack-is-cool Aug 7, 2026
924a05f
fix(terraform): pass lifecycle writers explicitly
zack-is-cool Aug 7, 2026
89e2d1d
test(step): quote spin helper command safely
zack-is-cool Aug 7, 2026
61b5d34
fix(terraform): retain suppressed source warnings
zack-is-cool Aug 7, 2026
5c527e6
fix(ui): route concurrent status through UI
zack-is-cool Aug 10, 2026
7b36216
fix(ui): track writer-aware output
zack-is-cool Aug 10, 2026
dc7218d
fix(ui): bind concurrent status output
zack-is-cool Aug 10, 2026
fcc2749
test(provisioner): normalize status output assertions
zack-is-cool Aug 10, 2026
f346906
test(step): quote Windows spin helper
zack-is-cool Aug 11, 2026
44e08bb
Merge branch 'main' into fix/jit-output-suppression
aknysh Aug 11, 2026
d55f142
test: harden JIT source regression coverage
zack-is-cool Aug 11, 2026
a8471db
Merge remote-tracking branch 'fork/fix/jit-output-suppression' into f…
zack-is-cool Aug 11, 2026
8b9e738
Merge branch 'main' into fix/jit-output-suppression
zack-is-cool Aug 11, 2026
00bc9a2
test: run spin helper without shell quoting
zack-is-cool Aug 11, 2026
1a884b6
Merge remote-tracking branch 'fork/fix/jit-output-suppression' into f…
zack-is-cool Aug 11, 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
3 changes: 2 additions & 1 deletion cmd/terraform/migrate/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/cloudposse/atmos/pkg/flags"
"github.com/cloudposse/atmos/pkg/flags/compat"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/provisioner"
"github.com/cloudposse/atmos/pkg/schema"
tfmigrate "github.com/cloudposse/atmos/pkg/terraform/tfmigrate"
"github.com/cloudposse/atmos/pkg/ui"
Expand Down Expand Up @@ -398,7 +399,7 @@ func resolveTfmigrateComponentPath(atmosConfig *schema.AtmosConfiguration, info

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
path, _, err := component.ProvisionAndResolveComponentPath(ctx, atmosConfig, info, cfg.TerraformComponentType, basePath)
path, _, err := component.ProvisionAndResolveComponentPath(ctx, provisioner.OutputWriters{}, atmosConfig, info, cfg.TerraformComponentType, basePath)
if err != nil {
return "", err
}
Expand Down
3 changes: 2 additions & 1 deletion cmd/terraform/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
h "github.com/cloudposse/atmos/pkg/hooks"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/provisioner"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/store/authbridge"
u "github.com/cloudposse/atmos/pkg/utils"
Expand Down Expand Up @@ -325,7 +326,7 @@ func ensureComponentSourceProvisioned(atmosConfig *schema.AtmosConfiguration, in

ctx, cancel := context.WithTimeout(context.Background(), componentSourceProvisionTimeout)
defer cancel()
if _, _, err := component.ProvisionAndResolveComponentPath(ctx, atmosConfig, info, cfg.TerraformComponentType, fallbackPath); err != nil {
if _, _, err := component.ProvisionAndResolveComponentPath(ctx, provisioner.OutputWriters{}, atmosConfig, info, cfg.TerraformComponentType, fallbackPath); err != nil {
log.Debug("hook source provisioning failed; the Terraform command will report this authoritatively", "component", info.ComponentFromArg, "error", err)
}
}
Expand Down
33 changes: 33 additions & 0 deletions docs/fixes/2026-08-06-jit-output-suppression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Fix: Suppress JIT provisioning output during concurrent Terraform runs

**Date:** 2026-08-06

## Summary

Concurrent Terraform runs now suppress transient provisioner and step-hook UI, preventing spinner and line-clear control sequences from corrupting prefixed component output.

## Context

PR #2860 normalized child-process carriage returns and suppressed provisioner UI for Terraform output lookups. Normal scheduled Terraform execution marked its scheduler context as output-suppressed, but initial component resolution, `prepareInitExecution`, and post-init provisioners could lose that context. Backend provisioning and step hooks also retained global spinner and terminal-line UI paths while component output was streaming concurrently.

## Changes

- `pkg/scheduler/adapters/terraform.go`: mark the scheduler context as output-suppressed whenever Terraform concurrency exceeds one.
- `internal/exec/`: preserve the process context through JIT component resolution and Terraform init preparation so source and workdir provisioners receive the suppression marker.
- `pkg/provisioner/`: share the suppression marker across backend, source, and workdir provisioners; backend creation now runs without spinner/warning UI when concurrent.
- `pkg/hooks/` and `pkg/runner/step/`: suppress `clear` and `spin` terminal UI for hooks that receive scheduler node writers.
- `internal/exec/terraform_execute_helpers.go`: forward process context and writers to post-init provider-lock provisioners.
- `pkg/scheduler/adapters/terraform_test.go`: verify concurrent nodes receive suppression and sequential nodes do not.
- `internal/exec/terraform_execute_helpers_test.go`: verify JIT and pre-init provisioners receive output suppression.
- `pkg/hooks/step_engine_test.go`: verify step hooks suppress transient UI when node writers are active.

## Validation

- Focused scheduler, hooks, runner-step, provisioner, and source/workdir provisioner tests passed.
- Focused explicit-init dispatch tests passed; the full `internal/exec` suite exceeded five minutes.
- `go build ./...` passed.
- `atmos lint --changed` passed.

## Follow-ups

None.
23 changes: 23 additions & 0 deletions docs/fixes/2026-08-10-writer-bound-ui-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Fix: Route concurrent provisioning status through UI

**Date:** 2026-08-10

## Summary

Concurrent provisioning status now uses UI formatting and masking while writing to its component-specific stderr stream.

## Context

Direct writes to component writers bypassed the UI layer. The initial writer-aware package functions also made call sites less clear than a destination-bound output object.

## Changes

Added `ui.New(writer)` with semantic success, warning, and info methods. Updated provisioning call sites and tests to use one bound output object per operation. Normalized formatted-output assertions.

## Validation

Passed `go build ./...`, focused provisioning and UI tests, and `atmos lint --changed`. Component-output tests strip ANSI sequences before semantic assertions so they are stable on color-capable macOS runners.

## Follow-ups

None.
3 changes: 2 additions & 1 deletion internal/exec/helmfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/cloudposse/atmos/pkg/helmfile"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/provisioner"
"github.com/cloudposse/atmos/pkg/provisioner/target"
"github.com/cloudposse/atmos/pkg/schema"
tfgenerate "github.com/cloudposse/atmos/pkg/terraform/generate"
Expand Down Expand Up @@ -117,7 +118,7 @@ func ExecuteHelmfile(info schema.ConfigAndStacksInfo) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
componentPath, componentPathExists, err := component.ProvisionAndResolveComponentPath(
ctx, &atmosConfig, &info, cfg.HelmfileComponentType, componentPath,
ctx, provisioner.OutputWriters{}, &atmosConfig, &info, cfg.HelmfileComponentType, componentPath,
)
if err != nil {
return err
Expand Down
3 changes: 2 additions & 1 deletion internal/exec/helmfile_generate_varfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
cfg "github.com/cloudposse/atmos/pkg/config"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/provisioner"
provSource "github.com/cloudposse/atmos/pkg/provisioner/source"
u "github.com/cloudposse/atmos/pkg/utils"
)
Expand Down Expand Up @@ -55,7 +56,7 @@ func ExecuteHelmfileGenerateVarfileCmd(cmd *cobra.Command, args []string) error
if provSource.HasSource(info.ComponentSection) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := provSource.AutoProvisionSource(ctx, &atmosConfig, cfg.HelmfileComponentType, info.ComponentSection, info.AuthContext); err != nil {
if err := provSource.AutoProvisionSource(ctx, &atmosConfig, cfg.HelmfileComponentType, info.ComponentSection, info.AuthContext, provisioner.OutputWriters{}); err != nil {
return err
}
}
Expand Down
3 changes: 2 additions & 1 deletion internal/exec/packer.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/cloudposse/atmos/pkg/dependencies"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/provisioner"
"github.com/cloudposse/atmos/pkg/schema"
tfgenerate "github.com/cloudposse/atmos/pkg/terraform/generate"
u "github.com/cloudposse/atmos/pkg/utils"
Expand Down Expand Up @@ -120,7 +121,7 @@ func ExecutePacker(
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
componentPath, componentPathExists, err := component.ProvisionAndResolveComponentPath(
ctx, &atmosConfig, info, cfg.PackerComponentType, componentPath,
ctx, provisioner.OutputWriters{}, &atmosConfig, info, cfg.PackerComponentType, componentPath,
)
if err != nil {
return err
Expand Down
3 changes: 2 additions & 1 deletion internal/exec/packer_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
cfg "github.com/cloudposse/atmos/pkg/config"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/provisioner"
provSource "github.com/cloudposse/atmos/pkg/provisioner/source"
provWorkdir "github.com/cloudposse/atmos/pkg/provisioner/workdir"
"github.com/cloudposse/atmos/pkg/schema"
Expand Down Expand Up @@ -61,7 +62,7 @@ func ExecutePackerOutput(
if provSource.HasSource(info.ComponentSection) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := provSource.AutoProvisionSource(ctx, &atmosConfig, cfg.PackerComponentType, info.ComponentSection, info.AuthContext); err != nil {
if err := provSource.AutoProvisionSource(ctx, &atmosConfig, cfg.PackerComponentType, info.ComponentSection, info.AuthContext, provisioner.OutputWriters{}); err != nil {
return nil, fmt.Errorf("failed to auto-provision component source: %w", errors.Join(errUtils.ErrSourceProvision, err))
}

Expand Down
26 changes: 26 additions & 0 deletions internal/exec/shell_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
process "github.com/cloudposse/atmos/pkg/process"
"github.com/cloudposse/atmos/pkg/provisioner"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/shell"
terminalpkg "github.com/cloudposse/atmos/pkg/terminal"
Expand Down Expand Up @@ -91,6 +92,31 @@ func WithProcessContext(ctx context.Context) ShellCommandOption {
}
}

func shellCommandContext(opts ...ShellCommandOption) context.Context {
var cfg shellCommandConfig
for _, opt := range opts {
opt(&cfg)
}
if cfg.ctx == nil {
return context.Background()
}
return cfg.ctx
}

func shellCommandOutputWriters(opts ...ShellCommandOption) provisioner.OutputWriters {
var cfg shellCommandConfig
for _, opt := range opts {
opt(&cfg)
}
if cfg.streams == nil {
return provisioner.OutputWriters{}
}
return provisioner.OutputWriters{
Stdout: cfg.streams.Stdout,
Stderr: cfg.streams.Stderr,
}
}

// WithEnvironment provides a pre-sanitized process environment for subprocess execution.
// When provided, ExecuteShellCommand uses this instead of re-reading os.Environ().
// Pass nil to fall back to the default os.Environ() behavior.
Expand Down
2 changes: 1 addition & 1 deletion internal/exec/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ func ExecuteTerraform(info schema.ConfigAndStacksInfo, opts ...ShellCommandOptio
}

// Resolve paths, install toolchain, write varfiles, validate, run hooks, and build env.
execCtx, err := prepareComponentExecution(&atmosConfig, &info, shouldProcess)
execCtx, err := prepareComponentExecution(shellCommandContext(opts...), shellCommandOutputWriters(opts...), &atmosConfig, &info, shouldProcess)
if err != nil {
return err
}
Expand Down
39 changes: 27 additions & 12 deletions internal/exec/terraform_execute_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,13 @@ func SetupComponentAuthForCLI(atmosConfig *schema.AtmosConfiguration, info *sche
// resolveAndProvisionComponentPath resolves the filesystem path for a terraform component,
// optionally auto-generates files, performs JIT source provisioning, and validates
// that the resulting directory actually exists.
func resolveAndProvisionComponentPath(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo) (string, error) {
// The provision-and-resolve component function is a seam for testing JIT provisioning context propagation.
var provisionAndResolveTerraformComponentPath = component.ProvisionAndResolveComponentPath

// The before-init provisioner function is a seam for testing context propagation.
var executeBeforeInitProvisioners = provisioner.ExecuteProvisioners

func resolveAndProvisionComponentPath(ctx context.Context, writers provisioner.OutputWriters, atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo) (string, error) {
componentPath, err := u.GetComponentPath(atmosConfig, "terraform", info.ComponentFolderPrefix, info.FinalComponent)
if err != nil {
return "", fmt.Errorf("failed to resolve component path: %w", err)
Expand All @@ -245,10 +251,13 @@ func resolveAndProvisionComponentPath(atmosConfig *schema.AtmosConfiguration, in
// Provision source before generating files: when provision.workdir.enabled
// is true the resolved path is the workdir, and generated files must land
// there rather than in the base component directory.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
componentPath, componentPathExists, err := component.ProvisionAndResolveComponentPath(
ctx, atmosConfig, info, cfg.TerraformComponentType, componentPath,
componentPath, componentPathExists, err := provisionAndResolveTerraformComponentPath(
ctx, writers, atmosConfig, info, cfg.TerraformComponentType, componentPath,
)
if err != nil {
return "", err
Expand Down Expand Up @@ -891,21 +900,25 @@ func buildInitArgs(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAn
// directories (terraform.tfstate.d/) but no .terraform/environment file and interprets the
// situation as a backend migration, producing the "Do you want to migrate all workspaces?"
// prompt on every apply. Skipping the cleanup for workdir components avoids this.
func prepareInitExecution(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, componentPath string) (string, error) {
func prepareInitExecution(ctx context.Context, writers provisioner.OutputWriters, atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, componentPath string) (string, error) {
_, isWorkdir := info.ComponentSection[provWorkdir.WorkdirPathKey].(string)
if !isWorkdir {
cleanTerraformWorkspace(*atmosConfig, componentPath)
}

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
if ctx == nil {
ctx = context.Background()
}
provisionCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()

if err := provisioner.ExecuteProvisioners(
ctx,
if err := executeBeforeInitProvisioners(
provisionCtx,
provisioner.HookEvent(beforeTerraformInitEvent),
atmosConfig,
info.ComponentSection,
info.AuthContext,
writers,
); err != nil {
return componentPath, fmt.Errorf("provisioner execution failed: %w", err)
}
Expand All @@ -928,7 +941,7 @@ func prepareInitExecution(atmosConfig *schema.AtmosConfiguration, info *schema.C
// invocation via prepareInitExecution. These two code paths must never both execute
// in the same command invocation or provisioners will run twice.
func executeTerraformInitPhase(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, componentPath, varFile string, opts ...ShellCommandOption) (string, error) {
newPath, err := prepareInitExecution(atmosConfig, info, componentPath)
newPath, err := prepareInitExecution(shellCommandContext(opts...), shellCommandOutputWriters(opts...), atmosConfig, info, componentPath)
if err != nil {
return componentPath, err
}
Expand Down Expand Up @@ -970,7 +983,7 @@ func executeTerraformInitCommand(atmosConfig *schema.AtmosConfiguration, info *s
return err
}

dispatchAfterInit(atmosConfig, info, componentPath)
dispatchAfterInit(atmosConfig, info, componentPath, opts...)

return nil
}
Expand All @@ -981,7 +994,7 @@ func executeTerraformInitCommand(atmosConfig *schema.AtmosConfiguration, info *s
// and working directory as init, so a `providers lock` runs against the already-warm cache.
// Lock completion is best-effort: a failure is logged, not propagated, so it never fails the
// user's plan/apply.
func dispatchAfterInit(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, componentPath string) {
func dispatchAfterInit(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, componentPath string, opts ...ShellCommandOption) {
execCtx := &provisioner.TerraformExecContext{
WorkingDir: componentPath,
Run: func(args []string) error {
Expand All @@ -993,11 +1006,12 @@ func dispatchAfterInit(atmosConfig *schema.AtmosConfiguration, info *schema.Conf
info.ComponentEnvList,
info.DryRun,
info.RedirectStdErr,
opts...,
)
},
}

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
ctx, cancel := context.WithTimeout(shellCommandContext(opts...), 5*time.Minute)
defer cancel()

if err := provisioner.ExecuteProvisioners(
Expand All @@ -1006,6 +1020,7 @@ func dispatchAfterInit(atmosConfig *schema.AtmosConfiguration, info *schema.Conf
atmosConfig,
info.ComponentSection,
info.AuthContext,
shellCommandOutputWriters(opts...),
execCtx,
); err != nil {
log.Warn("Failed to complete multi-platform provider lock", "error", err)
Expand Down
6 changes: 4 additions & 2 deletions internal/exec/terraform_execute_helpers_args.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ func buildInitSubcommandArgs(
allArgsAndFlags []string,
varFile string,
componentPath *string,
opts ...ShellCommandOption,
) ([]string, error) {
newPath, provErr := prepareInitExecution(atmosConfig, info, *componentPath)
newPath, provErr := prepareInitExecution(shellCommandContext(opts...), shellCommandOutputWriters(opts...), atmosConfig, info, *componentPath)
if provErr != nil {
return nil, provErr
}
Expand Down Expand Up @@ -135,6 +136,7 @@ func buildTerraformCommandArgs(
info *schema.ConfigAndStacksInfo,
varFile, planFile string,
componentPath *string,
opts ...ShellCommandOption,
) (allArgsAndFlags []string, uploadStatusFlag bool, err error) {
allArgsAndFlags = strings.Fields(info.SubCommand)

Expand All @@ -157,7 +159,7 @@ func buildTerraformCommandArgs(
allArgsAndFlags = buildApplySubcommandArgs(info, allArgsAndFlags, varFile)

case subcommandInit:
allArgsAndFlags, err = buildInitSubcommandArgs(atmosConfig, info, allArgsAndFlags, varFile, componentPath)
allArgsAndFlags, err = buildInitSubcommandArgs(atmosConfig, info, allArgsAndFlags, varFile, componentPath, opts...)
if err != nil {
return nil, false, err
}
Expand Down
7 changes: 4 additions & 3 deletions internal/exec/terraform_execute_helpers_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

cfg "github.com/cloudposse/atmos/pkg/config"
atmosio "github.com/cloudposse/atmos/pkg/io"
"github.com/cloudposse/atmos/pkg/provisioner"
"github.com/cloudposse/atmos/pkg/schema"
u "github.com/cloudposse/atmos/pkg/utils"
)
Expand Down Expand Up @@ -372,7 +373,7 @@ func TestPrepareInitExecution_WorkdirPath_ReturnsWorkdir(t *testing.T) {
},
}

result, err := prepareInitExecution(&atmosConfig, &info, tmpDir)
result, err := prepareInitExecution(t.Context(), provisioner.OutputWriters{}, &atmosConfig, &info, tmpDir)
require.NoError(t, err)
assert.Equal(t, customWorkdir, result)
}
Expand All @@ -386,7 +387,7 @@ func TestPrepareInitExecution_NoWorkdirPath_ReturnsOriginalPath(t *testing.T) {
ComponentSection: map[string]any{},
}

result, err := prepareInitExecution(&atmosConfig, &info, tmpDir)
result, err := prepareInitExecution(t.Context(), provisioner.OutputWriters{}, &atmosConfig, &info, tmpDir)
require.NoError(t, err)
assert.Equal(t, tmpDir, result)
}
Expand All @@ -402,7 +403,7 @@ func TestPrepareInitExecution_EmptyWorkdirPath_ReturnsOriginalPath(t *testing.T)
},
}

result, err := prepareInitExecution(&atmosConfig, &info, tmpDir)
result, err := prepareInitExecution(t.Context(), provisioner.OutputWriters{}, &atmosConfig, &info, tmpDir)
require.NoError(t, err)
assert.Equal(t, tmpDir, result)
}
Expand Down
Loading
Loading