Skip to content

Commit 19ef1eb

Browse files
ostermanclaude
andcommitted
feat(hooks): fire lifecycle hooks on terraform output and refresh
Store-write hooks (kind: store, and type: store steps) could only fire on plan/apply/deploy/test, so backfilling a store from infrastructure already deployed (before hooks existed, or outside Atmos entirely) meant forcing a re-apply. Adds before/after.terraform.output and before/after.terraform.refresh events, wired the same way as the existing terraform subcommands (cmd/terraform/output.go, refresh.go, and the terraformHookEvents multi-component dispatch), so a hook can now bind to after.terraform.output to sync a value without an apply. Closes #1055. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 00f73bf commit 19ef1eb

13 files changed

Lines changed: 388 additions & 20 deletions

File tree

agent-skills/skills/atmos-hooks/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ Use before/after events for component operations, for example:
6060
- `before.terraform.apply`, `after.terraform.apply`
6161
- `before.terraform.deploy`, `after.terraform.deploy`
6262
- `before.terraform.test`, `after.terraform.test`
63+
- `before.terraform.output`, `after.terraform.output` — fires for `atmos terraform output`,
64+
useful for backfilling a store from already-deployed infrastructure without an `apply`
65+
- `before.terraform.refresh`, `after.terraform.refresh`
6366

6467
Kubernetes provides `before`/`after` events for `render`, `diff`/`plan`, `apply`/`deploy`,
6568
`delete`, and `validate`. Native Helm provides `template`, `diff`, `apply`/`deploy`, and

cmd/terraform/output.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/cloudposse/atmos/pkg/flags"
1717
"github.com/cloudposse/atmos/pkg/flags/compat"
1818
ghactions "github.com/cloudposse/atmos/pkg/github/actions"
19+
h "github.com/cloudposse/atmos/pkg/hooks"
1920
"github.com/cloudposse/atmos/pkg/perf"
2021
"github.com/cloudposse/atmos/pkg/schema"
2122
tfoutput "github.com/cloudposse/atmos/pkg/terraform/output"
@@ -42,7 +43,25 @@ Without --format, passes through to native terraform/tofu output command.
4243
For complete Terraform/OpenTofu documentation, see:
4344
https://developer.hashicorp.com/terraform/cli/commands/output
4445
https://opentofu.org/docs/cli/commands/output`,
45-
RunE: func(cmd *cobra.Command, args []string) error {
46+
PreRunE: func(cmd *cobra.Command, args []string) error {
47+
return runBeforeHooks(h.BeforeTerraformOutput, cmd, args)
48+
},
49+
RunE: func(cmd *cobra.Command, args []string) (runErr error) {
50+
// Reset before any early return so the deferred hook and PostRunE read
51+
// consistent state.
52+
wasMultiComponentExecution = false
53+
54+
// On failure, run after hooks with error context. Cobra skips PostRunE on
55+
// error, so this is the only place the after.terraform.output hook fires
56+
// when reading outputs fails. In multi-component mode the per-component
57+
// hook already fired for each component, so the global error call is
58+
// suppressed to avoid double-firing.
59+
defer func() {
60+
if runErr != nil && !wasMultiComponentExecution {
61+
runHooksOnErrorWithOutput(h.AfterTerraformOutput, cmd, args, runErr, "")
62+
}
63+
}()
64+
4665
v := viper.GetViper()
4766
if err := terraformParser.BindFlagsToViper(cmd, v); err != nil {
4867
return err
@@ -56,6 +75,14 @@ For complete Terraform/OpenTofu documentation, see:
5675
}
5776
return outputRunWithFormat(cmd, args, format)
5877
},
78+
PostRunE: func(cmd *cobra.Command, args []string) error {
79+
// In multi-component mode, per-component hooks already fired inside the
80+
// affected/all/query dispatch. Calling them again here would double-fire.
81+
if wasMultiComponentExecution {
82+
return nil
83+
}
84+
return runHooksWithOutput(h.AfterTerraformOutput, cmd, args, "")
85+
},
5986
}
6087

6188
// outputRunWithFormat executes terraform output with atmos formatting.

