From f858b21163f548a5496ab8c158cad46152195c9d Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Fri, 12 Sep 2025 12:18:05 +0200 Subject: [PATCH] reorganize action plan tests --- .../terraform/context_plan_actions_test.go | 4544 +++++++++-------- 1 file changed, 2300 insertions(+), 2244 deletions(-) diff --git a/internal/terraform/context_plan_actions_test.go b/internal/terraform/context_plan_actions_test.go index da2448b94b0f..619e497b5c7c 100644 --- a/internal/terraform/context_plan_actions_test.go +++ b/internal/terraform/context_plan_actions_test.go @@ -103,7 +103,7 @@ func TestContextPlan_actions(t *testing.T) { Unlinked: &providers.UnlinkedAction{}, } - for name, tc := range map[string]struct { + for topic, tcs := range map[string]map[string]struct { toBeImplemented bool module map[string]string buildState func(*states.SyncState) @@ -126,52 +126,214 @@ func TestContextPlan_actions(t *testing.T) { assertPlan func(*testing.T, *plans.Plan) }{ - "unreferenced": { - module: map[string]string{ - "main.tf": ` + + // ======== BASIC ======== + // Fundamental behavior of actions + // ======== BASIC ======== + + "basics": { + "unreferenced": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} `, - }, - expectPlanActionCalled: false, + }, + expectPlanActionCalled: false, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 0 { - t.Fatalf("expected no actions in plan, got %d", len(p.Changes.ActionInvocations)) - } - if p.Applyable { - t.Fatalf("should not be able to apply this plan") - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 0 { + t.Fatalf("expected no actions in plan, got %d", len(p.Changes.ActionInvocations)) + } + if p.Applyable { + t.Fatalf("should not be able to apply this plan") + } + }, }, - }, - "invalid config": { - module: map[string]string{ - "main.tf": ` + "invalid config": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" { config { unknown_attr = "value" } } `, + }, + expectPlanActionCalled: false, + expectValidateDiagnostics: func(m *configs.Config) (diags tfdiags.Diagnostics) { + return diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unsupported argument", + Detail: `An argument named "unknown_attr" is not expected here.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 4, Column: 5, Byte: 49}, + End: hcl.Pos{Line: 4, Column: 17, Byte: 61}, + }, + }) + }, }, - expectPlanActionCalled: false, - expectValidateDiagnostics: func(m *configs.Config) (diags tfdiags.Diagnostics) { - return diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Unsupported argument", - Detail: `An argument named "unknown_attr" is not expected here.`, - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 4, Column: 5, Byte: 49}, - End: hcl.Pos{Line: 4, Column: 17, Byte: 61}, - }, - }) + + "actions cant be accessed in resources": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "my_action" { + config { + attr = "value" + } +} +resource "test_object" "a" { + name = action.test_unlinked.my_action.attr + lifecycle { + action_trigger { + events = [before_create] + actions = [action.test_unlinked.my_action] + } + } +} +`, + }, + expectValidateDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append( + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: "Actions can't be referenced in this context, they can only be referenced from within a resources lifecycle events list.", + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 8, Column: 10, Byte: 112}, + End: hcl.Pos{Line: 8, Column: 40, Byte: 142}, + }, + }) + }, + }, + + "actions cant be accessed in outputs": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "my_action" { + config { + attr = "value" + } +} +resource "test_object" "a" { + lifecycle { + action_trigger { + events = [before_create] + actions = [action.test_unlinked.my_action] + } + } +} + +output "my_output" { + value = action.test_unlinked.my_action.attr +} + +output "my_output2" { + value = action.test_unlinked.my_action +} +`, + }, + expectValidateDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append( + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: "Actions can't be referenced in this context, they can only be referenced from within a resources lifecycle events list.", + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 21, Column: 13, Byte: 337}, + End: hcl.Pos{Line: 21, Column: 43, Byte: 367}, + }, + }).Append( + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid reference", + Detail: "Actions can't be referenced in this context, they can only be referenced from within a resources lifecycle events list.", + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 17, Column: 13, Byte: 264}, + End: hcl.Pos{Line: 17, Column: 43, Byte: 294}, + }, + }, + ) + }, + }, + + "destroy run": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "hello" {} +resource "test_object" "a" { + lifecycle { + action_trigger { + events = [before_create, after_update] + actions = [action.test_unlinked.hello] + } + } +} +`, + }, + expectPlanActionCalled: false, + planOpts: SimplePlanOpts(plans.DestroyMode, InputValues{}), + }, + + "non-default provider namespace": { + module: map[string]string{ + "main.tf": ` +terraform { + required_providers { + ecosystem = { + source = "danielmschmidt/ecosystem" + } + } +} +action "ecosystem_unlinked" "hello" {} +resource "other_object" "a" { + lifecycle { + action_trigger { + events = [before_create] + actions = [action.ecosystem_unlinked.hello] + } + } +} +`, + }, + + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) + } + + action := p.Changes.ActionInvocations[0] + if action.Addr.String() != "action.ecosystem_unlinked.hello" { + t.Fatalf("expected action address to be 'action.ecosystem_unlinked.hello', got '%s'", action.Addr) + } + at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) + if !ok { + t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", action.ActionTrigger) + } + + if !at.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("other_object.a")) { + t.Fatalf("expected action to have triggering resource address 'other_object.a', but it is %s", at.TriggeringResourceAddr) + } + + if action.ProviderAddr.Provider.Namespace != "danielmschmidt" { + t.Fatalf("expected action to have the namespace 'danielmschmidt', got '%s'", action.ProviderAddr.Provider.Namespace) + } + }, }, }, - "before_create triggered": { - module: map[string]string{ - "main.tf": ` + // ======== TRIGGERING ======== + // action_trigger behavior + // ======== TRIGGERING ======== + + "triggering": { + "before_create triggered": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "a" { lifecycle { @@ -182,47 +344,47 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: true, + }, + expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) + } - action := p.Changes.ActionInvocations[0] - if action.Addr.String() != "action.test_unlinked.hello" { - t.Fatalf("expected action address to be 'action.test_unlinked.hello', got '%s'", action.Addr) - } + action := p.Changes.ActionInvocations[0] + if action.Addr.String() != "action.test_unlinked.hello" { + t.Fatalf("expected action address to be 'action.test_unlinked.hello', got '%s'", action.Addr) + } - at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) - if !ok { - t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", action.ActionTrigger) - } + at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) + if !ok { + t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", action.ActionTrigger) + } - if !at.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("test_object.a")) { - t.Fatalf("expected action to have a triggering resource address 'test_object.a', got '%s'", at.TriggeringResourceAddr) - } + if !at.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("test_object.a")) { + t.Fatalf("expected action to have a triggering resource address 'test_object.a', got '%s'", at.TriggeringResourceAddr) + } - if at.ActionTriggerBlockIndex != 0 { - t.Fatalf("expected action to have a triggering block index of 0, got %d", at.ActionTriggerBlockIndex) - } - if at.TriggerEvent() != configs.BeforeCreate { - t.Fatalf("expected action to have a triggering event of 'before_create', got '%s'", at.TriggerEvent()) - } - if at.ActionsListIndex != 0 { - t.Fatalf("expected action to have a actions list index of 0, got %d", at.ActionsListIndex) - } + if at.ActionTriggerBlockIndex != 0 { + t.Fatalf("expected action to have a triggering block index of 0, got %d", at.ActionTriggerBlockIndex) + } + if at.TriggerEvent() != configs.BeforeCreate { + t.Fatalf("expected action to have a triggering event of 'before_create', got '%s'", at.TriggerEvent()) + } + if at.ActionsListIndex != 0 { + t.Fatalf("expected action to have a actions list index of 0, got %d", at.ActionsListIndex) + } - if action.ProviderAddr.Provider != addrs.NewDefaultProvider("test") { - t.Fatalf("expected action to have a provider address of 'provider[\"registry.terraform.io/hashicorp/test\"]', got '%s'", action.ProviderAddr) - } + if action.ProviderAddr.Provider != addrs.NewDefaultProvider("test") { + t.Fatalf("expected action to have a provider address of 'provider[\"registry.terraform.io/hashicorp/test\"]', got '%s'", action.ProviderAddr) + } + }, }, - }, - "after_create triggered": { - module: map[string]string{ - "main.tf": ` + "after_create triggered": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "a" { lifecycle { @@ -233,26 +395,26 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: true, + }, + expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) + } - action := p.Changes.ActionInvocations[0] - if action.Addr.String() != "action.test_unlinked.hello" { - t.Fatalf("expected action address to be 'action.test_unlinked.hello', got '%s'", action.Addr) - } + action := p.Changes.ActionInvocations[0] + if action.Addr.String() != "action.test_unlinked.hello" { + t.Fatalf("expected action address to be 'action.test_unlinked.hello', got '%s'", action.Addr) + } - // TODO: Test that action the triggering resource address is set correctly + // TODO: Test that action the triggering resource address is set correctly + }, }, - }, - "before_update triggered - on create": { - module: map[string]string{ - "main.tf": ` + "before_update triggered - on create": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "a" { lifecycle { @@ -263,13 +425,13 @@ resource "test_object" "a" { } } `, + }, + expectPlanActionCalled: false, }, - expectPlanActionCalled: false, - }, - "after_update triggered - on create": { - module: map[string]string{ - "main.tf": ` + "after_update triggered - on create": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "a" { lifecycle { @@ -280,13 +442,13 @@ resource "test_object" "a" { } } `, + }, + expectPlanActionCalled: false, }, - expectPlanActionCalled: false, - }, - "before_update triggered - on update": { - module: map[string]string{ - "main.tf": ` + "before_update triggered - on update": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "a" { lifecycle { @@ -297,20 +459,20 @@ resource "test_object" "a" { } } `, - }, + }, - buildState: func(s *states.SyncState) { - addr := mustResourceInstanceAddr("test_object.a") - s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"name":"previous_run"}`), - }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + buildState: func(s *states.SyncState) { + addr := mustResourceInstanceAddr("test_object.a") + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"name":"previous_run"}`), + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + }, + expectPlanActionCalled: true, }, - expectPlanActionCalled: true, - }, - "after_update triggered - on update": { - module: map[string]string{ - "main.tf": ` + "after_update triggered - on update": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "a" { lifecycle { @@ -321,20 +483,20 @@ resource "test_object" "a" { } } `, - }, + }, - buildState: func(s *states.SyncState) { - addr := mustResourceInstanceAddr("test_object.a") - s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"name":"previous_run"}`), - }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + buildState: func(s *states.SyncState) { + addr := mustResourceInstanceAddr("test_object.a") + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"name":"previous_run"}`), + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + }, + expectPlanActionCalled: true, }, - expectPlanActionCalled: true, - }, - "before_update triggered - on replace": { - module: map[string]string{ - "main.tf": ` + "before_update triggered - on replace": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "a" { lifecycle { @@ -345,21 +507,21 @@ resource "test_object" "a" { } } `, - }, + }, - buildState: func(s *states.SyncState) { - addr := mustResourceInstanceAddr("test_object.a") - s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"name":"previous_run"}`), - Status: states.ObjectTainted, - }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + buildState: func(s *states.SyncState) { + addr := mustResourceInstanceAddr("test_object.a") + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"name":"previous_run"}`), + Status: states.ObjectTainted, + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + }, + expectPlanActionCalled: false, }, - expectPlanActionCalled: false, - }, - "after_update triggered - on replace": { - module: map[string]string{ - "main.tf": ` + "after_update triggered - on replace": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "a" { lifecycle { @@ -370,180 +532,431 @@ resource "test_object" "a" { } } `, - }, + }, - buildState: func(s *states.SyncState) { - addr := mustResourceInstanceAddr("test_object.a") - s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"name":"previous_run"}`), - Status: states.ObjectTainted, - }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + buildState: func(s *states.SyncState) { + addr := mustResourceInstanceAddr("test_object.a") + s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"name":"previous_run"}`), + Status: states.ObjectTainted, + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + }, + expectPlanActionCalled: false, }, - expectPlanActionCalled: false, - }, - "action for_each": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "hello" { - for_each = toset(["a", "b"]) - - config { - attr = "value-${each.key}" - } -} + "failing actions cancel next ones": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "failure" {} resource "test_object" "a" { lifecycle { action_trigger { events = [before_create] - actions = [action.test_unlinked.hello["a"], action.test_unlinked.hello["b"]] + actions = [action.test_unlinked.failure, action.test_unlinked.failure] + } + action_trigger { + events = [before_create] + actions = [action.test_unlinked.failure] } } } `, - }, - expectPlanActionCalled: true, - - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 2 { - t.Fatalf("expected 2 action in plan, got %d", len(p.Changes.ActionInvocations)) - } - - actionAddrs := []string{} - for _, action := range p.Changes.ActionInvocations { - actionAddrs = append(actionAddrs, action.Addr.String()) - } - slices.Sort(actionAddrs) + }, - if !slices.Equal(actionAddrs, []string{ - "action.test_unlinked.hello[\"a\"]", - "action.test_unlinked.hello[\"b\"]", - }) { - t.Fatalf("expected action addresses to be 'action.test_unlinked.hello[\"a\"]' and 'action.test_unlinked.hello[\"b\"]', got %v", actionAddrs) - } + planActionFn: func(_ *testing.T, _ providers.PlanActionRequest) providers.PlanActionResponse { + t.Helper() + return providers.PlanActionResponse{ + Diagnostics: tfdiags.Diagnostics{ + tfdiags.Sourceless(tfdiags.Error, "Planning failed", "Test case simulates an error while planning"), + }, + } + }, - // TODO: Test that action the triggering resource address is set correctly + expectPlanActionCalled: true, + // We only expect a single diagnostic here, the other should not have been called because the first one failed. + expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append( + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to plan action", + Detail: "Planning failed: Test case simulates an error while planning", + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 7, Column: 8, Byte: 149}, + End: hcl.Pos{Line: 7, Column: 46, Byte: 177}, + }, + }, + ) + }, }, - }, - - "action count": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "hello" { - count = 2 - - config { - attr = "value-${count.index}" - } -} + "actions with warnings don't cancel": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "failure" {} resource "test_object" "a" { lifecycle { action_trigger { events = [before_create] - actions = [action.test_unlinked.hello[0], action.test_unlinked.hello[1]] + actions = [action.test_unlinked.failure, action.test_unlinked.failure] + } + action_trigger { + events = [before_create] + actions = [action.test_unlinked.failure] } } } `, - }, - expectPlanActionCalled: true, - - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 2 { - t.Fatalf("expected 2 action in plan, got %d", len(p.Changes.ActionInvocations)) - } - - actionAddrs := []string{} - for _, action := range p.Changes.ActionInvocations { - actionAddrs = append(actionAddrs, action.Addr.String()) - } - slices.Sort(actionAddrs) + }, - if !slices.Equal(actionAddrs, []string{ - "action.test_unlinked.hello[0]", - "action.test_unlinked.hello[1]", - }) { - t.Fatalf("expected action addresses to be 'action.test_unlinked.hello[0]' and 'action.test_unlinked.hello[1]', got %v", actionAddrs) - } + planActionFn: func(t *testing.T, par providers.PlanActionRequest) providers.PlanActionResponse { + return providers.PlanActionResponse{ + Diagnostics: tfdiags.Diagnostics{ + tfdiags.Sourceless(tfdiags.Warning, "Warning during planning", "Test case simulates a warning while planning"), + }, + } + }, - // TODO: Test that action the triggering resource address is set correctly + expectPlanActionCalled: true, + // We only expect a single diagnostic here, the other should not have been called because the first one failed. + expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append( + &hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "Warnings when planning action", + Detail: "Warning during planning: Test case simulates a warning while planning", + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 7, Column: 8, Byte: 149}, + End: hcl.Pos{Line: 7, Column: 46, Byte: 177}, + }, + }, + &hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "Warnings when planning action", + Detail: "Warning during planning: Test case simulates a warning while planning", + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 7, Column: 48, Byte: 179}, + End: hcl.Pos{Line: 7, Column: 76, Byte: 207}, + }, + }, + &hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "Warnings when planning action", + Detail: "Warning during planning: Test case simulates a warning while planning", + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 11, Column: 8, Byte: 284}, + End: hcl.Pos{Line: 11, Column: 46, Byte: 312}, + }, + }, + ) + }, }, - }, - - "action for_each invalid access": { - module: map[string]string{ - "main.tf": ` + "splat is not supported": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" { - for_each = toset(["a", "b"]) - - config { - attr = "value-${each.key}" - } + count = 42 } resource "test_object" "a" { lifecycle { action_trigger { events = [before_create] - actions = [action.test_unlinked.hello["c"]] + actions = [action.test_unlinked.hello[*]] } } } `, + }, + expectPlanActionCalled: false, + expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid action expression", + Detail: "Unexpected expression found in action_triggers.actions.", + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 9, Column: 18, Byte: 161}, + End: hcl.Pos{Line: 9, Column: 47, Byte: 190}, + }, + }) + }, }, - expectPlanActionCalled: false, - expectPlanDiagnostics: func(m *configs.Config) (diags tfdiags.Diagnostics) { - return diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Reference to non-existent action instance", - Detail: "Action instance was not found in the current context.", - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 13, Column: 18, Byte: 226}, - End: hcl.Pos{Line: 13, Column: 49, Byte: 257}, - }, - }) - }, - }, - - "action count invalid access": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "hello" { - count = 2 - - config { - attr = "value-${count.index}" - } -} + "multiple events triggering in same action trigger": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "hello" {} resource "test_object" "a" { lifecycle { action_trigger { - events = [before_create] - actions = [action.test_unlinked.hello[2]] + events = [ + before_create, // should trigger + after_create, // should trigger + before_update // should be ignored + ] + actions = [action.test_unlinked.hello] } } } `, + }, + expectPlanActionCalled: true, + + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 2 { + t.Fatalf("expected 2 action in plan, got %d", len(p.Changes.ActionInvocations)) + } + + triggeredEvents := []configs.ActionTriggerEvent{} + for _, action := range p.Changes.ActionInvocations { + at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) + if !ok { + t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", action.ActionTrigger) + } + triggeredEvents = append(triggeredEvents, at.ActionTriggerEvent) + } + slices.Sort(triggeredEvents) + if diff := cmp.Diff([]configs.ActionTriggerEvent{configs.BeforeCreate, configs.AfterCreate}, triggeredEvents); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }, }, - expectPlanActionCalled: false, - expectPlanDiagnostics: func(m *configs.Config) (diags tfdiags.Diagnostics) { - return diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Reference to non-existent action instance", - Detail: "Action instance was not found in the current context.", - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 13, Column: 18, Byte: 210}, - End: hcl.Pos{Line: 13, Column: 47, Byte: 239}, - }, - }) + + "multiple events triggered together": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "one" {} +action "test_unlinked" "two" {} +resource "test_object" "a" { + lifecycle { + action_trigger { + events = [before_create, after_create, before_update, after_update] + actions = [action.test_unlinked.one, action.test_unlinked.two] + } + } +} +`, + }, + expectPlanActionCalled: true, + }, + + "multiple events triggering in multiple action trigger": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "hello" {} +resource "test_object" "a" { + lifecycle { + // should trigger + action_trigger { + events = [before_create] + actions = [action.test_unlinked.hello] + } + // should trigger + action_trigger { + events = [after_create] + actions = [action.test_unlinked.hello] + } + // should be ignored + action_trigger { + events = [before_update] + actions = [action.test_unlinked.hello] + } + } +} +`, + }, + expectPlanActionCalled: true, + + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 2 { + t.Fatalf("expected 2 action in plan, got %d", len(p.Changes.ActionInvocations)) + } + + triggeredEvents := []configs.ActionTriggerEvent{} + for _, action := range p.Changes.ActionInvocations { + at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) + if !ok { + t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", action.ActionTrigger) + } + triggeredEvents = append(triggeredEvents, at.ActionTriggerEvent) + } + slices.Sort(triggeredEvents) + if diff := cmp.Diff([]configs.ActionTriggerEvent{configs.BeforeCreate, configs.AfterCreate}, triggeredEvents); diff != "" { + t.Errorf("wrong result\n%s", diff) + } + }, }, }, - "expanded resource - unexpanded action": { - module: map[string]string{ - "main.tf": ` + // ======== EXPANSION ======== + // action expansion behavior (count & for_each) + // ======== EXPANSION ======== + + "expansion": { + "action for_each": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "hello" { + for_each = toset(["a", "b"]) + + config { + attr = "value-${each.key}" + } +} +resource "test_object" "a" { + lifecycle { + action_trigger { + events = [before_create] + actions = [action.test_unlinked.hello["a"], action.test_unlinked.hello["b"]] + } + } +} +`, + }, + expectPlanActionCalled: true, + + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 2 { + t.Fatalf("expected 2 action in plan, got %d", len(p.Changes.ActionInvocations)) + } + + actionAddrs := []string{} + for _, action := range p.Changes.ActionInvocations { + actionAddrs = append(actionAddrs, action.Addr.String()) + } + slices.Sort(actionAddrs) + + if !slices.Equal(actionAddrs, []string{ + "action.test_unlinked.hello[\"a\"]", + "action.test_unlinked.hello[\"b\"]", + }) { + t.Fatalf("expected action addresses to be 'action.test_unlinked.hello[\"a\"]' and 'action.test_unlinked.hello[\"b\"]', got %v", actionAddrs) + } + + // TODO: Test that action the triggering resource address is set correctly + }, + }, + + "action count": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "hello" { + count = 2 + + config { + attr = "value-${count.index}" + } +} + +resource "test_object" "a" { + lifecycle { + action_trigger { + events = [before_create] + actions = [action.test_unlinked.hello[0], action.test_unlinked.hello[1]] + } + } +} +`, + }, + expectPlanActionCalled: true, + + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 2 { + t.Fatalf("expected 2 action in plan, got %d", len(p.Changes.ActionInvocations)) + } + + actionAddrs := []string{} + for _, action := range p.Changes.ActionInvocations { + actionAddrs = append(actionAddrs, action.Addr.String()) + } + slices.Sort(actionAddrs) + + if !slices.Equal(actionAddrs, []string{ + "action.test_unlinked.hello[0]", + "action.test_unlinked.hello[1]", + }) { + t.Fatalf("expected action addresses to be 'action.test_unlinked.hello[0]' and 'action.test_unlinked.hello[1]', got %v", actionAddrs) + } + + // TODO: Test that action the triggering resource address is set correctly + }, + }, + + "action for_each invalid access": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "hello" { + for_each = toset(["a", "b"]) + + config { + attr = "value-${each.key}" + } +} +resource "test_object" "a" { + lifecycle { + action_trigger { + events = [before_create] + actions = [action.test_unlinked.hello["c"]] + } + } +} +`, + }, + expectPlanActionCalled: false, + expectPlanDiagnostics: func(m *configs.Config) (diags tfdiags.Diagnostics) { + return diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Reference to non-existent action instance", + Detail: "Action instance was not found in the current context.", + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 13, Column: 18, Byte: 226}, + End: hcl.Pos{Line: 13, Column: 49, Byte: 257}, + }, + }) + }, + }, + + "action count invalid access": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "hello" { + count = 2 + + config { + attr = "value-${count.index}" + } +} +resource "test_object" "a" { + lifecycle { + action_trigger { + events = [before_create] + actions = [action.test_unlinked.hello[2]] + } + } +} +`, + }, + expectPlanActionCalled: false, + expectPlanDiagnostics: func(m *configs.Config) (diags tfdiags.Diagnostics) { + return diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Reference to non-existent action instance", + Detail: "Action instance was not found in the current context.", + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 13, Column: 18, Byte: 210}, + End: hcl.Pos{Line: 13, Column: 47, Byte: 239}, + }, + }) + }, + }, + + "expanded resource - unexpanded action": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "a" { count = 2 @@ -556,33 +969,33 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: true, + }, + expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 2 { - t.Fatalf("expected 2 action in plan, got %d", len(p.Changes.ActionInvocations)) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 2 { + t.Fatalf("expected 2 action in plan, got %d", len(p.Changes.ActionInvocations)) + } - actionAddrs := []string{} - for _, action := range p.Changes.ActionInvocations { - actionAddrs = append(actionAddrs, action.Addr.String()) - } - slices.Sort(actionAddrs) + actionAddrs := []string{} + for _, action := range p.Changes.ActionInvocations { + actionAddrs = append(actionAddrs, action.Addr.String()) + } + slices.Sort(actionAddrs) - if !slices.Equal(actionAddrs, []string{ - "action.test_unlinked.hello", - "action.test_unlinked.hello", - }) { - t.Fatalf("expected action addresses to be 'action.test_unlinked.hello' and 'action.test_unlinked.hello', got %v", actionAddrs) - } + if !slices.Equal(actionAddrs, []string{ + "action.test_unlinked.hello", + "action.test_unlinked.hello", + }) { + t.Fatalf("expected action addresses to be 'action.test_unlinked.hello' and 'action.test_unlinked.hello', got %v", actionAddrs) + } - // TODO: Test that action the triggering resource address is set correctly + // TODO: Test that action the triggering resource address is set correctly + }, }, - }, - "expanded resource - expanded action": { - module: map[string]string{ - "main.tf": ` + "expanded resource - expanded action": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" { count = 2 @@ -601,34 +1014,77 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: true, + }, + expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 2 { - t.Fatalf("expected 2 action in plan, got %d", len(p.Changes.ActionInvocations)) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 2 { + t.Fatalf("expected 2 action in plan, got %d", len(p.Changes.ActionInvocations)) + } - actionAddrs := []string{} - for _, action := range p.Changes.ActionInvocations { - actionAddrs = append(actionAddrs, action.Addr.String()) - } - slices.Sort(actionAddrs) + actionAddrs := []string{} + for _, action := range p.Changes.ActionInvocations { + actionAddrs = append(actionAddrs, action.Addr.String()) + } + slices.Sort(actionAddrs) - if !slices.Equal(actionAddrs, []string{ - "action.test_unlinked.hello[0]", - "action.test_unlinked.hello[1]", - }) { - t.Fatalf("expected action addresses to be 'action.test_unlinked.hello[0]' and 'action.test_unlinked.hello[1]', got %v", actionAddrs) - } + if !slices.Equal(actionAddrs, []string{ + "action.test_unlinked.hello[0]", + "action.test_unlinked.hello[1]", + }) { + t.Fatalf("expected action addresses to be 'action.test_unlinked.hello[0]' and 'action.test_unlinked.hello[1]', got %v", actionAddrs) + } + + // TODO: Test that action the triggering resource address is set correctly + }, + }, - // TODO: Test that action the triggering resource address is set correctly + // Since if we just destroy a node there is no reference to an action in config, we try + // to provoke an error by just removing a resource instance. + "destroying expanded node": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "hello" {} +resource "test_object" "a" { + count = 2 + lifecycle { + action_trigger { + events = [before_create, after_update] + actions = [action.test_unlinked.hello] + } + } +} +`, + }, + expectPlanActionCalled: false, + + buildState: func(s *states.SyncState) { + s.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a[0]"), &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + + s.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a[1]"), &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + + s.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a[2]"), &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + }, }, }, - "transitive dependencies": { - module: map[string]string{ - "main.tf": ` + // ======== CONFIG ======== + // action config behavior (secrets, write_only, dependencies) + // ======== CONFIG ======== + + "config": { + "transitive dependencies": { + module: map[string]string{ + "main.tf": ` resource "test_object" "a" { name = "a" } @@ -647,13 +1103,13 @@ resource "test_object" "b" { } } `, + }, + expectPlanActionCalled: true, }, - expectPlanActionCalled: true, - }, - "expanded transitive dependencies": { - module: map[string]string{ - "main.tf": ` + "expanded transitive dependencies": { + module: map[string]string{ + "main.tf": ` resource "test_object" "a" { name = "a" } @@ -698,270 +1154,317 @@ resource "test_object" "e" { } } `, + }, + expectPlanActionCalled: true, }, - expectPlanActionCalled: true, - }, - "failing actions cancel next ones": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "failure" {} + "action config with after_create dependency to triggering resource": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "hello" { + config { + attr = test_object.a.name + } +} resource "test_object" "a" { + name = "test_name" lifecycle { action_trigger { - events = [before_create] - actions = [action.test_unlinked.failure, action.test_unlinked.failure] - } - action_trigger { - events = [before_create] - actions = [action.test_unlinked.failure] + events = [after_create] + actions = [action.test_unlinked.hello] } } } `, - }, + }, + expectPlanActionCalled: true, + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Fatalf("expected one action in plan, got %d", len(p.Changes.ActionInvocations)) + } - planActionFn: func(_ *testing.T, _ providers.PlanActionRequest) providers.PlanActionResponse { - t.Helper() - return providers.PlanActionResponse{ - Diagnostics: tfdiags.Diagnostics{ - tfdiags.Sourceless(tfdiags.Error, "Planning failed", "Test case simulates an error while planning"), - }, - } - }, + if p.Changes.ActionInvocations[0].ActionTrigger.TriggerEvent() != configs.AfterCreate { + t.Fatalf("expected trigger event to be of type AfterCreate, got: %v", p.Changes.ActionInvocations[0].ActionTrigger) + } - expectPlanActionCalled: true, - // We only expect a single diagnostic here, the other should not have been called because the first one failed. - expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - return tfdiags.Diagnostics{}.Append( - &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Failed to plan action", - Detail: "Planning failed: Test case simulates an error while planning", - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 7, Column: 8, Byte: 149}, - End: hcl.Pos{Line: 7, Column: 46, Byte: 177}, - }, - }, - ) + if p.Changes.ActionInvocations[0].Addr.Action.String() != "action.test_unlinked.hello" { + t.Fatalf("expected action to equal 'action.test_unlinked.hello', got '%s'", p.Changes.ActionInvocations[0].Addr) + } + + decode, err := p.Changes.ActionInvocations[0].ConfigValue.Decode(cty.Object(map[string]cty.Type{"attr": cty.String})) + if err != nil { + t.Fatal(err) + } + + if decode.GetAttr("attr").AsString() != "test_name" { + t.Fatalf("expected action config field 'attr' to have value 'test_name', got '%s'", decode.GetAttr("attr").AsString()) + } + }, }, - }, - "actions with warnings don't cancel": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "failure" {} + "action config refers to before triggering resource leads to validation error": { + module: map[string]string{ + "main.tf": ` +action "test_unlinked" "hello" { + config { + attr = test_object.a.name + } +} resource "test_object" "a" { + name = "test_name" lifecycle { action_trigger { events = [before_create] - actions = [action.test_unlinked.failure, action.test_unlinked.failure] - } - action_trigger { - events = [before_create] - actions = [action.test_unlinked.failure] + actions = [action.test_unlinked.hello] } } } `, + }, + expectPlanActionCalled: true, // The cycle only appears in the apply graph + assertPlanDiagnostics: func(t *testing.T, diags tfdiags.Diagnostics) { + if !diags.HasErrors() { + t.Fatalf("expected diagnostics to have errors, but it does not") + } + if len(diags) != 1 { + t.Fatalf("expected diagnostics to have 1 error, but it has %d", len(diags)) + } + // We expect the diagnostic to be about a cycle + if !strings.Contains(diags[0].Description().Summary, "Cycle") { + t.Fatalf("expected diagnostic summary to contain 'Cycle', got '%s'", diags[0].Description().Summary) + } + // We expect the action node to be part of the cycle + if !strings.Contains(diags[0].Description().Summary, "action.test_unlinked.hello") { + t.Fatalf("expected diagnostic summary to contain 'action.test_unlinked.hello', got '%s'", diags[0].Description().Summary) + } + // We expect the resource node to be part of the cycle + if !strings.Contains(diags[0].Description().Summary, "test_object.a") { + t.Fatalf("expected diagnostic summary to contain 'test_object.a', got '%s'", diags[0].Description().Summary) + } + }, }, - planActionFn: func(t *testing.T, par providers.PlanActionRequest) providers.PlanActionResponse { - return providers.PlanActionResponse{ - Diagnostics: tfdiags.Diagnostics{ - tfdiags.Sourceless(tfdiags.Warning, "Warning during planning", "Test case simulates a warning while planning"), - }, - } - }, - - expectPlanActionCalled: true, - // We only expect a single diagnostic here, the other should not have been called because the first one failed. - expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - return tfdiags.Diagnostics{}.Append( - &hcl.Diagnostic{ - Severity: hcl.DiagWarning, - Summary: "Warnings when planning action", - Detail: "Warning during planning: Test case simulates a warning while planning", - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 7, Column: 8, Byte: 149}, - End: hcl.Pos{Line: 7, Column: 46, Byte: 177}, - }, - }, - &hcl.Diagnostic{ - Severity: hcl.DiagWarning, - Summary: "Warnings when planning action", - Detail: "Warning during planning: Test case simulates a warning while planning", - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 7, Column: 48, Byte: 179}, - End: hcl.Pos{Line: 7, Column: 76, Byte: 207}, - }, - }, - &hcl.Diagnostic{ - Severity: hcl.DiagWarning, - Summary: "Warnings when planning action", - Detail: "Warning during planning: Test case simulates a warning while planning", - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 11, Column: 8, Byte: 284}, - End: hcl.Pos{Line: 11, Column: 46, Byte: 312}, - }, - }, - ) - }, - }, - - "actions cant be accessed in resources": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "my_action" { + "secret values": { + module: map[string]string{ + "main.tf": ` +variable "secret" { + type = string + sensitive = true +} +action "test_unlinked" "hello" { config { - attr = "value" + attr = var.secret } } resource "test_object" "a" { - name = action.test_unlinked.my_action.attr lifecycle { action_trigger { events = [before_create] - actions = [action.test_unlinked.my_action] + actions = [action.test_unlinked.hello] } } } `, + }, + planOpts: &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "secret": &InputValue{ + Value: cty.StringVal("secret"), + SourceType: ValueFromCLIArg, + }}, + }, + expectPlanActionCalled: true, + + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) + } + + action := p.Changes.ActionInvocations[0] + ac, err := action.Decode(&unlinkedActionSchema) + if err != nil { + t.Fatalf("expected action to decode successfully, but got error: %v", err) + } + + if !marks.Has(ac.ConfigValue.GetAttr("attr"), marks.Sensitive) { + t.Fatalf("expected attribute 'attr' to be marked as sensitive") + } + }, }, - expectValidateDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - return tfdiags.Diagnostics{}.Append( - &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid reference", - Detail: "Actions can't be referenced in this context, they can only be referenced from within a resources lifecycle events list.", - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 8, Column: 10, Byte: 112}, - End: hcl.Pos{Line: 8, Column: 40, Byte: 142}, - }, - }) - }, - }, - "actions cant be accessed in outputs": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "my_action" { - config { - attr = "value" - } + "write-only attributes": { + module: map[string]string{ + "main.tf": ` +variable "attr" { + type = string + ephemeral = true } -resource "test_object" "a" { + +resource "test_object" "resource" { + name = "hello" lifecycle { action_trigger { events = [before_create] - actions = [action.test_unlinked.my_action] + actions = [action.test_unlinked_wo.hello] } } } -output "my_output" { - value = action.test_unlinked.my_action.attr -} - -output "my_output2" { - value = action.test_unlinked.my_action +action "test_unlinked_wo" "hello" { + config { + attr = var.attr + } } `, - }, - expectValidateDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - return tfdiags.Diagnostics{}.Append( - &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid reference", - Detail: "Actions can't be referenced in this context, they can only be referenced from within a resources lifecycle events list.", - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 21, Column: 13, Byte: 337}, - End: hcl.Pos{Line: 21, Column: 43, Byte: 367}, - }, - }).Append( - &hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid reference", - Detail: "Actions can't be referenced in this context, they can only be referenced from within a resources lifecycle events list.", - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 17, Column: 13, Byte: 264}, - End: hcl.Pos{Line: 17, Column: 43, Byte: 294}, - }, + }, + planOpts: SimplePlanOpts(plans.NormalMode, InputValues{ + "attr": { + Value: cty.StringVal("wo-plan"), }, - ) + }), + expectPlanActionCalled: true, + assertPlan: func(t *testing.T, plan *plans.Plan) { + if len(plan.Changes.ActionInvocations) != 1 { + t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) + } + + ais := plan.Changes.ActionInvocations[0] + ai, err := ais.Decode(&writeOnlyUnlinkedActionSchema) + if err != nil { + t.Fatal(err) + } + + if !ai.ConfigValue.GetAttr("attr").IsNull() { + t.Fatal("should have converted ephemeral value to null in the plan") + } + }, }, - }, - "destroy run": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "hello" {} + "action config nested single + list blocks": { + module: map[string]string{ + "main.tf": ` +action "test_nested" "with_blocks" { + config { + top_attr = "top" + settings { + name = "primary" + rule { + value = "r1" + } + rule { + value = "r2" + } + } + } +} resource "test_object" "a" { + name = "object" lifecycle { action_trigger { - events = [before_create, after_update] - actions = [action.test_unlinked.hello] + events = [before_create] + actions = [action.test_nested.with_blocks] } } } `, + }, + expectPlanActionCalled: true, + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Fatalf("expected 1 action invocation, got %d", len(p.Changes.ActionInvocations)) + } + ais := p.Changes.ActionInvocations[0] + decoded, err := ais.Decode(&nestedActionSchema) + if err != nil { + t.Fatalf("error decoding nested action: %s", err) + } + cv := decoded.ConfigValue + if cv.GetAttr("top_attr").AsString() != "top" { + t.Fatalf("expected top_attr = top, got %s", cv.GetAttr("top_attr").GoString()) + } + settings := cv.GetAttr("settings") + if !settings.Type().IsObjectType() { + t.Fatalf("expected settings object, got %s", settings.Type().FriendlyName()) + } + if settings.GetAttr("name").AsString() != "primary" { + t.Fatalf("expected settings.name = primary, got %s", settings.GetAttr("name").GoString()) + } + rules := settings.GetAttr("rule") + if !rules.Type().IsListType() || rules.LengthInt() != 2 { + t.Fatalf("expected 2 rule blocks, got type %s length %d", rules.Type().FriendlyName(), rules.LengthInt()) + } + first := rules.Index(cty.NumberIntVal(0)).GetAttr("value").AsString() + second := rules.Index(cty.NumberIntVal(1)).GetAttr("value").AsString() + if first != "r1" || second != "r2" { + t.Fatalf("expected rule values r1,r2 got %s,%s", first, second) + } + }, }, - expectPlanActionCalled: false, - planOpts: SimplePlanOpts(plans.DestroyMode, InputValues{}), - }, - // Since if we just destroy a node there is no reference to an action in config, we try - // to provoke an error by just removing a resource instance. - "destroying expanded node": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "hello" {} + "action config top-level list block": { + module: map[string]string{ + "main.tf": ` +action "test_nested" "with_list" { + config { + settings_list { + id = "one" + } + settings_list { + id = "two" + } + } +} resource "test_object" "a" { - count = 2 lifecycle { action_trigger { - events = [before_create, after_update] - actions = [action.test_unlinked.hello] + events = [after_create] + actions = [action.test_nested.with_list] } } } `, - }, - expectPlanActionCalled: false, - - buildState: func(s *states.SyncState) { - s.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a[0]"), &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{}`), - Status: states.ObjectReady, - }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) - - s.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a[1]"), &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{}`), - Status: states.ObjectReady, - }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) - - s.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a[2]"), &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{}`), - Status: states.ObjectReady, - }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + }, + expectPlanActionCalled: true, + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Fatalf("expected 1 action invocation, got %d", len(p.Changes.ActionInvocations)) + } + ais := p.Changes.ActionInvocations[0] + decoded, err := ais.Decode(&nestedActionSchema) + if err != nil { + t.Fatalf("error decoding nested action: %s", err) + } + cv := decoded.ConfigValue + if !cv.GetAttr("top_attr").IsNull() { + t.Fatalf("expected top_attr to be null, got %s", cv.GetAttr("top_attr").GoString()) + } + sl := cv.GetAttr("settings_list") + if !sl.Type().IsListType() || sl.LengthInt() != 2 { + t.Fatalf("expected 2 settings_list blocks, got type %s length %d", sl.Type().FriendlyName(), sl.LengthInt()) + } + first := sl.Index(cty.NumberIntVal(0)).GetAttr("id").AsString() + second := sl.Index(cty.NumberIntVal(1)).GetAttr("id").AsString() + if first != "one" || second != "two" { + t.Fatalf("expected ids one,two got %s,%s", first, second) + } + }, }, }, - "triggered within module": { - module: map[string]string{ - "main.tf": ` + // ======== MODULES ======== + // actions within modules + // ======== MODULES ======== + + "modules": { + "triggered within module": { + module: map[string]string{ + "main.tf": ` module "mod" { source = "./mod" } `, - "mod/mod.tf": ` + "mod/mod.tf": ` action "test_unlinked" "hello" {} resource "other_object" "a" { lifecycle { @@ -972,53 +1475,53 @@ resource "other_object" "a" { } } `, - }, - expectPlanActionCalled: true, + }, + expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) + } - action := p.Changes.ActionInvocations[0] - if action.Addr.String() != "module.mod.action.test_unlinked.hello" { - t.Fatalf("expected action address to be 'module.mod.action.test_unlinked.hello', got '%s'", action.Addr) - } + action := p.Changes.ActionInvocations[0] + if action.Addr.String() != "module.mod.action.test_unlinked.hello" { + t.Fatalf("expected action address to be 'module.mod.action.test_unlinked.hello', got '%s'", action.Addr) + } - at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) - if !ok { - t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", action.ActionTrigger) - } + at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) + if !ok { + t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", action.ActionTrigger) + } - if !at.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("module.mod.other_object.a")) { - t.Fatalf("expected action to have triggering resource address 'module.mod.other_object.a', but it is %s", at.TriggeringResourceAddr) - } + if !at.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("module.mod.other_object.a")) { + t.Fatalf("expected action to have triggering resource address 'module.mod.other_object.a', but it is %s", at.TriggeringResourceAddr) + } - if at.ActionTriggerBlockIndex != 0 { - t.Fatalf("expected action to have a triggering block index of 0, got %d", at.ActionTriggerBlockIndex) - } - if at.TriggerEvent() != configs.BeforeCreate { - t.Fatalf("expected action to have a triggering event of 'before_create', got '%s'", at.TriggerEvent()) - } - if at.ActionsListIndex != 0 { - t.Fatalf("expected action to have a actions list index of 0, got %d", at.ActionsListIndex) - } + if at.ActionTriggerBlockIndex != 0 { + t.Fatalf("expected action to have a triggering block index of 0, got %d", at.ActionTriggerBlockIndex) + } + if at.TriggerEvent() != configs.BeforeCreate { + t.Fatalf("expected action to have a triggering event of 'before_create', got '%s'", at.TriggerEvent()) + } + if at.ActionsListIndex != 0 { + t.Fatalf("expected action to have a actions list index of 0, got %d", at.ActionsListIndex) + } - if action.ProviderAddr.Provider != addrs.NewDefaultProvider("test") { - t.Fatalf("expected action to have a provider address of 'provider[\"registry.terraform.io/hashicorp/test\"]', got '%s'", action.ProviderAddr) - } + if action.ProviderAddr.Provider != addrs.NewDefaultProvider("test") { + t.Fatalf("expected action to have a provider address of 'provider[\"registry.terraform.io/hashicorp/test\"]', got '%s'", action.ProviderAddr) + } + }, }, - }, - "triggered within module instance": { - module: map[string]string{ - "main.tf": ` + "triggered within module instance": { + module: map[string]string{ + "main.tf": ` module "mod" { count = 2 source = "./mod" } `, - "mod/mod.tf": ` + "mod/mod.tf": ` action "test_unlinked" "hello" {} resource "other_object" "a" { lifecycle { @@ -1029,77 +1532,77 @@ resource "other_object" "a" { } } `, - }, - expectPlanActionCalled: true, - - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 2 { - t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) - } + }, + expectPlanActionCalled: true, - // We know we are run within two child modules, so we can just sort by the triggering resource address - slices.SortFunc(p.Changes.ActionInvocations, func(a, b *plans.ActionInvocationInstanceSrc) int { - at, ok := a.ActionTrigger.(*plans.LifecycleActionTrigger) - if !ok { - t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", a.ActionTrigger) + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 2 { + t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) } - bt, ok := b.ActionTrigger.(*plans.LifecycleActionTrigger) - if !ok { - t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", b.ActionTrigger) - } - if at.TriggeringResourceAddr.String() < bt.TriggeringResourceAddr.String() { - return -1 - } else { - return 1 - } - }) - action := p.Changes.ActionInvocations[0] - if action.Addr.String() != "module.mod[0].action.test_unlinked.hello" { - t.Fatalf("expected action address to be 'module.mod[0].action.test_unlinked.hello', got '%s'", action.Addr) - } + // We know we are run within two child modules, so we can just sort by the triggering resource address + slices.SortFunc(p.Changes.ActionInvocations, func(a, b *plans.ActionInvocationInstanceSrc) int { + at, ok := a.ActionTrigger.(*plans.LifecycleActionTrigger) + if !ok { + t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", a.ActionTrigger) + } + bt, ok := b.ActionTrigger.(*plans.LifecycleActionTrigger) + if !ok { + t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", b.ActionTrigger) + } + if at.TriggeringResourceAddr.String() < bt.TriggeringResourceAddr.String() { + return -1 + } else { + return 1 + } + }) - at := action.ActionTrigger.(*plans.LifecycleActionTrigger) + action := p.Changes.ActionInvocations[0] + if action.Addr.String() != "module.mod[0].action.test_unlinked.hello" { + t.Fatalf("expected action address to be 'module.mod[0].action.test_unlinked.hello', got '%s'", action.Addr) + } - if !at.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("module.mod[0].other_object.a")) { - t.Fatalf("expected action to have triggering resource address 'module.mod[0].other_object.a', but it is %s", at.TriggeringResourceAddr) - } + at := action.ActionTrigger.(*plans.LifecycleActionTrigger) - if at.ActionTriggerBlockIndex != 0 { - t.Fatalf("expected action to have a triggering block index of 0, got %d", at.ActionTriggerBlockIndex) - } - if at.TriggerEvent() != configs.BeforeCreate { - t.Fatalf("expected action to have a triggering event of 'before_create', got '%s'", at.TriggerEvent()) - } - if at.ActionsListIndex != 0 { - t.Fatalf("expected action to have a actions list index of 0, got %d", at.ActionsListIndex) - } + if !at.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("module.mod[0].other_object.a")) { + t.Fatalf("expected action to have triggering resource address 'module.mod[0].other_object.a', but it is %s", at.TriggeringResourceAddr) + } - if action.ProviderAddr.Provider != addrs.NewDefaultProvider("test") { - t.Fatalf("expected action to have a provider address of 'provider[\"registry.terraform.io/hashicorp/test\"]', got '%s'", action.ProviderAddr) - } + if at.ActionTriggerBlockIndex != 0 { + t.Fatalf("expected action to have a triggering block index of 0, got %d", at.ActionTriggerBlockIndex) + } + if at.TriggerEvent() != configs.BeforeCreate { + t.Fatalf("expected action to have a triggering event of 'before_create', got '%s'", at.TriggerEvent()) + } + if at.ActionsListIndex != 0 { + t.Fatalf("expected action to have a actions list index of 0, got %d", at.ActionsListIndex) + } - action2 := p.Changes.ActionInvocations[1] - if action2.Addr.String() != "module.mod[1].action.test_unlinked.hello" { - t.Fatalf("expected action address to be 'module.mod[1].action.test_unlinked.hello', got '%s'", action2.Addr) - } + if action.ProviderAddr.Provider != addrs.NewDefaultProvider("test") { + t.Fatalf("expected action to have a provider address of 'provider[\"registry.terraform.io/hashicorp/test\"]', got '%s'", action.ProviderAddr) + } - a2t := action2.ActionTrigger.(*plans.LifecycleActionTrigger) + action2 := p.Changes.ActionInvocations[1] + if action2.Addr.String() != "module.mod[1].action.test_unlinked.hello" { + t.Fatalf("expected action address to be 'module.mod[1].action.test_unlinked.hello', got '%s'", action2.Addr) + } - if !a2t.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("module.mod[1].other_object.a")) { - t.Fatalf("expected action to have triggering resource address 'module.mod[1].other_object.a', but it is %s", a2t.TriggeringResourceAddr) - } + a2t := action2.ActionTrigger.(*plans.LifecycleActionTrigger) + + if !a2t.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("module.mod[1].other_object.a")) { + t.Fatalf("expected action to have triggering resource address 'module.mod[1].other_object.a', but it is %s", a2t.TriggeringResourceAddr) + } + }, }, - }, - "provider is within module": { - module: map[string]string{ - "main.tf": ` + "provider is within module": { + module: map[string]string{ + "main.tf": ` module "mod" { source = "./mod" } `, - "mod/mod.tf": ` + "mod/mod.tf": ` provider "test" { alias = "inthemodule" } @@ -1115,86 +1618,46 @@ resource "other_object" "a" { } } `, - }, - expectPlanActionCalled: true, + }, + expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) + } - action := p.Changes.ActionInvocations[0] - if action.Addr.String() != "module.mod.action.test_unlinked.hello" { - t.Fatalf("expected action address to be 'module.mod.action.test_unlinked.hello', got '%s'", action.Addr) - } + action := p.Changes.ActionInvocations[0] + if action.Addr.String() != "module.mod.action.test_unlinked.hello" { + t.Fatalf("expected action address to be 'module.mod.action.test_unlinked.hello', got '%s'", action.Addr) + } - at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) - if !ok { - t.Fatalf("expected action trigger to be a lifecycle action trigger, got %T", action.ActionTrigger) - } + at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) + if !ok { + t.Fatalf("expected action trigger to be a lifecycle action trigger, got %T", action.ActionTrigger) + } - if !at.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("module.mod.other_object.a")) { - t.Fatalf("expected action to have triggering resource address 'module.mod.other_object.a', but it is %s", at.TriggeringResourceAddr) - } + if !at.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("module.mod.other_object.a")) { + t.Fatalf("expected action to have triggering resource address 'module.mod.other_object.a', but it is %s", at.TriggeringResourceAddr) + } - if action.ProviderAddr.Module.String() != "module.mod" { - t.Fatalf("expected action to have a provider module address of 'module.mod' got '%s'", action.ProviderAddr.Module.String()) - } - if action.ProviderAddr.Alias != "inthemodule" { - t.Fatalf("expected action to have a provider alias of 'inthemodule', got '%s'", action.ProviderAddr.Alias) - } + if action.ProviderAddr.Module.String() != "module.mod" { + t.Fatalf("expected action to have a provider module address of 'module.mod' got '%s'", action.ProviderAddr.Module.String()) + } + if action.ProviderAddr.Alias != "inthemodule" { + t.Fatalf("expected action to have a provider alias of 'inthemodule', got '%s'", action.ProviderAddr.Alias) + } + }, }, }, - "non-default provider namespace": { - module: map[string]string{ - "main.tf": ` -terraform { - required_providers { - ecosystem = { - source = "danielmschmidt/ecosystem" - } - } -} -action "ecosystem_unlinked" "hello" {} -resource "other_object" "a" { - lifecycle { - action_trigger { - events = [before_create] - actions = [action.ecosystem_unlinked.hello] - } - } -} -`, - }, - - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) - } - - action := p.Changes.ActionInvocations[0] - if action.Addr.String() != "action.ecosystem_unlinked.hello" { - t.Fatalf("expected action address to be 'action.ecosystem_unlinked.hello', got '%s'", action.Addr) - } - at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) - if !ok { - t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", action.ActionTrigger) - } - - if !at.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("other_object.a")) { - t.Fatalf("expected action to have triggering resource address 'other_object.a', but it is %s", at.TriggeringResourceAddr) - } - - if action.ProviderAddr.Provider.Namespace != "danielmschmidt" { - t.Fatalf("expected action to have the namespace 'danielmschmidt', got '%s'", action.ProviderAddr.Provider.Namespace) - } - }, - }, + // ======== PROVIDER ======== + // provider meta-argument + // ======== PROVIDER ======== - "aliased provider": { - module: map[string]string{ - "main.tf": ` + "provider": { + "aliased provider": { + module: map[string]string{ + "main.tf": ` provider "test" { alias = "aliased" } @@ -1210,172 +1673,42 @@ resource "other_object" "a" { } } `, - }, - expectPlanActionCalled: true, + }, + expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) + } - action := p.Changes.ActionInvocations[0] - if action.Addr.String() != "action.test_unlinked.hello" { - t.Fatalf("expected action address to be 'action.test_unlinked.hello', got '%s'", action.Addr) - } + action := p.Changes.ActionInvocations[0] + if action.Addr.String() != "action.test_unlinked.hello" { + t.Fatalf("expected action address to be 'action.test_unlinked.hello', got '%s'", action.Addr) + } - at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) - if !ok { - t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", action.ActionTrigger) - } - - if !at.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("other_object.a")) { - t.Fatalf("expected action to have triggering resource address 'other_object.a', but it is %s", at.TriggeringResourceAddr) - } - - if action.ProviderAddr.Alias != "aliased" { - t.Fatalf("expected action to have a provider alias of 'aliased', got '%s'", action.ProviderAddr.Alias) - } - }, - }, - - "action config with after_create dependency to triggering resource": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "hello" { - config { - attr = test_object.a.name - } -} -resource "test_object" "a" { - name = "test_name" - lifecycle { - action_trigger { - events = [after_create] - actions = [action.test_unlinked.hello] - } - } -} -`, - }, - expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Fatalf("expected one action in plan, got %d", len(p.Changes.ActionInvocations)) - } - - if p.Changes.ActionInvocations[0].ActionTrigger.TriggerEvent() != configs.AfterCreate { - t.Fatalf("expected trigger event to be of type AfterCreate, got: %v", p.Changes.ActionInvocations[0].ActionTrigger) - } - - if p.Changes.ActionInvocations[0].Addr.Action.String() != "action.test_unlinked.hello" { - t.Fatalf("expected action to equal 'action.test_unlinked.hello', got '%s'", p.Changes.ActionInvocations[0].Addr) - } - - decode, err := p.Changes.ActionInvocations[0].ConfigValue.Decode(cty.Object(map[string]cty.Type{"attr": cty.String})) - if err != nil { - t.Fatal(err) - } - - if decode.GetAttr("attr").AsString() != "test_name" { - t.Fatalf("expected action config field 'attr' to have value 'test_name', got '%s'", decode.GetAttr("attr").AsString()) - } - }, - }, - - "action config refers to before triggering resource leads to validation error": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "hello" { - config { - attr = test_object.a.name - } -} -resource "test_object" "a" { - name = "test_name" - lifecycle { - action_trigger { - events = [before_create] - actions = [action.test_unlinked.hello] - } - } -} -`, - }, - expectPlanActionCalled: true, // The cycle only appears in the apply graph - assertPlanDiagnostics: func(t *testing.T, diags tfdiags.Diagnostics) { - if !diags.HasErrors() { - t.Fatalf("expected diagnostics to have errors, but it does not") - } - if len(diags) != 1 { - t.Fatalf("expected diagnostics to have 1 error, but it has %d", len(diags)) - } - // We expect the diagnostic to be about a cycle - if !strings.Contains(diags[0].Description().Summary, "Cycle") { - t.Fatalf("expected diagnostic summary to contain 'Cycle', got '%s'", diags[0].Description().Summary) - } - // We expect the action node to be part of the cycle - if !strings.Contains(diags[0].Description().Summary, "action.test_unlinked.hello") { - t.Fatalf("expected diagnostic summary to contain 'action.test_unlinked.hello', got '%s'", diags[0].Description().Summary) - } - // We expect the resource node to be part of the cycle - if !strings.Contains(diags[0].Description().Summary, "test_object.a") { - t.Fatalf("expected diagnostic summary to contain 'test_object.a', got '%s'", diags[0].Description().Summary) - } - }, - }, - - "secret values": { - module: map[string]string{ - "main.tf": ` -variable "secret" { - type = string - sensitive = true -} -action "test_unlinked" "hello" { - config { - attr = var.secret - } -} -resource "test_object" "a" { - lifecycle { - action_trigger { - events = [before_create] - actions = [action.test_unlinked.hello] - } - } -} -`, - }, - planOpts: &PlanOpts{ - Mode: plans.NormalMode, - SetVariables: InputValues{ - "secret": &InputValue{ - Value: cty.StringVal("secret"), - SourceType: ValueFromCLIArg, - }}, - }, - expectPlanActionCalled: true, - - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Fatalf("expected 1 action in plan, got %d", len(p.Changes.ActionInvocations)) - } + at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) + if !ok { + t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", action.ActionTrigger) + } - action := p.Changes.ActionInvocations[0] - ac, err := action.Decode(&unlinkedActionSchema) - if err != nil { - t.Fatalf("expected action to decode successfully, but got error: %v", err) - } + if !at.TriggeringResourceAddr.Equal(mustResourceInstanceAddr("other_object.a")) { + t.Fatalf("expected action to have triggering resource address 'other_object.a', but it is %s", at.TriggeringResourceAddr) + } - if !marks.Has(ac.ConfigValue.GetAttr("attr"), marks.Sensitive) { - t.Fatalf("expected attribute 'attr' to be marked as sensitive") - } + if action.ProviderAddr.Alias != "aliased" { + t.Fatalf("expected action to have a provider alias of 'aliased', got '%s'", action.ProviderAddr.Alias) + } + }, }, }, - "provider deferring action while not allowed": { - module: map[string]string{ - "main.tf": ` + // ======== DEFERRING ======== + // Deferred actions (partial expansion / provider deferring) + // ======== DEFERRING ======== + "deferring": { + "provider deferring action while not allowed": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "a" { lifecycle { @@ -1386,33 +1719,33 @@ resource "test_object" "a" { } } `, + }, + expectPlanActionCalled: true, + planOpts: &PlanOpts{ + Mode: plans.NormalMode, + DeferralAllowed: false, + }, + planActionFn: func(*testing.T, providers.PlanActionRequest) providers.PlanActionResponse { + return providers.PlanActionResponse{ + Deferred: &providers.Deferred{ + Reason: providers.DeferredReasonAbsentPrereq, + }, + } + }, + expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Provider deferred changes when Terraform did not allow deferrals", + `The provider signaled a deferred action for "action.test_unlinked.hello", but in this context deferrals are disabled. This is a bug in the provider, please file an issue with the provider developers.`, + ), + } + }, }, - expectPlanActionCalled: true, - planOpts: &PlanOpts{ - Mode: plans.NormalMode, - DeferralAllowed: false, - }, - planActionFn: func(*testing.T, providers.PlanActionRequest) providers.PlanActionResponse { - return providers.PlanActionResponse{ - Deferred: &providers.Deferred{ - Reason: providers.DeferredReasonAbsentPrereq, - }, - } - }, - expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - return tfdiags.Diagnostics{ - tfdiags.Sourceless( - tfdiags.Error, - "Provider deferred changes when Terraform did not allow deferrals", - `The provider signaled a deferred action for "action.test_unlinked.hello", but in this context deferrals are disabled. This is a bug in the provider, please file an issue with the provider developers.`, - ), - } - }, - }, - "provider deferring action": { - module: map[string]string{ - "main.tf": ` + "provider deferring action": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "a" { lifecycle { @@ -1423,45 +1756,45 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: true, - planOpts: &PlanOpts{ - Mode: plans.NormalMode, - DeferralAllowed: true, - }, - planActionFn: func(*testing.T, providers.PlanActionRequest) providers.PlanActionResponse { - return providers.PlanActionResponse{ - Deferred: &providers.Deferred{ - Reason: providers.DeferredReasonAbsentPrereq, - }, - } - }, + }, + expectPlanActionCalled: true, + planOpts: &PlanOpts{ + Mode: plans.NormalMode, + DeferralAllowed: true, + }, + planActionFn: func(*testing.T, providers.PlanActionRequest) providers.PlanActionResponse { + return providers.PlanActionResponse{ + Deferred: &providers.Deferred{ + Reason: providers.DeferredReasonAbsentPrereq, + }, + } + }, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 0 { - t.Fatalf("expected 0 actions in plan, got %d", len(p.Changes.ActionInvocations)) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 0 { + t.Fatalf("expected 0 actions in plan, got %d", len(p.Changes.ActionInvocations)) + } - if len(p.DeferredActionInvocations) != 1 { - t.Fatalf("expected 1 deferred action in plan, got %d", len(p.DeferredActionInvocations)) - } - deferredActionInvocation := p.DeferredActionInvocations[0] - if deferredActionInvocation.DeferredReason != providers.DeferredReasonAbsentPrereq { - t.Fatalf("expected deferred action to be deferred due to absent prereq, but got %s", deferredActionInvocation.DeferredReason) - } - if deferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { - t.Fatalf("expected deferred action to be triggered by test_object.a, but got %s", deferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) - } + if len(p.DeferredActionInvocations) != 1 { + t.Fatalf("expected 1 deferred action in plan, got %d", len(p.DeferredActionInvocations)) + } + deferredActionInvocation := p.DeferredActionInvocations[0] + if deferredActionInvocation.DeferredReason != providers.DeferredReasonAbsentPrereq { + t.Fatalf("expected deferred action to be deferred due to absent prereq, but got %s", deferredActionInvocation.DeferredReason) + } + if deferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { + t.Fatalf("expected deferred action to be triggered by test_object.a, but got %s", deferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) + } - if deferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.test_unlinked.hello" { - t.Fatalf("expected deferred action to be triggered by action.test_unlinked.hello, but got %s", deferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) - } + if deferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.test_unlinked.hello" { + t.Fatalf("expected deferred action to be triggered by action.test_unlinked.hello, but got %s", deferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) + } + }, }, - }, - "deferred after actions defer following actions": { - module: map[string]string{ - "main.tf": ` + "deferred after actions defer following actions": { + module: map[string]string{ + "main.tf": ` // Using this provider to have another provider type for an easier assertion terraform { required_providers { @@ -1481,60 +1814,60 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: true, - planOpts: &PlanOpts{ - Mode: plans.NormalMode, - DeferralAllowed: true, - }, - planActionFn: func(t *testing.T, r providers.PlanActionRequest) providers.PlanActionResponse { - if r.ActionType == "ecosystem_unlinked" { - t.Fatalf("expected second action to not be planned, but it was planned") - } - return providers.PlanActionResponse{ - Deferred: &providers.Deferred{ - Reason: providers.DeferredReasonAbsentPrereq, - }, - } - }, + }, + expectPlanActionCalled: true, + planOpts: &PlanOpts{ + Mode: plans.NormalMode, + DeferralAllowed: true, + }, + planActionFn: func(t *testing.T, r providers.PlanActionRequest) providers.PlanActionResponse { + if r.ActionType == "ecosystem_unlinked" { + t.Fatalf("expected second action to not be planned, but it was planned") + } + return providers.PlanActionResponse{ + Deferred: &providers.Deferred{ + Reason: providers.DeferredReasonAbsentPrereq, + }, + } + }, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 0 { - t.Fatalf("expected 0 actions in plan, got %d", len(p.Changes.ActionInvocations)) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 0 { + t.Fatalf("expected 0 actions in plan, got %d", len(p.Changes.ActionInvocations)) + } - if len(p.DeferredActionInvocations) != 2 { - t.Fatalf("expected 2 deferred actions in plan, got %d", len(p.DeferredActionInvocations)) - } - firstDeferredActionInvocation := p.DeferredActionInvocations[0] - if firstDeferredActionInvocation.DeferredReason != providers.DeferredReasonAbsentPrereq { - t.Fatalf("expected deferred action to be deferred due to absent prereq, but got %s", firstDeferredActionInvocation.DeferredReason) - } - if firstDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { - t.Fatalf("expected deferred action to be triggered by test_object.a, but got %s", firstDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) - } + if len(p.DeferredActionInvocations) != 2 { + t.Fatalf("expected 2 deferred actions in plan, got %d", len(p.DeferredActionInvocations)) + } + firstDeferredActionInvocation := p.DeferredActionInvocations[0] + if firstDeferredActionInvocation.DeferredReason != providers.DeferredReasonAbsentPrereq { + t.Fatalf("expected deferred action to be deferred due to absent prereq, but got %s", firstDeferredActionInvocation.DeferredReason) + } + if firstDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { + t.Fatalf("expected deferred action to be triggered by test_object.a, but got %s", firstDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) + } - if firstDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.test_unlinked.hello" { - t.Fatalf("expected deferred action to be triggered by action.test_unlinked.hello, but got %s", firstDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) - } + if firstDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.test_unlinked.hello" { + t.Fatalf("expected deferred action to be triggered by action.test_unlinked.hello, but got %s", firstDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) + } - secondDeferredActionInvocation := p.DeferredActionInvocations[1] - if secondDeferredActionInvocation.DeferredReason != providers.DeferredReasonDeferredPrereq { - t.Fatalf("expected second deferred action to be deferred due to deferred prereq, but got %s", secondDeferredActionInvocation.DeferredReason) - } - if secondDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { - t.Fatalf("expected second deferred action to be triggered by test_object.a, but got %s", secondDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) - } + secondDeferredActionInvocation := p.DeferredActionInvocations[1] + if secondDeferredActionInvocation.DeferredReason != providers.DeferredReasonDeferredPrereq { + t.Fatalf("expected second deferred action to be deferred due to deferred prereq, but got %s", secondDeferredActionInvocation.DeferredReason) + } + if secondDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { + t.Fatalf("expected second deferred action to be triggered by test_object.a, but got %s", secondDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) + } - if secondDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.ecosystem_unlinked.world" { - t.Fatalf("expected second deferred action to be triggered by action.ecosystem_unlinked.world, but got %s", secondDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) - } + if secondDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.ecosystem_unlinked.world" { + t.Fatalf("expected second deferred action to be triggered by action.ecosystem_unlinked.world, but got %s", secondDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) + } + }, }, - }, - "deferred before actions defer following actions and resource": { - module: map[string]string{ - "main.tf": ` + "deferred before actions defer following actions and resource": { + module: map[string]string{ + "main.tf": ` // Using this provider to have another provider type for an easier assertion terraform { required_providers { @@ -1558,73 +1891,73 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: true, - planOpts: &PlanOpts{ - Mode: plans.NormalMode, - DeferralAllowed: true, - }, - planActionFn: func(t *testing.T, r providers.PlanActionRequest) providers.PlanActionResponse { - if r.ActionType == "ecosystem_unlinked" { - t.Fatalf("expected second action to not be planned, but it was planned") - } - return providers.PlanActionResponse{ - Deferred: &providers.Deferred{ - Reason: providers.DeferredReasonAbsentPrereq, - }, - } - }, + }, + expectPlanActionCalled: true, + planOpts: &PlanOpts{ + Mode: plans.NormalMode, + DeferralAllowed: true, + }, + planActionFn: func(t *testing.T, r providers.PlanActionRequest) providers.PlanActionResponse { + if r.ActionType == "ecosystem_unlinked" { + t.Fatalf("expected second action to not be planned, but it was planned") + } + return providers.PlanActionResponse{ + Deferred: &providers.Deferred{ + Reason: providers.DeferredReasonAbsentPrereq, + }, + } + }, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 0 { - t.Fatalf("expected 0 actions in plan, got %d", len(p.Changes.ActionInvocations)) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 0 { + t.Fatalf("expected 0 actions in plan, got %d", len(p.Changes.ActionInvocations)) + } - if len(p.DeferredActionInvocations) != 2 { - t.Fatalf("expected 2 deferred actions in plan, got %d", len(p.DeferredActionInvocations)) - } - firstDeferredActionInvocation := p.DeferredActionInvocations[0] - if firstDeferredActionInvocation.DeferredReason != providers.DeferredReasonAbsentPrereq { - t.Fatalf("expected deferred action to be deferred due to absent prereq, but got %s", firstDeferredActionInvocation.DeferredReason) - } - if firstDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { - t.Fatalf("expected deferred action to be triggered by test_object.a, but got %s", firstDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) - } + if len(p.DeferredActionInvocations) != 2 { + t.Fatalf("expected 2 deferred actions in plan, got %d", len(p.DeferredActionInvocations)) + } + firstDeferredActionInvocation := p.DeferredActionInvocations[0] + if firstDeferredActionInvocation.DeferredReason != providers.DeferredReasonAbsentPrereq { + t.Fatalf("expected deferred action to be deferred due to absent prereq, but got %s", firstDeferredActionInvocation.DeferredReason) + } + if firstDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { + t.Fatalf("expected deferred action to be triggered by test_object.a, but got %s", firstDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) + } - if firstDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.test_unlinked.hello" { - t.Fatalf("expected deferred action to be triggered by action.test_unlinked.hello, but got %s", firstDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) - } + if firstDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.test_unlinked.hello" { + t.Fatalf("expected deferred action to be triggered by action.test_unlinked.hello, but got %s", firstDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) + } - secondDeferredActionInvocation := p.DeferredActionInvocations[1] - if secondDeferredActionInvocation.DeferredReason != providers.DeferredReasonDeferredPrereq { - t.Fatalf("expected second deferred action to be deferred due to deferred prereq, but got %s", secondDeferredActionInvocation.DeferredReason) - } - if secondDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { - t.Fatalf("expected second deferred action to be triggered by test_object.a, but got %s", secondDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) - } + secondDeferredActionInvocation := p.DeferredActionInvocations[1] + if secondDeferredActionInvocation.DeferredReason != providers.DeferredReasonDeferredPrereq { + t.Fatalf("expected second deferred action to be deferred due to deferred prereq, but got %s", secondDeferredActionInvocation.DeferredReason) + } + if secondDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { + t.Fatalf("expected second deferred action to be triggered by test_object.a, but got %s", secondDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) + } - if secondDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.ecosystem_unlinked.world" { - t.Fatalf("expected second deferred action to be triggered by action.ecosystem_unlinked.world, but got %s", secondDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) - } + if secondDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.ecosystem_unlinked.world" { + t.Fatalf("expected second deferred action to be triggered by action.ecosystem_unlinked.world, but got %s", secondDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) + } - if len(p.DeferredResources) != 1 { - t.Fatalf("expected 1 resource to be deferred, got %d", len(p.DeferredResources)) - } - deferredResource := p.DeferredResources[0] + if len(p.DeferredResources) != 1 { + t.Fatalf("expected 1 resource to be deferred, got %d", len(p.DeferredResources)) + } + deferredResource := p.DeferredResources[0] - if deferredResource.ChangeSrc.Addr.String() != "test_object.a" { - t.Fatalf("Expected resource %s to be deferred, but it was not", deferredResource.ChangeSrc.Addr) - } + if deferredResource.ChangeSrc.Addr.String() != "test_object.a" { + t.Fatalf("Expected resource %s to be deferred, but it was not", deferredResource.ChangeSrc.Addr) + } - if deferredResource.DeferredReason != providers.DeferredReasonDeferredPrereq { - t.Fatalf("Expected deferred reason to be deferred prereq, got %s", deferredResource.DeferredReason) - } + if deferredResource.DeferredReason != providers.DeferredReasonDeferredPrereq { + t.Fatalf("Expected deferred reason to be deferred prereq, got %s", deferredResource.DeferredReason) + } + }, }, - }, - "deferred resources also defer the actions they trigger": { - module: map[string]string{ - "main.tf": ` + "deferred resources also defer the actions they trigger": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "a" { lifecycle { @@ -1639,128 +1972,359 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: false, - planOpts: &PlanOpts{ - Mode: plans.NormalMode, - DeferralAllowed: true, - }, + }, + expectPlanActionCalled: false, + planOpts: &PlanOpts{ + Mode: plans.NormalMode, + DeferralAllowed: true, + }, - planResourceFn: func(_ *testing.T, req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { - return providers.PlanResourceChangeResponse{ - PlannedState: req.ProposedNewState, - PlannedPrivate: req.PriorPrivate, - Diagnostics: nil, - Deferred: &providers.Deferred{ - Reason: providers.DeferredReasonAbsentPrereq, - }, - } - }, + planResourceFn: func(_ *testing.T, req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: req.ProposedNewState, + PlannedPrivate: req.PriorPrivate, + Diagnostics: nil, + Deferred: &providers.Deferred{ + Reason: providers.DeferredReasonAbsentPrereq, + }, + } + }, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 0 { - t.Fatalf("expected 0 actions in plan, got %d", len(p.Changes.ActionInvocations)) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 0 { + t.Fatalf("expected 0 actions in plan, got %d", len(p.Changes.ActionInvocations)) + } - if len(p.DeferredActionInvocations) != 2 { - t.Fatalf("expected 2 deferred actions in plan, got %d", len(p.DeferredActionInvocations)) - } + if len(p.DeferredActionInvocations) != 2 { + t.Fatalf("expected 2 deferred actions in plan, got %d", len(p.DeferredActionInvocations)) + } - sort.Slice(p.DeferredActionInvocations, func(i, j int) bool { - return p.DeferredActionInvocations[i].ActionInvocationInstanceSrc.Addr.String() < p.DeferredActionInvocations[j].ActionInvocationInstanceSrc.Addr.String() - }) + sort.Slice(p.DeferredActionInvocations, func(i, j int) bool { + return p.DeferredActionInvocations[i].ActionInvocationInstanceSrc.Addr.String() < p.DeferredActionInvocations[j].ActionInvocationInstanceSrc.Addr.String() + }) - firstDeferredActionInvocation := p.DeferredActionInvocations[0] - if firstDeferredActionInvocation.DeferredReason != providers.DeferredReasonDeferredPrereq { - t.Fatalf("expected deferred action to be deferred due to deferred prereq, but got %s", firstDeferredActionInvocation.DeferredReason) - } + firstDeferredActionInvocation := p.DeferredActionInvocations[0] + if firstDeferredActionInvocation.DeferredReason != providers.DeferredReasonDeferredPrereq { + t.Fatalf("expected deferred action to be deferred due to deferred prereq, but got %s", firstDeferredActionInvocation.DeferredReason) + } - if firstDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { - t.Fatalf("expected deferred action to be triggered by test_object.a, but got %s", firstDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) - } + if firstDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { + t.Fatalf("expected deferred action to be triggered by test_object.a, but got %s", firstDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) + } - if firstDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.test_unlinked.hello" { - t.Fatalf("expected deferred action to be triggered by action.test_unlinked.hello, but got %s", firstDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) - } + if firstDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.test_unlinked.hello" { + t.Fatalf("expected deferred action to be triggered by action.test_unlinked.hello, but got %s", firstDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) + } - secondDeferredActionInvocation := p.DeferredActionInvocations[1] - if secondDeferredActionInvocation.DeferredReason != providers.DeferredReasonDeferredPrereq { - t.Fatalf("expected second deferred action to be deferred due to deferred prereq, but got %s", secondDeferredActionInvocation.DeferredReason) - } - if secondDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { - t.Fatalf("expected second deferred action to be triggered by test_object.a, but got %s", secondDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) - } + secondDeferredActionInvocation := p.DeferredActionInvocations[1] + if secondDeferredActionInvocation.DeferredReason != providers.DeferredReasonDeferredPrereq { + t.Fatalf("expected second deferred action to be deferred due to deferred prereq, but got %s", secondDeferredActionInvocation.DeferredReason) + } + if secondDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String() != "test_object.a" { + t.Fatalf("expected second deferred action to be triggered by test_object.a, but got %s", secondDeferredActionInvocation.ActionInvocationInstanceSrc.ActionTrigger.(*plans.LifecycleActionTrigger).TriggeringResourceAddr.String()) + } - if secondDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.test_unlinked.hello" { - t.Fatalf("expected second deferred action to be triggered by action.test_unlinked.hello, but got %s", secondDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) - } + if secondDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String() != "action.test_unlinked.hello" { + t.Fatalf("expected second deferred action to be triggered by action.test_unlinked.hello, but got %s", secondDeferredActionInvocation.ActionInvocationInstanceSrc.Addr.String()) + } - if len(p.DeferredResources) != 1 { - t.Fatalf("expected 1 resource to be deferred, got %d", len(p.DeferredResources)) - } - deferredResource := p.DeferredResources[0] + if len(p.DeferredResources) != 1 { + t.Fatalf("expected 1 resource to be deferred, got %d", len(p.DeferredResources)) + } + deferredResource := p.DeferredResources[0] - if deferredResource.ChangeSrc.Addr.String() != "test_object.a" { - t.Fatalf("Expected resource %s to be deferred, but it was not", deferredResource.ChangeSrc.Addr) - } + if deferredResource.ChangeSrc.Addr.String() != "test_object.a" { + t.Fatalf("Expected resource %s to be deferred, but it was not", deferredResource.ChangeSrc.Addr) + } - if deferredResource.DeferredReason != providers.DeferredReasonAbsentPrereq { - t.Fatalf("Expected deferred reason to be absent prereq, got %s", deferredResource.DeferredReason) - } + if deferredResource.DeferredReason != providers.DeferredReasonAbsentPrereq { + t.Fatalf("Expected deferred reason to be absent prereq, got %s", deferredResource.DeferredReason) + } + }, }, - }, - - "write-only attributes": { - module: map[string]string{ - "main.tf": ` -variable "attr" { - type = string - ephemeral = true + "action expansion with unknown instances": { + module: map[string]string{ + "main.tf": ` +variable "each" { + type = set(string) } - -resource "test_object" "resource" { - name = "hello" +action "test_unlinked" "hello" { + for_each = var.each +} +resource "test_object" "a" { lifecycle { action_trigger { events = [before_create] - actions = [action.test_unlinked_wo.hello] + actions = [action.test_unlinked.hello["a"]] } } } - -action "test_unlinked_wo" "hello" { +`, + }, + expectPlanActionCalled: false, + planOpts: &PlanOpts{ + Mode: plans.NormalMode, + DeferralAllowed: true, + SetVariables: InputValues{ + "each": &InputValue{ + Value: cty.UnknownVal(cty.Set(cty.String)), + SourceType: ValueFromCLIArg, + }, + }, + }, + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.DeferredActionInvocations) != 1 { + t.Fatalf("expected exactly one invocation, and found %d", len(p.DeferredActionInvocations)) + } + + if p.DeferredActionInvocations[0].DeferredReason != providers.DeferredReasonDeferredPrereq { + t.Fatalf("expected.DeferredReasonDeferredPrereq, got %s", p.DeferredActionInvocations[0].DeferredReason) + } + + ai := p.DeferredActionInvocations[0].ActionInvocationInstanceSrc + if ai.Addr.String() != `action.test_unlinked.hello["a"]` { + t.Fatalf(`expected action invocation for action.test_unlinked.hello["a"], got %s`, ai.Addr.String()) + } + + if len(p.DeferredResources) != 1 { + t.Fatalf("expected 1 deferred resource, got %d", len(p.DeferredResources)) + } + + if p.DeferredResources[0].ChangeSrc.Addr.String() != "test_object.a" { + t.Fatalf("expected test_object.a, got %s", p.DeferredResources[0].ChangeSrc.Addr.String()) + } + }, + }, + "action with unknown module expansion": { + // We have an unknown module expansion (for_each over an unknown value). The + // action and its triggering resource both live inside the (currently + // un-expanded) module instances. Since we cannot expand the module yet, the + // action invocation must be deferred. + module: map[string]string{ + "main.tf": ` +variable "mods" { + type = set(string) +} +module "mod" { + source = "./mod" + for_each = var.mods +} +`, + "mod/mod.tf": ` +action "test_unlinked" "hello" { config { - attr = var.attr + attr = "static" + } +} +resource "other_object" "a" { + lifecycle { + action_trigger { + events = [before_create] + actions = [action.test_unlinked.hello] + } } } `, + }, + expectPlanActionCalled: true, + planOpts: &PlanOpts{ + Mode: plans.NormalMode, + DeferralAllowed: true, + SetVariables: InputValues{ + "mods": &InputValue{ + Value: cty.UnknownVal(cty.Set(cty.String)), + SourceType: ValueFromCLIArg, + }, + }, + }, + assertPlan: func(t *testing.T, p *plans.Plan) { + // No concrete action invocations can be produced yet. + if got := len(p.Changes.ActionInvocations); got != 0 { + t.Fatalf("expected 0 planned action invocations, got %d", got) + } + + if got := len(p.DeferredActionInvocations); got != 1 { + t.Fatalf("expected 1 deferred action invocations, got %d", got) + } + ac, err := p.DeferredActionInvocations[0].Decode(&unlinkedActionSchema) + if err != nil { + t.Fatalf("error decoding action invocation: %s", err) + } + if ac.DeferredReason != providers.DeferredReasonInstanceCountUnknown { + t.Fatalf("expected DeferredReasonInstanceCountUnknown, got %s", ac.DeferredReason) + } + if ac.ActionInvocationInstance.ConfigValue.GetAttr("attr").AsString() != "static" { + t.Fatalf("expected attr to be static, got %s", ac.ActionInvocationInstance.ConfigValue.GetAttr("attr").AsString()) + } + + }, }, - planOpts: SimplePlanOpts(plans.NormalMode, InputValues{ - "attr": { - Value: cty.StringVal("wo-plan"), + "action with unknown module expansion and unknown instances": { + // Here both the module expansion and the action for_each expansion are unknown. + // The action is referenced (with a specific key) inside the module so we should + // get a single deferred action invocation for that specific (yet still + // unresolved) instance address. + module: map[string]string{ + "main.tf": ` +variable "mods" { + type = set(string) +} +variable "actions" { + type = set(string) +} +module "mod" { + source = "./mod" + for_each = var.mods + + actions = var.actions +} +`, + "mod/mod.tf": ` +variable "actions" { + type = set(string) +} +action "test_unlinked" "hello" { + // Unknown for_each inside the module instance. + for_each = var.actions +} +resource "other_object" "a" { + lifecycle { + action_trigger { + events = [before_create] + // We reference a specific (yet unknown) action instance key. + actions = [action.test_unlinked.hello["a"]] + } + } +} +`, }, - }), - expectPlanActionCalled: true, - assertPlan: func(t *testing.T, plan *plans.Plan) { - if len(plan.Changes.ActionInvocations) != 1 { - t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) - } + expectPlanActionCalled: true, + planOpts: &PlanOpts{ + Mode: plans.NormalMode, + DeferralAllowed: true, + SetVariables: InputValues{ + "mods": &InputValue{ + Value: cty.UnknownVal(cty.Set(cty.String)), + SourceType: ValueFromCLIArg, + }, + "actions": &InputValue{ + Value: cty.UnknownVal(cty.Set(cty.String)), + SourceType: ValueFromCLIArg, + }, + }, + }, + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 0 { + t.Fatalf("expected 0 planned action invocations, got %d", len(p.Changes.ActionInvocations)) + } - ais := plan.Changes.ActionInvocations[0] - ai, err := ais.Decode(&writeOnlyUnlinkedActionSchema) - if err != nil { - t.Fatal(err) - } + if len(p.DeferredActionInvocations) != 1 { + t.Fatalf("expected 1 deferred partial action invocations, got %d", len(p.DeferredActionInvocations)) + } - if !ai.ConfigValue.GetAttr("attr").IsNull() { - t.Fatal("should have converted ephemeral value to null in the plan") - } + ac, err := p.DeferredActionInvocations[0].Decode(&unlinkedActionSchema) + if err != nil { + t.Fatalf("error decoding action invocation: %s", err) + } + if ac.DeferredReason != providers.DeferredReasonInstanceCountUnknown { + t.Fatalf("expected deferred reason to be DeferredReasonInstanceCountUnknown, got %s", ac.DeferredReason) + } + if !ac.ActionInvocationInstance.ConfigValue.IsNull() { + t.Fatalf("expected config value to be null") + } + }, + }, + + "deferring resource dependencies should defer action": { + module: map[string]string{ + "main.tf": ` +resource "test_object" "origin" { + name = "origin" +} +action "test_unlinked" "hello" { + config { + attr = test_object.origin.name + } +} +resource "test_object" "a" { + name = "a" + lifecycle { + action_trigger { + events = [after_create] + actions = [action.test_unlinked.hello] + } + } +} +`, + }, + expectPlanActionCalled: false, + planOpts: &PlanOpts{ + Mode: plans.NormalMode, + DeferralAllowed: true, + }, + planResourceFn: func(t *testing.T, req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + if req.Config.GetAttr("name").AsString() == "origin" { + return providers.PlanResourceChangeResponse{ + Deferred: &providers.Deferred{ + Reason: providers.DeferredReasonAbsentPrereq, + }, + } + } + return providers.PlanResourceChangeResponse{ + PlannedState: req.ProposedNewState, + PlannedPrivate: req.PriorPrivate, + PlannedIdentity: req.PriorIdentity, + } + }, + + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.DeferredActionInvocations) != 1 { + t.Errorf("Expected 1 deferred action invocation, got %d", len(p.DeferredActionInvocations)) + } + if p.DeferredActionInvocations[0].ActionInvocationInstanceSrc.Addr.String() != "action.test_unlinked.hello" { + t.Errorf("Expected action. test_unlinked.hello, got %s", p.DeferredActionInvocations[0].ActionInvocationInstanceSrc.Addr.String()) + } + if p.DeferredActionInvocations[0].DeferredReason != providers.DeferredReasonDeferredPrereq { + t.Errorf("Expected DeferredReasonDeferredPrereq, got %s", p.DeferredActionInvocations[0].DeferredReason) + } + + if len(p.DeferredResources) != 2 { + t.Fatalf("Expected 2 deferred resources, got %d", len(p.DeferredResources)) + } + + slices.SortFunc(p.DeferredResources, func(a, b *plans.DeferredResourceInstanceChangeSrc) int { + if a.ChangeSrc.Addr.Less(b.ChangeSrc.Addr) { + return -1 + } + if b.ChangeSrc.Addr.Less(a.ChangeSrc.Addr) { + return 1 + } + return 0 + }) + + if p.DeferredResources[0].ChangeSrc.Addr.String() != "test_object.a" { + t.Errorf("Expected test_object.a to be first, got %s", p.DeferredResources[0].ChangeSrc.Addr.String()) + } + if p.DeferredResources[0].DeferredReason != providers.DeferredReasonDeferredPrereq { + t.Errorf("Expected DeferredReasonDeferredPrereq, got %s", p.DeferredResources[0].DeferredReason) + } + if p.DeferredResources[1].ChangeSrc.Addr.String() != "test_object.origin" { + t.Errorf("Expected test_object.origin to be second, got %s", p.DeferredResources[1].ChangeSrc.Addr.String()) + } + if p.DeferredResources[1].DeferredReason != providers.DeferredReasonAbsentPrereq { + t.Errorf("Expected DeferredReasonAbsentPrereq, got %s", p.DeferredResources[1].DeferredReason) + } + }, }, }, - "simple action invoke": { - module: map[string]string{ - "main.tf": ` + // ======== INVOKE ======== + // -invoke flag + // ======== INVOKE ======== + "invoke": { + "simple action invoke": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "one" { config { attr = "one" @@ -1772,53 +2336,53 @@ action "test_unlinked" "two" { } } `, - }, - planOpts: &PlanOpts{ - Mode: plans.RefreshOnlyMode, - ActionTargets: []addrs.Targetable{ - addrs.AbsActionInstance{ - Action: addrs.ActionInstance{ - Action: addrs.Action{ - Type: "test_unlinked", - Name: "one", + }, + planOpts: &PlanOpts{ + Mode: plans.RefreshOnlyMode, + ActionTargets: []addrs.Targetable{ + addrs.AbsActionInstance{ + Action: addrs.ActionInstance{ + Action: addrs.Action{ + Type: "test_unlinked", + Name: "one", + }, + Key: addrs.NoKey, }, - Key: addrs.NoKey, }, }, }, - }, - expectPlanActionCalled: true, - assertPlan: func(t *testing.T, plan *plans.Plan) { - if len(plan.Changes.ActionInvocations) != 1 { - t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) - } + expectPlanActionCalled: true, + assertPlan: func(t *testing.T, plan *plans.Plan) { + if len(plan.Changes.ActionInvocations) != 1 { + t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) + } - ais := plan.Changes.ActionInvocations[0] - ai, err := ais.Decode(&unlinkedActionSchema) - if err != nil { - t.Fatal(err) - } + ais := plan.Changes.ActionInvocations[0] + ai, err := ais.Decode(&unlinkedActionSchema) + if err != nil { + t.Fatal(err) + } - if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { - t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) - } + if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { + t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) + } - expected := cty.ObjectVal(map[string]cty.Value{ - "attr": cty.StringVal("one"), - }) - if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { - t.Fatalf("wrong value in plan: %s", diff) - } + expected := cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("one"), + }) + if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { + t.Fatalf("wrong value in plan: %s", diff) + } - if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one")) { - t.Fatalf("wrong address in plan: %s", ai.Addr) - } + if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one")) { + t.Fatalf("wrong address in plan: %s", ai.Addr) + } + }, }, - }, - "action invoke with count (all)": { - module: map[string]string{ - "main.tf": ` + "action invoke with count (all)": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "one" { count = 2 @@ -1834,75 +2398,75 @@ action "test_unlinked" "two" { } } `, - }, - planOpts: &PlanOpts{ - Mode: plans.RefreshOnlyMode, - ActionTargets: []addrs.Targetable{ - addrs.AbsAction{ - Action: addrs.Action{ - Type: "test_unlinked", - Name: "one", + }, + planOpts: &PlanOpts{ + Mode: plans.RefreshOnlyMode, + ActionTargets: []addrs.Targetable{ + addrs.AbsAction{ + Action: addrs.Action{ + Type: "test_unlinked", + Name: "one", + }, }, }, }, - }, - expectPlanActionCalled: true, - assertPlan: func(t *testing.T, plan *plans.Plan) { - if len(plan.Changes.ActionInvocations) != 2 { - t.Fatalf("expected exactly two invocations, and found %d", len(plan.Changes.ActionInvocations)) - } + expectPlanActionCalled: true, + assertPlan: func(t *testing.T, plan *plans.Plan) { + if len(plan.Changes.ActionInvocations) != 2 { + t.Fatalf("expected exactly two invocations, and found %d", len(plan.Changes.ActionInvocations)) + } - sort.Slice(plan.Changes.ActionInvocations, func(i, j int) bool { - return plan.Changes.ActionInvocations[i].Addr.Less(plan.Changes.ActionInvocations[j].Addr) - }) + sort.Slice(plan.Changes.ActionInvocations, func(i, j int) bool { + return plan.Changes.ActionInvocations[i].Addr.Less(plan.Changes.ActionInvocations[j].Addr) + }) - ais := plan.Changes.ActionInvocations[0] - ai, err := ais.Decode(&unlinkedActionSchema) - if err != nil { - t.Fatal(err) - } + ais := plan.Changes.ActionInvocations[0] + ai, err := ais.Decode(&unlinkedActionSchema) + if err != nil { + t.Fatal(err) + } - if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { - t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) - } + if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { + t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) + } - expected := cty.ObjectVal(map[string]cty.Value{ - "attr": cty.StringVal("0"), - }) - if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { - t.Fatalf("wrong value in plan: %s", diff) - } + expected := cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("0"), + }) + if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { + t.Fatalf("wrong value in plan: %s", diff) + } - if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one[0]")) { - t.Fatalf("wrong address in plan: %s", ai.Addr) - } + if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one[0]")) { + t.Fatalf("wrong address in plan: %s", ai.Addr) + } - ais = plan.Changes.ActionInvocations[1] - ai, err = ais.Decode(&unlinkedActionSchema) - if err != nil { - t.Fatal(err) - } + ais = plan.Changes.ActionInvocations[1] + ai, err = ais.Decode(&unlinkedActionSchema) + if err != nil { + t.Fatal(err) + } - if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { - t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) - } + if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { + t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) + } - expected = cty.ObjectVal(map[string]cty.Value{ - "attr": cty.StringVal("1"), - }) - if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { - t.Fatalf("wrong value in plan: %s", diff) - } + expected = cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("1"), + }) + if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { + t.Fatalf("wrong value in plan: %s", diff) + } - if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one[1]")) { - t.Fatalf("wrong address in plan: %s", ai.Addr) - } + if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one[1]")) { + t.Fatalf("wrong address in plan: %s", ai.Addr) + } + }, }, - }, - "action invoke with count (instance)": { - module: map[string]string{ - "main.tf": ` + "action invoke with count (instance)": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "one" { count = 2 @@ -1918,53 +2482,53 @@ action "test_unlinked" "two" { } } `, - }, - planOpts: &PlanOpts{ - Mode: plans.RefreshOnlyMode, - ActionTargets: []addrs.Targetable{ - addrs.AbsActionInstance{ - Action: addrs.ActionInstance{ - Action: addrs.Action{ - Type: "test_unlinked", - Name: "one", + }, + planOpts: &PlanOpts{ + Mode: plans.RefreshOnlyMode, + ActionTargets: []addrs.Targetable{ + addrs.AbsActionInstance{ + Action: addrs.ActionInstance{ + Action: addrs.Action{ + Type: "test_unlinked", + Name: "one", + }, + Key: addrs.IntKey(0), }, - Key: addrs.IntKey(0), }, }, }, - }, - expectPlanActionCalled: true, - assertPlan: func(t *testing.T, plan *plans.Plan) { - if len(plan.Changes.ActionInvocations) != 1 { - t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) - } + expectPlanActionCalled: true, + assertPlan: func(t *testing.T, plan *plans.Plan) { + if len(plan.Changes.ActionInvocations) != 1 { + t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) + } - ais := plan.Changes.ActionInvocations[0] - ai, err := ais.Decode(&unlinkedActionSchema) - if err != nil { - t.Fatal(err) - } + ais := plan.Changes.ActionInvocations[0] + ai, err := ais.Decode(&unlinkedActionSchema) + if err != nil { + t.Fatal(err) + } - if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { - t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) - } + if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { + t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) + } - expected := cty.ObjectVal(map[string]cty.Value{ - "attr": cty.StringVal("0"), - }) - if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { - t.Fatalf("wrong value in plan: %s", diff) - } + expected := cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("0"), + }) + if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { + t.Fatalf("wrong value in plan: %s", diff) + } - if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one[0]")) { - t.Fatalf("wrong address in plan: %s", ai.Addr) - } + if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one[0]")) { + t.Fatalf("wrong address in plan: %s", ai.Addr) + } + }, }, - }, - "invoke action with reference": { - module: map[string]string{ - "main.tf": ` + "invoke action with reference": { + module: map[string]string{ + "main.tf": ` resource "test_object" "a" { name = "hello" } @@ -1975,56 +2539,56 @@ action "test_unlinked" "one" { } } `, - }, - planOpts: &PlanOpts{ - Mode: plans.RefreshOnlyMode, - ActionTargets: []addrs.Targetable{ - addrs.AbsAction{ - Action: addrs.Action{ - Type: "test_unlinked", - Name: "one", + }, + planOpts: &PlanOpts{ + Mode: plans.RefreshOnlyMode, + ActionTargets: []addrs.Targetable{ + addrs.AbsAction{ + Action: addrs.Action{ + Type: "test_unlinked", + Name: "one", + }, }, }, }, - }, - expectPlanActionCalled: true, - buildState: func(state *states.SyncState) { - state.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a"), &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"name":"hello"}`), - Status: states.ObjectReady, - }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) - }, - assertPlan: func(t *testing.T, plan *plans.Plan) { - if len(plan.Changes.ActionInvocations) != 1 { - t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) - } + expectPlanActionCalled: true, + buildState: func(state *states.SyncState) { + state.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a"), &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"name":"hello"}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + }, + assertPlan: func(t *testing.T, plan *plans.Plan) { + if len(plan.Changes.ActionInvocations) != 1 { + t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) + } - ais := plan.Changes.ActionInvocations[0] - ai, err := ais.Decode(&unlinkedActionSchema) - if err != nil { - t.Fatal(err) - } + ais := plan.Changes.ActionInvocations[0] + ai, err := ais.Decode(&unlinkedActionSchema) + if err != nil { + t.Fatal(err) + } - if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { - t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) - } + if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { + t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) + } - expected := cty.ObjectVal(map[string]cty.Value{ - "attr": cty.StringVal("hello"), - }) - if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { - t.Fatalf("wrong value in plan: %s", diff) - } + expected := cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("hello"), + }) + if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { + t.Fatalf("wrong value in plan: %s", diff) + } - if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one")) { - t.Fatalf("wrong address in plan: %s", ai.Addr) - } + if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one")) { + t.Fatalf("wrong address in plan: %s", ai.Addr) + } + }, }, - }, - "invoke action with reference (drift)": { - module: map[string]string{ - "main.tf": ` + "invoke action with reference (drift)": { + module: map[string]string{ + "main.tf": ` resource "test_object" "a" { name = "hello" } @@ -2035,63 +2599,63 @@ action "test_unlinked" "one" { } } `, - }, - planOpts: &PlanOpts{ - Mode: plans.RefreshOnlyMode, - ActionTargets: []addrs.Targetable{ - addrs.AbsAction{ - Action: addrs.Action{ - Type: "test_unlinked", - Name: "one", + }, + planOpts: &PlanOpts{ + Mode: plans.RefreshOnlyMode, + ActionTargets: []addrs.Targetable{ + addrs.AbsAction{ + Action: addrs.Action{ + Type: "test_unlinked", + Name: "one", + }, }, }, }, - }, - expectPlanActionCalled: true, - buildState: func(state *states.SyncState) { - state.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a"), &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"name":"hello"}`), - Status: states.ObjectReady, - }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) - }, - assertPlan: func(t *testing.T, plan *plans.Plan) { - if len(plan.Changes.ActionInvocations) != 1 { - t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) - } + expectPlanActionCalled: true, + buildState: func(state *states.SyncState) { + state.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a"), &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"name":"hello"}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + }, + assertPlan: func(t *testing.T, plan *plans.Plan) { + if len(plan.Changes.ActionInvocations) != 1 { + t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) + } - ais := plan.Changes.ActionInvocations[0] - ai, err := ais.Decode(&unlinkedActionSchema) - if err != nil { - t.Fatal(err) - } + ais := plan.Changes.ActionInvocations[0] + ai, err := ais.Decode(&unlinkedActionSchema) + if err != nil { + t.Fatal(err) + } - if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { - t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) - } + if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { + t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) + } - expected := cty.ObjectVal(map[string]cty.Value{ - "attr": cty.StringVal("drifted value"), - }) - if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { - t.Fatalf("wrong value in plan: %s", diff) - } + expected := cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("drifted value"), + }) + if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { + t.Fatalf("wrong value in plan: %s", diff) + } - if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one")) { - t.Fatalf("wrong address in plan: %s", ai.Addr) - } - }, - readResourceFn: func(t *testing.T, request providers.ReadResourceRequest) providers.ReadResourceResponse { - return providers.ReadResourceResponse{ - NewState: cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("drifted value"), - }), - } + if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one")) { + t.Fatalf("wrong address in plan: %s", ai.Addr) + } + }, + readResourceFn: func(t *testing.T, request providers.ReadResourceRequest) providers.ReadResourceResponse { + return providers.ReadResourceResponse{ + NewState: cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("drifted value"), + }), + } + }, }, - }, - "invoke action with reference (drift, no refresh)": { - module: map[string]string{ - "main.tf": ` + "invoke action with reference (drift, no refresh)": { + module: map[string]string{ + "main.tf": ` resource "test_object" "a" { name = "hello" } @@ -2102,64 +2666,64 @@ action "test_unlinked" "one" { } } `, - }, - planOpts: &PlanOpts{ - Mode: plans.RefreshOnlyMode, - SkipRefresh: true, - ActionTargets: []addrs.Targetable{ - addrs.AbsAction{ - Action: addrs.Action{ - Type: "test_unlinked", - Name: "one", + }, + planOpts: &PlanOpts{ + Mode: plans.RefreshOnlyMode, + SkipRefresh: true, + ActionTargets: []addrs.Targetable{ + addrs.AbsAction{ + Action: addrs.Action{ + Type: "test_unlinked", + Name: "one", + }, }, }, }, - }, - expectPlanActionCalled: true, - buildState: func(state *states.SyncState) { - state.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a"), &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"name":"hello"}`), - Status: states.ObjectReady, - }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) - }, - assertPlan: func(t *testing.T, plan *plans.Plan) { - if len(plan.Changes.ActionInvocations) != 1 { - t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) - } + expectPlanActionCalled: true, + buildState: func(state *states.SyncState) { + state.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a"), &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"name":"hello"}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + }, + assertPlan: func(t *testing.T, plan *plans.Plan) { + if len(plan.Changes.ActionInvocations) != 1 { + t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) + } - ais := plan.Changes.ActionInvocations[0] - ai, err := ais.Decode(&unlinkedActionSchema) - if err != nil { - t.Fatal(err) - } + ais := plan.Changes.ActionInvocations[0] + ai, err := ais.Decode(&unlinkedActionSchema) + if err != nil { + t.Fatal(err) + } - if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { - t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) - } + if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { + t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) + } - expected := cty.ObjectVal(map[string]cty.Value{ - "attr": cty.StringVal("hello"), - }) - if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { - t.Fatalf("wrong value in plan: %s", diff) - } + expected := cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("hello"), + }) + if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { + t.Fatalf("wrong value in plan: %s", diff) + } - if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one")) { - t.Fatalf("wrong address in plan: %s", ai.Addr) - } - }, - readResourceFn: func(t *testing.T, request providers.ReadResourceRequest) providers.ReadResourceResponse { - return providers.ReadResourceResponse{ - NewState: cty.ObjectVal(map[string]cty.Value{ - "name": cty.StringVal("drifted value"), - }), - } + if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one")) { + t.Fatalf("wrong address in plan: %s", ai.Addr) + } + }, + readResourceFn: func(t *testing.T, request providers.ReadResourceRequest) providers.ReadResourceResponse { + return providers.ReadResourceResponse{ + NewState: cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("drifted value"), + }), + } + }, }, - }, - "non-referenced resource isn't refreshed during invoke": { - module: map[string]string{ - "main.tf": ` + "non-referenced resource isn't refreshed during invoke": { + module: map[string]string{ + "main.tf": ` resource "test_object" "a" { name = "hello" } @@ -2170,174 +2734,70 @@ action "test_unlinked" "one" { } } `, - }, - planOpts: &PlanOpts{ - Mode: plans.RefreshOnlyMode, - ActionTargets: []addrs.Targetable{ - addrs.AbsAction{ - Action: addrs.Action{ - Type: "test_unlinked", - Name: "one", + }, + planOpts: &PlanOpts{ + Mode: plans.RefreshOnlyMode, + ActionTargets: []addrs.Targetable{ + addrs.AbsAction{ + Action: addrs.Action{ + Type: "test_unlinked", + Name: "one", + }, }, }, }, - }, - expectPlanActionCalled: true, - buildState: func(state *states.SyncState) { - state.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a"), &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"name":"hello"}`), - Status: states.ObjectReady, - }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) - }, - assertPlan: func(t *testing.T, plan *plans.Plan) { - if len(plan.Changes.ActionInvocations) != 1 { - t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) - } - - ais := plan.Changes.ActionInvocations[0] - ai, err := ais.Decode(&unlinkedActionSchema) - if err != nil { - t.Fatal(err) - } + expectPlanActionCalled: true, + buildState: func(state *states.SyncState) { + state.SetResourceInstanceCurrent(mustResourceInstanceAddr("test_object.a"), &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"name":"hello"}`), + Status: states.ObjectReady, + }, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`)) + }, + assertPlan: func(t *testing.T, plan *plans.Plan) { + if len(plan.Changes.ActionInvocations) != 1 { + t.Fatalf("expected exactly one invocation, and found %d", len(plan.Changes.ActionInvocations)) + } - if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { - t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) - } + ais := plan.Changes.ActionInvocations[0] + ai, err := ais.Decode(&unlinkedActionSchema) + if err != nil { + t.Fatal(err) + } - expected := cty.ObjectVal(map[string]cty.Value{ - "attr": cty.StringVal("world"), - }) - if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { - t.Fatalf("wrong value in plan: %s", diff) - } + if _, ok := ai.ActionTrigger.(*plans.InvokeActionTrigger); !ok { + t.Fatalf("expected invoke action trigger type but was %T", ai.ActionTrigger) + } - if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one")) { - t.Fatalf("wrong address in plan: %s", ai.Addr) - } + expected := cty.ObjectVal(map[string]cty.Value{ + "attr": cty.StringVal("world"), + }) + if diff := cmp.Diff(ai.ConfigValue, expected, ctydebug.CmpOptions); len(diff) > 0 { + t.Fatalf("wrong value in plan: %s", diff) + } - if len(plan.DriftedResources) > 0 { - t.Fatalf("shouldn't have refreshed any resources") - } - }, - readResourceFn: func(t *testing.T, request providers.ReadResourceRequest) (resp providers.ReadResourceResponse) { - t.Fatalf("should not have tried to refresh any resources") - return - }, - }, + if !ai.Addr.Equal(mustActionInstanceAddr(t, "action.test_unlinked.one")) { + t.Fatalf("wrong address in plan: %s", ai.Addr) + } - "action config nested single + list blocks": { - module: map[string]string{ - "main.tf": ` -action "test_nested" "with_blocks" { - config { - top_attr = "top" - settings { - name = "primary" - rule { - value = "r1" - } - rule { - value = "r2" - } - } - } -} -resource "test_object" "a" { - name = "object" - lifecycle { - action_trigger { - events = [before_create] - actions = [action.test_nested.with_blocks] - } - } -} -`, - }, - expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Fatalf("expected 1 action invocation, got %d", len(p.Changes.ActionInvocations)) - } - ais := p.Changes.ActionInvocations[0] - decoded, err := ais.Decode(&nestedActionSchema) - if err != nil { - t.Fatalf("error decoding nested action: %s", err) - } - cv := decoded.ConfigValue - if cv.GetAttr("top_attr").AsString() != "top" { - t.Fatalf("expected top_attr = top, got %s", cv.GetAttr("top_attr").GoString()) - } - settings := cv.GetAttr("settings") - if !settings.Type().IsObjectType() { - t.Fatalf("expected settings object, got %s", settings.Type().FriendlyName()) - } - if settings.GetAttr("name").AsString() != "primary" { - t.Fatalf("expected settings.name = primary, got %s", settings.GetAttr("name").GoString()) - } - rules := settings.GetAttr("rule") - if !rules.Type().IsListType() || rules.LengthInt() != 2 { - t.Fatalf("expected 2 rule blocks, got type %s length %d", rules.Type().FriendlyName(), rules.LengthInt()) - } - first := rules.Index(cty.NumberIntVal(0)).GetAttr("value").AsString() - second := rules.Index(cty.NumberIntVal(1)).GetAttr("value").AsString() - if first != "r1" || second != "r2" { - t.Fatalf("expected rule values r1,r2 got %s,%s", first, second) - } + if len(plan.DriftedResources) > 0 { + t.Fatalf("shouldn't have refreshed any resources") + } + }, + readResourceFn: func(t *testing.T, request providers.ReadResourceRequest) (resp providers.ReadResourceResponse) { + t.Fatalf("should not have tried to refresh any resources") + return + }, }, }, - "action config top-level list block": { - module: map[string]string{ - "main.tf": ` -action "test_nested" "with_list" { - config { - settings_list { - id = "one" - } - settings_list { - id = "two" - } - } -} -resource "test_object" "a" { - lifecycle { - action_trigger { - events = [after_create] - actions = [action.test_nested.with_list] - } - } -} -`, - }, - expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Fatalf("expected 1 action invocation, got %d", len(p.Changes.ActionInvocations)) - } - ais := p.Changes.ActionInvocations[0] - decoded, err := ais.Decode(&nestedActionSchema) - if err != nil { - t.Fatalf("error decoding nested action: %s", err) - } - cv := decoded.ConfigValue - if !cv.GetAttr("top_attr").IsNull() { - t.Fatalf("expected top_attr to be null, got %s", cv.GetAttr("top_attr").GoString()) - } - sl := cv.GetAttr("settings_list") - if !sl.Type().IsListType() || sl.LengthInt() != 2 { - t.Fatalf("expected 2 settings_list blocks, got type %s length %d", sl.Type().FriendlyName(), sl.LengthInt()) - } - first := sl.Index(cty.NumberIntVal(0)).GetAttr("id").AsString() - second := sl.Index(cty.NumberIntVal(1)).GetAttr("id").AsString() - if first != "one" || second != "two" { - t.Fatalf("expected ids one,two got %s,%s", first, second) - } - }, - }, + // ======== CONDITIONS ======== + // condition action_trigger attribute + // ======== CONDITIONS ======== - "boolean condition": { - module: map[string]string{ - "main.tf": ` + "conditions": { + "boolean condition": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} action "test_unlinked" "world" {} action "test_unlinked" "bye" {} @@ -2359,32 +2819,32 @@ lifecycle { } } `, - }, - expectPlanActionCalled: true, + }, + expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 2 { - t.Fatalf("expected 2 actions in plan, got %d", len(p.Changes.ActionInvocations)) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 2 { + t.Fatalf("expected 2 actions in plan, got %d", len(p.Changes.ActionInvocations)) + } - invokedActionAddrs := []string{} - for _, action := range p.Changes.ActionInvocations { - invokedActionAddrs = append(invokedActionAddrs, action.Addr.String()) - } - slices.Sort(invokedActionAddrs) - expectedActions := []string{ - "action.test_unlinked.hello", - "action.test_unlinked.world", - } - if !cmp.Equal(expectedActions, invokedActionAddrs) { - t.Fatalf("expected actions: %v, got %v", expectedActions, invokedActionAddrs) - } + invokedActionAddrs := []string{} + for _, action := range p.Changes.ActionInvocations { + invokedActionAddrs = append(invokedActionAddrs, action.Addr.String()) + } + slices.Sort(invokedActionAddrs) + expectedActions := []string{ + "action.test_unlinked.hello", + "action.test_unlinked.world", + } + if !cmp.Equal(expectedActions, invokedActionAddrs) { + t.Fatalf("expected actions: %v, got %v", expectedActions, invokedActionAddrs) + } + }, }, - }, - "unknown condition": { - module: map[string]string{ - "main.tf": ` + "unknown condition": { + module: map[string]string{ + "main.tf": ` variable "cond" { type = string } @@ -2399,34 +2859,34 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: false, - planOpts: &PlanOpts{ - Mode: plans.NormalMode, - SetVariables: InputValues{ - "cond": &InputValue{ - Value: cty.UnknownVal(cty.String), - SourceType: ValueFromCaller, - }, }, - }, - expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Condition must be known", - Detail: "The condition expression resulted in an unknown value, but it must be a known boolean value.", - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 10, Column: 19, Byte: 186}, - End: hcl.Pos{Line: 10, Column: 36, Byte: 203}, + expectPlanActionCalled: false, + planOpts: &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: InputValues{ + "cond": &InputValue{ + Value: cty.UnknownVal(cty.String), + SourceType: ValueFromCaller, + }, }, - }) + }, + expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Condition must be known", + Detail: "The condition expression resulted in an unknown value, but it must be a known boolean value.", + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 10, Column: 19, Byte: 186}, + End: hcl.Pos{Line: 10, Column: 36, Byte: 203}, + }, + }) + }, }, - }, - "non-boolean condition": { - module: map[string]string{ - "main.tf": ` + "non-boolean condition": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} resource "test_object" "foo" { name = "foo" @@ -2441,26 +2901,26 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: false, + }, + expectPlanActionCalled: false, - expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Incorrect value type", - Detail: "Invalid expression value: a bool is required.", - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 10, Column: 19, Byte: 196}, - End: hcl.Pos{Line: 10, Column: 39, Byte: 216}, - }, - }) + expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Incorrect value type", + Detail: "Invalid expression value: a bool is required.", + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 10, Column: 19, Byte: 196}, + End: hcl.Pos{Line: 10, Column: 39, Byte: 216}, + }, + }) + }, }, - }, - "using self in before_* condition": { - module: map[string]string{ - "main.tf": ` + "using self in before_* condition": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} action "test_unlinked" "world" {} resource "test_object" "a" { @@ -2479,26 +2939,26 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: false, + }, + expectPlanActionCalled: false, - expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Self reference not allowed", - Detail: `The condition expression cannot reference "self".`, - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 9, Column: 19, Byte: 197}, - End: hcl.Pos{Line: 9, Column: 37, Byte: 215}, - }, - }) + expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Self reference not allowed", + Detail: `The condition expression cannot reference "self".`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 9, Column: 19, Byte: 197}, + End: hcl.Pos{Line: 9, Column: 37, Byte: 215}, + }, + }) + }, }, - }, - "using self in after_* condition": { - module: map[string]string{ - "main.tf": ` + "using self in after_* condition": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} action "test_unlinked" "world" {} resource "test_object" "a" { @@ -2517,27 +2977,27 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: false, + }, + expectPlanActionCalled: false, - expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - // We only expect one diagnostic, as the other condition is valid - return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Self reference not allowed", - Detail: `The condition expression cannot reference "self".`, - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 9, Column: 19, Byte: 196}, - End: hcl.Pos{Line: 9, Column: 37, Byte: 214}, - }, - }) + expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + // We only expect one diagnostic, as the other condition is valid + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Self reference not allowed", + Detail: `The condition expression cannot reference "self".`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 9, Column: 19, Byte: 196}, + End: hcl.Pos{Line: 9, Column: 37, Byte: 214}, + }, + }) + }, }, - }, - "using each in before_* condition": { - module: map[string]string{ - "main.tf": ` + "using each in before_* condition": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} action "test_unlinked" "world" {} resource "test_object" "a" { @@ -2552,35 +3012,35 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: false, + }, + expectPlanActionCalled: false, - expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Each reference not allowed", - Detail: `The condition expression cannot reference "each" if the action is run before the resource is applied.`, - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 10, Column: 19, Byte: 235}, - End: hcl.Pos{Line: 10, Column: 36, Byte: 252}, - }, - }).Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Each reference not allowed", - Detail: `The condition expression cannot reference "each" if the action is run before the resource is applied.`, - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 10, Column: 19, Byte: 235}, - End: hcl.Pos{Line: 10, Column: 36, Byte: 252}, - }, - }) + expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Each reference not allowed", + Detail: `The condition expression cannot reference "each" if the action is run before the resource is applied.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 10, Column: 19, Byte: 235}, + End: hcl.Pos{Line: 10, Column: 36, Byte: 252}, + }, + }).Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Each reference not allowed", + Detail: `The condition expression cannot reference "each" if the action is run before the resource is applied.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 10, Column: 19, Byte: 235}, + End: hcl.Pos{Line: 10, Column: 36, Byte: 252}, + }, + }) + }, }, - }, - "using each in after_* condition": { - module: map[string]string{ - "main.tf": ` + "using each in after_* condition": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} action "test_unlinked" "world" {} resource "test_object" "a" { @@ -2600,22 +3060,22 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: true, + }, + expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Errorf("Expected 1 action invocations, got %d", len(p.Changes.ActionInvocations)) - } - if p.Changes.ActionInvocations[0].Addr.String() != "action.test_unlinked.hello" { - t.Errorf("Expected action 'action.test_unlinked.hello', got %s", p.Changes.ActionInvocations[0].Addr.String()) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Errorf("Expected 1 action invocations, got %d", len(p.Changes.ActionInvocations)) + } + if p.Changes.ActionInvocations[0].Addr.String() != "action.test_unlinked.hello" { + t.Errorf("Expected action 'action.test_unlinked.hello', got %s", p.Changes.ActionInvocations[0].Addr.String()) + } + }, }, - }, - "using count.index in before_* condition": { - module: map[string]string{ - "main.tf": ` + "using count.index in before_* condition": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} action "test_unlinked" "world" {} resource "test_object" "a" { @@ -2635,44 +3095,44 @@ resource "test_object" "a" { } } `, + }, + expectPlanActionCalled: false, + + expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Count reference not allowed", + Detail: `The condition expression cannot reference "count" if the action is run before the resource is applied.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 10, Column: 21, Byte: 237}, + End: hcl.Pos{Line: 10, Column: 37, Byte: 253}, + }, + }).Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Count reference not allowed", + Detail: `The condition expression cannot reference "count" if the action is run before the resource is applied.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 10, Column: 21, Byte: 237}, + End: hcl.Pos{Line: 10, Column: 37, Byte: 253}, + }, + }).Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Count reference not allowed", + Detail: `The condition expression cannot reference "count" if the action is run before the resource is applied.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 10, Column: 21, Byte: 237}, + End: hcl.Pos{Line: 10, Column: 37, Byte: 253}, + }, + }) + }, }, - expectPlanActionCalled: false, - - expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Count reference not allowed", - Detail: `The condition expression cannot reference "count" if the action is run before the resource is applied.`, - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 10, Column: 21, Byte: 237}, - End: hcl.Pos{Line: 10, Column: 37, Byte: 253}, - }, - }).Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Count reference not allowed", - Detail: `The condition expression cannot reference "count" if the action is run before the resource is applied.`, - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 10, Column: 21, Byte: 237}, - End: hcl.Pos{Line: 10, Column: 37, Byte: 253}, - }, - }).Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Count reference not allowed", - Detail: `The condition expression cannot reference "count" if the action is run before the resource is applied.`, - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 10, Column: 21, Byte: 237}, - End: hcl.Pos{Line: 10, Column: 37, Byte: 253}, - }, - }) - }, - }, - "using count.index in after_* condition": { - module: map[string]string{ - "main.tf": ` + "using count.index in after_* condition": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} action "test_unlinked" "world" {} resource "test_object" "a" { @@ -2692,22 +3152,22 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: true, + }, + expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Errorf("Expected 1 action invocation, got %d", len(p.Changes.ActionInvocations)) - } - if p.Changes.ActionInvocations[0].Addr.String() != "action.test_unlinked.hello" { - t.Errorf("Expected action invocation %q, got %q", "action.test_unlinked.hello", p.Changes.ActionInvocations[0].Addr.String()) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Errorf("Expected 1 action invocation, got %d", len(p.Changes.ActionInvocations)) + } + if p.Changes.ActionInvocations[0].Addr.String() != "action.test_unlinked.hello" { + t.Errorf("Expected action invocation %q, got %q", "action.test_unlinked.hello", p.Changes.ActionInvocations[0].Addr.String()) + } + }, }, - }, - "using each.value in before_* condition": { - module: map[string]string{ - "main.tf": ` + "using each.value in before_* condition": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} action "test_unlinked" "world" {} resource "test_object" "a" { @@ -2727,35 +3187,35 @@ resource "test_object" "a" { } } `, + }, + expectPlanActionCalled: false, + + expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { + return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Each reference not allowed", + Detail: `The condition expression cannot reference "each" if the action is run before the resource is applied.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 10, Column: 21, Byte: 264}, + End: hcl.Pos{Line: 10, Column: 43, Byte: 286}, + }, + }).Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Each reference not allowed", + Detail: `The condition expression cannot reference "each" if the action is run before the resource is applied.`, + Subject: &hcl.Range{ + Filename: filepath.Join(m.Module.SourceDir, "main.tf"), + Start: hcl.Pos{Line: 10, Column: 21, Byte: 264}, + End: hcl.Pos{Line: 10, Column: 43, Byte: 286}, + }, + }) + }, }, - expectPlanActionCalled: false, - - expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Each reference not allowed", - Detail: `The condition expression cannot reference "each" if the action is run before the resource is applied.`, - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 10, Column: 21, Byte: 264}, - End: hcl.Pos{Line: 10, Column: 43, Byte: 286}, - }, - }).Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Each reference not allowed", - Detail: `The condition expression cannot reference "each" if the action is run before the resource is applied.`, - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 10, Column: 21, Byte: 264}, - End: hcl.Pos{Line: 10, Column: 43, Byte: 286}, - }, - }) - }, - }, - "using each.value in after_* condition": { - module: map[string]string{ - "main.tf": ` + "using each.value in after_* condition": { + module: map[string]string{ + "main.tf": ` action "test_unlinked" "hello" {} action "test_unlinked" "world" {} resource "test_object" "a" { @@ -2775,588 +3235,184 @@ resource "test_object" "a" { } } `, - }, - expectPlanActionCalled: true, + }, + expectPlanActionCalled: true, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 1 { - t.Errorf("Expected 1 action invocations, got %d", len(p.Changes.ActionInvocations)) - } - if p.Changes.ActionInvocations[0].Addr.String() != "action.test_unlinked.hello" { - t.Errorf("Expected action 'action.test_unlinked.hello', got %s", p.Changes.ActionInvocations[0].Addr.String()) - } - }, - }, - "splat is not supported": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "hello" { - count = 42 -} -resource "test_object" "a" { - lifecycle { - action_trigger { - events = [before_create] - actions = [action.test_unlinked.hello[*]] - } - } -} -`, - }, - expectPlanActionCalled: false, - expectPlanDiagnostics: func(m *configs.Config) tfdiags.Diagnostics { - return tfdiags.Diagnostics{}.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: "Invalid action expression", - Detail: "Unexpected expression found in action_triggers.actions.", - Subject: &hcl.Range{ - Filename: filepath.Join(m.Module.SourceDir, "main.tf"), - Start: hcl.Pos{Line: 9, Column: 18, Byte: 161}, - End: hcl.Pos{Line: 9, Column: 47, Byte: 190}, - }, - }) - }, - }, - "action expansion with unknown instances": { - module: map[string]string{ - "main.tf": ` -variable "each" { - type = set(string) -} -action "test_unlinked" "hello" { - for_each = var.each -} -resource "test_object" "a" { - lifecycle { - action_trigger { - events = [before_create] - actions = [action.test_unlinked.hello["a"]] - } - } -} -`, - }, - expectPlanActionCalled: false, - planOpts: &PlanOpts{ - Mode: plans.NormalMode, - DeferralAllowed: true, - SetVariables: InputValues{ - "each": &InputValue{ - Value: cty.UnknownVal(cty.Set(cty.String)), - SourceType: ValueFromCLIArg, - }, - }, - }, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.DeferredActionInvocations) != 1 { - t.Fatalf("expected exactly one invocation, and found %d", len(p.DeferredActionInvocations)) - } - - if p.DeferredActionInvocations[0].DeferredReason != providers.DeferredReasonDeferredPrereq { - t.Fatalf("expected.DeferredReasonDeferredPrereq, got %s", p.DeferredActionInvocations[0].DeferredReason) - } - - ai := p.DeferredActionInvocations[0].ActionInvocationInstanceSrc - if ai.Addr.String() != `action.test_unlinked.hello["a"]` { - t.Fatalf(`expected action invocation for action.test_unlinked.hello["a"], got %s`, ai.Addr.String()) - } - - if len(p.DeferredResources) != 1 { - t.Fatalf("expected 1 deferred resource, got %d", len(p.DeferredResources)) - } - - if p.DeferredResources[0].ChangeSrc.Addr.String() != "test_object.a" { - t.Fatalf("expected test_object.a, got %s", p.DeferredResources[0].ChangeSrc.Addr.String()) - } - }, - }, - "action with unknown module expansion": { - // We have an unknown module expansion (for_each over an unknown value). The - // action and its triggering resource both live inside the (currently - // un-expanded) module instances. Since we cannot expand the module yet, the - // action invocation must be deferred. - module: map[string]string{ - "main.tf": ` -variable "mods" { - type = set(string) -} -module "mod" { - source = "./mod" - for_each = var.mods -} -`, - "mod/mod.tf": ` -action "test_unlinked" "hello" { - config { - attr = "static" - } -} -resource "other_object" "a" { - lifecycle { - action_trigger { - events = [before_create] - actions = [action.test_unlinked.hello] - } - } -} -`, - }, - expectPlanActionCalled: true, - planOpts: &PlanOpts{ - Mode: plans.NormalMode, - DeferralAllowed: true, - SetVariables: InputValues{ - "mods": &InputValue{ - Value: cty.UnknownVal(cty.Set(cty.String)), - SourceType: ValueFromCLIArg, - }, - }, - }, - assertPlan: func(t *testing.T, p *plans.Plan) { - // No concrete action invocations can be produced yet. - if got := len(p.Changes.ActionInvocations); got != 0 { - t.Fatalf("expected 0 planned action invocations, got %d", got) - } - - if got := len(p.DeferredActionInvocations); got != 1 { - t.Fatalf("expected 1 deferred action invocations, got %d", got) - } - ac, err := p.DeferredActionInvocations[0].Decode(&unlinkedActionSchema) - if err != nil { - t.Fatalf("error decoding action invocation: %s", err) - } - if ac.DeferredReason != providers.DeferredReasonInstanceCountUnknown { - t.Fatalf("expected DeferredReasonInstanceCountUnknown, got %s", ac.DeferredReason) - } - if ac.ActionInvocationInstance.ConfigValue.GetAttr("attr").AsString() != "static" { - t.Fatalf("expected attr to be static, got %s", ac.ActionInvocationInstance.ConfigValue.GetAttr("attr").AsString()) - } - - }, - }, - "action with unknown module expansion and unknown instances": { - // Here both the module expansion and the action for_each expansion are unknown. - // The action is referenced (with a specific key) inside the module so we should - // get a single deferred action invocation for that specific (yet still - // unresolved) instance address. - module: map[string]string{ - "main.tf": ` -variable "mods" { - type = set(string) -} -variable "actions" { - type = set(string) -} -module "mod" { - source = "./mod" - for_each = var.mods - - actions = var.actions -} -`, - "mod/mod.tf": ` -variable "actions" { - type = set(string) -} -action "test_unlinked" "hello" { - // Unknown for_each inside the module instance. - for_each = var.actions -} -resource "other_object" "a" { - lifecycle { - action_trigger { - events = [before_create] - // We reference a specific (yet unknown) action instance key. - actions = [action.test_unlinked.hello["a"]] - } - } -} -`, - }, - expectPlanActionCalled: true, - planOpts: &PlanOpts{ - Mode: plans.NormalMode, - DeferralAllowed: true, - SetVariables: InputValues{ - "mods": &InputValue{ - Value: cty.UnknownVal(cty.Set(cty.String)), - SourceType: ValueFromCLIArg, - }, - "actions": &InputValue{ - Value: cty.UnknownVal(cty.Set(cty.String)), - SourceType: ValueFromCLIArg, - }, - }, - }, - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 0 { - t.Fatalf("expected 0 planned action invocations, got %d", len(p.Changes.ActionInvocations)) - } - - if len(p.DeferredActionInvocations) != 1 { - t.Fatalf("expected 1 deferred partial action invocations, got %d", len(p.DeferredActionInvocations)) - } - - ac, err := p.DeferredActionInvocations[0].Decode(&unlinkedActionSchema) - if err != nil { - t.Fatalf("error decoding action invocation: %s", err) - } - if ac.DeferredReason != providers.DeferredReasonInstanceCountUnknown { - t.Fatalf("expected deferred reason to be DeferredReasonInstanceCountUnknown, got %s", ac.DeferredReason) - } - if !ac.ActionInvocationInstance.ConfigValue.IsNull() { - t.Fatalf("expected config value to be null") - } - }, - }, - - "deferring resource dependencies should defer action": { - module: map[string]string{ - "main.tf": ` -resource "test_object" "origin" { - name = "origin" -} -action "test_unlinked" "hello" { - config { - attr = test_object.origin.name - } -} -resource "test_object" "a" { - name = "a" - lifecycle { - action_trigger { - events = [after_create] - actions = [action.test_unlinked.hello] - } - } -} -`, - }, - expectPlanActionCalled: false, - planOpts: &PlanOpts{ - Mode: plans.NormalMode, - DeferralAllowed: true, - }, - planResourceFn: func(t *testing.T, req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { - if req.Config.GetAttr("name").AsString() == "origin" { - return providers.PlanResourceChangeResponse{ - Deferred: &providers.Deferred{ - Reason: providers.DeferredReasonAbsentPrereq, - }, - } - } - return providers.PlanResourceChangeResponse{ - PlannedState: req.ProposedNewState, - PlannedPrivate: req.PriorPrivate, - PlannedIdentity: req.PriorIdentity, - } - }, - - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.DeferredActionInvocations) != 1 { - t.Errorf("Expected 1 deferred action invocation, got %d", len(p.DeferredActionInvocations)) - } - if p.DeferredActionInvocations[0].ActionInvocationInstanceSrc.Addr.String() != "action.test_unlinked.hello" { - t.Errorf("Expected action. test_unlinked.hello, got %s", p.DeferredActionInvocations[0].ActionInvocationInstanceSrc.Addr.String()) - } - if p.DeferredActionInvocations[0].DeferredReason != providers.DeferredReasonDeferredPrereq { - t.Errorf("Expected DeferredReasonDeferredPrereq, got %s", p.DeferredActionInvocations[0].DeferredReason) - } - - if len(p.DeferredResources) != 2 { - t.Fatalf("Expected 2 deferred resources, got %d", len(p.DeferredResources)) - } - - slices.SortFunc(p.DeferredResources, func(a, b *plans.DeferredResourceInstanceChangeSrc) int { - if a.ChangeSrc.Addr.Less(b.ChangeSrc.Addr) { - return -1 - } - if b.ChangeSrc.Addr.Less(a.ChangeSrc.Addr) { - return 1 - } - return 0 - }) - - if p.DeferredResources[0].ChangeSrc.Addr.String() != "test_object.a" { - t.Errorf("Expected test_object.a to be first, got %s", p.DeferredResources[0].ChangeSrc.Addr.String()) - } - if p.DeferredResources[0].DeferredReason != providers.DeferredReasonDeferredPrereq { - t.Errorf("Expected DeferredReasonDeferredPrereq, got %s", p.DeferredResources[0].DeferredReason) - } - if p.DeferredResources[1].ChangeSrc.Addr.String() != "test_object.origin" { - t.Errorf("Expected test_object.origin to be second, got %s", p.DeferredResources[1].ChangeSrc.Addr.String()) - } - if p.DeferredResources[1].DeferredReason != providers.DeferredReasonAbsentPrereq { - t.Errorf("Expected DeferredReasonAbsentPrereq, got %s", p.DeferredResources[1].DeferredReason) - } - }, - }, - - "multiple events triggering in same action trigger": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "hello" {} -resource "test_object" "a" { - lifecycle { - action_trigger { - events = [ - before_create, // should trigger - after_create, // should trigger - before_update // should be ignored - ] - actions = [action.test_unlinked.hello] - } - } -} -`, - }, - expectPlanActionCalled: true, - - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 2 { - t.Fatalf("expected 2 action in plan, got %d", len(p.Changes.ActionInvocations)) - } - - triggeredEvents := []configs.ActionTriggerEvent{} - for _, action := range p.Changes.ActionInvocations { - at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) - if !ok { - t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", action.ActionTrigger) - } - triggeredEvents = append(triggeredEvents, at.ActionTriggerEvent) - } - slices.Sort(triggeredEvents) - if diff := cmp.Diff([]configs.ActionTriggerEvent{configs.BeforeCreate, configs.AfterCreate}, triggeredEvents); diff != "" { - t.Errorf("wrong result\n%s", diff) - } - }, - }, - - "multiple events triggered together": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "one" {} -action "test_unlinked" "two" {} -resource "test_object" "a" { - lifecycle { - action_trigger { - events = [before_create, after_create, before_update, after_update] - actions = [action.test_unlinked.one, action.test_unlinked.two] - } - } -} -`, - }, - expectPlanActionCalled: true, - }, - - "multiple events triggering in multiple action trigger": { - module: map[string]string{ - "main.tf": ` -action "test_unlinked" "hello" {} -resource "test_object" "a" { - lifecycle { - // should trigger - action_trigger { - events = [before_create] - actions = [action.test_unlinked.hello] - } - // should trigger - action_trigger { - events = [after_create] - actions = [action.test_unlinked.hello] - } - // should be ignored - action_trigger { - events = [before_update] - actions = [action.test_unlinked.hello] - } - } -} -`, - }, - expectPlanActionCalled: true, - - assertPlan: func(t *testing.T, p *plans.Plan) { - if len(p.Changes.ActionInvocations) != 2 { - t.Fatalf("expected 2 action in plan, got %d", len(p.Changes.ActionInvocations)) - } - - triggeredEvents := []configs.ActionTriggerEvent{} - for _, action := range p.Changes.ActionInvocations { - at, ok := action.ActionTrigger.(*plans.LifecycleActionTrigger) - if !ok { - t.Fatalf("expected action trigger to be a LifecycleActionTrigger, got %T", action.ActionTrigger) - } - triggeredEvents = append(triggeredEvents, at.ActionTriggerEvent) - } - slices.Sort(triggeredEvents) - if diff := cmp.Diff([]configs.ActionTriggerEvent{configs.BeforeCreate, configs.AfterCreate}, triggeredEvents); diff != "" { - t.Errorf("wrong result\n%s", diff) - } + assertPlan: func(t *testing.T, p *plans.Plan) { + if len(p.Changes.ActionInvocations) != 1 { + t.Errorf("Expected 1 action invocations, got %d", len(p.Changes.ActionInvocations)) + } + if p.Changes.ActionInvocations[0].Addr.String() != "action.test_unlinked.hello" { + t.Errorf("Expected action 'action.test_unlinked.hello', got %s", p.Changes.ActionInvocations[0].Addr.String()) + } + }, }, }, } { - t.Run(name, func(t *testing.T) { - if tc.toBeImplemented { - t.Skip("Test not implemented yet") - } + t.Run(topic, func(t *testing.T) { + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + if tc.toBeImplemented { + t.Skip("Test not implemented yet") + } - m := testModuleInline(t, tc.module) - - p := &testing_provider.MockProvider{ - GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ - Actions: map[string]providers.ActionSchema{ - "test_unlinked": unlinkedActionSchema, - "test_unlinked_wo": writeOnlyUnlinkedActionSchema, - "test_nested": nestedActionSchema, - - "test_lifecycle": { - ConfigSchema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "attr": { - Type: cty.String, - Optional: true, + m := testModuleInline(t, tc.module) + + p := &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + Actions: map[string]providers.ActionSchema{ + "test_unlinked": unlinkedActionSchema, + "test_unlinked_wo": writeOnlyUnlinkedActionSchema, + "test_nested": nestedActionSchema, + + "test_lifecycle": { + ConfigSchema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "attr": { + Type: cty.String, + Optional: true, + }, + }, }, }, - }, - }, - "test_linked": { - ConfigSchema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "attr": { - Type: cty.String, - Optional: true, + "test_linked": { + ConfigSchema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "attr": { + Type: cty.String, + Optional: true, + }, + }, }, }, }, - }, - }, - ResourceTypes: map[string]providers.Schema{ - "test_object": { - Body: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "name": { - Type: cty.String, - Optional: true, + ResourceTypes: map[string]providers.Schema{ + "test_object": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": { + Type: cty.String, + Optional: true, + }, + }, }, }, }, }, - }, - }, - } + } - other := &testing_provider.MockProvider{ - GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ - ResourceTypes: map[string]providers.Schema{ - "other_object": { - Body: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "name": { - Type: cty.String, - Optional: true, + other := &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + ResourceTypes: map[string]providers.Schema{ + "other_object": { + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": { + Type: cty.String, + Optional: true, + }, + }, }, }, }, }, - }, - }, - } + } - ecosystem := &testing_provider.MockProvider{ - GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ - Actions: map[string]providers.ActionSchema{ - "ecosystem_unlinked": { - ConfigSchema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "attr": { - Type: cty.String, - Optional: true, + ecosystem := &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + Actions: map[string]providers.ActionSchema{ + "ecosystem_unlinked": { + ConfigSchema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "attr": { + Type: cty.String, + Optional: true, + }, + }, }, + + Unlinked: &providers.UnlinkedAction{}, }, }, - - Unlinked: &providers.UnlinkedAction{}, }, - }, - }, - } + } - if tc.planActionFn != nil { - p.PlanActionFn = func(r providers.PlanActionRequest) providers.PlanActionResponse { - return tc.planActionFn(t, r) - } - } + if tc.planActionFn != nil { + p.PlanActionFn = func(r providers.PlanActionRequest) providers.PlanActionResponse { + return tc.planActionFn(t, r) + } + } - if tc.planResourceFn != nil { - p.PlanResourceChangeFn = func(r providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { - return tc.planResourceFn(t, r) - } - } + if tc.planResourceFn != nil { + p.PlanResourceChangeFn = func(r providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return tc.planResourceFn(t, r) + } + } - if tc.readResourceFn != nil { - p.ReadResourceFn = func(r providers.ReadResourceRequest) providers.ReadResourceResponse { - return tc.readResourceFn(t, r) - } - } + if tc.readResourceFn != nil { + p.ReadResourceFn = func(r providers.ReadResourceRequest) providers.ReadResourceResponse { + return tc.readResourceFn(t, r) + } + } - ctx := testContext2(t, &ContextOpts{ - Providers: map[addrs.Provider]providers.Factory{ - // The providers never actually going to get called here, we should - // catch the error long before anything happens. - addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), - addrs.NewDefaultProvider("other"): testProviderFuncFixed(other), - { - Type: "ecosystem", - Namespace: "danielmschmidt", - Hostname: addrs.DefaultProviderRegistryHost, - }: testProviderFuncFixed(ecosystem), - }, - }) - - diags := ctx.Validate(m, &ValidateOpts{}) - if tc.expectValidateDiagnostics != nil { - tfdiags.AssertDiagnosticsMatch(t, diags, tc.expectValidateDiagnostics(m)) - } else if tc.assertValidateDiagnostics != nil { - tc.assertValidateDiagnostics(t, diags) - } else { - tfdiags.AssertNoDiagnostics(t, diags) - } + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + // The providers never actually going to get called here, we should + // catch the error long before anything happens. + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + addrs.NewDefaultProvider("other"): testProviderFuncFixed(other), + { + Type: "ecosystem", + Namespace: "danielmschmidt", + Hostname: addrs.DefaultProviderRegistryHost, + }: testProviderFuncFixed(ecosystem), + }, + }) - if diags.HasErrors() { - return - } + diags := ctx.Validate(m, &ValidateOpts{}) + if tc.expectValidateDiagnostics != nil { + tfdiags.AssertDiagnosticsMatch(t, diags, tc.expectValidateDiagnostics(m)) + } else if tc.assertValidateDiagnostics != nil { + tc.assertValidateDiagnostics(t, diags) + } else { + tfdiags.AssertNoDiagnostics(t, diags) + } - var prevRunState *states.State - if tc.buildState != nil { - prevRunState = states.BuildState(tc.buildState) - } + if diags.HasErrors() { + return + } - opts := SimplePlanOpts(plans.NormalMode, InputValues{}) - if tc.planOpts != nil { - opts = tc.planOpts - } + var prevRunState *states.State + if tc.buildState != nil { + prevRunState = states.BuildState(tc.buildState) + } - plan, diags := ctx.Plan(m, prevRunState, opts) + opts := SimplePlanOpts(plans.NormalMode, InputValues{}) + if tc.planOpts != nil { + opts = tc.planOpts + } - if tc.expectPlanDiagnostics != nil { - tfdiags.AssertDiagnosticsMatch(t, diags, tc.expectPlanDiagnostics(m)) - } else if tc.assertPlanDiagnostics != nil { - tc.assertPlanDiagnostics(t, diags) - } else { - tfdiags.AssertNoDiagnostics(t, diags) - } + plan, diags := ctx.Plan(m, prevRunState, opts) - if tc.expectPlanActionCalled && !p.PlanActionCalled { - t.Errorf("expected plan action to be called, but it was not") - } else if !tc.expectPlanActionCalled && p.PlanActionCalled { - t.Errorf("expected plan action to not be called, but it was") - } + if tc.expectPlanDiagnostics != nil { + tfdiags.AssertDiagnosticsMatch(t, diags, tc.expectPlanDiagnostics(m)) + } else if tc.assertPlanDiagnostics != nil { + tc.assertPlanDiagnostics(t, diags) + } else { + tfdiags.AssertNoDiagnostics(t, diags) + } - if tc.assertPlan != nil { - tc.assertPlan(t, plan) + if tc.expectPlanActionCalled && !p.PlanActionCalled { + t.Errorf("expected plan action to be called, but it was not") + } else if !tc.expectPlanActionCalled && p.PlanActionCalled { + t.Errorf("expected plan action to not be called, but it was") + } + + if tc.assertPlan != nil { + tc.assertPlan(t, plan) + } + }) } }) }