Files
2026-07-13 13:32:57 +08:00

398 lines
12 KiB
TypeScript

import {
ExternalBuildData,
type FinalizeDeploymentRequestBody,
} from "@trigger.dev/core/v3/schemas";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, writeFile } from "node:fs/promises";
import { env } from "~/env.server";
import { depot as execDepot } from "@depot/cli";
import { FinalizeDeploymentService } from "./finalizeDeployment.server";
import { remoteBuildsEnabled } from "../remoteImageBuilder.server";
import { getEcrAuthToken, isEcrRegistry } from "../getDeploymentImageRef.server";
import { tryCatch } from "@trigger.dev/core";
import { getRegistryConfig, type RegistryConfig } from "../registryConfig.server";
import { ComputeTemplateCreationService } from "./computeTemplateCreation.server";
import { ecrImageExists } from "./verifyDeploymentImage.server";
export class FinalizeDeploymentV2Service extends BaseService {
public async call(
authenticatedEnv: AuthenticatedEnvironment,
id: string,
body: FinalizeDeploymentRequestBody,
writer?: WritableStreamDefaultWriter
) {
const deployment = await this._prisma.workerDeployment.findFirst({
where: {
friendlyId: id,
environmentId: authenticatedEnv.id,
},
select: {
status: true,
id: true,
version: true,
externalBuildData: true,
environment: true,
imageReference: true,
type: true,
worker: {
select: {
project: true,
},
},
},
});
if (!deployment) {
logger.error("Worker deployment not found", { id });
return;
}
if (!deployment.worker) {
logger.error("Worker deployment does not have a worker", { id });
throw new ServiceValidationError("Worker deployment does not have a worker");
}
if (deployment.status === "DEPLOYED") {
logger.debug("Worker deployment is already deployed", { id });
return deployment;
}
if (deployment.status !== "DEPLOYING") {
logger.error("Worker deployment is not in DEPLOYING status", { id });
throw new ServiceValidationError("Worker deployment is not in DEPLOYING status");
}
const finalizeService = new FinalizeDeploymentService();
// If remote builds are not enabled, skip image push and go straight to template + finalize
if (!remoteBuildsEnabled() || body.skipPushToRegistry) {
if (body.skipPushToRegistry) {
logger.debug("Skipping push to registry during deployment finalization", {
deployment,
});
}
// The CLI claims the image is already in the registry (local build, or a
// self-hosted setup). Verify before promoting so we never mark a
// deployment DEPLOYED when nothing was actually pushed.
await this.#assertImagePullable(deployment, body);
await this.#createTemplateIfNeeded(deployment, id, authenticatedEnv, writer);
return finalizeService.call(authenticatedEnv, id, body);
}
const externalBuildData = deployment.externalBuildData
? ExternalBuildData.safeParse(deployment.externalBuildData)
: undefined;
if (!externalBuildData) {
throw new ServiceValidationError("External build data is missing");
}
if (!externalBuildData.success) {
throw new ServiceValidationError("External build data is invalid");
}
const isV4Deployment = deployment.type === "MANAGED";
const registryConfig = getRegistryConfig(isV4Deployment);
// For non-ECR registries, username and password are required upfront
if (
!isEcrRegistry(registryConfig.host) &&
(!registryConfig.username || !registryConfig.password)
) {
throw new ServiceValidationError("Missing deployment registry credentials");
}
if (!env.DEPOT_TOKEN) {
throw new ServiceValidationError("Missing depot token");
}
// All new deployments will set the image reference at creation time
if (!deployment.imageReference) {
throw new ServiceValidationError("Missing image reference");
}
logger.debug("Pushing image to registry", { id, deployment, body });
const pushResult = await executePushToRegistry(
{
depot: {
buildId: externalBuildData.data.buildId,
orgToken: env.DEPOT_TOKEN,
projectId: externalBuildData.data.projectId,
},
registry: registryConfig,
deployment: {
version: deployment.version,
environmentSlug: deployment.environment.slug,
projectExternalRef: deployment.worker.project.externalRef,
imageReference: deployment.imageReference,
},
},
writer
);
if (!pushResult.ok) {
throw new ServiceValidationError(pushResult.error);
}
logger.debug("Image pushed to registry", {
id,
deployment,
body,
pushedImage: pushResult.image,
});
// Belt and suspenders: confirm the push actually landed before promoting.
await this.#assertImagePullable(deployment, body);
await this.#createTemplateIfNeeded(deployment, id, authenticatedEnv, writer);
return finalizeService.call(authenticatedEnv, id, body);
}
async #assertImagePullable(
deployment: { imageReference: string | null; type: string | null },
body: FinalizeDeploymentRequestBody
): Promise<void> {
if (!env.DEPLOY_IMAGE_VERIFICATION_ENABLED) {
return;
}
if (!deployment.imageReference) {
return;
}
const registryConfig = getRegistryConfig(deployment.type === "MANAGED");
// ECR-only: non-ECR (self-hosted) registries can't be checked this way, so skip.
if (!isEcrRegistry(registryConfig.host)) {
return;
}
const result = await ecrImageExists({
imageReference: deployment.imageReference,
imageDigest: body.imageDigest,
registryConfig,
});
if (result === "missing") {
throw new ServiceValidationError(
"Deployment image was not found in the registry. It may not have been pushed (for example a local build without a push, or a push to a different registry). Aborting the deploy to avoid promoting a version that cannot start."
);
}
if (result === "nonconformant") {
throw new ServiceValidationError(
"Deployment image is not runnable: it contains zstd-compressed layers inside a Docker (v2s2) manifest, which the container runtime cannot pull. This typically comes from an outdated CLI version. Please upgrade to the latest trigger.dev CLI and re-deploy."
);
}
// Fail closed: if we can't confirm the image is present, don't promote a version
// that might not start. Set DEPLOY_IMAGE_VERIFICATION_ENABLED=0 for out-of-band pushes.
if (result === "unknown") {
throw new ServiceValidationError(
"Could not verify the deployment image exists in the registry. Aborting the deploy."
);
}
}
async #createTemplateIfNeeded(
deployment: { imageReference: string | null; worker: { project: { id: string } } | null },
deploymentFriendlyId: string,
authenticatedEnv: AuthenticatedEnvironment,
writer?: WritableStreamDefaultWriter
): Promise<void> {
if (!deployment.imageReference || !deployment.worker) {
return;
}
const templateService = new ComputeTemplateCreationService();
await templateService.handleDeployTemplate({
projectId: deployment.worker.project.id,
imageReference: deployment.imageReference,
deploymentFriendlyId,
authenticatedEnv,
prisma: this._prisma,
writer,
});
}
}
type ExecutePushToRegistryOptions = {
depot: {
buildId: string;
orgToken: string;
projectId: string;
};
registry: RegistryConfig;
deployment: {
version: string;
environmentSlug: string;
projectExternalRef: string;
imageReference: string;
};
};
type ExecutePushResult =
| {
ok: true;
image: string;
logs: string;
}
| {
ok: false;
error: string;
logs: string;
};
async function executePushToRegistry(
{ depot, registry, deployment }: ExecutePushToRegistryOptions,
writer?: WritableStreamDefaultWriter
): Promise<ExecutePushResult> {
// Step 1: We need to "login" to the registry
const [loginError, configDir] = await tryCatch(ensureLoggedIntoDockerRegistry(registry));
if (loginError) {
logger.error("Failed to login to registry", {
deployment,
registryHost: registry.host,
error: loginError.message,
});
return {
ok: false as const,
error: "Failed to login to registry",
logs: "",
};
}
const imageTag = deployment.imageReference;
// Step 2: We need to run the depot push command
const childProcess = execDepot(["push", depot.buildId, "-t", imageTag, "--progress", "plain"], {
env: {
NODE_ENV: process.env.NODE_ENV,
DEPOT_TOKEN: depot.orgToken,
DEPOT_PROJECT_ID: depot.projectId,
DEPOT_NO_SUMMARY_LINK: "1",
DEPOT_NO_UPDATE_NOTIFIER: "1",
DOCKER_CONFIG: configDir,
},
});
const errors: string[] = [];
try {
const processCode = await new Promise<number | null>((res, rej) => {
// For some reason everything is output on stderr, not stdout
childProcess.stderr?.on("data", async (data: Buffer) => {
const text = data.toString();
// Emitted data chunks can contain multiple lines. Remove empty lines.
const lines = text.split("\n").filter(Boolean);
errors.push(...lines);
logger.debug(text, { deployment });
// Now we can write strings directly
if (writer) {
for (const line of lines) {
await writer.write(`event: log\ndata: ${JSON.stringify({ message: line })}\n\n`);
}
}
});
childProcess.on("error", (e) => rej(e));
childProcess.on("close", (code) => res(code));
});
const logs = extractLogs(errors);
if (processCode !== 0) {
return {
ok: false as const,
error: `Error pushing image`,
logs,
};
}
return {
ok: true as const,
image: imageTag,
logs,
};
} catch (e) {
return {
ok: false as const,
error: e instanceof Error ? e.message : JSON.stringify(e),
logs: extractLogs(errors),
};
}
}
async function ensureLoggedIntoDockerRegistry(registryConfig: RegistryConfig) {
const tmpDir = await createTempDir();
const dockerConfigPath = join(tmpDir, "config.json");
let auth: { username: string; password: string };
// If this is an ECR registry, get fresh credentials
if (isEcrRegistry(registryConfig.host)) {
auth = await getEcrAuthToken({
registryHost: registryConfig.host,
assumeRole: registryConfig.ecrAssumeRoleArn
? {
roleArn: registryConfig.ecrAssumeRoleArn,
externalId: registryConfig.ecrAssumeRoleExternalId,
}
: undefined,
});
} else if (!registryConfig.username || !registryConfig.password) {
throw new Error("Authentication required for non-ECR registry");
} else {
auth = {
username: registryConfig.username,
password: registryConfig.password,
};
}
await writeJSONFile(dockerConfigPath, {
auths: {
[registryConfig.host]: {
auth: Buffer.from(`${auth.username}:${auth.password}`).toString("base64"),
},
},
});
logger.debug(`Writing docker config to ${dockerConfigPath}`);
return tmpDir;
}
// Create a temporary directory within the OS's temp directory
async function createTempDir(): Promise<string> {
// Generate a unique temp directory path
const tempDirPath: string = join(tmpdir(), "trigger-");
// Create the temp directory synchronously and return the path
const directory = await mkdtemp(tempDirPath);
return directory;
}
async function writeJSONFile(path: string, json: any, pretty = false) {
await writeFile(path, JSON.stringify(json, undefined, pretty ? 2 : undefined), "utf8");
}
function extractLogs(outputs: string[]) {
// Remove empty lines
const cleanedOutputs = outputs.map((line) => line.trim()).filter((line) => line !== "");
return cleanedOutputs.map((line) => line.trim()).join("\n");
}