mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user