-
Notifications
You must be signed in to change notification settings - Fork 943
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
Changes from 1 commit
ba41ae8
c4ee5b6
c49c33e
ba1dbf3
acffda6
b672d76
86a34df
42170ab
5db9d71
1ede20c
2597615
6c2d0cf
c800494
c5cb203
1de84cc
17fb6a3
1b7b614
634f556
4deace0
43430fa
6272d93
4d4a01d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) | ||
// @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 { | ||
|
@@ -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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should probably be a clone of this query with a There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(), | ||
|
@@ -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, | ||
|
@@ -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) { | ||
ethanndickson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
template, err := store.GetTemplateByID(ctx, templateID) | ||
if err != nil { | ||
return false, xerrors.New("no template exists for this workspace") | ||
} | ||
|
@@ -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") | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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) | ||
|
||
|
@@ -734,7 +734,7 @@ func TestPatchCancelWorkspaceBuild(t *testing.T) { | |
|
||
// When: a cancel request is made with expect_state=pending | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
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) | ||
|
@@ -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") | ||
}) | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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 { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe call this |
||||||
ExpectState ProvisionerJobStatus `json:"expect_state,omitempty"` | ||||||
ExpectStatus CancelWorkspaceBuildStatus `json:"expect_status,omitempty"` | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Since it's never used as JSON There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Or just omit the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||
} | ||||||
|
||||||
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() | ||||||
} | ||||||
} | ||||||
|
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
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: