Skip to content
8 changes: 7 additions & 1 deletion apps/webapp/app/components/runs/v3/DeploymentStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
CheckCircleIcon,
ExclamationTriangleIcon,
NoSymbolIcon,
RectangleStackIcon,
XCircleIcon,
} from "@heroicons/react/20/solid";
import type { WorkerDeploymentStatus } from "@trigger.dev/database";
Expand Down Expand Up @@ -49,6 +50,9 @@ export function DeploymentStatusIcon({
}) {
switch (status) {
case "PENDING":
return (
<RectangleStackIcon className={cn(deploymentStatusClassNameColor(status), className)} />
);
case "BUILDING":
case "DEPLOYING":
return <Spinner className={cn(deploymentStatusClassNameColor(status), className)} />;
Expand All @@ -73,6 +77,7 @@ export function DeploymentStatusIcon({
export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): string {
switch (status) {
case "PENDING":
return "text-charcoal-500";
case "BUILDING":
case "DEPLOYING":
return "text-pending";
Expand All @@ -92,7 +97,7 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus):
export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: boolean): string {
switch (status) {
case "PENDING":
return "Pending…";
return "Queued…";
case "BUILDING":
return "Building…";
case "DEPLOYING":
Expand Down Expand Up @@ -121,6 +126,7 @@ export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: b

// PENDING and CANCELED are not used so are ommited from the UI
export const deploymentStatuses: WorkerDeploymentStatus[] = [
"PENDING",
"BUILDING",
"DEPLOYING",
"DEPLOYED",
Expand Down
4 changes: 4 additions & 0 deletions apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ const EnvironmentSchema = z
.number()
.int()
.default(60 * 1000 * 8), // 8 minutes
DEPLOY_QUEUE_TIMEOUT_MS: z.coerce
.number()
.int()
.default(60 * 1000 * 15), // 15 minutes

OBJECT_STORE_BASE_URL: z.string().optional(),
OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
Expand Down
2 changes: 2 additions & 0 deletions apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export class DeploymentPresenter {
builtAt: true,
deployedAt: true,
createdAt: true,
startedAt: true,
git: true,
promotions: {
select: {
Expand Down Expand Up @@ -145,6 +146,7 @@ export class DeploymentPresenter {
version: deployment.version,
status: deployment.status,
createdAt: deployment.createdAt,
startedAt: deployment.startedAt,
builtAt: deployment.builtAt,
deployedAt: deployment.deployedAt,
tasks: deployment.worker?.tasks,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
import { capitalizeWord } from "~/utils/string";
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";

export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
Expand Down Expand Up @@ -187,7 +188,13 @@ export default function Page() {
<Property.Item>
<Property.Label>Started at</Property.Label>
<Property.Value>
<DateTimeAccurate date={deployment.createdAt} /> UTC
{deployment.startedAt ? (
<>
<DateTimeAccurate date={deployment.startedAt} /> UTC
</>
) : (
"–"
)}
</Property.Value>
</Property.Item>
<Property.Item>
Expand Down Expand Up @@ -226,17 +233,16 @@ export default function Page() {
<Property.Item>
<Property.Label>Deployed by</Property.Label>
<Property.Value>
{deployment.deployedBy ? (
<div className="flex items-center gap-1">
<UserAvatar
avatarUrl={deployment.deployedBy.avatarUrl}
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName}
className="h-4 w-4"
/>
<Paragraph variant="small">
{deployment.deployedBy.name ?? deployment.deployedBy.displayName}
</Paragraph>
</div>
{deployment.git?.source === "trigger_github_app" ? (
<UserTag
name={deployment.git.ghUsername ?? "GitHub Integration"}
avatarUrl={deployment.git.ghUserAvatarUrl}
/>
) : deployment.deployedBy ? (
<UserTag
name={deployment.deployedBy.name ?? deployment.deployedBy.displayName ?? ""}
avatarUrl={deployment.deployedBy.avatarUrl ?? undefined}
/>
) : (
"–"
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ export default function Page() {
);
}

function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) {
return (
<div className="flex items-center gap-1">
<UserAvatar avatarUrl={avatarUrl} name={name} className="h-4 w-4" />
Expand Down
71 changes: 71 additions & 0 deletions apps/webapp/app/routes/api.v1.deployments.$deploymentId.start.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { StartDeploymentRequestBody } 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 request.json();
const body = StartDeploymentRequestBody.safeParse(rawBody);

if (!body.success) {
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
}

const deploymentService = new DeploymentService();

return await deploymentService
.startDeployment(authenticatedEnv, deploymentId, {
contentHash: body.data.contentHash,
git: body.data.gitMeta,
runtime: body.data.runtime,
})
.match(
() => {
return json(null, { status: 204 });
},
(error) => {
switch (error.type) {
case "failed_to_extend_deployment_timeout":
return json(null, { status: 204 }); // ignore these errors for now
case "deployment_not_found":
return json({ error: "Deployment not found" }, { status: 404 });
case "deployment_not_pending":
return json({ error: "Deployment is not pending" }, { status: 409 });
case "other":
default:
error.type satisfies "other";
return json({ error: "Internal server error" }, { status: 500 });
}
}
);
}
4 changes: 2 additions & 2 deletions apps/webapp/app/routes/api.v1.deployments.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import {
ApiDeploymentListSearchParams,
InitializeDeploymentRequestBody,
InitializeDeploymentResponseBody,
type InitializeDeploymentResponseBody,
} from "@trigger.dev/core/v3";
import { $replica } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
Expand Down
85 changes: 85 additions & 0 deletions apps/webapp/app/v3/services/deployment.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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 { logger, type GitMeta } from "@trigger.dev/core/v3";
import { TimeoutDeploymentService } from "./timeoutDeployment.server";
import { env } from "~/env.server";

export class DeploymentService extends BaseService {
public startDeployment(
authenticatedEnv: AuthenticatedEnvironment,
friendlyId: string,
updates: Partial<Pick<WorkerDeployment, "contentHash" | "runtime"> & { git: GitMeta }>
) {
const getDeployment = () =>
fromPromise(
this._prisma.workerDeployment.findFirst({
where: {
friendlyId,
environmentId: authenticatedEnv.id,
},
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 (deployment.status !== "PENDING") {
logger.warn("Attempted starting deployment that is not in PENDING status", {
deployment,
});
return errAsync({ type: "deployment_not_pending" as const });
}

return okAsync(deployment);
};

const updateDeployment = (deployment: Pick<WorkerDeployment, "id">) =>
fromPromise(
this._prisma.workerDeployment.updateMany({
where: { id: deployment.id, status: "PENDING" }, // status could've changed in the meantime, we're not locking the row
data: { ...updates, status: "BUILDING", startedAt: new Date() },
}),
(error) => ({
type: "other" as const,
cause: error,
})
).andThen((result) => {
if (result.count === 0) {
return errAsync({ type: "deployment_not_pending" as const });
}
return okAsync({ id: deployment.id });
});

const extendTimeout = (deployment: Pick<WorkerDeployment, "id">) =>
fromPromise(
TimeoutDeploymentService.enqueue(
deployment.id,
"BUILDING" satisfies WorkerDeploymentStatus,
"Building timed out",
new Date(Date.now() + env.DEPLOY_TIMEOUT_MS)
),
(error) => ({
type: "failed_to_extend_deployment_timeout" as const,
cause: error,
})
).map(() => undefined);

return getDeployment()
.andThen(validateDeployment)
.andThen(updateDeployment)
.andThen(extendTimeout);
}
}
15 changes: 12 additions & 3 deletions apps/webapp/app/v3/services/initializeDeployment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export class InitializeDeploymentService extends BaseService {

const { imageRef, isEcr, repoCreated } = imageRefResult;

// we keep using `BUILDING` as the initial status if not explicitly set
// to avoid changing the behavior for deployments not created in the build server
const initialStatus = payload.initialStatus ?? "BUILDING";

logger.debug("Creating deployment", {
environmentId: environment.id,
projectId: environment.projectId,
Expand All @@ -108,6 +112,7 @@ export class InitializeDeploymentService extends BaseService {
imageRef,
isEcr,
repoCreated,
initialStatus,
});

const deployment = await this._prisma.workerDeployment.create({
Expand All @@ -116,7 +121,7 @@ export class InitializeDeploymentService extends BaseService {
contentHash: payload.contentHash,
shortCode: deploymentShortCode,
version: nextVersion,
status: "BUILDING",
status: initialStatus,
environmentId: environment.id,
projectId: environment.projectId,
externalBuildData,
Expand All @@ -126,14 +131,18 @@ export class InitializeDeploymentService extends BaseService {
imagePlatform: env.DEPLOY_IMAGE_PLATFORM,
git: payload.gitMeta ?? undefined,
runtime: payload.runtime ?? undefined,
startedAt: initialStatus === "BUILDING" ? new Date() : undefined,
},
});

const timeoutMs =
deployment.status === "PENDING" ? env.DEPLOY_QUEUE_TIMEOUT_MS : env.DEPLOY_TIMEOUT_MS;

await TimeoutDeploymentService.enqueue(
deployment.id,
"BUILDING",
deployment.status,
"Building timed out",
new Date(Date.now() + env.DEPLOY_TIMEOUT_MS)
new Date(Date.now() + timeoutMs)
);

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "public"."WorkerDeployment" ADD COLUMN "startedAt" TIMESTAMP(3);
1 change: 1 addition & 0 deletions internal-packages/database/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1764,6 +1764,7 @@ model WorkerDeployment {
triggeredBy User? @relation(fields: [triggeredById], references: [id], onDelete: SetNull, onUpdate: Cascade)
triggeredById String?

startedAt DateTime?
builtAt DateTime?
deployedAt DateTime?

Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/v3/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,14 @@ export const FinalizeDeploymentRequestBody = z.object({

export type FinalizeDeploymentRequestBody = z.infer<typeof FinalizeDeploymentRequestBody>;

export const StartDeploymentRequestBody = z.object({
contentHash: z.string().optional(),
gitMeta: GitMeta.optional(),
runtime: z.string().optional(),
});

export type StartDeploymentRequestBody = z.infer<typeof StartDeploymentRequestBody>;

export const ExternalBuildData = z.object({
buildId: z.string(),
buildToken: z.string(),
Expand Down Expand Up @@ -419,6 +427,7 @@ export const InitializeDeploymentRequestBody = z.object({
gitMeta: GitMeta.optional(),
type: z.enum(["MANAGED", "UNMANAGED", "V1"]).optional(),
runtime: z.string().optional(),
initialStatus: z.enum(["PENDING", "BUILDING"]).optional(),
});

export type InitializeDeploymentRequestBody = z.infer<typeof InitializeDeploymentRequestBody>;
Expand Down
Loading