From 6c7db16566dc5893c861e97474885a04b2dc990e Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Wed, 19 Jul 2023 10:31:32 +0200 Subject: [PATCH] testing framework: refactor interrupt logic for immediate exits (#33532) * testing framework: refactor interrupt logic * fix formatting --- internal/command/test.go | 175 +++--- internal/command/test_test.go | 13 + internal/command/testing/test_provider.go | 6 + internal/command/views/json/message_types.go | 15 +- internal/command/views/json/test.go | 6 + internal/command/views/test.go | 113 +++- internal/command/views/test_test.go | 585 +++++++++++++++++++ 7 files changed, 816 insertions(+), 97 deletions(-) diff --git a/internal/command/test.go b/internal/command/test.go index 379318b89bcb..a38db04320ef 100644 --- a/internal/command/test.go +++ b/internal/command/test.go @@ -6,6 +6,7 @@ import ( "path" "sort" "strings" + "time" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/backend" @@ -173,6 +174,14 @@ func (c *TestCommand) Run(rawArgs []string) int { return 1 } + // We have two levels of interrupt here. A 'stop' and a 'cancel'. A 'stop' + // is a soft request to stop. We'll finish the current test, do the tidy up, + // but then skip all remaining tests and run blocks. A 'cancel' is a hard + // request to stop now. We'll cancel the current operation immediately + // even if it's a delete operation, and we won't clean up any infrastructure + // if we're halfway through a test. We'll print details explaining what was + // stopped so the user can do their best to recover from it. + runningCtx, done := context.WithCancel(context.Background()) stopCtx, stop := context.WithCancel(runningCtx) cancelCtx, cancel := context.WithCancel(context.Background()) @@ -199,7 +208,7 @@ func (c *TestCommand) Run(rawArgs []string) int { go func() { defer logging.PanicHandler() - defer done() // We completed successfully. + defer done() defer stop() defer cancel() @@ -224,10 +233,12 @@ func (c *TestCommand) Run(rawArgs []string) int { runner.Cancelled = true cancel() - // TODO(liamcervante): Should we add a timer here? That would mean - // after 5 seconds we just give up and don't even print out the - // lists of resources left behind? - <-runningCtx.Done() // Nothing left to do now but wait. + // We'll wait 5 seconds for this operation to finish now, regardless + // of whether it finishes successfully or not. + select { + case <-runningCtx.Done(): + case <-time.After(5 * time.Second): + } case <-runningCtx.Done(): // The application finished nicely after the request was stopped. @@ -331,13 +342,13 @@ func (runner *TestRunner) ExecuteTestFile(file *moduletest.File, globals map[str if run.Config.ConfigUnderTest != nil { // Then we want to execute a different module under a kind of // sandbox. - state := runner.ExecuteTestRun(run, file, states.NewState(), run.Config.ConfigUnderTest, globals) + state := runner.ExecuteTestRun(mgr, run, file, states.NewState(), run.Config.ConfigUnderTest, globals) mgr.States = append(mgr.States, &TestModuleState{ State: state, Run: run, }) } else { - mgr.State = runner.ExecuteTestRun(run, file, mgr.State, runner.Config, globals) + mgr.State = runner.ExecuteTestRun(mgr, run, file, mgr.State, runner.Config, globals) } file.Status = file.Status.Merge(run.Status) } @@ -348,7 +359,7 @@ func (runner *TestRunner) ExecuteTestFile(file *moduletest.File, globals map[str } } -func (runner *TestRunner) ExecuteTestRun(run *moduletest.Run, file *moduletest.File, state *states.State, config *configs.Config, globals map[string]backend.UnparsedVariableValue) *states.State { +func (runner *TestRunner) ExecuteTestRun(mgr *TestStateManager, run *moduletest.Run, file *moduletest.File, state *states.State, config *configs.Config, globals map[string]backend.UnparsedVariableValue) *states.State { if runner.Cancelled { // Don't do anything, just give up and return immediately. // The surrounding functions should stop this even being called, but in @@ -376,7 +387,7 @@ func (runner *TestRunner) ExecuteTestRun(run *moduletest.Run, file *moduletest.F return state } - ctx, plan, state, diags := runner.execute(run, file, config, state, &terraform.PlanOpts{ + ctx, plan, state, diags := runner.execute(mgr, run, file, config, state, &terraform.PlanOpts{ Mode: func() plans.Mode { switch run.Config.Options.Mode { case configs.RefreshOnlyTestMode: @@ -458,17 +469,12 @@ func (runner *TestRunner) ExecuteTestRun(run *moduletest.Run, file *moduletest.F // // The command argument decides whether it executes only a plan or also applies // the plan it creates during the planning. -func (runner *TestRunner) execute(run *moduletest.Run, file *moduletest.File, config *configs.Config, state *states.State, opts *terraform.PlanOpts, command configs.TestCommand, globals map[string]backend.UnparsedVariableValue) (*terraform.Context, *plans.Plan, *states.State, tfdiags.Diagnostics) { +func (runner *TestRunner) execute(mgr *TestStateManager, run *moduletest.Run, file *moduletest.File, config *configs.Config, state *states.State, opts *terraform.PlanOpts, command configs.TestCommand, globals map[string]backend.UnparsedVariableValue) (*terraform.Context, *plans.Plan, *states.State, tfdiags.Diagnostics) { if opts.Mode == plans.DestroyMode && state.Empty() { // Nothing to do! return nil, nil, state, nil } - identifier := file.Name - if run != nil { - identifier = fmt.Sprintf("%s/%s", identifier, run.Name) - } - // First, transform the config for the given test run and test file. var diags tfdiags.Diagnostics @@ -517,7 +523,7 @@ func (runner *TestRunner) execute(run *moduletest.Run, file *moduletest.File, co defer done() plan, planDiags = tfCtx.Plan(config, state, opts) }() - waitDiags, cancelled := runner.wait(tfCtx, runningCtx, opts, identifier) + waitDiags, cancelled := runner.wait(tfCtx, runningCtx, mgr, run, file, nil) planDiags = planDiags.Append(waitDiags) diags = diags.Append(planDiags) @@ -556,6 +562,25 @@ func (runner *TestRunner) execute(run *moduletest.Run, file *moduletest.File, co runningCtx, done = context.WithCancel(context.Background()) + // If things get cancelled while we are executing the apply operation below + // we want to print out all the objects that we were creating so the user + // can verify we managed to tidy everything up possibly. + // + // Unfortunately, this creates a race condition as the apply operation can + // edit the plan (by removing changes once they are applied) while at the + // same time our cancellation process will try to read the plan. + // + // We take a quick copy of the changes we care about here, which will then + // be used in place of the plan when we print out the objects to be created + // as part of the cancellation process. + var created []*plans.ResourceInstanceChangeSrc + for _, change := range plan.Changes.Resources { + if change.Action != plans.Create { + continue + } + created = append(created, change) + } + var updated *states.State var applyDiags tfdiags.Diagnostics @@ -564,77 +589,58 @@ func (runner *TestRunner) execute(run *moduletest.Run, file *moduletest.File, co defer done() updated, applyDiags = tfCtx.Apply(plan, config) }() - waitDiags, _ = runner.wait(tfCtx, runningCtx, opts, identifier) + waitDiags, _ = runner.wait(tfCtx, runningCtx, mgr, run, file, created) applyDiags = applyDiags.Append(waitDiags) diags = diags.Append(applyDiags) return tfCtx, plan, updated, diags } -func (runner *TestRunner) wait(ctx *terraform.Context, runningCtx context.Context, opts *terraform.PlanOpts, identifier string) (diags tfdiags.Diagnostics, cancelled bool) { - select { - case <-runner.StoppedCtx.Done(): +func (runner *TestRunner) wait(ctx *terraform.Context, runningCtx context.Context, mgr *TestStateManager, run *moduletest.Run, file *moduletest.File, created []*plans.ResourceInstanceChangeSrc) (diags tfdiags.Diagnostics, cancelled bool) { - if opts.Mode != plans.DestroyMode { - // It takes more impetus from the user to cancel the cleanup - // operations, so we only do this during the actual tests. - cancelled = true - go ctx.Stop() - } + // This function handles what happens when the user presses the second + // interrupt. This is a "hard cancel", we are going to stop doing whatever + // it is we're doing. This means even if we're halfway through creating or + // destroying infrastructure we just give up. + handleCancelled := func() { - select { - case <-runner.CancelledCtx.Done(): + states := make(map[*moduletest.Run]*states.State) + states[nil] = mgr.State + for _, module := range mgr.States { + states[module.Run] = module.State + } + runner.View.FatalInterruptSummary(run, file, states, created) - // If the user still really wants to cancel, then we'll oblige - // even during the destroy mode at this point. - if opts.Mode == plans.DestroyMode { - cancelled = true - go ctx.Stop() - } + cancelled = true + go ctx.Stop() - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Terraform Test Interrupted", - fmt.Sprintf("Terraform test was interrupted while executing %s. This means resources that were created during the test may have been left active, please monitor the rest of the output closely as any dangling resources will be listed.", identifier))) - - // It is actually quite disastrous if we exist early at this - // point as it means we'll have created resources that we - // haven't tracked at all. So for now, we won't ever actually - // forcibly terminate the test. When cancelled, we make the - // clean up faster by not performing it but we should still - // always manage it give an accurate list of resources left - // alive. - // TODO(liamcervante): Consider adding a timer here, so that we - // exit early even if that means some resources are just lost - // forever. - <-runningCtx.Done() // Just wait for things to finish now. + // Just wait for things to finish now, the overall test execution will + // exit early if this takes too long. + <-runningCtx.Done() + } + // This function handles what happens when the user presses the first + // interrupt. This is essentially a "soft cancel", we're not going to do + // anything but just wait for things to finish safely. But, we do listen + // for the crucial second interrupt which will prompt a hard stop / cancel. + handleStopped := func() { + select { + case <-runner.CancelledCtx.Done(): + // We've been asked again. This time we stop whatever we're doing + // and abandon all attempts to do anything reasonable. + handleCancelled() case <-runningCtx.Done(): - // The operation exited nicely when asked! + // Do nothing, we finished safely and skipping the remaining tests + // will be handled elsewhere. } - case <-runner.CancelledCtx.Done(): - // This shouldn't really happen, as we'd expect to see the StoppedCtx - // being triggered first. But, just in case. - cancelled = true - go ctx.Stop() - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Error, - "Terraform Test Interrupted", - fmt.Sprintf("Terraform test was interrupted while executing %s. This means resources that were created during the test may have been left active, please monitor the rest of the output closely as any dangling resources will be listed.", identifier))) - - // It is actually quite disastrous if we exist early at this - // point as it means we'll have created resources that we - // haven't tracked at all. So for now, we won't ever actually - // forcibly terminate the test. When cancelled, we make the - // clean up faster by not performing it but we should still - // always manage it give an accurate list of resources left - // alive. - // TODO(liamcervante): Consider adding a timer here, so that we - // exit early even if that means some resources are just lost - // forever. - <-runningCtx.Done() // Just wait for things to finish now. + } + select { + case <-runner.StoppedCtx.Done(): + handleStopped() + case <-runner.CancelledCtx.Done(): + handleCancelled() case <-runningCtx.Done(): // The operation exited normally. } @@ -675,27 +681,21 @@ type TestModuleState struct { func (manager *TestStateManager) cleanupStates(file *moduletest.File, globals map[string]backend.UnparsedVariableValue) { if manager.runner.Cancelled { - - // We are still going to print out the resources that we have left - // even though the user asked for an immediate exit. - - var diags tfdiags.Diagnostics - diags = diags.Append(tfdiags.Sourceless(tfdiags.Error, "Test cleanup skipped due to immediate exit", "Terraform could not clean up the state left behind due to immediate interrupt.")) - manager.runner.View.DestroySummary(diags, nil, file, manager.State) - - for _, module := range manager.States { - manager.runner.View.DestroySummary(diags, module.Run, file, module.State) - } - + // Don't try and clean anything up if the execution has been cancelled. return } // First, we'll clean up the main state. - _, _, state, diags := manager.runner.execute(nil, file, manager.runner.Config, manager.State, &terraform.PlanOpts{ + _, _, state, diags := manager.runner.execute(manager, nil, file, manager.runner.Config, manager.State, &terraform.PlanOpts{ Mode: plans.DestroyMode, }, configs.ApplyTestCommand, globals) manager.runner.View.DestroySummary(diags, nil, file, state) + if manager.runner.Cancelled { + // In case things were cancelled during the last execution. + return + } + // Then we'll clean up the additional states for custom modules in reverse // order. for ix := len(manager.States); ix > 0; ix-- { @@ -704,11 +704,10 @@ func (manager *TestStateManager) cleanupStates(file *moduletest.File, globals ma if manager.runner.Cancelled { // In case the cancellation came while a previous state was being // destroyed. - manager.runner.View.DestroySummary(diags, module.Run, file, module.State) - continue + return } - _, _, state, diags := manager.runner.execute(module.Run, file, module.Run.Config.ConfigUnderTest, module.State, &terraform.PlanOpts{ + _, _, state, diags := manager.runner.execute(manager, module.Run, file, module.Run.Config.ConfigUnderTest, module.State, &terraform.PlanOpts{ Mode: plans.DestroyMode, }, configs.ApplyTestCommand, globals) manager.runner.View.DestroySummary(diags, module.Run, file, state) diff --git a/internal/command/test_test.go b/internal/command/test_test.go index d234ea31d21c..43ba67a4c200 100644 --- a/internal/command/test_test.go +++ b/internal/command/test_test.go @@ -211,6 +211,19 @@ func TestTest_DoubleInterrupt(t *testing.T) { t.Errorf("output didn't produce the right output:\n\n%s", output) } + cleanupMessage := `Terraform was interrupted while executing main.tftest, and may not have performed the expected cleanup operations. + +Terraform has already created the following resources from the module under test: + - test_resource.primary + - test_resource.secondary + - test_resource.tertiary` + + // It's really important that the above message is printed, so we're testing + // for it specifically and making sure it contains all the resources. + if !strings.Contains(output, cleanupMessage) { + t.Errorf("output didn't produce the right output:\n\n%s", output) + } + // This time the test command shouldn't have cleaned up the resource because // of the hard interrupt. if provider.ResourceCount() != 3 { diff --git a/internal/command/testing/test_provider.go b/internal/command/testing/test_provider.go index 351c4f7bb6a9..124812ffa98c 100644 --- a/internal/command/testing/test_provider.go +++ b/internal/command/testing/test_provider.go @@ -4,6 +4,7 @@ import ( "fmt" "path" "strings" + "time" "github.com/hashicorp/go-uuid" "github.com/zclconf/go-cty/cty" @@ -217,6 +218,11 @@ func (provider *TestProvider) ApplyResourceChange(request providers.ApplyResourc for ix := 0; ix < int(count); ix++ { provider.Interrupt <- struct{}{} } + + // Wait for a second to make sure the interrupts are processed by + // Terraform before the provider finishes. This is an attempt to ensure + // the output of any tests that rely on this behaviour is deterministic. + time.Sleep(time.Second) } provider.Store.Put(provider.GetResourceKey(id.AsString()), resource) diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index 216345499467..581cb7f4837e 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -30,11 +30,12 @@ const ( MessageRefreshComplete MessageType = "refresh_complete" // Test messages - MessageTestAbstract MessageType = "test_abstract" - MessageTestFile MessageType = "test_file" - MessageTestRun MessageType = "test_run" - MessageTestPlan MessageType = "test_plan" - MessageTestState MessageType = "test_state" - MessageTestSummary MessageType = "test_summary" - MessageTestCleanup MessageType = "test_cleanup" + MessageTestAbstract MessageType = "test_abstract" + MessageTestFile MessageType = "test_file" + MessageTestRun MessageType = "test_run" + MessageTestPlan MessageType = "test_plan" + MessageTestState MessageType = "test_state" + MessageTestSummary MessageType = "test_summary" + MessageTestCleanup MessageType = "test_cleanup" + MessageTestInterrupt MessageType = "test_interrupt" ) diff --git a/internal/command/views/json/test.go b/internal/command/views/json/test.go index b4e4369cbb26..276bee3d22f3 100644 --- a/internal/command/views/json/test.go +++ b/internal/command/views/json/test.go @@ -38,6 +38,12 @@ type TestFailedResource struct { DeposedKey string `json:"deposed_key,omitempty"` } +type TestFatalInterrupt struct { + State []TestFailedResource `json:"state,omitempty"` + States map[string][]TestFailedResource `json:"states,omitempty"` + Planned []string `json:"planned,omitempty"` +} + func ToTestStatus(status moduletest.Status) TestStatus { return TestStatus(strings.ToLower(status.String())) } diff --git a/internal/command/views/test.go b/internal/command/views/test.go index 769f0a98b304..0eec46b26e8c 100644 --- a/internal/command/views/test.go +++ b/internal/command/views/test.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform/internal/command/views/json" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/moduletest" + "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/terraform" @@ -53,6 +54,16 @@ type Test interface { // FatalInterrupt prints out a message stating that a hard interrupt has // been received and testing will stop and cleanup will be skipped. FatalInterrupt() + + // FatalInterruptSummary prints out the resources that were held in state + // and were being created at the time the FatalInterrupt was received. + // + // This will typically be called in place of DestroySummary, as there is no + // guarantee that this function will be called during a FatalInterrupt. In + // addition, this function prints additional details about the current + // operation alongside the current state as the state will be missing newly + // created resources that also need to be handled manually. + FatalInterruptSummary(run *moduletest.Run, file *moduletest.File, states map[*moduletest.Run]*states.State, created []*plans.ResourceInstanceChangeSrc) } func NewTest(vt arguments.ViewType, view *View) Test { @@ -221,11 +232,65 @@ func (t *TestHuman) Diagnostics(_ *moduletest.Run, _ *moduletest.File, diags tfd } func (t *TestHuman) Interrupted() { - t.view.streams.Print(interrupted) + t.view.streams.Eprint(interrupted) } func (t *TestHuman) FatalInterrupt() { - t.view.streams.Print(fatalInterrupt) + t.view.streams.Eprint(fatalInterrupt) +} + +func (t *TestHuman) FatalInterruptSummary(run *moduletest.Run, file *moduletest.File, existingStates map[*moduletest.Run]*states.State, created []*plans.ResourceInstanceChangeSrc) { + t.view.streams.Eprintf("\nTerraform was interrupted while executing %s, and may not have performed the expected cleanup operations.\n", file.Name) + + for run, state := range existingStates { + if state.Empty() { + // Then it's fine, don't worry about it. + continue + } + + if run == nil { + // Then this is just the main state for the whole file. + t.view.streams.Eprintln("\nTerraform has already created the following resources from the module under test:") + for _, resource := range state.AllResourceInstanceObjectAddrs() { + if resource.DeposedKey != states.NotDeposed { + t.view.streams.Eprintf(" - %s (%s)\n", resource.Instance, resource.DeposedKey) + continue + } + t.view.streams.Eprintf(" - %s\n", resource.Instance) + } + } else { + t.view.streams.Eprintf("\nTerraform has already created the following resources for %s from %s:\n", run.Name, run.Config.Module.Source) + for _, resource := range state.AllResourceInstanceObjectAddrs() { + if resource.DeposedKey != states.NotDeposed { + t.view.streams.Eprintf(" - %s (%s)\n", resource.Instance, resource.DeposedKey) + continue + } + t.view.streams.Eprintf(" - %s\n", resource.Instance) + } + } + } + + if len(created) == 0 { + // No planned changes, so we won't print anything. + return + } + + var resources []string + for _, change := range created { + resources = append(resources, change.Addr.String()) + } + + if len(resources) > 0 { + module := "the module under test" + if run.Config.ConfigUnderTest != nil { + module = run.Config.Module.Source.String() + } + + t.view.streams.Eprintf("\nTerraform was in the process of creating the following resources for %s from %s, and they may not have been destroyed:\n", run.Name, module) + for _, resource := range resources { + t.view.streams.Eprintf(" - %s\n", resource) + } + } } type TestJSON struct { @@ -422,6 +487,50 @@ func (t *TestJSON) FatalInterrupt() { t.view.Log(fatalInterrupt) } +func (t *TestJSON) FatalInterruptSummary(run *moduletest.Run, file *moduletest.File, existingStates map[*moduletest.Run]*states.State, created []*plans.ResourceInstanceChangeSrc) { + + message := json.TestFatalInterrupt{ + States: make(map[string][]json.TestFailedResource), + } + + for run, state := range existingStates { + if state.Empty() { + continue + } + + var resources []json.TestFailedResource + for _, resource := range state.AllResourceInstanceObjectAddrs() { + resources = append(resources, json.TestFailedResource{ + Instance: resource.Instance.String(), + DeposedKey: resource.DeposedKey.String(), + }) + } + + if run == nil { + message.State = resources + } else { + message.States[run.Name] = resources + } + } + + if len(created) > 0 { + for _, change := range created { + message.Planned = append(message.Planned, change.Addr.String()) + } + } + + if len(message.States) == 0 && len(message.State) == 0 && len(message.Planned) == 0 { + // Then we don't have any information to share with the user. + return + } + + t.view.log.Error( + "Terraform was interrupted during test execution, and may not have performed the expected cleanup operations.", + "type", json.MessageTestInterrupt, + json.MessageTestInterrupt, message, + "@testfile", file.Name) +} + func colorizeTestStatus(status moduletest.Status, color *colorstring.Colorize) string { switch status { case moduletest.Error, moduletest.Fail: diff --git a/internal/command/views/test_test.go b/internal/command/views/test_test.go index fb1b89b9e0d0..27160c3fb274 100644 --- a/internal/command/views/test_test.go +++ b/internal/command/views/test_test.go @@ -939,6 +939,285 @@ Terraform left the following resources in state after executing main.tftest, the } } +func TestTestHuman_FatalInterruptSummary(t *testing.T) { + tcs := map[string]struct { + states map[*moduletest.Run]*states.State + run *moduletest.Run + created []*plans.ResourceInstanceChangeSrc + want string + }{ + "no_state_only_plan": { + states: make(map[*moduletest.Run]*states.State), + run: &moduletest.Run{ + Config: &configs.TestRun{}, + Name: "run_block", + }, + created: []*plans.ResourceInstanceChangeSrc{ + { + Addr: addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "one", + }, + }, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + }, + }, + { + Addr: addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "two", + }, + }, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + }, + }, + }, + want: ` +Terraform was interrupted while executing main.tftest, and may not have performed the expected cleanup operations. + +Terraform was in the process of creating the following resources for run_block from the module under test, and they may not have been destroyed: + - test_instance.one + - test_instance.two +`, + }, + "file_state_no_plan": { + states: map[*moduletest.Run]*states.State{ + nil: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "one", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "two", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + }), + }, + created: nil, + want: ` +Terraform was interrupted while executing main.tftest, and may not have performed the expected cleanup operations. + +Terraform has already created the following resources from the module under test: + - test_instance.one + - test_instance.two +`, + }, + "run_states_no_plan": { + states: map[*moduletest.Run]*states.State{ + &moduletest.Run{ + Name: "setup_block", + Config: &configs.TestRun{ + Module: &configs.TestRunModuleCall{ + Source: addrs.ModuleSourceLocal("../setup"), + }, + }, + }: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "one", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "two", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + }), + }, + created: nil, + want: ` +Terraform was interrupted while executing main.tftest, and may not have performed the expected cleanup operations. + +Terraform has already created the following resources for setup_block from ../setup: + - test_instance.one + - test_instance.two +`, + }, + "all_states_with_plan": { + states: map[*moduletest.Run]*states.State{ + &moduletest.Run{ + Name: "setup_block", + Config: &configs.TestRun{ + Module: &configs.TestRunModuleCall{ + Source: addrs.ModuleSourceLocal("../setup"), + }, + }, + }: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "setup_one", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "setup_two", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + }), + nil: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "one", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "two", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + }), + }, + created: []*plans.ResourceInstanceChangeSrc{ + { + Addr: addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "new_one", + }, + }, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + }, + }, + { + Addr: addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "new_two", + }, + }, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + }, + }, + }, + run: &moduletest.Run{ + Config: &configs.TestRun{}, + Name: "run_block", + }, + want: ` +Terraform was interrupted while executing main.tftest, and may not have performed the expected cleanup operations. + +Terraform has already created the following resources for setup_block from ../setup: + - test_instance.setup_one + - test_instance.setup_two + +Terraform has already created the following resources from the module under test: + - test_instance.one + - test_instance.two + +Terraform was in the process of creating the following resources for run_block from the module under test, and they may not have been destroyed: + - test_instance.new_one + - test_instance.new_two +`, + }, + } + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewTest(arguments.ViewHuman, NewView(streams)) + + file := &moduletest.File{Name: "main.tftest"} + + view.FatalInterruptSummary(tc.run, file, tc.states, tc.created) + actual, expected := done(t).Stderr(), tc.want + if diff := cmp.Diff(expected, actual); len(diff) > 0 { + t.Errorf("expected:\n%s\nactual:\n%s\ndiff:\n%s", expected, actual, diff) + } + }) + } +} + func TestTestJSON_Abstract(t *testing.T) { tcs := map[string]struct { suite *moduletest.Suite @@ -2462,6 +2741,312 @@ func TestTestJSON_Run(t *testing.T) { } } +func TestTestJSON_FatalInterruptSummary(t *testing.T) { + tcs := map[string]struct { + states map[*moduletest.Run]*states.State + changes []*plans.ResourceInstanceChangeSrc + want []map[string]interface{} + }{ + "no_state_only_plan": { + states: make(map[*moduletest.Run]*states.State), + changes: []*plans.ResourceInstanceChangeSrc{ + { + Addr: addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "one", + }, + }, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + }, + }, + { + Addr: addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "two", + }, + }, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + }, + }, + }, + want: []map[string]interface{}{ + { + "@level": "error", + "@message": "Terraform was interrupted during test execution, and may not have performed the expected cleanup operations.", + "@module": "terraform.ui", + "@testfile": "main.tftest", + "test_interrupt": map[string]interface{}{ + "planned": []interface{}{ + "test_instance.one", + "test_instance.two", + }, + }, + "type": "test_interrupt", + }, + }, + }, + "file_state_no_plan": { + states: map[*moduletest.Run]*states.State{ + nil: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "one", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "two", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + }), + }, + changes: nil, + want: []map[string]interface{}{ + { + "@level": "error", + "@message": "Terraform was interrupted during test execution, and may not have performed the expected cleanup operations.", + "@module": "terraform.ui", + "@testfile": "main.tftest", + "test_interrupt": map[string]interface{}{ + "state": []interface{}{ + map[string]interface{}{ + "instance": "test_instance.one", + }, + map[string]interface{}{ + "instance": "test_instance.two", + }, + }, + }, + "type": "test_interrupt", + }, + }, + }, + "run_states_no_plan": { + states: map[*moduletest.Run]*states.State{ + &moduletest.Run{Name: "setup_block"}: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "one", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "two", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + }), + }, + changes: nil, + want: []map[string]interface{}{ + { + "@level": "error", + "@message": "Terraform was interrupted during test execution, and may not have performed the expected cleanup operations.", + "@module": "terraform.ui", + "@testfile": "main.tftest", + "test_interrupt": map[string]interface{}{ + "states": map[string]interface{}{ + "setup_block": []interface{}{ + map[string]interface{}{ + "instance": "test_instance.one", + }, + map[string]interface{}{ + "instance": "test_instance.two", + }, + }, + }, + }, + "type": "test_interrupt", + }, + }, + }, + "all_states_with_plan": { + states: map[*moduletest.Run]*states.State{ + &moduletest.Run{Name: "setup_block"}: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "setup_one", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "setup_two", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + }), + nil: states.BuildState(func(state *states.SyncState) { + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "one", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + + state.SetResourceInstanceCurrent( + addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "two", + }, + }, + }, + &states.ResourceInstanceObjectSrc{}, + addrs.AbsProviderConfig{}) + }), + }, + changes: []*plans.ResourceInstanceChangeSrc{ + { + Addr: addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "new_one", + }, + }, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + }, + }, + { + Addr: addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "new_two", + }, + }, + }, + ChangeSrc: plans.ChangeSrc{ + Action: plans.Create, + }, + }, + }, + want: []map[string]interface{}{ + { + "@level": "error", + "@message": "Terraform was interrupted during test execution, and may not have performed the expected cleanup operations.", + "@module": "terraform.ui", + "@testfile": "main.tftest", + "test_interrupt": map[string]interface{}{ + "state": []interface{}{ + map[string]interface{}{ + "instance": "test_instance.one", + }, + map[string]interface{}{ + "instance": "test_instance.two", + }, + }, + "states": map[string]interface{}{ + "setup_block": []interface{}{ + map[string]interface{}{ + "instance": "test_instance.setup_one", + }, + map[string]interface{}{ + "instance": "test_instance.setup_two", + }, + }, + }, + "planned": []interface{}{ + "test_instance.new_one", + "test_instance.new_two", + }, + }, + "type": "test_interrupt", + }, + }, + }, + } + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewTest(arguments.ViewJSON, NewView(streams)) + + file := &moduletest.File{Name: "main.tftest"} + run := &moduletest.Run{Name: "run_block"} + + view.FatalInterruptSummary(run, file, tc.states, tc.changes) + testJSONViewOutputEquals(t, done(t).All(), tc.want) + }) + } +} + func dynamicValue(t *testing.T, value cty.Value, typ cty.Type) plans.DynamicValue { d, err := plans.NewDynamicValue(value, typ) if err != nil {