diff --git a/CHANGELOG.md b/CHANGELOG.md index c56e07fb1..c550fd4db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ * Add `Query` and `Status` fields to `OrganizationMembershipListOptions` to allow filtering memberships by status or username by @sebasslash [#550](https://github.com/hashicorp/go-tfe/pull/550) * Add `ListForWorkspace` method to `VariableSets` interface to enable fetching variable sets associated with a workspace by @tstapler [#552](https://github.com/hashicorp/go-tfe/pull/552) * Add `NotificationTriggerAssessmentDrifted` and `NotificationTriggerAssessmentFailed` notification trigger types by @lawliet89 [#542](https://github.com/hashicorp/go-tfe/pull/542) +* Add `AllowForceDeleteWorkspaces` setting to `Organizations` by @JarrettSpiker [#539](https://github.com/hashicorp/go-tfe/pull/539) +* Add `SafeDelete` and `SafeDeleteID` APIs to `Workspaces` by @JarrettSpiker [#539](https://github.com/hashicorp/go-tfe/pull/539) + ## Bug Fixes * Fix marshalling of run variables in `RunCreateOptions`. The `Variables` field type in `Run` struct has changed from `[]*RunVariable` to `[]*RunVariableAttr` by @Uk1288 [#531](https://github.com/hashicorp/go-tfe/pull/531) diff --git a/helper_test.go b/helper_test.go index 1a3f6b194..e587d19b9 100644 --- a/helper_test.go +++ b/helper_test.go @@ -1598,10 +1598,10 @@ func createWorkspaceWithOptions(t *testing.T, client *Client, org *Organization, } return w, func() { - if err := client.Workspaces.Delete(ctx, org.Name, w.Name); err != nil { + if err := client.Workspaces.DeleteByID(ctx, w.ID); err != nil { t.Errorf("Error destroying workspace! WARNING: Dangling resources\n"+ "may exist! The full error is shown below.\n\n"+ - "Workspace: %s\nError: %s", w.Name, err) + "Workspace: %s\nError: %s", w.ID, err) } if orgCleanup != nil { diff --git a/mocks/workspace_mocks.go b/mocks/workspace_mocks.go index f5fa01ce3..adde2eb7a 100644 --- a/mocks/workspace_mocks.go +++ b/mocks/workspace_mocks.go @@ -330,6 +330,34 @@ func (mr *MockWorkspacesMockRecorder) RemoveVCSConnectionByID(ctx, workspaceID i return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveVCSConnectionByID", reflect.TypeOf((*MockWorkspaces)(nil).RemoveVCSConnectionByID), ctx, workspaceID) } +// SafeDelete mocks base method. +func (m *MockWorkspaces) SafeDelete(ctx context.Context, organization, workspace string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SafeDelete", ctx, organization, workspace) + ret0, _ := ret[0].(error) + return ret0 +} + +// SafeDelete indicates an expected call of SafeDelete. +func (mr *MockWorkspacesMockRecorder) SafeDelete(ctx, organization, workspace interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SafeDelete", reflect.TypeOf((*MockWorkspaces)(nil).SafeDelete), ctx, organization, workspace) +} + +// SafeDeleteByID mocks base method. +func (m *MockWorkspaces) SafeDeleteByID(ctx context.Context, workspaceID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SafeDeleteByID", ctx, workspaceID) + ret0, _ := ret[0].(error) + return ret0 +} + +// SafeDeleteByID indicates an expected call of SafeDeleteByID. +func (mr *MockWorkspacesMockRecorder) SafeDeleteByID(ctx, workspaceID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SafeDeleteByID", reflect.TypeOf((*MockWorkspaces)(nil).SafeDeleteByID), ctx, workspaceID) +} + // UnassignSSHKey mocks base method. func (m *MockWorkspaces) UnassignSSHKey(ctx context.Context, workspaceID string) (*tfe.Workspace, error) { m.ctrl.T.Helper() diff --git a/organization.go b/organization.go index d7b0a2b0a..e4b9086ee 100644 --- a/organization.go +++ b/organization.go @@ -78,6 +78,9 @@ type Organization struct { TrialExpiresAt time.Time `jsonapi:"attr,trial-expires-at,iso8601"` TwoFactorConformant bool `jsonapi:"attr,two-factor-conformant"` SendPassingStatusesForUntriggeredSpeculativePlans bool `jsonapi:"attr,send-passing-statuses-for-untriggered-speculative-plans"` + // Note: This will be false for TFE versions older than v202211, where the setting was introduced. + // On those TFE versions, safe delete does not exist, so ALL deletes will be force deletes. + AllowForceDeleteWorkspaces bool `jsonapi:"attr,allow-force-delete-workspaces"` } // Capacity represents the current run capacity of an organization. @@ -166,6 +169,9 @@ type OrganizationCreateOptions struct { // Optional: SendPassingStatusesForUntriggeredSpeculativePlans toggles behavior of untriggered speculative plans to send status updates to version control systems like GitHub. SendPassingStatusesForUntriggeredSpeculativePlans *bool `jsonapi:"attr,send-passing-statuses-for-untriggered-speculative-plans,omitempty"` + + // Optional: AllowForceDeleteWorkspaces toggles behavior of allowing workspace admins to delete workspaces with resources under management. + AllowForceDeleteWorkspaces *bool `jsonapi:"attr,allow-force-delete-workspaces,omitempty"` } // OrganizationUpdateOptions represents the options for updating an organization. @@ -202,6 +208,9 @@ type OrganizationUpdateOptions struct { // SendPassingStatusesForUntriggeredSpeculativePlans toggles behavior of untriggered speculative plans to send status updates to version control systems like GitHub. SendPassingStatusesForUntriggeredSpeculativePlans *bool `jsonapi:"attr,send-passing-statuses-for-untriggered-speculative-plans,omitempty"` + + // Optional: AllowForceDeleteWorkspaces toggles behavior of allowing workspace admins to delete workspaces with resources under management. + AllowForceDeleteWorkspaces *bool `jsonapi:"attr,allow-force-delete-workspaces,omitempty"` } // ReadRunQueueOptions represents the options for showing the queue. diff --git a/organization_integration_test.go b/organization_integration_test.go index 41c24f84f..22952721b 100644 --- a/organization_integration_test.go +++ b/organization_integration_test.go @@ -565,7 +565,43 @@ func TestOrganizationsReadRunTasksEntitlement(t *testing.T) { assert.NotEmpty(t, entitlements.ID) assert.True(t, entitlements.RunTasks) }) +} + +func TestOrganizationsAllowForceDeleteSetting(t *testing.T) { + skipIfNotCINode(t) + client := testClient(t) + ctx := context.Background() + + t.Run("creates and updates allow force delete", func(t *testing.T) { + options := OrganizationCreateOptions{ + Name: String(randomString(t)), + Email: String(randomString(t) + "@tfe.local"), + AllowForceDeleteWorkspaces: Bool(true), + } + + org, err := client.Organizations.Create(ctx, options) + require.NoError(t, err) + + t.Cleanup(func() { + err := client.Organizations.Delete(ctx, org.Name) + if err != nil { + t.Errorf("error deleting organization (%s): %s", org.Name, err) + } + }) + + assert.Equal(t, *options.Name, org.Name) + assert.Equal(t, *options.Email, org.Email) + assert.True(t, org.AllowForceDeleteWorkspaces) + + org, err = client.Organizations.Update(ctx, org.Name, OrganizationUpdateOptions{AllowForceDeleteWorkspaces: Bool(false)}) + require.NoError(t, err) + assert.False(t, org.AllowForceDeleteWorkspaces) + + org, err = client.Organizations.Read(ctx, org.Name) + require.NoError(t, err) + assert.False(t, org.AllowForceDeleteWorkspaces) + }) } func orgItemsContainsName(items []*Organization, name string) bool { diff --git a/workspace.go b/workspace.go index 462048e3b..89554b1b4 100644 --- a/workspace.go +++ b/workspace.go @@ -50,6 +50,12 @@ type Workspaces interface { // DeleteByID deletes a workspace by its ID. DeleteByID(ctx context.Context, workspaceID string) error + // SafeDelete a workspace by its name. + SafeDelete(ctx context.Context, organization string, workspace string) error + + // SafeDeleteByID deletes a workspace by its ID. + SafeDeleteByID(ctx context.Context, workspaceID string) error + // RemoveVCSConnection from a workspace. RemoveVCSConnection(ctx context.Context, organization, workspace string) (*Workspace, error) @@ -194,17 +200,18 @@ type WorkspaceActions struct { // WorkspacePermissions represents the workspace permissions. type WorkspacePermissions struct { - CanDestroy bool `jsonapi:"attr,can-destroy"` - CanForceUnlock bool `jsonapi:"attr,can-force-unlock"` - CanLock bool `jsonapi:"attr,can-lock"` - CanManageRunTasks bool `jsonapi:"attr,can-manage-run-tasks"` - CanQueueApply bool `jsonapi:"attr,can-queue-apply"` - CanQueueDestroy bool `jsonapi:"attr,can-queue-destroy"` - CanQueueRun bool `jsonapi:"attr,can-queue-run"` - CanReadSettings bool `jsonapi:"attr,can-read-settings"` - CanUnlock bool `jsonapi:"attr,can-unlock"` - CanUpdate bool `jsonapi:"attr,can-update"` - CanUpdateVariable bool `jsonapi:"attr,can-update-variable"` + CanDestroy bool `jsonapi:"attr,can-destroy"` + CanForceUnlock bool `jsonapi:"attr,can-force-unlock"` + CanLock bool `jsonapi:"attr,can-lock"` + CanManageRunTasks bool `jsonapi:"attr,can-manage-run-tasks"` + CanQueueApply bool `jsonapi:"attr,can-queue-apply"` + CanQueueDestroy bool `jsonapi:"attr,can-queue-destroy"` + CanQueueRun bool `jsonapi:"attr,can-queue-run"` + CanReadSettings bool `jsonapi:"attr,can-read-settings"` + CanUnlock bool `jsonapi:"attr,can-unlock"` + CanUpdate bool `jsonapi:"attr,can-update"` + CanUpdateVariable bool `jsonapi:"attr,can-update-variable"` + CanForceDelete *bool `jsonapi:"attr,can-force-delete"` // pointer b/c it will be useful to check if this property exists, as opposed to having it default to false } // WSIncludeOpt represents the available options for include query params. @@ -770,6 +777,43 @@ func (s *workspaces) DeleteByID(ctx context.Context, workspaceID string) error { return req.Do(ctx, nil) } +// SafeDelete a workspace by its name. +func (s *workspaces) SafeDelete(ctx context.Context, organization, workspace string) error { + if !validStringID(&organization) { + return ErrInvalidOrg + } + if !validStringID(&workspace) { + return ErrInvalidWorkspaceValue + } + + u := fmt.Sprintf( + "organizations/%s/workspaces/%s/actions/safe-delete", + url.QueryEscape(organization), + url.QueryEscape(workspace), + ) + req, err := s.client.NewRequest("POST", u, nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} + +// SafeDeleteByID safely deletes a workspace by its ID. +func (s *workspaces) SafeDeleteByID(ctx context.Context, workspaceID string) error { + if !validStringID(&workspaceID) { + return ErrInvalidWorkspaceID + } + + u := fmt.Sprintf("workspaces/%s/actions/safe-delete", url.QueryEscape(workspaceID)) + req, err := s.client.NewRequest("POST", u, nil) + if err != nil { + return err + } + + return req.Do(ctx, nil) +} + // RemoveVCSConnection from a workspace. func (s *workspaces) RemoveVCSConnection(ctx context.Context, organization, workspace string) (*Workspace, error) { if !validStringID(&organization) { diff --git a/workspace_integration_test.go b/workspace_integration_test.go index cb915b788..797db98d5 100644 --- a/workspace_integration_test.go +++ b/workspace_integration_test.go @@ -39,12 +39,12 @@ func TestWorkspacesList(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest1, wTest1Cleanup := createWorkspace(t, client, orgTest) - defer wTest1Cleanup() + t.Cleanup(wTest1Cleanup) wTest2, wTest2Cleanup := createWorkspace(t, client, orgTest) - defer wTest2Cleanup() + t.Cleanup(wTest2Cleanup) t.Run("without list options", func(t *testing.T) { wl, err := client.Workspaces.List(ctx, orgTest.Name, nil) @@ -193,7 +193,7 @@ func TestWorkspacesCreateTableDriven(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) workspaceTableTests := []WorkspaceTableTest{ { @@ -216,8 +216,8 @@ func TestWorkspacesCreateTableDriven(t *testing.T) { w, wTestCleanup := createWorkspaceWithVCS(t, client, orgTest, *options.createOptions) return w, func() { - defer orgTestCleanup() - defer wTestCleanup() + t.Cleanup(orgTestCleanup) + t.Cleanup(wTestCleanup) } }, assertion: func(w *Workspace, options *WorkspaceTableOptions, err error) { @@ -292,7 +292,7 @@ func TestWorkspacesCreateTableDriven(t *testing.T) { w, wTestCleanup := createWorkspaceWithVCS(t, client, orgTest, *options.createOptions) return w, func() { - defer wTestCleanup() + t.Cleanup(wTestCleanup) } }, assertion: func(w *Workspace, options *WorkspaceTableOptions, err error) { @@ -325,7 +325,7 @@ func TestWorkspacesCreate(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) t.Run("with valid options", func(t *testing.T) { options := WorkspaceCreateOptions{ @@ -457,7 +457,7 @@ func TestWorkspacesCreate(t *testing.T) { Name: String("tst-" + randomString(t)[0:20] + "-ff-on"), Email: String(fmt.Sprintf("%s@tfe.local", randomString(t))), }) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) options := WorkspaceCreateOptions{ Name: String("foobar"), @@ -500,7 +500,7 @@ func TestWorkspacesCreate(t *testing.T) { Name: String("tst-" + randomString(t)[0:20] + "-ff-on"), Email: String(fmt.Sprintf("%s@tfe.local", randomString(t))), }) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) options := WorkspaceCreateOptions{ Name: String(fmt.Sprintf("foobar-%s", randomString(t))), @@ -522,10 +522,10 @@ func TestWorkspacesRead(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) t.Run("when the workspace exists", func(t *testing.T) { w, err := client.Workspaces.Read(ctx, orgTest.Name, wTest.Name) @@ -570,13 +570,13 @@ func TestWorkspacesReadWithOptions(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) svTest, svTestCleanup := createStateVersion(t, client, 0, wTest) - defer svTestCleanup() + t.Cleanup(svTestCleanup) // give TFC some time to process the statefile and extract the outputs. waitForSVOutputs(t, client, svTest.ID) @@ -620,13 +620,13 @@ func TestWorkspacesReadWithHistory(t *testing.T) { client := testClient(t) orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) _, rCleanup := createRunApply(t, client, wTest) - defer rCleanup() + t.Cleanup(rCleanup) _, err := retry(func() (interface{}, error) { w, err := client.Workspaces.Read(context.Background(), orgTest.Name, wTest.Name) @@ -655,13 +655,13 @@ func TestWorkspacesReadReadme(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspaceWithVCS(t, client, orgTest, WorkspaceCreateOptions{}) - defer wTestCleanup() + t.Cleanup(wTestCleanup) _, rCleanup := createRunApply(t, client, wTest) - defer rCleanup() + t.Cleanup(rCleanup) t.Run("when the readme exists", func(t *testing.T) { w, err := client.Workspaces.Readme(ctx, wTest.ID) @@ -697,10 +697,10 @@ func TestWorkspacesReadByID(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) t.Run("when the workspace exists", func(t *testing.T) { w, err := client.Workspaces.ReadByID(ctx, wTest.ID) @@ -733,11 +733,12 @@ func TestWorkspacesUpdate(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) upgradeOrganizationSubscription(t, client, orgTest) - wTest, _ := createWorkspace(t, client, orgTest) + wTest, wCleanup := createWorkspace(t, client, orgTest) + t.Cleanup(wCleanup) t.Run("when updating a subset of values", func(t *testing.T) { options := WorkspaceUpdateOptions{ @@ -851,12 +852,13 @@ func TestWorkspacesUpdate(t *testing.T) { Name: String("tst-" + randomString(t)[0:20] + "-ff-on"), Email: String(fmt.Sprintf("%s@tfe.local", randomString(t))), }) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) - wTest, _ := createWorkspaceWithOptions(t, client, orgTest, WorkspaceCreateOptions{ + wTest, wCleanup := createWorkspaceWithOptions(t, client, orgTest, WorkspaceCreateOptions{ Name: String(randomString(t)), TriggerPrefixes: []string{"/prefix-1/", "/prefix-2/"}, }) + t.Cleanup(wCleanup) assert.Equal(t, wTest.TriggerPrefixes, []string{"/prefix-1/", "/prefix-2/"}) // Sanity test options := WorkspaceUpdateOptions{ @@ -899,12 +901,13 @@ func TestWorkspacesUpdate(t *testing.T) { Name: String("tst-" + randomString(t)[0:20] + "-ff-on"), Email: String(fmt.Sprintf("%s@tfe.local", randomString(t))), }) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) - wTest, _ := createWorkspaceWithOptions(t, client, orgTest, WorkspaceCreateOptions{ + wTest, wCleanup := createWorkspaceWithOptions(t, client, orgTest, WorkspaceCreateOptions{ Name: String(randomString(t)), TriggerPatterns: []string{"/pattern-1/**/*", "/pattern-2/**/*"}, }) + t.Cleanup(wCleanup) assert.Equal(t, wTest.TriggerPatterns, []string{"/pattern-1/**/*", "/pattern-2/**/*"}) // Sanity test options := WorkspaceUpdateOptions{ @@ -937,9 +940,10 @@ func TestWorkspacesUpdateTableDriven(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) - wTest, _ := createWorkspace(t, client, orgTest) + wTest, wCleanup := createWorkspace(t, client, orgTest) + t.Cleanup(wCleanup) workspaceTableTests := []WorkspaceTableTest{ { @@ -965,8 +969,8 @@ func TestWorkspacesUpdateTableDriven(t *testing.T) { wTest, wTestCleanup := createWorkspaceWithVCS(t, client, orgTest, *options.createOptions) return wTest, func() { - defer orgTestCleanup() - defer wTestCleanup() + t.Cleanup(orgTestCleanup) + t.Cleanup(wTestCleanup) } }, assertion: func(workspace *Workspace, options *WorkspaceTableOptions, _ error) { @@ -1074,9 +1078,10 @@ func TestWorkspacesUpdateByID(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) - wTest, _ := createWorkspace(t, client, orgTest) + wTest, wCleanup := createWorkspace(t, client, orgTest) + t.Cleanup(wCleanup) t.Run("when updating a subset of values", func(t *testing.T) { options := WorkspaceUpdateOptions{ @@ -1161,8 +1166,9 @@ func TestWorkspacesDelete(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) + // ignore workspace cleanup b/c it will be destroyed during tests wTest, _ := createWorkspace(t, client, orgTest) t.Run("with valid options", func(t *testing.T) { @@ -1192,8 +1198,9 @@ func TestWorkspacesDeleteByID(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) + // ignore workspace cleanup b/c it will be destroyed during tests wTest, _ := createWorkspace(t, client, orgTest) t.Run("with valid options", func(t *testing.T) { @@ -1211,6 +1218,135 @@ func TestWorkspacesDeleteByID(t *testing.T) { }) } +func TestCanForceDeletePermission(t *testing.T) { + skipIfNotCINode(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + wTest, wCleanup := createWorkspace(t, client, orgTest) + t.Cleanup(wCleanup) + + t.Run("workspace permission set includes can-force-delete", func(t *testing.T) { + w, err := client.Workspaces.ReadByID(ctx, wTest.ID) + require.NoError(t, err) + assert.Equal(t, wTest, w) + require.NotNil(t, w.Permissions) + require.NotNil(t, w.Permissions.CanForceDelete) + assert.True(t, *w.Permissions.CanForceDelete) + }) +} + +func TestWorkspacesSafeDelete(t *testing.T) { + skipIfNotCINode(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + // ignore workspace cleanup b/c it will be destroyed during tests + wTest, _ := createWorkspace(t, client, orgTest) + + t.Run("with valid options", func(t *testing.T) { + err := client.Workspaces.SafeDelete(ctx, orgTest.Name, wTest.Name) + require.NoError(t, err) + + // Try loading the workspace - it should fail. + _, err = client.Workspaces.Read(ctx, orgTest.Name, wTest.Name) + assert.Equal(t, ErrResourceNotFound, err) + }) + + t.Run("when organization is invalid", func(t *testing.T) { + err := client.Workspaces.SafeDelete(ctx, badIdentifier, wTest.Name) + assert.EqualError(t, err, ErrInvalidOrg.Error()) + }) + + t.Run("when workspace is invalid", func(t *testing.T) { + err := client.Workspaces.SafeDelete(ctx, orgTest.Name, badIdentifier) + assert.EqualError(t, err, ErrInvalidWorkspaceValue.Error()) + }) + + t.Run("when workspace is locked", func(t *testing.T) { + wTest, workspaceCleanup := createWorkspace(t, client, orgTest) + t.Cleanup(workspaceCleanup) + w, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{}) + require.NoError(t, err) + require.True(t, w.Locked) + + err = client.Workspaces.SafeDelete(ctx, orgTest.Name, wTest.Name) + assert.Contains(t, err.Error(), "conflict") + assert.Contains(t, err.Error(), "currently locked") + }) + + t.Run("when workspace has resources under management", func(t *testing.T) { + wTest, workspaceCleanup := createWorkspace(t, client, orgTest) + t.Cleanup(workspaceCleanup) + _, svTestCleanup := createStateVersion(t, client, 0, wTest) + t.Cleanup(svTestCleanup) + + err := client.Workspaces.SafeDelete(ctx, orgTest.Name, wTest.Name) + // cant verify the exact error here because it is timing dependent on the backend + // based on whether the state version has been processed yet + assert.Contains(t, err.Error(), "conflict") + }) +} + +func TestWorkspacesSafeDeleteByID(t *testing.T) { + skipIfNotCINode(t) + + client := testClient(t) + ctx := context.Background() + + orgTest, orgTestCleanup := createOrganization(t, client) + t.Cleanup(orgTestCleanup) + + // ignore workspace cleanup b/c it will be destroyed during tests + wTest, _ := createWorkspace(t, client, orgTest) + + t.Run("with valid options", func(t *testing.T) { + err := client.Workspaces.SafeDeleteByID(ctx, wTest.ID) + require.NoError(t, err) + + // Try loading the workspace - it should fail. + _, err = client.Workspaces.ReadByID(ctx, wTest.ID) + assert.Equal(t, ErrResourceNotFound, err) + }) + + t.Run("without a valid workspace ID", func(t *testing.T) { + err := client.Workspaces.SafeDeleteByID(ctx, badIdentifier) + assert.EqualError(t, err, ErrInvalidWorkspaceID.Error()) + }) + + t.Run("when workspace is locked", func(t *testing.T) { + wTest, workspaceCleanup := createWorkspace(t, client, orgTest) + t.Cleanup(workspaceCleanup) + w, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{}) + require.NoError(t, err) + require.True(t, w.Locked) + + err = client.Workspaces.SafeDeleteByID(ctx, wTest.ID) + assert.Contains(t, err.Error(), "conflict") + assert.Contains(t, err.Error(), "currently locked") + }) + + t.Run("when workspace has resources under management", func(t *testing.T) { + wTest, workspaceCleanup := createWorkspace(t, client, orgTest) + t.Cleanup(workspaceCleanup) + _, svTestCleanup := createStateVersion(t, client, 0, wTest) + t.Cleanup(svTestCleanup) + + err := client.Workspaces.SafeDeleteByID(ctx, wTest.ID) + // cant verify the exact error here because it is timing dependent on the backend + // based on whether the state version has been processed yet + assert.Contains(t, err.Error(), "conflict") + }) +} + func TestWorkspacesRemoveVCSConnection(t *testing.T) { skipIfNotCINode(t) @@ -1218,10 +1354,10 @@ func TestWorkspacesRemoveVCSConnection(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspaceWithVCS(t, client, orgTest, WorkspaceCreateOptions{}) - defer wTestCleanup() + t.Cleanup(wTestCleanup) t.Run("remove vcs integration", func(t *testing.T) { w, err := client.Workspaces.RemoveVCSConnection(ctx, orgTest.Name, wTest.Name) @@ -1237,10 +1373,10 @@ func TestWorkspacesRemoveVCSConnectionByID(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspaceWithVCS(t, client, orgTest, WorkspaceCreateOptions{}) - defer wTestCleanup() + t.Cleanup(wTestCleanup) t.Run("remove vcs integration", func(t *testing.T) { w, err := client.Workspaces.RemoveVCSConnectionByID(ctx, wTest.ID) @@ -1256,10 +1392,10 @@ func TestWorkspacesLock(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) t.Run("with valid options", func(t *testing.T) { w, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{}) @@ -1286,10 +1422,10 @@ func TestWorkspacesUnlock(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) w, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{}) if err != nil { @@ -1311,10 +1447,10 @@ func TestWorkspacesUnlock(t *testing.T) { t.Run("when a workspace is locked by a run", func(t *testing.T) { wTest2, wTest2Cleanup := createWorkspace(t, client, orgTest) - defer wTest2Cleanup() + t.Cleanup(wTest2Cleanup) _, rTestCleanup := createRun(t, client, wTest2) - defer rTestCleanup() + t.Cleanup(rTestCleanup) // Wait for wTest2 to be locked by a run waitForRunLock(t, client, wTest2.ID) @@ -1337,10 +1473,10 @@ func TestWorkspacesForceUnlock(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) w, err := client.Workspaces.Lock(ctx, wTest.ID, WorkspaceLockOptions{}) if err != nil { @@ -1374,13 +1510,13 @@ func TestWorkspacesAssignSSHKey(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) sshKeyTest, sshKeyTestCleanup := createSSHKey(t, client, orgTest) - defer sshKeyTestCleanup() + t.Cleanup(sshKeyTestCleanup) t.Run("with valid options", func(t *testing.T) { w, err := client.Workspaces.AssignSSHKey(ctx, wTest.ID, WorkspaceAssignSSHKeyOptions{ @@ -1421,13 +1557,13 @@ func TestWorkspacesUnassignSSHKey(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) sshKeyTest, sshKeyTestCleanup := createSSHKey(t, client, orgTest) - defer sshKeyTestCleanup() + t.Cleanup(sshKeyTestCleanup) w, err := client.Workspaces.AssignSSHKey(ctx, wTest.ID, WorkspaceAssignSSHKeyOptions{ SSHKeyID: String(sshKeyTest.ID), @@ -1459,10 +1595,10 @@ func TestWorkspaces_AddRemoteStateConsumers(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) // Update workspace to not allow global remote state options := WorkspaceUpdateOptions{ @@ -1473,9 +1609,9 @@ func TestWorkspaces_AddRemoteStateConsumers(t *testing.T) { t.Run("successfully adds a remote state consumer", func(t *testing.T) { wTestConsumer1, wTestCleanupConsumer1 := createWorkspace(t, client, orgTest) - defer wTestCleanupConsumer1() + t.Cleanup(wTestCleanupConsumer1) wTestConsumer2, wTestCleanupConsumer2 := createWorkspace(t, client, orgTest) - defer wTestCleanupConsumer2() + t.Cleanup(wTestCleanupConsumer2) err := client.Workspaces.AddRemoteStateConsumers(ctx, wTest.ID, WorkspaceAddRemoteStateConsumersOptions{ Workspaces: []*Workspace{wTestConsumer1, wTestConsumer2}, @@ -1518,10 +1654,10 @@ func TestWorkspaces_RemoveRemoteStateConsumers(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) // Update workspace to not allow global remote state options := WorkspaceUpdateOptions{ @@ -1532,9 +1668,9 @@ func TestWorkspaces_RemoveRemoteStateConsumers(t *testing.T) { t.Run("successfully removes a remote state consumer", func(t *testing.T) { wTestConsumer1, wTestCleanupConsumer1 := createWorkspace(t, client, orgTest) - defer wTestCleanupConsumer1() + t.Cleanup(wTestCleanupConsumer1) wTestConsumer2, wTestCleanupConsumer2 := createWorkspace(t, client, orgTest) - defer wTestCleanupConsumer2() + t.Cleanup(wTestCleanupConsumer2) err := client.Workspaces.AddRemoteStateConsumers(ctx, wTest.ID, WorkspaceAddRemoteStateConsumersOptions{ Workspaces: []*Workspace{wTestConsumer1, wTestConsumer2}, @@ -1596,10 +1732,10 @@ func TestWorkspaces_UpdateRemoteStateConsumers(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) // Update workspace to not allow global remote state options := WorkspaceUpdateOptions{ @@ -1610,9 +1746,9 @@ func TestWorkspaces_UpdateRemoteStateConsumers(t *testing.T) { t.Run("successfully updates a remote state consumer", func(t *testing.T) { wTestConsumer1, wTestCleanupConsumer1 := createWorkspace(t, client, orgTest) - defer wTestCleanupConsumer1() + t.Cleanup(wTestCleanupConsumer1) wTestConsumer2, wTestCleanupConsumer2 := createWorkspace(t, client, orgTest) - defer wTestCleanupConsumer2() + t.Cleanup(wTestCleanupConsumer2) err := client.Workspaces.AddRemoteStateConsumers(ctx, wTest.ID, WorkspaceAddRemoteStateConsumersOptions{ Workspaces: []*Workspace{wTestConsumer1}, @@ -1662,10 +1798,10 @@ func TestWorkspaces_AddTags(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) options := WorkspaceAddTagsOptions{ Tags: []*Tag{ @@ -1713,7 +1849,7 @@ func TestWorkspaces_AddTags(t *testing.T) { t.Run("successfully adds tags by id and name", func(t *testing.T) { wTest2, wTest2Cleanup := createWorkspace(t, client, orgTest) - defer wTest2Cleanup() + t.Cleanup(wTest2Cleanup) // add a tag to another workspace err := client.Workspaces.AddTags(ctx, wTest2.ID, WorkspaceAddTagsOptions{ @@ -1776,10 +1912,10 @@ func TestWorkspaces_RemoveTags(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) tags := []*Tag{ { @@ -1949,10 +2085,10 @@ func TestWorkspacesRunTasksPermission(t *testing.T) { ctx := context.Background() orgTest, orgTestCleanup := createOrganization(t, client) - defer orgTestCleanup() + t.Cleanup(orgTestCleanup) wTest, wTestCleanup := createWorkspace(t, client, orgTest) - defer wTestCleanup() + t.Cleanup(wTestCleanup) t.Run("when the workspace exists", func(t *testing.T) { w, err := client.Workspaces.Read(ctx, orgTest.Name, wTest.Name)