diff --git a/coderd/aitasks_test.go b/coderd/aitasks_test.go index 9fa8e168a4..4f645d34cf 100644 --- a/coderd/aitasks_test.go +++ b/coderd/aitasks_test.go @@ -355,10 +355,10 @@ func TestTasks(t *testing.T) { } }) - t.Run("NoWorkspace", func(t *testing.T) { + t.Run("DeletedWorkspace", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) user := coderdtest.CreateFirstUser(t, client) template := createAITemplate(t, client, user) ctx := testutil.Context(t, testutil.WaitLong) @@ -372,14 +372,54 @@ func TestTasks(t *testing.T) { ws, err := client.Workspace(ctx, task.WorkspaceID.UUID) require.NoError(t, err) coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) - // Delete the task workspace - coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionDelete) - // We should still be able to fetch the task after deleting its workspace + + // Mark the workspace as deleted directly in the database, bypassing provisionerd. + require.NoError(t, db.UpdateWorkspaceDeletedByID(dbauthz.AsProvisionerd(ctx), database.UpdateWorkspaceDeletedByIDParams{ + ID: ws.ID, + Deleted: true, + })) + // We should still be able to fetch the task if its workspace was deleted. + // Provisionerdserver will attempt delete the related task when deleting a workspace. + // This test ensures that we can still handle the case where, for some reason, the + // task has not been marked as deleted, but the workspace has. task, err = exp.TaskByID(ctx, task.ID) - require.NoError(t, err, "fetching a task should still work after deleting its related workspace") + require.NoError(t, err, "fetching a task should still work if its related workspace is deleted") err = exp.DeleteTask(ctx, task.OwnerID.String(), task.ID) require.NoError(t, err, "should be possible to delete a task with no workspace") }) + + t.Run("DeletingTaskWorkspaceDeletesTask", 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, + Input: "delete me", + }) + require.NoError(t, err) + require.True(t, task.WorkspaceID.Valid, "task should have a workspace ID") + ws, err := client.Workspace(ctx, task.WorkspaceID.UUID) + require.NoError(t, err) + if assert.True(t, ws.TaskID.Valid, "task id should be set on workspace") { + assert.Equal(t, task.ID, ws.TaskID.UUID, "workspace task id should match") + } + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + + // When; the task workspace is deleted + coderdtest.MustTransitionWorkspace(t, client, ws.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionDelete) + // Then: the task associated with the workspace is also deleted + _, err = exp.TaskByID(ctx, task.ID) + require.Error(t, err, "expected an error fetching the task") + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr, "expected a codersdk.Error") + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) }) t.Run("Send", func(t *testing.T) { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 1b2a6a5d97..8066ebd047 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -219,8 +219,8 @@ var ( rbac.ResourceUser.Type: {policy.ActionRead, policy.ActionReadPersonal, policy.ActionUpdatePersonal}, rbac.ResourceWorkspaceDormant.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStop}, rbac.ResourceWorkspace.Type: {policy.ActionDelete, policy.ActionRead, policy.ActionUpdate, policy.ActionWorkspaceStart, policy.ActionWorkspaceStop, policy.ActionCreateAgent}, - // Provisionerd needs to read and update tasks associated with workspaces. - rbac.ResourceTask.Type: {policy.ActionRead, policy.ActionUpdate}, + // Provisionerd needs to read, update, and delete tasks associated with workspaces. + rbac.ResourceTask.Type: {policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}, rbac.ResourceApiKey.Type: {policy.WildcardSymbol}, // When org scoped provisioner credentials are implemented, // this can be reduced to read a specific org. diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index bf7741bdc2..2e00796d1c 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -2278,6 +2278,14 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro if err != nil { return xerrors.Errorf("update workspace deleted: %w", err) } + if workspace.TaskID.Valid { + if _, err := db.DeleteTask(ctx, database.DeleteTaskParams{ + ID: workspace.TaskID.UUID, + DeletedAt: dbtime.Now(), + }); err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("delete task related to workspace: %w", err) + } + } return nil }, nil) diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.stories.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.stories.tsx index b5fcd44b7c..7debfb1ce9 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.stories.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.stories.tsx @@ -1,4 +1,8 @@ -import { MockFailedWorkspace, MockWorkspace } from "testHelpers/entities"; +import { + MockFailedWorkspace, + MockTaskWorkspace, + MockWorkspace, +} from "testHelpers/entities"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { daysAgo } from "utils/time"; import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog"; @@ -45,3 +49,9 @@ export const UnhealthyAdminView: Story = { canDeleteFailedWorkspace: true, }, }; + +export const WithTask: Story = { + args: { + workspace: MockTaskWorkspace, + }, +}; diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx index 2cfb74f276..245f95c0f7 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceDeleteDialog.tsx @@ -56,6 +56,8 @@ export const WorkspaceDeleteDialog: FC = ({ (workspace.latest_build.status === "failed" || workspace.latest_build.status === "canceled"); + const hasTask = !!workspace.task_id; + return ( = ({ "data-testid": "delete-dialog-name-confirmation", }} /> + {hasTask && ( +
+
+

This workspace is related to a task

+ + Deleting this workspace will also delete{" "} + + this task + + . + +
+
+ )} {canOrphan && ( -
+
({ + warnContainer: (theme) => ({ marginTop: 24, display: "flex", backgroundColor: theme.roles.danger.background, diff --git a/site/src/pages/TaskPage/TaskPage.stories.tsx b/site/src/pages/TaskPage/TaskPage.stories.tsx index 19003eb621..22fccb7349 100644 --- a/site/src/pages/TaskPage/TaskPage.stories.tsx +++ b/site/src/pages/TaskPage/TaskPage.stories.tsx @@ -1,4 +1,5 @@ import { + MockDeletedWorkspace, MockFailedWorkspace, MockStartingWorkspace, MockStoppedWorkspace, @@ -169,6 +170,15 @@ export const TerminatedBuildWithStatus: Story = { }, }; +export const DeletedWorkspace: Story = { + beforeEach: () => { + spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); + spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValue( + MockDeletedWorkspace, + ); + }, +}; + export const WaitingStartupScripts: Story = { beforeEach: () => { spyOn(API.experimental, "getTask").mockResolvedValue(MockTask); diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx index 17cdc8b686..49f9d75696 100644 --- a/site/src/pages/TaskPage/TaskPage.tsx +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -221,7 +221,27 @@ const WorkspaceNotRunning: FC = ({ workspace }) => { ? mutateStartWorkspace.error : undefined; - return ( + const deleted = workspace.latest_build?.transition === ("delete" as const); + + return deleted ? ( + +
+
+

+ Task workspace was deleted. +

+ + This task cannot be resumed. Delete this task and create a new one. + + +
+
+
+ ) : (