Skip to content

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

Merged
merged 22 commits into from
Jul 8, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ba41ae8
add support for canceling workspace builds with expect_state param (l…
kacpersaw Jul 1, 2025
c4ee5b6
Use single transaction for canceling workspace build
kacpersaw Jul 2, 2025
c49c33e
Fix lint problem in ut
kacpersaw Jul 2, 2025
ba1dbf3
add cancel confirmation dialog for workspace builds and add expect_st…
kacpersaw Jul 2, 2025
acffda6
Fix lint
kacpersaw Jul 2, 2025
b672d76
Merge branch 'main' into kacpersaw/cancel-pending-provisioner-jobs
kacpersaw Jul 2, 2025
86a34df
Apply review suggestions
kacpersaw Jul 3, 2025
42170ab
Fix unit test
kacpersaw Jul 3, 2025
5db9d71
Apply review suggestions
kacpersaw Jul 3, 2025
1ede20c
Fix typo
kacpersaw Jul 3, 2025
2597615
Regenerate api types
kacpersaw Jul 3, 2025
6c2d0cf
Fix typo
kacpersaw Jul 3, 2025
c800494
Merge branch 'main' into kacpersaw/cancel-pending-provisioner-jobs
kacpersaw Jul 3, 2025
c5cb203
Apply a new authorization check for GetProvisionerJobByIDForUpdate
kacpersaw Jul 6, 2025
1de84cc
Apply a new authorization check for GetProvisionerJobByIDForUpdate
kacpersaw Jul 6, 2025
17fb6a3
Apply FE review suggestions
kacpersaw Jul 6, 2025
1b7b614
Apply review suggestions
kacpersaw Jul 7, 2025
634f556
Refactor cancelWorkspaceBuild parameter handling
kacpersaw Jul 7, 2025
4deace0
Fix lint
kacpersaw Jul 7, 2025
43430fa
Update coderd/workspacebuilds.go
kacpersaw Jul 7, 2025
6272d93
Extract cancel confirm dialog to a separate component
kacpersaw Jul 7, 2025
4d4a01d
Merge branch 'main' into kacpersaw/cancel-pending-provisioner-jobs
kacpersaw Jul 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Use single transaction for canceling workspace build
  • Loading branch information
kacpersaw committed Jul 2, 2025
commit c4ee5b682a34255ef3003c493d5ce930a3d754b2
8 changes: 6 additions & 2 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

118 changes: 49 additions & 69 deletions coderd/workspacebuilds.go
Original file line number Diff line number Diff line change
Expand Up @@ -581,12 +581,12 @@ func (api *API) notifyWorkspaceUpdated(
// @Produce json
// @Tags Builds
// @Param workspacebuild path string true "Workspace build ID"
// @Param expect_state query string false "Expected state of the job"
// @Param expect_status query string false "Expected status of the job" Enums(running, pending)
Copy link
Member

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.

// @Success 200 {object} codersdk.Response
// @Router /workspacebuilds/{workspacebuild}/cancel [patch]
func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
expectState := r.URL.Query().Get("expect_state")
expectStatus := r.URL.Query().Get("expect_status")
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
if err != nil {
Expand All @@ -596,90 +596,60 @@ 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
}

if expectState != "" {
jobStatus := database.ProvisionerJobStatus(expectState)
if !jobStatus.Valid() {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid expect_state. Only 'pending', 'running', 'succeeded', 'canceling', 'canceled', 'failed', or 'unknown' are allowed.",
})
return
}

// use local error type to detect if the error is due to job state mismatch
var errJobStateMismatch = xerrors.New("job is not in the expected state")

err := api.Database.InTx(func(store database.Store) error {
job, err := store.GetProvisionerJobByIDForUpdate(ctx, workspaceBuild.JobID)
if err != nil {
return err
}

if job.JobStatus != jobStatus {
return errJobStateMismatch
}

return store.UpdateProvisionerJobWithCancelByID(ctx, database.UpdateProvisionerJobWithCancelByIDParams{
ID: job.ID,
CanceledAt: sql.NullTime{
Time: dbtime.Now(),
Valid: true,
},
CompletedAt: sql.NullTime{
Time: dbtime.Now(),
Valid: !job.WorkerID.Valid,
},
})
}, nil)
err = api.Database.InTx(func(store database.Store) error {
valid, err := api.verifyUserCanCancelWorkspaceBuilds(ctx, store, httpmw.APIKey(r).UserID, workspace.TemplateID)
if err != nil {
if errors.Is(err, errJobStateMismatch) {
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
Message: "Job is not in the expected state.",
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job.",
Message: "Internal error verifying permission to cancel workspace build.",
Detail: err.Error(),
})
return
return nil
}
if !valid {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "User is not allowed to cancel workspace builds. Owner role is required.",
})
return nil
}
} else {
job, err := api.Database.GetProvisionerJobByID(ctx, workspaceBuild.JobID)

job, err := store.GetProvisionerJobByID(ctx, workspaceBuild.JobID)
Copy link
Member

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.

Copy link
Contributor Author

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 🙅

if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job.",
Detail: err.Error(),
})
return
return nil
}
if job.CompletedAt.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Job has already completed!",
})
return
return nil
}
if job.CanceledAt.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Job has already been marked as canceled!",
})
return
return nil
}

