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
This commit is contained in:
Mathias Fredriksson
2025-08-29 13:54:54 +00:00
committed by GitHub
parent 605dad8b1f
commit e5ac640e5e
5 changed files with 258 additions and 18 deletions
+75
View File
@@ -472,3 +472,78 @@ func (api *API) taskGet(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, tasks[0])
}
// taskDelete is an experimental endpoint to delete a task by ID (workspace ID).
// It creates a delete workspace build and returns 202 Accepted if the build was
// created.
func (api *API) taskDelete(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
idStr := chi.URLParam(r, "id")
taskID, err := uuid.Parse(idStr)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Invalid UUID %q for task ID.", idStr),
})
return
}
// For now, taskID = workspaceID, once we have a task data model in
// the DB, we can change this lookup.
workspaceID := taskID
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceID)
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace.",
Detail: err.Error(),
})
return
}
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace resources.",
Detail: err.Error(),
})
return
}
if len(data.builds) == 0 || len(data.templates) == 0 {
httpapi.ResourceNotFound(rw)
return
}
if data.builds[0].HasAITask == nil || !*data.builds[0].HasAITask {
httpapi.ResourceNotFound(rw)
return
}
// Construct a request to the workspace build creation handler to
// initiate deletion.
buildReq := codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionDelete,
Reason: "Deleted via tasks API",
}
_, err = api.postWorkspaceBuildsInternal(
ctx,
apiKey,
workspace,
buildReq,
func(action policy.Action, object rbac.Objecter) bool {
return api.Authorize(r, action, object)
},
audit.WorkspaceBuildBaggageFromRequest(r),
)
if err != nil {
httperror.WriteWorkspaceBuildError(ctx, rw, err)
return
}
// Delete build created successfully.
rw.WriteHeader(http.StatusAccepted)
}
+120
View File
@@ -3,6 +3,7 @@ package coderd_test
import (
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
@@ -265,6 +266,125 @@ func TestTasks(t *testing.T) {
assert.Equal(t, workspace.ID, task.WorkspaceID.UUID, "workspace id should match")
assert.NotEmpty(t, task.Status, "task status should not be empty")
})
t.Run("Delete", func(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
template := createAITemplate(t, client, user)
ctx := testutil.Context(t, testutil.WaitLong)
exp := codersdk.NewExperimentalClient(client)
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Prompt: "delete me",
})
require.NoError(t, err)
ws, err := client.Workspace(ctx, task.ID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
err = exp.DeleteTask(ctx, "me", task.ID)
require.NoError(t, err, "delete task request should be accepted")
// Poll until the workspace is deleted.
for {
dws, derr := client.DeletedWorkspace(ctx, task.ID)
if derr == nil && dws.LatestBuild.Status == codersdk.WorkspaceStatusDeleted {
break
}
if ctx.Err() != nil {
require.NoError(t, derr, "expected to fetch deleted workspace before deadline")
require.Equal(t, codersdk.WorkspaceStatusDeleted, dws.LatestBuild.Status, "workspace should be deleted before deadline")
break
}
time.Sleep(testutil.IntervalMedium)
}
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitShort)
exp := codersdk.NewExperimentalClient(client)
err := exp.DeleteTask(ctx, "me", uuid.New())
var sdkErr *codersdk.Error
require.Error(t, err, "expected an error for non-existent task")
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, 404, sdkErr.StatusCode())
})
t.Run("NotTaskWorkspace", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitShort)
// Create a template without AI tasks support and a workspace from it.
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
ws := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
exp := codersdk.NewExperimentalClient(client)
err := exp.DeleteTask(ctx, "me", ws.ID)
var sdkErr *codersdk.Error
require.Error(t, err, "expected an error for non-task workspace delete via tasks endpoint")
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, 404, sdkErr.StatusCode())
})
t.Run("UnauthorizedUserCannotDeleteOthersTask", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
// Owner's AI-capable template and workspace (task).
template := createAITemplate(t, client, owner)
ctx := testutil.Context(t, testutil.WaitShort)
exp := codersdk.NewExperimentalClient(client)
task, err := exp.CreateTask(ctx, "me", codersdk.CreateTaskRequest{
TemplateVersionID: template.ActiveVersionID,
Prompt: "delete me not",
})
require.NoError(t, err)
ws, err := client.Workspace(ctx, task.ID)
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID)
// Another regular org member without elevated permissions.
otherClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
expOther := codersdk.NewExperimentalClient(otherClient)
// Attempt to delete the owner's task as a non-owner without permissions.
err = expOther.DeleteTask(ctx, "me", task.ID)
var authErr *codersdk.Error
require.Error(t, err, "expected an authorization error when deleting another user's task")
require.ErrorAs(t, err, &authErr)
// Accept either 403 or 404 depending on authz behavior.
if authErr.StatusCode() != 403 && authErr.StatusCode() != 404 {
t.Fatalf("unexpected status code: %d (expected 403 or 404)", authErr.StatusCode())
}
})
})
}
func TestTasksCreate(t *testing.T) {
+1
View File
@@ -1015,6 +1015,7 @@ func New(options *Options) *API {
r.Route("/{user}", func(r chi.Router) {
r.Use(httpmw.ExtractOrganizationMembersParam(options.Database, api.HTTPAuth.Authorize))
r.Get("/{id}", api.taskGet)
r.Delete("/{id}", api.taskDelete)
r.Post("/", api.tasksCreate)
})
})
+47 -18
View File
@@ -329,13 +329,44 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ
func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
workspace := httpmw.WorkspaceParam(r)
var createBuild codersdk.CreateWorkspaceBuildRequest
if !httpapi.Read(ctx, rw, r, &createBuild) {
return
}
apiBuild, err := api.postWorkspaceBuildsInternal(
ctx,
apiKey,
workspace,
createBuild,
func(action policy.Action, object rbac.Objecter) bool {
return api.Authorize(r, action, object)
},
audit.WorkspaceBuildBaggageFromRequest(r),
)
if err != nil {
httperror.WriteWorkspaceBuildError(ctx, rw, err)
return
}
httpapi.Write(ctx, rw, http.StatusCreated, apiBuild)
}
// postWorkspaceBuildsInternal handles the internal logic for creating
// workspace builds, can be called by other handlers and must not
// reference httpmw.
func (api *API) postWorkspaceBuildsInternal(
ctx context.Context,
apiKey database.APIKey,
workspace database.Workspace,
createBuild codersdk.CreateWorkspaceBuildRequest,
authorize func(action policy.Action, object rbac.Objecter) bool,
workspaceBuildBaggage audit.WorkspaceBuildBaggage,
) (
codersdk.WorkspaceBuild,
error,
) {
transition := database.WorkspaceTransition(createBuild.Transition)
builder := wsbuilder.New(workspace, transition, *api.BuildUsageChecker.Load()).
Initiator(apiKey.UserID).
@@ -362,11 +393,10 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
previousWorkspaceBuild, err = tx.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
api.Logger.Error(ctx, "failed fetching previous workspace build", slog.F("workspace_id", workspace.ID), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching previous workspace build",
Detail: err.Error(),
})
return nil
}
if createBuild.TemplateVersionID != uuid.Nil {
@@ -375,16 +405,14 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
if createBuild.Orphan {
if createBuild.Transition != codersdk.WorkspaceTransitionDelete {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "Orphan is only permitted when deleting a workspace.",
})
return nil
}
if len(createBuild.ProvisionerState) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.",
})
return nil
}
builder = builder.Orphan()
}
@@ -397,24 +425,23 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
tx,
api.FileCache,
func(action policy.Action, object rbac.Objecter) bool {
if auth := api.Authorize(r, action, object); auth {
if auth := authorize(action, object); auth {
return true
}
// Special handling for prebuilt workspace deletion
if action == policy.ActionDelete {
if workspaceObj, ok := object.(database.PrebuiltWorkspaceResource); ok && workspaceObj.IsPrebuild() {
return api.Authorize(r, action, workspaceObj.AsPrebuild())
return authorize(action, workspaceObj.AsPrebuild())
}
}
return false
},
audit.WorkspaceBuildBaggageFromRequest(r),
workspaceBuildBaggage,
)
return err
}, nil)
if err != nil {
httperror.WriteWorkspaceBuildError(ctx, rw, err)
return
return codersdk.WorkspaceBuild{}, err
}
var queuePos database.GetProvisionerJobsByIDsWithQueuePositionRow
@@ -478,11 +505,13 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
provisionerDaemons,
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting workspace build.",
Detail: err.Error(),
})
return
return codersdk.WorkspaceBuild{}, httperror.NewResponseError(
http.StatusInternalServerError,
codersdk.Response{
Message: "Internal error converting workspace build.",
Detail: err.Error(),
},
)
}
// 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) {
WorkspaceID: workspace.ID,
})
httpapi.Write(ctx, rw, http.StatusCreated, apiBuild)
return apiBuild, nil
}
func (api *API) notifyWorkspaceUpdated(