Skip to content

Commit e5ac640

Browse files
authored
feat(coderd): add tasks delete endpoint (#19638)
This change adds a DELETE endpoint for tasks (for now, alias of workspace build delete transition). Fixes coder/internal#903
1 parent 605dad8 commit e5ac640

File tree

5 files changed

+258
-18
lines changed

5 files changed

+258
-18
lines changed

coderd/aitasks.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,3 +472,78 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) {
472472

473473
httpapi.Write(ctx, rw, http.StatusOK, tasks[0])
474474
}
475+
476+
// taskDelete is an experimental endpoint to delete a task by ID (workspace ID).
477+
// It creates a delete workspace build and returns 202 Accepted if the build was
478+
// created.
479+
func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) {
480+
ctx := r.Context()
481+
apiKey := httpmw.APIKey(r)
482+
483+
idStr := chi.URLParam(r, "id")
484+
taskID, err := uuid.Parse(idStr)
485+
if err != nil {
486+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
487+
Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr),
488+
})
489+
return
490+
}
491+
492+
// For now, taskID = workspaceID, once we have a task data model in
493+
// the DB, we can change this lookup.
494+
workspaceID := taskID
495+
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID)
496+
if httpapi.Is404Error(err) {
497+
httpapi.ResourceNotFound(rw)
498+
return
499+
}
500+
if err != nil {
501+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
502+
Message: "Internal error fetching workspace.",
503+
Detail: err.Error(),
504+
})
505+
return
506+
}
507+
508+
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
509+
if err != nil {
510+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
511+
Message: "Internal error fetching workspace resources.",
512+
Detail: err.Error(),
513+
})
514+
return
515+
}
516+
if len(data.builds) == 0 || len(data.templates) == 0 {
517+
httpapi.ResourceNotFound(rw)
518+
return
519+
}
520+
if data.builds[0].HasAITask == nil || !*data.builds[0].HasAITask {
521+
httpapi.ResourceNotFound(rw)
522+
return
523+
}
524+
525+
// Construct a request to the workspace build creation handler to
526+
// initiate deletion.
527+
buildReq := codersdk.CreateWorkspaceBuildRequest{
528+
Transition: codersdk.WorkspaceTransitionDelete,
529+
Reason: "Deleted via tasks API",
530+
}
531+
532+
_, err = api.postWorkspaceBuildsInternal(
533+
ctx,
534+
apiKey,
535+
workspace,
536+
buildReq,
537+
func(action policy.Action, object rbac.Objecter) bool {
538+
return api.Authorize(r, action, object)
539+
},
540+
audit.WorkspaceBuildBaggageFromRequest(r),
541+
)
542+
if err != nil {
543+
httperror.WriteWorkspaceBuildError(ctx, rw, err)
544+
return
545+
}
546+
547+
// Delete build created successfully.
548+
rw.WriteHeader(http.StatusAccepted)
549+
}

