diff --git a/internal/addrs/action.go b/internal/addrs/action.go index 040d01329030..d3b6773c8009 100644 --- a/internal/addrs/action.go +++ b/internal/addrs/action.go @@ -504,3 +504,79 @@ func ParseAbsActionInstance(traversal hcl.Traversal) (AbsActionInstance, tfdiags return AbsActionInstance{}, diags } } + +// ParseAbsActionStr is a helper wrapper around ParseAbsAction that takes a +// string and parses it with the HCL native syntax traversal parser before +// interpreting it. +// +// Error diagnostics are returned if either the parsing fails or the analysis of +// the traversal fails. There is no way for the caller to distinguish the two +// kinds of diagnostics programmatically. If error diagnostics are returned the +// returned address may be incomplete. +// +// Since this function has no context about the source of the given string, any +// returned diagnostics will not have meaningful source location information. +func ParseAbsActionStr(str string) (AbsAction, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + traversal, parseDiags := hclsyntax.ParseTraversalAbs([]byte(str), "", hcl.Pos{Line: 1, Column: 1}) + diags = diags.Append(parseDiags) + if parseDiags.HasErrors() { + return AbsAction{}, diags + } + + addr, addrDiags := ParseAbsAction(traversal) + diags = diags.Append(addrDiags) + return addr, diags +} + +// ParseAbsAction attempts to interpret the given traversal as an absolute +// action address, using the same syntax as expected by ParseTarget. +// +// If no error diagnostics are returned, the returned target includes the +// address that was extracted and the source range it was extracted from. +// +// If error diagnostics are returned then the AbsResource value is invalid and +// must not be used. +func ParseAbsAction(traversal hcl.Traversal) (AbsAction, tfdiags.Diagnostics) { + addr, diags := ParseTargetAction(traversal) + if diags.HasErrors() { + return AbsAction{}, diags + } + + switch tt := addr.Subject.(type) { + + case AbsAction: + return tt, diags + + case AbsActionInstance: // Catch likely user error with specialized message + // Assume that the last element of the traversal must be the index, + // since that's required for a valid resource instance address. + indexStep := traversal[len(traversal)-1] + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "An action address is required. This instance key identifies a specific action instance, which is not expected here.", + Subject: indexStep.SourceRange().Ptr(), + }) + return AbsAction{}, diags + + case ModuleInstance: // Catch likely user error with specialized message + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "An action address is required here. The module path must be followed by an action specification.", + Subject: traversal.SourceRange().Ptr(), + }) + return AbsAction{}, diags + + default: // Generic message for other address types + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid address", + Detail: "An action address is required here.", + Subject: traversal.SourceRange().Ptr(), + }) + return AbsAction{}, diags + } +} diff --git a/internal/providers/testing/provider_mock.go b/internal/providers/testing/provider_mock.go index 26c7bb0d074e..57a6e50ba1ba 100644 --- a/internal/providers/testing/provider_mock.go +++ b/internal/providers/testing/provider_mock.go @@ -227,6 +227,7 @@ func (p *MockProvider) getProviderSchema() providers.GetProviderSchemaResponse { ResourceTypes: map[string]providers.Schema{}, ListResourceTypes: map[string]providers.Schema{}, StateStores: map[string]providers.Schema{}, + Actions: map[string]providers.ActionSchema{}, } } diff --git a/internal/terraform/context_apply_action_test.go b/internal/terraform/context_apply_action_test.go index c6f363d68d9c..140db8964134 100644 --- a/internal/terraform/context_apply_action_test.go +++ b/internal/terraform/context_apply_action_test.go @@ -27,13 +27,12 @@ import ( func TestContextApply_actions(t *testing.T) { for name, tc := range map[string]struct { - toBeImplemented bool - module map[string]string - mode plans.Mode - prevRunState *states.State - events func(req providers.InvokeActionRequest) []providers.InvokeActionEvent - readResourceFn func(*testing.T, providers.ReadResourceRequest) providers.ReadResourceResponse - callingInvokeReturnsDiagnostics func(providers.InvokeActionRequest) tfdiags.Diagnostics + module map[string]string + mode plans.Mode + prevRunState *states.State + events func(req providers.InvokeActionRequest) []providers.InvokeActionEvent + readResourceFn func(*testing.T, providers.ReadResourceRequest) providers.ReadResourceResponse + invokeDiagnosticResponse func(providers.InvokeActionRequest) tfdiags.Diagnostics planOpts *PlanOpts applyOpts *ApplyOpts @@ -116,16 +115,10 @@ resource "test_object" "a" { // the before should have happened first, and the order should // be correct. - - beforeStart := capture.startActionHooks[0] - beforeComplete := capture.completeActionHooks[0] - evaluateHook(beforeStart, "action.action_example.hello", configs.BeforeCreate) - evaluateHook(beforeComplete, "action.action_example.hello", configs.BeforeCreate) - - afterStart := capture.startActionHooks[1] - afterComplete := capture.completeActionHooks[1] - evaluateHook(afterStart, "action.action_example.hello", configs.AfterCreate) - evaluateHook(afterComplete, "action.action_example.hello", configs.AfterCreate) + evaluateHook(capture.startActionHooks[0], "action.action_example.hello", configs.BeforeCreate) + evaluateHook(capture.completeActionHooks[0], "action.action_example.hello", configs.BeforeCreate) + evaluateHook(capture.startActionHooks[1], "action.action_example.hello", configs.AfterCreate) + evaluateHook(capture.completeActionHooks[1], "action.action_example.hello", configs.AfterCreate) }, }, @@ -146,19 +139,12 @@ resource "test_object" "a" { }, prevRunState: states.BuildState(func(s *states.SyncState) { s.SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_object", - Name: "a", - }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + mustResourceInstanceAddr("test_object.a"), &states.ResourceInstanceObjectSrc{ Status: states.ObjectReady, AttrsJSON: []byte(`{"name":"old name"}`), }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), ) }), expectInvokeActionCalled: true, @@ -181,19 +167,12 @@ resource "test_object" "a" { }, prevRunState: states.BuildState(func(s *states.SyncState) { s.SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_object", - Name: "a", - }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + mustResourceInstanceAddr("test_object.a"), &states.ResourceInstanceObjectSrc{ Status: states.ObjectReady, AttrsJSON: []byte(`{"name":"old"}`), }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), ) }), expectInvokeActionCalled: true, @@ -315,7 +294,7 @@ resource "test_object" "a" { `, }, expectInvokeActionCalled: true, - callingInvokeReturnsDiagnostics: func(providers.InvokeActionRequest) tfdiags.Diagnostics { + invokeDiagnosticResponse: func(providers.InvokeActionRequest) tfdiags.Diagnostics { return tfdiags.Diagnostics{ tfdiags.Sourceless( tfdiags.Error, @@ -425,7 +404,7 @@ resource "test_object" "a" { `, }, expectInvokeActionCalled: true, - callingInvokeReturnsDiagnostics: func(r providers.InvokeActionRequest) tfdiags.Diagnostics { + invokeDiagnosticResponse: func(r providers.InvokeActionRequest) tfdiags.Diagnostics { if !r.PlannedActionData.IsNull() && r.PlannedActionData.GetAttr("attr").AsString() == "failure" { // Simulate a failure for the second action return tfdiags.Diagnostics{ @@ -496,7 +475,7 @@ resource "test_object" "a" { `, }, expectInvokeActionCalled: true, - callingInvokeReturnsDiagnostics: func(r providers.InvokeActionRequest) tfdiags.Diagnostics { + invokeDiagnosticResponse: func(r providers.InvokeActionRequest) tfdiags.Diagnostics { if !r.PlannedActionData.IsNull() && r.PlannedActionData.GetAttr("attr").AsString() == "failure" { // Simulate a failure for the second action return tfdiags.Diagnostics{ @@ -944,10 +923,7 @@ resource "test_object" "a" { Status: states.ObjectTainted, AttrsJSON: []byte(`{"name":"previous_run"}`), }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), ) }), expectInvokeActionCalled: false, @@ -979,10 +955,7 @@ resource "test_object" "a" { Status: states.ObjectTainted, AttrsJSON: []byte(`{"name":"previous_run"}`), }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), ) }), expectInvokeActionCalled: false, @@ -1139,19 +1112,12 @@ resource "test_object" "a" { planOpts: SimplePlanOpts(plans.DestroyMode, InputValues{}), prevRunState: states.BuildState(func(state *states.SyncState) { state.SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_object", - Name: "a", - }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + mustResourceInstanceAddr("test_object.a"), &states.ResourceInstanceObjectSrc{ Status: states.ObjectReady, AttrsJSON: []byte(`{"name":"previous_run"}`), }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), ) }), }, @@ -1175,51 +1141,30 @@ resource "test_object" "a" { prevRunState: states.BuildState(func(s *states.SyncState) { s.SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_object", - Name: "a", - }.Instance(addrs.IntKey(0)).Absolute(addrs.RootModuleInstance), + mustResourceInstanceAddr("test_object.a[0]"), &states.ResourceInstanceObjectSrc{ Status: states.ObjectReady, AttrsJSON: []byte(`{}`), }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), ) s.SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_object", - Name: "a", - }.Instance(addrs.IntKey(1)).Absolute(addrs.RootModuleInstance), + mustResourceInstanceAddr("test_object.a[1]"), &states.ResourceInstanceObjectSrc{ Status: states.ObjectReady, AttrsJSON: []byte(`{}`), }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), ) s.SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_object", - Name: "a", - }.Instance(addrs.IntKey(2)).Absolute(addrs.RootModuleInstance), + mustResourceInstanceAddr("test_object.a[2]"), &states.ResourceInstanceObjectSrc{ Status: states.ObjectReady, AttrsJSON: []byte(`{}`), }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, - }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), ) }), }, @@ -1614,18 +1559,8 @@ action "action_example" "two" { `, }, planOpts: &PlanOpts{ - Mode: plans.RefreshOnlyMode, - ActionTargets: []addrs.Targetable{ - addrs.AbsActionInstance{ - Action: addrs.ActionInstance{ - Action: addrs.Action{ - Type: "action_example", - Name: "one", - }, - Key: addrs.IntKey(0), - }, - }, - }, + Mode: plans.RefreshOnlyMode, + ActionTargets: []addrs.Targetable{mustAbsActionInstanceAddr("action.action_example.one[0]")}, }, expectInvokeActionCalled: true, expectInvokeActionCalls: []providers.InvokeActionRequest{ @@ -1653,15 +1588,8 @@ action "action_example" "one" { `, }, planOpts: &PlanOpts{ - Mode: plans.RefreshOnlyMode, - ActionTargets: []addrs.Targetable{ - addrs.AbsAction{ - Action: addrs.Action{ - Type: "action_example", - Name: "one", - }, - }, - }, + Mode: plans.RefreshOnlyMode, + ActionTargets: []addrs.Targetable{mustAbsActionAddr("action.action_example.one")}, }, expectInvokeActionCalled: true, expectInvokeActionCalls: []providers.InvokeActionRequest{ @@ -1695,15 +1623,8 @@ action "action_example" "one" { `, }, planOpts: &PlanOpts{ - Mode: plans.RefreshOnlyMode, - ActionTargets: []addrs.Targetable{ - addrs.AbsAction{ - Action: addrs.Action{ - Type: "action_example", - Name: "one", - }, - }, - }, + Mode: plans.RefreshOnlyMode, + ActionTargets: []addrs.Targetable{mustAbsActionAddr("action.action_example.one")}, }, expectInvokeActionCalled: true, expectInvokeActionCalls: []providers.InvokeActionRequest{ @@ -1744,16 +1665,9 @@ action "action_example" "one" { `, }, planOpts: &PlanOpts{ - Mode: plans.RefreshOnlyMode, - SkipRefresh: true, - ActionTargets: []addrs.Targetable{ - addrs.AbsAction{ - Action: addrs.Action{ - Type: "action_example", - Name: "one", - }, - }, - }, + Mode: plans.RefreshOnlyMode, + SkipRefresh: true, + ActionTargets: []addrs.Targetable{mustAbsActionAddr("action.action_example.one")}, }, expectInvokeActionCalled: true, expectInvokeActionCalls: []providers.InvokeActionRequest{ @@ -2315,12 +2229,8 @@ resource "test_object" "b" { }, }, planOpts: &PlanOpts{ - Mode: plans.NormalMode, - Targets: []addrs.Targetable{ - addrs.RootModuleInstance. - Resource(addrs.ManagedResourceMode, "test_object", "a"). - Instance(addrs.IntKey(2)), - }, + Mode: plans.NormalMode, + Targets: []addrs.Targetable{mustResourceInstanceAddr("test_object.a[2]")}, }, }, @@ -2556,30 +2466,15 @@ lifecycle { }, } { t.Run(name, func(t *testing.T) { - if tc.toBeImplemented { - t.Skip("This test is not implemented yet") - } - m := testModuleInline(t, tc.module) invokeActionCalls := []providers.InvokeActionRequest{} - testProvider := &testing_provider.MockProvider{ - GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ - ResourceTypes: map[string]providers.Schema{ - "test_object": { - Body: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "name": { - Type: cty.String, - Optional: true, - }, - }, - }, - }, - }, + testProvider := mockProviderWithResourceTypeSchema("test_object", &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": {Type: cty.String, Optional: true}, }, - } + }) if tc.readResourceFn != nil { testProvider.ReadResourceFn = func(r providers.ReadResourceRequest) providers.ReadResourceResponse { @@ -2589,20 +2484,19 @@ lifecycle { invokeActionFn := func(req providers.InvokeActionRequest) providers.InvokeActionResponse { invokeActionCalls = append(invokeActionCalls, req) - if tc.callingInvokeReturnsDiagnostics != nil && len(tc.callingInvokeReturnsDiagnostics(req)) > 0 { + if tc.invokeDiagnosticResponse != nil && len(tc.invokeDiagnosticResponse(req)) > 0 { return providers.InvokeActionResponse{ - Diagnostics: tc.callingInvokeReturnsDiagnostics(req), + Diagnostics: tc.invokeDiagnosticResponse(req), } } - defaultEvents := []providers.InvokeActionEvent{} - defaultEvents = append(defaultEvents, providers.InvokeActionEvent_Progress{ - Message: "Hello world!", - }) - defaultEvents = append(defaultEvents, providers.InvokeActionEvent_Completed{}) - - events := defaultEvents - if tc.events != nil { + var events []providers.InvokeActionEvent + if tc.events == nil { + events = []providers.InvokeActionEvent{ + providers.InvokeActionEvent_Progress{Message: "Hello world!"}, + providers.InvokeActionEvent_Completed{}, + } + } else { events = tc.events(req) } @@ -2616,90 +2510,84 @@ lifecycle { }, } } - actionProvider := &testing_provider.MockProvider{ - GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ - Actions: map[string]providers.ActionSchema{ - "action_example": { - ConfigSchema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "attr": { - Type: cty.String, - Optional: true, - }, + + actionProvider := simpleMockProvider() + actionProvider.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + Actions: map[string]providers.ActionSchema{ + "action_example": { + ConfigSchema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "attr": { + Type: cty.String, + Optional: true, }, }, }, - "action_example_wo": { - ConfigSchema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "attr": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, + }, + "action_example_wo": { + ConfigSchema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "attr": { + Type: cty.String, + Optional: true, + WriteOnly: true, }, }, }, - // Added nested action schema with nested blocks - "action_nested": { - ConfigSchema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "top_attr": {Type: cty.String, Optional: true}, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "settings": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "name": {Type: cty.String, Required: true}, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "rule": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "value": {Type: cty.String, Required: true}, - }, + }, + // Added nested action schema with nested blocks + "action_nested": { + ConfigSchema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "top_attr": {Type: cty.String, Optional: true}, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "settings": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": {Type: cty.String, Required: true}, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "rule": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": {Type: cty.String, Required: true}, }, }, }, }, }, - "settings_list": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Required: true}, - }, + }, + "settings_list": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Required: true}, }, }, }, }, }, }, - ResourceTypes: map[string]providers.Schema{}, }, - InvokeActionFn: invokeActionFn, } - - ecosystem := &testing_provider.MockProvider{ - GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ - Actions: map[string]providers.ActionSchema{ - "ecosystem": { - ConfigSchema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "attr": { - Type: cty.String, - Optional: true, - }, - }, + actionProvider.InvokeActionFn = invokeActionFn + + ecosystem := simpleMockProvider() + ecosystem.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ + Actions: map[string]providers.ActionSchema{ + "ecosystem": { + ConfigSchema: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "attr": {Type: cty.String, Optional: true}, }, }, }, - ResourceTypes: map[string]providers.Schema{}, }, - InvokeActionFn: invokeActionFn, } + ecosystem.InvokeActionFn = invokeActionFn hookCapture := newActionHookCapture() ctx := testContext2(t, &ContextOpts{ @@ -2822,13 +2710,13 @@ func (a *actionHookCapture) CompleteAction(identity HookActionIdentity, _ error) return HookActionContinue, nil } -func TestContextApply_actions_after_trigger_runs_after_expanded_resource(t *testing.T) { +func TestContextApplyActions_after_trigger_runs_after_expanded_resource(t *testing.T) { m := testModuleInline(t, map[string]string{ "main.tf": ` locals { each = toset(["one"]) } -action "action_example" "hello" { +action "test_action" "hello" { config { attr = "hello" } @@ -2839,7 +2727,7 @@ resource "test_object" "a" { lifecycle { action_trigger { events = [after_create] - actions = [action.action_example.hello] + actions = [action.test_action.hello] } } } @@ -2862,21 +2750,8 @@ resource "test_object" "a" { }, }, }, - }, - ApplyResourceChangeFn: func(arcr providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse { - time.Sleep(100 * time.Millisecond) - orderedCalls = append(orderedCalls, fmt.Sprintf("ApplyResourceChangeFn %s", arcr.TypeName)) - return providers.ApplyResourceChangeResponse{ - NewState: arcr.PlannedState, - NewIdentity: arcr.PlannedIdentity, - } - }, - } - - actionProvider := &testing_provider.MockProvider{ - GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ Actions: map[string]providers.ActionSchema{ - "action_example": { + "test_action": { ConfigSchema: &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "attr": { @@ -2887,7 +2762,14 @@ resource "test_object" "a" { }, }, }, - ResourceTypes: map[string]providers.Schema{}, + }, + ApplyResourceChangeFn: func(arcr providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse { + time.Sleep(100 * time.Millisecond) + orderedCalls = append(orderedCalls, fmt.Sprintf("ApplyResourceChangeFn %s", arcr.TypeName)) + return providers.ApplyResourceChangeResponse{ + NewState: arcr.PlannedState, + NewIdentity: arcr.PlannedIdentity, + } }, InvokeActionFn: func(iar providers.InvokeActionRequest) providers.InvokeActionResponse { orderedCalls = append(orderedCalls, fmt.Sprintf("InvokeAction %s", iar.ActionType)) @@ -2902,8 +2784,7 @@ resource "test_object" "a" { hookCapture := newActionHookCapture() ctx := testContext2(t, &ContextOpts{ Providers: map[addrs.Provider]providers.Factory{ - addrs.NewDefaultProvider("test"): testProviderFuncFixed(testProvider), - addrs.NewDefaultProvider("action"): testProviderFuncFixed(actionProvider), + addrs.NewDefaultProvider("test"): testProviderFuncFixed(testProvider), }, Hooks: []Hook{ &hookCapture, @@ -2928,10 +2809,98 @@ resource "test_object" "a" { expectedOrder := []string{ "ApplyResourceChangeFn test_object", - "InvokeAction action_example", + "InvokeAction test_action", } if diff := cmp.Diff(expectedOrder, orderedCalls); diff != "" { t.Fatalf("expected calls in order did not match actual calls (-expected +actual):\n%s", diff) } } + +func TestContextApply_actions_failures(t *testing.T) { + + m := testModuleInline(t, map[string]string{ + "main.tf": ` +module "mod" { + for_each = toset(["a", "b"]) + source = "./mod" + instance_name = each.key +}`, + + "mod/mod.tf": ` +variable "instance_name" { + type = string +} + +action "test_action" "hello" { + config { + test_string = "hello" + } +} + +action "fail_action" "fail" { + config { + test_string = "hello" + } +} + +resource "test_object" "a" { + test_string = var.instance_name + lifecycle { + action_trigger { + events = [before_create] + actions = [action.fail_action.fail] + } + action_trigger { + events = [after_create] + actions = [action.test_action.hello] + } + } +} + +resource "test_object" "b" { + test_string = test_object.a.test_string //set up a dependency between test_object.a and test_object.b + lifecycle { + action_trigger { + events = [before_create] + actions = [action.test_action.hello] + } + } +} +`}) + + p := simpleMockProvider() + fp := namedMockProvider("fail") + fp.InvokeActionResponse = &providers.InvokeActionResponse{ + Diagnostics: tfdiags.Diagnostics{tfdiags.Sourceless(tfdiags.Error, "bad", "failed")}, + } + + hookCapture := newActionHookCapture() + ctx := testContext2(t, &ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): testProviderFuncFixed(p), + addrs.NewDefaultProvider("fail"): testProviderFuncFixed(fp), + }, + Hooks: []Hook{&hookCapture}, + }) + + diags := ctx.Validate(m, &ValidateOpts{}) + tfdiags.AssertNoDiagnostics(t, diags) + + planOpts := SimplePlanOpts(plans.NormalMode, InputValues{}) + + plan, diags := ctx.Plan(m, nil, planOpts) + tfdiags.AssertNoDiagnostics(t, diags) + + if !plan.Applyable { + t.Fatalf("plan is not applyable but should be") + } + + state, diags := ctx.Apply(plan, m, nil) + tfdiags.AssertDiagnosticCount(t, diags, 2) + + rs := state.AllResourceInstanceObjectAddrs() + if len(rs) > 0 { + t.Fatal("resource found in state, nothing should have been created") + } +} diff --git a/internal/terraform/context_apply_test.go b/internal/terraform/context_apply_test.go index b1099dfa1b25..12a2b0ad3a73 100644 --- a/internal/terraform/context_apply_test.go +++ b/internal/terraform/context_apply_test.go @@ -12866,12 +12866,10 @@ resource "test_object" "a" { func TestContext2Apply_nilResponse(t *testing.T) { // empty config to remove our resource - m := testModuleInline(t, map[string]string{ - "main.tf": ` + m := testMainModuleInline(t, ` resource "test_object" "a" { } -`, - }) +`) p := simpleMockProvider() p.ApplyResourceChangeResponse = &providers.ApplyResourceChangeResponse{} diff --git a/internal/terraform/resource_provider_mock_test.go b/internal/terraform/resource_provider_mock_test.go index 06c2b178a9df..17a49f24bfb6 100644 --- a/internal/terraform/resource_provider_mock_test.go +++ b/internal/terraform/resource_provider_mock_test.go @@ -4,6 +4,8 @@ package terraform import ( + "fmt" + "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/providers" testing_provider "github.com/hashicorp/terraform/internal/providers/testing" @@ -85,6 +87,26 @@ func simpleMockProvider() *testing_provider.MockProvider { } } +// namedMockProvider is similar to simpleMockProvider, but the names of it's resources begin with the specified name string. +// It is pre-configured with schema for its own config, a resource type called NAME_object, a data source also +// called NAME_object, and an action called NAME_action. +func namedMockProvider(name string) *testing_provider.MockProvider { + return &testing_provider.MockProvider{ + GetProviderSchemaResponse: &providers.GetProviderSchemaResponse{ + Provider: providers.Schema{Body: simpleTestSchema()}, + ResourceTypes: map[string]providers.Schema{ + fmt.Sprintf("%s_object", name): {Body: simpleTestSchema()}, + }, + DataSources: map[string]providers.Schema{ + fmt.Sprintf("%s_object", name): {Body: simpleTestSchema()}, + }, + Actions: map[string]providers.ActionSchema{ + fmt.Sprintf("%s_action", name): {ConfigSchema: simpleTestSchema()}, + }, + }, + } +} + // getProviderSchema is a helper to convert from the internal // GetProviderSchemaResponse to a providerSchema. func getProviderSchema(p *testing_provider.MockProvider) *providerSchema { diff --git a/internal/terraform/terraform_test.go b/internal/terraform/terraform_test.go index 14be79f5375b..361e5f961aa9 100644 --- a/internal/terraform/terraform_test.go +++ b/internal/terraform/terraform_test.go @@ -20,14 +20,13 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configload" "github.com/hashicorp/terraform/internal/initwd" + _ "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" testing_provider "github.com/hashicorp/terraform/internal/providers/testing" "github.com/hashicorp/terraform/internal/provisioners" "github.com/hashicorp/terraform/internal/registry" "github.com/hashicorp/terraform/internal/states" - - _ "github.com/hashicorp/terraform/internal/logging" ) // This is the directory where our test fixtures are. @@ -87,6 +86,11 @@ func testModuleWithSnapshot(t *testing.T, name string) (*configs.Config, *config return config, snap } +// testMainModuleInline is a wrapper for testModuleInline, because many of our tests just need a main.tf +func testMainModuleInline(t testing.TB, source string, parserOpts ...configs.Option) *configs.Config { + return testModuleInline(t, map[string]string{"main.tf": source}, parserOpts...) +} + // testModuleInline takes a map of path -> config strings and yields a config // structure with those files loaded from disk func testModuleInline(t testing.TB, sources map[string]string, parserOpts ...configs.Option) *configs.Config { @@ -261,6 +265,22 @@ func mustModuleInstance(s string) addrs.ModuleInstance { return p } +func mustAbsActionAddr(s string) addrs.AbsAction { + addr, diags := addrs.ParseAbsActionStr(s) + if diags.HasErrors() { + panic(diags.Err()) + } + return addr +} + +func mustAbsActionInstanceAddr(s string) addrs.AbsActionInstance { + addr, diags := addrs.ParseAbsActionInstanceStr(s) + if diags.HasErrors() { + panic(diags.Err()) + } + return addr +} + // HookRecordApplyOrder is a test hook that records the order of applies // by recording the PreApply event. type HookRecordApplyOrder struct {