diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index 7bb9e96816..d9a9dc59e1 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -15,6 +15,7 @@ const ( 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" 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), 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 diff --git a/internal/rpcapi/stacks.go b/internal/rpcapi/stacks.go index 6ffdb7e039..e39c71d103 100644 --- a/internal/rpcapi/stacks.go +++ b/internal/rpcapi/stacks.go @@ -1226,6 +1226,66 @@ 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() + } + + 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: protoStatus, + }, + }) + + 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() + } + + 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: protoProgress, + }, + }) + + 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()), @@ -1344,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/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/action_invocation_hooks_validation_test.go b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go new file mode 100644 index 0000000000..8f63fed045 --- /dev/null +++ b/internal/stacks/stackruntime/action_invocation_hooks_validation_test.go @@ -0,0 +1,215 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package stackruntime + +import ( + "testing" + + "github.com/hashicorp/terraform/internal/stacks/stackruntime/hooks" +) + +// 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("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 slice starts empty (nil or zero length) + if len(capturedHooks.ReportActionInvocationStatus) != 0 { + t.Errorf("expected empty initial hook list, got %d", len(capturedHooks.ReportActionInvocationStatus)) + } + + // 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, + }, + ) + + if len(capturedHooks.ReportActionInvocationStatus) != 1 { + t.Errorf("after append, expected 1 hook, got %d", len(capturedHooks.ReportActionInvocationStatus)) + } + }) + + 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, + } + + expectedStrings := map[hooks.ActionInvocationStatus]string{ + hooks.ActionInvocationStatusInvalid: "ActionInvocationStatusInvalid", + hooks.ActionInvocationPending: "ActionInvocationPending", + hooks.ActionInvocationRunning: "ActionInvocationRunning", + hooks.ActionInvocationCompleted: "ActionInvocationCompleted", + hooks.ActionInvocationErrored: "ActionInvocationErrored", + } + + // 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) + } + } + + // 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) + } + } + + // Verify we have exactly 5 status values + if len(statuses) != 5 { + t.Errorf("expected 5 status constants, got %d", len(statuses)) + } + }) + + 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, + } + + // 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") + } + + // Verify String() method + str := hookData.String() + if str == "" || str == "" { + t.Errorf("String() should return valid representation, got: %q", str) + } + + // 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("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: "successful_action", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationRunning, + hooks.ActionInvocationCompleted, + }, + wantValid: true, + description: "Action starts running and completes successfully", + }, + { + name: "failed_action", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationRunning, + hooks.ActionInvocationErrored, + }, + wantValid: true, + description: "Action starts running but encounters an error", + }, + { + 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: "invalid_only_completed", + capturedStatuses: []hooks.ActionInvocationStatus{ + hooks.ActionInvocationCompleted, + }, + wantValid: false, + description: "Invalid: completed without running", + }, + } + + 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) + } + } + }) + } + }) +} + +// 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 +} 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..15e91a7f3a 100644 --- a/internal/stacks/stackruntime/helper_test.go +++ b/internal/stacks/stackruntime/helper_test.go @@ -527,6 +527,14 @@ func mustAbsComponentInstance(addr string) stackaddrs.AbsComponentInstance { return ret } +func mustAbsActionInvocationInstance(addr string) stackaddrs.AbsActionInvocationInstance { + ret, diags := stackaddrs.ParseActionInvocationInstanceStr(addr) + if len(diags) > 0 { + panic(fmt.Sprintf("failed to parse action invocation instance address %q: %s", addr, diags)) + } + return ret +} + func mustAbsComponent(addr string) stackaddrs.AbsComponent { ret, diags := stackaddrs.ParsePartialComponentInstanceStr(addr) if len(diags) > 0 { 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..c00a93e629 100644 --- a/internal/stacks/stackruntime/hooks/resource_instance.go +++ b/internal/stacks/stackruntime/hooks/resource_instance.go @@ -123,3 +123,62 @@ 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 + Trigger plans.ActionTrigger +} + +// 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() + "]" +} + +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. +func (a *ActionInvocationProgressHookData) String() string { + if a == nil { + return "" + } + return a.Addr.String() + ": " + a.Message +} diff --git a/internal/stacks/stackruntime/internal/stackeval/applying.go b/internal/stacks/stackruntime/internal/stackeval/applying.go index a39b7c8f02..e14c22a97d 100644 --- a/internal/stacks/stackruntime/internal/stackeval/applying.go +++ b/internal/stacks/stackruntime/internal/stackeval/applying.go @@ -127,6 +127,24 @@ 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 plan.Changes != nil && len(plan.Changes.ActionInvocations) > 0 { + for _, action := range plan.Changes.ActionInvocations { + absActionAddr := stackaddrs.AbsActionInvocationInstance{ + Component: inst.Addr(), + Item: action.Addr, + } + + hookMore(ctx, seq, h.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: absActionAddr, + ProviderAddr: action.ProviderAddr.Provider, + Status: hooks.ActionInvocationPending, + Trigger: action.ActionTrigger, + }) + } + } + moduleTree := inst.ModuleTree(ctx) if moduleTree == nil { // We should not get here because if the configuration was statically @@ -174,6 +192,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/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 diff --git a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go index b07b0beea0..71d279d04f 100644 --- a/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook.go @@ -42,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) @@ -211,3 +215,82 @@ func (h *componentInstanceTerraformHook) ResourceInstanceObjectAppliedAction(add func (h *componentInstanceTerraformHook) ResourceInstanceObjectsSuccessfullyApplied() addrs.Set[addrs.AbsResourceInstanceObject] { return h.resourceInstanceObjectApplySuccess } + +// StartAction fires when action execution begins +func (h *componentInstanceTerraformHook) StartAction(id terraform.HookActionIdentity) (terraform.HookAction, error) { + 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: providerAddr, + Status: hooks.ActionInvocationRunning, + Trigger: ai.Trigger, + }) + return terraform.HookActionContinue, nil +} + +// 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) + if !ok { + // 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, + Message: progress, + Trigger: ai.Trigger, + }) + return terraform.HookActionContinue, nil +} + +// CompleteAction fires when action finishes (success or error) +func (h *componentInstanceTerraformHook) CompleteAction(id terraform.HookActionIdentity, err error) (terraform.HookAction, error) { + 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 + } + + // Report status transition: RUNNING → COMPLETED or ERRORED (action finishes) + hookMore(h.ctx, h.seq, h.hooks.ReportActionInvocationStatus, &hooks.ActionInvocationStatusHookData{ + Addr: ai.Addr, + ProviderAddr: providerAddr, + Status: status, + Trigger: ai.Trigger, + }) + 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 { + providerAddr, _ := h.actionInvocationProviderAddr.GetOk(id.Addr) + ai := &hooks.ActionInvocation{ + Addr: stackaddrs.AbsActionInvocationInstance{ + Component: h.addr, + Item: id.Addr, + }, + ProviderAddr: providerAddr, + Trigger: id.ActionTrigger, + } + return ai +} 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..fb9369bfb0 --- /dev/null +++ b/internal/stacks/stackruntime/internal/stackeval/terraform_hook_action_test.go @@ -0,0 +1,103 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +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 + actionAddr := addrs.AbsActionInstance{} + id := terraform.HookActionIdentity{ + Addr: actionAddr, + ActionTrigger: &plans.InvokeActionTrigger{}, + } + + // 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 { + 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 should not trigger status hooks + _, _ = c.ProgressAction(id, "in-progress") + if statusCount != 1 { + t.Fatalf("expected ProgressAction to avoid status hooks, got %d total", statusCount) + } + + // ProgressAction with "pending" should still avoid status hooks + _, _ = c.ProgressAction(id, "pending") + 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 != 2 { + t.Fatalf("expected CompleteAction to trigger status hook, got %d total", statusCount) + } + if statuses[1] != hooks.ActionInvocationCompleted { + t.Fatalf("expected ActionInvocationCompleted status, got %s", statuses[1].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()) + } +}