Issue 248: left-over resources after tofu test should be written to a file (#1243)

Signed-off-by: kazzhar <karthik.nayak@harness.io>
This commit is contained in:
Karthik Nayak 2024-03-12 19:59:06 +05:30 committed by GitHub
parent 670f2515c3
commit 311b5c37b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 562 additions and 10 deletions

View File

@ -980,6 +980,9 @@ func (runner *TestFileRunner) Cleanup(file *moduletest.File) {
}
runner.Suite.View.DestroySummary(diags, state.Run, file, updated)
if updated.HasManagedResourceInstanceObjects() {
views.SaveErroredTestStateFile(updated, state.Run, file, runner.Suite.View)
}
reset()
}
}

View File

@ -19,10 +19,12 @@ import (
"github.com/opentofu/opentofu/internal/command/jsonstate"
"github.com/opentofu/opentofu/internal/command/views/json"
"github.com/opentofu/opentofu/internal/configs"
"github.com/opentofu/opentofu/internal/encryption"
"github.com/opentofu/opentofu/internal/moduletest"
"github.com/opentofu/opentofu/internal/plans"
"github.com/opentofu/opentofu/internal/states"
"github.com/opentofu/opentofu/internal/states/statefile"
"github.com/opentofu/opentofu/internal/states/statemgr"
"github.com/opentofu/opentofu/internal/tfdiags"
"github.com/opentofu/opentofu/internal/tofu"
)
@ -223,7 +225,7 @@ func (t *TestHuman) DestroySummary(diags tfdiags.Diagnostics, run *moduletest.Ru
t.Diagnostics(run, file, diags)
if state.HasManagedResourceInstanceObjects() {
t.view.streams.Eprint(format.WordWrap(fmt.Sprintf("\nOpenTofu left the following resources in state after executing %s, and they need to be cleaned up manually:\n", identifier), t.view.errorColumns()))
t.view.streams.Eprint(format.WordWrap(fmt.Sprintf("\nOpenTofu left the following resources in state after executing %s, these left-over resources can be viewed by reading the statefile written to disk(errored_test.tfstate) and they need to be cleaned up manually:\n", identifier), t.view.errorColumns()))
for _, resource := range state.AllResourceInstanceObjectAddrs() {
if resource.DeposedKey != states.NotDeposed {
t.view.streams.Eprintf(" - %s (%s)\n", resource.Instance, resource.DeposedKey)
@ -460,21 +462,19 @@ func (t *TestJSON) DestroySummary(diags tfdiags.Diagnostics, run *moduletest.Run
if run != nil {
t.view.log.Error(
fmt.Sprintf("OpenTofu left some resources in state after executing %s/%s, they need to be cleaned up manually.", file.Name, run.Name),
fmt.Sprintf("OpenTofu left some resources in state after executing %s/%s, these left-over resources can be viewed by reading the statefile written to disk(errored_test.tfstate) and they need to be cleaned up manually:", file.Name, run.Name),
"type", json.MessageTestCleanup,
json.MessageTestCleanup, cleanup,
"@testfile", file.Name,
"@testrun", run.Name)
} else {
t.view.log.Error(
fmt.Sprintf("OpenTofu left some resources in state after executing %s, they need to be cleaned up manually.", file.Name),
fmt.Sprintf("OpenTofu left some resources in state after executing %s, these left-over resources can be viewed by reading the statefile written to disk(errored_test.tfstate) and they need to be cleaned up manually:", file.Name),
"type", json.MessageTestCleanup,
json.MessageTestCleanup, cleanup,
"@testfile", file.Name)
}
}
t.Diagnostics(run, file, diags)
}
@ -570,3 +570,61 @@ func testStatus(status moduletest.Status) string {
panic("unrecognized status: " + status.String())
}
}
// SaveErroredTestStateFile is a helper function to invoked in DestorySummary
// to store the state to errored_test.tfstate and handle associated diagnostics and errors with this operation
func SaveErroredTestStateFile(state *states.State, run *moduletest.Run, file *moduletest.File, view Test) {
var diags tfdiags.Diagnostics
localFileSystem := statemgr.NewFilesystem("errored_test.tfstate", encryption.StateEncryptionDisabled())
stateFile := statemgr.NewStateFile()
stateFile.State = state
//creating an operation to invoke EmergencyDumpState()
var op Operation
switch v := view.(type) {
case *TestHuman:
op = NewOperation(arguments.ViewHuman, false, v.view)
v.view.streams.Eprint(format.WordWrap("\nWriting state to file: errored_test.tfstate\n", v.view.errorColumns()))
case *TestJSON:
op = &OperationJSON{
view: v.view,
}
v.view.log.Info("Writing state to file: errored_test.tfstate")
default:
}
writeErr := localFileSystem.WriteStateForMigration(stateFile, true)
if writeErr != nil {
// if the write operation to errored_test.tfstate executed by WriteStateForMigration fails, as a final attempt to
// prevent leaving the user with no state file at all, the JSON state is printed onto the terminal by EmergencyDumpState()
if dumpErr := op.EmergencyDumpState(stateFile, encryption.StateEncryptionDisabled()); dumpErr != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to serialize state",
fmt.Sprintf(stateWriteFatalErrorFmt, dumpErr),
))
}
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to persist state",
stateWriteConsoleFallbackError,
))
}
view.Diagnostics(run, file, diags)
}
const stateWriteFatalErrorFmt = `Failed to save state after an errored test run.
Error serializing state: %s
A catastrophic error has prevented OpenTofu from persisting the state during an errored test run.
This is a serious bug in OpenTofu and should be reported.
`
const stateWriteConsoleFallbackError = `The errors shown above prevented OpenTofu from writing the state to
the errored_test.tfstate. As a fallback, the raw state data is printed above as a JSON object.
To retry writing this state, copy the state data (from the first { to the last } inclusive) and save it into a local file named "errored_test.tfstate".
`