cmd/terraform/refresh.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"github.com/spf13/cobra"
55

66
"github.com/cloudposse/atmos/cmd/internal"
7+
h "github.com/cloudposse/atmos/pkg/hooks"
78
)
89

910
// refreshCmd represents the terraform refresh command.
@@ -15,9 +16,35 @@ var refreshCmd = &cobra.Command{
1516
For complete Terraform/OpenTofu documentation, see:
1617
https://developer.hashicorp.com/terraform/cli/commands/refresh
1718
https://opentofu.org/docs/cli/commands/refresh`,
18-
RunE: func(cmd *cobra.Command, args []string) error {
19+
PreRunE: func(cmd *cobra.Command, args []string) error {
20+
return runBeforeHooks(h.BeforeTerraformRefresh, cmd, args)
21+
},
22+
RunE: func(cmd *cobra.Command, args []string) (runErr error) {
23+
// Reset before any early return so the deferred hook and PostRunE read
24+
// consistent state.
25+
wasMultiComponentExecution = false
26+
27+
// On failure, run after hooks with error context. Cobra skips PostRunE on
28+
// error, so this is the only place the after.terraform.refresh hook fires
29+
// when a refresh fails. In multi-component mode the per-component hook
30+
// already fired for each component, so the global error call is
31+
// suppressed to avoid double-firing.
32+
defer func() {
33+
if runErr != nil && !wasMultiComponentExecution {
34+
runHooksOnErrorWithOutput(h.AfterTerraformRefresh, cmd, args, runErr, "")
35+
}
36+
}()
37+
1938
return terraformRun(terraformCmd, cmd, args)
2039
},
40+
PostRunE: func(cmd *cobra.Command, args []string) error {
41+
// In multi-component mode, per-component hooks already fired inside the
42+
// affected/all/query dispatch. Calling them again here would double-fire.
43+
if wasMultiComponentExecution {
44+
return nil
45+
}
46+
return runHooksWithOutput(h.AfterTerraformRefresh, cmd, args, "")
47+
},
2148
}
2249

2350
func init() {

cmd/terraform/utils.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,10 @@ func terraformHookEvents(subCommand string) (before, after h.HookEvent, ok bool)
616616
return h.BeforeTerraformApply, h.AfterTerraformApply, true
617617
case "deploy":
618618
return h.BeforeTerraformDeploy, h.AfterTerraformDeploy, true
619+
case "output":
620+
return h.BeforeTerraformOutput, h.AfterTerraformOutput, true
621+
case "refresh":
622+
return h.BeforeTerraformRefresh, h.AfterTerraformRefresh, true
619623
default:
620624
return "", "", false
621625
}

cmd/terraform/utils_hooks_test.go

Lines changed: 143 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,48 @@ func TestApplyPostRunE_SuppressedWhenMultiComponent(t *testing.T) {
416416
assert.NoError(t, err, "PostRunE must fire normally in single-component mode")
417417
}
418418

419+
// TestOutputPostRunE_SuppressedWhenMultiComponent verifies that outputCmd.PostRunE
420+
// returns nil without error when wasMultiComponentExecution is true (multi-component
421+
// mode), mirroring the apply/deploy/plan PostRunE suppression contract.
422+
func TestOutputPostRunE_SuppressedWhenMultiComponent(t *testing.T) {
423+
t.Chdir("../../examples/demo-stacks")
424+
425+
orig := wasMultiComponentExecution
426+
defer func() { wasMultiComponentExecution = orig }()
427+
428+
cmd := newHookTestCmd()
429+
cmd.Use = "output"
430+
431+
wasMultiComponentExecution = true
432+
err := outputCmd.PostRunE(cmd, []string{"--stack", "dev", "myapp"})
433+
assert.NoError(t, err, "PostRunE must be suppressed when wasMultiComponentExecution is true")
434+
435+
wasMultiComponentExecution = false
436+
err = outputCmd.PostRunE(cmd, []string{"--stack", "dev", "myapp"})
437+
assert.NoError(t, err, "PostRunE must fire normally in single-component mode")
438+
}
439+
440+
// TestRefreshPostRunE_SuppressedWhenMultiComponent verifies that refreshCmd.PostRunE
441+
// returns nil without error when wasMultiComponentExecution is true (multi-component
442+
// mode), mirroring the apply/deploy/plan PostRunE suppression contract.
443+
func TestRefreshPostRunE_SuppressedWhenMultiComponent(t *testing.T) {
444+
t.Chdir("../../examples/demo-stacks")
445+
446+
orig := wasMultiComponentExecution
447+
defer func() { wasMultiComponentExecution = orig }()
448+
449+
cmd := newHookTestCmd()
450+
cmd.Use = "refresh"
451+
452+
wasMultiComponentExecution = true
453+
err := refreshCmd.PostRunE(cmd, []string{"--stack", "dev", "myapp"})
454+
assert.NoError(t, err, "PostRunE must be suppressed when wasMultiComponentExecution is true")
455+
456+
wasMultiComponentExecution = false
457+
err = refreshCmd.PostRunE(cmd, []string{"--stack", "dev", "myapp"})
458+
assert.NoError(t, err, "PostRunE must fire normally in single-component mode")
459+
}
460+
419461
// TestDeployRunE_DeferGuard verifies the RunE defer-guard contract in
420462
// deploy.go: the global error hook (runHooksOnErrorWithOutput) must fire
421463
// when runErr is non-nil AND wasMultiComponentExecution is false, and must
@@ -521,6 +563,88 @@ func TestDeployRunE_DeferGuard(t *testing.T) {
521563
}
522564
}
523565

