From 90bdc053f29d4a6e3ffd6d8d9dc3ffa4f5236a95 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 20 Aug 2025 11:01:06 +0200 Subject: [PATCH] add SRO message for planned action invocation --- internal/command/views/json/change.go | 36 +++ internal/command/views/json/message_types.go | 9 +- internal/command/views/json_view.go | 8 + internal/command/views/operation.go | 3 + internal/command/views/operation_test.go | 220 +++++++++++++++++++ 5 files changed, 272 insertions(+), 4 deletions(-) diff --git a/internal/command/views/json/change.go b/internal/command/views/json/change.go index 32c0f529e4..dbdb373a37 100644 --- a/internal/command/views/json/change.go +++ b/internal/command/views/json/change.go @@ -59,6 +59,42 @@ func (c *ResourceInstanceChange) String() string { return fmt.Sprintf("%s: Plan to %s", c.Resource.Addr, c.Action) } +func NewPlannedActionInvocation(aiSrc *plans.ActionInvocationInstanceSrc) *ActionInvocation { + ai := &ActionInvocation{ + Action: newActionAddr(aiSrc.Addr), + } + + if at, ok := aiSrc.ActionTrigger.(plans.LifecycleActionTrigger); ok { + ai.LifecycleTrigger = &ActionInvocationLifecycleTrigger{ + TriggeringResource: newResourceAddr(at.TriggeringResourceAddr), + TriggeringEvent: at.ActionTriggerEvent.String(), + ActionTriggerBlockIndex: at.ActionTriggerBlockIndex, + ActionsListIndex: at.ActionsListIndex, + } + } + + return ai +} + +type ActionInvocation struct { + Action ActionAddr `json:"action_addr"` + LifecycleTrigger *ActionInvocationLifecycleTrigger `json:"lifecycle_trigger,omitempty"` +} + +func (c *ActionInvocation) String() string { + if c.LifecycleTrigger != nil { + return fmt.Sprintf("%s: triggered by %s trigger on %s", c.Action.Addr, c.LifecycleTrigger.TriggeringEvent, c.LifecycleTrigger.TriggeringResource) + } + return c.Action.Addr +} + +type ActionInvocationLifecycleTrigger struct { + TriggeringResource ResourceAddr `json:"triggering_resource"` + TriggeringEvent string `json:"triggering_event"` + ActionTriggerBlockIndex int `json:"action_trigger_block_index"` + ActionsListIndex int `json:"actions_list_index"` +} + type ChangeAction string const ( diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index b1d0cbd62c..7ebc72b9e8 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -12,10 +12,11 @@ const ( MessageDiagnostic MessageType = "diagnostic" // Operation results - MessageResourceDrift MessageType = "resource_drift" - MessagePlannedChange MessageType = "planned_change" - MessageChangeSummary MessageType = "change_summary" - MessageOutputs MessageType = "outputs" + MessageResourceDrift MessageType = "resource_drift" + MessagePlannedChange MessageType = "planned_change" + MessagePlannedActionInvocation MessageType = "planned_action_invocation" + MessageChangeSummary MessageType = "change_summary" + MessageOutputs MessageType = "outputs" // Hook-driven messages MessageApplyStart MessageType = "apply_start" diff --git a/internal/command/views/json_view.go b/internal/command/views/json_view.go index 085d0e703b..f184a0bc52 100644 --- a/internal/command/views/json_view.go +++ b/internal/command/views/json_view.go @@ -95,6 +95,14 @@ func (v *JSONView) PlannedChange(c *json.ResourceInstanceChange) { ) } +func (v *JSONView) PlannedActionInvocation(action *json.ActionInvocation) { + v.log.Info( + fmt.Sprintf("planned action invocation: %s", action.Action.Action), + "type", json.MessagePlannedActionInvocation, + "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/command/views/operation.go b/internal/command/views/operation.go index 85201a8542..ae9beca1b1 100644 --- a/internal/command/views/operation.go +++ b/internal/command/views/operation.go @@ -255,6 +255,9 @@ func (v *OperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) { } } cs.ActionInvocation = len(plan.Changes.ActionInvocations) + for _, action := range plan.Changes.ActionInvocations { + v.view.PlannedActionInvocation(json.NewPlannedActionInvocation(action)) + } v.view.ChangeSummary(cs) diff --git a/internal/command/views/operation_test.go b/internal/command/views/operation_test.go index 9f4397c3d4..1ae24c3fb3 100644 --- a/internal/command/views/operation_test.go +++ b/internal/command/views/operation_test.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/command/arguments" + "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/lang/globalref" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/states" @@ -557,6 +558,225 @@ func TestOperationJSON_emergencyDumpState(t *testing.T) { testJSONViewOutputEquals(t, done(t).Stdout(), want) } +func TestOperationJSON_plan_with_actions(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := &OperationJSON{view: NewJSONView(NewView(streams))} + + root := addrs.RootModuleInstance + vpc, diags := addrs.ParseModuleInstanceStr("module.vpc") + if len(diags) > 0 { + t.Fatal(diags.Err()) + } + boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "boop"}.Instance(addrs.NoKey).Absolute(root) + beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "beep"}.Instance(addrs.IntKey(0)).Absolute(vpc) + + act1 := &plans.ActionInvocationInstanceSrc{ + Addr: addrs.Action{Type: "test_unlinked_action", Name: "hello"}.Instance(addrs.NoKey).Absolute(root), + ActionTrigger: plans.LifecycleActionTrigger{ + TriggeringResourceAddr: boop, + ActionTriggerEvent: configs.AfterCreate, + ActionTriggerBlockIndex: 0, + ActionsListIndex: 0, + }, + } + act2 := &plans.ActionInvocationInstanceSrc{ + Addr: addrs.Action{Type: "test_unlinked_other_action", Name: "world"}.Instance(addrs.NoKey).Absolute(root), + ActionTrigger: plans.LifecycleActionTrigger{ + TriggeringResourceAddr: boop, + ActionTriggerEvent: configs.AfterCreate, + ActionTriggerBlockIndex: 0, + ActionsListIndex: 1, + }, + } + act3 := &plans.ActionInvocationInstanceSrc{ + Addr: addrs.Action{Type: "test_unlinked_action", Name: "goodbye"}.Instance(addrs.IntKey(0)).Absolute(vpc), + ActionTrigger: plans.LifecycleActionTrigger{ + TriggeringResourceAddr: beep, + ActionTriggerEvent: configs.BeforeUpdate, + ActionTriggerBlockIndex: 1, + ActionsListIndex: 0, + }, + } + + plan := &plans.Plan{ + Changes: &plans.ChangesSrc{ + Resources: []*plans.ResourceInstanceChangeSrc{ + { + Addr: boop, + PrevRunAddr: boop, + ChangeSrc: plans.ChangeSrc{Action: plans.Create}, + }, + { + Addr: beep, + PrevRunAddr: beep, + ChangeSrc: plans.ChangeSrc{Action: plans.Update}, + }, + }, + + ActionInvocations: []*plans.ActionInvocationInstanceSrc{ + act1, + act2, + act3, + }, + }, + } + v.Plan(plan, testSchemas()) + + want := []map[string]interface{}{ + // Simple create + { + "@level": "info", + "@message": "test_resource.boop: Plan to create", + "@module": "terraform.ui", + "type": "planned_change", + "change": map[string]interface{}{ + "action": "create", + "resource": map[string]interface{}{ + "addr": `test_resource.boop`, + "implied_provider": "test", + "module": "", + "resource": `test_resource.boop`, + "resource_key": nil, + "resource_name": "boop", + "resource_type": "test_resource", + }, + }, + }, + // Simple update + { + "@level": "info", + "@message": "module.vpc.test_resource.beep[0]: Plan to update", + "@module": "terraform.ui", + "type": "planned_change", + "change": map[string]interface{}{ + "action": "update", + "resource": map[string]interface{}{ + "addr": `module.vpc.test_resource.beep[0]`, + "implied_provider": "test", + "module": "module.vpc", + "resource": `test_resource.beep[0]`, + "resource_key": float64(0), + "resource_name": "beep", + "resource_type": "test_resource", + }, + }, + }, + // Action invocation 1 + { + "@level": "info", + "@message": "planned action invocation: action.test_unlinked_action.hello", + "@module": "terraform.ui", + "type": "planned_action_invocation", + "invocation": map[string]interface{}{ + "action_addr": map[string]interface{}{ + "addr": `action.test_unlinked_action.hello`, + "implied_provider": "test", + "module": "", + "resource": `action.test_unlinked_action.hello`, + "resource_key": nil, + "resource_name": "hello", + "resource_type": "test_unlinked_action", + }, + "lifecycle_trigger": map[string]interface{}{ + "action_trigger_block_index": float64(0), + "actions_list_index": float64(0), + "triggering_event": "AfterCreate", + "triggering_resource": map[string]interface{}{ + "addr": `test_resource.boop`, + "implied_provider": "test", + "module": "", + "resource": `test_resource.boop`, + "resource_key": nil, + "resource_name": "boop", + "resource_type": "test_resource", + }, + }, + }, + }, + // Action invocation 2 + { + "@level": "info", + "@message": "planned action invocation: action.test_unlinked_other_action.world", + "@module": "terraform.ui", + "type": "planned_action_invocation", + "invocation": map[string]interface{}{ + "action_addr": map[string]interface{}{ + "addr": `action.test_unlinked_other_action.world`, + "implied_provider": "test", + "module": "", + "resource": `action.test_unlinked_other_action.world`, + "resource_key": nil, + "resource_name": "world", + "resource_type": "test_unlinked_other_action", + }, + "lifecycle_trigger": map[string]interface{}{ + "action_trigger_block_index": float64(0), + "actions_list_index": float64(1), + "triggering_event": "AfterCreate", + "triggering_resource": map[string]interface{}{ + "addr": `test_resource.boop`, + "implied_provider": "test", + "module": "", + "resource": `test_resource.boop`, + "resource_key": nil, + "resource_name": "boop", + "resource_type": "test_resource", + }, + }, + }, + }, + // Action invocation 3 + { + "@level": "info", + "@message": "planned action invocation: action.test_unlinked_action.goodbye[0]", + "@module": "terraform.ui", + "type": "planned_action_invocation", + "invocation": map[string]interface{}{ + "action_addr": map[string]interface{}{ + "addr": `module.vpc.action.test_unlinked_action.goodbye[0]`, + "implied_provider": "test", + "module": "module.vpc", + "resource": `action.test_unlinked_action.goodbye[0]`, + "resource_key": float64(0), + "resource_name": "goodbye", + "resource_type": "test_unlinked_action", + }, + "lifecycle_trigger": map[string]interface{}{ + "action_trigger_block_index": float64(1), + "actions_list_index": float64(0), + "triggering_event": "BeforeUpdate", + "triggering_resource": map[string]interface{}{ + "addr": `module.vpc.test_resource.beep[0]`, + "implied_provider": "test", + "module": "module.vpc", + "resource": `test_resource.beep[0]`, + "resource_key": float64(0), + "resource_name": "beep", + "resource_type": "test_resource", + }, + }, + }, + }, + // Change summary with action invocations + { + "@level": "info", + "@message": "Plan: 1 to add, 1 to change, 0 to destroy. Actions: 3 to invoke.", + "@module": "terraform.ui", + "type": "change_summary", + "changes": map[string]interface{}{ + "operation": "plan", + "action_invocation": float64(3), + "add": float64(1), + "import": float64(0), + "change": float64(1), + "remove": float64(0), + }, + }, + } + + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + func TestOperationJSON_planNoChanges(t *testing.T) { streams, done := terminal.StreamsForTesting(t) v := &OperationJSON{view: NewJSONView(NewView(streams))}