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