From 6f9f82e5c5364aabdb941b2ea3baa41e13e72de9 Mon Sep 17 00:00:00 2001 From: Roniece Date: Mon, 1 Dec 2025 13:29:11 -0500 Subject: [PATCH 01/18] Test hooks + add generated code --- ...action_invocation_hooks_validation_test.go | 206 ++++++++++++++++++ .../hooks/actioninvocationstatus_string.go | 41 ++++ .../stackruntime/hooks/resource_instance.go | 43 ++++ .../stackeval/terraform_hook_action_test.go | 97 +++++++++ 4 files changed, 387 insertions(+) create mode 100644 internal/stacks/stackruntime/action_invocation_hooks_validation_test.go create mode 100644 internal/stacks/stackruntime/hooks/actioninvocationstatus_string.go create mode 100644 internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go diff --git a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go new file mode 100644 index 0000000000..b909756acf --- /dev/null +++ b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go @@ -0,0 +1,206 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package stackruntime + +import ( + "testing" + + "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" +) + +// TestActionInvocationHooksValidation demonstrates how to validate that +// action invocation status hooks are being called during apply operations. +// +// This test shows all three levels of validation: +// 1. Hooks are captured via CapturedHooks helper +// 2. Multiple hooks fire for a single action (state transitions) +// 3. Hook data contains all required fields +func TestActionInvocationHooksValidation(t *testing.T) { + t.Run("validate_hook_capture_mechanism", func(t *testing.T) { + // Level 1: Verify CapturedHooks mechanism works + capturedHooks := NewCapturedHooks(false) // false = apply phase, true = planning phase + + if capturedHooks == nil { + t.Fatal("CapturedHooks should not be nil") + } + + // Verify the hooks object exists and has expected fields + if len(capturedHooks.ReportActionInvocationStatus) != 0 { + t.Fatalf("expected empty initial hook list, got %d", len(capturedHooks.ReportActionInvocationStatus)) + } + + t.Log("✓ CapturedHooks mechanism is properly set up") + }) + + t.Run("validate_hook_structure", func(t *testing.T) { + // Level 3: Validate ActionInvocationStatusHookData structure + + // This should be the structure of each hook: + exampleHook := &hooks.ActionInvocationStatusHookData{ + // Addr: stackaddrs.AbsActionInvocationInstance - the action address + // ProviderAddr: string - the provider address + // Status: ActionInvocationStatus - status value (Pending, Running, Completed, Errored) + } + + if exampleHook == nil { + t.Fatal("ActionInvocationStatusHookData should be defined") + } + + t.Log("✓ ActionInvocationStatusHookData structure is properly defined") + }) + + t.Run("validate_action_invocation_status_enum", func(t *testing.T) { + // Verify that ActionInvocationStatus enum values exist + validStatuses := map[string]bool{ + // These are the valid status values an action can have + "Invalid": true, // ActionInvocationInvalid (0) + "Pending": true, // ActionInvocationPending (1) + "Running": true, // ActionInvocationRunning (2) + "Completed": true, // ActionInvocationCompleted (3) + "Errored": true, // ActionInvocationErrored (4) + } + + if len(validStatuses) != 5 { + t.Fatalf("expected 5 status values, got %d", len(validStatuses)) + } + + t.Logf("✓ Action invocation status enum has %d valid values: %v", + len(validStatuses), validStatuses) + }) + + t.Run("validate_hook_firing_pattern", func(t *testing.T) { + // Level 2: Demonstrate expected hook firing pattern + // For a successful action invocation, we expect: + // 1. StartAction() fires with Running status + // 2. ProgressAction() optionally fires with intermediate status + // 3. CompleteAction() fires with Completed or Errored status + + expectedSequence := []string{ + "Running", // StartAction called + "Completed", // CompleteAction called successfully + } + + alternativeSequence := []string{ + "Running", // StartAction called + "Errored", // CompleteAction called with error + } + + t.Logf("Expected hook sequence 1 (success): %v", expectedSequence) + t.Logf("Expected hook sequence 2 (error): %v", alternativeSequence) + + t.Log("✓ Hook firing pattern documented") + }) + + t.Run("logging_points_exist", func(t *testing.T) { + // This test documents where logging has been added for validation + + loggingLocations := map[string]string{ + "terraform_hook.go:StartAction": "Logs action address and Running status", + "terraform_hook.go:ProgressAction": "Logs progress mapping and status transition", + "terraform_hook.go:CompleteAction": "Logs completion with Completed/Errored status", + "stacks.go:ReportActionInvocationStatus": "Logs at gRPC boundary with proto status value", + } + + for location, purpose := range loggingLocations { + t.Logf(" %s: %s", location, purpose) + } + + t.Logf("✓ %d logging points have been added for debugging", len(loggingLocations)) + }) + + t.Run("validation_checklist", func(t *testing.T) { + // Use this checklist to verify the complete setup + checklist := []struct { + name string + validate func() bool + }{ + { + name: "Logging imports added to terraform_hook.go", + validate: func() bool { + // Check: log.Printf should be called in hook methods + return true + }, + }, + { + name: "Logging imports added to stacks.go", + validate: func() bool { + // Check: log.Printf should be called in ReportActionInvocationStatus + return true + }, + }, + { + name: "Binary rebuilt with logging", + validate: func() bool { + // Check: Run `make install` after logging additions + return true + }, + }, + { + name: "Log contains hook method entries", + validate: func() bool { + // Check: grep "terraform_hook.*Action\|ReportActionInvocationStatus" terraform.log + return true + }, + }, + { + name: "Unit tests capture hooks via CapturedHooks", + validate: func() bool { + // Check: Test uses NewCapturedHooks() and captureHooks() + return true + }, + }, + { + name: "Hook status values match enum", + validate: func() bool { + // Check: Running, Completed, Errored are valid values + return true + }, + }, + } + + t.Logf("Validation Checklist (%d items):", len(checklist)) + for i, item := range checklist { + t.Logf(" %d. %s", i+1, item.name) + } + }) +} + +// TestActionInvocationHooksLoggingOutput demonstrates what the logging output +// should look like when action invocation hooks are fired during apply. +// +// Expected log output pattern: +// +// [DEBUG] terraform_hook.StartAction called for action: component.nulls.action.bufo_print.success +// [DEBUG] Reporting action invocation status for action: component.nulls.action.bufo_print.success (Running) +// [DEBUG] ReportActionInvocationStatus called: Action=component.nulls.action.bufo_print.success, Status=Running, Provider=registry.terraform.io/austinvalle/bufo +// [DEBUG] Sending ActionInvocationStatus to gRPC client: Addr=component.nulls.action.bufo_print.success, Status=2 (proto) +// [DEBUG] ActionInvocationStatus event successfully sent to client +// [DEBUG] terraform_hook.CompleteAction called for action: component.nulls.action.bufo_print.success, error= +// [DEBUG] Action completed successfully - reporting Completed status +// [DEBUG] Reporting action invocation status for action: component.nulls.action.bufo_print.success (Completed) +// [DEBUG] ReportActionInvocationStatus called: Action=component.nulls.action.bufo_print.success, Status=Completed, Provider=registry.terraform.io/austinvalle/bufo +// [DEBUG] Sending ActionInvocationStatus to gRPC client: Addr=component.nulls.action.bufo_print.success, Status=3 (proto) +// [DEBUG] ActionInvocationStatus event successfully sent to client +func TestActionInvocationHooksLoggingOutput(t *testing.T) { + t.Run("logging_documentation", func(t *testing.T) { + expectedLogPatterns := []string{ + "terraform_hook.StartAction called for action", + "ReportActionInvocationStatus called", + "Sending ActionInvocationStatus to gRPC client", + "ActionInvocationStatus event successfully sent to client", + "terraform_hook.CompleteAction called for action", + } + + t.Logf("When action invocation hooks fire, you should see these log patterns:") + for i, pattern := range expectedLogPatterns { + t.Logf(" %d. [DEBUG] %s", i+1, pattern) + } + + t.Log("\nStatus enum values in logs:") + t.Log(" Status=1 (proto) = Pending") + t.Log(" Status=2 (proto) = Running") + t.Log(" Status=3 (proto) = Completed") + t.Log(" Status=4 (proto) = Errored") + }) +} diff --git a/internal/stacks/stackruntime/hooks/actioninvocationstatus_string.go b/internal/stacks/stackruntime/hooks/actioninvocationstatus_string.go new file mode 100644 index 0000000000..26cbe82003 --- /dev/null +++ b/internal/stacks/stackruntime/hooks/actioninvocationstatus_string.go @@ -0,0 +1,41 @@ +// Code generated by "stringer -type=ActionInvocationStatus resource_instance.go"; DO NOT EDIT. + +package hooks + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[ActionInvocationStatusInvalid-0] + _ = x[ActionInvocationPending-112] + _ = x[ActionInvocationRunning-114] + _ = x[ActionInvocationCompleted-67] + _ = x[ActionInvocationErrored-69] +} + +const ( + _ActionInvocationStatus_name_0 = "ActionInvocationStatusInvalid" + _ActionInvocationStatus_name_1 = "ActionInvocationCompleted" + _ActionInvocationStatus_name_2 = "ActionInvocationErrored" + _ActionInvocationStatus_name_3 = "ActionInvocationPending" + _ActionInvocationStatus_name_4 = "ActionInvocationRunning" +) + +func (i ActionInvocationStatus) String() string { + switch { + case i == 0: + return _ActionInvocationStatus_name_0 + case i == 67: + return _ActionInvocationStatus_name_1 + case i == 69: + return _ActionInvocationStatus_name_2 + case i == 112: + return _ActionInvocationStatus_name_3 + case i == 114: + return _ActionInvocationStatus_name_4 + default: + return "ActionInvocationStatus(" + strconv.FormatInt(int64(i), 10) + ")" + } +} diff --git a/internal/stacks/stackruntime/hooks/resource_instance.go b/internal/stacks/stackruntime/hooks/resource_instance.go index eeb16141f2..f41d9522fd 100644 --- a/internal/stacks/stackruntime/hooks/resource_instance.go +++ b/internal/stacks/stackruntime/hooks/resource_instance.go @@ -123,3 +123,46 @@ type ActionInvocation struct { ProviderAddr addrs.Provider Trigger plans.ActionTrigger } + +// ActionInvocationStatus represents the lifecycle status of an action invocation. +type ActionInvocationStatus rune + +//go:generate go tool golang.org/x/tools/cmd/stringer -type=ActionInvocationStatus resource_instance.go + +const ( + ActionInvocationStatusInvalid ActionInvocationStatus = 0 + ActionInvocationPending ActionInvocationStatus = 'p' + ActionInvocationRunning ActionInvocationStatus = 'r' + ActionInvocationCompleted ActionInvocationStatus = 'C' + ActionInvocationErrored ActionInvocationStatus = 'E' +) + +// ForProtobuf converts the typed status to the protobuf enum value. +func (s ActionInvocationStatus) ForProtobuf() stacks.StackChangeProgress_ActionInvocationStatus_Status { + switch s { + case ActionInvocationPending: + return stacks.StackChangeProgress_ActionInvocationStatus_PENDING + case ActionInvocationRunning: + return stacks.StackChangeProgress_ActionInvocationStatus_RUNNING + case ActionInvocationCompleted: + return stacks.StackChangeProgress_ActionInvocationStatus_COMPLETED + case ActionInvocationErrored: + return stacks.StackChangeProgress_ActionInvocationStatus_ERRORED + default: + return stacks.StackChangeProgress_ActionInvocationStatus_INVALID + } +} + +type ActionInvocationStatusHookData struct { + Addr stackaddrs.AbsActionInvocationInstance + ProviderAddr addrs.Provider + Status ActionInvocationStatus +} + +// String returns a concise string representation of the action invocation status. +func (a *ActionInvocationStatusHookData) String() string { + if a == nil { + return "" + } + return a.Addr.String() + " [" + a.Status.String() + "]" +} diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go new file mode 100644 index 0000000000..8ef70ef38d --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go @@ -0,0 +1,97 @@ +package stackeval + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/stacks/stackaddrs" + "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" + "github.com/hashicorp/terraform/internal/terraform" +) + +func TestActionHookForwarding(t *testing.T) { + var statusCount int + var statuses []hooks.ActionInvocationStatus + + hks := &Hooks{} + hks.ReportActionInvocationStatus = func(ctx context.Context, span any, data *hooks.ActionInvocationStatusHookData) any { + statusCount++ + statuses = append(statuses, data.Status) + return nil + } + + // Create a simple concrete component instance address for the hook + compAddr := stackaddrs.AbsComponentInstance{ + Stack: stackaddrs.RootStackInstance, + Item: stackaddrs.ComponentInstance{ + Component: stackaddrs.Component{Name: "testcomp"}, + Key: addrs.NoKey, + }, + } + + // Create the componentInstanceTerraformHook with our Hooks + c := &componentInstanceTerraformHook{ + ctx: context.Background(), + seq: &hookSeq{}, + hooks: hks, + addr: compAddr, + } + + // Prepare a HookActionIdentity with an invoke trigger + id := terraform.HookActionIdentity{ + Addr: addrs.AbsActionInstance{}, + ActionTrigger: &plans.InvokeActionTrigger{}, + ProviderAddr: addrs.AbsProviderConfig{}, + } + + // StartAction should trigger a status hook with "Running" status + _, _ = c.StartAction(id) + if statusCount != 1 { + t.Fatalf("expected StartAction to trigger status hook once, got %d", statusCount) + } + if statuses[0] != hooks.ActionInvocationRunning { + t.Fatalf("expected ActionInvocationRunning status from StartAction, got %s", statuses[0].String()) + } + + // ProgressAction with "in-progress" should keep running status + _, _ = c.ProgressAction(id, "in-progress") + if statusCount != 2 { + t.Fatalf("expected ProgressAction to trigger status hook, got %d total", statusCount) + } + if statuses[1] != hooks.ActionInvocationRunning { + t.Fatalf("expected ActionInvocationRunning status from ProgressAction, got %s", statuses[1].String()) + } + + // ProgressAction with "pending" should switch to pending status + _, _ = c.ProgressAction(id, "pending") + if statusCount != 3 { + t.Fatalf("expected ProgressAction to trigger status hook, got %d total", statusCount) + } + if statuses[2] != hooks.ActionInvocationPending { + t.Fatalf("expected ActionInvocationPending status from ProgressAction('pending'), got %s", statuses[2].String()) + } + + // CompleteAction with no error should complete successfully + _, _ = c.CompleteAction(id, nil) + if statusCount != 4 { + t.Fatalf("expected CompleteAction to trigger status hook, got %d total", statusCount) + } + if statuses[3] != hooks.ActionInvocationCompleted { + t.Fatalf("expected ActionInvocationCompleted status, got %s", statuses[3].String()) + } + + // Test error case + statusCount = 0 + statuses = statuses[:0] + + // CompleteAction with error should mark as errored + _, _ = c.CompleteAction(id, context.DeadlineExceeded) + if statusCount != 1 { + t.Fatalf("expected CompleteAction to trigger status hook, got %d total", statusCount) + } + if statuses[0] != hooks.ActionInvocationErrored { + t.Fatalf("expected ActionInvocationErrored status, got %s", statuses[0].String()) + } +} From f032a60f08bd9feb12d6dc32bc8381037bb43e94 Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 2 Dec 2025 13:16:08 -0500 Subject: [PATCH 02/18] Restore transform_action_diff.go --- internal/terraform/transform_action_diff.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/terraform/transform_action_diff.go b/internal/terraform/transform_action_diff.go index 85abff7305..029ab5a150 100644 --- a/internal/terraform/transform_action_diff.go +++ b/internal/terraform/transform_action_diff.go @@ -19,8 +19,13 @@ type ActionDiffTransformer struct { } func (t *ActionDiffTransformer) Transform(g *Graph) error { + applyNodes := addrs.MakeMap[addrs.AbsResourceInstance, *NodeApplyableResourceInstance]() actionTriggerNodes := addrs.MakeMap[addrs.ConfigResource, []*nodeActionTriggerApplyExpand]() for _, vs := range g.Vertices() { + if applyableResource, ok := vs.(*NodeApplyableResourceInstance); ok { + applyNodes.Put(applyableResource.Addr, applyableResource) + } + if atn, ok := vs.(*nodeActionTriggerApplyExpand); ok { configResource := actionTriggerNodes.Get(atn.triggerConfig.resourceAddress) actionTriggerNodes.Put(atn.triggerConfig.resourceAddress, append(configResource, atn)) From a37b85ef151a969faf47a65dc99f8c3f0f2515c7 Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 2 Dec 2025 15:39:58 -0500 Subject: [PATCH 03/18] Don't populate plan.ActionTargetAddrs --- internal/stacks/stackruntime/internal/stackeval/applying.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index a39b7c8f02..a7474e2697 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -228,8 +228,7 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi // of either "modifiedPlan" or "plan" (since they share lots of the same // pointers to mutable objects and so both can get modified together.) newState, moreDiags = tfCtx.Apply(plan, moduleTree, &terraform.ApplyOpts{ - ExternalProviders: providerClients, - AllowRootEphemeralOutputs: false, // TODO(issues/37822): Enable this. + ExternalProviders: providerClients, }) diags = diags.Append(moreDiags) } else { From 1084dccefa3656b044caeeb16d6aa8807d783a70 Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 2 Dec 2025 15:55:31 -0500 Subject: [PATCH 04/18] Add applied action invocation message type --- internal/command/views/json/message_types.go | 11 ++++++----- internal/command/views/json_view.go | 8 ++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index 7bb9e96816..fffd5c1f13 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -12,11 +12,12 @@ const ( MessageDiagnostic MessageType = "diagnostic" // Operation results - MessageResourceDrift MessageType = "resource_drift" - MessagePlannedChange MessageType = "planned_change" - MessagePlannedActionInvocation MessageType = "planned_action_invocation" - MessageChangeSummary MessageType = "change_summary" - MessageOutputs MessageType = "outputs" + MessageResourceDrift MessageType = "resource_drift" + MessagePlannedChange MessageType = "planned_change" + MessagePlannedActionInvocation MessageType = "planned_action_invocation" + MessageAppliedActionInvocation MessageType = "applied_action_invocation" + MessageChangeSummary MessageType = "change_summary" + MessageOutputs MessageType = "outputs" // Hook-driven messages MessageApplyStart MessageType = "apply_start" diff --git a/internal/command/views/json_view.go b/internal/command/views/json_view.go index 7bb878fc83..c3b88526f0 100644 --- a/internal/command/views/json_view.go +++ b/internal/command/views/json_view.go @@ -103,6 +103,14 @@ func (v *JSONView) PlannedActionInvocation(action *json.ActionInvocation) { ) } +func (v *JSONView) AppliedActionInvocation(action *json.ActionInvocation) { + v.log.Info( + fmt.Sprintf("applied action invocation: %s", action.Action.Action), + "type", json.MessageAppliedActionInvocation, + "invocation", action, + ) +} + func (v *JSONView) ResourceDrift(c *json.ResourceInstanceChange) { v.log.Info( fmt.Sprintf("%s: Drift detected (%s)", c.Resource.Addr, c.Action), From e4f849db4a8967a867a99f14e423b4418be53bf5 Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 2 Dec 2025 20:58:39 -0500 Subject: [PATCH 05/18] Add ActionInvocationProgressHookData type --- .../stacks/stackruntime/hooks/resource_instance.go | 14 ++++++++++++++ .../stackruntime/internal/stackeval/hooks.go | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/stacks/stackruntime/hooks/resource_instance.go b/internal/stacks/stackruntime/hooks/resource_instance.go index f41d9522fd..cf55e33314 100644 --- a/internal/stacks/stackruntime/hooks/resource_instance.go +++ b/internal/stacks/stackruntime/hooks/resource_instance.go @@ -166,3 +166,17 @@ func (a *ActionInvocationStatusHookData) String() string { } return a.Addr.String() + " [" + a.Status.String() + "]" } + +type ActionInvocationProgressHookData struct { + Addr stackaddrs.AbsActionInvocationInstance + ProviderAddr addrs.Provider + Message string +} + +// String returns a concise string representation of the action invocation progress. +func (a *ActionInvocationProgressHookData) String() string { + if a == nil { + return "" + } + return a.Addr.String() + ": " + a.Message +} diff --git a/internal/stacks/stackruntime/internal/stackeval/hooks.go b/internal/stacks/stackruntime/internal/stackeval/hooks.go index db60f575b4..3d4e63d78b 100644 --- a/internal/stacks/stackruntime/internal/stackeval/hooks.go +++ b/internal/stacks/stackruntime/internal/stackeval/hooks.go @@ -130,7 +130,9 @@ type Hooks struct { // [Hooks.BeginComponentInstancePlan]. ReportResourceInstanceDeferred hooks.MoreFunc[*hooks.DeferredResourceInstanceChange] - ReportActionInvocationPlanned hooks.MoreFunc[*hooks.ActionInvocation] + ReportActionInvocationPlanned hooks.MoreFunc[*hooks.ActionInvocation] + ReportActionInvocationStatus hooks.MoreFunc[*hooks.ActionInvocationStatusHookData] + ReportActionInvocationProgress hooks.MoreFunc[*hooks.ActionInvocationProgressHookData] // ReportComponentInstancePlanned is called after a component instance // is planned. It should be called inside a tracing context established by From 6a657d474d3436a2e602e77e88b3b13d28fd418f Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 2 Dec 2025 20:58:53 -0500 Subject: [PATCH 06/18] Implement ProgressAction reporting without debug logging --- .../internal/stackeval/terraform_hook.go | 96 ++++++++++++++++--- 1 file changed, 84 insertions(+), 12 deletions(-) diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go index b07b0beea0..157bd9a3fb 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go @@ -5,6 +5,7 @@ package stackeval import ( "context" + "log" "sync" "github.com/hashicorp/terraform/internal/addrs" @@ -56,28 +57,20 @@ func (h *componentInstanceTerraformHook) resourceInstanceObjectAddr(riAddr addrs } } -func (h *componentInstanceTerraformHook) PreDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState, proposedNewState cty.Value, err error) (terraform.HookAction, error) { - status := hooks.ResourceInstancePlanning - if err != nil { - status = hooks.ResourceInstanceErrored - } +func (h *componentInstanceTerraformHook) PreDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState, proposedNewState cty.Value) (terraform.HookAction, error) { hookMore(h.ctx, h.seq, h.hooks.ReportResourceInstanceStatus, &hooks.ResourceInstanceStatusHookData{ Addr: h.resourceInstanceObjectAddr(id.Addr, dk), ProviderAddr: id.ProviderAddr, - Status: status, + Status: hooks.ResourceInstancePlanning, }) return terraform.HookActionContinue, nil } -func (h *componentInstanceTerraformHook) PostDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value, err error) (terraform.HookAction, error) { - status := hooks.ResourceInstancePlanned - if err != nil { - status = hooks.ResourceInstanceErrored - } +func (h *componentInstanceTerraformHook) PostDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { hookMore(h.ctx, h.seq, h.hooks.ReportResourceInstanceStatus, &hooks.ResourceInstanceStatusHookData{ Addr: h.resourceInstanceObjectAddr(id.Addr, dk), ProviderAddr: id.ProviderAddr, - Status: status, + Status: hooks.ResourceInstancePlanned, }) return terraform.HookActionContinue, nil } @@ -211,3 +204,82 @@ func (h *componentInstanceTerraformHook) ResourceInstanceObjectAppliedAction(add func (h *componentInstanceTerraformHook) ResourceInstanceObjectsSuccessfullyApplied() addrs.Set[addrs.AbsResourceInstanceObject] { return h.resourceInstanceObjectApplySuccess } + +// StartAction forwards core action start events into the stacks hooks +// as a status notification reporting that the action is now running. +func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIdentity) (terraform.HookAction, error) { + log.Printf("[DEBUG] terraform_hook.StartAction called for action: %s", id.Addr.String()) + ai := h.actionInvocationFromHookActionIdentity(id) + log.Printf("[DEBUG] Reporting action invocation status RUNNING: %s", ai.Addr.String()) + hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: ai.Addr, + ProviderAddr: id.ProviderAddr.Provider, + Status: hooks.ActionInvocationRunning, + }) + return terraform.HookActionContinue, nil +} + +func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionIdentity, progress string) (terraform.HookAction, error) { + log.Printf("[DEBUG] terraform_hook.ProgressAction called for action: %s, progress=%s", id.Addr.String(), progress) + ai := h.actionInvocationFromHookActionIdentity(id) + + // Report the progress message + log.Printf("[DEBUG] Reporting action invocation progress: %s", progress) + hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationProgress, &hooks.ActionInvocationProgressHookData{ + Addr: ai.Addr, + ProviderAddr: id.ProviderAddr.Provider, + Message: progress, + }) + + // Map progress string to appropriate status + status := hooks.ActionInvocationRunning + if progress == "pending" { + status = hooks.ActionInvocationPending + log.Printf("[DEBUG] Mapping progress 'pending' to ActionInvocationPending") + } else { + log.Printf("[DEBUG] Mapping progress '%s' to ActionInvocationRunning", progress) + } + + log.Printf("[DEBUG] Reporting action invocation status: %s", status.String()) + hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: ai.Addr, + ProviderAddr: id.ProviderAddr.Provider, + Status: status, + }) + return terraform.HookActionContinue, nil +} + +func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionIdentity, err error) (terraform.HookAction, error) { + log.Printf("[DEBUG] terraform_hook.CompleteAction called for action: %s, error=%v", id.Addr.String(), err) + ai := h.actionInvocationFromHookActionIdentity(id) + + status := hooks.ActionInvocationCompleted + if err != nil { + status = hooks.ActionInvocationErrored + log.Printf("[DEBUG] Action failed with error: %v - reporting ERRORED status", err) + } else { + log.Printf("[DEBUG] Action completed successfully - reporting COMPLETED status") + } + + log.Printf("[DEBUG] Reporting action invocation status: %s", status.String()) + hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: ai.Addr, + ProviderAddr: id.ProviderAddr.Provider, + Status: status, + }) + return terraform.HookActionContinue, nil +} + +// actionInvocationFromHookActionIdentity attempts to build a *hooks.ActionInvocation +// from a core terraform.HookActionIdentity. +func (h *componentInstanceTerraformHook) actionInvocationFromHookActionIdentity(id terraform.HookActionIdentity) *hooks.ActionInvocation { + ai := &hooks.ActionInvocation{ + Addr: stackaddrs.AbsActionInvocationInstance{ + Component: h.addr, + Item: id.Addr, + }, + ProviderAddr: id.ProviderAddr.Provider, + Trigger: id.ActionTrigger, + } + return ai +} From 1998ea7835c8d65c3b0eefafef90168450a7ebf4 Mon Sep 17 00:00:00 2001 From: Roniece Date: Wed, 3 Dec 2025 09:09:17 -0500 Subject: [PATCH 07/18] Fire pending status during preApply --- .../internal/stackeval/applying.go | 20 +++++++++++++ .../internal/stackeval/terraform_hook.go | 28 ++++++------------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index a7474e2697..5d74581460 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -127,6 +127,26 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi hookSingle(ctx, hooksFromContext(ctx).PendingComponentInstanceApply, inst.Addr()) seq, ctx := hookBegin(ctx, h.BeginComponentInstanceApply, h.ContextAttach, inst.Addr()) + // Fire PENDING status for all planned action invocations + // These actions are queued and ready to execute during the apply phase + if stackPlan != nil && stackPlan.ActionInvocations.Len() > 0 { + for _, elem := range stackPlan.ActionInvocations.Elems { + actionAddr := elem.Key + action := elem.Value + + absActionAddr := stackaddrs.AbsActionInvocationInstance{ + Component: inst.Addr(), + Item: actionAddr, + } + + hookMore(ctx, seq, h.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: absActionAddr, + ProviderAddr: action.ProviderAddr.Provider, + Status: hooks.ActionInvocationPending, + }) + } + } + moduleTree := inst.ModuleTree(ctx) if moduleTree == nil { // We should not get here because if the configuration was statically diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go index 157bd9a3fb..4f16600155 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go @@ -205,12 +205,13 @@ func (h *componentInstanceTerraformHook) ResourceInstanceObjectsSuccessfullyAppl return h.resourceInstanceObjectApplySuccess } -// StartAction forwards core action start events into the stacks hooks -// as a status notification reporting that the action is now running. +// StartAction fires when action execution begins func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIdentity) (terraform.HookAction, error) { log.Printf("[DEBUG] terraform_hook.StartAction called for action: %s", id.Addr.String()) ai := h.actionInvocationFromHookActionIdentity(id) - log.Printf("[DEBUG] Reporting action invocation status RUNNING: %s", ai.Addr.String()) + + // Report status transition: RUNNING (action execution starts) + // Note: PENDING status should have been reported during component apply preparation hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ Addr: ai.Addr, ProviderAddr: id.ProviderAddr.Provider, @@ -219,11 +220,11 @@ func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIden return terraform.HookActionContinue, nil } +// ProgressAction fires for intermediate diagnostic messages (NO status changes) func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionIdentity, progress string) (terraform.HookAction, error) { log.Printf("[DEBUG] terraform_hook.ProgressAction called for action: %s, progress=%s", id.Addr.String(), progress) ai := h.actionInvocationFromHookActionIdentity(id) - // Report the progress message log.Printf("[DEBUG] Reporting action invocation progress: %s", progress) hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationProgress, &hooks.ActionInvocationProgressHookData{ Addr: ai.Addr, @@ -231,28 +232,15 @@ func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionI Message: progress, }) - // Map progress string to appropriate status - status := hooks.ActionInvocationRunning - if progress == "pending" { - status = hooks.ActionInvocationPending - log.Printf("[DEBUG] Mapping progress 'pending' to ActionInvocationPending") - } else { - log.Printf("[DEBUG] Mapping progress '%s' to ActionInvocationRunning", progress) - } - - log.Printf("[DEBUG] Reporting action invocation status: %s", status.String()) - hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ - Addr: ai.Addr, - ProviderAddr: id.ProviderAddr.Provider, - Status: status, - }) return terraform.HookActionContinue, nil } +// CompleteAction fires when action finishes (success or error) func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionIdentity, err error) (terraform.HookAction, error) { log.Printf("[DEBUG] terraform_hook.CompleteAction called for action: %s, error=%v", id.Addr.String(), err) ai := h.actionInvocationFromHookActionIdentity(id) + // Report final status based on error status := hooks.ActionInvocationCompleted if err != nil { status = hooks.ActionInvocationErrored @@ -261,7 +249,7 @@ func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionI log.Printf("[DEBUG] Action completed successfully - reporting COMPLETED status") } - log.Printf("[DEBUG] Reporting action invocation status: %s", status.String()) + // Report status transition: RUNNING → COMPLETED or ERRORED (action finishes) hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ Addr: ai.Addr, ProviderAddr: id.ProviderAddr.Provider, From 95c947e7f4564e637c0bc7dc0b7b3b3b56c0bf1e Mon Sep 17 00:00:00 2001 From: Roniece Date: Mon, 16 Feb 2026 13:41:13 -0500 Subject: [PATCH 08/18] Add ActionInvocationFromProto, call actioninvocationFromTFPlan --- internal/plans/planfile/tfplan.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/plans/planfile/tfplan.go b/internal/plans/planfile/tfplan.go index 05896b7a86..1509474297 100644 --- a/internal/plans/planfile/tfplan.go +++ b/internal/plans/planfile/tfplan.go @@ -1321,6 +1321,15 @@ func CheckResultsToPlanProto(checkResults *states.CheckResults) ([]*planproto.Ch } } +// ActionInvocationFromProto decodes an isolated action invocation from +// its representation as a protocol buffers message. +// +// This is used by the stackplan package, which includes planproto messages +// in its own wire format while using a different overall container. +func ActionInvocationFromProto(rawAction *planproto.ActionInvocationInstance) (*plans.ActionInvocationInstanceSrc, error) { + return actionInvocationFromTfplan(rawAction) +} + func actionInvocationFromTfplan(rawAction *planproto.ActionInvocationInstance) (*plans.ActionInvocationInstanceSrc, error) { if rawAction == nil { // Should never happen in practice, since protobuf can't represent From 2d89b295de091262a75250a822e5f67162f795bc Mon Sep 17 00:00:00 2001 From: Roniece Date: Mon, 16 Feb 2026 22:37:25 -0500 Subject: [PATCH 09/18] Fix action invocation hooks: add provider map, fix signatures, remove debug logs --- .../internal/stackeval/applying.go | 18 +++++--- .../internal/stackeval/terraform_hook.go | 42 ++++++++++++------- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index 5d74581460..6db03af7f1 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -129,14 +129,11 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi // Fire PENDING status for all planned action invocations // These actions are queued and ready to execute during the apply phase - if stackPlan != nil && stackPlan.ActionInvocations.Len() > 0 { - for _, elem := range stackPlan.ActionInvocations.Elems { - actionAddr := elem.Key - action := elem.Value - + if plan.Changes != nil && len(plan.Changes.ActionInvocations) > 0 { + for _, action := range plan.Changes.ActionInvocations { absActionAddr := stackaddrs.AbsActionInvocationInstance{ Component: inst.Addr(), - Item: actionAddr, + Item: action.Addr, } hookMore(ctx, seq, h.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ @@ -194,6 +191,15 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi hooks: hooksFromContext(ctx), addr: inst.Addr(), } + + // Populate action invocation provider address map for hook callbacks + if plan.Changes != nil && len(plan.Changes.ActionInvocations) > 0 { + tfHook.actionInvocationProviderAddr = addrs.MakeMap[addrs.AbsActionInstance, addrs.Provider]() + for _, action := range plan.Changes.ActionInvocations { + tfHook.actionInvocationProviderAddr.Put(action.Addr, action.ProviderAddr.Provider) + } + } + tfCtx, err := terraform.NewContext(&terraform.ContextOpts{ Hooks: []terraform.Hook{ tfHook, diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go index 4f16600155..6a2833e73d 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go @@ -5,7 +5,6 @@ package stackeval import ( "context" - "log" "sync" "github.com/hashicorp/terraform/internal/addrs" @@ -43,6 +42,10 @@ type componentInstanceTerraformHook struct { // change counts for the apply operation, so we record whether or not apply // failed here. resourceInstanceObjectApplySuccess addrs.Set[addrs.AbsResourceInstanceObject] + + // Track provider addresses for action invocations so we can report them + // in action lifecycle hooks. + actionInvocationProviderAddr addrs.Map[addrs.AbsActionInstance, addrs.Provider] } var _ terraform.Hook = (*componentInstanceTerraformHook)(nil) @@ -57,7 +60,7 @@ func (h *componentInstanceTerraformHook) resourceInstanceObjectAddr(riAddr addrs } } -func (h *componentInstanceTerraformHook) PreDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState, proposedNewState cty.Value) (terraform.HookAction, error) { +func (h *componentInstanceTerraformHook) PreDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState, proposedNewState cty.Value, err error) (terraform.HookAction, error) { hookMore(h.ctx, h.seq, h.hooks.ReportResourceInstanceStatus, &hooks.ResourceInstanceStatusHookData{ Addr: h.resourceInstanceObjectAddr(id.Addr, dk), ProviderAddr: id.ProviderAddr, @@ -66,7 +69,7 @@ func (h *componentInstanceTerraformHook) PreDiff(id terraform.HookResourceIdenti return terraform.HookActionContinue, nil } -func (h *componentInstanceTerraformHook) PostDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { +func (h *componentInstanceTerraformHook) PostDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value, err error) (terraform.HookAction, error) { hookMore(h.ctx, h.seq, h.hooks.ReportResourceInstanceStatus, &hooks.ResourceInstanceStatusHookData{ Addr: h.resourceInstanceObjectAddr(id.Addr, dk), ProviderAddr: id.ProviderAddr, @@ -207,14 +210,18 @@ func (h *componentInstanceTerraformHook) ResourceInstanceObjectsSuccessfullyAppl // StartAction fires when action execution begins func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIdentity) (terraform.HookAction, error) { - log.Printf("[DEBUG] terraform_hook.StartAction called for action: %s", id.Addr.String()) ai := h.actionInvocationFromHookActionIdentity(id) + providerAddr, ok := h.actionInvocationProviderAddr.GetOk(id.Addr) + if !ok { + // Should not happen - actions should be pre-registered + return terraform.HookActionContinue, nil + } // Report status transition: RUNNING (action execution starts) // Note: PENDING status should have been reported during component apply preparation hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ Addr: ai.Addr, - ProviderAddr: id.ProviderAddr.Provider, + ProviderAddr: providerAddr, Status: hooks.ActionInvocationRunning, }) return terraform.HookActionContinue, nil @@ -222,37 +229,39 @@ func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIden // ProgressAction fires for intermediate diagnostic messages (NO status changes) func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionIdentity, progress string) (terraform.HookAction, error) { - log.Printf("[DEBUG] terraform_hook.ProgressAction called for action: %s, progress=%s", id.Addr.String(), progress) ai := h.actionInvocationFromHookActionIdentity(id) - - log.Printf("[DEBUG] Reporting action invocation progress: %s", progress) + providerAddr, ok := h.actionInvocationProviderAddr.GetOk(id.Addr) + if !ok { + // Should not happen - actions should be pre-registered + return terraform.HookActionContinue, nil + } hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationProgress, &hooks.ActionInvocationProgressHookData{ Addr: ai.Addr, - ProviderAddr: id.ProviderAddr.Provider, + ProviderAddr: providerAddr, Message: progress, }) - return terraform.HookActionContinue, nil } // CompleteAction fires when action finishes (success or error) func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionIdentity, err error) (terraform.HookAction, error) { - log.Printf("[DEBUG] terraform_hook.CompleteAction called for action: %s, error=%v", id.Addr.String(), err) ai := h.actionInvocationFromHookActionIdentity(id) + providerAddr, ok := h.actionInvocationProviderAddr.GetOk(id.Addr) + if !ok { + // Should not happen - actions should be pre-registered + return terraform.HookActionContinue, nil + } // Report final status based on error status := hooks.ActionInvocationCompleted if err != nil { status = hooks.ActionInvocationErrored - log.Printf("[DEBUG] Action failed with error: %v - reporting ERRORED status", err) - } else { - log.Printf("[DEBUG] Action completed successfully - reporting COMPLETED status") } // Report status transition: RUNNING → COMPLETED or ERRORED (action finishes) hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ Addr: ai.Addr, - ProviderAddr: id.ProviderAddr.Provider, + ProviderAddr: providerAddr, Status: status, }) return terraform.HookActionContinue, nil @@ -261,12 +270,13 @@ func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionI // actionInvocationFromHookActionIdentity attempts to build a *hooks.ActionInvocation // from a core terraform.HookActionIdentity. func (h *componentInstanceTerraformHook) actionInvocationFromHookActionIdentity(id terraform.HookActionIdentity) *hooks.ActionInvocation { + providerAddr, _ := h.actionInvocationProviderAddr.GetOk(id.Addr) ai := &hooks.ActionInvocation{ Addr: stackaddrs.AbsActionInvocationInstance{ Component: h.addr, Item: id.Addr, }, - ProviderAddr: id.ProviderAddr.Provider, + ProviderAddr: providerAddr, Trigger: id.ActionTrigger, } return ai From a9e059e0b30a8b91a957071794b2f2042f15b0b5 Mon Sep 17 00:00:00 2001 From: Roniece Date: Mon, 16 Feb 2026 22:39:49 -0500 Subject: [PATCH 10/18] Add action invocation hook support to test helpers --- ...action_invocation_hooks_validation_test.go | 8 +--- .../stacks/stackruntime/helper_hooks_test.go | 44 +++++++++++++++++++ internal/stacks/stackruntime/helper_test.go | 29 ++++++++++++ .../stackeval/terraform_hook_action_test.go | 16 ++++++- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go index b909756acf..23e04c2a30 100644 --- a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go +++ b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go @@ -37,16 +37,12 @@ func TestActionInvocationHooksValidation(t *testing.T) { // Level 3: Validate ActionInvocationStatusHookData structure // This should be the structure of each hook: - exampleHook := &hooks.ActionInvocationStatusHookData{ + _ = &hooks.ActionInvocationStatusHookData{ // Addr: stackaddrs.AbsActionInvocationInstance - the action address - // ProviderAddr: string - the provider address + // ProviderAddr: addrs.Provider - the provider address // Status: ActionInvocationStatus - status value (Pending, Running, Completed, Errored) } - if exampleHook == nil { - t.Fatal("ActionInvocationStatusHookData should be defined") - } - t.Log("✓ ActionInvocationStatusHookData structure is properly defined") }) diff --git a/internal/stacks/stackruntime/helper_hooks_test.go b/internal/stacks/stackruntime/helper_hooks_test.go index 15b269ff5d..8cc9bbe431 100644 --- a/internal/stacks/stackruntime/helper_hooks_test.go +++ b/internal/stacks/stackruntime/helper_hooks_test.go @@ -34,6 +34,8 @@ type ExpectedHooks struct { ReportResourceInstancePlanned []*hooks.ResourceInstanceChange ReportResourceInstanceDeferred []*hooks.DeferredResourceInstanceChange ReportActionInvocationPlanned []*hooks.ActionInvocation + ReportActionInvocationStatus []*hooks.ActionInvocationStatusHookData + ReportActionInvocationProgress []*hooks.ActionInvocationProgressHookData ReportComponentInstancePlanned []*hooks.ComponentInstanceChange ReportComponentInstanceApplied []*hooks.ComponentInstanceChange } @@ -63,6 +65,12 @@ func (eh *ExpectedHooks) Validate(t *testing.T, expectedHooks *ExpectedHooks) { sort.SliceStable(expectedHooks.ReportActionInvocationPlanned, func(i, j int) bool { return expectedHooks.ReportActionInvocationPlanned[i].Addr.String() < expectedHooks.ReportActionInvocationPlanned[j].Addr.String() }) + sort.SliceStable(expectedHooks.ReportActionInvocationStatus, func(i, j int) bool { + return expectedHooks.ReportActionInvocationStatus[i].Addr.String() < expectedHooks.ReportActionInvocationStatus[j].Addr.String() + }) + sort.SliceStable(expectedHooks.ReportActionInvocationProgress, func(i, j int) bool { + return expectedHooks.ReportActionInvocationProgress[i].Addr.String() < expectedHooks.ReportActionInvocationProgress[j].Addr.String() + }) sort.SliceStable(expectedHooks.ReportComponentInstancePlanned, func(i, j int) bool { return expectedHooks.ReportComponentInstancePlanned[i].Addr.String() < expectedHooks.ReportComponentInstancePlanned[j].Addr.String() }) @@ -121,6 +129,12 @@ func (eh *ExpectedHooks) Validate(t *testing.T, expectedHooks *ExpectedHooks) { if diff := cmp.Diff(expectedHooks.ReportActionInvocationPlanned, eh.ReportActionInvocationPlanned); len(diff) > 0 { t.Errorf("wrong ReportActionInvocationPlanned hooks: %s", diff) } + if diff := cmp.Diff(expectedHooks.ReportActionInvocationStatus, eh.ReportActionInvocationStatus); len(diff) > 0 { + t.Errorf("wrong ReportActionInvocationStatus hooks: %s", diff) + } + if diff := cmp.Diff(expectedHooks.ReportActionInvocationProgress, eh.ReportActionInvocationProgress); len(diff) > 0 { + t.Errorf("wrong ReportActionInvocationProgress hooks: %s", diff) + } if diff := cmp.Diff(expectedHooks.ReportComponentInstancePlanned, eh.ReportComponentInstancePlanned); len(diff) > 0 { t.Errorf("wrong ReportComponentInstancePlanned hooks: %s", diff) } @@ -391,6 +405,36 @@ func (ch *CapturedHooks) captureHooks() *Hooks { ch.ReportActionInvocationPlanned = append(ch.ReportActionInvocationPlanned, ai) return a }, + ReportActionInvocationStatus: func(ctx context.Context, a any, status *hooks.ActionInvocationStatusHookData) any { + ch.Lock() + defer ch.Unlock() + + if !ch.ComponentInstanceBegun(status.Addr.Component) { + panic("tried to report action invocation status before component") + } + + if ch.ComponentInstanceFinished(status.Addr.Component) { + panic("tried to report action invocation status after component") + } + + ch.ReportActionInvocationStatus = append(ch.ReportActionInvocationStatus, status) + return a + }, + ReportActionInvocationProgress: func(ctx context.Context, a any, progress *hooks.ActionInvocationProgressHookData) any { + ch.Lock() + defer ch.Unlock() + + if !ch.ComponentInstanceBegun(progress.Addr.Component) { + panic("tried to report action invocation progress before component") + } + + if ch.ComponentInstanceFinished(progress.Addr.Component) { + panic("tried to report action invocation progress after component") + } + + ch.ReportActionInvocationProgress = append(ch.ReportActionInvocationProgress, progress) + return a + }, ReportComponentInstancePlanned: func(ctx context.Context, a any, change *hooks.ComponentInstanceChange) any { ch.Lock() defer ch.Unlock() diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index 974a29a610..8327de6c26 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -527,6 +527,35 @@ func mustAbsComponentInstance(addr string) stackaddrs.AbsComponentInstance { return ret } +func mustAbsActionInvocationInstance(addr string) stackaddrs.AbsActionInvocationInstance { + // Parse as "component.instance.action.type.name" format + // E.g., "component.self.action.local_exec.example" + // For simplicity, we'll construct it manually - in a real scenario you'd need proper parsing + parts := strings.Split(addr, ".") + if len(parts) < 5 || parts[2] != "action" { + panic(fmt.Sprintf("invalid action invocation instance address format %q", addr)) + } + + // Extract component part: component.instance + compAddr := strings.Join(parts[:2], ".") + comp, diags := stackaddrs.ParsePartialComponentInstanceStr(compAddr) + if len(diags) > 0 { + panic(fmt.Sprintf("failed to parse component address from %q: %s", addr, diags)) + } + + // Extract action part: action.type.name + actionStr := strings.Join(parts[2:], ".") + actionAddr, moreDiags := addrs.ParseAbsActionInstanceStr(actionStr) + if len(moreDiags) > 0 { + panic(fmt.Sprintf("failed to parse action address from %q: %s", addr, moreDiags)) + } + + return stackaddrs.AbsActionInvocationInstance{ + Component: comp, + Item: actionAddr, + } +} + func mustAbsComponent(addr string) stackaddrs.AbsComponent { ret, diags := stackaddrs.ParsePartialComponentInstanceStr(addr) if len(diags) > 0 { diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go index 8ef70ef38d..cd0ff73e8e 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + package stackeval import ( @@ -40,12 +43,21 @@ func TestActionHookForwarding(t *testing.T) { } // Prepare a HookActionIdentity with an invoke trigger + actionAddr := addrs.AbsActionInstance{} id := terraform.HookActionIdentity{ - Addr: addrs.AbsActionInstance{}, + Addr: actionAddr, ActionTrigger: &plans.InvokeActionTrigger{}, - ProviderAddr: addrs.AbsProviderConfig{}, } + // Pre-populate the provider address map + providerAddr := addrs.Provider{ + Type: "test", + Namespace: "hashicorp", + Hostname: "registry.terraform.io", + } + c.actionInvocationProviderAddr = addrs.MakeMap[addrs.AbsActionInstance, addrs.Provider]() + c.actionInvocationProviderAddr.Put(actionAddr, providerAddr) + // StartAction should trigger a status hook with "Running" status _, _ = c.StartAction(id) if statusCount != 1 { From 15324c96ec812bb919af9b78f5ceccf7d3cf14c0 Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 17 Feb 2026 11:23:50 -0500 Subject: [PATCH 11/18] Clean up action invocation hooks tests --- ...action_invocation_hooks_validation_test.go | 295 +++++++++--------- 1 file changed, 154 insertions(+), 141 deletions(-) diff --git a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go index 23e04c2a30..23bd37ec18 100644 --- a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go +++ b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go @@ -9,194 +9,207 @@ import ( "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" ) -// TestActionInvocationHooksValidation demonstrates how to validate that -// action invocation status hooks are being called during apply operations. -// -// This test shows all three levels of validation: -// 1. Hooks are captured via CapturedHooks helper -// 2. Multiple hooks fire for a single action (state transitions) -// 3. Hook data contains all required fields +// TestActionInvocationHooksValidation validates that action invocation status +// hooks work correctly, including enum values, hook data structure, and lifecycle ordering. func TestActionInvocationHooksValidation(t *testing.T) { - t.Run("validate_hook_capture_mechanism", func(t *testing.T) { - // Level 1: Verify CapturedHooks mechanism works - capturedHooks := NewCapturedHooks(false) // false = apply phase, true = planning phase + t.Run("hook_capture_mechanism", func(t *testing.T) { + // Verify CapturedHooks mechanism initializes correctly + capturedHooks := NewCapturedHooks(false) // false = apply phase if capturedHooks == nil { t.Fatal("CapturedHooks should not be nil") } - // Verify the hooks object exists and has expected fields + // Verify the hooks slice starts empty (nil or zero length) if len(capturedHooks.ReportActionInvocationStatus) != 0 { - t.Fatalf("expected empty initial hook list, got %d", len(capturedHooks.ReportActionInvocationStatus)) + t.Errorf("expected empty initial hook list, got %d", len(capturedHooks.ReportActionInvocationStatus)) } - t.Log("✓ CapturedHooks mechanism is properly set up") - }) - - t.Run("validate_hook_structure", func(t *testing.T) { - // Level 3: Validate ActionInvocationStatusHookData structure + // Verify we can append to it + capturedHooks.ReportActionInvocationStatus = append( + capturedHooks.ReportActionInvocationStatus, + &hooks.ActionInvocationStatusHookData{ + Addr: mustAbsActionInvocationInstance("component.test.action.example.run"), + ProviderAddr: mustDefaultRootProvider("testing").Provider, + Status: hooks.ActionInvocationRunning, + }, + ) - // This should be the structure of each hook: - _ = &hooks.ActionInvocationStatusHookData{ - // Addr: stackaddrs.AbsActionInvocationInstance - the action address - // ProviderAddr: addrs.Provider - the provider address - // Status: ActionInvocationStatus - status value (Pending, Running, Completed, Errored) + if len(capturedHooks.ReportActionInvocationStatus) != 1 { + t.Errorf("after append, expected 1 hook, got %d", len(capturedHooks.ReportActionInvocationStatus)) } - - t.Log("✓ ActionInvocationStatusHookData structure is properly defined") }) - t.Run("validate_action_invocation_status_enum", func(t *testing.T) { - // Verify that ActionInvocationStatus enum values exist - validStatuses := map[string]bool{ - // These are the valid status values an action can have - "Invalid": true, // ActionInvocationInvalid (0) - "Pending": true, // ActionInvocationPending (1) - "Running": true, // ActionInvocationRunning (2) - "Completed": true, // ActionInvocationCompleted (3) - "Errored": true, // ActionInvocationErrored (4) + t.Run("action_invocation_status_enum", func(t *testing.T) { + // Test that all enum constants are defined and have valid string representations + statuses := []hooks.ActionInvocationStatus{ + hooks.ActionInvocationStatusInvalid, + hooks.ActionInvocationPending, + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, + hooks.ActionInvocationErrored, } - if len(validStatuses) != 5 { - t.Fatalf("expected 5 status values, got %d", len(validStatuses)) + expectedStrings := map[hooks.ActionInvocationStatus]string{ + hooks.ActionInvocationStatusInvalid: "ActionInvocationStatusInvalid", + hooks.ActionInvocationPending: "ActionInvocationPending", + hooks.ActionInvocationRunning: "ActionInvocationRunning", + hooks.ActionInvocationCompleted: "ActionInvocationCompleted", + hooks.ActionInvocationErrored: "ActionInvocationErrored", } - t.Logf("✓ Action invocation status enum has %d valid values: %v", - len(validStatuses), validStatuses) - }) - - t.Run("validate_hook_firing_pattern", func(t *testing.T) { - // Level 2: Demonstrate expected hook firing pattern - // For a successful action invocation, we expect: - // 1. StartAction() fires with Running status - // 2. ProgressAction() optionally fires with intermediate status - // 3. CompleteAction() fires with Completed or Errored status - - expectedSequence := []string{ - "Running", // StartAction called - "Completed", // CompleteAction called successfully + // Verify String() returns expected values + for _, status := range statuses { + str := status.String() + expected, ok := expectedStrings[status] + if !ok { + t.Errorf("unexpected status constant: %v", status) + continue + } + if str != expected { + t.Errorf("status %v: expected String() = %q, got %q", status, expected, str) + } } - alternativeSequence := []string{ - "Running", // StartAction called - "Errored", // CompleteAction called with error + // Verify ForProtobuf() returns valid values (non-negative) + for _, status := range statuses { + proto := status.ForProtobuf() + if proto < 0 { + t.Errorf("status %v has invalid protobuf value: %v", status, proto) + } } - t.Logf("Expected hook sequence 1 (success): %v", expectedSequence) - t.Logf("Expected hook sequence 2 (error): %v", alternativeSequence) - - t.Log("✓ Hook firing pattern documented") + // Verify we have exactly 5 status values + if len(statuses) != 5 { + t.Errorf("expected 5 status constants, got %d", len(statuses)) + } }) - t.Run("logging_points_exist", func(t *testing.T) { - // This test documents where logging has been added for validation + t.Run("hook_data_structure", func(t *testing.T) { + // Validate ActionInvocationStatusHookData structure and methods + hookData := &hooks.ActionInvocationStatusHookData{ + Addr: mustAbsActionInvocationInstance("component.test.action.example.run"), + ProviderAddr: mustDefaultRootProvider("testing").Provider, + Status: hooks.ActionInvocationRunning, + } - loggingLocations := map[string]string{ - "terraform_hook.go:StartAction": "Logs action address and Running status", - "terraform_hook.go:ProgressAction": "Logs progress mapping and status transition", - "terraform_hook.go:CompleteAction": "Logs completion with Completed/Errored status", - "stacks.go:ReportActionInvocationStatus": "Logs at gRPC boundary with proto status value", + // Verify fields are set + if hookData.Addr.String() == "" { + t.Error("Addr should not be empty") + } + if hookData.ProviderAddr.String() == "" { + t.Error("ProviderAddr should not be empty") + } + if hookData.Status == hooks.ActionInvocationStatusInvalid { + t.Error("Status should not be Invalid when explicitly set to Running") } - for location, purpose := range loggingLocations { - t.Logf(" %s: %s", location, purpose) + // Verify String() method + str := hookData.String() + if str == "" || str == "" { + t.Errorf("String() should return valid representation, got: %q", str) } - t.Logf("✓ %d logging points have been added for debugging", len(loggingLocations)) + // Verify String() contains address + if !contains(str, "component.test") { + t.Errorf("String() should contain address, got: %q", str) + } + + // Verify nil handling + var nilHook *hooks.ActionInvocationStatusHookData + if nilHook.String() != "" { + t.Errorf("nil hook String() should return , got: %q", nilHook.String()) + } }) - t.Run("validation_checklist", func(t *testing.T) { - // Use this checklist to verify the complete setup - checklist := []struct { - name string - validate func() bool + t.Run("hook_status_lifecycle_ordering", func(t *testing.T) { + // Test expected hook status sequences for different scenarios + testCases := []struct { + name string + capturedStatuses []hooks.ActionInvocationStatus + wantValid bool + description string }{ { - name: "Logging imports added to terraform_hook.go", - validate: func() bool { - // Check: log.Printf should be called in hook methods - return true - }, - }, - { - name: "Logging imports added to stacks.go", - validate: func() bool { - // Check: log.Printf should be called in ReportActionInvocationStatus - return true - }, - }, - { - name: "Binary rebuilt with logging", - validate: func() bool { - // Check: Run `make install` after logging additions - return true + name: "successful_action", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, }, + wantValid: true, + description: "Action starts running and completes successfully", }, { - name: "Log contains hook method entries", - validate: func() bool { - // Check: grep "terraform_hook.*Action\|ReportActionInvocationStatus" terraform.log - return true + name: "failed_action", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationRunning, + hooks.ActionInvocationErrored, }, + wantValid: true, + description: "Action starts running but encounters an error", }, { - name: "Unit tests capture hooks via CapturedHooks", - validate: func() bool { - // Check: Test uses NewCapturedHooks() and captureHooks() - return true + name: "pending_then_running_then_completed", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationPending, + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, }, + wantValid: true, + description: "Action goes through all states including pending", }, { - name: "Hook status values match enum", - validate: func() bool { - // Check: Running, Completed, Errored are valid values - return true + name: "invalid_only_completed", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationCompleted, }, + wantValid: false, + description: "Invalid: completed without running", }, } - t.Logf("Validation Checklist (%d items):", len(checklist)) - for i, item := range checklist { - t.Logf(" %d. %s", i+1, item.name) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Verify we captured the expected number of statuses + if len(tc.capturedStatuses) == 0 { + t.Error("test case should have at least one status") + return + } + + // For valid sequences, verify terminal state is at the end + if tc.wantValid && len(tc.capturedStatuses) > 0 { + lastStatus := tc.capturedStatuses[len(tc.capturedStatuses)-1] + isTerminal := lastStatus == hooks.ActionInvocationCompleted || + lastStatus == hooks.ActionInvocationErrored + + if !isTerminal { + t.Errorf("valid sequence should end in terminal state (Completed/Errored), got %v", lastStatus) + } + } + + // For invalid sequences starting with Completed, verify it's actually invalid + if !tc.wantValid && len(tc.capturedStatuses) > 0 { + firstStatus := tc.capturedStatuses[0] + if firstStatus == hooks.ActionInvocationCompleted && len(tc.capturedStatuses) == 1 { + // This is indeed invalid - can't complete without running + t.Logf("correctly identified invalid sequence: %v", tc.capturedStatuses) + } + } + }) } }) } -// TestActionInvocationHooksLoggingOutput demonstrates what the logging output -// should look like when action invocation hooks are fired during apply. -// -// Expected log output pattern: -// -// [DEBUG] terraform_hook.StartAction called for action: component.nulls.action.bufo_print.success -// [DEBUG] Reporting action invocation status for action: component.nulls.action.bufo_print.success (Running) -// [DEBUG] ReportActionInvocationStatus called: Action=component.nulls.action.bufo_print.success, Status=Running, Provider=registry.terraform.io/austinvalle/bufo -// [DEBUG] Sending ActionInvocationStatus to gRPC client: Addr=component.nulls.action.bufo_print.success, Status=2 (proto) -// [DEBUG] ActionInvocationStatus event successfully sent to client -// [DEBUG] terraform_hook.CompleteAction called for action: component.nulls.action.bufo_print.success, error= -// [DEBUG] Action completed successfully - reporting Completed status -// [DEBUG] Reporting action invocation status for action: component.nulls.action.bufo_print.success (Completed) -// [DEBUG] ReportActionInvocationStatus called: Action=component.nulls.action.bufo_print.success, Status=Completed, Provider=registry.terraform.io/austinvalle/bufo -// [DEBUG] Sending ActionInvocationStatus to gRPC client: Addr=component.nulls.action.bufo_print.success, Status=3 (proto) -// [DEBUG] ActionInvocationStatus event successfully sent to client -func TestActionInvocationHooksLoggingOutput(t *testing.T) { - t.Run("logging_documentation", func(t *testing.T) { - expectedLogPatterns := []string{ - "terraform_hook.StartAction called for action", - "ReportActionInvocationStatus called", - "Sending ActionInvocationStatus to gRPC client", - "ActionInvocationStatus event successfully sent to client", - "terraform_hook.CompleteAction called for action", - } - - t.Logf("When action invocation hooks fire, you should see these log patterns:") - for i, pattern := range expectedLogPatterns { - t.Logf(" %d. [DEBUG] %s", i+1, pattern) - } - - t.Log("\nStatus enum values in logs:") - t.Log(" Status=1 (proto) = Pending") - t.Log(" Status=2 (proto) = Running") - t.Log(" Status=3 (proto) = Completed") - t.Log(" Status=4 (proto) = Errored") - }) +// contains checks if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false } From 7a9577e3102b2c39f946731797b902c235f02dd3 Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 17 Feb 2026 12:56:57 -0500 Subject: [PATCH 12/18] Lint --- internal/command/views/json/message_types.go | 12 ++++++------ internal/stacks/stackruntime/helper_test.go | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index fffd5c1f13..d9a9dc59e1 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -12,12 +12,12 @@ const ( MessageDiagnostic MessageType = "diagnostic" // Operation results - MessageResourceDrift MessageType = "resource_drift" - MessagePlannedChange MessageType = "planned_change" - MessagePlannedActionInvocation MessageType = "planned_action_invocation" - MessageAppliedActionInvocation MessageType = "applied_action_invocation" - MessageChangeSummary MessageType = "change_summary" - MessageOutputs MessageType = "outputs" + MessageResourceDrift MessageType = "resource_drift" + MessagePlannedChange MessageType = "planned_change" + MessagePlannedActionInvocation MessageType = "planned_action_invocation" + MessageAppliedActionInvocation MessageType = "applied_action_invocation" + MessageChangeSummary MessageType = "change_summary" + MessageOutputs MessageType = "outputs" // Hook-driven messages MessageApplyStart MessageType = "apply_start" diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index 8327de6c26..78ccc49efe 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -535,21 +535,21 @@ func mustAbsActionInvocationInstance(addr string) stackaddrs.AbsActionInvocation if len(parts) < 5 || parts[2] != "action" { panic(fmt.Sprintf("invalid action invocation instance address format %q", addr)) } - + // Extract component part: component.instance compAddr := strings.Join(parts[:2], ".") comp, diags := stackaddrs.ParsePartialComponentInstanceStr(compAddr) if len(diags) > 0 { panic(fmt.Sprintf("failed to parse component address from %q: %s", addr, diags)) } - + // Extract action part: action.type.name actionStr := strings.Join(parts[2:], ".") actionAddr, moreDiags := addrs.ParseAbsActionInstanceStr(actionStr) if len(moreDiags) > 0 { panic(fmt.Sprintf("failed to parse action address from %q: %s", addr, moreDiags)) } - + return stackaddrs.AbsActionInvocationInstance{ Component: comp, Item: actionAddr, From cb2241733928504464d0159ceec8ba244d6351eb Mon Sep 17 00:00:00 2001 From: Roniece Date: Tue, 17 Feb 2026 14:37:58 -0500 Subject: [PATCH 13/18] Fix hooks --- internal/rpcapi/stacks.go | 50 +++++++++++++++++++ .../internal/stackeval/terraform_hook.go | 16 ++++-- .../stackeval/terraform_hook_action_test.go | 24 ++++----- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index 6ffdb7e039..f7adf2291f 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -1226,6 +1226,56 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou return span }, + ReportActionInvocationStatus: func(ctx context.Context, span any, statusData *hooks.ActionInvocationStatusHookData) any { + span.(trace.Span).AddEvent("action invocation status", trace.WithAttributes( + attribute.String("component_instance", statusData.Addr.Component.String()), + attribute.String("action_invocation_instance", statusData.Addr.Item.String()), + attribute.String("status", statusData.Status.String()), + )) + + providerAddr := "" + if !statusData.ProviderAddr.IsZero() { + providerAddr = statusData.ProviderAddr.String() + } + + send(&stacks.StackChangeProgress{ + Event: &stacks.StackChangeProgress_ActionInvocationStatus_{ + ActionInvocationStatus: &stacks.StackChangeProgress_ActionInvocationStatus{ + Addr: stacks.NewActionInvocationInStackAddr(statusData.Addr), + Status: statusData.Status.ForProtobuf(), + ProviderAddr: providerAddr, + }, + }, + }) + + return span + }, + + ReportActionInvocationProgress: func(ctx context.Context, span any, progressData *hooks.ActionInvocationProgressHookData) any { + span.(trace.Span).AddEvent("action invocation progress", trace.WithAttributes( + attribute.String("component_instance", progressData.Addr.Component.String()), + attribute.String("action_invocation_instance", progressData.Addr.Item.String()), + attribute.String("message", progressData.Message), + )) + + providerAddr := "" + if !progressData.ProviderAddr.IsZero() { + providerAddr = progressData.ProviderAddr.String() + } + + send(&stacks.StackChangeProgress{ + Event: &stacks.StackChangeProgress_ActionInvocationProgress_{ + ActionInvocationProgress: &stacks.StackChangeProgress_ActionInvocationProgress{ + Addr: stacks.NewActionInvocationInStackAddr(progressData.Addr), + Message: progressData.Message, + ProviderAddr: providerAddr, + }, + }, + }) + + return span + }, + ReportResourceInstanceDeferred: func(ctx context.Context, span any, change *hooks.DeferredResourceInstanceChange) any { span.(trace.Span).AddEvent("deferred resource instance", trace.WithAttributes( attribute.String("component_instance", change.Change.Addr.Component.String()), diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go index 6a2833e73d..6848d1626f 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go @@ -61,19 +61,27 @@ func (h *componentInstanceTerraformHook) resourceInstanceObjectAddr(riAddr addrs } func (h *componentInstanceTerraformHook) PreDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState, proposedNewState cty.Value, err error) (terraform.HookAction, error) { + status := hooks.ResourceInstancePlanning + if err != nil { + status = hooks.ResourceInstanceErrored + } hookMore(h.ctx, h.seq, h.hooks.ReportResourceInstanceStatus, &hooks.ResourceInstanceStatusHookData{ Addr: h.resourceInstanceObjectAddr(id.Addr, dk), ProviderAddr: id.ProviderAddr, - Status: hooks.ResourceInstancePlanning, + Status: status, }) return terraform.HookActionContinue, nil } func (h *componentInstanceTerraformHook) PostDiff(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value, err error) (terraform.HookAction, error) { + status := hooks.ResourceInstancePlanned + if err != nil { + status = hooks.ResourceInstanceErrored + } hookMore(h.ctx, h.seq, h.hooks.ReportResourceInstanceStatus, &hooks.ResourceInstanceStatusHookData{ Addr: h.resourceInstanceObjectAddr(id.Addr, dk), ProviderAddr: id.ProviderAddr, - Status: hooks.ResourceInstancePlanned, + Status: status, }) return terraform.HookActionContinue, nil } @@ -227,7 +235,7 @@ func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIden return terraform.HookActionContinue, nil } -// ProgressAction fires for intermediate diagnostic messages (NO status changes) +// ProgressAction fires for intermediate diagnostic messages from the provider. func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionIdentity, progress string) (terraform.HookAction, error) { ai := h.actionInvocationFromHookActionIdentity(id) providerAddr, ok := h.actionInvocationProviderAddr.GetOk(id.Addr) @@ -235,6 +243,8 @@ func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionI // Should not happen - actions should be pre-registered return terraform.HookActionContinue, nil } + + // Always report progress message hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationProgress, &hooks.ActionInvocationProgressHookData{ Addr: ai.Addr, ProviderAddr: providerAddr, diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go index cd0ff73e8e..09427082d8 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go @@ -67,31 +67,25 @@ func TestActionHookForwarding(t *testing.T) { t.Fatalf("expected ActionInvocationRunning status from StartAction, got %s", statuses[0].String()) } - // ProgressAction with "in-progress" should keep running status + // ProgressAction should not trigger status hooks _, _ = c.ProgressAction(id, "in-progress") - if statusCount != 2 { - t.Fatalf("expected ProgressAction to trigger status hook, got %d total", statusCount) - } - if statuses[1] != hooks.ActionInvocationRunning { - t.Fatalf("expected ActionInvocationRunning status from ProgressAction, got %s", statuses[1].String()) + if statusCount != 1 { + t.Fatalf("expected ProgressAction to avoid status hooks, got %d total", statusCount) } - // ProgressAction with "pending" should switch to pending status + // ProgressAction with "pending" should still avoid status hooks _, _ = c.ProgressAction(id, "pending") - if statusCount != 3 { - t.Fatalf("expected ProgressAction to trigger status hook, got %d total", statusCount) - } - if statuses[2] != hooks.ActionInvocationPending { - t.Fatalf("expected ActionInvocationPending status from ProgressAction('pending'), got %s", statuses[2].String()) + if statusCount != 1 { + t.Fatalf("expected ProgressAction to avoid status hooks, got %d total", statusCount) } // CompleteAction with no error should complete successfully _, _ = c.CompleteAction(id, nil) - if statusCount != 4 { + if statusCount != 2 { t.Fatalf("expected CompleteAction to trigger status hook, got %d total", statusCount) } - if statuses[3] != hooks.ActionInvocationCompleted { - t.Fatalf("expected ActionInvocationCompleted status, got %s", statuses[3].String()) + if statuses[1] != hooks.ActionInvocationCompleted { + t.Fatalf("expected ActionInvocationCompleted status, got %s", statuses[1].String()) } // Test error case From b4dff0888d6978b6c9c60548d4f51f4ff2389bb0 Mon Sep 17 00:00:00 2001 From: Roniece Date: Wed, 18 Feb 2026 12:11:58 -0500 Subject: [PATCH 14/18] Restore transform_action_diff.go --- internal/terraform/transform_action_diff.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/internal/terraform/transform_action_diff.go b/internal/terraform/transform_action_diff.go index 029ab5a150..85abff7305 100644 --- a/internal/terraform/transform_action_diff.go +++ b/internal/terraform/transform_action_diff.go @@ -19,13 +19,8 @@ type ActionDiffTransformer struct { } func (t *ActionDiffTransformer) Transform(g *Graph) error { - applyNodes := addrs.MakeMap[addrs.AbsResourceInstance, *NodeApplyableResourceInstance]() actionTriggerNodes := addrs.MakeMap[addrs.ConfigResource, []*nodeActionTriggerApplyExpand]() for _, vs := range g.Vertices() { - if applyableResource, ok := vs.(*NodeApplyableResourceInstance); ok { - applyNodes.Put(applyableResource.Addr, applyableResource) - } - if atn, ok := vs.(*nodeActionTriggerApplyExpand); ok { configResource := actionTriggerNodes.Get(atn.triggerConfig.resourceAddress) actionTriggerNodes.Put(atn.triggerConfig.resourceAddress, append(configResource, atn)) From 5e32bf2cdc5de01f4c17fed12e020957ef6535ec Mon Sep 17 00:00:00 2001 From: Roniece Date: Wed, 18 Feb 2026 13:01:40 -0500 Subject: [PATCH 15/18] Add trigger information to action invocation status and progress Include the action trigger (lifecycle or invoke) in ActionInvocationStatus and ActionInvocationProgress messages to uniquely identify action invocations triggered by different events. Additional context: https://github.com/hashicorp/terraform/pull/38051#discussion_r2812460131 --- internal/rpcapi/stacks.go | 99 +++++++++++++++---- .../stackruntime/hooks/resource_instance.go | 2 + .../internal/stackeval/applying.go | 1 + .../internal/stackeval/terraform_hook.go | 3 + 4 files changed, 84 insertions(+), 21 deletions(-) diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index f7adf2291f..e39c71d103 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -1238,13 +1238,18 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou providerAddr = statusData.ProviderAddr.String() } + protoStatus := &stacks.StackChangeProgress_ActionInvocationStatus{ + Addr: stacks.NewActionInvocationInStackAddr(statusData.Addr), + Status: statusData.Status.ForProtobuf(), + ProviderAddr: providerAddr, + } + + // Set the action trigger oneof + setActionInvocationStatusTrigger(protoStatus, statusData.Addr.Component, statusData.Trigger) + send(&stacks.StackChangeProgress{ Event: &stacks.StackChangeProgress_ActionInvocationStatus_{ - ActionInvocationStatus: &stacks.StackChangeProgress_ActionInvocationStatus{ - Addr: stacks.NewActionInvocationInStackAddr(statusData.Addr), - Status: statusData.Status.ForProtobuf(), - ProviderAddr: providerAddr, - }, + ActionInvocationStatus: protoStatus, }, }) @@ -1263,13 +1268,18 @@ func stackChangeHooks(send func(*stacks.StackChangeProgress) error, mainStackSou providerAddr = progressData.ProviderAddr.String() } + protoProgress := &stacks.StackChangeProgress_ActionInvocationProgress{ + Addr: stacks.NewActionInvocationInStackAddr(progressData.Addr), + Message: progressData.Message, + ProviderAddr: providerAddr, + } + + // Set the action trigger oneof + setActionInvocationProgressTrigger(protoProgress, progressData.Addr.Component, progressData.Trigger) + send(&stacks.StackChangeProgress{ Event: &stacks.StackChangeProgress_ActionInvocationProgress_{ - ActionInvocationProgress: &stacks.StackChangeProgress_ActionInvocationProgress{ - Addr: stacks.NewActionInvocationInStackAddr(progressData.Addr), - Message: progressData.Message, - ProviderAddr: providerAddr, - }, + ActionInvocationProgress: protoProgress, }, }) @@ -1394,34 +1404,81 @@ func actionInvocationPlanned(ai *hooks.ActionInvocation) (*stacks.StackChangePro ProviderAddr: ai.ProviderAddr.String(), } - switch trig := ai.Trigger.(type) { + setActionInvocationPlannedTrigger(res, ai.Addr.Component, ai.Trigger) + + return res, nil +} + +// setActionInvocationStatusTrigger sets the ActionTrigger oneof field on an ActionInvocationStatus message. +func setActionInvocationStatusTrigger(msg *stacks.StackChangeProgress_ActionInvocationStatus, component stackaddrs.AbsComponentInstance, trigger plans.ActionTrigger) { + switch trig := trigger.(type) { case *plans.ResourceActionTrigger: - triggerEvent, err := stacks.ActionTriggerEventForStackChangeProgress(trig.TriggerEvent()) - if err != nil { - return nil, err + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationStatus_ResourceActionTrigger{ + ResourceActionTrigger: &stacks.StackChangeProgress_ResourceActionTrigger{ + TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( + stackaddrs.AbsResourceInstance{ + Component: component, + Item: trig.TriggeringResourceAddr, + }, + ), + TriggerEvent: stacks.StackChangeProgress_ActionTriggerEvent(trig.TriggerEvent()), + ActionTriggerBlockIndex: int64(trig.ActionTriggerBlockIndex), + ActionsListIndex: int64(trig.ActionsListIndex), + }, } - res.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger{ + case *plans.InvokeActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationStatus_InvokeActionTrigger{ + InvokeActionTrigger: &stacks.StackChangeProgress_InvokeActionTrigger{}, + } + } +} + +// setActionInvocationProgressTrigger sets the ActionTrigger oneof field on an ActionInvocationProgress message. +func setActionInvocationProgressTrigger(msg *stacks.StackChangeProgress_ActionInvocationProgress, component stackaddrs.AbsComponentInstance, trigger plans.ActionTrigger) { + switch trig := trigger.(type) { + case *plans.ResourceActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationProgress_ResourceActionTrigger{ ResourceActionTrigger: &stacks.StackChangeProgress_ResourceActionTrigger{ TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( stackaddrs.AbsResourceInstance{ - Component: ai.Addr.Component, + Component: component, Item: trig.TriggeringResourceAddr, }, ), - TriggerEvent: triggerEvent, + TriggerEvent: stacks.StackChangeProgress_ActionTriggerEvent(trig.TriggerEvent()), ActionTriggerBlockIndex: int64(trig.ActionTriggerBlockIndex), ActionsListIndex: int64(trig.ActionsListIndex), }, } case *plans.InvokeActionTrigger: - res.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger{ + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationProgress_InvokeActionTrigger{ InvokeActionTrigger: &stacks.StackChangeProgress_InvokeActionTrigger{}, } - default: - return nil, fmt.Errorf("unsupported action invocation trigger type") } +} - return res, nil +// setActionInvocationPlannedTrigger sets the ActionTrigger oneof field on an ActionInvocationPlanned message. +func setActionInvocationPlannedTrigger(msg *stacks.StackChangeProgress_ActionInvocationPlanned, component stackaddrs.AbsComponentInstance, trigger plans.ActionTrigger) { + switch trig := trigger.(type) { + case *plans.ResourceActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger{ + ResourceActionTrigger: &stacks.StackChangeProgress_ResourceActionTrigger{ + TriggeringResourceAddress: stacks.NewResourceInstanceInStackAddr( + stackaddrs.AbsResourceInstance{ + Component: component, + Item: trig.TriggeringResourceAddr, + }, + ), + TriggerEvent: stacks.StackChangeProgress_ActionTriggerEvent(trig.TriggerEvent()), + ActionTriggerBlockIndex: int64(trig.ActionTriggerBlockIndex), + ActionsListIndex: int64(trig.ActionsListIndex), + }, + } + case *plans.InvokeActionTrigger: + msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_InvokeActionTrigger{ + InvokeActionTrigger: &stacks.StackChangeProgress_InvokeActionTrigger{}, + } + } } func evtComponentInstanceStatus(ci stackaddrs.AbsComponentInstance, status hooks.ComponentInstanceStatus) *stacks.StackChangeProgress { diff --git a/internal/stacks/stackruntime/hooks/resource_instance.go b/internal/stacks/stackruntime/hooks/resource_instance.go index cf55e33314..c00a93e629 100644 --- a/internal/stacks/stackruntime/hooks/resource_instance.go +++ b/internal/stacks/stackruntime/hooks/resource_instance.go @@ -157,6 +157,7 @@ type ActionInvocationStatusHookData struct { Addr stackaddrs.AbsActionInvocationInstance ProviderAddr addrs.Provider Status ActionInvocationStatus + Trigger plans.ActionTrigger } // String returns a concise string representation of the action invocation status. @@ -171,6 +172,7 @@ type ActionInvocationProgressHookData struct { Addr stackaddrs.AbsActionInvocationInstance ProviderAddr addrs.Provider Message string + Trigger plans.ActionTrigger } // String returns a concise string representation of the action invocation progress. diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index 6db03af7f1..2ad1704610 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -140,6 +140,7 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi Addr: absActionAddr, ProviderAddr: action.ProviderAddr.Provider, Status: hooks.ActionInvocationPending, + Trigger: action.ActionTrigger, }) } } diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go index 6848d1626f..71d279d04f 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go @@ -231,6 +231,7 @@ func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIden Addr: ai.Addr, ProviderAddr: providerAddr, Status: hooks.ActionInvocationRunning, + Trigger: ai.Trigger, }) return terraform.HookActionContinue, nil } @@ -249,6 +250,7 @@ func (h *componentInstanceTerraformHook) ProgressAction(id terraform.HookActionI Addr: ai.Addr, ProviderAddr: providerAddr, Message: progress, + Trigger: ai.Trigger, }) return terraform.HookActionContinue, nil } @@ -273,6 +275,7 @@ func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionI Addr: ai.Addr, ProviderAddr: providerAddr, Status: status, + Trigger: ai.Trigger, }) return terraform.HookActionContinue, nil } From d9045c8b97e7879f3e33af42829d334f79ac2981 Mon Sep 17 00:00:00 2001 From: Roniece Date: Wed, 18 Feb 2026 13:06:47 -0500 Subject: [PATCH 16/18] Mirror existing functions and parse with stackaddrs.ParseActionInvocationInstanceStr --- internal/stacks/stackaddrs/in_component.go | 31 +++++++++++++++++++++ internal/stacks/stackruntime/helper_test.go | 27 ++---------------- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/internal/stacks/stackaddrs/in_component.go b/internal/stacks/stackaddrs/in_component.go index 7283720efe..c130c215db 100644 --- a/internal/stacks/stackaddrs/in_component.go +++ b/internal/stacks/stackaddrs/in_component.go @@ -162,3 +162,34 @@ func ParseAbsResourceInstanceObjectStr(s string) (AbsResourceInstanceObject, tfd diags = diags.Append(moreDiags) return ret, diags } + +func ParseAbsActionInvocationInstance(traversal hcl.Traversal) (AbsActionInvocationInstance, tfdiags.Diagnostics) { + component, remain, diags := ParseAbsComponentInstanceOnly(traversal) + if diags.HasErrors() { + return AbsActionInvocationInstance{}, diags + } + + action, actionDiags := addrs.ParseAbsActionInstance(remain) + diags = diags.Append(actionDiags) + if diags.HasErrors() { + return AbsActionInvocationInstance{}, diags + } + + return AbsActionInvocationInstance{ + Component: component, + Item: action, + }, diags +} + +func ParseActionInvocationInstanceStr(s string) (AbsActionInvocationInstance, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + traversal, hclDiags := hclsyntax.ParseTraversalAbs([]byte(s), "", hcl.InitialPos) + diags = diags.Append(hclDiags) + if diags.HasErrors() { + return AbsActionInvocationInstance{}, diags + } + + ret, moreDiags := ParseAbsActionInvocationInstance(traversal) + diags = diags.Append(moreDiags) + return ret, diags +} diff --git a/internal/stacks/stackruntime/helper_test.go b/internal/stacks/stackruntime/helper_test.go index 78ccc49efe..15e91a7f3a 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -528,32 +528,11 @@ func mustAbsComponentInstance(addr string) stackaddrs.AbsComponentInstance { } func mustAbsActionInvocationInstance(addr string) stackaddrs.AbsActionInvocationInstance { - // Parse as "component.instance.action.type.name" format - // E.g., "component.self.action.local_exec.example" - // For simplicity, we'll construct it manually - in a real scenario you'd need proper parsing - parts := strings.Split(addr, ".") - if len(parts) < 5 || parts[2] != "action" { - panic(fmt.Sprintf("invalid action invocation instance address format %q", addr)) - } - - // Extract component part: component.instance - compAddr := strings.Join(parts[:2], ".") - comp, diags := stackaddrs.ParsePartialComponentInstanceStr(compAddr) + ret, diags := stackaddrs.ParseActionInvocationInstanceStr(addr) if len(diags) > 0 { - panic(fmt.Sprintf("failed to parse component address from %q: %s", addr, diags)) - } - - // Extract action part: action.type.name - actionStr := strings.Join(parts[2:], ".") - actionAddr, moreDiags := addrs.ParseAbsActionInstanceStr(actionStr) - if len(moreDiags) > 0 { - panic(fmt.Sprintf("failed to parse action address from %q: %s", addr, moreDiags)) - } - - return stackaddrs.AbsActionInvocationInstance{ - Component: comp, - Item: actionAddr, + panic(fmt.Sprintf("failed to parse action invocation instance address %q: %s", addr, diags)) } + return ret } func mustAbsComponent(addr string) stackaddrs.AbsComponent { From 87b37486b930dca1afb2b6ec9670f4251f385e21 Mon Sep 17 00:00:00 2001 From: Roniece Date: Wed, 18 Feb 2026 13:12:49 -0500 Subject: [PATCH 17/18] Restore applying.go --- internal/stacks/stackruntime/internal/stackeval/applying.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index 2ad1704610..e14c22a97d 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -255,7 +255,8 @@ func ApplyComponentPlan(ctx context.Context, main *Main, plan *plans.Plan, requi // of either "modifiedPlan" or "plan" (since they share lots of the same // pointers to mutable objects and so both can get modified together.) newState, moreDiags = tfCtx.Apply(plan, moduleTree, &terraform.ApplyOpts{ - ExternalProviders: providerClients, + ExternalProviders: providerClients, + AllowRootEphemeralOutputs: false, // TODO(issues/37822): Enable this. }) diags = diags.Append(moreDiags) } else { From 88f8567b779c2c55527f8fe84566ac1c9164cd0f Mon Sep 17 00:00:00 2001 From: Roniece Date: Fri, 6 Mar 2026 11:56:25 -0500 Subject: [PATCH 18/18] Update copyright headers --- .../stackruntime/action_invocation_hooks_validation_test.go | 2 +- .../internal/stackeval/terraform_hook_action_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go index 23bd37ec18..8f63fed045 100644 --- a/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go +++ b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go @@ -1,4 +1,4 @@ -// Copyright (c) HashiCorp, Inc. +// Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 package stackruntime diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go index 09427082d8..fb9369bfb0 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go @@ -1,4 +1,4 @@ -// Copyright (c) HashiCorp, Inc. +// Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 package stackeval