From ffeff0914db0cfcf528da5b0191924233c7501d3 Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Wed, 4 Feb 2026 12:43:12 +0100 Subject: [PATCH] Support for deferred action invocations in plan We encovered that deferred action invocations don't get provider addresses, which prevents us from loading the schema. That being said, I think it shouldn't be an issue, but will come back to revisit this as we build the support end to end. Add a test for deferred actions support --- internal/stacks/stackplan/from_plan.go | 26 +++++++++++++++++++ internal/stacks/stackruntime/helper_test.go | 2 ++ internal/stacks/stackruntime/plan_test.go | 24 ++++++++++++++--- .../mainbundle/test/deferred-action/main.tf | 2 +- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/internal/stacks/stackplan/from_plan.go b/internal/stacks/stackplan/from_plan.go index 0d49147892..daa2c924b7 100644 --- a/internal/stacks/stackplan/from_plan.go +++ b/internal/stacks/stackplan/from_plan.go @@ -207,6 +207,32 @@ func FromPlan(ctx context.Context, config *configs.Config, plan *plans.Plan, ref }) } + // Handle deferred action invocations from the plan + for _, deferredAction := range plan.DeferredActionInvocations { + invocation := deferredAction.ActionInvocationInstanceSrc + + if invocation == nil { + continue + } + + // For deferred actions, the provider address is typically empty because + // actions are deferred before being fully evaluated. We create the planned + // change without schema since we can't fetch it without a provider address. + plannedActionInvocation := PlannedChangeActionInvocationInstancePlanned{ + ActionInvocationAddr: stackaddrs.AbsActionInvocationInstance{ + Component: producer.Addr(), + Item: invocation.Addr, + }, + Invocation: invocation, + Schema: providers.ActionSchema{}, // Empty schema for deferred actions + ProviderConfigAddr: invocation.ProviderAddr, // Will be empty, that's expected + } + changes = append(changes, &PlannedChangeDeferredActionInvocation{ + DeferredReason: deferredAction.DeferredReason, + ActionInvocationPlanned: plannedActionInvocation, + }) + } + // We also need to catch any objects that exist in the "prior state" // but don't have any actions planned, since we still need to capture // the prior state part in case it was updated by refreshing during diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index 6e18385b97..3d39c69247 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -443,6 +443,8 @@ func plannedChangeSortKey(change stackplan.PlannedChange) string { return "function-results" case *stackplan.PlannedChangeActionInvocationInstancePlanned: return change.ActionInvocationAddr.String() + case *stackplan.PlannedChangeDeferredActionInvocation: + return "deferred:" + change.ActionInvocationPlanned.ActionInvocationAddr.String() default: // This is only going to happen during tests, so we can panic here. panic(fmt.Errorf("unrecognized planned change type: %T", change)) diff --git a/internal/stacks/stackruntime/plan_test.go b/internal/stacks/stackruntime/plan_test.go index 9586557244..61e3d6006e 100644 --- a/internal/stacks/stackruntime/plan_test.go +++ b/internal/stacks/stackruntime/plan_test.go @@ -6573,15 +6573,33 @@ func TestPlanWithDeferredActionInvocation(t *testing.T) { return plannedChangeSortKey(gotChanges[i]) < plannedChangeSortKey(gotChanges[j]) }) - // Find the deferred action invocation in the changes + // First, let's verify the resource was actually deferred + var foundDeferredResource bool + var foundNormalActionInvocation bool var foundDeferredAction bool + for _, change := range gotChanges { - if _, ok := change.(*stackplan.PlannedChangeDeferredActionInvocation); ok { + switch c := change.(type) { + case *stackplan.PlannedChangeDeferredResourceInstancePlanned: + foundDeferredResource = true + t.Logf("Found deferred resource: %s", c.ResourceInstancePlanned.ResourceInstanceObjectAddr) + case *stackplan.PlannedChangeActionInvocationInstancePlanned: + foundNormalActionInvocation = true + t.Logf("Found normal action invocation: %s", c.ActionInvocationAddr) + case *stackplan.PlannedChangeDeferredActionInvocation: foundDeferredAction = true - break + t.Logf("Found deferred action invocation: %s", c.ActionInvocationPlanned.ActionInvocationAddr) } } + if !foundDeferredResource { + t.Error("Expected to find a deferred resource, but none was found") + } + + if foundNormalActionInvocation { + t.Error("Action invocation should be deferred, not appearing as a normal invocation") + } + if !foundDeferredAction { t.Error("Expected to find a deferred action invocation in the plan changes, but none was found") t.Logf("Got %d changes:", len(gotChanges)) diff --git a/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf index 8a8cc18806..9b88cbe0c0 100644 --- a/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf +++ b/internal/stacks/stackruntime/testdata/mainbundle/test/deferred-action/main.tf @@ -18,7 +18,7 @@ variable "defer" { type = bool } -# Action that should be invoked when resource is created +# Simple action action "testing_action" "notify" { config { message = "resource created with id ${var.id}"