Skip to content

Commit a3ef6ea

Browse files
authored
feat: separate deployment initialize and start steps (#2522)
* Enable setting the initial status on deployment creation * Expose endpoint to start deployments * Extend build timeout on deployment start * Use separate timeout value for queued deployments * Add startedAt to the deployment schema * Show the new startedAt instead of createdAt in the dashboard * Show github user tag also in the deployment details page * Show `pending` deployment status as `queued` in the dashboard * Apply some good 🐰 suggestions * Add missing return
1 parent ae22000 commit a3ef6ea

File tree

12 files changed

+213
-19
lines changed

12 files changed

+213
-19
lines changed

apps/webapp/app/components/runs/v3/DeploymentStatus.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
CheckCircleIcon,
33
ExclamationTriangleIcon,
44
NoSymbolIcon,
5+
RectangleStackIcon,
56
XCircleIcon,
67
} from "@heroicons/react/20/solid";
78
import type { WorkerDeploymentStatus } from "@trigger.dev/database";
@@ -49,6 +50,9 @@ export function DeploymentStatusIcon({
4950
}) {
5051
switch (status) {
5152
case "PENDING":
53+
return (
54+
<RectangleStackIcon className={cn(deploymentStatusClassNameColor(status), className)} />
55+
);
5256
case "BUILDING":
5357
case "DEPLOYING":
5458
return <Spinner className={cn(deploymentStatusClassNameColor(status), className)} />;
@@ -73,6 +77,7 @@ export function DeploymentStatusIcon({
7377
export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): string {
7478
switch (status) {
7579
case "PENDING":
80+
return "text-charcoal-500";
7681
case "BUILDING":
7782
case "DEPLOYING":
7883
return "text-pending";
@@ -92,7 +97,7 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus):
9297
export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: boolean): string {
9398
switch (status) {
9499
case "PENDING":
95-
return "Pending…";
100+
return "Queued…";
96101
case "BUILDING":
97102
return "Building…";
98103
case "DEPLOYING":
@@ -121,6 +126,7 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b
121126

122127
// PENDING and CANCELED are not used so are ommited from the UI
123128
export const deploymentStatuses: WorkerDeploymentStatus[] = [
129+
"PENDING",
124130
"BUILDING",
125131
"DEPLOYING",
126132
"DEPLOYED",

apps/webapp/app/env.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,10 @@ const EnvironmentSchema = z
312312
.number()
313313
.int()
314314
.default(60 * 1000 * 8), // 8 minutes
315+
DEPLOY_QUEUE_TIMEOUT_MS: z.coerce
316+
.number()
317+
.int()
318+
.default(60 * 1000 * 15), // 15 minutes
315319

316320
OBJECT_STORE_BASE_URL: z.string().optional(),
317321
OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),

apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export class DeploymentPresenter {
102102
builtAt: true,
103103
deployedAt: true,
104104
createdAt: true,
105+
startedAt: true,
105106
git: true,
106107
promotions: {
107108
select: {
@@ -145,6 +146,7 @@ export class DeploymentPresenter {
145146
version: deployment.version,
146147
status: deployment.status,
147148
createdAt: deployment.createdAt,
149+
startedAt: deployment.startedAt,
148150
builtAt: deployment.builtAt,
149151
deployedAt: deployment.deployedAt,
150152
tasks: deployment.worker?.tasks,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { requireUserId } from "~/services/session.server";
3232
import { cn } from "~/utils/cn";
3333
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
3434
import { capitalizeWord } from "~/utils/string";
35+
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
3536

3637
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
3738
const userId = await requireUserId(request);
@@ -187,7 +188,13 @@ export default function Page() {
187188
<Property.Item>
188189
<Property.Label>Started at</Property.Label>
189190
<Property.Value>
190-
<DateTimeAccurate date={deployment.createdAt} /> UTC
191+
{deployment.startedAt ? (
192+
<>
193+
<DateTimeAccurate date={deployment.startedAt} /> UTC
194+
</>
195+
) : (
196+
"–"
197+
)}
191198
</Property.Value>
192199
</Property.Item>
193200
<Property.Item>
@@ -226,17 +233,16 @@ export default function Page() {
226233
<Property.Item>
227234
<Property.Label>Deployed by</Property.Label>
228235
<Property.Value>
229-
{deployment.deployedBy ? (
230-
<div className="flex items-center gap-1">
231-
<UserAvatar
232-
avatarUrl={deployment.deployedBy.avatarUrl}
233-
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName}
234-
className="h-4 w-4"
235-
/>
236-
<Paragraph variant="small">
237-
{deployment.deployedBy.name ?? deployment.deployedBy.displayName}
238-
</Paragraph>
239-
</div>
236+
{deployment.git?.source === "trigger_github_app" ? (
237+
<UserTag
238+
name={deployment.git.ghUsername ?? "GitHub Integration"}
239+
avatarUrl={deployment.git.ghUserAvatarUrl}
240+
/>
241+
) : deployment.deployedBy ? (
242+
<UserTag
243+
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName ?? ""}
244+
avatarUrl={deployment.deployedBy.avatarUrl ?? undefined}
245+
/>
240246
) : (
241247
"–"
242248
)}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ export default function Page() {
358358
);
359359
}
360360

361-
function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
361+
export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
362362
return (
363363
<div className="flex items-center gap-1">
364364
<UserAvatar avatarUrl={avatarUrl} name={name} className="h-4 w-4" />
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { StartDeploymentRequestBody } 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 = StartDeploymentRequestBody.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+
.startDeployment(authenticatedEnv, deploymentId, {
48+
contentHash: body.data.contentHash,
49+
git: body.data.gitMeta,
50+
runtime: body.data.runtime,
51+
})
52+
.match(
53+
() => {
54+
return json(null, { status: 204 });
55+
},
56+
(error) => {
57+
switch (error.type) {
58+
case "failed_to_extend_deployment_timeout":
59+
return json(null, { status: 204 }); // ignore these errors for now
60+
case "deployment_not_found":
61+
return json({ error: "Deployment not found" }, { status: 404 });
62+
case "deployment_not_pending":
63+
return json({ error: "Deployment is not pending" }, { status: 409 });
64+
case "other":
65+
default:
66+
error.type satisfies "other";
67+
return json({ error: "Internal server error" }, { status: 500 });
68+
}
69+
}
70+
);
71+
}

apps/webapp/app/routes/api.v1.deployments.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
22
import {
33
ApiDeploymentListSearchParams,
44
InitializeDeploymentRequestBody,
5-
InitializeDeploymentResponseBody,
5+
type InitializeDeploymentResponseBody,
66
} from "@trigger.dev/core/v3";
77
import { $replica } from "~/db.server";
88
import { authenticateApiRequest } from "~/services/apiAuth.server";
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
2+
import { BaseService } from "./baseService.server";
3+
import { errAsync, fromPromise, okAsync } from "neverthrow";
4+
import { type WorkerDeploymentStatus, type WorkerDeployment } from "@trigger.dev/database";
5+
import { logger, type GitMeta } from "@trigger.dev/core/v3";
6+
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
7+
import { env } from "~/env.server";
8+
9+
export class DeploymentService extends BaseService {
10+
public startDeployment(
11+
authenticatedEnv: AuthenticatedEnvironment,
12+
friendlyId: string,
13+
updates: Partial<Pick<WorkerDeployment, "contentHash" | "runtime"> & { git: GitMeta }>
14+
) {
15+
const getDeployment = () =>
16+
fromPromise(
17+
this._prisma.workerDeployment.findFirst({
18+
where: {
19+
friendlyId,
20+
environmentId: authenticatedEnv.id,
21+
},
22+
select: {
23+
status: true,
24+
id: true,
25+
},
26+
}),
27+
(error) => ({
28+
type: "other" as const,
29+
cause: error,
30+
})
31+
).andThen((deployment) => {
32+
if (!deployment) {
33+
return errAsync({ type: "deployment_not_found" as const });
34+
}
35+
return okAsync(deployment);
36+
});
37+
38+
const validateDeployment = (deployment: Pick<WorkerDeployment, "id" | "status">) => {
39+
if (deployment.status !== "PENDING") {
40+
logger.warn("Attempted starting deployment that is not in PENDING status", {
41+
deployment,
42+
});
43+
return errAsync({ type: "deployment_not_pending" as const });
44+
}
45+
46+
return okAsync(deployment);
47+
};
48+
49+
const updateDeployment = (deployment: Pick<WorkerDeployment, "id">) =>
50+
fromPromise(
51+
this._prisma.workerDeployment.updateMany({
52+
where: { id: deployment.id, status: "PENDING" }, // status could've changed in the meantime, we're not locking the row
53+
data: { ...updates, status: "BUILDING", startedAt: new Date() },
54+
}),
55+
(error) => ({
56+
type: "other" as const,
57+
cause: error,
58+
})
59+
).andThen((result) => {
60+
if (result.count === 0) {
61+
return errAsync({ type: "deployment_not_pending" as const });
62+
}
63+
return okAsync({ id: deployment.id });
64+
});
65+
66+
const extendTimeout = (deployment: Pick<WorkerDeployment, "id">) =>
67+
fromPromise(
68+
TimeoutDeploymentService.enqueue(
69+
deployment.id,
70+
"BUILDING" satisfies WorkerDeploymentStatus,
71+
"Building timed out",
72+
new Date(Date.now() + env.DEPLOY_TIMEOUT_MS)
73+
),
74+
(error) => ({
75+
type: "failed_to_extend_deployment_timeout" as const,
76+
cause: error,
77+
})
78+
).map(() => undefined);
79+
80+
return getDeployment()
81+
.andThen(validateDeployment)
82+
.andThen(updateDeployment)
83+
.andThen(extendTimeout);
84+
}
85+
}

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ export class InitializeDeploymentService extends BaseService {
9999

100100
const { imageRef, isEcr, repoCreated } = imageRefResult;
101101

102+
// we keep using `BUILDING` as the initial status if not explicitly set
103+
// to avoid changing the behavior for deployments not created in the build server
104+
const initialStatus = payload.initialStatus ?? "BUILDING";
105+
102106
logger.debug("Creating deployment", {
103107
environmentId: environment.id,
104108
projectId: environment.projectId,
@@ -108,6 +112,7 @@ export class InitializeDeploymentService extends BaseService {
108112
imageRef,
109113
isEcr,
110114
repoCreated,
115+
initialStatus,
111116
});
112117

113118
const deployment = await this._prisma.workerDeployment.create({
@@ -116,7 +121,7 @@ export class InitializeDeploymentService extends BaseService {
116121
contentHash: payload.contentHash,
117122
shortCode: deploymentShortCode,
118123
version: nextVersion,
119-
status: "BUILDING",
124+
status: initialStatus,
120125
environmentId: environment.id,
121126
projectId: environment.projectId,
122127
externalBuildData,
@@ -126,14 +131,18 @@ export class InitializeDeploymentService extends BaseService {
126131
imagePlatform: env.DEPLOY_IMAGE_PLATFORM,
127132
git: payload.gitMeta ?? undefined,
128133
runtime: payload.runtime ?? undefined,
134+
startedAt: initialStatus === "BUILDING" ? new Date() : undefined,
129135
},
130136
});
131137

138+
const timeoutMs =
139+
deployment.status === "PENDING" ? env.DEPLOY_QUEUE_TIMEOUT_MS : env.DEPLOY_TIMEOUT_MS;
140+
132141
await TimeoutDeploymentService.enqueue(
133142
deployment.id,
134-
"BUILDING",
143+
deployment.status,
135144
"Building timed out",
136-
new Date(Date.now() + env.DEPLOY_TIMEOUT_MS)
145+
new Date(Date.now() + timeoutMs)
137146
);
138147

139148
return {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "public"."WorkerDeployment" ADD COLUMN "startedAt" TIMESTAMP(3);

0 commit comments

Comments
 (0)