Skip to content

Commit 297bf1a

Browse files
committed
fix(terraform): suppress concurrent lifecycle output
1 parent 3ce4349 commit 297bf1a

20 files changed

Lines changed: 263 additions & 38 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Fix: Suppress JIT provisioning output during concurrent Terraform runs
2+
3+
**Date:** 2026-08-06
4+
5+
## Summary
6+
7+
Concurrent Terraform runs now suppress transient provisioner and step-hook UI, preventing spinner and line-clear control sequences from corrupting prefixed component output.
8+
9+
## Context
10+
11+
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.
12+
13+
## Changes
14+
15+
- `pkg/scheduler/adapters/terraform.go`: mark the scheduler context as output-suppressed whenever Terraform concurrency exceeds one.
16+
- `internal/exec/`: preserve the process context through JIT component resolution and Terraform init preparation so source and workdir provisioners receive the suppression marker.
17+
- `pkg/provisioner/`: share the suppression marker across backend, source, and workdir provisioners; backend creation now runs without spinner/warning UI when concurrent.
18+
- `pkg/hooks/` and `pkg/runner/step/`: suppress `clear` and `spin` terminal UI for hooks that receive scheduler node writers.
19+
- `internal/exec/terraform_execute_helpers.go`: forward process context and writers to post-init provider-lock provisioners.
20+
- `pkg/scheduler/adapters/terraform_test.go`: verify concurrent nodes receive suppression and sequential nodes do not.
21+
- `internal/exec/terraform_execute_helpers_test.go`: verify JIT and pre-init provisioners receive output suppression.
22+
- `pkg/hooks/step_engine_test.go`: verify step hooks suppress transient UI when node writers are active.
23+
24+
## Validation
25+
26+
- Focused scheduler, hooks, runner-step, provisioner, and source/workdir provisioner tests passed.
27+
- Focused explicit-init dispatch tests passed; the full `internal/exec` suite exceeded five minutes.
28+
- `go build ./...` passed.
29+
- `atmos lint --changed` passed.
30+
31+
## Follow-ups
32+
33+
None.

internal/exec/shell_utils.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ func WithProcessContext(ctx context.Context) ShellCommandOption {
9191
}
9292
}
9393

94+
func shellCommandContext(opts ...ShellCommandOption) context.Context {
95+
var cfg shellCommandConfig
96+
for _, opt := range opts {
97+
opt(&cfg)
98+
}
99+
if cfg.ctx == nil {
100+
return context.Background()
101+
}
102+
return cfg.ctx
103+
}
104+
94105
// WithEnvironment provides a pre-sanitized process environment for subprocess execution.
95106
// When provided, ExecuteShellCommand uses this instead of re-reading os.Environ().
96107
// Pass nil to fall back to the default os.Environ() behavior.

internal/exec/terraform.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ func ExecuteTerraform(info schema.ConfigAndStacksInfo, opts ...ShellCommandOptio
157157
}
158158

159159
// Resolve paths, install toolchain, write varfiles, validate, run hooks, and build env.
160-
execCtx, err := prepareComponentExecution(&atmosConfig, &info, shouldProcess)
160+
execCtx, err := prepareComponentExecution(shellCommandContext(opts...), &atmosConfig, &info, shouldProcess)
161161
if err != nil {
162162
return err
163163
}

