Skip to content

Commit 0f00018

Browse files
committed
Expose an api endpoint to cancel deployments
1 parent 3f4a6b4 commit 0f00018

File tree

4 files changed

+158
-6
lines changed

4 files changed

+158
-6
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { CancelDeploymentRequestBody } from "@trigger.dev/core/v3";
3+
import { z } from "zod";
4+
import { authenticateRequest } from "~/services/apiAuth.server";
5+
import { logger } from "~/services/logger.server";
6+
import { DeploymentService } from "~/v3/services/deployment.server";
7+
8+
const ParamsSchema = z.object({
9+
deploymentId: z.string(),
10+
});
11+
12+
export async function action({ request, params }: ActionFunctionArgs) {
13+
if (request.method.toUpperCase() !== "POST") {
14+
return json({ error: "Method Not Allowed" }, { status: 405 });
15+
}
16+
17+
const parsedParams = ParamsSchema.safeParse(params);
18+
19+
if (!parsedParams.success) {
20+
return json({ error: "Invalid params" }, { status: 400 });
21+
}
22+
23+
const authenticationResult = await authenticateRequest(request, {
24+
apiKey: true,
25+
organizationAccessToken: false,
26+
personalAccessToken: false,
27+
});
28+
29+
if (!authenticationResult || !authenticationResult.result.ok) {
30+
logger.info("Invalid or missing api key", { url: request.url });
31+
return json({ error: "Invalid or Missing API key" }, { status: 401 });
32+
}
33+
34+
const { environment: authenticatedEnv } = authenticationResult.result;
35+
const { deploymentId } = parsedParams.data;
36+
37+
const rawBody = await request.json();
38+
const body = CancelDeploymentRequestBody.safeParse(rawBody);
39+
40+
if (!body.success) {
41+
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
42+
}
43+
44+
const deploymentService = new DeploymentService();
45+
46+
return await deploymentService
47+
.cancelDeployment(authenticatedEnv, deploymentId, {
48+
canceledReason: body.data.reason,
49+
})
50+
.match(
51+
() => {
52+
return new Response(null, { status: 204 });
53+
},
54+
(error) => {
55+
switch (error.type) {
56+
case "deployment_not_found":
57+
return json({ error: "Deployment not found" }, { status: 404 });
58+
case "deployment_cannot_be_cancelled":
59+
return json(
60+
{ error: "Deployment is already in a final state and cannot be canceled" },
61+
{ status: 409 }
62+
);
63+
case "other":
64+
default:
65+
error.type satisfies "other";
66+
return json({ error: "Internal server error" }, { status: 500 });
67+
}
68+
}
69+
);
70+
}

apps/webapp/app/v3/services/deployment.server.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
22
import { BaseService } from "./baseService.server";
33
import { errAsync, fromPromise, okAsync } from "neverthrow";
4-
import { type WorkerDeploymentStatus, type WorkerDeployment } from "@trigger.dev/database";
5-
import { type ExternalBuildData, logger, type GitMeta } from "@trigger.dev/core/v3";
4+
import { type WorkerDeployment } from "@trigger.dev/database";
5+
import { logger, type GitMeta } from "@trigger.dev/core/v3";
66
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
77
import { env } from "~/env.server";
88
import { createRemoteImageBuild } from "../remoteImageBuilder.server";
9+
import { FINAL_DEPLOYMENT_STATUSES } from "./failDeployment.server";
910

1011
export class DeploymentService extends BaseService {
1112
/**
@@ -143,4 +144,79 @@ export class DeploymentService extends BaseService {
143144
.andThen(extendTimeout)
144145
.map(() => undefined);
145146
}
147+
148+
/**
149+
* Cancels a deployment that is not yet in a final state.
150+
*
151+
* Only acts when the current status is not final. Not idempotent.
152+
*
153+
* @param authenticatedEnv The environment which the deployment belongs to.
154+
* @param friendlyId The friendly deployment ID.
155+
* @param data Cancelation reason.
156+
*/
157+
public cancelDeployment(
158+
authenticatedEnv: AuthenticatedEnvironment,
159+
friendlyId: string,
160+
data: Partial<Pick<WorkerDeployment, "canceledReason">>
161+
) {
162+
const getDeployment = () =>
163+
fromPromise(
164+
this._prisma.workerDeployment.findFirst({
165+
where: {
166+
friendlyId,
167+
environmentId: authenticatedEnv.id,
168+
},
169+
select: {
170+
status: true,
171+
id: true,
172+
},
173+
}),
174+
(error) => ({
175+
type: "other" as const,
176+
cause: error,
177+
})
178+
).andThen((deployment) => {
179+
if (!deployment) {
180+
return errAsync({ type: "deployment_not_found" as const });
181+
}
182+
return okAsync(deployment);
183+
});
184+
185+
const validateDeployment = (deployment: Pick<WorkerDeployment, "id" | "status">) => {
186+
if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) {
187+
logger.warn("Attempted cancelling deployment in a final state", {
188+
deployment,
189+
});
190+
return errAsync({ type: "deployment_cannot_be_cancelled" as const });
191+
}
192+
193+
return okAsync(deployment);
194+
};
195+
196+
const cancelDeployment = (deployment: Pick<WorkerDeployment, "id">) =>
197+
fromPromise(
198+
this._prisma.workerDeployment.updateMany({
199+
where: {
200+
id: deployment.id,
201+
status: {
202+
notIn: FINAL_DEPLOYMENT_STATUSES, // status could've changed in the meantime, we're not locking the row
203+
},
204+
},
205+
data: {
206+
status: "CANCELED",
207+
canceledAt: new Date(),
208+
canceledReason: data.canceledReason,
209+
},
210+
}),
211+
(error) => ({
212+
type: "other" as const,
213+
cause: error,
214+
})
215+
);
216+
217+
return getDeployment()
218+
.andThen(validateDeployment)
219+
.andThen(cancelDeployment)
220+
.map(() => undefined);
221+
}
146222
}

apps/webapp/app/v3/services/failDeployment.server.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
22
import { BaseService } from "./baseService.server";
33
import { logger } from "~/services/logger.server";
4-
import { WorkerDeploymentStatus } from "@trigger.dev/database";
5-
import { FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
6-
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
4+
import { type WorkerDeploymentStatus } from "@trigger.dev/database";
5+
import { type FailDeploymentRequestBody } from "@trigger.dev/core/v3/schemas";
6+
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
77

8-
const FINAL_DEPLOYMENT_STATUSES: WorkerDeploymentStatus[] = [
8+
export const FINAL_DEPLOYMENT_STATUSES: WorkerDeploymentStatus[] = [
99
"CANCELED",
1010
"DEPLOYED",
1111
"FAILED",

packages/core/src/v3/schemas/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,12 @@ export const ProgressDeploymentRequestBody = z.object({
385385

386386
export type ProgressDeploymentRequestBody = z.infer<typeof ProgressDeploymentRequestBody>;
387387

388+
export const CancelDeploymentRequestBody = z.object({
389+
reason: z.string().max(200, "Reason must be less than 200 characters").optional(),
390+
});
391+
392+
export type CancelDeploymentRequestBody = z.infer<typeof CancelDeploymentRequestBody>;
393+
388394
export const ExternalBuildData = z.object({
389395
buildId: z.string(),
390396
buildToken: z.string(),

0 commit comments

Comments
 (0)