diff --git a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx index 1eae4c548a..a2a6d199ab 100644 --- a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx +++ b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx @@ -137,6 +137,7 @@ export const deploymentStatuses: WorkerDeploymentStatus[] = [ "DEPLOYED", "FAILED", "TIMED_OUT", + "CANCELED", ]; export function deploymentStatusDescription(status: WorkerDeploymentStatus): string { diff --git a/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx b/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx deleted file mode 100644 index 50df478098..0000000000 --- a/apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx +++ /dev/null @@ -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 ( - - Rollback to this deployment? - - 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. - - - - - -
- -
-
-
- ); -} - -export function PromoteDeploymentDialog({ - projectId, - deploymentShortCode, - redirectPath, -}: RollbackDeploymentDialogProps) { - const navigation = useNavigation(); - - const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`; - const isLoading = navigation.formAction === formAction; - - return ( - - Promote this deployment? - - This deployment will become the default for all future runs not explicitly tied to a - specific deployment. - - - - - -
- -
-
-
- ); -} diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts index 21f2b79a81..8387269cb6 100644 --- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts @@ -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: { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 0805338ed7..63c0fc41a6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -158,6 +158,22 @@ export default function Page() { /> + {deployment.canceledAt && ( + + Canceled at + + <> + UTC + + + + )} + {deployment.canceledReason && ( + + Cancelation reason + {deployment.canceledReason} + + )} Tasks {deployment.tasks ? deployment.tasks.length : "–"} @@ -200,6 +216,18 @@ export default function Page() { )} + + Installed at + + {deployment.installedAt ? ( + <> + UTC + + ) : ( + "–" + )} + + Built at diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 204ffbc58b..7f1f94dc31 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -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 ( {""} @@ -419,7 +440,7 @@ function DeploymentActionsCell({ fullWidth textAlignLeft > - Rollback… + Rollback - Promote… + Promote )} + {canBeCanceled && ( + + + + + + + )} } /> ); } + +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 ( + + Rollback to this deployment? + + 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. + + + + + +
+ +
+
+
+ ); +} + +function PromoteDeploymentDialog({ + projectId, + deploymentShortCode, + redirectPath, +}: RollbackDeploymentDialogProps) { + const navigation = useNavigation(); + + const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`; + const isLoading = navigation.formAction === formAction; + + return ( + + Promote this deployment? + + This deployment will become the default for all future runs not explicitly tied to a + specific deployment. + + + + + +
+ +
+
+
+ ); +} + +function CancelDeploymentDialog({ + projectId, + deploymentShortCode, + redirectPath, +}: RollbackDeploymentDialogProps) { + const navigation = useNavigation(); + + const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/cancel`; + const isLoading = navigation.formAction === formAction; + + return ( + + Cancel this deployment? + Canceling a deployment cannot be undone. Are you sure? + + + + +
+ +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts new file mode 100644 index 0000000000..dd209d4494 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.cancel.ts @@ -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 }); + } + } + ); +} diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts index d07d9a2f3c..beb0fcd7c8 100644 --- a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.progress.ts @@ -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 }); diff --git a/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts new file mode 100644 index 0000000000..c802d115ad --- /dev/null +++ b/apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.cancel.ts @@ -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}.` + ); +}; diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index d3411d9f8f..e12256526c 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -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, + friendlyId: string, + data?: Partial> + ) { + 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) => { + 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) => + 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) => + 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); + } } diff --git a/apps/webapp/app/v3/services/failDeployment.server.ts b/apps/webapp/app/v3/services/failDeployment.server.ts index b486a0f67e..79234b8310 100644 --- a/apps/webapp/app/v3/services/failDeployment.server.ts +++ b/apps/webapp/app/v3/services/failDeployment.server.ts @@ -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", diff --git a/internal-packages/database/prisma/migrations/20250923192901_add_canceled_at_to_deployments/migration.sql b/internal-packages/database/prisma/migrations/20250923192901_add_canceled_at_to_deployments/migration.sql new file mode 100644 index 0000000000..c279f9dd32 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250923192901_add_canceled_at_to_deployments/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "public"."WorkerDeployment" ADD COLUMN "canceledAt" TIMESTAMP(3), +ADD COLUMN "canceledReason" TEXT; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index c3c26ba507..e7e47b0707 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1772,6 +1772,9 @@ model WorkerDeployment { failedAt DateTime? errorData Json? + canceledAt DateTime? + canceledReason String? + // This is GitMeta type git Json? diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 1a6242b0c1..f8e12f62cc 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -385,6 +385,12 @@ export const ProgressDeploymentRequestBody = z.object({ export type ProgressDeploymentRequestBody = z.infer; +export const CancelDeploymentRequestBody = z.object({ + reason: z.string().max(200, "Reason must be less than 200 characters").optional(), +}); + +export type CancelDeploymentRequestBody = z.infer; + export const ExternalBuildData = z.object({ buildId: z.string(), buildToken: z.string(),