From 698b7bb4b4125bea36ccfacf9ce490147e2e22c8 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Mon, 24 Nov 2025 11:49:32 +0100 Subject: [PATCH] actions: connect resource instance nodes to after actions --- .changes/v1.14/BUG FIXES-20251124-150000.yaml | 5 + .../terraform/context_apply_action_test.go | 116 ++++++++++++++++++ internal/terraform/graph_builder_apply.go | 3 +- internal/terraform/graph_builder_plan.go | 3 +- .../transform_action_trigger_config.go | 29 ++++- internal/terraform/transform_targets.go | 7 ++ 6 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 .changes/v1.14/BUG FIXES-20251124-150000.yaml diff --git a/.changes/v1.14/BUG FIXES-20251124-150000.yaml b/.changes/v1.14/BUG FIXES-20251124-150000.yaml new file mode 100644 index 0000000000..798c4744cc --- /dev/null +++ b/.changes/v1.14/BUG FIXES-20251124-150000.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'actions: make after_create & after_update actions run after the resource has applied' +time: 2025-11-24T15:00:00.316597+01:00 +custom: + Issue: "37936" diff --git a/internal/terraform/context_apply_action_test.go b/internal/terraform/context_apply_action_test.go index 63bd0c170f..07fe19009d 100644 --- a/internal/terraform/context_apply_action_test.go +++ b/internal/terraform/context_apply_action_test.go @@ -4,9 +4,11 @@ package terraform import ( + "fmt" "path/filepath" "sync" "testing" + "time" "github.com/google/go-cmp/cmp" "github.com/hashicorp/hcl/v2" @@ -2811,3 +2813,117 @@ func (a *actionHookCapture) Stopping() {} func (a *actionHookCapture) PostStateUpdate(*states.State) (HookAction, error) { return HookActionContinue, nil } + +func TestContextApply_actions_after_trigger_runs_after_expanded_resource(t *testing.T) { + m := testModuleInline(t, map[string]string{ + "main.tf": ` +locals { + each = toset(["one"]) +} +action "action_example" "hello" { + config { + attr = "hello" + } +} +resource "test_object" "a" { + for_each = local.each + name = each.value + lifecycle { + action_trigger { + events = [after_create] + actions = [action.action_example.hello] + } + } +} +`, + }) + + orderedCalls := []string{} + + testProvider := &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "test_object": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + }, + ApplyResourceChangeFn: func(arcr providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse { + time.Sleep(100 * time.Millisecond) + orderedCalls = append(orderedCalls, fmt.Sprintf("ApplyResourceChangeFn %s", arcr.TypeName)) + return providers.ApplyResourceChangeResponse{ + NewState: arcr.PlannedState, + NewIdentity: arcr.PlannedIdentity, + } + }, + } + + actionProvider := &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + Actions: map[string]providers.ActionSchema{ + "action_example": { + ConfigSchema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "attr": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + ResourceTypes: map[string]providers.Schema{}, + }, + InvokeActionFn: func(iar providers.InvokeActionRequest) providers.InvokeActionResponse { + orderedCalls = append(orderedCalls, fmt.Sprintf("InvokeAction %s", iar.ActionType)) + return providers.InvokeActionResponse{ + Events: func(yield func(providers.InvokeActionEvent) bool) { + yield(providers.InvokeActionEvent_Completed{}) + }, + } + }, + } + + hookCapture := newActionHookCapture() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(testProvider), + addrs.NewDefaultProvider("action"): testProviderFuncFixed(actionProvider), + }, + Hooks: []Hook{ + &hookCapture, + }, + }) + + // Just a sanity check that the module is valid + diags := ctx.Validate(m, &ValidateOpts{}) + tfdiags.AssertNoDiagnostics(t, diags) + + planOpts := SimplePlanOpts(plans.NormalMode, InputValues{}) + + plan, diags := ctx.Plan(m, nil, planOpts) + tfdiags.AssertNoDiagnostics(t, diags) + + if !plan.Applyable { + t.Fatalf("plan is not applyable but should be") + } + + _, diags = ctx.Apply(plan, m, nil) + tfdiags.AssertNoDiagnostics(t, diags) + + expectedOrder := []string{ + "ApplyResourceChangeFn test_object", + "InvokeAction action_example", + } + + if diff := cmp.Diff(expectedOrder, orderedCalls); diff != "" { + t.Fatalf("expected calls in order did not match actual calls (-expected +actual):\n%s", diff) + } +} diff --git a/internal/terraform/graph_builder_apply.go b/internal/terraform/graph_builder_apply.go index 8835a434e5..6f2353a095 100644 --- a/internal/terraform/graph_builder_apply.go +++ b/internal/terraform/graph_builder_apply.go @@ -167,7 +167,8 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer { } }, // we want before_* actions to run before and after_* actions to run after the resource - CreateNodesAsAfter: false, + CreateNodesAsAfter: false, + ConnectToResourceInstanceNodes: true, }, &ActionInvokeApplyTransformer{ diff --git a/internal/terraform/graph_builder_plan.go b/internal/terraform/graph_builder_plan.go index 4a395ed7e0..bcf4467758 100644 --- a/internal/terraform/graph_builder_plan.go +++ b/internal/terraform/graph_builder_plan.go @@ -178,7 +178,8 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { }, // We plan all actions after the resource is handled - CreateNodesAsAfter: true, + CreateNodesAsAfter: true, + ConnectToResourceInstanceNodes: false, }, &ActionInvokePlanTransformer{ diff --git a/internal/terraform/transform_action_trigger_config.go b/internal/terraform/transform_action_trigger_config.go index cb98d3e348..9696c04a09 100644 --- a/internal/terraform/transform_action_trigger_config.go +++ b/internal/terraform/transform_action_trigger_config.go @@ -19,8 +19,9 @@ type ActionTriggerConfigTransformer struct { queryPlanMode bool - ConcreteActionTriggerNodeFunc ConcreteActionTriggerNodeFunc - CreateNodesAsAfter bool + ConcreteActionTriggerNodeFunc ConcreteActionTriggerNodeFunc + CreateNodesAsAfter bool + ConnectToResourceInstanceNodes bool // if false it connects to resource nodes instead of resource instance nodes } func (t *ActionTriggerConfigTransformer) Transform(g *Graph) error { @@ -55,7 +56,11 @@ func (t *ActionTriggerConfigTransformer) transformSingle(g *Graph, config *confi } resourceNodes := addrs.MakeMap[addrs.ConfigResource, []GraphNodeConfigResource]() + resourceInstanceNodes := addrs.MakeMap[addrs.ConfigResource, []GraphNodeResourceInstance]() for _, node := range g.Vertices() { + if rin, ok := node.(GraphNodeResourceInstance); ok { + resourceInstanceNodes.Put(rin.ResourceInstanceAddr().ConfigResource(), append(resourceInstanceNodes.Get(rin.ResourceInstanceAddr().ConfigResource()), rin)) + } rn, ok := node.(GraphNodeConfigResource) if !ok { continue @@ -141,8 +146,14 @@ func (t *ActionTriggerConfigTransformer) transformSingle(g *Graph, config *confi g.Add(nat) // We want to run before the resource nodes - for _, node := range resourceNode { - g.Connect(dag.BasicEdge(node, nat)) + if t.ConnectToResourceInstanceNodes { + for _, node := range resourceInstanceNodes.Get(resourceAddr) { + g.Connect(dag.BasicEdge(node, nat)) + } + } else { + for _, node := range resourceNode { + g.Connect(dag.BasicEdge(node, nat)) + } } // We want to run after all prior nodes @@ -157,8 +168,14 @@ func (t *ActionTriggerConfigTransformer) transformSingle(g *Graph, config *confi g.Add(nat) // We want to run after the resource nodes - for _, node := range resourceNode { - g.Connect(dag.BasicEdge(nat, node)) + if t.ConnectToResourceInstanceNodes { + for _, node := range resourceInstanceNodes.Get(resourceAddr) { + g.Connect(dag.BasicEdge(nat, node)) + } + } else { + for _, node := range resourceNode { + g.Connect(dag.BasicEdge(nat, node)) + } } // We want to run after all prior nodes diff --git a/internal/terraform/transform_targets.go b/internal/terraform/transform_targets.go index b299e57ce9..10fabdae37 100644 --- a/internal/terraform/transform_targets.go +++ b/internal/terraform/transform_targets.go @@ -231,4 +231,11 @@ func (t *TargetsTransformer) addVertexDependenciesToTargetedNodes(g *Graph, v da } } } + if _, ok := v.(*NodeApplyableResourceInstance); ok { + for _, f := range g.UpEdges(v) { + if _, ok := f.(*nodeActionTriggerApplyExpand); ok { + t.addVertexDependenciesToTargetedNodes(g, f, targetedNodes, addrs) + } + } + } }