diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 4124b2abdb..48a049615d 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -109,7 +109,7 @@ type Backend interface { // DeleteWorkspace cannot prevent deleting a state that is in use. It is // the responsibility of the caller to hold a Lock for the state manager // belonging to this workspace before calling this method. - DeleteWorkspace(name string) error + DeleteWorkspace(name string, force bool) error // States returns a list of the names of all of the workspaces that exist // in this backend. diff --git a/internal/backend/local/backend.go b/internal/backend/local/backend.go index de1e7e5a42..de0e62d481 100644 --- a/internal/backend/local/backend.go +++ b/internal/backend/local/backend.go @@ -214,10 +214,10 @@ func (b *Local) Workspaces() ([]string, error) { // DeleteWorkspace removes a workspace. // // The "default" workspace cannot be removed. -func (b *Local) DeleteWorkspace(name string) error { +func (b *Local) DeleteWorkspace(name string, force bool) error { // If we have a backend handling state, defer to that. if b.Backend != nil { - return b.Backend.DeleteWorkspace(name) + return b.Backend.DeleteWorkspace(name, force) } if name == "" { diff --git a/internal/backend/local/testing.go b/internal/backend/local/testing.go index 3b9a3c40fb..2958196b1d 100644 --- a/internal/backend/local/testing.go +++ b/internal/backend/local/testing.go @@ -135,7 +135,7 @@ func (b *TestLocalSingleState) Workspaces() ([]string, error) { return nil, backend.ErrWorkspacesNotSupported } -func (b *TestLocalSingleState) DeleteWorkspace(string) error { +func (b *TestLocalSingleState) DeleteWorkspace(string, bool) error { return backend.ErrWorkspacesNotSupported } @@ -177,11 +177,11 @@ func (b *TestLocalNoDefaultState) Workspaces() ([]string, error) { return filtered, nil } -func (b *TestLocalNoDefaultState) DeleteWorkspace(name string) error { +func (b *TestLocalNoDefaultState) DeleteWorkspace(name string, force bool) error { if name == backend.DefaultStateName { return backend.ErrDefaultWorkspaceNotSupported } - return b.Local.DeleteWorkspace(name) + return b.Local.DeleteWorkspace(name, force) } func (b *TestLocalNoDefaultState) StateMgr(name string) (statemgr.Full, error) { diff --git a/internal/backend/remote-state/azure/backend_state.go b/internal/backend/remote-state/azure/backend_state.go index 82d2505c65..fbbb855d00 100644 --- a/internal/backend/remote-state/azure/backend_state.go +++ b/internal/backend/remote-state/azure/backend_state.go @@ -58,7 +58,7 @@ func (b *Backend) Workspaces() ([]string, error) { return result, nil } -func (b *Backend) DeleteWorkspace(name string) error { +func (b *Backend) DeleteWorkspace(name string, _ bool) error { if name == backend.DefaultStateName || name == "" { return fmt.Errorf("can't delete default state") } diff --git a/internal/backend/remote-state/consul/backend_state.go b/internal/backend/remote-state/consul/backend_state.go index 5bd74b2508..98934f3197 100644 --- a/internal/backend/remote-state/consul/backend_state.go +++ b/internal/backend/remote-state/consul/backend_state.go @@ -49,7 +49,7 @@ func (b *Backend) Workspaces() ([]string, error) { return result, nil } -func (b *Backend) DeleteWorkspace(name string) error { +func (b *Backend) DeleteWorkspace(name string, _ bool) error { if name == backend.DefaultStateName || name == "" { return fmt.Errorf("can't delete default state") } diff --git a/internal/backend/remote-state/cos/backend_state.go b/internal/backend/remote-state/cos/backend_state.go index 4e47ae0e68..46bd3d3957 100644 --- a/internal/backend/remote-state/cos/backend_state.go +++ b/internal/backend/remote-state/cos/backend_state.go @@ -57,7 +57,7 @@ func (b *Backend) Workspaces() ([]string, error) { } // DeleteWorkspace deletes the named workspaces. The "default" state cannot be deleted. -func (b *Backend) DeleteWorkspace(name string) error { +func (b *Backend) DeleteWorkspace(name string, _ bool) error { log.Printf("[DEBUG] delete workspace, workspace: %v", name) if name == backend.DefaultStateName || name == "" { diff --git a/internal/backend/remote-state/gcs/backend_state.go b/internal/backend/remote-state/gcs/backend_state.go index d2d5a2f6b2..1f1aa8f1b8 100644 --- a/internal/backend/remote-state/gcs/backend_state.go +++ b/internal/backend/remote-state/gcs/backend_state.go @@ -55,7 +55,7 @@ func (b *Backend) Workspaces() ([]string, error) { } // DeleteWorkspace deletes the named workspaces. The "default" state cannot be deleted. -func (b *Backend) DeleteWorkspace(name string) error { +func (b *Backend) DeleteWorkspace(name string, _ bool) error { if name == backend.DefaultStateName { return fmt.Errorf("cowardly refusing to delete the %q state", name) } diff --git a/internal/backend/remote-state/http/backend.go b/internal/backend/remote-state/http/backend.go index 863e3f00f1..39b279b768 100644 --- a/internal/backend/remote-state/http/backend.go +++ b/internal/backend/remote-state/http/backend.go @@ -195,6 +195,6 @@ func (b *Backend) Workspaces() ([]string, error) { return nil, backend.ErrWorkspacesNotSupported } -func (b *Backend) DeleteWorkspace(string) error { +func (b *Backend) DeleteWorkspace(string, bool) error { return backend.ErrWorkspacesNotSupported } diff --git a/internal/backend/remote-state/inmem/backend.go b/internal/backend/remote-state/inmem/backend.go index 4e0113cbc7..215dcc41be 100644 --- a/internal/backend/remote-state/inmem/backend.go +++ b/internal/backend/remote-state/inmem/backend.go @@ -101,7 +101,7 @@ func (b *Backend) Workspaces() ([]string, error) { return workspaces, nil } -func (b *Backend) DeleteWorkspace(name string) error { +func (b *Backend) DeleteWorkspace(name string, _ bool) error { states.Lock() defer states.Unlock() diff --git a/internal/backend/remote-state/kubernetes/backend_state.go b/internal/backend/remote-state/kubernetes/backend_state.go index 6e8ce449d1..56009bc346 100644 --- a/internal/backend/remote-state/kubernetes/backend_state.go +++ b/internal/backend/remote-state/kubernetes/backend_state.go @@ -60,7 +60,7 @@ func (b *Backend) Workspaces() ([]string, error) { return states, nil } -func (b *Backend) DeleteWorkspace(name string) error { +func (b *Backend) DeleteWorkspace(name string, _ bool) error { if name == backend.DefaultStateName || name == "" { return fmt.Errorf("can't delete default state") } diff --git a/internal/backend/remote-state/oss/backend_state.go b/internal/backend/remote-state/oss/backend_state.go index 672a2e1aa2..c1b6616e5e 100644 --- a/internal/backend/remote-state/oss/backend_state.go +++ b/internal/backend/remote-state/oss/backend_state.go @@ -96,7 +96,7 @@ func (b *Backend) Workspaces() ([]string, error) { return result, nil } -func (b *Backend) DeleteWorkspace(name string) error { +func (b *Backend) DeleteWorkspace(name string, _ bool) error { if name == backend.DefaultStateName || name == "" { return fmt.Errorf("can't delete default state") } diff --git a/internal/backend/remote-state/pg/backend_state.go b/internal/backend/remote-state/pg/backend_state.go index f3eb650092..a1a5544ddb 100644 --- a/internal/backend/remote-state/pg/backend_state.go +++ b/internal/backend/remote-state/pg/backend_state.go @@ -35,7 +35,7 @@ func (b *Backend) Workspaces() ([]string, error) { return result, nil } -func (b *Backend) DeleteWorkspace(name string) error { +func (b *Backend) DeleteWorkspace(name string, _ bool) error { if name == backend.DefaultStateName || name == "" { return fmt.Errorf("can't delete default state") } diff --git a/internal/backend/remote-state/s3/backend_state.go b/internal/backend/remote-state/s3/backend_state.go index d13cc32d4f..d5505f2733 100644 --- a/internal/backend/remote-state/s3/backend_state.go +++ b/internal/backend/remote-state/s3/backend_state.go @@ -90,7 +90,7 @@ func (b *Backend) keyEnv(key string) string { return parts[0] } -func (b *Backend) DeleteWorkspace(name string) error { +func (b *Backend) DeleteWorkspace(name string, _ bool) error { if name == backend.DefaultStateName || name == "" { return fmt.Errorf("can't delete default state") } diff --git a/internal/backend/remote/backend.go b/internal/backend/remote/backend.go index 55e9b46d3a..7265060d1c 100644 --- a/internal/backend/remote/backend.go +++ b/internal/backend/remote/backend.go @@ -582,7 +582,7 @@ func (b *Remote) WorkspaceNamePattern() string { } // DeleteWorkspace implements backend.Enhanced. -func (b *Remote) DeleteWorkspace(name string) error { +func (b *Remote) DeleteWorkspace(name string, _ bool) error { if b.workspace == "" && name == backend.DefaultStateName { return backend.ErrDefaultWorkspaceNotSupported } diff --git a/internal/backend/testing.go b/internal/backend/testing.go index a8c04e0ac7..3b97e307de 100644 --- a/internal/backend/testing.go +++ b/internal/backend/testing.go @@ -219,12 +219,12 @@ func TestBackendStates(t *testing.T, b Backend) { } // Delete some workspaces - if err := b.DeleteWorkspace("foo"); err != nil { + if err := b.DeleteWorkspace("foo", true); err != nil { t.Fatalf("err: %s", err) } // Verify the default state can't be deleted - if err := b.DeleteWorkspace(DefaultStateName); err == nil { + if err := b.DeleteWorkspace(DefaultStateName, true); err == nil { t.Fatal("expected error") } @@ -242,7 +242,7 @@ func TestBackendStates(t *testing.T, b Backend) { t.Fatalf("should be empty: %s", v) } // and delete it again - if err := b.DeleteWorkspace("foo"); err != nil { + if err := b.DeleteWorkspace("foo", true); err != nil { t.Fatalf("err: %s", err) } diff --git a/internal/builtin/providers/terraform/data_source_state_test.go b/internal/builtin/providers/terraform/data_source_state_test.go index 1a9f514ecb..cf0e3c2a6b 100644 --- a/internal/builtin/providers/terraform/data_source_state_test.go +++ b/internal/builtin/providers/terraform/data_source_state_test.go @@ -362,7 +362,7 @@ func (b backendFailsConfigure) StateMgr(workspace string) (statemgr.Full, error) return nil, fmt.Errorf("StateMgr not implemented") } -func (b backendFailsConfigure) DeleteWorkspace(name string) error { +func (b backendFailsConfigure) DeleteWorkspace(name string, _ bool) error { return fmt.Errorf("DeleteWorkspace not implemented") } diff --git a/internal/cloud/backend.go b/internal/cloud/backend.go index ded38f75f2..e445628eb1 100644 --- a/internal/cloud/backend.go +++ b/internal/cloud/backend.go @@ -516,7 +516,7 @@ func (b *Cloud) Workspaces() ([]string, error) { } // DeleteWorkspace implements backend.Enhanced. -func (b *Cloud) DeleteWorkspace(name string) error { +func (b *Cloud) DeleteWorkspace(name string, force bool) error { if name == backend.DefaultStateName { return backend.ErrDefaultWorkspaceNotSupported } @@ -525,11 +525,14 @@ func (b *Cloud) DeleteWorkspace(name string) error { return backend.ErrWorkspacesNotSupported } + workspace, err := b.client.Workspaces.Read(context.Background(), b.organization, name) + if err != nil { + return fmt.Errorf("failed to retrieve workspace %s: %v", name, err) + } + // Configure the remote workspace name. - State := &State{tfeClient: b.client, organization: b.organization, workspace: &tfe.Workspace{ - Name: name, - }} - return State.Delete() + State := &State{tfeClient: b.client, organization: b.organization, workspace: workspace} + return State.Delete(force) } // StateMgr implements backend.Enhanced. diff --git a/internal/cloud/backend_test.go b/internal/cloud/backend_test.go index 181bd6e17a..00ad72a9bf 100644 --- a/internal/cloud/backend_test.go +++ b/internal/cloud/backend_test.go @@ -40,11 +40,11 @@ func TestCloud_backendWithName(t *testing.T) { t.Fatalf("expected fetching a state which is NOT the single configured workspace to have an ErrWorkspacesNotSupported error, but got: %v", err) } - if err := b.DeleteWorkspace(testBackendSingleWorkspaceName); err != backend.ErrWorkspacesNotSupported { + if err := b.DeleteWorkspace(testBackendSingleWorkspaceName, true); err != backend.ErrWorkspacesNotSupported { t.Fatalf("expected deleting the single configured workspace name to result in an error, but got: %v", err) } - if err := b.DeleteWorkspace("foo"); err != backend.ErrWorkspacesNotSupported { + if err := b.DeleteWorkspace("foo", true); err != backend.ErrWorkspacesNotSupported { t.Fatalf("expected deleting a workspace which is NOT the configured workspace name to result in an error, but got: %v", err) } } @@ -856,7 +856,7 @@ func TestCloud_addAndRemoveWorkspacesDefault(t *testing.T) { t.Fatalf("expected no error, got %v", err) } - if err := b.DeleteWorkspace(testBackendSingleWorkspaceName); err != backend.ErrWorkspacesNotSupported { + if err := b.DeleteWorkspace(testBackendSingleWorkspaceName, true); err != backend.ErrWorkspacesNotSupported { t.Fatalf("expected error %v, got %v", backend.ErrWorkspacesNotSupported, err) } } @@ -1139,3 +1139,82 @@ func TestCloud_VerifyWorkspaceTerraformVersion_ignoreFlagSet(t *testing.T) { t.Errorf("wrong summary: got %s, want %s", got, wantDetail) } } + +func TestClodBackend_DeleteWorkspace_SafeAndForce(t *testing.T) { + b, bCleanup := testBackendWithTags(t) + defer bCleanup() + safeDeleteWorkspaceName := "safe-delete-workspace" + forceDeleteWorkspaceName := "force-delete-workspace" + + _, err := b.StateMgr(safeDeleteWorkspaceName) + if err != nil { + t.Fatalf("error: %s", err) + } + + _, err = b.StateMgr(forceDeleteWorkspaceName) + if err != nil { + t.Fatalf("error: %s", err) + } + + // sanity check that the mock now contains two workspaces + wl, err := b.Workspaces() + if err != nil { + t.Fatalf("error fetching workspace names: %v", err) + } + if len(wl) != 2 { + t.Fatalf("expected 2 workspaced but got %d", len(wl)) + } + + c := context.Background() + safeDeleteWorkspace, err := b.client.Workspaces.Read(c, b.organization, safeDeleteWorkspaceName) + if err != nil { + t.Fatalf("error fetching workspace: %v", err) + } + + // Lock a workspace so that it should fail to be safe deleted + _, err = b.client.Workspaces.Lock(context.Background(), safeDeleteWorkspace.ID, tfe.WorkspaceLockOptions{Reason: tfe.String("test")}) + if err != nil { + t.Fatalf("error locking workspace: %v", err) + } + err = b.DeleteWorkspace(safeDeleteWorkspaceName, false) + if err == nil { + t.Fatalf("workspace should have failed to safe delete") + } + + // unlock the workspace and confirm that safe-delete now works + _, err = b.client.Workspaces.Unlock(context.Background(), safeDeleteWorkspace.ID) + if err != nil { + t.Fatalf("error unlocking workspace: %v", err) + } + err = b.DeleteWorkspace(safeDeleteWorkspaceName, false) + if err != nil { + t.Fatalf("error safe deleting workspace: %v", err) + } + + // lock a workspace and then confirm that force deleting it works + forceDeleteWorkspace, err := b.client.Workspaces.Read(c, b.organization, forceDeleteWorkspaceName) + if err != nil { + t.Fatalf("error fetching workspace: %v", err) + } + _, err = b.client.Workspaces.Lock(context.Background(), forceDeleteWorkspace.ID, tfe.WorkspaceLockOptions{Reason: tfe.String("test")}) + if err != nil { + t.Fatalf("error locking workspace: %v", err) + } + err = b.DeleteWorkspace(forceDeleteWorkspaceName, true) + if err != nil { + t.Fatalf("error force deleting workspace: %v", err) + } +} + +func TestClodBackend_DeleteWorkspace_DoesNotExist(t *testing.T) { + b, bCleanup := testBackendWithTags(t) + defer bCleanup() + + err := b.DeleteWorkspace("non-existent-workspace", false) + if err == nil { + t.Fatalf("expected deleting a workspace which does not exist to fail") + } + if !strings.Contains(err.Error(), "failed to retrieve workspace non-existent-workspace") { + t.Fatalf("expected deletion to fail with cannot find workspace error, but got %s", err.Error()) + } +} diff --git a/internal/cloud/state.go b/internal/cloud/state.go index 6f1a433b0d..853cb4061b 100644 --- a/internal/cloud/state.go +++ b/internal/cloud/state.go @@ -400,8 +400,17 @@ func (s *State) Unlock(id string) error { } // Delete the remote state. -func (s *State) Delete() error { - err := s.tfeClient.Workspaces.Delete(context.Background(), s.organization, s.workspace.Name) +func (s *State) Delete(force bool) error { + + var err error + + isSafeDeleteSupported := s.workspace.Permissions.CanForceDelete != nil + if force || !isSafeDeleteSupported { + err = s.tfeClient.Workspaces.Delete(context.Background(), s.organization, s.workspace.Name) + } else { + err = s.tfeClient.Workspaces.SafeDelete(context.Background(), s.organization, s.workspace.Name) + } + if err != nil && err != tfe.ErrResourceNotFound { return fmt.Errorf("error deleting workspace %s: %v", s.workspace.Name, err) } diff --git a/internal/cloud/state_test.go b/internal/cloud/state_test.go index ee819d339f..95561bdd76 100644 --- a/internal/cloud/state_test.go +++ b/internal/cloud/state_test.go @@ -2,6 +2,7 @@ package cloud import ( "bytes" + "context" "io/ioutil" "testing" @@ -118,7 +119,7 @@ func TestState(t *testing.T) { t.Fatalf("expected full state %q\n\ngot: %q", string(payload.Data), string(data)) } - if err := state.Delete(); err != nil { + if err := state.Delete(true); err != nil { t.Fatalf("delete: %s", err) } @@ -193,3 +194,59 @@ func TestCloudLocks(t *testing.T) { t.Fatal("error unlocking client B:", err) } } + +func TestDelete_SafeDeleteNotSupported(t *testing.T) { + state := testCloudState(t) + workspaceId := state.workspace.ID + state.workspace.Permissions.CanForceDelete = nil + state.workspace.ResourceCount = 5 + + // Typically delete(false) should safe-delete a cloud workspace, which should fail on this workspace with resources + // However, since we have set the workspace canForceDelete permission to nil, we should fall back to force delete + if err := state.Delete(false); err != nil { + t.Fatalf("delete: %s", err) + } + workspace, err := state.tfeClient.Workspaces.ReadByID(context.Background(), workspaceId) + if workspace != nil || err != tfe.ErrResourceNotFound { + t.Fatalf("workspace %s not deleted", workspaceId) + } +} + +func TestDelete_ForceDelete(t *testing.T) { + state := testCloudState(t) + workspaceId := state.workspace.ID + state.workspace.Permissions.CanForceDelete = tfe.Bool(true) + state.workspace.ResourceCount = 5 + + if err := state.Delete(true); err != nil { + t.Fatalf("delete: %s", err) + } + workspace, err := state.tfeClient.Workspaces.ReadByID(context.Background(), workspaceId) + if workspace != nil || err != tfe.ErrResourceNotFound { + t.Fatalf("workspace %s not deleted", workspaceId) + } +} + +func TestDelete_SafeDelete(t *testing.T) { + state := testCloudState(t) + workspaceId := state.workspace.ID + state.workspace.Permissions.CanForceDelete = tfe.Bool(false) + state.workspace.ResourceCount = 5 + + // safe-deleting a workspace with resources should fail + err := state.Delete(false) + if err == nil { + t.Fatalf("workspace should have failed to safe delete") + } + + // safe-deleting a workspace with resources should succeed once it has no resources + state.workspace.ResourceCount = 0 + if err = state.Delete(false); err != nil { + t.Fatalf("workspace safe-delete err: %s", err) + } + + workspace, err := state.tfeClient.Workspaces.ReadByID(context.Background(), workspaceId) + if workspace != nil || err != tfe.ErrResourceNotFound { + t.Fatalf("workspace %s not deleted", workspaceId) + } +} diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index 2ac259a2eb..5806e4dc38 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -1236,8 +1236,9 @@ func (m *MockWorkspaces) Create(ctx context.Context, organization string, option ExecutionMode: *options.ExecutionMode, Operations: *options.Operations, Permissions: &tfe.WorkspacePermissions{ - CanQueueApply: true, - CanQueueRun: true, + CanQueueApply: true, + CanQueueRun: true, + CanForceDelete: tfe.Bool(true), }, } if options.AutoApply != nil { @@ -1370,6 +1371,41 @@ func (m *MockWorkspaces) DeleteByID(ctx context.Context, workspaceID string) err return nil } +func (m *MockWorkspaces) SafeDelete(ctx context.Context, organization, workspace string) error { + w, ok := m.client.Workspaces.workspaceNames[workspace] + + if !ok { + return tfe.ErrResourceNotFound + } + + if w.Locked { + return errors.New("cannot safe delete locked workspace") + } + + if w.ResourceCount > 0 { + return fmt.Errorf("cannot safe delete workspace with %d resources", w.ResourceCount) + } + + return m.Delete(ctx, organization, workspace) +} + +func (m *MockWorkspaces) SafeDeleteByID(ctx context.Context, workspaceID string) error { + w, ok := m.client.Workspaces.workspaceIDs[workspaceID] + if !ok { + return tfe.ErrResourceNotFound + } + + if w.Locked { + return errors.New("cannot safe delete locked workspace") + } + + if w.ResourceCount > 0 { + return fmt.Errorf("cannot safe delete workspace with %d resources", w.ResourceCount) + } + + return m.DeleteByID(ctx, workspaceID) +} + func (m *MockWorkspaces) RemoveVCSConnection(ctx context.Context, organization, workspace string) (*tfe.Workspace, error) { w, ok := m.workspaceNames[workspace] if !ok { diff --git a/internal/command/workspace_delete.go b/internal/command/workspace_delete.go index 013db3966c..1725e5b3cc 100644 --- a/internal/command/workspace_delete.go +++ b/internal/command/workspace_delete.go @@ -165,7 +165,7 @@ func (c *WorkspaceDeleteCommand) Run(args []string) int { // be delegated from the Backend to the State itself. stateLocker.Unlock() - err = b.DeleteWorkspace(workspace) + err = b.DeleteWorkspace(workspace, force) if err != nil { c.Ui.Error(err.Error()) return 1