From bee7403f3ef64a38c4d0730157b4154eb9a09a12 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Wed, 13 Oct 2021 12:21:23 -0700 Subject: [PATCH] command/workspace_delete: Allow deleting a workspace with empty husks Previously we would reject attempts to delete a workspace if its state contained any resources at all, even if none of the resources had any resource instance objects associated with it. Nowadays there isn't any situation where the normal Terraform workflow will leave behind resource husks, and so this isn't as problematic as it might've been in the v0.12 era, but nonetheless what we actually care about for this check is whether there might be any remote objects that this state is tracking, and for that it's more precise to look for non-nil resource instance objects, rather than whole resources. This also includes some adjustments to our error messaging to give more information about the problem and to use terminology more consistent with how we currently talk about this situation in our documentation and elsewhere in the UI. We were also using the old State.HasResources method as part of some of our tests. I considered preserving it to avoid changing the behavior of those tests, but the new check seemed close enough to the intent of those tests that it wasn't worth maintaining this method that wouldn't be used in any main code anymore. I've therefore updated those tests to use the new HasResourceInstanceObjects method instead. --- internal/backend/local/backend_refresh.go | 4 +- internal/backend/remote/backend_apply_test.go | 6 +- internal/backend/testing.go | 12 +- internal/command/workspace_command.go | 9 -- internal/command/workspace_command_test.go | 13 +- internal/command/workspace_delete.go | 25 +++- internal/states/state.go | 90 ++++++++++++- internal/states/state_equal.go | 4 +- internal/states/state_test.go | 119 ++++++++++++++++++ internal/terraform/context_apply_test.go | 4 +- internal/terraform/node_data_destroy_test.go | 2 +- internal/terraform/transform_state.go | 4 +- 12 files changed, 255 insertions(+), 37 deletions(-) diff --git a/internal/backend/local/backend_refresh.go b/internal/backend/local/backend_refresh.go index 988a8b8f3..ddbedaf19 100644 --- a/internal/backend/local/backend_refresh.go +++ b/internal/backend/local/backend_refresh.go @@ -64,11 +64,11 @@ func (b *Local) opRefresh( // If we succeed then we'll overwrite this with the resulting state below, // but otherwise the resulting state is just the input state. runningOp.State = lr.InputState - if !runningOp.State.HasResources() { + if !runningOp.State.HasManagedResourceInstanceObjects() { diags = diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Empty or non-existent state", - "There are currently no resources tracked in the state, so there is nothing to refresh.", + "There are currently no remote objects tracked in the state, so there is nothing to refresh.", )) } diff --git a/internal/backend/remote/backend_apply_test.go b/internal/backend/remote/backend_apply_test.go index 71c819b9e..e15fe0bb3 100644 --- a/internal/backend/remote/backend_apply_test.go +++ b/internal/backend/remote/backend_apply_test.go @@ -1008,7 +1008,7 @@ func TestRemote_applyForceLocal(t *testing.T) { if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { t.Fatalf("expected plan summary in output: %s", output) } - if !run.State.HasResources() { + if !run.State.HasManagedResourceInstanceObjects() { t.Fatalf("expected resources in state") } } @@ -1071,7 +1071,7 @@ func TestRemote_applyWorkspaceWithoutOperations(t *testing.T) { if output := done(t).Stdout(); !strings.Contains(output, "1 to add, 0 to change, 0 to destroy") { t.Fatalf("expected plan summary in output: %s", output) } - if !run.State.HasResources() { + if !run.State.HasManagedResourceInstanceObjects() { t.Fatalf("expected resources in state") } } @@ -1646,7 +1646,7 @@ func TestRemote_applyVersionCheck(t *testing.T) { output := b.CLI.(*cli.MockUi).OutputWriter.String() hasRemote := strings.Contains(output, "Running apply in the remote backend") hasSummary := strings.Contains(output, "1 added, 0 changed, 0 destroyed") - hasResources := run.State.HasResources() + hasResources := run.State.HasManagedResourceInstanceObjects() if !tc.forceLocal && tc.hasOperations { if !hasRemote { t.Errorf("missing remote backend header in output: %s", output) diff --git a/internal/backend/testing.go b/internal/backend/testing.go index 00a9c684e..b4a76879b 100644 --- a/internal/backend/testing.go +++ b/internal/backend/testing.go @@ -105,7 +105,7 @@ func TestBackendStates(t *testing.T, b Backend) { if err := foo.RefreshState(); err != nil { t.Fatalf("bad: %s", err) } - if v := foo.State(); v.HasResources() { + if v := foo.State(); v.HasManagedResourceInstanceObjects() { t.Fatalf("should be empty: %s", v) } @@ -116,7 +116,7 @@ func TestBackendStates(t *testing.T, b Backend) { if err := bar.RefreshState(); err != nil { t.Fatalf("bad: %s", err) } - if v := bar.State(); v.HasResources() { + if v := bar.State(); v.HasManagedResourceInstanceObjects() { t.Fatalf("should be empty: %s", v) } @@ -168,7 +168,7 @@ func TestBackendStates(t *testing.T, b Backend) { t.Fatal("error refreshing foo:", err) } fooState = foo.State() - if fooState.HasResources() { + if fooState.HasManagedResourceInstanceObjects() { t.Fatal("after writing a resource to bar, foo now has resources too") } @@ -181,7 +181,7 @@ func TestBackendStates(t *testing.T, b Backend) { t.Fatal("error refreshing foo:", err) } fooState = foo.State() - if fooState.HasResources() { + if fooState.HasManagedResourceInstanceObjects() { t.Fatal("after writing a resource to bar and re-reading foo, foo now has resources too") } @@ -194,7 +194,7 @@ func TestBackendStates(t *testing.T, b Backend) { t.Fatal("error refreshing bar:", err) } barState = bar.State() - if !barState.HasResources() { + if !barState.HasManagedResourceInstanceObjects() { t.Fatal("after writing a resource instance object to bar and re-reading it, the object has vanished") } } @@ -237,7 +237,7 @@ func TestBackendStates(t *testing.T, b Backend) { if err := foo.RefreshState(); err != nil { t.Fatalf("bad: %s", err) } - if v := foo.State(); v.HasResources() { + if v := foo.State(); v.HasManagedResourceInstanceObjects() { t.Fatalf("should be empty: %s", v) } // and delete it again diff --git a/internal/command/workspace_command.go b/internal/command/workspace_command.go index 993b2a878..a0f5f542a 100644 --- a/internal/command/workspace_command.go +++ b/internal/command/workspace_command.go @@ -82,15 +82,6 @@ for this configuration. envDeleted = `[reset][green]Deleted workspace %q!` - envNotEmpty = ` -Workspace %[1]q is not empty. - -Deleting %[1]q can result in dangling resources: resources that -exist but are no longer manageable by Terraform. Please destroy -these resources first. If you want to delete this workspace -anyway and risk dangling resources, use the '-force' flag. -` - envWarnNotEmpty = `[reset][yellow]WARNING: %q was non-empty. The resources managed by the deleted workspace may still exist, but are no longer manageable by Terraform since the state has diff --git a/internal/command/workspace_command_test.go b/internal/command/workspace_command_test.go index 92a87c504..30096cb86 100644 --- a/internal/command/workspace_command_test.go +++ b/internal/command/workspace_command_test.go @@ -391,10 +391,10 @@ func TestWorkspace_deleteWithState(t *testing.T) { // create a non-empty state originalState := &legacy.State{ Modules: []*legacy.ModuleState{ - &legacy.ModuleState{ + { Path: []string{"root"}, Resources: map[string]*legacy.ResourceState{ - "test_instance.foo": &legacy.ResourceState{ + "test_instance.foo": { Type: "test_instance", Primary: &legacy.InstanceState{ ID: "bar", @@ -414,7 +414,7 @@ func TestWorkspace_deleteWithState(t *testing.T) { t.Fatal(err) } - ui := new(cli.MockUi) + ui := cli.NewMockUi() view, _ := testView(t) delCmd := &WorkspaceDeleteCommand{ Meta: Meta{Ui: ui, View: view}, @@ -423,6 +423,13 @@ func TestWorkspace_deleteWithState(t *testing.T) { if code := delCmd.Run(args); code == 0 { t.Fatalf("expected failure without -force.\noutput: %s", ui.OutputWriter) } + gotStderr := ui.ErrorWriter.String() + if want, got := `Workspace "test" is currently tracking the following resource instances`, gotStderr; !strings.Contains(got, want) { + t.Errorf("missing expected error message\nwant substring: %s\ngot:\n%s", want, got) + } + if want, got := `- test_instance.foo`, gotStderr; !strings.Contains(got, want) { + t.Errorf("error message doesn't mention the remaining instance\nwant substring: %s\ngot:\n%s", want, got) + } ui = new(cli.MockUi) delCmd.Meta.Ui = ui diff --git a/internal/command/workspace_delete.go b/internal/command/workspace_delete.go index a29479e92..5c826d908 100644 --- a/internal/command/workspace_delete.go +++ b/internal/command/workspace_delete.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" "github.com/mitchellh/cli" "github.com/posener/complete" @@ -124,12 +125,32 @@ func (c *WorkspaceDeleteCommand) Run(args []string) int { return 1 } - hasResources := stateMgr.State().HasResources() + hasResources := stateMgr.State().HasManagedResourceInstanceObjects() if hasResources && !force { + // We'll collect a list of what's being managed here as extra context + // for the message. + var buf strings.Builder + for _, obj := range stateMgr.State().AllResourceInstanceObjectAddrs() { + if obj.DeposedKey == states.NotDeposed { + fmt.Fprintf(&buf, "\n - %s", obj.Instance.String()) + } else { + fmt.Fprintf(&buf, "\n - %s (deposed object %s)", obj.Instance.String(), obj.DeposedKey) + } + } + // We need to release the lock before exit stateLocker.Unlock() - c.Ui.Error(fmt.Sprintf(strings.TrimSpace(envNotEmpty), workspace)) + + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Workspace is not empty", + fmt.Sprintf( + "Workspace %q is currently tracking the following resource instances:%s\n\nDeleting this workspace would cause Terraform to lose track of any associated remote objects, which would then require you to delete them manually outside of Terraform. You should destroy these objects with Terraform before deleting the workspace.\n\nIf you want to delete this workspace anyway, and have Terraform forget about these managed objects, use the -force option to disable this safety check.", + workspace, buf.String(), + ), + )) + c.showDiagnostics(diags) return 1 } diff --git a/internal/states/state.go b/internal/states/state.go index 1c972d662..28d962786 100644 --- a/internal/states/state.go +++ b/internal/states/state.go @@ -151,15 +151,27 @@ func (s *State) EnsureModule(addr addrs.ModuleInstance) *Module { return ms } -// HasResources returns true if there is at least one resource (of any mode) -// present in the receiving state. -func (s *State) HasResources() bool { +// HasManagedResourceInstanceObjects returns true if there is at least one +// resource instance object (current or deposed) associated with a managed +// resource in the receiving state. +// +// A true result would suggest that just discarding this state without first +// destroying these objects could leave "dangling" objects in remote systems, +// no longer tracked by any Terraform state. +func (s *State) HasManagedResourceInstanceObjects() bool { if s == nil { return false } for _, ms := range s.Modules { - if len(ms.Resources) > 0 { - return true + for _, rs := range ms.Resources { + if rs.Addr.Resource.Mode != addrs.ManagedResourceMode { + continue + } + for _, is := range rs.Instances { + if is.Current != nil || len(is.Deposed) != 0 { + return true + } + } } } return false @@ -187,6 +199,74 @@ func (s *State) Resources(addr addrs.ConfigResource) []*Resource { return ret } +// AllManagedResourceInstanceObjectAddrs returns a set of addresses for all of +// the leaf resource instance objects associated with managed resources that +// are tracked in this state. +// +// This result is the set of objects that would be effectively "forgotten" +// (like "terraform state rm") if this state were totally discarded, such as +// by deleting a workspace. This function is intended only for reporting +// context in error messages, such as when we reject deleting a "non-empty" +// workspace as detected by s.HasManagedResourceInstanceObjects. +// +// The ordering of the result is meaningless but consistent. DeposedKey will +// be NotDeposed (the zero value of DeposedKey) for any "current" objects. +// This method is guaranteed to return at least one item if +// s.HasManagedResourceInstanceObjects returns true for the same state, and +// to return a zero-length slice if it returns false. +func (s *State) AllResourceInstanceObjectAddrs() []struct { + Instance addrs.AbsResourceInstance + DeposedKey DeposedKey +} { + if s == nil { + return nil + } + + // We use an unnamed return type here just because we currently have no + // general need to return pairs of instance address and deposed key aside + // from this method, and this method itself is only of marginal value + // when producing some error messages. + // + // If that need ends up arising more in future then it might make sense to + // name this as addrs.AbsResourceInstanceObject, although that would require + // moving DeposedKey into the addrs package too. + type ResourceInstanceObject = struct { + Instance addrs.AbsResourceInstance + DeposedKey DeposedKey + } + var ret []ResourceInstanceObject + + for _, ms := range s.Modules { + for _, rs := range ms.Resources { + if rs.Addr.Resource.Mode != addrs.ManagedResourceMode { + continue + } + + for instKey, is := range rs.Instances { + instAddr := rs.Addr.Instance(instKey) + if is.Current != nil { + ret = append(ret, ResourceInstanceObject{instAddr, NotDeposed}) + } + for deposedKey := range is.Deposed { + ret = append(ret, ResourceInstanceObject{instAddr, deposedKey}) + } + } + } + } + + sort.SliceStable(ret, func(i, j int) bool { + objI, objJ := ret[i], ret[j] + switch { + case !objI.Instance.Equal(objJ.Instance): + return objI.Instance.Less(objJ.Instance) + default: + return objI.DeposedKey < objJ.DeposedKey + } + }) + + return ret +} + // ResourceInstance returns the state for the resource instance with the given // address, or nil if no such resource is tracked in the state. func (s *State) ResourceInstance(addr addrs.AbsResourceInstance) *ResourceInstance { diff --git a/internal/states/state_equal.go b/internal/states/state_equal.go index 06658ef26..b37aba062 100644 --- a/internal/states/state_equal.go +++ b/internal/states/state_equal.go @@ -34,10 +34,10 @@ func (s *State) ManagedResourcesEqual(other *State) bool { return true } if s == nil { - return !other.HasResources() + return !other.HasManagedResourceInstanceObjects() } if other == nil { - return !s.HasResources() + return !s.HasManagedResourceInstanceObjects() } // If we get here then both states are non-nil. diff --git a/internal/states/state_test.go b/internal/states/state_test.go index b4495c0cd..768772aeb 100644 --- a/internal/states/state_test.go +++ b/internal/states/state_test.go @@ -294,6 +294,125 @@ func TestStateDeepCopy(t *testing.T) { } } +func TestStateHasResourceInstanceObjects(t *testing.T) { + providerConfig := addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.MustParseProviderSourceString("test/test"), + } + childModuleProviderConfig := addrs.AbsProviderConfig{ + Module: addrs.RootModule.Child("child"), + Provider: addrs.MustParseProviderSourceString("test/test"), + } + + tests := map[string]struct { + Setup func(ss *SyncState) + Want bool + }{ + "empty": { + func(ss *SyncState) {}, + false, + }, + "one current, ready object in root module": { + func(ss *SyncState) { + ss.SetResourceInstanceCurrent( + mustAbsResourceAddr("test.foo").Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: ObjectReady, + }, + providerConfig, + ) + }, + true, + }, + "one current, ready object in child module": { + func(ss *SyncState) { + ss.SetResourceInstanceCurrent( + mustAbsResourceAddr("module.child.test.foo").Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: ObjectReady, + }, + childModuleProviderConfig, + ) + }, + true, + }, + "one current, tainted object in root module": { + func(ss *SyncState) { + ss.SetResourceInstanceCurrent( + mustAbsResourceAddr("test.foo").Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: ObjectTainted, + }, + providerConfig, + ) + }, + true, + }, + "one deposed, ready object in root module": { + func(ss *SyncState) { + ss.SetResourceInstanceDeposed( + mustAbsResourceAddr("test.foo").Instance(addrs.NoKey), + DeposedKey("uhoh"), + &ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: ObjectTainted, + }, + providerConfig, + ) + }, + true, + }, + "one empty resource husk in root module": { + func(ss *SyncState) { + // Current Terraform doesn't actually create resource husks + // as part of its everyday work, so this is a "should never + // happen" case but we'll test to make sure we're robust to + // it anyway, because this was a historical bug blocking + // "terraform workspace delete" and similar. + ss.SetResourceInstanceCurrent( + mustAbsResourceAddr("test.foo").Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: ObjectTainted, + }, + providerConfig, + ) + s := ss.Lock() + delete(s.Modules[""].Resources["test.foo"].Instances, addrs.NoKey) + ss.Unlock() + }, + false, + }, + "one current data resource object in root module": { + func(ss *SyncState) { + ss.SetResourceInstanceCurrent( + mustAbsResourceAddr("data.test.foo").Instance(addrs.NoKey), + &ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{}`), + Status: ObjectReady, + }, + providerConfig, + ) + }, + false, // data resources aren't managed resources, so they don't count + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + state := BuildState(test.Setup) + got := state.HasManagedResourceInstanceObjects() + if got != test.Want { + t.Errorf("wrong result\nstate content: (using legacy state string format; might not be comprehensive)\n%s\n\ngot: %t\nwant: %t", state, got, test.Want) + } + }) + } + +} + func TestState_MoveAbsResource(t *testing.T) { // Set up a starter state for the embedded tests, which should start from a copy of this state. state := NewState() diff --git a/internal/terraform/context_apply_test.go b/internal/terraform/context_apply_test.go index 4f6a7df89..28c5c788b 100644 --- a/internal/terraform/context_apply_test.go +++ b/internal/terraform/context_apply_test.go @@ -5743,7 +5743,7 @@ func TestContext2Apply_destroyModuleWithAttrsReferencingResource(t *testing.T) { } //Test that things were destroyed - if state.HasResources() { + if state.HasManagedResourceInstanceObjects() { t.Fatal("expected empty state, got:", state) } } @@ -8661,7 +8661,7 @@ func TestContext2Apply_providerWithLocals(t *testing.T) { t.Fatalf("err: %s", diags.Err()) } - if state.HasResources() { + if state.HasManagedResourceInstanceObjects() { t.Fatal("expected no state, got:", state) } diff --git a/internal/terraform/node_data_destroy_test.go b/internal/terraform/node_data_destroy_test.go index 7f06b4d4b..f399ee418 100644 --- a/internal/terraform/node_data_destroy_test.go +++ b/internal/terraform/node_data_destroy_test.go @@ -42,7 +42,7 @@ func TestNodeDataDestroyExecute(t *testing.T) { } // verify resource removed from state - if state.HasResources() { + if state.HasManagedResourceInstanceObjects() { t.Fatal("resources still in state after NodeDataDestroy.Execute") } } diff --git a/internal/terraform/transform_state.go b/internal/terraform/transform_state.go index 098768ca6..1ca060a88 100644 --- a/internal/terraform/transform_state.go +++ b/internal/terraform/transform_state.go @@ -26,8 +26,8 @@ type StateTransformer struct { } func (t *StateTransformer) Transform(g *Graph) error { - if !t.State.HasResources() { - log.Printf("[TRACE] StateTransformer: state is empty, so nothing to do") + if t.State == nil { + log.Printf("[TRACE] StateTransformer: state is nil, so nothing to do") return nil }