fix: delete related task when deleting workspace (#20567)

* Instead of prompting the user to start a deleted workspace (which is
silly), prompt them to create a new task instead.
* Adds a warning dialog when deleting a workspace
* Updates provisionerdserver to delete the related task if a workspace
is related to a task
This commit is contained in:
Cian Johnston
2025-10-30 10:37:51 +00:00
committed by GitHub
parent 94f6e83cfa
commit 73dedcc765
7 changed files with 118 additions and 12 deletions
+46 -6
View File
@@ -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) {
+2 -2
View File
@@ -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.
@@ -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)
@@ -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,
},
};
@@ -56,6 +56,8 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
(workspace.latest_build.status === "failed" ||
workspace.latest_build.status === "canceled");
const hasTask = !!workspace.task_id;
return (
<ConfirmDialog
type="delete"
@@ -109,8 +111,24 @@ export const WorkspaceDeleteDialog: FC<WorkspaceDeleteDialogProps> = ({
"data-testid": "delete-dialog-name-confirmation",
}}
/>
{hasTask && (
<div css={styles.warnContainer}>
<div css={{ flexDirection: "column" }}>
<p className="info">This workspace is related to a task</p>
<span css={{ fontSize: 12, marginTop: 4, display: "block" }}>
Deleting this workspace will also delete{" "}
<Link
href={`/tasks/${workspace.owner_name}/${workspace.task_id}`}
>
this task
</Link>
.
</span>
</div>
</div>
)}
{canOrphan && (
<div css={styles.orphanContainer}>
<div css={styles.warnContainer}>
<div css={{ flexDirection: "column" }}>
<Checkbox
id="orphan_resources"
@@ -178,7 +196,7 @@ const styles = {
color: theme.palette.text.primary,
},
}),
orphanContainer: (theme) => ({
warnContainer: (theme) => ({
marginTop: 24,
display: "flex",
backgroundColor: theme.roles.danger.background,
@@ -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);
+21 -1
View File
@@ -221,7 +221,27 @@ const WorkspaceNotRunning: FC<WorkspaceNotRunningProps> = ({ workspace }) => {
? mutateStartWorkspace.error
: undefined;
return (
const deleted = workspace.latest_build?.transition === ("delete" as const);
return deleted ? (
<Margins>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">
<h3 className="m-0 font-medium text-content-primary text-base">
Task workspace was deleted.
</h3>
<span className="text-content-secondary text-sm">
This task cannot be resumed. Delete this task and create a new one.
</span>
<Button size="sm" variant="outline" asChild className="mt-4">
<RouterLink to="/tasks" data-testid="task-create-new">
Create a new task
</RouterLink>
</Button>
</div>
</div>
</Margins>
) : (
<Margins>
<div className="w-full min-h-80 flex items-center justify-center">
<div className="flex flex-col items-center">