Merge pull request #38246 from RonRicardo/rr/actions/stacks-sro

[Stacks Actions] Apply SRO
pull/38274/head
Roniece Ricardo 2 months ago committed by GitHub
commit c070c0ee31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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"

@ -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),

@ -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

@ -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 {

@ -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
}

@ -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 == "<nil>" {
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() != "<nil>" {
t.Errorf("nil hook String() should return <nil>, 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
}

@ -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()

@ -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 {

@ -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) + ")"
}
}

@ -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 "<nil>"
}
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 "<nil>"
}
return a.Addr.String() + ": " + a.Message
}

@ -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,

@ -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

@ -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
}

@ -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())
}
}
Loading…
Cancel
Save