fix: complete job and mark workspace as deleted when no provisioners are available (#18465)

Alternate fix for https://github.com/coder/coder/issues/18080

Modifies wsbuilder to complete the provisioner job and mark the
workspace as deleted if it is clear that no provisioner will be able to
pick up the delete build.

This has a significant advantage of not deviating too much from the
current semantics of `POST /api/v2/workspacebuilds`.
https://github.com/coder/coder/pull/18460 ends up returning a 204 on
orphan delete due to no build being created.

Downside is that we have to duplicate some responsibilities of
provisionerdserver in wsbuilder.

There is a slight gotcha to this approach though: if you stop a
provisioner and then immediately try to orphan-delete, the job will
still be created because of the provisioner heartbeat interval. However
you can cancel it and try again.
This commit is contained in:
Cian Johnston
2025-06-23 14:07:42 +01:00
committed by GitHub
parent c3bc1e75ec
commit 2f55e29466
7 changed files with 502 additions and 86 deletions

View File

@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"io" "io"
"net/http"
"testing" "testing"
"time" "time"
@ -60,28 +61,35 @@ func TestDelete(t *testing.T) {
t.Parallel() t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client) owner := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID) template := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) workspace := coderdtest.CreateWorkspace(t, templateAdmin, template.ID)
inv, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan") 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{}) doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv) pty := ptytest.New(t).Attach(inv)
inv.Stderr = pty.Output() inv.Stderr = pty.Output()
go func() { go func() {
defer close(doneChan) defer close(doneChan)
err := inv.Run() err := inv.WithContext(ctx).Run()
// When running with the race detector on, we sometimes get an EOF. // When running with the race detector on, we sometimes get an EOF.
if err != nil { if err != nil {
assert.ErrorIs(t, err, io.EOF) assert.ErrorIs(t, err, io.EOF)
} }
}() }()
pty.ExpectMatch("has been deleted") 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. // Super orphaned, as the workspace doesn't even have a user.

View File

@ -4497,7 +4497,8 @@ func (q *FakeQuerier) GetProvisionerDaemons(_ context.Context) ([]database.Provi
defer q.mutex.RUnlock() defer q.mutex.RUnlock()
if len(q.provisionerDaemons) == 0 { 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 // copy the data so that the caller can't manipulate any data inside dbmem
// after returning // after returning

View File

@ -3,6 +3,7 @@ package coderd
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"math" "math"
@ -433,20 +434,56 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
return return
} }
var queuePos database.GetProvisionerJobsByIDsWithQueuePositionRow
if provisionerJob != nil { if provisionerJob != nil {
queuePos.ProvisionerJob = *provisionerJob
queuePos.QueuePosition = 0
if err := provisionerjobs.PostJob(api.Pubsub, *provisionerJob); err != nil { if err := provisionerjobs.PostJob(api.Pubsub, *provisionerJob); err != nil {
// Client probably doesn't care about this error, so just log it. // 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)) 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( apiBuild, err := api.convertWorkspaceBuild(
*workspaceBuild, *workspaceBuild,
workspace, workspace,
database.GetProvisionerJobsByIDsWithQueuePositionRow{ queuePos,
ProvisionerJob: *provisionerJob,
QueuePosition: 0,
},
[]database.WorkspaceResource{}, []database.WorkspaceResource{},
[]database.WorkspaceResourceMetadatum{}, []database.WorkspaceResourceMetadatum{},
[]database.WorkspaceAgent{}, []database.WorkspaceAgent{},

View File

@ -1,6 +1,7 @@
package coderd_test package coderd_test
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
@ -25,6 +26,7 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest/oidctest" "github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz" "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/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime" "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.Run("Orphan", func(t *testing.T) {
t.Parallel() t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
first := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) t.Run("WithoutDelete", func(t *testing.T) {
defer cancel() t.Parallel()
client, store := coderdtest.NewWithDatabase(t, nil)
first := coderdtest.CreateFirstUser(t, client)
templateAdmin, templateAdminUser := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleTemplateAdmin())
version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil) r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) OwnerID: templateAdminUser.ID,
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) OrganizationID: first.OrganizationID,
}).Do()
workspace := coderdtest.CreateWorkspace(t, client, template.ID) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) defer cancel()
// Providing both state and orphan fails. // Trying to orphan without delete transition fails.
_, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ _, err := templateAdmin.CreateWorkspaceBuild(ctx, r.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: workspace.LatestBuild.TemplateVersionID, TemplateVersionID: r.TemplateVersion.ID,
Transition: codersdk.WorkspaceTransitionDelete, Transition: codersdk.WorkspaceTransitionStart,
ProvisionerState: []byte(" "), Orphan: true,
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())
}) })
require.Error(t, err)
cerr := coderdtest.SDKError(t, err)
require.Equal(t, http.StatusBadRequest, cerr.StatusCode())
// Regular orphan operation succeeds. t.Run("WithState", func(t *testing.T) {
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ t.Parallel()
TemplateVersionID: workspace.LatestBuild.TemplateVersionID, client, store := coderdtest.NewWithDatabase(t, nil)
Transition: codersdk.WorkspaceTransitionDelete, first := coderdtest.CreateFirstUser(t, client)
Orphan: true, 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())
}) })
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID)
_, err = client.Workspace(ctx, workspace.ID) t.Run("NoPermission", func(t *testing.T) {
require.Error(t, err) t.Parallel()
require.Equal(t, http.StatusGone, coderdtest.SDKError(t, err).StatusCode()) 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())
})
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,
}))
})
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,
}))
})
}) })
} }

