mirror of https://github.com/hashicorp/terraform
Merge pull request #38246 from RonRicardo/rr/actions/stacks-sro
[Stacks Actions] Apply SROpull/38274/head
commit
c070c0ee31
@ -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
|
||||
}
|
||||
@ -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) + ")"
|
||||
}
|
||||
}
|
||||
@ -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 new issue