View File

@ -6,6 +6,8 @@
package views
import (
"os"
"path/filepath"
"strings"
"testing"
@ -849,7 +851,9 @@ some thing not very bad happened again
`,
stderr: `
OpenTofu left the following resources in state after executing
main.tftest.hcl, and they need to be cleaned up manually:
main.tftest.hcl, these left-over resources can be viewed by reading the
statefile written to disk(errored_test.tfstate) and they need to be cleaned
up manually:
- test.bar
- test.bar (0fcb640a)
- test.foo
@ -921,10 +925,82 @@ Error: first error
this time it is very bad
OpenTofu left the following resources in state after executing
main.tftest.hcl, and they need to be cleaned up manually:
main.tftest.hcl, these left-over resources can be viewed by reading the
statefile written to disk(errored_test.tfstate) and they need to be cleaned
up manually:
- test.bar
- test.bar (0fcb640a)
- test.foo
`,
},
"state_null_resource_with_errors": {
diags: tfdiags.Diagnostics{
tfdiags.Sourceless(tfdiags.Warning, "first warning", "some thing not very bad happened"),
tfdiags.Sourceless(tfdiags.Warning, "second warning", "some thing not very bad happened again"),
tfdiags.Sourceless(tfdiags.Error, "first error", "this time it is very bad"),
},
file: &moduletest.File{Name: "main.tftest.hcl"},
state: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "null_resource",
Name: "failing_will_depend_on_me",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("null"),
})
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "null_resource",
Name: "failing",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
Dependencies: []addrs.ConfigResource{
{
Module: []string{},
Resource: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "null_resource",
Name: "failing_will_depend_on_me",
},
},
},
CreateBeforeDestroy: false,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("null"),
})
}),
stdout: `
Warning: first warning
some thing not very bad happened
Warning: second warning
some thing not very bad happened again
`,
stderr: `OpenTofu encountered an error destroying resources created while executing
main.tftest.hcl.
Error: first error
this time it is very bad
OpenTofu left the following resources in state after executing
main.tftest.hcl, these left-over resources can be viewed by reading the
statefile written to disk(errored_test.tfstate) and they need to be cleaned
up manually:
- null_resource.failing
- null_resource.failing_will_depend_on_me
`,
},
}
@ -1949,7 +2025,7 @@ func TestTestJSON_DestroySummary(t *testing.T) {
want: []map[string]interface{}{
{
"@level": "error",
"@message": "OpenTofu left some resources in state after executing main.tftest.hcl/run_block, they need to be cleaned up manually.",
"@message": "OpenTofu left some resources in state after executing main.tftest.hcl/run_block, these left-over resources can be viewed by reading the statefile written to disk(errored_test.tfstate) and they need to be cleaned up manually:",
"@module": "tofu.ui",
"@testfile": "main.tftest.hcl",
"@testrun": "run_block",
@ -2015,7 +2091,7 @@ func TestTestJSON_DestroySummary(t *testing.T) {
want: []map[string]interface{}{
{
"@level": "error",
"@message": "OpenTofu left some resources in state after executing main.tftest.hcl, they need to be cleaned up manually.",
"@message": "OpenTofu left some resources in state after executing main.tftest.hcl, these left-over resources can be viewed by reading the statefile written to disk(errored_test.tfstate) and they need to be cleaned up manually:",
"@module": "tofu.ui",
"@testfile": "main.tftest.hcl",
"test_cleanup": map[string]interface{}{
@ -2112,7 +2188,7 @@ func TestTestJSON_DestroySummary(t *testing.T) {
want: []map[string]interface{}{
{
"@level": "error",
"@message": "OpenTofu left some resources in state after executing main.tftest.hcl, they need to be cleaned up manually.",
"@message": "OpenTofu left some resources in state after executing main.tftest.hcl, these left-over resources can be viewed by reading the statefile written to disk(errored_test.tfstate) and they need to be cleaned up manually:",
"@module": "tofu.ui",
"@testfile": "main.tftest.hcl",
"test_cleanup": map[string]interface{}{
@ -2169,6 +2245,107 @@ func TestTestJSON_DestroySummary(t *testing.T) {
},
},
},
"state_null_resource_with_errors": {
diags: tfdiags.Diagnostics{
tfdiags.Sourceless(tfdiags.Warning, "first warning", "something not very bad happened"),
tfdiags.Sourceless(tfdiags.Warning, "second warning", "something not very bad happened again"),
tfdiags.Sourceless(tfdiags.Error, "first error", "this time it is very bad"),
},
file: &moduletest.File{Name: "main.tftest.hcl"},
state: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "null_resource",
Name: "failing_will_depend_on_me",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("null"),
})
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "null_resource",
Name: "failing",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
Dependencies: []addrs.ConfigResource{
{
Module: []string{},
Resource: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "null_resource",
Name: "failing_will_depend_on_me",
},
},
},
CreateBeforeDestroy: false,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("null"),
})
}), want: []map[string]interface{}{
{
"@level": "error",
"@message": "OpenTofu left some resources in state after executing main.tftest.hcl, these left-over resources can be viewed by reading the statefile written to disk(errored_test.tfstate) and they need to be cleaned up manually:",
"@module": "tofu.ui",
"@testfile": "main.tftest.hcl",
"test_cleanup": map[string]interface{}{
"failed_resources": []interface{}{
map[string]interface{}{
"instance": "null_resource.failing",
},
map[string]interface{}{
"instance": "null_resource.failing_will_depend_on_me",
},
},
},
"type": "test_cleanup",
},
{
"@level": "warn",
"@message": "Warning: first warning",
"@module": "tofu.ui",
"@testfile": "main.tftest.hcl",
"diagnostic": map[string]interface{}{
"detail": "something not very bad happened",
"severity": "warning",
"summary": "first warning",
},
"type": "diagnostic",
},
{
"@level": "warn",
"@message": "Warning: second warning",
"@module": "tofu.ui",
"@testfile": "main.tftest.hcl",
"diagnostic": map[string]interface{}{
"detail": "something not very bad happened again",
"severity": "warning",
"summary": "second warning",
},
"type": "diagnostic",
},
{
"@level": "error",
"@message": "Error: first error",
"@module": "tofu.ui",
"@testfile": "main.tftest.hcl",
"diagnostic": map[string]interface{}{
"detail": "this time it is very bad",
"severity": "error",
"summary": "first error",
},
"type": "diagnostic",
},
},
},
}
for name, tc := range tcs {
t.Run(name, func(t *testing.T) {
@ -3078,6 +3255,320 @@ func TestTestJSON_FatalInterruptSummary(t *testing.T) {
}
}
func TestSaveErroredStateFile(t *testing.T) {
tcsHuman := map[string]struct {
state *states.State
run *moduletest.Run
file *moduletest.File
stderr string
want interface{}
}{
"state_foo_bar_human": {
file: &moduletest.File{Name: "main.tftest.hcl"},
state: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
})
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test",
Name: "bar",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
})
state.SetResourceInstanceDeposed(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test",
Name: "bar",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
"0fcb640a",
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
})
}),
stderr: `
Writing state to file: errored_test.tfstate
`,
want: nil,
},
"state_null_resource_human": {
file: &moduletest.File{Name: "main.tftest.hcl"},
state: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "null_resource",
Name: "failing_will_depend_on_me",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("null"),
})
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "null_resource",
Name: "failing",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
Dependencies: []addrs.ConfigResource{
{
Module: []string{},
Resource: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "null_resource",
Name: "failing_will_depend_on_me",
},
},
},
CreateBeforeDestroy: false,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("null"),
})
}),
stderr: `
Writing state to file: errored_test.tfstate
`,
want: nil,
},
}
tcsJson := map[string]struct {
state *states.State
run *moduletest.Run
file *moduletest.File
stderr string
want interface{}
}{
"state_with_run_json": {
file: &moduletest.File{Name: "main.tftest.hcl"},
run: &moduletest.Run{Name: "run_block"},
state: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
})
}),
stderr: "",
want: []map[string]interface{}{
{
"@level": "info",
"@message": "Writing state to file: errored_test.tfstate",
"@module": string("tofu.ui"),
},
},
},
"state_foo_bar_json": {
file: &moduletest.File{Name: "main.tftest.hcl"},
state: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
})
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test",
Name: "bar",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
})
state.SetResourceInstanceDeposed(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test",
Name: "bar",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
"0fcb640a",
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("test"),
})
}),
stderr: "",
want: []map[string]interface{}{
{
"@level": "info",
"@message": "Writing state to file: errored_test.tfstate",
"@module": "tofu.ui",
},
},
},
"state_null_resource_with_errors": {
file: &moduletest.File{Name: "main.tftest.hcl"},
state: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "null_resource",
Name: "failing_will_depend_on_me",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("null"),
})
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "null_resource",
Name: "failing",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
Dependencies: []addrs.ConfigResource{
{
Module: []string{},
Resource: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "null_resource",
Name: "failing_will_depend_on_me",
},
},
},
CreateBeforeDestroy: false,
},
addrs.AbsProviderConfig{
Module: addrs.RootModule,
Provider: addrs.NewDefaultProvider("null"),
})
}),
stderr: "",
want: []map[string]interface{}{
{
"@level": "info",
"@message": "Writing state to file: errored_test.tfstate",
"@module": "tofu.ui",
},
},
},
}
// Run tests for Human view
runTestSaveErroredStateFile(t, tcsHuman, arguments.ViewHuman)
// Run tests for JSON view
runTestSaveErroredStateFile(t, tcsJson, arguments.ViewJSON)
}
func runTestSaveErroredStateFile(t *testing.T, tc map[string]struct {
state *states.State
run *moduletest.Run
file *moduletest.File
stderr string
want interface{}
}, viewType arguments.ViewType) {
for name, data := range tc {
t.Run(name, func(t *testing.T) {
// Create a temporary directory
tempDir := t.TempDir()
// Modify the state file path to use the temporary directory
tempStateFilePath := filepath.Clean(filepath.Join(tempDir, "errored_test.tfstate"))
// Get the current working directory
originalDir, err := os.Getwd()
if err != nil {
t.Fatalf("Error getting current working directory: %v", err)
}
// Change the working directory to the temporary directory
if err := os.Chdir(tempDir); err != nil {
t.Fatalf("Error changing working directory: %v", err)
}
defer func() {
// Change the working directory back to the original directory after the test
if err := os.Chdir(originalDir); err != nil {
t.Fatalf("Error changing working directory back: %v", err)
}
}()
streams, done := terminal.StreamsForTesting(t)
if viewType == arguments.ViewHuman {
view := NewTest(arguments.ViewHuman, NewView(streams))
SaveErroredTestStateFile(data.state, data.run, data.file, view)
output := done(t)
actual, expected := output.Stderr(), data.stderr
if diff := cmp.Diff(expected, actual); len(diff) > 0 {
t.Errorf("expected:\n%s\nactual:\n%s\ndiff:\n%s", expected, actual, diff)
}
} else if viewType == arguments.ViewJSON {
view := NewTest(arguments.ViewJSON, NewView(streams))
SaveErroredTestStateFile(data.state, data.run, data.file, view)
want, ok := data.want.([]map[string]interface{})
if !ok {
t.Fatalf("Failed to assert want as []map[string]interface{}")
}
testJSONViewOutputEquals(t, done(t).All(), want)
} else {
t.Fatalf("Unsupported view type: %v", viewType)
}
// Check if the state file exists
if _, err := os.Stat(tempStateFilePath); os.IsNotExist(err) {
// File does not exist
t.Errorf("Expected state file 'errored_test.tfstate' to exist in: %s, but it does not.", tempDir)
}
})
}
}
func dynamicValue(t *testing.T, value cty.Value, typ cty.Type) plans.DynamicValue {
d, err := plans.NewDynamicValue(value, typ)
if err != nil {