if expectStatus != "" {
if expectStatus != "running" && expectStatus != "pending" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid expect_status. Only 'running' or 'pending' are allowed.",
})
return nil
}

if job.JobStatus != database.ProvisionerJobStatus(expectStatus) {
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
Message: "Job is not in the expected state.",
})
return nil
}
}
err = api.Database.UpdateProvisionerJobWithCancelByID(ctx, database.UpdateProvisionerJobWithCancelByIDParams{

err = store.UpdateProvisionerJobWithCancelByID(ctx, database.UpdateProvisionerJobWithCancelByIDParams{
ID: job.ID,
CanceledAt: sql.NullTime{
Time: dbtime.Now(),
Expand All @@ -696,9 +666,19 @@ func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques
Message: "Internal error updating provisioner job.",
Detail: err.Error(),
})
return
return nil
}

return nil
}, nil)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating provisioner job.",
Detail: err.Error(),
})
return
}

api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
Kind: wspubsub.WorkspaceEventKindStateChange,
WorkspaceID: workspace.ID,
Expand All @@ -709,8 +689,8 @@ 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 (*API) verifyUserCanCancelWorkspaceBuilds(ctx context.Context, store database.Store, userID uuid.UUID, templateID uuid.UUID) (bool, error) {
template, err := store.GetTemplateByID(ctx, templateID)
if err != nil {
return false, xerrors.New("no template exists for this workspace")
}
Expand All @@ -719,7 +699,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")
}
Expand Down
8 changes: 4 additions & 4 deletions coderd/workspacebuilds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,7 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) {

// When: the workspace build is canceled
err = client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildRequest{
ExpectState: codersdk.ProvisionerJobPending,
ExpectStatus: codersdk.CancelWorkspaceBuildStatusPending,
})
require.NoError(t, err)

Expand Down Expand Up @@ -734,7 +734,7 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) {

// When: a cancel request is made with expect_state=pending
Copy link
Member

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

Copy link
Contributor Author

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

err = client.CancelWorkspaceBuild(ctx, build.ID, codersdk.CancelWorkspaceBuildRequest{
ExpectState: codersdk.ProvisionerJobPending,
ExpectStatus: codersdk.CancelWorkspaceBuildStatusPending,
})
// Then: the request should fail with 412.
require.Error(t, err)
Expand Down Expand Up @@ -765,13 +765,13 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) {

// When: a cancel request is made with invalid expect_state
err := client.CancelWorkspaceBuild(ctx, workspace.LatestBuild.ID, codersdk.CancelWorkspaceBuildRequest{
ExpectState: "invalid_status",
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_state")
require.Contains(t, apiErr.Message, "Invalid expect_status")
})
}

Expand Down
11 changes: 9 additions & 2 deletions codersdk/workspacebuilds.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,21 @@ 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 CancelWorkspaceBuildRequest struct {
Copy link
Member

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

ExpectState ProvisionerJobStatus `json:"expect_state,omitempty"`
ExpectStatus CancelWorkspaceBuildStatus `json:"expect_status,omitempty"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ExpectStatus CancelWorkspaceBuildStatus `json:"expect_status,omitempty"`
ExpectStatus CancelWorkspaceBuildStatus `json:"-"`

Since it's never used as JSON

Copy link
Member

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

Copy link
Contributor Author

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.

}

func (c *CancelWorkspaceBuildRequest) asRequestOption() RequestOption {
return func(r *http.Request) {
q := r.URL.Query()
q.Set("expect_state", string(c.ExpectState))
q.Set("expect_status", string(c.ExpectStatus))
r.URL.RawQuery = q.Encode()
}
}
Expand Down
15 changes: 11 additions & 4 deletions docs/reference/api/builds.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.