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
co-authored by Dean Sheather
parent 2f42b64182
commit 8202514ce0
20 changed files with 556 additions and 93 deletions
+31 -18
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) {
+53 -2
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()