mirror of
https://github.com/hashicorp/terraform.git
synced 2026-06-08 16:35:25 -04:00
Merge pull request #38246 from RonRicardo/rr/actions/stacks-sro
[Stacks Actions] Apply SRO
This commit is contained in:
commit
c070c0ee31
14 changed files with 751 additions and 13 deletions
|
|
@ -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
|
||||
}
|
||||
res.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationPlanned_ResourceActionTrigger{
|
||||
msg.ActionTrigger = &stacks.StackChangeProgress_ActionInvocationStatus_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_ActionInvocationStatus_InvokeActionTrigger{
|
||||
InvokeActionTrigger: &stacks.StackChangeProgress_InvokeActionTrigger{},
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported action invocation trigger type")
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
// 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: 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_ActionInvocationProgress_InvokeActionTrigger{
|
||||
InvokeActionTrigger: &stacks.StackChangeProgress_InvokeActionTrigger{},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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…
Reference in a new issue