566+
// TestOutputRefreshRunE_DeferGuard verifies the RunE defer-guard contract
567+
// shared by output.go and refresh.go: the global error hook
568+
// (runHooksOnErrorWithOutput) must fire when runErr is non-nil AND
569+
// wasMultiComponentExecution is false, and must be suppressed when
570+
// wasMultiComponentExecution is true (multi-component mode, where
571+
// per-component hooks already fired inside the affected/all/query
572+
// dispatch). Mirrors TestDeployRunE_DeferGuard for output.go's and
573+
// refresh.go's guards.
574+
func TestOutputRefreshRunE_DeferGuard(t *testing.T) {
575+
subcommands := []struct {
576+
name string
577+
use string
578+
event hooks.HookEvent
579+
}{
580+
{name: "output", use: "output", event: hooks.AfterTerraformOutput},
581+
{name: "refresh", use: "refresh", event: hooks.AfterTerraformRefresh},
582+
}
583+
584+
for _, sub := range subcommands {
585+
t.Run(sub.name, func(t *testing.T) {
586+
origGuard := wasMultiComponentExecution
587+
origHook := runHooksOnErrorWithOutput
588+
defer func() {
589+
wasMultiComponentExecution = origGuard
590+
runHooksOnErrorWithOutput = origHook
591+
}()
592+
593+
var called bool
594+
var calledEvent hooks.HookEvent
595+
var calledErr error
596+
runHooksOnErrorWithOutput = func(event hooks.HookEvent, _ *cobra.Command, _ []string, cmdErr error, _ string) {
597+
called = true
598+
calledEvent = event
599+
calledErr = cmdErr
600+
}
601+
602+
cmd := newHookTestCmd()
603+
cmd.Use = sub.use
604+
args := []string{"--stack", "dev", "myapp"}
605+
606+
// invokeDefer mirrors the RunE defer-guard body in output.go/refresh.go.
607+
// Any change to the production guard must be reflected here.
608+
invokeDefer := func(runErr error) {
609+
if runErr != nil && !wasMultiComponentExecution {
610+
runHooksOnErrorWithOutput(sub.event, cmd, args, runErr, "")
611+
}
612+
}
613+
614+
runErr := errors.New("terraform " + sub.name + " failed")
615+
616+
tests := []struct {
617+
name string
618+
runErr error
619+
multiComponent bool
620+
expectCalled bool
621+
}{
622+
{name: "non-nil error + single-component → hook fires", runErr: runErr, multiComponent: false, expectCalled: true},
623+
{name: "non-nil error + multi-component → hook suppressed", runErr: runErr, multiComponent: true, expectCalled: false},
624+
{name: "nil error + single-component → hook does not fire", runErr: nil, multiComponent: false, expectCalled: false},
625+
{name: "nil error + multi-component → hook does not fire", runErr: nil, multiComponent: true, expectCalled: false},
626+
}
627+
628+
for _, tc := range tests {
629+
t.Run(tc.name, func(t *testing.T) {
630+
called = false
631+
calledEvent = ""
632+
calledErr = nil
633+
wasMultiComponentExecution = tc.multiComponent
634+
635+
invokeDefer(tc.runErr)
636+
637+
assert.Equal(t, tc.expectCalled, called, "hook firing did not match expectation")
638+
if tc.expectCalled {
639+
assert.Equal(t, sub.event, calledEvent, "hook event mismatch")
640+
assert.Equal(t, runErr, calledErr, "hook did not receive original runErr")
641+
}
642+
})
643+
}
644+
})
645+
}
646+
}
647+
524648
// TestTerraformNodeHooksAfter_DeployExitCodeForwarding verifies that the exit
525649
// code extracted from execErr is forwarded correctly, matching the plan
526650
// component hook behaviour.
@@ -598,8 +722,8 @@ func TestTerraformNodeHooksAfter_ApplyExitCodeForwarding(t *testing.T) {
598722
func TestWirePerComponentHook(t *testing.T) {
599723
withoutCIDetection(t)
600724

601-
t.Run("plan/deploy/apply install a non-nil NodeHooks", func(t *testing.T) {
602-
for _, sub := range []string{"plan", "deploy", "apply"} {
725+
t.Run("plan/deploy/apply/output/refresh install a non-nil NodeHooks", func(t *testing.T) {
726+
for _, sub := range []string{"plan", "deploy", "apply", "output", "refresh"} {
603727
t.Run(sub, func(t *testing.T) {
604728
info := &schema.ConfigAndStacksInfo{
605729
TerraformPlanCIResultHandler: nil,
@@ -663,8 +787,8 @@ func TestWirePerComponentHook(t *testing.T) {
663787
t.Run("unknown subcommand leaves NodeHooks unset", func(t *testing.T) {
664788
// `init`, `validate`, etc. are valid terraform subcommands but they do
665789
// not have per-component hooks today. The helper must be a no-op
666-
// for anything outside the {plan, deploy, apply} set so other
667-
// subcommands don't accidentally start firing hooks.
790+
// for anything outside the {plan, deploy, apply, output, refresh} set
791+
// so other subcommands don't accidentally start firing hooks.
668792
for _, sub := range []string{"destroy", "init", "validate", ""} {
669793
t.Run(sub, func(t *testing.T) {
670794
info := &schema.ConfigAndStacksInfo{}
@@ -683,7 +807,7 @@ func TestWirePerComponentHook(t *testing.T) {
683807
t.Chdir("../../examples/demo-stacks")
684808
cmd := newHookTestCmd()
685809

686-
for _, sub := range []string{"plan", "deploy", "apply"} {
810+
for _, sub := range []string{"plan", "deploy", "apply", "output", "refresh"} {
687811
t.Run(sub, func(t *testing.T) {
688812
info := &schema.ConfigAndStacksInfo{
689813
Stack: "dev",
@@ -702,7 +826,7 @@ func TestWirePerComponentHook(t *testing.T) {
702826
}
703827
})
704828

705-
t.Run("the three subcommands wire to distinct events", func(t *testing.T) {
829+
t.Run("the five subcommands wire to distinct events", func(t *testing.T) {
706830
// Sanity check: a future edit that copy-pastes one case over another
707831
// (e.g. apply ends up using the plan event) wouldn't be caught by the
708832
// nil/non-nil assertions above. Assert the wired before/after events
@@ -714,16 +838,19 @@ func TestWirePerComponentHook(t *testing.T) {
714838
require.True(t, ok)
715839
return nodeHooks.beforeEvent, nodeHooks.afterEvent
716840
}
717-
planBefore, planAfter := eventsFor("plan")
718-
applyBefore, applyAfter := eventsFor("apply")
719-
deployBefore, deployAfter := eventsFor("deploy")
720-
721-
assert.NotEqual(t, planAfter, applyAfter, "plan and apply must fire different after-events")
722-
assert.NotEqual(t, planAfter, deployAfter, "plan and deploy must fire different after-events")
723-
assert.NotEqual(t, applyAfter, deployAfter, "apply and deploy must fire different after-events")
724-
assert.NotEqual(t, planBefore, applyBefore, "plan and apply must fire different before-events")
725-
assert.NotEqual(t, planBefore, deployBefore, "plan and deploy must fire different before-events")
726-
assert.NotEqual(t, applyBefore, deployBefore, "apply and deploy must fire different before-events")
841+
subs := []string{"plan", "apply", "deploy", "output", "refresh"}
842+
before := make(map[string]hooks.HookEvent, len(subs))
843+
after := make(map[string]hooks.HookEvent, len(subs))
844+
for _, sub := range subs {
845+
before[sub], after[sub] = eventsFor(sub)
846+
}
847+
848+
for i, a := range subs {
849+
for _, b := range subs[i+1:] {
850+
assert.NotEqual(t, after[a], after[b], "%s and %s must fire different after-events", a, b)
851+
assert.NotEqual(t, before[a], before[b], "%s and %s must fire different before-events", a, b)
852+
}
853+
}
727854
})
728855
}
729856

pkg/hooks/event.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ const (
1515
BeforeTerraformPlan HookEvent = "before.terraform.plan"
1616
BeforeTerraformTest HookEvent = "before.terraform.test"
1717
AfterTerraformTest HookEvent = "after.terraform.test"
18+
BeforeTerraformOutput HookEvent = "before.terraform.output"
19+
AfterTerraformOutput HookEvent = "after.terraform.output"
20+
BeforeTerraformRefresh HookEvent = "before.terraform.refresh"
21+
AfterTerraformRefresh HookEvent = "after.terraform.refresh"
1822
BeforeTerraformDeploy HookEvent = "before.terraform.deploy"
1923
AfterTerraformDeploy HookEvent = "after.terraform.deploy"
2024
AfterTerraformDestroyAggregate HookEvent = "after.terraform.destroy.aggregate"

pkg/hooks/event_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ func TestHookEvent_IsPostExecution(t *testing.T) {
1717
{name: "before-plan is pre-execution", event: BeforeTerraformPlan, want: false},
1818
{name: "after-plan is post-execution", event: AfterTerraformPlan, want: true},
1919
{name: "after-apply is post-execution", event: AfterTerraformApply, want: true},
20+
{name: "before-output is pre-execution", event: BeforeTerraformOutput, want: false},
21+
{name: "after-output is post-execution", event: AfterTerraformOutput, want: true},
22+
{name: "before-refresh is pre-execution", event: BeforeTerraformRefresh, want: false},
23+
{name: "after-refresh is post-execution", event: AfterTerraformRefresh, want: true},
2024
}
2125
for _, tt := range tests {
2226
t.Run(tt.name, func(t *testing.T) {
@@ -30,3 +34,13 @@ func TestHookEvent_Normalize_InitNotAliased(t *testing.T) {
3034
assert.Equal(t, BeforeTerraformInit, BeforeTerraformInit.Normalize())
3135
assert.Equal(t, AfterTerraformInit, AfterTerraformInit.Normalize())
3236
}
37+
38+
func TestHookEvent_Normalize_OutputRefreshNotAliased(t *testing.T) {
39+
// output and refresh are read-only operations with no deploy-style alias —
40+
// they normalize to themselves, and must stay distinct from apply/plan so a
41+
// hook scoped to one does not cross-fire on the other.
42+
assert.Equal(t, BeforeTerraformOutput, BeforeTerraformOutput.Normalize())
43+
assert.Equal(t, AfterTerraformOutput, AfterTerraformOutput.Normalize())
44+
assert.Equal(t, BeforeTerraformRefresh, BeforeTerraformRefresh.Normalize())
45+
assert.Equal(t, AfterTerraformRefresh, AfterTerraformRefresh.Normalize())
46+
}

0 commit comments

Comments
 (0)