diff --git a/coderd/aitasks.go b/coderd/aitasks.go index c736998b7ae88..466cedd4097d3 100644 --- a/coderd/aitasks.go +++ b/coderd/aitasks.go @@ -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) +} diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 131238de8a5bd..802d738162854 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -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) { diff --git a/coderd/coderd.go b/coderd/coderd.go index 053880ce31b89..c06f44b10b40e 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) }) }) diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index e54f75ef5cba6..2fdb40a1e4661 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -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( diff --git a/codersdk/aitasks.go b/codersdk/aitasks.go index 753471e34b565..764fd26ae7996 100644 --- a/codersdk/aitasks.go +++ b/codersdk/aitasks.go @@ -190,3 +190,18 @@ func (c *ExperimentalClient) TaskByID(ctx context.Context, id uuid.UUID) (Task, return task, nil } + +// DeleteTask deletes a task by its ID. +// +// Experimental: This method is experimental and may change in the future. +func (c *ExperimentalClient) DeleteTask(ctx context.Context, user string, id uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/experimental/tasks/%s/%s", user, id.String()), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusAccepted { + return ReadBodyAsError(res) + } + return nil +}