From cb3dfa615f50bc0cf0adbd9746ccc489d8d72deb Mon Sep 17 00:00:00 2001 From: Mutahhir Hayat Date: Tue, 13 Jan 2026 14:50:42 +0100 Subject: [PATCH] Add integration test for stacks action invocation via lifecycle trigger --- internal/stacks/stackplan/from_proto.go | 3 + .../internal/stackeval/planning_test.go | 136 ++++++++++++++++++ .../action-lifecycle.tfcomponent.hcl | 24 ++++ .../action_lifecycle/module_web/main.tf | 31 ++++ 4 files changed, 194 insertions(+) create mode 100644 internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/action-lifecycle.tfcomponent.hcl create mode 100644 internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/module_web/main.tf diff --git a/internal/stacks/stackplan/from_proto.go b/internal/stacks/stackplan/from_proto.go index 6e2990b8fd..31fd11c97b 100644 --- a/internal/stacks/stackplan/from_proto.go +++ b/internal/stacks/stackplan/from_proto.go @@ -301,6 +301,9 @@ func (l *Loader) AddRaw(rawMsg *anypb.Any) error { DeferredReason: deferredReason, }) + case *tfstackdata1.PlanActionInvocationPlanned: + // TODO: Implemented in a future apply-related PR. + default: // Should not get here, because a stack plan can only be loaded by // the same version of Terraform that created it, and the above diff --git a/internal/stacks/stackruntime/internal/stackeval/planning_test.go b/internal/stacks/stackruntime/internal/stackeval/planning_test.go index 62d1595840..c05d290546 100644 --- a/internal/stacks/stackruntime/internal/stackeval/planning_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/planning_test.go @@ -1053,3 +1053,139 @@ func mustPlanDynamicValue(t *testing.T, v cty.Value) *tfstackdata1.DynamicValue } return tfstackdata1.Terraform1ToStackDataDynamicValue(ret) } + +func TestPlanning_ActionInvocationLifecycle(t *testing.T) { + // This integration test verifies that action invocations with lifecycle + // triggers are correctly planned and included in the PlannedChange objects. + + cfg := testStackConfig(t, "planning", "action_lifecycle") + componentInstAddr := stackaddrs.AbsComponentInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ComponentInstance{ + Component: stackaddrs.Component{ + Name: "web", + }, + }, + } + actionInstAddr := addrs.AbsActionInstance{ + Module: addrs.RootModuleInstance, + Action: addrs.ActionInstance{ + Action: addrs.Action{ + Type: "test_action", + Name: "notify", + }, + Key: addrs.NoKey, + }, + } + providerAddr := addrs.NewBuiltInProvider("test") + providerInstAddr := addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: providerAddr, + } + + resourceTypeSchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Required: true, + }, + }, + } + actionTypeSchema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "message": { + Type: cty.String, + Required: true, + }, + }, + } + + main := NewForPlanning(cfg, stackstate.NewState(), PlanOpts{ + PlanningMode: plans.NormalMode, + PlanTimestamp: time.Now().UTC(), + ProviderFactories: ProviderFactories{ + addrs.NewBuiltInProvider("test"): func() (providers.Interface, error) { + return &providerTesting.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + Provider: providers.Schema{ + Body: &configschema.Block{}, + }, + ResourceTypes: map[string]providers.Schema{ + "test_resource": { + Body: resourceTypeSchema, + }, + }, + Actions: map[string]providers.ActionSchema{ + "test_action": { + ConfigSchema: actionTypeSchema, + }, + }, + }, + ConfigureProviderFn: func(cpr providers.ConfigureProviderRequest) providers.ConfigureProviderResponse { + return providers.ConfigureProviderResponse{} + }, + PlanResourceChangeFn: func(prcr providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: prcr.ProposedNewState, + } + }, + }, nil + }, + }, + }) + + outp, outpTest := testPlanOutput(t) + main.PlanAll(context.Background(), outp) + plan, diags := outpTest.Close(t) + assertNoDiagnostics(t, diags) + + cmpPlan := plan.GetComponent(componentInstAddr) + if cmpPlan == nil { + t.Fatalf("no plan for %s", componentInstAddr) + } + + // Verify that we have planned changes for action invocations + plannedChanges := outpTest.PlannedChanges() + var foundActionChange *stackplan.PlannedChangeActionInvocationInstancePlanned + for _, pc := range plannedChanges { + if actionChange, ok := pc.(*stackplan.PlannedChangeActionInvocationInstancePlanned); ok { + foundActionChange = actionChange + break + } + } + + if foundActionChange == nil { + t.Fatalf("no action invocation planned change found; got %d changes", len(plannedChanges)) + } + + // Verify the action invocation details + if got, want := foundActionChange.ActionInvocationAddr.Component.String(), componentInstAddr.String(); got != want { + t.Errorf("wrong component instance\ngot: %s\nwant: %s", got, want) + } + if got, want := foundActionChange.ActionInvocationAddr.Item.String(), actionInstAddr.String(); got != want { + t.Errorf("wrong action instance\ngot: %s\nwant: %s", got, want) + } + if got, want := foundActionChange.ProviderConfigAddr.String(), providerInstAddr.String(); got != want { + t.Errorf("wrong provider config addr\ngot: %s\nwant: %s", got, want) + } + + // Verify the invocation has the correct trigger type + if foundActionChange.Invocation == nil { + t.Fatal("invocation is nil") + } + if _, ok := foundActionChange.Invocation.ActionTrigger.(*plans.LifecycleActionTrigger); !ok { + t.Errorf("wrong action trigger type\ngot: %T\nwant: *plans.LifecycleActionTrigger", foundActionChange.Invocation.ActionTrigger) + } + + // Verify we can convert to proto successfully + protoChange, err := foundActionChange.PlannedChangeProto() + if err != nil { + t.Fatalf("failed to convert to proto: %s", err) + } + if protoChange == nil { + t.Fatal("proto change is nil") + } + if len(protoChange.Descriptions) == 0 { + t.Error("expected at least one description in proto change") + } +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/action-lifecycle.tfcomponent.hcl b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/action-lifecycle.tfcomponent.hcl new file mode 100644 index 0000000000..b43965bb65 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/action-lifecycle.tfcomponent.hcl @@ -0,0 +1,24 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: BUSL-1.1 + +required_providers { + test = { + source = "terraform.io/builtin/test" + } +} + +provider "test" "main" { +} + +component "web" { + source = "./module_web" + + providers = { + test = provider.test.main + } +} + +output "result" { + type = string + value = component.web.result +} diff --git a/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/module_web/main.tf b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/module_web/main.tf new file mode 100644 index 0000000000..1d1a4d191a --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/testdata/sourcebundle/planning/action_lifecycle/module_web/main.tf @@ -0,0 +1,31 @@ + +terraform { + required_providers { + test = { + source = "terraform.io/builtin/test" + + configuration_aliases = [ test ] + } + } +} + +action "test_action" "notify" { + config { + message = "resource created" + } +} + +resource "test_resource" "main" { + value = "example" + + lifecycle { + action_trigger { + events = [after_create] + actions = [action.test_action.notify] + } + } +} + +output "result" { + value = test_resource.main.value +}