Use safe or force workspace delete for cloud backend

This commit is contained in:
Jarrett Spiker 2022-10-05 15:57:09 -04:00
parent c0964438d6
commit 060255a9d5
22 changed files with 219 additions and 35 deletions

View File

@ -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.

View File

@ -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 == "" {

View File

@ -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) {

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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 == "" {

View File

@ -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)
}

View File

@ -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
}

View File

@ -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()

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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.

View File

@ -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())
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -1238,6 +1238,7 @@ func (m *MockWorkspaces) Create(ctx context.Context, organization string, option
Permissions: &tfe.WorkspacePermissions{
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 {

View File

@ -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