View File

@ -464,6 +464,50 @@ func (b *Builder) buildTx(authFunc func(action policy.Action, object rbac.Object
return BuildError{http.StatusInternalServerError, "get workspace build", err} 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 return nil
}, nil) }, nil)
if err != nil { if err != nil {

View File

@ -839,6 +839,147 @@ func TestWorkspaceBuildWithPreset(t *testing.T) {
req.NoError(err) 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) { func TestProvisionerVersionSupportsDynamicParameters(t *testing.T) {
t.Parallel() 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) { func withBuild(mTx *dbmock.MockStore) {
mTx.EXPECT().GetWorkspaceBuildByID(gomock.Any(), gomock.Any()).Times(1). mTx.EXPECT().GetWorkspaceBuildByID(gomock.Any(), gomock.Any()).Times(1).
DoAndReturn(func(ctx context.Context, id uuid.UUID) (database.WorkspaceBuild, error) { DoAndReturn(func(ctx context.Context, id uuid.UUID) (database.WorkspaceBuild, error) {

View File

@ -43,6 +43,18 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
const hasError = !deletionConfirmed && userConfirmationText.length > 0; const hasError = !deletionConfirmed && userConfirmationText.length > 0;
const displayErrorMessage = hasError && !isFocused; const displayErrorMessage = hasError && !isFocused;
const inputColor = hasError ? "error" : "primary"; 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 ( return (
<ConfirmDialog <ConfirmDialog
@ -97,49 +109,41 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
"data-testid": "delete-dialog-name-confirmation", "data-testid": "delete-dialog-name-confirmation",
}} }}
/> />
{ {canOrphan && (
// Orphaning is sort of a "last resort" that should really only <div css={styles.orphanContainer}>
// be used if Terraform is failing to apply while deleting, which <div css={{ flexDirection: "column" }}>
// usually means that builds are failing as well. <Checkbox
canDeleteFailedWorkspace && id="orphan_resources"
workspace.latest_build.status === "failed" && ( size="small"
<div css={styles.orphanContainer}> color="warning"
<div css={{ flexDirection: "column" }}> onChange={() => {
<Checkbox setOrphanWorkspace(!orphanWorkspace);
id="orphan_resources" }}
size="small" className="option"
color="warning" name="orphan_resources"
onChange={() => { checked={orphanWorkspace}
setOrphanWorkspace(!orphanWorkspace); data-testid="orphan-checkbox"
}} />
className="option" </div>
name="orphan_resources" <div css={{ flexDirection: "column" }}>
checked={orphanWorkspace} <p className="info">Orphan Resources</p>
data-testid="orphan-checkbox" <span css={{ fontSize: 12, marginTop: 4, display: "block" }}>
/> As a Template Admin, you may skip resource cleanup to delete
</div> a failed workspace. Resources such as volumes and virtual
<div css={{ flexDirection: "column" }}> machines will not be destroyed.&nbsp;
<p className="info">Orphan Resources</p> <Link
<span href={docs(
css={{ fontSize: 12, marginTop: 4, display: "block" }} "/user-guides/workspace-management#workspace-resources",
> )}
As a Template Admin, you may skip resource cleanup to target="_blank"
delete a failed workspace. Resources such as volumes and rel="noreferrer"
virtual machines will not be destroyed.&nbsp; >
<Link Learn more...
href={docs( </Link>
"/user-guides/workspace-management#workspace-resources", </span>
)} </div>
target="_blank" </div>
rel="noreferrer" )}
>
Learn more...
</Link>
</span>
</div>
</div>
)
}
</form> </form>
</> </>
} }