-
Notifications
You must be signed in to change notification settings - Fork 940
feat!: add ability to cancel pending workspace build #18713
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good, just one comment w/ the transaction
8081f33
to
42170ab
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BE only review
coderd/workspacebuilds.go
Outdated
code := http.StatusOK | ||
resp := codersdk.Response{} | ||
err = api.Database.InTx(func(store database.Store) error { | ||
valid, err := verifyUserCanCancelWorkspaceBuilds(ctx, store, httpmw.APIKey(r).UserID, workspace.TemplateID) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We often just use the variable name db
in these transaction closures
coderd/workspacebuilds.go
Outdated
if !valid { | ||
code = http.StatusForbidden | ||
resp.Message = "User is not allowed to cancel workspace builds. Owner role is required." | ||
|
||
return xerrors.Errorf("user is not allowed to cancel workspace builds") | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should still allow for cancellations even if the template forbids them as long as the user specified expect_status=pending
.
coderd/workspacebuilds.go
Outdated
return xerrors.Errorf("user is not allowed to cancel workspace builds") | ||
} | ||
|
||
job, err := store.GetProvisionerJobByID(ctx, workspaceBuild.JobID) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably be a clone of this query with a SELECT ... FOR UPDATE
query instead, otherwise there's nothing preventing this job from being picked up during the cancel operation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch, I forgot to change that query 🙅
coderd/workspacebuilds.go
Outdated
code = http.StatusBadRequest | ||
resp.Message = "Job has already completed!" | ||
|
||
return xerrors.Errorf("job has already completed") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return xerrors.Errorf("job has already completed") | |
return xerrors.New("job has already completed") |
coderd/workspacebuilds.go
Outdated
code = http.StatusBadRequest | ||
resp.Message = "Job has already been marked as canceled!" | ||
|
||
return xerrors.Errorf("job has already been marked as canceled") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return xerrors.Errorf("job has already been marked as canceled") | |
return xerrors.New("job has already been marked as canceled") |
coderd/workspacebuilds.go
Outdated
code = http.StatusBadRequest | ||
resp.Message = "Invalid expect_status. Only 'running' or 'pending' are allowed." | ||
|
||
return xerrors.Errorf("invalid expect_status") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return xerrors.Errorf("invalid expect_status") | |
return xerrors.Errorf("invalid expect_status %q", expectStatus) |
coderd/workspacebuilds.go
Outdated
if expectStatus != "" { | ||
if expectStatus != "running" && expectStatus != "pending" { | ||
code = http.StatusBadRequest | ||
resp.Message = "Invalid expect_status. Only 'running' or 'pending' are allowed." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resp.Message = "Invalid expect_status. Only 'running' or 'pending' are allowed." | |
resp.Message = fmt.Sprintf("Invalid expect_status %q. Only 'running' or 'pending' are allowed.", expectStatus) |
coderd/workspacebuilds.go
Outdated
code = http.StatusPreconditionFailed | ||
resp.Message = "Job is not in the expected state." | ||
|
||
return xerrors.Errorf("job is not in the expected state") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return xerrors.Errorf("job is not in the expected state") | |
return xerrors.Errorf("job is not in the expected state: expected %q, got %q", expectStatus, job.JobStatus) |
codersdk/workspacebuilds.go
Outdated
CancelWorkspaceBuildStatusPending CancelWorkspaceBuildStatus = "pending" | ||
) | ||
|
||
type CancelWorkspaceBuildRequest struct { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe call this Params
instead of Request
since it's not the request body
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I left a few comments related to the FE.
Note: When submitting FE related code, it is always nice to include a screenshot or a short video demo showing off the changes. It makes easier for us to visualize things and make faster reviews.
site/src/api/api.ts
Outdated
@@ -1277,9 +1277,14 @@ class ApiMethods { | |||
|
|||
cancelWorkspaceBuild = async ( | |||
workspaceBuildId: TypesGen.WorkspaceBuild["id"], | |||
request?: TypesGen.CancelWorkspaceBuildParams, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the type has the "Params" sufix, I would name this variable as params
instead of request
.
site/src/api/api.ts
Outdated
): Promise<TypesGen.Response> => { | ||
const params = request?.expect_status |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can do:
const params = new URLSearchParams(params)
site/src/api/api.ts
Outdated
const response = await this.axios.patch( | ||
`/api/v2/workspacebuilds/${workspaceBuildId}/cancel`, | ||
`/api/v2/workspacebuilds/${workspaceBuildId}/cancel${params ? `?${params}` : ""}`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And just use it directly:
`/api/v2/workspacebuilds/${workspaceBuildId}/cancel?${params}`
site/src/api/queries/workspaces.ts
Outdated
@@ -266,6 +266,12 @@ export const startWorkspace = ( | |||
export const cancelBuild = (workspace: Workspace, queryClient: QueryClient) => { | |||
return { | |||
mutationFn: () => { | |||
// If workspace status is pending, include expect_status parameter |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment is just saying what the code is doing below. I would rather not having this comment at all, since I can see it in the code, or explaining the why we need to set the expect_status
to pending.
@@ -352,6 +354,21 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({ | |||
} | |||
/> | |||
|
|||
{/* Cancel confirmation dialog */} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we need this comment. The code below is pretty easy to understand.
@@ -497,6 +497,8 @@ const WorkspaceActionsCell: FC<WorkspaceActionsCellProps> = ({ | |||
|
|||
// State for stop confirmation dialog | |||
const [isStopConfirmOpen, setIsStopConfirmOpen] = useState(false); | |||
// State for cancel confirmation dialog |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The same here, even seeing we do this for the state above.
@@ -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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can be wrong, but I think when we add a comment to the struct, or its props in codersdk package, it gets included in the TS types as well. I think lefting a comment in the CancelWorkspaceBuildParams.ExpectStatus
could be helpful to understand when and why use it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I left a comment in codersdk, but unfortunately it doesn't get passed to the TS types 😞
11d8c0a
to
c5cb203
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BG looks good, just some minor comments
coderd/workspacebuilds.go
Outdated
@@ -581,10 +581,12 @@ 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" Enums(running, pending) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might pay to have a description field (idk if our API generation has something like that, you might need to check other routes) that explains in more detail what this actually does:
If
expect_status
is supplied, the request will be rejected with 499 Precondition Failed if the job doesn't match the state when performing the cancellation.
coderd/workspacebuilds.go
Outdated
if expectStatus != "running" && expectStatus != "pending" { | ||
code = http.StatusBadRequest | ||
resp.Message = fmt.Sprintf("Invalid expect_status %q. Only 'running' or 'pending' are allowed.", expectStatus) | ||
|
||
return xerrors.Errorf("invalid expect_status %q", expectStatus) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This status type checking stuff should probably happen outside of the TX at the top of the route since it doesn't depend on the job properties.
coderd/workspacebuilds.go
Outdated
}) | ||
return | ||
} | ||
code := http.StatusOK |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably have a default status code of 500 and a generic error message, otherwise if the tx returns an error below (because the tx couldn't start) you'll be returning an empty 200 response.
coderd/workspacebuilds.go
Outdated
@@ -659,8 +688,13 @@ 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, expectStatus string) (bool, error) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you move the parsing/checking of the expectStatus
param to the top of the route, this could just directly accept the database enum type instead of the string.
return assert.NoError(t, err) && build.Job.Status == codersdk.ProvisionerJobRunning | ||
}, testutil.WaitShort, testutil.IntervalFast) | ||
|
||
// When: a cancel request is made with expect_state=pending |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this have a chance to race against the job succeeding? The code for checking if the build is already done is before the code that checks the expect_status
param
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From my understanding - this job should stay in running state until cancel, because the test doesn't include ApplyComplete in ProvisionApply.
https://github.com/coder/coder/blob/main/provisioner/echo/serve.go#L194
|
||
type CancelWorkspaceBuildParams struct { | ||
// ExpectStatus ensures the build is in the expected status before canceling. | ||
ExpectStatus CancelWorkspaceBuildStatus `json:"expect_status,omitempty"` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ExpectStatus CancelWorkspaceBuildStatus `json:"expect_status,omitempty"` | |
ExpectStatus CancelWorkspaceBuildStatus `json:"-"` |
Since it's never used as JSON
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or just omit the json
tag entirely perhaps
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
json
tag is required to generate correct field names for TS - without it, you'll get ExpectStatus
instead of the required expect_status
param.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Backend looks great!
<ConfirmDialog | ||
open={isCancelConfirmOpen} | ||
title="Cancel workspace build" | ||
description={`Are you sure you want to cancel the build for workspace "${workspace.name}"? This will stop the current build process.`} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be nice if this could differ based on whether it thinks it's going to cancel a pending or running build.
e.g. This will remove the current build from the build queue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same with the Dialog on the other page too, maybe it should be made into a component?
Co-authored-by: Dean Sheather <dean@deansheather.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just left one minor question, but it should not block the PR. 👍
status === "pending" || status === "running" | ||
? { expect_status: status } | ||
: undefined; | ||
return API.cancelWorkspaceBuild(workspace.latest_build.id, params); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Double checking... can cancelWorkspaceBuild
being called in any status? I see the params can be undefined
, but it would still call the cancelWorkspaceBuild
🤔. This part of the code is quite hard for me to understand why the params are only set when the status is pending or running.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
expect_status
is an optional parameter for cancel that ensures the job is in a specific state before proceeding - it only supports pending
and running
. So, cancel can be called without it.
Closes #17791
This PR adds ability to cancel workspace builds that are in "pending" status.
Breaking changes:
API:
expect_status
query parameter to the cancel workspace build endpoint412 Precondition Failed
if the job is not in the expected statusrunning
orpending
UI:
Added confirmation dialog to the


Cancel
button, since it's a destructive operationEnabled cancel action for pending workspaces (

expect_status=pending
is sent if workspace is in pending status)