diff --git a/cli/delete_test.go b/cli/delete_test.go index 1d4dc8dfb40ad..bde50a13158cd 100644 --- a/cli/delete_test.go +++ b/cli/delete_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -51,28 +52,35 @@ func TestDelete(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) owner := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin()) + version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID) + template := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, templateAdmin, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, templateAdmin, workspace.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitShort) inv, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan") + clitest.SetupConfig(t, templateAdmin, root) - //nolint:gocritic // Deleting orphaned workspaces requires an admin. - clitest.SetupConfig(t, client, root) doneChan := make(chan struct{}) pty := ptytest.New(t).Attach(inv) inv.Stderr = pty.Output() go func() { defer close(doneChan) - err := inv.Run() + err := inv.WithContext(ctx).Run() // When running with the race detector on, we sometimes get an EOF. if err != nil { assert.ErrorIs(t, err, io.EOF) } }() pty.ExpectMatch("has been deleted") - <-doneChan + testutil.TryReceive(ctx, t, doneChan) + + _, err := client.Workspace(ctx, workspace.ID) + require.Error(t, err) + cerr := coderdtest.SDKError(t, err) + require.Equal(t, http.StatusGone, cerr.StatusCode()) }) // Super orphaned, as the workspace doesn't even have a user. diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index ee1c7471808d5..ce15b0b05264c 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -4464,7 +4464,8 @@ func (q *FakeQuerier) GetProvisionerDaemons(_ context.Context) ([]database.Provi defer q.mutex.RUnlock() if len(q.provisionerDaemons) == 0 { - return nil, sql.ErrNoRows + // Returning err=nil here for consistency with real querier + return []database.ProvisionerDaemon{}, nil } // copy the data so that the caller can't manipulate any data inside dbmem // after returning diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 4d90948a8f9a1..e816c32d78593 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -3,6 +3,7 @@ package coderd import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "math" @@ -423,20 +424,56 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } + var queuePos database.GetProvisionerJobsByIDsWithQueuePositionRow if provisionerJob != nil { + queuePos.ProvisionerJob = *provisionerJob + queuePos.QueuePosition = 0 if err := provisionerjobs.PostJob(api.Pubsub, *provisionerJob); err != nil { // Client probably doesn't care about this error, so just log it. api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err)) } + + // We may need to complete the audit if wsbuilder determined that + // no provisioner could handle an orphan-delete job and completed it. + if createBuild.Orphan && createBuild.Transition == codersdk.WorkspaceTransitionDelete && provisionerJob.CompletedAt.Valid { + api.Logger.Warn(ctx, "orphan delete handled by wsbuilder due to no eligible provisioners", + slog.F("workspace_id", workspace.ID), + slog.F("workspace_build_id", workspaceBuild.ID), + slog.F("provisioner_job_id", provisionerJob.ID), + ) + buildResourceInfo := audit.AdditionalFields{ + WorkspaceName: workspace.Name, + BuildNumber: strconv.Itoa(int(workspaceBuild.BuildNumber)), + BuildReason: workspaceBuild.Reason, + WorkspaceID: workspace.ID, + WorkspaceOwner: workspace.OwnerName, + } + briBytes, err := json.Marshal(buildResourceInfo) + if err != nil { + api.Logger.Error(ctx, "failed to marshal build resource info for audit", slog.Error(err)) + } + auditor := api.Auditor.Load() + bag := audit.BaggageFromContext(ctx) + audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceBuild]{ + Audit: *auditor, + Log: api.Logger, + UserID: provisionerJob.InitiatorID, + OrganizationID: workspace.OrganizationID, + RequestID: provisionerJob.ID, + IP: bag.IP, + Action: database.AuditActionDelete, + Old: previousWorkspaceBuild, + New: *workspaceBuild, + Status: http.StatusOK, + AdditionalFields: briBytes, + }) + } } apiBuild, err := api.convertWorkspaceBuild( *workspaceBuild, workspace, - database.GetProvisionerJobsByIDsWithQueuePositionRow{ - ProvisionerJob: *provisionerJob, - QueuePosition: 0, - }, + queuePos, []database.WorkspaceResource{}, []database.WorkspaceResourceMetadatum{}, []database.WorkspaceAgent{}, diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index ac33c9e92c4f7..135226ca615f7 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -1,6 +1,7 @@ package coderd_test import ( + "bytes" "context" "database/sql" "errors" @@ -25,6 +26,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbfake" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -371,42 +373,174 @@ func TestWorkspaceBuildsProvisionerState(t *testing.T) { t.Run("Orphan", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - first := coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + t.Run("WithoutDelete", func(t *testing.T) { + t.Parallel() + client, store := coderdtest.NewWithDatabase(t, nil) + first := coderdtest.CreateFirstUser(t, client) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin()) + + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: templateAdminUser.ID, + OrganizationID: first.OrganizationID, + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Trying to orphan without delete transition fails. + _, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionStart, + Orphan: true, + }) + require.Error(t, err, "Orphan is only permitted when deleting a workspace.") + cerr := coderdtest.SDKError(t, err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) - workspace := coderdtest.CreateWorkspace(t, client, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + t.Run("WithState", func(t *testing.T) { + t.Parallel() + client, store := coderdtest.NewWithDatabase(t, nil) + first := coderdtest.CreateFirstUser(t, client) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin()) + + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: templateAdminUser.ID, + OrganizationID: first.OrganizationID, + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Providing both state and orphan fails. + _, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionDelete, + ProvisionerState: []byte(" "), + Orphan: true, + }) + require.Error(t, err) + cerr := coderdtest.SDKError(t, err) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) - // Providing both state and orphan fails. - _, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - Transition: codersdk.WorkspaceTransitionDelete, - ProvisionerState: []byte(" "), - Orphan: true, + t.Run("NoPermission", func(t *testing.T) { + t.Parallel() + client, store := coderdtest.NewWithDatabase(t, nil) + first := coderdtest.CreateFirstUser(t, client) + member, memberUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: memberUser.ID, + OrganizationID: first.OrganizationID, + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Trying to orphan without being a template admin fails. + _, err := member.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionDelete, + Orphan: true, + }) + require.Error(t, err) + cerr := coderdtest.SDKError(t, err) + require.Equal(t, http.StatusForbidden, cerr.StatusCode()) }) - require.Error(t, err) - cerr := coderdtest.SDKError(t, err) - require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) - // Regular orphan operation succeeds. - build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: workspace.LatestBuild.TemplateVersionID, - Transition: codersdk.WorkspaceTransitionDelete, - Orphan: true, + t.Run("OK", func(t *testing.T) { + // Include a provisioner so that we can test that provisionerdserver + // performs deletion. + auditor := audit.NewMock() + client, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor}) + first := coderdtest.CreateFirstUser(t, client) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + // This is a valid zip file. Without this the job will fail to complete. + // TODO: add this to dbfake by default. + zipBytes := make([]byte, 22) + zipBytes[0] = 80 + zipBytes[1] = 75 + zipBytes[2] = 0o5 + zipBytes[3] = 0o6 + uploadRes, err := client.Upload(ctx, codersdk.ContentTypeZip, bytes.NewReader(zipBytes)) + require.NoError(t, err) + + tv := dbfake.TemplateVersion(t, store). + FileID(uploadRes.ID). + Seed(database.TemplateVersion{ + OrganizationID: first.OrganizationID, + CreatedBy: templateAdminUser.ID, + }). + Do() + + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: templateAdminUser.ID, + OrganizationID: first.OrganizationID, + TemplateID: tv.Template.ID, + }).Do() + + auditor.ResetLogs() + // Regular orphan operation succeeds. + build, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionDelete, + Orphan: true, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + // Validate that the deletion was audited. + require.True(t, auditor.Contains(t, database.AuditLog{ + ResourceID: build.ID, + Action: database.AuditActionDelete, + })) }) - require.NoError(t, err) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) - _, err = client.Workspace(ctx, workspace.ID) - require.Error(t, err) - require.Equal(t, http.StatusGone, coderdtest.SDKError(t, err).StatusCode()) + t.Run("NoProvisioners", func(t *testing.T) { + t.Parallel() + auditor := audit.NewMock() + client, store := coderdtest.NewWithDatabase(t, &coderdtest.Options{Auditor: auditor}) + first := coderdtest.CreateFirstUser(t, client) + templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin()) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{ + OwnerID: templateAdminUser.ID, + OrganizationID: first.OrganizationID, + }).Do() + + // nolint:gocritic // For testing + daemons, err := store.GetProvisionerDaemons(dbauthz.AsSystemReadProvisionerDaemons(ctx)) + require.NoError(t, err) + require.Empty(t, daemons, "Provisioner daemons should be empty for this test") + + // Orphan deletion still succeeds despite no provisioners being available. + build, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: r.TemplateVersion.ID, + Transition: codersdk.WorkspaceTransitionDelete, + Orphan: true, + }) + require.NoError(t, err) + require.Equal(t, codersdk.WorkspaceTransitionDelete, build.Transition) + require.Equal(t, codersdk.ProvisionerJobSucceeded, build.Job.Status) + require.Empty(t, build.Job.Error) + + ws, err := client.Workspace(ctx, r.Workspace.ID) + require.Empty(t, ws) + require.Equal(t, http.StatusGone, coderdtest.SDKError(t, err).StatusCode()) + + // Validate that the deletion was audited. + require.True(t, auditor.Contains(t, database.AuditLog{ + ResourceID: build.ID, + Action: database.AuditActionDelete, + })) + }) }) } diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 9605df58014de..1a754467971aa 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -464,6 +464,50 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object return BuildError{http.StatusInternalServerError, "get workspace build", err} } + // If the requestor is trying to orphan-delete a workspace and there are no + // provisioners available, we should complete the build and mark the + // workspace as deleted ourselves. + // There are cases where tagged provisioner daemons have been decommissioned + // without deleting the relevant workspaces, and without any provisioners + // available these workspaces cannot be deleted. + // Orphan-deleting a workspace sends an empty state to Terraform, which means + // it won't actually delete anything. So we actually don't need to execute a + // provisioner job at all for an orphan delete, but deleting without a workspace + // build or provisioner job would result in no audit log entry, which is a deal-breaker. + hasActiveEligibleProvisioner := false + for _, pd := range provisionerDaemons { + age := now.Sub(pd.ProvisionerDaemon.LastSeenAt.Time) + if age <= provisionerdserver.StaleInterval { + hasActiveEligibleProvisioner = true + break + } + } + if b.state.orphan && !hasActiveEligibleProvisioner { + // nolint: gocritic // At this moment, we are pretending to be provisionerd. + if err := store.UpdateProvisionerJobWithCompleteWithStartedAtByID(dbauthz.AsProvisionerd(b.ctx), database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams{ + CompletedAt: sql.NullTime{Valid: true, Time: now}, + Error: sql.NullString{Valid: false}, + ErrorCode: sql.NullString{Valid: false}, + ID: provisionerJob.ID, + StartedAt: sql.NullTime{Valid: true, Time: now}, + UpdatedAt: now, + }); err != nil { + return BuildError{http.StatusInternalServerError, "mark orphan-delete provisioner job as completed", err} + } + + // Re-fetch the completed provisioner job. + if pj, err := store.GetProvisionerJobByID(b.ctx, provisionerJob.ID); err == nil { + provisionerJob = pj + } + + if err := store.UpdateWorkspaceDeletedByID(b.ctx, database.UpdateWorkspaceDeletedByIDParams{ + ID: b.workspace.ID, + Deleted: true, + }); err != nil { + return BuildError{http.StatusInternalServerError, "mark workspace as deleted", err} + } + } + return nil }, nil) if err != nil { diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index 58999a33e6e5e..f6b4756455f14 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -839,6 +839,147 @@ func TestWorkspaceBuildWithPreset(t *testing.T) { req.NoError(err) } +func TestWorkspaceBuildDeleteOrphan(t *testing.T) { + t.Parallel() + + t.Run("WithActiveProvisioners", func(t *testing.T) { + t.Parallel() + req := require.New(t) + asrt := assert.New(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var buildID uuid.UUID + + mDB := expectDB(t, + // Inputs + withTemplate, + withInactiveVersion(nil), + withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), + withRichParameters(nil), + withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{{ + JobID: inactiveJobID, + ProvisionerDaemon: database.ProvisionerDaemon{ + LastSeenAt: sql.NullTime{Valid: true, Time: dbtime.Now()}, + }, + }}), + + // Outputs + expectProvisionerJob(func(job database.InsertProvisionerJobParams) { + asrt.Equal(userID, job.InitiatorID) + asrt.Equal(inactiveFileID, job.FileID) + input := provisionerdserver.WorkspaceProvisionJob{} + err := json.Unmarshal(job.Input, &input) + req.NoError(err) + // store build ID for later + buildID = input.WorkspaceBuildID + }), + + withInTx, + expectBuild(func(bld database.InsertWorkspaceBuildParams) { + asrt.Equal(inactiveVersionID, bld.TemplateVersionID) + asrt.Equal(workspaceID, bld.WorkspaceID) + asrt.Equal(int32(2), bld.BuildNumber) + asrt.Empty(string(bld.ProvisionerState)) + asrt.Equal(userID, bld.InitiatorID) + asrt.Equal(database.WorkspaceTransitionDelete, bld.Transition) + asrt.Equal(database.BuildReasonInitiator, bld.Reason) + asrt.Equal(buildID, bld.ID) + }), + withBuild, + expectBuildParameters(func(params database.InsertWorkspaceBuildParametersParams) { + asrt.Equal(buildID, params.WorkspaceBuildID) + asrt.Empty(params.Name) + asrt.Empty(params.Value) + }), + ) + + ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} + uut := wsbuilder.New(ws, database.WorkspaceTransitionDelete).Orphan() + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + req.NoError(err) + }) + + t.Run("NoActiveProvisioners", func(t *testing.T) { + t.Parallel() + req := require.New(t) + asrt := assert.New(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var buildID uuid.UUID + var jobID uuid.UUID + + mDB := expectDB(t, + // Inputs + withTemplate, + withInactiveVersion(nil), + withLastBuildFound, + withTemplateVersionVariables(inactiveVersionID, nil), + withRichParameters(nil), + withWorkspaceTags(inactiveVersionID, nil), + withProvisionerDaemons([]database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow{}), + + // Outputs + expectProvisionerJob(func(job database.InsertProvisionerJobParams) { + asrt.Equal(userID, job.InitiatorID) + asrt.Equal(inactiveFileID, job.FileID) + input := provisionerdserver.WorkspaceProvisionJob{} + err := json.Unmarshal(job.Input, &input) + req.NoError(err) + // store build ID for later + buildID = input.WorkspaceBuildID + // store job ID for later + jobID = job.ID + }), + + withInTx, + expectBuild(func(bld database.InsertWorkspaceBuildParams) { + asrt.Equal(inactiveVersionID, bld.TemplateVersionID) + asrt.Equal(workspaceID, bld.WorkspaceID) + asrt.Equal(int32(2), bld.BuildNumber) + asrt.Empty(string(bld.ProvisionerState)) + asrt.Equal(userID, bld.InitiatorID) + asrt.Equal(database.WorkspaceTransitionDelete, bld.Transition) + asrt.Equal(database.BuildReasonInitiator, bld.Reason) + asrt.Equal(buildID, bld.ID) + }), + withBuild, + expectBuildParameters(func(params database.InsertWorkspaceBuildParametersParams) { + asrt.Equal(buildID, params.WorkspaceBuildID) + asrt.Empty(params.Name) + asrt.Empty(params.Value) + }), + + // Because no provisioners were available and the request was to delete --orphan + expectUpdateProvisionerJobWithCompleteWithStartedAtByID(func(params database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams) { + asrt.Equal(jobID, params.ID) + asrt.False(params.Error.Valid) + asrt.True(params.CompletedAt.Valid) + asrt.True(params.StartedAt.Valid) + }), + expectUpdateWorkspaceDeletedByID(func(params database.UpdateWorkspaceDeletedByIDParams) { + asrt.Equal(workspaceID, params.ID) + asrt.True(params.Deleted) + }), + expectGetProvisionerJobByID(func(job database.ProvisionerJob) { + asrt.Equal(jobID, job.ID) + }), + ) + + ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} + uut := wsbuilder.New(ws, database.WorkspaceTransitionDelete).Orphan() + // nolint: dogsled + _, _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + req.NoError(err) + }) +} + func TestProvisionerVersionSupportsDynamicParameters(t *testing.T) { t.Parallel() @@ -1107,6 +1248,53 @@ func expectProvisionerJob( } } +// expectUpdateProvisionerJobWithCompleteWithStartedAtByID asserts a call to +// expectUpdateProvisionerJobWithCompleteWithStartedAtByID and runs the provided +// assertions against it. +func expectUpdateProvisionerJobWithCompleteWithStartedAtByID(assertions func(params database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams)) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().UpdateProvisionerJobWithCompleteWithStartedAtByID(gomock.Any(), gomock.Any()). + Times(1). + DoAndReturn( + func(ctx context.Context, params database.UpdateProvisionerJobWithCompleteWithStartedAtByIDParams) error { + assertions(params) + return nil + }, + ) + } +} + +// expectUpdateWorkspaceDeletedByID asserts a call to UpdateWorkspaceDeletedByID +// and runs the provided assertions against it. +func expectUpdateWorkspaceDeletedByID(assertions func(params database.UpdateWorkspaceDeletedByIDParams)) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().UpdateWorkspaceDeletedByID(gomock.Any(), gomock.Any()). + Times(1). + DoAndReturn( + func(ctx context.Context, params database.UpdateWorkspaceDeletedByIDParams) error { + assertions(params) + return nil + }, + ) + } +} + +// expectGetProvisionerJobByID asserts a call to GetProvisionerJobByID +// and runs the provided assertions against it. +func expectGetProvisionerJobByID(assertions func(job database.ProvisionerJob)) func(mTx *dbmock.MockStore) { + return func(mTx *dbmock.MockStore) { + mTx.EXPECT().GetProvisionerJobByID(gomock.Any(), gomock.Any()). + Times(1). + DoAndReturn( + func(ctx context.Context, id uuid.UUID) (database.ProvisionerJob, error) { + job := database.ProvisionerJob{ID: id} + assertions(job) + return job, nil + }, + ) + } +} + func withBuild(mTx *dbmock.MockStore) { mTx.EXPECT().GetWorkspaceBuildByID(gomock.Any(), gomock.Any()).Times(1). DoAndReturn(func(ctx context.Context, id uuid.UUID) (database.WorkspaceBuild, error) { diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx index 8f5179b0b64da..2cfb74f2765c3 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx @@ -43,6 +43,18 @@ export const WorkspaceDeleteDialog: FC = ({ const hasError = !deletionConfirmed && userConfirmationText.length > 0; const displayErrorMessage = hasError && !isFocused; const inputColor = hasError ? "error" : "primary"; + // Orphaning is sort of a "last resort" that should really only + // be used under the following circumstances: + // a) Terraform is failing to apply while deleting, which + // usually means that builds are failing as well. + // b) No provisioner is available to delete the workspace, which will + // cause the job to remain in the "pending" state indefinitely. + // The assumption here is that an admin will cancel the job, in which + // case we want to allow them to perform an orphan-delete. + const canOrphan = + canDeleteFailedWorkspace && + (workspace.latest_build.status === "failed" || + workspace.latest_build.status === "canceled"); return ( = ({ "data-testid": "delete-dialog-name-confirmation", }} /> - { - // Orphaning is sort of a "last resort" that should really only - // be used if Terraform is failing to apply while deleting, which - // usually means that builds are failing as well. - canDeleteFailedWorkspace && - workspace.latest_build.status === "failed" && ( -
-
- { - setOrphanWorkspace(!orphanWorkspace); - }} - className="option" - name="orphan_resources" - checked={orphanWorkspace} - data-testid="orphan-checkbox" - /> -
-
-

Orphan Resources

- - As a Template Admin, you may skip resource cleanup to - delete a failed workspace. Resources such as volumes and - virtual machines will not be destroyed.  - - Learn more... - - -
-
- ) - } + {canOrphan && ( +
+
+ { + setOrphanWorkspace(!orphanWorkspace); + }} + className="option" + name="orphan_resources" + checked={orphanWorkspace} + data-testid="orphan-checkbox" + /> +
+
+

Orphan Resources

+ + As a Template Admin, you may skip resource cleanup to delete + a failed workspace. Resources such as volumes and virtual + machines will not be destroyed.  + + Learn more... + + +
+
+ )} } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy