331 lines
9.5 KiB
TypeScript
331 lines
9.5 KiB
TypeScript
import {
|
|
BuildServerMetadata,
|
|
DeploymentErrorData,
|
|
ExternalBuildData,
|
|
prepareDeploymentError,
|
|
} from "@trigger.dev/core/v3";
|
|
import { type RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
|
|
import { type PrismaClient, prisma } from "~/db.server";
|
|
import { type Organization } from "~/models/organization.server";
|
|
import { type Project } from "~/models/project.server";
|
|
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
|
import { type User } from "~/models/user.server";
|
|
import { getUsername } from "~/utils/username";
|
|
import { processGitMetadata } from "./BranchesPresenter.server";
|
|
import { VercelProjectIntegrationDataSchema } from "~/v3/vercel/vercelProjectIntegrationSchema";
|
|
import { S2 } from "@s2-dev/streamstore";
|
|
import { env } from "~/env.server";
|
|
import { createRedisClient } from "~/redis.server";
|
|
import { tryCatch } from "@trigger.dev/core";
|
|
import { logger } from "~/services/logger.server";
|
|
|
|
const S2_TOKEN_KEY_PREFIX = "s2-token:project:";
|
|
|
|
const s2TokenRedis = createRedisClient("s2-token-cache", {
|
|
host: env.CACHE_REDIS_HOST,
|
|
port: env.CACHE_REDIS_PORT,
|
|
username: env.CACHE_REDIS_USERNAME,
|
|
password: env.CACHE_REDIS_PASSWORD,
|
|
tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true",
|
|
clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1",
|
|
});
|
|
|
|
const s2 = env.S2_ENABLED === "1" ? new S2({ accessToken: env.S2_ACCESS_TOKEN }) : undefined;
|
|
|
|
export type ErrorData = {
|
|
name: string;
|
|
message: string;
|
|
stack?: string;
|
|
stderr?: string;
|
|
};
|
|
|
|
export class DeploymentPresenter {
|
|
#prismaClient: PrismaClient;
|
|
|
|
constructor(prismaClient: PrismaClient = prisma) {
|
|
this.#prismaClient = prismaClient;
|
|
}
|
|
|
|
public async call({
|
|
userId,
|
|
projectSlug,
|
|
organizationSlug,
|
|
environmentSlug,
|
|
deploymentShortCode,
|
|
}: {
|
|
userId: User["id"];
|
|
projectSlug: Project["slug"];
|
|
organizationSlug: Organization["slug"];
|
|
environmentSlug: RuntimeEnvironment["slug"];
|
|
deploymentShortCode: WorkerDeployment["shortCode"];
|
|
}) {
|
|
const project = await this.#prismaClient.project.findFirstOrThrow({
|
|
select: {
|
|
id: true,
|
|
organizationId: true,
|
|
externalRef: true,
|
|
},
|
|
where: {
|
|
slug: projectSlug,
|
|
organization: {
|
|
slug: organizationSlug,
|
|
members: {
|
|
some: {
|
|
userId,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const environment = await findEnvironmentBySlug(project.id, environmentSlug, userId);
|
|
if (!environment) {
|
|
throw new Error(`Environment not found`);
|
|
}
|
|
|
|
const deployment = await this.#prismaClient.workerDeployment.findFirstOrThrow({
|
|
where: {
|
|
projectId: project.id,
|
|
shortCode: deploymentShortCode,
|
|
environmentId: environment.id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
shortCode: true,
|
|
version: true,
|
|
runtime: true,
|
|
runtimeVersion: true,
|
|
errorData: true,
|
|
imageReference: true,
|
|
imagePlatform: true,
|
|
externalBuildData: true,
|
|
projectId: true,
|
|
type: true,
|
|
environment: {
|
|
select: {
|
|
id: true,
|
|
type: true,
|
|
slug: true,
|
|
orgMember: {
|
|
select: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
displayName: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
status: true,
|
|
builtAt: true,
|
|
deployedAt: true,
|
|
createdAt: true,
|
|
startedAt: true,
|
|
installedAt: true,
|
|
canceledAt: true,
|
|
canceledReason: true,
|
|
git: true,
|
|
promotions: {
|
|
select: {
|
|
label: true,
|
|
},
|
|
},
|
|
worker: {
|
|
select: {
|
|
tasks: {
|
|
select: {
|
|
slug: true,
|
|
filePath: true,
|
|
},
|
|
orderBy: {
|
|
slug: "asc",
|
|
},
|
|
},
|
|
sdkVersion: true,
|
|
cliVersion: true,
|
|
},
|
|
},
|
|
triggeredBy: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
displayName: true,
|
|
avatarUrl: true,
|
|
},
|
|
},
|
|
buildServerMetadata: true,
|
|
triggeredVia: true,
|
|
},
|
|
});
|
|
|
|
const gitMetadata = processGitMetadata(deployment.git);
|
|
|
|
// Look up Vercel integration data to construct a deployment URL
|
|
let vercelDeploymentUrl: string | undefined;
|
|
const vercelProjectIntegration =
|
|
await this.#prismaClient.organizationProjectIntegration.findFirst({
|
|
where: {
|
|
projectId: project.id,
|
|
deletedAt: null,
|
|
organizationIntegration: {
|
|
service: "VERCEL",
|
|
deletedAt: null,
|
|
},
|
|
},
|
|
select: {
|
|
integrationData: true,
|
|
},
|
|
});
|
|
|
|
if (vercelProjectIntegration) {
|
|
const parsed = VercelProjectIntegrationDataSchema.safeParse(
|
|
vercelProjectIntegration.integrationData
|
|
);
|
|
|
|
if (parsed.success && parsed.data.vercelTeamSlug) {
|
|
const integrationDeployment = await this.#prismaClient.integrationDeployment.findFirst({
|
|
where: {
|
|
deploymentId: deployment.id,
|
|
integrationName: "vercel",
|
|
},
|
|
select: {
|
|
integrationDeploymentId: true,
|
|
},
|
|
orderBy: {
|
|
createdAt: "desc",
|
|
},
|
|
});
|
|
|
|
if (integrationDeployment) {
|
|
const vercelId = integrationDeployment.integrationDeploymentId;
|
|
vercelDeploymentUrl = `https://vercel.com/${parsed.data.vercelTeamSlug}/${parsed.data.vercelProjectName}/${vercelId}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
const externalBuildData = deployment.externalBuildData
|
|
? ExternalBuildData.safeParse(deployment.externalBuildData)
|
|
: undefined;
|
|
const buildServerMetadata = deployment.buildServerMetadata
|
|
? BuildServerMetadata.safeParse(deployment.buildServerMetadata)
|
|
: undefined;
|
|
|
|
let eventStream = undefined;
|
|
if (
|
|
env.S2_ENABLED === "1" &&
|
|
(buildServerMetadata ||
|
|
gitMetadata?.source === "trigger_github_app" ||
|
|
env.S2_DEPLOYMENT_STREAMS_LOCAL === "1")
|
|
) {
|
|
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
|
|
|
|
if (error) {
|
|
logger.error("Failed getting S2 access token", { error });
|
|
} else {
|
|
eventStream = {
|
|
s2: {
|
|
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
|
|
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
|
|
accessToken,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
eventStream,
|
|
deployment: {
|
|
id: deployment.id,
|
|
shortCode: deployment.shortCode,
|
|
version: deployment.version,
|
|
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: {
|
|
id: deployment.environment.id,
|
|
type: deployment.environment.type,
|
|
slug: deployment.environment.slug,
|
|
userId: deployment.environment.orgMember?.user.id,
|
|
userName: getUsername(deployment.environment.orgMember?.user),
|
|
},
|
|
deployedBy: deployment.triggeredBy,
|
|
sdkVersion: deployment.worker?.sdkVersion,
|
|
cliVersion: deployment.worker?.cliVersion,
|
|
runtime: deployment.runtime,
|
|
runtimeVersion: deployment.runtimeVersion,
|
|
imageReference: deployment.imageReference,
|
|
imagePlatform: deployment.imagePlatform,
|
|
externalBuildData:
|
|
externalBuildData && externalBuildData.success ? externalBuildData.data : undefined,
|
|
projectId: deployment.projectId,
|
|
organizationId: project.organizationId,
|
|
errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
|
|
isBuilt: !!deployment.builtAt,
|
|
type: deployment.type,
|
|
git: gitMetadata,
|
|
triggeredVia: deployment.triggeredVia,
|
|
vercelDeploymentUrl,
|
|
},
|
|
};
|
|
}
|
|
|
|
private async getS2AccessToken(projectRef: string): Promise<string> {
|
|
if (env.S2_ENABLED !== "1" || !s2) {
|
|
throw new Error("Failed getting S2 access token: S2 is not enabled");
|
|
}
|
|
|
|
const redisKey = `${S2_TOKEN_KEY_PREFIX}${projectRef}`;
|
|
const cachedToken = await s2TokenRedis.get(redisKey);
|
|
|
|
if (cachedToken) {
|
|
return cachedToken;
|
|
}
|
|
|
|
const { accessToken } = await s2.accessTokens.issue({
|
|
id: `${projectRef}-${new Date().getTime()}`,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
|
|
scope: {
|
|
ops: ["read"],
|
|
basins: {
|
|
exact: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
|
|
},
|
|
streams: {
|
|
prefix: `projects/${projectRef}/deployments/`,
|
|
},
|
|
},
|
|
});
|
|
|
|
await s2TokenRedis.setex(
|
|
redisKey,
|
|
59 * 60, // slightly shorter than the token validity period
|
|
accessToken
|
|
);
|
|
|
|
return accessToken;
|
|
}
|
|
|
|
public static prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
|
|
if (!errorData) {
|
|
return;
|
|
}
|
|
|
|
const deploymentError = DeploymentErrorData.safeParse(errorData);
|
|
|
|
if (!deploymentError.success) {
|
|
return;
|
|
}
|
|
|
|
return prepareDeploymentError(deploymentError.data);
|
|
}
|
|
}
|