mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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   - Enabled cancel action for pending workspaces (`expect_status=pending` is sent if workspace is in pending status)  --------- Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
co-authored by
Dean Sheather
parent
2f42b64182
commit
8202514ce0
+92
-52
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user