feat: enable canceling deployments (#2545)

* Add canceledAt to the deployment db schema

* Expose an api endpoint to cancel deployments

* Show the canceled status description in the dashboard

* Enable canceling deployments from the dashboard

* Show cancelation reason in the deployment details

* Make verifyProjectMembership a function for consistency

* Apply some good 🐰 suggestions
This commit is contained in:
Saadi Myftija
2025-09-24 11:14:29 +02:00
committed by GitHub
parent cc94d121f2
commit 700a6ea598
13 changed files with 526 additions and 121 deletions
@@ -137,6 +137,7 @@ export const deploymentStatuses: WorkerDeploymentStatus[] = [
"DEPLOYED",
"FAILED",
"TIMED_OUT",
"CANCELED",
];
export function deploymentStatusDescription(status: WorkerDeploymentStatus): string {
@@ -1,102 +0,0 @@
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation } from "@remix-run/react";
import { Button } from "~/components/primitives/Buttons";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
} from "~/components/primitives/Dialog";
import { SpinnerWhite } from "~/components/primitives/Spinner";
type RollbackDeploymentDialogProps = {
projectId: string;
deploymentShortCode: string;
redirectPath: string;
};
export function RollbackDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="rollback">
<DialogHeader>Rollback to this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs. Tasks triggered but not
included in this deploy will remain queued until you roll back to or create a new deployment
with these tasks included.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Rolling back..." : "Rollback deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
export function PromoteDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="promote">
<DialogHeader>Promote this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs not explicitly tied to a
specific deployment.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Promoting..." : "Promote deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
@@ -103,6 +103,9 @@ export class DeploymentPresenter {
deployedAt: true,
createdAt: true,
startedAt: true,
installedAt: true,
canceledAt: true,
canceledReason: true,
git: true,
promotions: {
select: {
@@ -147,8 +150,11 @@ export class DeploymentPresenter {
status: deployment.status,
createdAt: deployment.createdAt,
startedAt: deployment.startedAt,
installedAt: deployment.installedAt,
builtAt: deployment.builtAt,
deployedAt: deployment.deployedAt,
canceledAt: deployment.canceledAt,
canceledReason: deployment.canceledReason,
tasks: deployment.worker?.tasks,
label: deployment.promotions?.[0]?.label,
environment: {
@@ -158,6 +158,22 @@ export default function Page() {
/>
</Property.Value>
</Property.Item>
{deployment.canceledAt && (
<Property.Item>
<Property.Label>Canceled at</Property.Label>
<Property.Value>
<>
<DateTimeAccurate date={deployment.canceledAt} /> UTC
</>
</Property.Value>
</Property.Item>
)}
{deployment.canceledReason && (
<Property.Item>
<Property.Label>Cancelation reason</Property.Label>
<Property.Value>{deployment.canceledReason}</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Tasks</Property.Label>
<Property.Value>{deployment.tasks ? deployment.tasks.length : ""}</Property.Value>
@@ -200,6 +216,18 @@ export default function Page() {
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Installed at</Property.Label>
<Property.Value>
{deployment.installedAt ? (
<>
<DateTimeAccurate date={deployment.installedAt} /> UTC
</>
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Built at</Property.Label>
<Property.Value>
@@ -1,5 +1,18 @@
import { ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid";
import { type MetaFunction, Outlet, useLocation, useNavigate, useParams } from "@remix-run/react";
import {
ArrowPathIcon,
ArrowUturnLeftIcon,
BookOpenIcon,
NoSymbolIcon,
} from "@heroicons/react/20/solid";
import {
Form,
type MetaFunction,
Outlet,
useLocation,
useNavigate,
useNavigation,
useParams,
} from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { CogIcon, GitBranchIcon } from "lucide-react";
import { useEffect } from "react";
@@ -15,7 +28,15 @@ import { MainCenteredContainer, PageBody, PageContainer } from "~/components/lay
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import {
Dialog,
DialogDescription,
DialogContent,
DialogHeader,
DialogTrigger,
DialogFooter,
} from "~/components/primitives/Dialog";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -39,10 +60,6 @@ import {
deploymentStatusDescription,
deploymentStatuses,
} from "~/components/runs/v3/DeploymentStatus";
import {
PromoteDeploymentDialog,
RollbackDeploymentDialog,
} from "~/components/runs/v3/RollbackDeploymentDialog";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
@@ -63,6 +80,7 @@ import { createSearchParams } from "~/utils/searchParams";
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
import { env } from "~/env.server";
import { DialogClose } from "@radix-ui/react-dialog";
export const meta: MetaFunction = () => {
return [
@@ -395,7 +413,10 @@ function DeploymentActionsCell({
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;
if (!canBeRolledBack && !canBePromoted) {
const finalStatuses = ["CANCELED", "DEPLOYED", "FAILED", "TIMED_OUT"];
const canBeCanceled = !finalStatuses.includes(deployment.status);
if (!canBeRolledBack && !canBePromoted && !canBeCanceled) {
return (
<TableCell to={path} isSelected={isSelected}>
{""}
@@ -419,7 +440,7 @@ function DeploymentActionsCell({
fullWidth
textAlignLeft
>
Rollback
Rollback
</Button>
</DialogTrigger>
<RollbackDeploymentDialog
@@ -439,7 +460,7 @@ function DeploymentActionsCell({
fullWidth
textAlignLeft
>
Promote
Promote
</Button>
</DialogTrigger>
<PromoteDeploymentDialog
@@ -449,8 +470,155 @@ function DeploymentActionsCell({
/>
</Dialog>
)}
{canBeCanceled && (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-error"
fullWidth
textAlignLeft
>
Cancel
</Button>
</DialogTrigger>
<CancelDeploymentDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
</>
}
/>
);
}
type RollbackDeploymentDialogProps = {
projectId: string;
deploymentShortCode: string;
redirectPath: string;
};
function RollbackDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="rollback">
<DialogHeader>Rollback to this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs. Tasks triggered but not
included in this deploy will remain queued until you roll back to or create a new deployment
with these tasks included.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Rolling back..." : "Rollback deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
function PromoteDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="promote">
<DialogHeader>Promote this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs not explicitly tied to a
specific deployment.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? SpinnerWhite : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Promoting..." : "Promote deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
function CancelDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`;
const isLoading = navigation.formAction === formAction;
return (
<DialogContent key="cancel">
<DialogHeader>Cancel this deployment?</DialogHeader>
<DialogDescription>Canceling a deployment cannot be undone. Are you sure?</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Back</Button>
</DialogClose>
<Form action={formAction} method="post">
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="danger/medium"
LeadingIcon={isLoading ? SpinnerWhite : NoSymbolIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Canceling..." : "Cancel deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}
@@ -0,0 +1,72 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { CancelDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { DeploymentService } from "~/v3/services/deployment.server";
const ParamsSchema = z.object({
deploymentId: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "Method Not Allowed" }, { status: 405 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
const authenticationResult = await authenticateRequest(request, {
apiKey: true,
organizationAccessToken: false,
personalAccessToken: false,
});
if (!authenticationResult || !authenticationResult.result.ok) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const { environment: authenticatedEnv } = authenticationResult.result;
const { deploymentId } = parsedParams.data;
const [, rawBody] = await tryCatch(request.json());
const body = CancelDeploymentRequestBody.safeParse(rawBody ?? {});
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}
const deploymentService = new DeploymentService();
return await deploymentService
.cancelDeployment(authenticatedEnv, deploymentId, {
canceledReason: body.data.reason,
})
.match(
() => {
return new Response(null, { status: 204 });
},
(error) => {
switch (error.type) {
case "deployment_not_found":
return json({ error: "Deployment not found" }, { status: 404 });
case "failed_to_delete_deployment_timeout":
return new Response(null, { status: 204 }); // not a critical error, ignore
case "deployment_cannot_be_cancelled":
return json(
{ error: "Deployment is already in a final state and cannot be canceled" },
{ status: 409 }
);
case "other":
default:
error.type satisfies "other";
return json({ error: "Internal server error" }, { status: 500 });
}
}
);
}
@@ -1,5 +1,5 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { ProgressDeploymentRequestBody } from "@trigger.dev/core/v3";
import { ProgressDeploymentRequestBody, tryCatch } from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
@@ -34,8 +34,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
const { environment: authenticatedEnv } = authenticationResult.result;
const { deploymentId } = parsedParams.data;
const rawBody = await request.json();
const body = ProgressDeploymentRequestBody.safeParse(rawBody);
const [, rawBody] = await tryCatch(request.json());
const body = ProgressDeploymentRequestBody.safeParse(rawBody ?? {});
if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
@@ -0,0 +1,133 @@
import { parse } from "@conform-to/zod";
import { type ActionFunction, json } from "@remix-run/node";
import { errAsync, fromPromise, okAsync } from "neverthrow";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { DeploymentService } from "~/v3/services/deployment.server";
export const cancelSchema = z.object({
redirectUrl: z.string(),
});
const ParamSchema = z.object({
projectId: z.string(),
deploymentShortCode: z.string(),
});
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { projectId, deploymentShortCode } = ParamSchema.parse(params);
const formData = await request.formData();
const submission = parse(formData, { schema: cancelSchema });
if (!submission.value) {
return json(submission);
}
const verifyProjectMembership = () =>
fromPromise(
prisma.project.findFirst({
where: {
id: projectId,
organization: {
members: {
some: {
userId,
},
},
},
},
select: {
id: true,
},
}),
(error) => ({ type: "other" as const, cause: error })
).andThen((project) => {
if (!project) {
return errAsync({ type: "project_not_found" as const });
}
return okAsync(project);
});
const findDeploymentFriendlyId = ({ id }: { id: string }) =>
fromPromise(
prisma.workerDeployment.findUnique({
select: {
friendlyId: true,
projectId: true,
},
where: {
projectId_shortCode: {
projectId: id,
shortCode: deploymentShortCode,
},
},
}),
(error) => ({ type: "other" as const, cause: error })
).andThen((deployment) => {
if (!deployment) {
return errAsync({ type: "deployment_not_found" as const });
}
return okAsync(deployment);
});
const deploymentService = new DeploymentService();
const result = await verifyProjectMembership()
.andThen(findDeploymentFriendlyId)
.andThen((deployment) =>
deploymentService.cancelDeployment({ projectId: deployment.projectId }, deployment.friendlyId)
);
if (result.isErr()) {
logger.error(
`Failed to cancel deployment: ${result.error.type}`,
result.error.type === "other"
? {
cause: result.error.cause,
}
: undefined
);
switch (result.error.type) {
case "project_not_found":
return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found");
case "deployment_not_found":
return redirectWithErrorMessage(
submission.value.redirectUrl,
request,
"Deployment not found"
);
case "deployment_cannot_be_cancelled":
return redirectWithErrorMessage(
submission.value.redirectUrl,
request,
"Deployment is already in a final state and cannot be canceled"
);
case "failed_to_delete_deployment_timeout":
// not a critical error, ignore
return redirectWithSuccessMessage(
submission.value.redirectUrl,
request,
`Canceled deployment ${deploymentShortCode}.`
);
case "other":
default:
result.error.type satisfies "other";
return redirectWithErrorMessage(
submission.value.redirectUrl,
request,
"Internal server error"
);
}
}
return redirectWithSuccessMessage(
submission.value.redirectUrl,
request,
`Canceled deployment ${deploymentShortCode}.`
);
};
@@ -1,11 +1,12 @@
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { BaseService } from "./baseService.server";
import { errAsync, fromPromise, okAsync } from "neverthrow";
import { type WorkerDeploymentStatus, type WorkerDeployment } from "@trigger.dev/database";
import { type ExternalBuildData, logger, type GitMeta } from "@trigger.dev/core/v3";
import { type WorkerDeployment } from "@trigger.dev/database";
import { logger, type GitMeta } from "@trigger.dev/core/v3";
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
import { env } from "~/env.server";
import { createRemoteImageBuild } from "../remoteImageBuilder.server";
import { FINAL_DEPLOYMENT_STATUSES } from "./failDeployment.server";
export class DeploymentService extends BaseService {
/**
@@ -143,4 +144,91 @@ export class DeploymentService extends BaseService {
.andThen(extendTimeout)
.map(() => undefined);
}
/**
* Cancels a deployment that is not yet in a final state.
*
* Only acts when the current status is not final. Not idempotent.
*
* @param authenticatedEnv The environment which the deployment belongs to.
* @param friendlyId The friendly deployment ID.
* @param data Cancelation reason.
*/
public cancelDeployment(
authenticatedEnv: Pick<AuthenticatedEnvironment, "projectId">,
friendlyId: string,
data?: Partial<Pick<WorkerDeployment, "canceledReason">>
) {
const getDeployment = () =>
fromPromise(
this._prisma.workerDeployment.findFirst({
where: {
friendlyId,
projectId: authenticatedEnv.projectId,
},
select: {
status: true,
id: true,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).andThen((deployment) => {
if (!deployment) {
return errAsync({ type: "deployment_not_found" as const });
}
return okAsync(deployment);
});
const validateDeployment = (deployment: Pick<WorkerDeployment, "id" | "status">) => {
if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) {
logger.warn("Attempted cancelling deployment in a final state", {
deployment,
});
return errAsync({ type: "deployment_cannot_be_cancelled" as const });
}
return okAsync(deployment);
};
const cancelDeployment = (deployment: Pick<WorkerDeployment, "id">) =>
fromPromise(
this._prisma.workerDeployment.updateMany({
where: {
id: deployment.id,
status: {
notIn: FINAL_DEPLOYMENT_STATUSES, // status could've changed in the meantime, we're not locking the row
},
},
data: {
status: "CANCELED",
canceledAt: new Date(),
canceledReason: data?.canceledReason,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).andThen((result) => {
if (result.count === 0) {
return errAsync({ type: "deployment_cannot_be_cancelled" as const });
}
return okAsync({ id: deployment.id });
});
const deleteTimeout = (deployment: Pick<WorkerDeployment, "id">) =>
fromPromise(TimeoutDeploymentService.dequeue(deployment.id, this._prisma), (error) => ({
type: "failed_to_delete_deployment_timeout" as const,
cause: error,
}));
return getDeployment()
.andThen(validateDeployment)
.andThen(cancelDeployment)
.andThen(deleteTimeout)
.map(() => undefined);
}
}
@@ -1,11 +1,11 @@
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
import { BaseService } from "./baseService.server";
import { logger } from "~/services/logger.server";
import { WorkerDeploymentStatus } from "@trigger.dev/database";
import { FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { type WorkerDeploymentStatus } from "@trigger.dev/database";
import { type FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
const FINAL_DEPLOYMENT_STATUSES: WorkerDeploymentStatus[] = [
export const FINAL_DEPLOYMENT_STATUSES: WorkerDeploymentStatus[] = [
"CANCELED",
"DEPLOYED",
"FAILED",
@@ -0,0 +1,2 @@
ALTER TABLE "public"."WorkerDeployment" ADD COLUMN "canceledAt" TIMESTAMP(3),
ADD COLUMN "canceledReason" TEXT;
@@ -1772,6 +1772,9 @@ model WorkerDeployment {
failedAt DateTime?
errorData Json?
canceledAt DateTime?
canceledReason String?
// This is GitMeta type
git Json?
+6
View File
@@ -385,6 +385,12 @@ export const ProgressDeploymentRequestBody = z.object({
export type ProgressDeploymentRequestBody = z.infer<typeof ProgressDeploymentRequestBody>;
export const CancelDeploymentRequestBody = z.object({
reason: z.string().max(200, "Reason must be less than 200 characters").optional(),
});
export type CancelDeploymentRequestBody = z.infer<typeof CancelDeploymentRequestBody>;
export const ExternalBuildData = z.object({
buildId: z.string(),
buildToken: z.string(),