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:
@@ -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: {
|
||||
|
||||
+28
@@ -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>
|
||||
|
||||
+178
-10
@@ -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 });
|
||||
|
||||
+133
@@ -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",
|
||||
|
||||
+2
@@ -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?
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user