coderd/aitasks_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package coderd_test
33
import (
44
"net/http"
55
"testing"
6+
"time"
67

78
"github.com/google/uuid"
89
"github.com/stretchr/testify/assert"
@@ -265,6 +266,125 @@ func TestTasks(t *testing.T) {
265266
assert.Equal(t, workspace.ID, task.WorkspaceID.UUID, "workspace id should match")
266267
assert.NotEmpty(t, task.Status, "task status should not be empty")
267268
})
269+
270+
t.Run("Delete", func(t *testing.T) {
271+
t.Parallel()
272+
273+
t.Run("OK", func(t *testing.T) {
274+
t.Parallel()
275+
276+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
277+
user := coderdtest.CreateFirstUser(t, client)
278+
template := createAITemplate(t, client, user)
279+
280+
ctx := testutil.Context(t, testutil.WaitLong)
281+
282+
exp := codersdk.NewExperimentalClient(client)
283+
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
284+
TemplateVersionID: template.ActiveVersionID,
285+
Prompt: "delete me",
286+
})
287+
require.NoError(t, err)
288+
ws, err := client.Workspace(ctx, task.ID)
289+
require.NoError(t, err)
290+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
291+
292+
err = exp.DeleteTask(ctx, "me", task.ID)
293+
require.NoError(t, err, "delete task request should be accepted")
294+
295+
// Poll until the workspace is deleted.
296+
for {
297+
dws, derr := client.DeletedWorkspace(ctx, task.ID)
298+
if derr == nil && dws.LatestBuild.Status == codersdk.WorkspaceStatusDeleted {
299+
break
300+
}
301+
if ctx.Err() != nil {
302+
require.NoError(t, derr, "expected to fetch deleted workspace before deadline")
303+
require.Equal(t, codersdk.WorkspaceStatusDeleted, dws.LatestBuild.Status, "workspace should be deleted before deadline")
304+
break
305+
}
306+
time.Sleep(testutil.IntervalMedium)
307+
}
308+
})
309+
310+
t.Run("NotFound", func(t *testing.T) {
311+
t.Parallel()
312+
313+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
314+
_ = coderdtest.CreateFirstUser(t, client)
315+
316+
ctx := testutil.Context(t, testutil.WaitShort)
317+
318+
exp := codersdk.NewExperimentalClient(client)
319+
err := exp.DeleteTask(ctx, "me", uuid.New())
320+
321+
var sdkErr *codersdk.Error
322+
require.Error(t, err, "expected an error for non-existent task")
323+
require.ErrorAs(t, err, &sdkErr)
324+
require.Equal(t, 404, sdkErr.StatusCode())
325+
})
326+
327+
t.Run("NotTaskWorkspace", func(t *testing.T) {
328+
t.Parallel()
329+
330+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
331+
user := coderdtest.CreateFirstUser(t, client)
332+
333+
ctx := testutil.Context(t, testutil.WaitShort)
334+
335+
// Create a template without AI tasks support and a workspace from it.
336+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
337+
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
338+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
339+
ws := coderdtest.CreateWorkspace(t, client, template.ID)
340+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
341+
342+
exp := codersdk.NewExperimentalClient(client)
343+
err := exp.DeleteTask(ctx, "me", ws.ID)
344+
345+
var sdkErr *codersdk.Error
346+
require.Error(t, err, "expected an error for non-task workspace delete via tasks endpoint")
347+
require.ErrorAs(t, err, &sdkErr)
348+
require.Equal(t, 404, sdkErr.StatusCode())
349+
})
350+
351+
t.Run("UnauthorizedUserCannotDeleteOthersTask", func(t *testing.T) {
352+
t.Parallel()
353+
354+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
355+
owner := coderdtest.CreateFirstUser(t, client)
356+
357+
// Owner's AI-capable template and workspace (task).
358+
template := createAITemplate(t, client, owner)
359+
360+
ctx := testutil.Context(t, testutil.WaitShort)
361+
362+
exp := codersdk.NewExperimentalClient(client)
363+
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
364+
TemplateVersionID: template.ActiveVersionID,
365+
Prompt: "delete me not",
366+
})
367+
require.NoError(t, err)
368+
ws, err := client.Workspace(ctx, task.ID)
369+
require.NoError(t, err)
370+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
371+
372+
// Another regular org member without elevated permissions.
373+
otherClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
374+
expOther := codersdk.NewExperimentalClient(otherClient)
375+
376+
// Attempt to delete the owner's task as a non-owner without permissions.
377+
err = expOther.DeleteTask(ctx, "me", task.ID)
378+
379+
var authErr *codersdk.Error
380+
require.Error(t, err, "expected an authorization error when deleting another user's task")
381+
require.ErrorAs(t, err, &authErr)
382+
// Accept either 403 or 404 depending on authz behavior.
383+
if authErr.StatusCode() != 403 && authErr.StatusCode() != 404 {
384+
t.Fatalf("unexpected status code: %d (expected 403 or 404)", authErr.StatusCode())
385+
}
386+
})
387+
})
268388
}
269389

270390
func TestTasksCreate(t *testing.T) {

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,6 +1015,7 @@ func New(options *Options) *API {
10151015
r.Route("/{user}", func(r chi.Router) {
10161016
r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize))
10171017
r.Get("/{id}", api.taskGet)
1018+
r.Delete("/{id}", api.taskDelete)
10181019
r.Post("/", api.tasksCreate)
10191020
})
10201021
})

coderd/workspacebuilds.go

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -329,13 +329,44 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ
329329
func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
330330
ctx := r.Context()
331331
apiKey := httpmw.APIKey(r)
332-
333332
workspace := httpmw.WorkspaceParam(r)
334333
var createBuild codersdk.CreateWorkspaceBuildRequest
335334
if !httpapi.Read(ctx, rw, r, &createBuild) {
336335
return
337336
}
338337

