feat!: add ability to cancel pending workspace build (#18713)

Closes #17791 

This PR adds ability to cancel workspace builds that are in "pending"
status.

Breaking changes:
- CancelWorkspaceBuild method in codersdk now accepts an optional
request parameter

API:
- Added `expect_status` query parameter to the cancel workspace build
endpoint
- This parameter ensures the job hasn't changed state before canceling
- API returns `412 Precondition Failed` if the job is not in the
expected status
- Valid values: `running` or `pending`
- Wrapped the entire cancel method in a database transaction

UI:
- Added confirmation dialog to the `Cancel` button, since it's a
destructive operation

![image](https://github.com/user-attachments/assets/437aa5f4-5669-45b6-82a0-e46f277114bf)

![image](https://github.com/user-attachments/assets/423b5cb1-a4fb-4a10-933b-c1c73f4b838c)


- Enabled cancel action for pending workspaces (`expect_status=pending`
is sent if workspace is in pending status)

![image](https://github.com/user-attachments/assets/32d35ff1-12e6-4f7b-9f6c-fde9da9de6cf)

---------

Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
Kacper Sawicki
2025-07-08 11:02:58 +02:00
committed by GitHub
parent 2f42b64182
commit 8202514ce0
20 changed files with 556 additions and 93 deletions

View File

@ -166,7 +166,7 @@ func (r *RootCmd) provisionerJobsCancel() *serpent.Command {
err = client.CancelTemplateVersion(ctx, ptr.NilToEmpty(job.Input.TemplateVersionID))
case codersdk.ProvisionerJobTypeWorkspaceBuild:
_, _ = fmt.Fprintf(inv.Stdout, "Canceling workspace build job %s...\n", job.ID)
err = client.CancelWorkspaceBuild(ctx, ptr.NilToEmpty(job.Input.WorkspaceBuildID))
err = client.CancelWorkspaceBuild(ctx, ptr.NilToEmpty(job.Input.WorkspaceBuildID), codersdk.CancelWorkspaceBuildParams{})
}
if err != nil {
return xerrors.Errorf("cancel provisioner job: %w", err)

10
coderd/apidoc/docs.go generated
View File

@ -9122,6 +9122,16 @@ const docTemplate = `{
"name": "workspacebuild",
"in": "path",
"required": true
},
{
"enum": [
"running",
"pending"
],
"type": "string",
"description": "Expected status of the job. If expect_status is supplied, the request will be rejected with 412 Precondition Failed if the job doesn't match the state when performing the cancellation.",
"name": "expect_status",
"in": "query"
}
],
"responses": {

View File

@ -8065,6 +8065,13 @@
"name": "workspacebuild",
"in": "path",
"required": true
},
{
"enum": ["running", "pending"],
"type": "string",
"description": "Expected status of the job. If expect_status is supplied, the request will be rejected with 412 Precondition Failed if the job doesn't match the state when performing the cancellation.",
"name": "expect_status",
"in": "query"
}
],
"responses": {

View File

@ -1182,6 +1182,27 @@ func (q *querier) customRoleCheck(ctx context.Context, role database.CustomRole)
return nil
}
func (q *querier) authorizeProvisionerJob(ctx context.Context, job database.ProvisionerJob) error {
switch job.Type {
case database.ProvisionerJobTypeWorkspaceBuild:
// Authorized call to get workspace build. If we can read the build, we
// can read the job.
_, err := q.GetWorkspaceBuildByJobID(ctx, job.ID)
if err != nil {
return xerrors.Errorf("fetch related workspace build: %w", err)
}
case database.ProvisionerJobTypeTemplateVersionDryRun, database.ProvisionerJobTypeTemplateVersionImport:
// Authorized call to get template version.
_, err := authorizedTemplateVersionFromJob(ctx, q, job)
if err != nil {
return xerrors.Errorf("fetch related template version: %w", err)
}
default:
return xerrors.Errorf("unknown job type: %q", job.Type)
}
return nil
}
func (q *querier) AcquireLock(ctx context.Context, id int64) error {
return q.db.AcquireLock(ctx, id)
}
@ -2445,32 +2466,24 @@ func (q *querier) GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (data
return database.ProvisionerJob{}, err
}
switch job.Type {
case database.ProvisionerJobTypeWorkspaceBuild:
// Authorized call to get workspace build. If we can read the build, we
// can read the job.
_, err := q.GetWorkspaceBuildByJobID(ctx, id)
if err != nil {
return database.ProvisionerJob{}, xerrors.Errorf("fetch related workspace build: %w", err)
}
case database.ProvisionerJobTypeTemplateVersionDryRun, database.ProvisionerJobTypeTemplateVersionImport:
// Authorized call to get template version.
_, err := authorizedTemplateVersionFromJob(ctx, q, job)
if err != nil {
return database.ProvisionerJob{}, xerrors.Errorf("fetch related template version: %w", err)
}
default:
return database.ProvisionerJob{}, xerrors.Errorf("unknown job type: %q", job.Type)
if err := q.authorizeProvisionerJob(ctx, job); err != nil {
return database.ProvisionerJob{}, err
}
return job, nil
}
func (q *querier) GetProvisionerJobByIDForUpdate(ctx context.Context, id uuid.UUID) (database.ProvisionerJob, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceProvisionerJobs); err != nil {
job, err := q.db.GetProvisionerJobByIDForUpdate(ctx, id)
if err != nil {
return database.ProvisionerJob{}, err
}
return q.db.GetProvisionerJobByIDForUpdate(ctx, id)
if err := q.authorizeProvisionerJob(ctx, job); err != nil {
return database.ProvisionerJob{}, err
}
return job, nil
}
func (q *querier) GetProvisionerJobTimingsByJobID(ctx context.Context, jobID uuid.UUID) ([]database.ProvisionerJobTiming, error) {

View File

@ -4655,8 +4655,59 @@ func (s *MethodTestSuite) TestSystemFunctions() {
VapidPrivateKey: "test",
}).Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate)
}))
s.Run("GetProvisionerJobByIDForUpdate", s.Subtest(func(db database.Store, check *expects) {
check.Args(uuid.New()).Asserts(rbac.ResourceProvisionerJobs, policy.ActionRead).Errors(sql.ErrNoRows)
s.Run("Build/GetProvisionerJobByIDForUpdate", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
o := dbgen.Organization(s.T(), db, database.Organization{})
tpl := dbgen.Template(s.T(), db, database.Template{
OrganizationID: o.ID,
CreatedBy: u.ID,
})
w := dbgen.Workspace(s.T(), db, database.WorkspaceTable{
OwnerID: u.ID,
OrganizationID: o.ID,
TemplateID: tpl.ID,
})
j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{
Type: database.ProvisionerJobTypeWorkspaceBuild,
})
tv := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{
TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true},
JobID: j.ID,
OrganizationID: o.ID,
CreatedBy: u.ID,
})
_ = dbgen.WorkspaceBuild(s.T(), db, database.WorkspaceBuild{
JobID: j.ID,
WorkspaceID: w.ID,
TemplateVersionID: tv.ID,
})
check.Args(j.ID).Asserts(w, policy.ActionRead).Returns(j)
}))
s.Run("TemplateVersion/GetProvisionerJobByIDForUpdate", s.Subtest(func(db database.Store, check *expects) {
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{
Type: database.ProvisionerJobTypeTemplateVersionImport,
})
tpl := dbgen.Template(s.T(), db, database.Template{})
v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{
TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true},
JobID: j.ID,
})
check.Args(j.ID).Asserts(v.RBACObject(tpl), policy.ActionRead).Returns(j)
}))
s.Run("TemplateVersionDryRun/GetProvisionerJobByIDForUpdate", s.Subtest(func(db database.Store, check *expects) {
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
tpl := dbgen.Template(s.T(), db, database.Template{})
v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{
TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true},
})
j := dbgen.ProvisionerJob(s.T(), db, nil, database.ProvisionerJob{
Type: database.ProvisionerJobTypeTemplateVersionDryRun,
Input: must(json.Marshal(struct {
TemplateVersionID uuid.UUID `json:"template_version_id"`
}{TemplateVersionID: v.ID})),
})
check.Args(j.ID).Asserts(v.RBACObject(tpl), policy.ActionRead).Returns(j)
}))
s.Run("HasTemplateVersionsWithAITask", s.Subtest(func(db database.Store, check *expects) {
check.Args().Asserts()

View File

@ -581,10 +581,24 @@ func (api *API) notifyWorkspaceUpdated(
// @Produce json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID"
// @Param expect_status query string false "Expected status of the job. If expect_status is supplied, the request will be rejected with 412 Precondition Failed if the job doesn't match the state when performing the cancellation." Enums(running, pending)
// @Success 200 {object} codersdk.Response
// @Router /workspacebuilds/{workspacebuild}/cancel [patch]
func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var expectStatus database.ProvisionerJobStatus
expectStatusParam := r.URL.Query().Get("expect_status")
if expectStatusParam != "" {
if expectStatusParam != "running" && expectStatusParam != "pending" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Invalid expect_status %q. Only 'running' or 'pending' are allowed.", expectStatusParam),
})
return
}
expectStatus = database.ProvisionerJobStatus(expectStatusParam)
}
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
if err != nil {
@ -594,58 +608,78 @@ func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques
return
}
valid, err := api.verifyUserCanCancelWorkspaceBuilds(ctx, httpmw.APIKey(r).UserID, workspace.TemplateID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error verifying permission to cancel workspace build.",
Detail: err.Error(),
})
return
}
if !valid {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "User is not allowed to cancel workspace builds. Owner role is required.",
})
return
code := http.StatusInternalServerError
resp := codersdk.Response{
Message: "Internal error canceling workspace build.",
}
err = api.Database.InTx(func(db database.Store) error {
valid, err := verifyUserCanCancelWorkspaceBuilds(ctx, db, httpmw.APIKey(r).UserID, workspace.TemplateID, expectStatus)
if err != nil {
code = http.StatusInternalServerError
resp.Message = "Internal error verifying permission to cancel workspace build."
resp.Detail = err.Error()
job, err := api.Database.GetProvisionerJobByID(ctx, workspaceBuild.JobID)
return xerrors.Errorf("verify user can cancel workspace builds: %w", err)
}
if !valid {
code = http.StatusForbidden
resp.Message = "User is not allowed to cancel workspace builds. Owner role is required."
return xerrors.New("user is not allowed to cancel workspace builds")
}
job, err := db.GetProvisionerJobByIDForUpdate(ctx, workspaceBuild.JobID)
if err != nil {
code = http.StatusInternalServerError
resp.Message = "Internal error fetching provisioner job."
resp.Detail = err.Error()
return xerrors.Errorf("get provisioner job: %w", err)
}
if job.CompletedAt.Valid {
code = http.StatusBadRequest
resp.Message = "Job has already completed!"
return xerrors.New("job has already completed")
}
if job.CanceledAt.Valid {
code = http.StatusBadRequest
resp.Message = "Job has already been marked as canceled!"
return xerrors.New("job has already been marked as canceled")
}
if expectStatus != "" && job.JobStatus != expectStatus {
code = http.StatusPreconditionFailed
resp.Message = "Job is not in the expected state."
return xerrors.Errorf("job is not in the expected state: expected: %q, got %q", expectStatus, job.JobStatus)
}
err = db.UpdateProvisionerJobWithCancelByID(ctx, database.UpdateProvisionerJobWithCancelByIDParams{
ID: job.ID,
CanceledAt: sql.NullTime{
Time: dbtime.Now(),
Valid: true,
},
CompletedAt: sql.NullTime{
Time: dbtime.Now(),
// If the job is running, don't mark it completed!
Valid: !job.WorkerID.Valid,
},
})
if err != nil {
code = http.StatusInternalServerError
resp.Message = "Internal error updating provisioner job."
resp.Detail = err.Error()
return xerrors.Errorf("update provisioner job: %w", err)
}
return nil
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job.",
Detail: err.Error(),
})
return
}
if job.CompletedAt.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Job has already completed!",
})
return
}
if job.CanceledAt.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Job has already been marked as canceled!",
})
return
}
err = api.Database.UpdateProvisionerJobWithCancelByID(ctx, database.UpdateProvisionerJobWithCancelByIDParams{
ID: job.ID,
CanceledAt: sql.NullTime{
Time: dbtime.Now(),
Valid: true,
},
CompletedAt: sql.NullTime{
Time: dbtime.Now(),
// If the job is running, don't mark it completed!
Valid: !job.WorkerID.Valid,
},
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating provisioner job.",
Detail: err.Error(),
})
httpapi.Write(ctx, rw, code, resp)
return
}
@ -659,8 +693,14 @@ func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques
})
}
func (api *API) verifyUserCanCancelWorkspaceBuilds(ctx context.Context, userID uuid.UUID, templateID uuid.UUID) (bool, error) {
template, err := api.Database.GetTemplateByID(ctx, templateID)
func verifyUserCanCancelWorkspaceBuilds(ctx context.Context, store database.Store, userID uuid.UUID, templateID uuid.UUID, jobStatus database.ProvisionerJobStatus) (bool, error) {
// If the jobStatus is pending, we always allow cancellation regardless of
// the template setting as it's non-destructive to Terraform resources.
if jobStatus == database.ProvisionerJobStatusPending {
return true, nil
}
template, err := store.GetTemplateByID(ctx, templateID)
if err != nil {
return false, xerrors.New("no template exists for this workspace")
}
@ -669,7 +709,7 @@ func (api *API) verifyUserCanCancelWorkspaceBuilds(ctx context.Context, userID u
return true, nil // all users can cancel workspace builds
}
user, err := api.Database.GetUserByID(ctx, userID)
user, err := store.GetUserByID(ctx, userID)
if err != nil {
return false, xerrors.New("user does not exist")
}

View File

@ -573,7 +573,7 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) {
build, err = client.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
return assert.NoError(t, err) && build.Job.Status == codersdk.ProvisionerJobRunning
}, testutil.WaitShort, testutil.IntervalFast)
err := client.CancelWorkspaceBuild(ctx, build.ID)
err := client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{})
require.NoError(t, err)
require.Eventually(t, func() bool {
var err error
@ -618,11 +618,199 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) {
build, err = userClient.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
return assert.NoError(t, err) && build.Job.Status == codersdk.ProvisionerJobRunning
}, testutil.WaitShort, testutil.IntervalFast)
err := userClient.CancelWorkspaceBuild(ctx, build.ID)
err := userClient.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusForbidden, apiErr.StatusCode())
})
t.Run("Cancel with expect_state=pending", func(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
t.Skip("this test requires postgres")
}
// Given: a coderd instance with a provisioner daemon
store, ps, db := dbtestutil.NewDBWithSQLDB(t)
client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{
Database: store,
Pubsub: ps,
IncludeProvisionerDaemon: true,
})
defer closeDaemon.Close()
// Given: a user, template, and workspace
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Stop the provisioner daemon.
require.NoError(t, closeDaemon.Close())
ctx := testutil.Context(t, testutil.WaitLong)
// Given: no provisioner daemons exist.
_, err := db.ExecContext(ctx, `DELETE FROM provisioner_daemons;`)
require.NoError(t, err)
// When: a new workspace build is created
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: template.ActiveVersionID,
Transition: codersdk.WorkspaceTransitionStart,
})
// Then: the request should succeed.
require.NoError(t, err)
// Then: the provisioner job should remain pending.
require.Equal(t, codersdk.ProvisionerJobPending, build.Job.Status)
// Then: the response should indicate no provisioners are available.
if assert.NotNil(t, build.MatchedProvisioners) {
assert.Zero(t, build.MatchedProvisioners.Count)
assert.Zero(t, build.MatchedProvisioners.Available)
assert.Zero(t, build.MatchedProvisioners.MostRecentlySeen.Time)
assert.False(t, build.MatchedProvisioners.MostRecentlySeen.Valid)
}
// When: the workspace build is canceled
err = client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{
ExpectStatus: codersdk.CancelWorkspaceBuildStatusPending,
})
require.NoError(t, err)
// Then: the workspace build should be canceled.
build, err = client.WorkspaceBuild(ctx, build.ID)
require.NoError(t, err)
require.Equal(t, codersdk.ProvisionerJobCanceled, build.Job.Status)
})
t.Run("Cancel with expect_state=pending when job is running - should fail with 412", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Log{
Log: &proto.Log{},
},
}},
ProvisionPlan: echo.PlanComplete,
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
var build codersdk.WorkspaceBuild
require.Eventually(t, func() bool {
var err error
build, err = client.WorkspaceBuild(ctx, workspace.LatestBuild.ID)
return assert.NoError(t, err) && build.Job.Status == codersdk.ProvisionerJobRunning
}, testutil.WaitShort, testutil.IntervalFast)
// When: a cancel request is made with expect_state=pending
err := client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{
ExpectStatus: codersdk.CancelWorkspaceBuildStatusPending,
})
// Then: the request should fail with 412.
require.Error(t, err)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusPreconditionFailed, apiErr.StatusCode())
})
t.Run("Cancel with expect_state=running when job is pending - should fail with 412", func(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
t.Skip("this test requires postgres")
}
// Given: a coderd instance with a provisioner daemon
store, ps, db := dbtestutil.NewDBWithSQLDB(t)
client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{
Database: store,
Pubsub: ps,
IncludeProvisionerDaemon: true,
})
defer closeDaemon.Close()
// Given: a user, template, and workspace
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Stop the provisioner daemon.
require.NoError(t, closeDaemon.Close())
ctx := testutil.Context(t, testutil.WaitLong)
// Given: no provisioner daemons exist.
_, err := db.ExecContext(ctx, `DELETE FROM provisioner_daemons;`)
require.NoError(t, err)
// When: a new workspace build is created
build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: template.ActiveVersionID,
Transition: codersdk.WorkspaceTransitionStart,
})
// Then: the request should succeed.
require.NoError(t, err)
// Then: the provisioner job should remain pending.
require.Equal(t, codersdk.ProvisionerJobPending, build.Job.Status)
// Then: the response should indicate no provisioners are available.
if assert.NotNil(t, build.MatchedProvisioners) {
assert.Zero(t, build.MatchedProvisioners.Count)
assert.Zero(t, build.MatchedProvisioners.Available)
assert.Zero(t, build.MatchedProvisioners.MostRecentlySeen.Time)
assert.False(t, build.MatchedProvisioners.MostRecentlySeen.Valid)
}
// When: a cancel request is made with expect_state=running
err = client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{
ExpectStatus: codersdk.CancelWorkspaceBuildStatusRunning,
})
// Then: the request should fail with 412.
require.Error(t, err)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusPreconditionFailed, apiErr.StatusCode())
})
t.Run("Cancel with expect_state - invalid status", func(t *testing.T) {
t.Parallel()
// Given: a coderd instance with a provisioner daemon
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Log{
Log: &proto.Log{},
},
}},
ProvisionPlan: echo.PlanComplete,
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
ctx := testutil.Context(t, testutil.WaitLong)
// When: a cancel request is made with invalid expect_state
err := client.CancelWorkspaceBuild(ctx, workspace.LatestBuild.ID, codersdk.CancelWorkspaceBuildParams{
ExpectStatus: "invalid_status",
})
// Then: the request should fail with 400.
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Contains(t, apiErr.Message, "Invalid expect_status")
})
}
func TestWorkspaceBuildResources(t *testing.T) {
@ -968,7 +1156,7 @@ func TestWorkspaceBuildStatus(t *testing.T) {
_ = closeDaemon.Close()
// after successful cancel is "canceled"
build = coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart)
err = client.CancelWorkspaceBuild(ctx, build.ID)
err = client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{})
require.NoError(t, err)
workspace, err = client.Workspace(ctx, workspace.ID)

View File

@ -3245,7 +3245,7 @@ func TestWorkspaceWatcher(t *testing.T) {
closeFunc.Close()
build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart)
wait("first is for the workspace build itself", nil)
err = client.CancelWorkspaceBuild(ctx, build.ID)
err = client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{})
require.NoError(t, err)
wait("second is for the build cancel", nil)
}

View File

@ -164,7 +164,7 @@ func TestTools(t *testing.T) {
// Important: cancel the build. We don't run any provisioners, so this
// will remain in the 'pending' state indefinitely.
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID))
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{}))
})
t.Run("Start", func(t *testing.T) {
@ -184,7 +184,7 @@ func TestTools(t *testing.T) {
// Important: cancel the build. We don't run any provisioners, so this
// will remain in the 'pending' state indefinitely.
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID))
require.NoError(t, client.CancelWorkspaceBuild(ctx, result.ID, codersdk.CancelWorkspaceBuildParams{}))
})
t.Run("TemplateVersionChange", func(t *testing.T) {
@ -216,7 +216,7 @@ func TestTools(t *testing.T) {
require.Equal(t, r.Workspace.ID.String(), updateBuild.WorkspaceID.String())
require.Equal(t, newVersion.TemplateVersion.ID.String(), updateBuild.TemplateVersionID.String())
// Cancel the build so it doesn't remain in the 'pending' state indefinitely.
require.NoError(t, client.CancelWorkspaceBuild(ctx, updateBuild.ID))
require.NoError(t, client.CancelWorkspaceBuild(ctx, updateBuild.ID, codersdk.CancelWorkspaceBuildParams{}))
// Roll back to the original version
rollbackBuild, err := testTool(t, toolsdk.CreateWorkspaceBuild, tb, toolsdk.CreateWorkspaceBuildArgs{
@ -229,7 +229,7 @@ func TestTools(t *testing.T) {
require.Equal(t, r.Workspace.ID.String(), rollbackBuild.WorkspaceID.String())
require.Equal(t, originalVersionID.String(), rollbackBuild.TemplateVersionID.String())
// Cancel the build so it doesn't remain in the 'pending' state indefinitely.
require.NoError(t, client.CancelWorkspaceBuild(ctx, rollbackBuild.ID))
require.NoError(t, client.CancelWorkspaceBuild(ctx, rollbackBuild.ID, codersdk.CancelWorkspaceBuildParams{}))
})
})

View File

@ -123,9 +123,29 @@ func (c *Client) WorkspaceBuild(ctx context.Context, id uuid.UUID) (WorkspaceBui
return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild)
}
type CancelWorkspaceBuildStatus string
const (
CancelWorkspaceBuildStatusRunning CancelWorkspaceBuildStatus = "running"
CancelWorkspaceBuildStatusPending CancelWorkspaceBuildStatus = "pending"
)
type CancelWorkspaceBuildParams struct {
// ExpectStatus ensures the build is in the expected status before canceling.
ExpectStatus CancelWorkspaceBuildStatus `json:"expect_status,omitempty"`
}
func (c *CancelWorkspaceBuildParams) asRequestOption() RequestOption {
return func(r *http.Request) {
q := r.URL.Query()
q.Set("expect_status", string(c.ExpectStatus))
r.URL.RawQuery = q.Encode()
}
}
// CancelWorkspaceBuild marks a workspace build job as canceled.
func (c *Client) CancelWorkspaceBuild(ctx context.Context, id uuid.UUID) error {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/workspacebuilds/%s/cancel", id), nil)
func (c *Client) CancelWorkspaceBuild(ctx context.Context, id uuid.UUID, req CancelWorkspaceBuildParams) error {
res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/workspacebuilds/%s/cancel", id), nil, req.asRequestOption())
if err != nil {
return err
}

View File

@ -491,9 +491,17 @@ curl -X PATCH http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/c
### Parameters
| Name | In | Type | Required | Description |
|------------------|------|--------|----------|--------------------|
| `workspacebuild` | path | string | true | Workspace build ID |
| Name | In | Type | Required | Description |
|------------------|-------|--------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `workspacebuild` | path | string | true | Workspace build ID |
| `expect_status` | query | string | false | Expected status of the job. If expect_status is supplied, the request will be rejected with 412 Precondition Failed if the job doesn't match the state when performing the cancellation. |
#### Enumerated Values
| Parameter | Value |
|-----------------|-----------|
| `expect_status` | `running` |
| `expect_status` | `pending` |
### Example responses

View File

@ -150,7 +150,7 @@ func (r *CleanupRunner) Run(ctx context.Context, _ string, logs io.Writer) error
if err == nil && build.Job.Status.Active() {
// mark the build as canceled
logger.Info(ctx, "canceling workspace build", slog.F("build_id", build.ID), slog.F("workspace_id", r.workspaceID))
if err = r.client.CancelWorkspaceBuild(ctx, build.ID); err == nil {
if err = r.client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildParams{}); err == nil {
// Wait for the job to cancel before we delete it
_ = waitForBuild(ctx, logs, r.client, build.ID) // it will return a "build canceled" error
} else {

View File

@ -1277,9 +1277,12 @@ class ApiMethods {
cancelWorkspaceBuild = async (
workspaceBuildId: TypesGen.WorkspaceBuild["id"],
params?: TypesGen.CancelWorkspaceBuildParams,
): Promise<TypesGen.Response> => {
const response = await this.axios.patch(
`/api/v2/workspacebuilds/${workspaceBuildId}/cancel`,
null,
{ params },
);
return response.data;

View File

@ -266,7 +266,12 @@ export const startWorkspace = (
export const cancelBuild = (workspace: Workspace, queryClient: QueryClient) => {
return {
mutationFn: () => {
return API.cancelWorkspaceBuild(workspace.latest_build.id);
const { status } = workspace.latest_build;
const params =
status === "pending" || status === "running"
? { expect_status: status }
: undefined;
return API.cancelWorkspaceBuild(workspace.latest_build.id, params);
},
onSuccess: async () => {
await queryClient.invalidateQueries({

View File

@ -292,6 +292,19 @@ export const BypassRatelimitHeader = "X-Coder-Bypass-Ratelimit";
// From codersdk/client.go
export const CLITelemetryHeader = "Coder-CLI-Telemetry";
// From codersdk/workspacebuilds.go
export interface CancelWorkspaceBuildParams {
readonly expect_status?: CancelWorkspaceBuildStatus;
}
// From codersdk/workspacebuilds.go
export type CancelWorkspaceBuildStatus = "pending" | "running";
export const CancelWorkspaceBuildStatuses: CancelWorkspaceBuildStatus[] = [
"pending",
"running",
];
// From codersdk/users.go
export interface ChangePasswordWithOneTimePasscodeRequest {
readonly email: string;

View File

@ -0,0 +1,32 @@
import type { Workspace } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import type { FC } from "react";
interface WorkspaceBuildCancelDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
workspace: Workspace;
}
export const WorkspaceBuildCancelDialog: FC<
WorkspaceBuildCancelDialogProps
> = ({ open, onClose, onConfirm, workspace }) => {
const action =
workspace.latest_build.status === "pending"
? "remove the current build from the build queue"
: "stop the current build process";
return (
<ConfirmDialog
open={open}
title="Cancel workspace build"
description={`Are you sure you want to cancel the build for workspace "${workspace.name}"? This will ${action}.`}
confirmText="Confirm"
cancelText="Discard"
onClose={onClose}
onConfirm={onConfirm}
type="delete"
/>
);
};

View File

@ -145,7 +145,7 @@ export const abilitiesByWorkspaceStatus = (
case "pending": {
return {
actions: ["pending"],
canCancel: false,
canCancel: true,
canAcceptJobs: false,
};
}

View File

@ -19,6 +19,7 @@ import {
MockFailedWorkspace,
MockOrganization,
MockOutdatedWorkspace,
MockPendingWorkspace,
MockStartingWorkspace,
MockStoppedWorkspace,
MockTemplate,
@ -224,11 +225,59 @@ describe("WorkspacePage", () => {
}),
);
const user = userEvent.setup({ delay: 0 });
const cancelWorkspaceMock = jest
.spyOn(API, "cancelWorkspaceBuild")
.mockImplementation(() => Promise.resolve({ message: "job canceled" }));
await renderWorkspacePage(MockStartingWorkspace);
await testButton(MockStartingWorkspace, "Cancel", cancelWorkspaceMock);
// Click on Cancel
const cancelButton = await screen.findByRole("button", { name: "Cancel" });
await user.click(cancelButton);
// Get dialog and confirm
const dialog = await screen.findByTestId("dialog");
const confirmButton = within(dialog).getByRole("button", {
name: "Confirm",
hidden: false,
});
await user.click(confirmButton);
expect(cancelWorkspaceMock).toHaveBeenCalledWith(
MockStartingWorkspace.latest_build.id,
undefined,
);
});
it("requests cancellation when the user presses Cancel and the workspace is pending", async () => {
server.use(
http.get("/api/v2/users/:userId/workspace/:workspaceName", () => {
return HttpResponse.json(MockPendingWorkspace);
}),
);
const user = userEvent.setup({ delay: 0 });
const cancelWorkspaceMock = jest
.spyOn(API, "cancelWorkspaceBuild")
.mockImplementation(() => Promise.resolve({ message: "job canceled" }));
await renderWorkspacePage(MockPendingWorkspace);
// Click on Cancel
const cancelButton = await screen.findByRole("button", { name: "Cancel" });
await user.click(cancelButton);
// Get dialog and confirm
const dialog = await screen.findByTestId("dialog");
const confirmButton = within(dialog).getByRole("button", {
name: "Confirm",
hidden: false,
});
await user.click(confirmButton);
expect(cancelWorkspaceMock).toHaveBeenCalledWith(
MockPendingWorkspace.latest_build.id,
{ expect_status: "pending" },
);
});
it("requests an update when the user presses Update", async () => {

View File

@ -20,6 +20,7 @@ import { displayError } from "components/GlobalSnackbar/utils";
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
import { EphemeralParametersDialog } from "modules/workspaces/EphemeralParametersDialog/EphemeralParametersDialog";
import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog";
import { WorkspaceBuildCancelDialog } from "modules/workspaces/WorkspaceBuildCancelDialog/WorkspaceBuildCancelDialog";
import {
WorkspaceUpdateDialogs,
useWorkspaceUpdate,
@ -80,6 +81,8 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
ephemeralParameters: TypesGen.TemplateVersionParameter[];
}>({ open: false, action: "start", ephemeralParameters: [] });
const [isCancelConfirmOpen, setIsCancelConfirmOpen] = useState(false);
const { mutate: mutateRestartWorkspace, isPending: isRestarting } =
useMutation({
mutationFn: API.restartWorkspace,
@ -316,7 +319,7 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
}
}}
handleUpdate={workspaceUpdate.update}
handleCancel={cancelBuildMutation.mutate}
handleCancel={() => setIsCancelConfirmOpen(true)}
handleRetry={handleRetry}
handleDebug={handleDebug}
handleDormantActivate={async () => {
@ -352,6 +355,16 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
}
/>
<WorkspaceBuildCancelDialog
open={isCancelConfirmOpen}
onClose={() => setIsCancelConfirmOpen(false)}
onConfirm={() => {
cancelBuildMutation.mutate();
setIsCancelConfirmOpen(false);
}}
workspace={workspace}
/>
<EphemeralParametersDialog
open={ephemeralParametersDialog.open}
onClose={() =>

View File

@ -62,6 +62,7 @@ import {
import { useAppLink } from "modules/apps/useAppLink";
import { useDashboard } from "modules/dashboard/useDashboard";
import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus";
import { WorkspaceBuildCancelDialog } from "modules/workspaces/WorkspaceBuildCancelDialog/WorkspaceBuildCancelDialog";
import { WorkspaceDormantBadge } from "modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge";
import { WorkspaceMoreActions } from "modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions";
import { WorkspaceOutdatedTooltip } from "modules/workspaces/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip";
@ -495,8 +496,8 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
onError: onActionError,
});
// State for stop confirmation dialog
const [isStopConfirmOpen, setIsStopConfirmOpen] = useState(false);
const [isCancelConfirmOpen, setIsCancelConfirmOpen] = useState(false);
const isRetrying =
startWorkspaceMutation.isPending ||
@ -606,7 +607,7 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
{abilities.canCancel && (
<PrimaryAction
onClick={cancelBuildMutation.mutate}
onClick={() => setIsCancelConfirmOpen(true)}
isLoading={cancelBuildMutation.isPending}
label="Cancel build"
>
@ -643,6 +644,16 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({
}}
type="delete"
/>
<WorkspaceBuildCancelDialog
open={isCancelConfirmOpen}
onClose={() => setIsCancelConfirmOpen(false)}
onConfirm={() => {
cancelBuildMutation.mutate();
setIsCancelConfirmOpen(false);
}}
workspace={workspace}
/>
</TableCell>
);
};