chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { errAsync, fromPromise, okAsync, type ResultAsync } from "neverthrow";
|
||||
import { type WorkerDeployment, type Project } from "@trigger.dev/database";
|
||||
import {
|
||||
BuildServerMetadata,
|
||||
logger,
|
||||
type GitMeta,
|
||||
type DeploymentEvent,
|
||||
} 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";
|
||||
import { enqueueBuild, generateRegistryCredentials } from "~/services/platform.v3.server";
|
||||
import { AppendInput, AppendRecord, S2 } from "@s2-dev/streamstore";
|
||||
import { createRedisClient } from "~/redis.server";
|
||||
|
||||
const S2_TOKEN_KEY_PREFIX = "s2-token:read:deployment-event-stream: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 class DeploymentService extends BaseService {
|
||||
/**
|
||||
* Progresses a deployment from PENDING to INSTALLING and then to BUILDING.
|
||||
* Also extends the deployment timeout.
|
||||
*
|
||||
* When progressing to BUILDING, the remote Depot build is also created.
|
||||
*
|
||||
* Only acts when the current status allows. Not idempotent.
|
||||
*
|
||||
* @param authenticatedEnv The environment which the deployment belongs to.
|
||||
* @param friendlyId The friendly deployment ID.
|
||||
* @param updates Optional deployment details to persist.
|
||||
*/
|
||||
public progressDeployment(
|
||||
authenticatedEnv: AuthenticatedEnvironment,
|
||||
friendlyId: string,
|
||||
updates: Partial<
|
||||
Pick<WorkerDeployment, "contentHash" | "runtime"> & {
|
||||
git: GitMeta;
|
||||
buildServerMetadata: BuildServerMetadata;
|
||||
}
|
||||
>
|
||||
) {
|
||||
const { buildServerMetadata: newBuildServerMetadata, ...restUpdates } = updates;
|
||||
|
||||
const validateDeployment = (
|
||||
deployment: Pick<WorkerDeployment, "id" | "status"> & {
|
||||
buildServerMetadata?: BuildServerMetadata;
|
||||
}
|
||||
) => {
|
||||
if (deployment.status !== "PENDING" && deployment.status !== "INSTALLING") {
|
||||
logger.warn(
|
||||
"Attempted progressing deployment that is not in PENDING or INSTALLING status",
|
||||
{
|
||||
deployment,
|
||||
}
|
||||
);
|
||||
return errAsync({ type: "deployment_cannot_be_progressed" as const });
|
||||
}
|
||||
|
||||
return okAsync(deployment);
|
||||
};
|
||||
|
||||
const progressToInstalling = (
|
||||
deployment: Pick<WorkerDeployment, "id"> & { buildServerMetadata?: BuildServerMetadata }
|
||||
) => {
|
||||
const existingBuildServerMetadata = deployment.buildServerMetadata;
|
||||
|
||||
return 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: {
|
||||
...restUpdates,
|
||||
...(newBuildServerMetadata
|
||||
? {
|
||||
buildServerMetadata: {
|
||||
...(existingBuildServerMetadata ?? {}),
|
||||
...newBuildServerMetadata,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
status: "INSTALLING",
|
||||
startedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).andThen((result) => {
|
||||
if (result.count === 0) {
|
||||
return errAsync({ type: "deployment_cannot_be_progressed" as const });
|
||||
}
|
||||
return okAsync({ id: deployment.id, status: "INSTALLING" as const });
|
||||
});
|
||||
};
|
||||
|
||||
const progressToBuilding = (
|
||||
deployment: Pick<WorkerDeployment, "id"> & { buildServerMetadata?: BuildServerMetadata }
|
||||
) => {
|
||||
const createRemoteBuildIfNeeded = deployment.buildServerMetadata?.isNativeBuild
|
||||
? okAsync(undefined)
|
||||
: fromPromise(createRemoteImageBuild(authenticatedEnv.project), (error) => ({
|
||||
type: "failed_to_create_remote_build" as const,
|
||||
cause: error,
|
||||
}));
|
||||
|
||||
const existingBuildServerMetadata = deployment.buildServerMetadata as
|
||||
| BuildServerMetadata
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
return createRemoteBuildIfNeeded
|
||||
.andThen((externalBuildData) =>
|
||||
fromPromise(
|
||||
this._prisma.workerDeployment.updateMany({
|
||||
where: { id: deployment.id, status: "INSTALLING" }, // status could've changed in the meantime, we're not locking the row
|
||||
data: {
|
||||
...restUpdates,
|
||||
...(newBuildServerMetadata
|
||||
? {
|
||||
buildServerMetadata: {
|
||||
...(existingBuildServerMetadata ?? {}),
|
||||
...newBuildServerMetadata,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(externalBuildData ? { externalBuildData } : {}),
|
||||
status: "BUILDING",
|
||||
installedAt: new Date(),
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
)
|
||||
)
|
||||
.andThen((result) => {
|
||||
if (result.count === 0) {
|
||||
return errAsync({ type: "deployment_cannot_be_progressed" as const });
|
||||
}
|
||||
return okAsync({ id: deployment.id, status: "BUILDING" as const });
|
||||
});
|
||||
};
|
||||
|
||||
const extendTimeout = (deployment: Pick<WorkerDeployment, "id" | "status">) =>
|
||||
fromPromise(
|
||||
TimeoutDeploymentService.enqueue(
|
||||
deployment.id,
|
||||
deployment.status,
|
||||
deployment.status === "INSTALLING"
|
||||
? "Installing dependencies timed out"
|
||||
: "Building timed out",
|
||||
new Date(Date.now() + env.DEPLOY_TIMEOUT_MS)
|
||||
),
|
||||
(error) => ({
|
||||
type: "failed_to_extend_deployment_timeout" as const,
|
||||
cause: error,
|
||||
})
|
||||
);
|
||||
|
||||
return this.getDeployment(authenticatedEnv.projectId, friendlyId)
|
||||
.andThen(validateDeployment)
|
||||
.andThen((deployment) => {
|
||||
if (deployment.status === "PENDING") {
|
||||
return progressToInstalling(deployment);
|
||||
}
|
||||
return progressToBuilding(deployment);
|
||||
})
|
||||
.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<AuthenticatedEnvironment, "projectId">,
|
||||
friendlyId: string,
|
||||
data?: Partial<Pick<WorkerDeployment, "canceledReason">>
|
||||
) {
|
||||
const validateDeployment = (
|
||||
deployment: Pick<WorkerDeployment, "id" | "status" | "shortCode"> & {
|
||||
environment: { project: { externalRef: string } };
|
||||
}
|
||||
) => {
|
||||
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<WorkerDeployment, "id" | "shortCode"> & {
|
||||
environment: { project: { externalRef: string } };
|
||||
}
|
||||
) =>
|
||||
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({ deployment });
|
||||
});
|
||||
|
||||
const deleteTimeout = (deployment: Pick<WorkerDeployment, "id">) =>
|
||||
fromPromise(TimeoutDeploymentService.dequeue(deployment.id, this._prisma), (error) => ({
|
||||
type: "failed_to_delete_deployment_timeout" as const,
|
||||
cause: error,
|
||||
}));
|
||||
|
||||
return this.getDeployment(authenticatedEnv.projectId, friendlyId)
|
||||
.andThen(validateDeployment)
|
||||
.andThen(cancelDeployment)
|
||||
.andThen(({ deployment }) =>
|
||||
this.appendToEventLog(deployment.environment.project, deployment, [
|
||||
{
|
||||
type: "finalized",
|
||||
data: {
|
||||
result: "canceled",
|
||||
message: data?.canceledReason ?? undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
.orElse((error) => {
|
||||
logger.error("Failed to append event to deployment event log", { error });
|
||||
return okAsync(deployment);
|
||||
})
|
||||
.map(() => deployment)
|
||||
)
|
||||
.andThen(deleteTimeout)
|
||||
.map(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates registry credentials for a deployment. Returns an error if the deployment is in a final state.
|
||||
*
|
||||
* Uses the `platform` package, only available in cloud.
|
||||
*
|
||||
* @param authenticatedEnv The environment which the deployment belongs to.
|
||||
* @param friendlyId The friendly deployment ID.
|
||||
*/
|
||||
public generateRegistryCredentials(
|
||||
authenticatedEnv: Pick<AuthenticatedEnvironment, "projectId">,
|
||||
friendlyId: string
|
||||
) {
|
||||
const validateDeployment = (
|
||||
deployment: Pick<WorkerDeployment, "id" | "status" | "imageReference">
|
||||
) => {
|
||||
if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) {
|
||||
return errAsync({ type: "deployment_is_already_final" as const });
|
||||
}
|
||||
return okAsync(deployment);
|
||||
};
|
||||
|
||||
const getDeploymentRegion = (deployment: Pick<WorkerDeployment, "imageReference">) => {
|
||||
if (!deployment.imageReference) {
|
||||
return errAsync({ type: "deployment_has_no_image_reference" as const });
|
||||
}
|
||||
if (!deployment.imageReference.includes("amazonaws.com")) {
|
||||
return errAsync({ type: "registry_not_supported" as const });
|
||||
}
|
||||
|
||||
// we should connect the deployment to a region more explicitly in the future
|
||||
// for now we just use the image reference to determine the region
|
||||
if (deployment.imageReference.includes("us-east-1")) {
|
||||
return okAsync({ region: "us-east-1" as const });
|
||||
}
|
||||
if (deployment.imageReference.includes("eu-central-1")) {
|
||||
return okAsync({ region: "eu-central-1" as const });
|
||||
}
|
||||
|
||||
return errAsync({ type: "registry_region_not_supported" as const });
|
||||
};
|
||||
|
||||
const generateCredentials = ({ region }: { region: "us-east-1" | "eu-central-1" }) =>
|
||||
fromPromise(generateRegistryCredentials(authenticatedEnv.projectId, region), (error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})).andThen((result) => {
|
||||
if (!result || !result.success) {
|
||||
return errAsync({ type: "missing_registry_credentials" as const });
|
||||
}
|
||||
return okAsync({
|
||||
username: result.username,
|
||||
password: result.password,
|
||||
expiresAt: new Date(result.expiresAt),
|
||||
repositoryUri: result.repositoryUri,
|
||||
});
|
||||
});
|
||||
|
||||
return this.getDeployment(authenticatedEnv.projectId, friendlyId)
|
||||
.andThen(validateDeployment)
|
||||
.andThen(getDeploymentRegion)
|
||||
.andThen(generateCredentials);
|
||||
}
|
||||
|
||||
public enqueueBuild(
|
||||
authenticatedEnv: Pick<AuthenticatedEnvironment, "projectId">,
|
||||
deployment: Pick<WorkerDeployment, "friendlyId">,
|
||||
artifactKey: string,
|
||||
options: {
|
||||
skipPromotion?: boolean;
|
||||
configFilePath?: string;
|
||||
}
|
||||
) {
|
||||
return fromPromise(
|
||||
enqueueBuild(authenticatedEnv.projectId, deployment.friendlyId, artifactKey, options),
|
||||
(error) => ({
|
||||
type: "failed_to_enqueue_build" as const,
|
||||
cause: error,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public appendToEventLog(
|
||||
project: Pick<Project, "externalRef">,
|
||||
deployment: Pick<WorkerDeployment, "shortCode">,
|
||||
events: DeploymentEvent[]
|
||||
): ResultAsync<
|
||||
undefined,
|
||||
{ type: "s2_is_disabled" } | { type: "failed_to_append_to_event_log"; cause: unknown }
|
||||
> {
|
||||
if (env.S2_ENABLED !== "1" || !s2) {
|
||||
return errAsync({ type: "s2_is_disabled" as const });
|
||||
}
|
||||
|
||||
const basin = s2.basin(env.S2_DEPLOYMENT_LOGS_BASIN_NAME);
|
||||
const stream = basin.stream(
|
||||
`projects/${project.externalRef}/deployments/${deployment.shortCode}`
|
||||
);
|
||||
|
||||
return fromPromise(
|
||||
stream.append(
|
||||
AppendInput.create(
|
||||
events.map((event) => AppendRecord.string({ body: JSON.stringify(event) }))
|
||||
)
|
||||
),
|
||||
(error) => ({
|
||||
type: "failed_to_append_to_event_log" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map(() => undefined);
|
||||
}
|
||||
|
||||
public createEventStream(
|
||||
project: Pick<Project, "externalRef">,
|
||||
deployment: Pick<WorkerDeployment, "shortCode">
|
||||
): ResultAsync<
|
||||
{ basin: string; stream: string },
|
||||
{ type: "s2_is_disabled" } | { type: "failed_to_create_event_stream"; cause: unknown }
|
||||
> {
|
||||
if (env.S2_ENABLED !== "1" || !s2) {
|
||||
return errAsync({ type: "s2_is_disabled" as const });
|
||||
}
|
||||
const basin = s2.basin(env.S2_DEPLOYMENT_LOGS_BASIN_NAME);
|
||||
|
||||
return fromPromise(
|
||||
basin.streams.create({
|
||||
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
|
||||
}),
|
||||
(error) => ({
|
||||
type: "failed_to_create_event_stream" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map(() => ({
|
||||
basin: basin.name,
|
||||
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
|
||||
}));
|
||||
}
|
||||
|
||||
public getEventStreamAccessToken(
|
||||
project: Pick<Project, "externalRef">
|
||||
): ResultAsync<string, { type: "s2_is_disabled" } | { type: "other"; cause: unknown }> {
|
||||
if (env.S2_ENABLED !== "1" || !s2) {
|
||||
return errAsync({ type: "s2_is_disabled" as const });
|
||||
}
|
||||
const basinName = env.S2_DEPLOYMENT_LOGS_BASIN_NAME;
|
||||
const redisKey = `${S2_TOKEN_KEY_PREFIX}${project.externalRef}`;
|
||||
|
||||
const getTokenFromCache = () =>
|
||||
fromPromise(s2TokenRedis.get(redisKey), (error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})).andThen((cachedToken) => {
|
||||
if (!cachedToken) {
|
||||
return errAsync({ type: "s2_token_cache_not_found" as const });
|
||||
}
|
||||
return okAsync(cachedToken);
|
||||
});
|
||||
|
||||
const issueS2Token = () =>
|
||||
fromPromise(
|
||||
s2.accessTokens.issue({
|
||||
id: `${project.externalRef}-${new Date().getTime()}`,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
|
||||
scope: {
|
||||
ops: ["read"],
|
||||
basins: {
|
||||
exact: basinName,
|
||||
},
|
||||
streams: {
|
||||
prefix: `projects/${project.externalRef}/deployments/`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
).map(({ accessToken }) => accessToken);
|
||||
|
||||
const cacheToken = (token: string) =>
|
||||
fromPromise(
|
||||
s2TokenRedis.setex(
|
||||
redisKey,
|
||||
59 * 60, // slightly shorter than the token validity period
|
||||
token
|
||||
),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
);
|
||||
|
||||
return getTokenFromCache().orElse(() =>
|
||||
issueS2Token().andThen((token) =>
|
||||
cacheToken(token)
|
||||
.map(() => token)
|
||||
.orElse((error) => {
|
||||
logger.error("Failed to cache S2 token", { error });
|
||||
return okAsync(token); // ignore the cache error
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private getDeployment(projectId: string, friendlyId: string) {
|
||||
return fromPromise(
|
||||
this._prisma.workerDeployment.findFirst({
|
||||
where: {
|
||||
friendlyId,
|
||||
projectId,
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
id: true,
|
||||
buildServerMetadata: true,
|
||||
imageReference: true,
|
||||
shortCode: true,
|
||||
environment: {
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
externalRef: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
(error) => ({
|
||||
type: "other" as const,
|
||||
cause: error,
|
||||
})
|
||||
)
|
||||
.andThen((deployment) => {
|
||||
if (!deployment) {
|
||||
return errAsync({ type: "deployment_not_found" as const });
|
||||
}
|
||||
return okAsync(deployment);
|
||||
})
|
||||
.map((deployment) => ({
|
||||
...deployment,
|
||||
buildServerMetadata: BuildServerMetadata.safeParse(deployment.buildServerMetadata).data,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user