internal/exec/terraform_execute_helpers.go

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,13 @@ func SetupComponentAuthForCLI(atmosConfig *schema.AtmosConfiguration, info *sche
236236
// resolveAndProvisionComponentPath resolves the filesystem path for a terraform component,
237237
// optionally auto-generates files, performs JIT source provisioning, and validates
238238
// that the resulting directory actually exists.
239-
func resolveAndProvisionComponentPath(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo) (string, error) {
239+
// The provision-and-resolve component function is a seam for testing JIT provisioning context propagation.
240+
var provisionAndResolveTerraformComponentPath = component.ProvisionAndResolveComponentPath
241+
242+
// The before-init provisioner function is a seam for testing context propagation.
243+
var executeBeforeInitProvisioners = provisioner.ExecuteProvisioners
244+
245+
func resolveAndProvisionComponentPath(ctx context.Context, atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo) (string, error) {
240246
componentPath, err := u.GetComponentPath(atmosConfig, "terraform", info.ComponentFolderPrefix, info.FinalComponent)
241247
if err != nil {
242248
return "", fmt.Errorf("failed to resolve component path: %w", err)
@@ -245,9 +251,12 @@ func resolveAndProvisionComponentPath(atmosConfig *schema.AtmosConfiguration, in
245251
// Provision source before generating files: when provision.workdir.enabled
246252
// is true the resolved path is the workdir, and generated files must land
247253
// there rather than in the base component directory.
248-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
254+
if ctx == nil {
255+
ctx = context.Background()
256+
}
257+
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
249258
defer cancel()
250-
componentPath, componentPathExists, err := component.ProvisionAndResolveComponentPath(
259+
componentPath, componentPathExists, err := provisionAndResolveTerraformComponentPath(
251260
ctx, atmosConfig, info, cfg.TerraformComponentType, componentPath,
252261
)
253262
if err != nil {
@@ -891,17 +900,20 @@ func buildInitArgs(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAn
891900
// directories (terraform.tfstate.d/) but no .terraform/environment file and interprets the
892901
// situation as a backend migration, producing the "Do you want to migrate all workspaces?"
893902
// prompt on every apply. Skipping the cleanup for workdir components avoids this.
894-
func prepareInitExecution(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, componentPath string) (string, error) {
903+
func prepareInitExecution(ctx context.Context, atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, componentPath string) (string, error) {
895904
_, isWorkdir := info.ComponentSection[provWorkdir.WorkdirPathKey].(string)
896905
if !isWorkdir {
897906
cleanTerraformWorkspace(*atmosConfig, componentPath)
898907
}
899908

900-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
909+
if ctx == nil {
910+
ctx = context.Background()
911+
}
912+
provisionCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
901913
defer cancel()
902914

903-
if err := provisioner.ExecuteProvisioners(
904-
ctx,
915+
if err := executeBeforeInitProvisioners(
916+
provisionCtx,
905917
provisioner.HookEvent(beforeTerraformInitEvent),
906918
atmosConfig,
907919
info.ComponentSection,
@@ -928,7 +940,7 @@ func prepareInitExecution(atmosConfig *schema.AtmosConfiguration, info *schema.C
928940
// invocation via prepareInitExecution. These two code paths must never both execute
929941
// in the same command invocation or provisioners will run twice.
930942
func executeTerraformInitPhase(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, componentPath, varFile string, opts ...ShellCommandOption) (string, error) {
931-
newPath, err := prepareInitExecution(atmosConfig, info, componentPath)
943+
newPath, err := prepareInitExecution(shellCommandContext(opts...), atmosConfig, info, componentPath)
932944
if err != nil {
933945
return componentPath, err
934946
}
@@ -970,7 +982,7 @@ func executeTerraformInitCommand(atmosConfig *schema.AtmosConfiguration, info *s
970982
return err
971983
}
972984

973-
dispatchAfterInit(atmosConfig, info, componentPath)
985+
dispatchAfterInit(atmosConfig, info, componentPath, opts...)
974986

975987
return nil
976988
}
@@ -981,7 +993,7 @@ func executeTerraformInitCommand(atmosConfig *schema.AtmosConfiguration, info *s
981993
// and working directory as init, so a `providers lock` runs against the already-warm cache.
982994
// Lock completion is best-effort: a failure is logged, not propagated, so it never fails the
983995
// user's plan/apply.
984-
func dispatchAfterInit(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, componentPath string) {
996+
func dispatchAfterInit(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, componentPath string, opts ...ShellCommandOption) {
985997
execCtx := &provisioner.TerraformExecContext{
986998
WorkingDir: componentPath,
987999
Run: func(args []string) error {
@@ -993,11 +1005,12 @@ func dispatchAfterInit(atmosConfig *schema.AtmosConfiguration, info *schema.Conf
9931005
info.ComponentEnvList,
9941006
info.DryRun,
9951007
info.RedirectStdErr,
1008+
opts...,
9961009
)
9971010
},
9981011
}
9991012

1000-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
1013+
ctx, cancel := context.WithTimeout(shellCommandContext(opts...), 5*time.Minute)
10011014
defer cancel()
10021015

10031016
if err := provisioner.ExecuteProvisioners(

internal/exec/terraform_execute_helpers_args.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,9 @@ func buildInitSubcommandArgs(
8989
allArgsAndFlags []string,
9090
varFile string,
9191
componentPath *string,
92+
opts ...ShellCommandOption,
9293
) ([]string, error) {
93-
newPath, provErr := prepareInitExecution(atmosConfig, info, *componentPath)
94+
newPath, provErr := prepareInitExecution(shellCommandContext(opts...), atmosConfig, info, *componentPath)
9495
if provErr != nil {
9596
return nil, provErr
9697
}
@@ -135,6 +136,7 @@ func buildTerraformCommandArgs(
135136
info *schema.ConfigAndStacksInfo,
136137
varFile, planFile string,
137138
componentPath *string,
139+
opts ...ShellCommandOption,
138140
) (allArgsAndFlags []string, uploadStatusFlag bool, err error) {
139141
allArgsAndFlags = strings.Fields(info.SubCommand)
140142

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

159161
case subcommandInit:
160-
allArgsAndFlags, err = buildInitSubcommandArgs(atmosConfig, info, allArgsAndFlags, varFile, componentPath)
162+
allArgsAndFlags, err = buildInitSubcommandArgs(atmosConfig, info, allArgsAndFlags, varFile, componentPath, opts...)
161163
if err != nil {
162164
return nil, false, err
163165
}

internal/exec/terraform_execute_helpers_coverage_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ func TestPrepareInitExecution_WorkdirPath_ReturnsWorkdir(t *testing.T) {
372372
},
373373
}
374374

375-
result, err := prepareInitExecution(&atmosConfig, &info, tmpDir)
375+
result, err := prepareInitExecution(t.Context(), &atmosConfig, &info, tmpDir)
376376
require.NoError(t, err)
377377
assert.Equal(t, customWorkdir, result)
378378
}
@@ -386,7 +386,7 @@ func TestPrepareInitExecution_NoWorkdirPath_ReturnsOriginalPath(t *testing.T) {
386386
ComponentSection: map[string]any{},
387387
}
388388

389-
result, err := prepareInitExecution(&atmosConfig, &info, tmpDir)
389+
result, err := prepareInitExecution(t.Context(), &atmosConfig, &info, tmpDir)
390390
require.NoError(t, err)
391391
assert.Equal(t, tmpDir, result)
392392
}
@@ -402,7 +402,7 @@ func TestPrepareInitExecution_EmptyWorkdirPath_ReturnsOriginalPath(t *testing.T)
402402
},
403403
}
404404

405-
result, err := prepareInitExecution(&atmosConfig, &info, tmpDir)
405+
result, err := prepareInitExecution(t.Context(), &atmosConfig, &info, tmpDir)
406406
require.NoError(t, err)
407407
assert.Equal(t, tmpDir, result)
408408
}

internal/exec/terraform_execute_helpers_exec.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type componentExecContext struct {
4545
// OPA/JSON-schema validation, auth pre-hook, config file generation, and env assembly.
4646
// Extracting this reduces ExecuteTerraform's cyclomatic complexity by ~10 decision points.
4747
func prepareComponentExecution(
48+
ctx context.Context,
4849
atmosConfig *schema.AtmosConfiguration,
4950
info *schema.ConfigAndStacksInfo,
5051
shouldProcess bool,
@@ -53,7 +54,7 @@ func prepareComponentExecution(
5354
return nil, err
5455
}
5556

56-
componentPath, err := resolveAndProvisionComponentPath(atmosConfig, info)
57+
componentPath, err := resolveAndProvisionComponentPath(ctx, atmosConfig, info)
5758
if err != nil {
5859
return nil, err
5960
}
@@ -185,7 +186,7 @@ func executeCommandPipeline(
185186
logTerraformContext(info, execCtx.workingDir)
186187
addTerraformTestVarfileArg(info, execCtx.testVarFile)
187188

188-
allArgsAndFlags, uploadStatusFlag, err := buildTerraformCommandArgs(atmosConfig, info, execCtx.varFile, execCtx.planFile, &componentPath)
189+
allArgsAndFlags, uploadStatusFlag, err := buildTerraformCommandArgs(atmosConfig, info, execCtx.varFile, execCtx.planFile, &componentPath, opts...)
189190
if err != nil {
190191
return err
191192
}

internal/exec/terraform_execute_helpers_pipeline_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ func TestPrepareComponentExecution_NoComponentPath_ReturnsError(t *testing.T) {
8484
// BasePath is empty → GetComponentPath returns an error.
8585
info := schema.ConfigAndStacksInfo{}
8686

87-
_, err := prepareComponentExecution(&atmosConfig, &info, false)
87+
_, err := prepareComponentExecution(t.Context(), &atmosConfig, &info, false)
8888
// An empty BasePath causes checkTerraformConfig to return an error.
8989
require.Error(t, err)
9090
}

internal/exec/terraform_execute_helpers_test.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package exec
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
67
"os"
78
osexec "os/exec"
89
"path/filepath"
910
"strings"
11+
"sync/atomic"
1012
"testing"
1113

1214
"github.com/hashicorp/terraform-config-inspect/tfconfig"
@@ -17,6 +19,7 @@ import (
1719
cfg "github.com/cloudposse/atmos/pkg/config"
1820
"github.com/cloudposse/atmos/pkg/degradation"
1921
atmosio "github.com/cloudposse/atmos/pkg/io"
22+
"github.com/cloudposse/atmos/pkg/provisioner"
2023
provWorkdir "github.com/cloudposse/atmos/pkg/provisioner/workdir"
2124
"github.com/cloudposse/atmos/pkg/schema"
2225
u "github.com/cloudposse/atmos/pkg/utils"
@@ -423,7 +426,7 @@ func TestPrepareInitExecution_SkipsCleanWorkspaceForWorkdir(t *testing.T) {
423426
},
424427
}
425428

426-
_, err := prepareInitExecution(&atmosConfig, &info, tmpDir)
429+
_, err := prepareInitExecution(t.Context(), &atmosConfig, &info, tmpDir)
427430
require.NoError(t, err)
428431

429432
_, statErr := os.Stat(envFile)
@@ -444,13 +447,48 @@ func TestPrepareInitExecution_CleansWorkspaceForNonWorkdir(t *testing.T) {
444447
ComponentSection: map[string]any{}, // no WorkdirPathKey
445448
}
446449

447-
_, err := prepareInitExecution(&atmosConfig, &info, tmpDir)
450+
_, err := prepareInitExecution(t.Context(), &atmosConfig, &info, tmpDir)
448451
require.NoError(t, err)
449452

450453
_, statErr := os.Stat(envFile)
451454
assert.True(t, os.IsNotExist(statErr), ".terraform/environment must be deleted for non-workdir components")
452455
}
453456

457+
func TestPrepareInitExecutionPropagatesOutputSuppression(t *testing.T) {
458+
originalExecuteProvisioners := executeBeforeInitProvisioners
459+
t.Cleanup(func() { executeBeforeInitProvisioners = originalExecuteProvisioners })
460+
var observedSuppression atomic.Bool
461+
executeBeforeInitProvisioners = func(ctx context.Context, _ provisioner.HookEvent, _ *schema.AtmosConfiguration, _ map[string]any, _ *schema.AuthContext, _ ...*provisioner.TerraformExecContext) error {
462+
observedSuppression.Store(provWorkdir.OutputSuppressed(ctx))
463+
return nil
464+
}
465+
466+
atmosConfig := schema.AtmosConfiguration{}
467+
info := schema.ConfigAndStacksInfo{ComponentSection: map[string]any{}}
468+
469+
_, err := prepareInitExecution(provWorkdir.WithOutputSuppressed(t.Context()), &atmosConfig, &info, t.TempDir())
470+
require.NoError(t, err)
471+
require.True(t, observedSuppression.Load())
472+
}
473+
474+
func TestResolveAndProvisionComponentPathPropagatesOutputSuppression(t *testing.T) {
475+
originalProvisioner := provisionAndResolveTerraformComponentPath
476+
t.Cleanup(func() { provisionAndResolveTerraformComponentPath = originalProvisioner })
477+
478+
var observedSuppression atomic.Bool
479+
provisionAndResolveTerraformComponentPath = func(ctx context.Context, _ *schema.AtmosConfiguration, _ *schema.ConfigAndStacksInfo, _ string, componentPath string) (string, bool, error) {
480+
observedSuppression.Store(provWorkdir.OutputSuppressed(ctx))
481+
return componentPath, true, nil
482+
}
483+
484+
atmosConfig := schema.AtmosConfiguration{BasePath: t.TempDir()}
485+
info := schema.ConfigAndStacksInfo{FinalComponent: "component"}
486+
487+
_, err := resolveAndProvisionComponentPath(provWorkdir.WithOutputSuppressed(t.Context()), &atmosConfig, &info)
488+
require.NoError(t, err)
489+
require.True(t, observedSuppression.Load())
490+
}
491+
454492
// ──────────────────────────────────────────────────────────────────────────────
455493
// handleDeploySubcommand
456494
// ──────────────────────────────────────────────────────────────────────────────

internal/exec/terraform_execute_helpers_workspace_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ func TestExecuteMainTerraformCommand_ExplicitInitDispatchesAfterInit(t *testing.
223223
t.Cleanup(func() { dispatchAfterInitFn = originalDispatch })
224224

225225
var dispatched bool
226-
dispatchAfterInitFn = func(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, componentPath string) {
226+
dispatchAfterInitFn = func(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, componentPath string, _ ...ShellCommandOption) {
227227
dispatched = true
228228
assert.Equal(t, "/tmp/component", componentPath)
229229
assert.Equal(t, subcommandInit, info.SubCommand)
@@ -239,7 +239,7 @@ func TestExecuteMainTerraformCommand_FailedExplicitInitSkipsAfterInit(t *testing
239239
originalDispatch := dispatchAfterInitFn
240240
t.Cleanup(func() { dispatchAfterInitFn = originalDispatch })
241241

242-
dispatchAfterInitFn = func(*schema.AtmosConfiguration, *schema.ConfigAndStacksInfo, string) {
242+
dispatchAfterInitFn = func(*schema.AtmosConfiguration, *schema.ConfigAndStacksInfo, string, ...ShellCommandOption) {
243243
t.Fatal("failed init must not dispatch after.terraform.init provisioners")
244244
}
245245

0 commit comments

Comments
 (0)