Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
328392f
fix: respect workdir path for generate: writes and hook-triggered ter…
zack-is-cool Apr 10, 2026
090ea42
fix: event filtering, skip-init in store hook, and reconfigure for JI…
zack-is-cool Apr 10, 2026
10c2dd7
fix: preserve backward compat for hooks with no events configured
zack-is-cool Apr 10, 2026
b8979a7
fix: clear errors and event-aware init for store hook
zack-is-cool Apr 10, 2026
708c6f7
fix: only add -reconfigure when workdir was actually re-provisioned
zack-is-cool Apr 10, 2026
0800677
fix: skip .terraform/environment cleanup for workdir components
zack-is-cool Apr 10, 2026
5fc10c5
fix: ignore InitRunReconfigure for preserved workdir components
zack-is-cool Apr 10, 2026
92462c6
Merge branch 'main' into fix/workdir-generate-and-hooks-2308
zack-is-cool Apr 10, 2026
77d7f20
chore: comments
zack-is-cool Apr 10, 2026
c7ef142
fix: skip-init should skip yaml function evaluation
zack-is-cool Apr 10, 2026
8c1de03
fix: address CodeRabbit feedback on GetOutputWithOptions error handling
zack-is-cool Apr 10, 2026
ab6f037
Merge branch 'main' into fix/workdir-generate-and-hooks-2308
osterman Apr 10, 2026
218f0b9
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 10, 2026
e842c4e
fix: update test assertion broken by CodeRabbit-requested error build…
zack-is-cool Apr 10, 2026
1b24266
fix: align hooks fixture event with deploy command
zack-is-cool Apr 10, 2026
1522959
feat: treat apply and deploy as equivalent hook events
zack-is-cool Apr 10, 2026
7514bb5
Merge branch 'main' into fix/workdir-generate-and-hooks-2308
zack-is-cool Apr 10, 2026
c9e3ab6
fix: fire before-terraform-deploy hook in deploy command
zack-is-cool Apr 10, 2026
38b5c13
test: add RunAll cross-fire cases for apply/deploy event aliasing
zack-is-cool Apr 10, 2026
03a3fc1
Merge branch 'main' into fix/workdir-generate-and-hooks-2308
zack-is-cool Apr 11, 2026
676fb55
fix: use unambiguous null-byte separator for terraform output cache key
zack-is-cool Apr 13, 2026
99acef3
fix: replace deprecated u.PrintfMessageToTUI with ui.* methods in out…
zack-is-cool Apr 13, 2026
0a92069
refactor: consolidate isWorkdirEnabled into provisioner/workdir package
zack-is-cool Apr 13, 2026
92f4226
test: add coverage for GetOutputWithOptions error paths and store_cmd…
zack-is-cool Apr 13, 2026
ee9557a
fix: address remaining CodeRabbit feedback
zack-is-cool Apr 13, 2026
ed461ac
fix: use static error sentinel in fetchAndCacheOutputs execute failure
zack-is-cool Apr 13, 2026
5124639
ci: return this to after-terraform-apply
zack-is-cool Apr 13, 2026
af7001e
Merge branch 'main' into fix/workdir-generate-and-hooks-2308
zack-is-cool Apr 13, 2026
671b0b2
fix: pass instance component name to BuildPath in extractComponentPath
zack-is-cool Apr 13, 2026
88930a4
fix: correct indentation in static remote state ui calls in executor
zack-is-cool Apr 13, 2026
a020186
test: update extractComponentPath assertion to reflect instance name fix
zack-is-cool Apr 13, 2026
f79886f
Merge branch 'main' into fix/workdir-generate-and-hooks-2308
aknysh Apr 13, 2026
b7b1880
Merge branch 'main' into fix/workdir-generate-and-hooks-2308
aknysh Apr 14, 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: 3 additions & 0 deletions cmd/terraform/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ var deployCmd = &cobra.Command{
Long: `Deploys infrastructure by running the Terraform apply command with automatic approval.

This ensures that the changes defined in your Terraform configuration are applied without requiring manual confirmation, streamlining the deployment process.`,
PreRunE: func(cmd *cobra.Command, args []string) error {
return runHooks(h.BeforeTerraformDeploy, cmd, args)
},
RunE: func(cmd *cobra.Command, args []string) (runErr error) {
// Reset captured output for this run.
capturedDeployOutput = ""
Expand Down
58 changes: 50 additions & 8 deletions internal/exec/terraform_execute_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,16 @@ func resolveAndProvisionComponentPath(atmosConfig *schema.AtmosConfiguration, in
return "", fmt.Errorf("failed to resolve component path: %w", err)
}

if err = autoGenerateComponentFiles(atmosConfig, info, componentPath); err != nil {
// Provision source BEFORE generating files so that generated files are written to
// the correct (possibly JIT workdir) path. When provision.workdir.enabled is true,
// provisionComponentSource returns the workdir path; autoGenerateComponentFiles must
// write to that path, not the base component directory.
componentPath, componentPathExists, err := provisionComponentSource(atmosConfig, info, componentPath)
if err != nil {
return "", err
}

componentPath, componentPathExists, err := provisionComponentSource(atmosConfig, info, componentPath)
if err != nil {
if err = autoGenerateComponentFiles(atmosConfig, info, componentPath); err != nil {
return "", err
}

Expand Down Expand Up @@ -431,10 +435,34 @@ func shouldRunTerraformInit(atmosConfig *schema.AtmosConfiguration, info *schema
}

// buildInitArgs constructs the argument list for `terraform init`.
// It adds -reconfigure when the component uses the workspace subcommand or when
// InitRunReconfigure is enabled, and appends the varfile flag when PassVars is set.
//
// For non-workdir components, -reconfigure is added when:
// - the component uses the workspace subcommand, or
// - InitRunReconfigure is explicitly enabled in atmos.yaml.
//
// For workdir components, InitRunReconfigure is intentionally ignored when the workdir
// was not re-provisioned this invocation. The backend configuration for workdir
// components is always generated deterministically from the same stack config, so it
// never changes between runs of a preserved workdir. When -reconfigure is combined
// with existing workspace state directories (terraform.tfstate.d/), OpenTofu treats
// init as a fresh backend initialization and prompts "Do you want to migrate all
// workspaces?" — even when the backend is unchanged. The correct signal to add
// -reconfigure for workdir components is WorkdirReprovisionedKey, which is set only
// when the workdir was actually wiped and re-downloaded (TTL expired or TTL=0s).
func buildInitArgs(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAndStacksInfo, varFile string) []string {
if info.SubCommand == subcommandWorkspace || atmosConfig.Components.Terraform.InitRunReconfigure {
_, hasWorkdir := info.ComponentSection[provWorkdir.WorkdirPathKey].(string)
_, wasReprovisioned := info.ComponentSection[provWorkdir.WorkdirReprovisionedKey]

var useReconfigure bool
if hasWorkdir {
// Workdir component: only reconfigure when the workdir was actually wiped.
useReconfigure = wasReprovisioned || info.SubCommand == subcommandWorkspace
} else {
// Non-workdir component: honour global InitRunReconfigure setting.
useReconfigure = info.SubCommand == subcommandWorkspace || atmosConfig.Components.Terraform.InitRunReconfigure
}

if useReconfigure {
if atmosConfig.Components.Terraform.Init.PassVars {
return []string{subcommandInit, "-reconfigure", varFileFlag, varFile}
}
Expand All @@ -447,11 +475,25 @@ func buildInitArgs(atmosConfig *schema.AtmosConfiguration, info *schema.ConfigAn
}

// prepareInitExecution performs the pre-init housekeeping:
// 1. Deletes the .terraform/environment file so Terraform doesn't prompt for workspace selection.
// 1. Deletes the .terraform/environment file so Terraform doesn't prompt for workspace selection
// (skipped for workdir-enabled components — see note below).
// 2. Executes all provisioners registered for the before.terraform.init hook event.
// 3. Returns the effective component path (which may be overridden by a workdir provisioner).
//
// NOTE on cleanTerraformWorkspace and workdir components:
// cleanTerraformWorkspace was designed to prevent workspace-selection prompts when different
// backends are used for the same component across runs. For workdir-enabled components the
// backend configuration is always consistent (generated fresh from the same stack config),
// so deleting .terraform/environment is not only unnecessary — it is actively harmful:
// when -reconfigure or init_run_reconfigure is also used, OpenTofu sees workspace state
// 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) {
cleanTerraformWorkspace(*atmosConfig, componentPath)
_, isWorkdir := info.ComponentSection[provWorkdir.WorkdirPathKey].(string)
if !isWorkdir {
cleanTerraformWorkspace(*atmosConfig, componentPath)
}

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
Expand Down
11 changes: 10 additions & 1 deletion internal/exec/terraform_execute_helpers_args.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

cfg "github.com/cloudposse/atmos/pkg/config"
provWorkdir "github.com/cloudposse/atmos/pkg/provisioner/workdir"
"github.com/cloudposse/atmos/pkg/schema"
u "github.com/cloudposse/atmos/pkg/utils"
)
Expand Down Expand Up @@ -94,7 +95,15 @@ func buildInitSubcommandArgs(
}
*componentPath = newPath

if atmosConfig.Components.Terraform.InitRunReconfigure {
// For workdir components, ignore InitRunReconfigure when the workdir was not
// re-provisioned — see buildInitArgs for the full rationale.
_, hasWorkdir := info.ComponentSection[provWorkdir.WorkdirPathKey].(string)
_, wasReprovisioned := info.ComponentSection[provWorkdir.WorkdirReprovisionedKey]
useReconfigure := wasReprovisioned
if !hasWorkdir {
useReconfigure = useReconfigure || atmosConfig.Components.Terraform.InitRunReconfigure
}
if useReconfigure {
allArgsAndFlags = append(allArgsAndFlags, "-reconfigure")
}
if atmosConfig.Components.Terraform.Init.PassVars {
Expand Down
147 changes: 147 additions & 0 deletions internal/exec/terraform_execute_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package exec
import (
"errors"
"fmt"
"os"
osexec "os/exec"
"path/filepath"
"testing"
Expand All @@ -12,6 +13,7 @@ import (

errUtils "github.com/cloudposse/atmos/errors"
cfg "github.com/cloudposse/atmos/pkg/config"
provWorkdir "github.com/cloudposse/atmos/pkg/provisioner/workdir"
"github.com/cloudposse/atmos/pkg/schema"
)

Expand Down Expand Up @@ -299,6 +301,151 @@ func TestBuildInitArgs_PassVarsWithWorkspaceAndReconfigure(t *testing.T) {
assert.Equal(t, []string{"init", "-reconfigure", varFileFlag, "my-component.tfvars.json"}, args)
}

// TestBuildInitArgs_ReconfigureWhenWorkdirReprovisioned verifies that -reconfigure is
// added when the workdir was actually wiped and re-provisioned this invocation
// (WorkdirReprovisionedKey set by the source/workdir provisioner).
// This prevents "Do you want to migrate all workspaces?" on fresh workdirs.
func TestBuildInitArgs_ReconfigureWhenWorkdirReprovisioned(t *testing.T) {
atmosConfig := schema.AtmosConfiguration{}
info := schema.ConfigAndStacksInfo{
SubCommand: "apply",
ComponentSection: map[string]any{
provWorkdir.WorkdirPathKey: "/tmp/.workdir/terraform/demo-consumer",
provWorkdir.WorkdirReprovisionedKey: struct{}{},
},
}
args := buildInitArgs(&atmosConfig, &info, "vars.tfvars.json")
assert.Equal(t, []string{"init", "-reconfigure"}, args)
}

// TestBuildInitArgs_ReconfigureWhenWorkdirReprovisioned_WithPassVars verifies that both
// -reconfigure and -var-file are added when workdir was re-provisioned and PassVars is enabled.
func TestBuildInitArgs_ReconfigureWhenWorkdirReprovisioned_WithPassVars(t *testing.T) {
atmosConfig := schema.AtmosConfiguration{}
atmosConfig.Components.Terraform.Init.PassVars = true
info := schema.ConfigAndStacksInfo{
SubCommand: "apply",
ComponentSection: map[string]any{
provWorkdir.WorkdirPathKey: "/tmp/.workdir/terraform/demo-consumer",
provWorkdir.WorkdirReprovisionedKey: struct{}{},
},
}
args := buildInitArgs(&atmosConfig, &info, "my-component.tfvars.json")
assert.Equal(t, []string{"init", "-reconfigure", varFileFlag, "my-component.tfvars.json"}, args)
}

// TestBuildInitArgs_NoReconfigureWhenWorkdirPreserved verifies that -reconfigure is NOT
// added when the workdir exists but was not re-provisioned (TTL not expired).
// Adding -reconfigure causes OpenTofu to treat init as fresh and prompt
// "Do you want to migrate all workspaces?" even when the backend is unchanged.
func TestBuildInitArgs_NoReconfigureWhenWorkdirPreserved(t *testing.T) {
atmosConfig := schema.AtmosConfiguration{}
info := schema.ConfigAndStacksInfo{
SubCommand: "apply",
ComponentSection: map[string]any{
provWorkdir.WorkdirPathKey: "/tmp/.workdir/terraform/demo-consumer",
// WorkdirReprovisionedKey intentionally absent — TTL not expired
},
}
args := buildInitArgs(&atmosConfig, &info, "vars.tfvars.json")
assert.Equal(t, []string{"init"}, args)
}

// TestBuildInitArgs_NoReconfigureWhenWorkdirPreserved_InitRunReconfigureIgnored verifies
// that InitRunReconfigure: true is ignored for workdir components with a preserved workdir.
// -reconfigure + workspace state dirs causes the "migrate all workspaces?" prompt even
// when the backend is unchanged; the global flag must not override this protection.
func TestBuildInitArgs_NoReconfigureWhenWorkdirPreserved_InitRunReconfigureIgnored(t *testing.T) {
atmosConfig := schema.AtmosConfiguration{}
atmosConfig.Components.Terraform.InitRunReconfigure = true
info := schema.ConfigAndStacksInfo{
SubCommand: "apply",
ComponentSection: map[string]any{
provWorkdir.WorkdirPathKey: "/tmp/.workdir/terraform/demo-consumer",
// WorkdirReprovisionedKey intentionally absent — workdir was NOT wiped
},
}
args := buildInitArgs(&atmosConfig, &info, "vars.tfvars.json")
assert.Equal(t, []string{"init"}, args)
}

// TestBuildInitArgs_ReconfigureForNonWorkdir_InitRunReconfigure verifies that
// InitRunReconfigure: true still works as expected for non-workdir components.
func TestBuildInitArgs_ReconfigureForNonWorkdir_InitRunReconfigure(t *testing.T) {
atmosConfig := schema.AtmosConfiguration{}
atmosConfig.Components.Terraform.InitRunReconfigure = true
info := schema.ConfigAndStacksInfo{
SubCommand: "apply",
ComponentSection: map[string]any{}, // no WorkdirPathKey
}
args := buildInitArgs(&atmosConfig, &info, "vars.tfvars.json")
assert.Equal(t, []string{"init", "-reconfigure"}, args)
}

// TestBuildInitArgs_NoReconfigureWithoutWorkdir verifies that -reconfigure is NOT
// added for regular (non-workdir) components unless explicitly configured.
func TestBuildInitArgs_NoReconfigureWithoutWorkdir(t *testing.T) {
atmosConfig := schema.AtmosConfiguration{}
info := schema.ConfigAndStacksInfo{
SubCommand: "apply",
ComponentSection: map[string]any{},
}
args := buildInitArgs(&atmosConfig, &info, "vars.tfvars.json")
assert.Equal(t, []string{"init"}, args)
}

// ──────────────────────────────────────────────────────────────────────────────
// prepareInitExecution — workspace file cleanup behaviour
// ──────────────────────────────────────────────────────────────────────────────

// TestPrepareInitExecution_SkipsCleanWorkspaceForWorkdir verifies that
// .terraform/environment is NOT deleted for workdir-enabled components.
// Deleting the file before init -reconfigure causes OpenTofu to prompt
// "Do you want to migrate all workspaces?" because it sees workspace state
// directories (terraform.tfstate.d/) but no active workspace recorded.
// For workdir components the backend is always consistent so cleanup is wrong.
func TestPrepareInitExecution_SkipsCleanWorkspaceForWorkdir(t *testing.T) {
tmpDir := t.TempDir()
tfDir := filepath.Join(tmpDir, ".terraform")
require.NoError(t, os.MkdirAll(tfDir, 0o755))
envFile := filepath.Join(tfDir, "environment")
require.NoError(t, os.WriteFile(envFile, []byte("myworkspace"), 0o644))

atmosConfig := schema.AtmosConfiguration{}
info := schema.ConfigAndStacksInfo{
ComponentSection: map[string]any{
provWorkdir.WorkdirPathKey: tmpDir,
},
}

_, err := prepareInitExecution(&atmosConfig, &info, tmpDir)
require.NoError(t, err)

_, statErr := os.Stat(envFile)
assert.NoError(t, statErr, ".terraform/environment must not be deleted for workdir components")
}

// TestPrepareInitExecution_CleansWorkspaceForNonWorkdir verifies that the standard
// .terraform/environment cleanup still runs for non-workdir components.
func TestPrepareInitExecution_CleansWorkspaceForNonWorkdir(t *testing.T) {
tmpDir := t.TempDir()
tfDir := filepath.Join(tmpDir, ".terraform")
require.NoError(t, os.MkdirAll(tfDir, 0o755))
envFile := filepath.Join(tfDir, "environment")
require.NoError(t, os.WriteFile(envFile, []byte("myworkspace"), 0o644))

atmosConfig := schema.AtmosConfiguration{}
info := schema.ConfigAndStacksInfo{
ComponentSection: map[string]any{}, // no WorkdirPathKey
}

_, err := prepareInitExecution(&atmosConfig, &info, tmpDir)
require.NoError(t, err)

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

// ──────────────────────────────────────────────────────────────────────────────
// handleDeploySubcommand
// ──────────────────────────────────────────────────────────────────────────────
Expand Down
26 changes: 26 additions & 0 deletions pkg/hooks/event.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package hooks

import "strings"

type HookEvent string

const (
Expand All @@ -11,3 +13,27 @@ const (
BeforeTerraformDeploy HookEvent = "before.terraform.deploy"
AfterTerraformDeploy HookEvent = "after.terraform.deploy"
)

// Normalize returns the canonical form of a HookEvent, collapsing deploy aliases
// to their apply equivalents. deploy and apply are semantically equivalent —
// deploy is apply with -auto-approve — so hooks configured for either should
// fire regardless of which command the user runs.
func (e HookEvent) Normalize() HookEvent {
switch e {
case AfterTerraformDeploy:
return AfterTerraformApply
case BeforeTerraformDeploy:
return BeforeTerraformApply
default:
return e
}
}

// IsPostExecution reports whether the event fires after terraform has already run
// (and therefore after terraform init has already completed).
// Store hooks use this to decide whether to skip terraform init when reading outputs:
// after-events can safely skip init because the workdir is already initialized;
// before-events must run init because the workdir may not be initialized yet.
func (e HookEvent) IsPostExecution() bool {
return strings.HasPrefix(string(e), "after.")
}
22 changes: 22 additions & 0 deletions pkg/hooks/hook.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package hooks

import "strings"

// Hook is the structure for a hook and is using in the stack config to define
// a command that should be run when a specific event occurs.
type Hook struct {
Expand All @@ -16,3 +18,23 @@ type Hook struct {
// Use RunCIHooks which automatically triggers CI actions based on
// component provider bindings. See pkg/ci/ for the modern implementation.
}

// MatchesEvent reports whether this hook should run for the given event.
// It normalises the yaml event format (hyphens, e.g. "after-terraform-apply")
// to the canonical Go format (dots, e.g. "after.terraform.apply") before
// comparing, so both styles are accepted in stack configuration.
//
// If the hook has no events configured, it matches all events to preserve
// backward compatibility with configs written before event filtering existed.
func (h Hook) MatchesEvent(event HookEvent) bool {
if len(h.Events) == 0 {
return true
}
normalizedEvent := event.Normalize()
for _, e := range h.Events {
if HookEvent(strings.ReplaceAll(e, "-", ".")).Normalize() == normalizedEvent {
return true
}
}
return false
}
Loading
Loading