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.
This commit is contained in:
Martin Atkins 2021-10-13 12:21:23 -07:00
parent 968f422681
commit bee7403f3e
12 changed files with 255 additions and 37 deletions

View File

@ -64,11 +64,11 @@ func (b *Local) opRefresh(
// If we succeed then we'll overwrite this with the resulting state below, // If we succeed then we'll overwrite this with the resulting state below,
// but otherwise the resulting state is just the input state. // but otherwise the resulting state is just the input state.
runningOp.State = lr.InputState runningOp.State = lr.InputState
if !runningOp.State.HasResources() { if !runningOp.State.HasManagedResourceInstanceObjects() {
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
tfdiags.Warning, tfdiags.Warning,
"Empty or non-existent state", "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.",
)) ))
} }

View File

@ -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") { 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) t.Fatalf("expected plan summary in output: %s", output)
} }
if !run.State.HasResources() { if !run.State.HasManagedResourceInstanceObjects() {
t.Fatalf("expected resources in state") 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") { 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) t.Fatalf("expected plan summary in output: %s", output)
} }
if !run.State.HasResources() { if !run.State.HasManagedResourceInstanceObjects() {
t.Fatalf("expected resources in state") t.Fatalf("expected resources in state")
} }
} }
@ -1646,7 +1646,7 @@ func TestRemote_applyVersionCheck(t *testing.T) {
output := b.CLI.(*cli.MockUi).OutputWriter.String() output := b.CLI.(*cli.MockUi).OutputWriter.String()
hasRemote := strings.Contains(output, "Running apply in the remote backend") hasRemote := strings.Contains(output, "Running apply in the remote backend")
hasSummary := strings.Contains(output, "1 added, 0 changed, 0 destroyed") hasSummary := strings.Contains(output, "1 added, 0 changed, 0 destroyed")
hasResources := run.State.HasResources() hasResources := run.State.HasManagedResourceInstanceObjects()
if !tc.forceLocal && tc.hasOperations { if !tc.forceLocal && tc.hasOperations {
if !hasRemote { if !hasRemote {
t.Errorf("missing remote backend header in output: %s", output) t.Errorf("missing remote backend header in output: %s", output)

View File

@ -105,7 +105,7 @@ func TestBackendStates(t *testing.T, b Backend) {
if err := foo.RefreshState(); err != nil { if err := foo.RefreshState(); err != nil {
t.Fatalf("bad: %s", err) t.Fatalf("bad: %s", err)
} }
if v := foo.State(); v.HasResources() { if v := foo.State(); v.HasManagedResourceInstanceObjects() {
t.Fatalf("should be empty: %s", v) t.Fatalf("should be empty: %s", v)
} }
@ -116,7 +116,7 @@ func TestBackendStates(t *testing.T, b Backend) {
if err := bar.RefreshState(); err != nil { if err := bar.RefreshState(); err != nil {
t.Fatalf("bad: %s", err) t.Fatalf("bad: %s", err)
} }
if v := bar.State(); v.HasResources() { if v := bar.State(); v.HasManagedResourceInstanceObjects() {
t.Fatalf("should be empty: %s", v) t.Fatalf("should be empty: %s", v)
} }
@ -168,7 +168,7 @@ func TestBackendStates(t *testing.T, b Backend) {
t.Fatal("error refreshing foo:", err) t.Fatal("error refreshing foo:", err)
} }
fooState = foo.State() fooState = foo.State()
if fooState.HasResources() { if fooState.HasManagedResourceInstanceObjects() {
t.Fatal("after writing a resource to bar, foo now has resources too") 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) t.Fatal("error refreshing foo:", err)
} }
fooState = foo.State() 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") 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) t.Fatal("error refreshing bar:", err)
} }
barState = bar.State() 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") 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 { if err := foo.RefreshState(); err != nil {
t.Fatalf("bad: %s", err) t.Fatalf("bad: %s", err)
} }
if v := foo.State(); v.HasResources() { if v := foo.State(); v.HasManagedResourceInstanceObjects() {
t.Fatalf("should be empty: %s", v) t.Fatalf("should be empty: %s", v)
} }
// and delete it again // and delete it again

View File

@ -82,15 +82,6 @@ for this configuration.
envDeleted = `[reset][green]Deleted workspace %q!` 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. envWarnNotEmpty = `[reset][yellow]WARNING: %q was non-empty.
The resources managed by the deleted workspace may still exist, The resources managed by the deleted workspace may still exist,
but are no longer manageable by Terraform since the state has but are no longer manageable by Terraform since the state has

View File

@ -391,10 +391,10 @@ func TestWorkspace_deleteWithState(t *testing.T) {
// create a non-empty state // create a non-empty state
originalState := &legacy.State{ originalState := &legacy.State{
Modules: []*legacy.ModuleState{ Modules: []*legacy.ModuleState{
&legacy.ModuleState{ {
Path: []string{"root"}, Path: []string{"root"},
Resources: map[string]*legacy.ResourceState{ Resources: map[string]*legacy.ResourceState{
"test_instance.foo": &legacy.ResourceState{ "test_instance.foo": {
Type: "test_instance", Type: "test_instance",
Primary: &legacy.InstanceState{ Primary: &legacy.InstanceState{
ID: "bar", ID: "bar",
@ -414,7 +414,7 @@ func TestWorkspace_deleteWithState(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
ui := new(cli.MockUi) ui := cli.NewMockUi()
view, _ := testView(t) view, _ := testView(t)
delCmd := &WorkspaceDeleteCommand{ delCmd := &WorkspaceDeleteCommand{
Meta: Meta{Ui: ui, View: view}, Meta: Meta{Ui: ui, View: view},
@ -423,6 +423,13 @@ func TestWorkspace_deleteWithState(t *testing.T) {
if code := delCmd.Run(args); code == 0 { if code := delCmd.Run(args); code == 0 {
t.Fatalf("expected failure without -force.\noutput: %s", ui.OutputWriter) 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) ui = new(cli.MockUi)
delCmd.Meta.Ui = ui delCmd.Meta.Ui = ui

View File

@ -8,6 +8,7 @@ import (
"github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/clistate"
"github.com/hashicorp/terraform/internal/command/views" "github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/tfdiags" "github.com/hashicorp/terraform/internal/tfdiags"
"github.com/mitchellh/cli" "github.com/mitchellh/cli"
"github.com/posener/complete" "github.com/posener/complete"
@ -124,12 +125,32 @@ func (c *WorkspaceDeleteCommand) Run(args []string) int {
return 1 return 1
} }
hasResources := stateMgr.State().HasResources() hasResources := stateMgr.State().HasManagedResourceInstanceObjects()
if hasResources && !force { 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 // We need to release the lock before exit
stateLocker.Unlock() 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 return 1
} }

View File

@ -151,17 +151,29 @@ func (s *State) EnsureModule(addr addrs.ModuleInstance) *Module {
return ms return ms
} }
// HasResources returns true if there is at least one resource (of any mode) // HasManagedResourceInstanceObjects returns true if there is at least one
// present in the receiving state. // resource instance object (current or deposed) associated with a managed
func (s *State) HasResources() bool { // 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 { if s == nil {
return false return false
} }
for _, ms := range s.Modules { for _, ms := range s.Modules {
if len(ms.Resources) > 0 { 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 true
} }
} }
}
}
return false return false
} }
@ -187,6 +199,74 @@ func (s *State) Resources(addr addrs.ConfigResource) []*Resource {
return ret 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 // ResourceInstance returns the state for the resource instance with the given
// address, or nil if no such resource is tracked in the state. // address, or nil if no such resource is tracked in the state.
func (s *State) ResourceInstance(addr addrs.AbsResourceInstance) *ResourceInstance { func (s *State) ResourceInstance(addr addrs.AbsResourceInstance) *ResourceInstance {

View File

@ -34,10 +34,10 @@ func (s *State) ManagedResourcesEqual(other *State) bool {
return true return true
} }
if s == nil { if s == nil {
return !other.HasResources() return !other.HasManagedResourceInstanceObjects()
} }
if other == nil { if other == nil {
return !s.HasResources() return !s.HasManagedResourceInstanceObjects()
} }
// If we get here then both states are non-nil. // If we get here then both states are non-nil.

View File

@ -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) { func TestState_MoveAbsResource(t *testing.T) {
// Set up a starter state for the embedded tests, which should start from a copy of this state. // Set up a starter state for the embedded tests, which should start from a copy of this state.
state := NewState() state := NewState()

View File

@ -5743,7 +5743,7 @@ func TestContext2Apply_destroyModuleWithAttrsReferencingResource(t *testing.T) {
} }
//Test that things were destroyed //Test that things were destroyed
if state.HasResources() { if state.HasManagedResourceInstanceObjects() {
t.Fatal("expected empty state, got:", state) t.Fatal("expected empty state, got:", state)
} }
} }
@ -8661,7 +8661,7 @@ func TestContext2Apply_providerWithLocals(t *testing.T) {
t.Fatalf("err: %s", diags.Err()) t.Fatalf("err: %s", diags.Err())
} }
if state.HasResources() { if state.HasManagedResourceInstanceObjects() {
t.Fatal("expected no state, got:", state) t.Fatal("expected no state, got:", state)
} }

View File

@ -42,7 +42,7 @@ func TestNodeDataDestroyExecute(t *testing.T) {
} }
// verify resource removed from state // verify resource removed from state
if state.HasResources() { if state.HasManagedResourceInstanceObjects() {
t.Fatal("resources still in state after NodeDataDestroy.Execute") t.Fatal("resources still in state after NodeDataDestroy.Execute")
} }
} }

View File

@ -26,8 +26,8 @@ type StateTransformer struct {
} }
func (t *StateTransformer) Transform(g *Graph) error { func (t *StateTransformer) Transform(g *Graph) error {
if !t.State.HasResources() { if t.State == nil {
log.Printf("[TRACE] StateTransformer: state is empty, so nothing to do") log.Printf("[TRACE] StateTransformer: state is nil, so nothing to do")
return nil return nil
} }