338+
apiBuild, err := api.postWorkspaceBuildsInternal(
339+
ctx,
340+
apiKey,
341+
workspace,
342+
createBuild,
343+
func(action policy.Action, object rbac.Objecter) bool {
344+
return api.Authorize(r, action, object)
345+
},
346+
audit.WorkspaceBuildBaggageFromRequest(r),
347+
)
348+
if err != nil {
349+
httperror.WriteWorkspaceBuildError(ctx, rw, err)
350+
return
351+
}
352+
353+
httpapi.Write(ctx, rw, http.StatusCreated, apiBuild)
354+
}
355+
356+
// postWorkspaceBuildsInternal handles the internal logic for creating
357+
// workspace builds, can be called by other handlers and must not
358+
// reference httpmw.
359+
func (api *API) postWorkspaceBuildsInternal(
360+
ctx context.Context,
361+
apiKey database.APIKey,
362+
workspace database.Workspace,
363+
createBuild codersdk.CreateWorkspaceBuildRequest,
364+
authorize func(action policy.Action, object rbac.Objecter) bool,
365+
workspaceBuildBaggage audit.WorkspaceBuildBaggage,
366+
) (
367+
codersdk.WorkspaceBuild,
368+
error,
369+
) {
339370
transition := database.WorkspaceTransition(createBuild.Transition)
340371
builder := wsbuilder.New(workspace, transition, *api.BuildUsageChecker.Load()).
341372
Initiator(apiKey.UserID).
@@ -362,11 +393,10 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
362393
previousWorkspaceBuild, err = tx.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
363394
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
364395
api.Logger.Error(ctx, "failed fetching previous workspace build", slog.F("workspace_id", workspace.ID), slog.Error(err))
365-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
396+
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
366397
Message: "Internal error fetching previous workspace build",
367398
Detail: err.Error(),
368399
})
369-
return nil
370400
}
371401

372402
if createBuild.TemplateVersionID != uuid.Nil {
@@ -375,16 +405,14 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
375405

376406
if createBuild.Orphan {
377407
if createBuild.Transition != codersdk.WorkspaceTransitionDelete {
378-
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
408+
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
379409
Message: "Orphan is only permitted when deleting a workspace.",
380410
})
381-
return nil
382411
}
383412
if len(createBuild.ProvisionerState) > 0 {
384-
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
413+
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
385414
Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.",
386415
})
387-
return nil
388416
}
389417
builder = builder.Orphan()
390418
}
@@ -397,24 +425,23 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
397425
tx,
398426
api.FileCache,
399427
func(action policy.Action, object rbac.Objecter) bool {
400-
if auth := api.Authorize(r, action, object); auth {
428+
if auth := authorize(action, object); auth {
401429
return true
402430
}
403431
// Special handling for prebuilt workspace deletion
404432
if action == policy.ActionDelete {
405433
if workspaceObj, ok := object.(database.PrebuiltWorkspaceResource); ok && workspaceObj.IsPrebuild() {
406-
return api.Authorize(r, action, workspaceObj.AsPrebuild())
434+
return authorize(action, workspaceObj.AsPrebuild())
407435
}
408436
}
409437
return false
410438
},
411-
audit.WorkspaceBuildBaggageFromRequest(r),
439+
workspaceBuildBaggage,
412440
)
413441
return err
414442
}, nil)
415443
if err != nil {
416-
httperror.WriteWorkspaceBuildError(ctx, rw, err)
417-
return
444+
return codersdk.WorkspaceBuild{}, err
418445
}
419446

420447
var queuePos database.GetProvisionerJobsByIDsWithQueuePositionRow
@@ -478,11 +505,13 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
478505
provisionerDaemons,
479506
)
480507
if err != nil {
481-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
482-
Message: "Internal error converting workspace build.",
483-
Detail: err.Error(),
484-
})
485-
return
508+
return codersdk.WorkspaceBuild{}, httperror.NewResponseError(
509+
http.StatusInternalServerError,
510+
codersdk.Response{
511+
Message: "Internal error converting workspace build.",
512+
Detail: err.Error(),
513+
},
514+
)
486515
}
487516

488517
// If this workspace build has a different template version ID to the previous build
@@ -509,7 +538,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
509538
WorkspaceID: workspace.ID,
510539
})
511540

512-
httpapi.Write(ctx, rw, http.StatusCreated, apiBuild)
541+
return apiBuild, nil
513542
}
514543

515544
func (api *API) notifyWorkspaceUpdated(

codersdk/aitasks.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,18 @@ func (c *ExperimentalClient) TaskByID(ctx context.Context, id uuid.UUID) (Task,
190190

191191
return task, nil
192192
}
193+
194+
// DeleteTask deletes a task by its ID.
195+
//
196+
// Experimental: This method is experimental and may change in the future.
197+
func (c *ExperimentalClient) DeleteTask(ctx context.Context, user string, id uuid.UUID) error {
198+
res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/tasks/%s/%s", user, id.String()), nil)
199+
if err != nil {
200+
return err
201+
}
202+
defer res.Body.Close()
203+
if res.StatusCode != http.StatusAccepted {
204+
return ReadBodyAsError(res)
205+
}
206+
return nil
207+
}

0 commit comments

Comments
 (0)