Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/deploy-external-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---

`trigger.dev deploy --external-id` tags a deployment with an id of your own — a commit SHA, a CI run id, a release tag — so runs triggered by that release of your app go to that deployment. Deploying an id that is already deployed builds nothing and reports the existing version instead of creating a duplicate; use `--force` to rebuild it.
1 change: 1 addition & 0 deletions apps/webapp/app/routes/api.v1.deployments.$deploymentId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
externalBuildData:
deployment.externalBuildData as GetDeploymentResponseBody["externalBuildData"],
errorData: deployment.errorData as GetDeploymentResponseBody["errorData"],
canceledReason: deployment.canceledReason,
worker: deployment.worker
? {
id: deployment.worker.friendlyId,
Expand Down
18 changes: 13 additions & 5 deletions apps/webapp/app/routes/api.v1.deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,24 +42,32 @@ export async function action({ request, params }: ActionFunctionArgs) {
const service = new InitializeDeploymentService();

try {
const { deployment, imageRef, eventStream } = await service.call(authenticatedEnv, body.data);
const result = await service.call(authenticatedEnv, body.data);
const { deployment, imageRef } = result;

const responseBody: InitializeDeploymentResponseBody = {
id: deployment.friendlyId,
contentHash: deployment.contentHash,
shortCode: deployment.shortCode,
version: deployment.version,
externalBuildData:
deployment.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"],
imageTag: imageRef,
imagePlatform: deployment.imagePlatform,
eventStream,
externalId: deployment.externalId ?? undefined,
outcome: result.outcome,
...(result.outcome === "created"
? {
externalBuildData: result.deployment
.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"],
eventStream: result.eventStream,
canceledDeployments: result.canceledDeployments,
}
: { isPromoted: result.isPromoted }),
};

return json(responseBody, { status: 200 });
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 400 });
return json({ error: error.message }, { status: error.status ?? 400 });
}

logger.error("Error initializing deployment", { error });
Expand Down
105 changes: 102 additions & 3 deletions apps/webapp/app/v3/services/initializeDeployment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,53 @@ import { tryCatch } from "@trigger.dev/core";
import { getRegistryConfig } from "../registryConfig.server";
import { DeploymentService } from "./deployment.server";
import { createDeploymentWithNextVersion } from "./initializeDeployment/createDeploymentWithNextVersion.server";
import {
cancelSupersededDeployments,
type SupersededDeployment,
} from "./initializeDeployment/cancelSupersededDeployments.server";
import {
resolveExternalIdReuse,
type ExternalIdReuseDeployment,
} from "./initializeDeployment/resolveExternalIdReuse.server";
import { type WorkerDeployment } from "@trigger.dev/database";
import { errAsync } from "neverthrow";

const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8);

type DeploymentEventStream = {
s2: {
basin: string;
stream: string;
accessToken: string;
};
};

export type InitializeDeploymentResult =
| {
outcome: "created";
deployment: WorkerDeployment;
imageRef: string;
eventStream?: DeploymentEventStream;
canceledDeployments?: SupersededDeployment[];
}
| {
outcome: "existing";
deployment: ExternalIdReuseDeployment;
imageRef: string;
isPromoted: boolean;
};

export class InitializeDeploymentService extends BaseService {
public async call(
environment: AuthenticatedEnvironment,
payload: InitializeDeploymentRequestBody
) {
return this.traceWithEnv("call", environment, async () => {
): Promise<InitializeDeploymentResult> {
return this.traceWithEnv("call", environment, async (span) => {
if (payload.externalId) {
span.setAttribute("externalId", payload.externalId);
}
span.setAttribute("force", payload.force ?? false);

if (payload.gitMeta?.commitSha?.startsWith("deployment_")) {
// When we introduced automatic deployments via the build server, we slightly changed the deployment flow
// mainly in the initialization and starting step: now deployments are first initialized in the `PENDING` status
Expand All @@ -39,6 +76,13 @@ export class InitializeDeploymentService extends BaseService {
// build server experience for users with older CLI versions. We'll eventually be able to remove this workaround
// once we stop supporting 3.x CLI versions.

if (payload.externalId || payload.force) {
throw new ServiceValidationError(
"externalId and force are not supported when attaching to an existing deployment",
400
);
}

const existingDeploymentId = payload.gitMeta.commitSha;
const existingDeployment = await this._prisma.workerDeployment.findFirst({
where: {
Expand All @@ -53,7 +97,10 @@ export class InitializeDeploymentService extends BaseService {
);
}

span.setAttribute("outcome", "created");

return {
outcome: "created",
deployment: existingDeployment,
imageRef: existingDeployment.imageReference ?? "",
};
Expand Down Expand Up @@ -103,6 +150,56 @@ export class InitializeDeploymentService extends BaseService {
);
}

const deploymentService = new DeploymentService();

const reuse = await resolveExternalIdReuse({
prisma: this._prisma,
environmentId: environment.id,
externalId: payload.externalId,
force: payload.force,
});

if (reuse.action === "reject") {
span.setAttribute("outcome", "rejected");

throw new ServiceValidationError(
`A deployment for external id "${payload.externalId}" is already in progress (version ${reuse.deployment.version}). Wait for it to finish, or deploy again with --force to cancel it and start a new one.`,
409
);
}

if (reuse.action === "short-circuit") {
span.setAttribute("outcome", "existing");

logger.debug("Reusing deployed external id, skipping build", {
environmentId: environment.id,
projectId: environment.projectId,
externalId: payload.externalId,
version: reuse.deployment.version,
});

return {
outcome: "existing",
deployment: reuse.deployment,
imageRef: reuse.deployment.imageReference ?? "",
isPromoted: reuse.isPromoted,
};
}
Comment thread
0ski marked this conversation as resolved.

span.setAttribute("outcome", "created");

const canceledDeployments =
reuse.action === "cancel-then-build"
? await cancelSupersededDeployments({
deploymentService,
environmentId: environment.id,
externalId: reuse.externalId,
deployments: reuse.deployments,
})
: [];

span.setAttribute("canceledDeploymentCount", canceledDeployments.length);
Comment thread
0ski marked this conversation as resolved.

// For the `PENDING` initial status, defer the creation of the Depot build until the deployment is started to avoid token expiration issues.
// For local and native builds we don't need to generate the Depot tokens. We still need to create an empty object sadly due to a bug in older CLI versions.
const generateExternalBuildToken =
Expand Down Expand Up @@ -140,7 +237,6 @@ export class InitializeDeploymentService extends BaseService {
const initialStatus =
payload.initialStatus ?? (payload.isNativeBuild ? "PENDING" : "BUILDING");

const deploymentService = new DeploymentService();
const s2StreamOrFail = await deploymentService
.createEventStream(environment.project, { shortCode: deploymentShortCode })
.andThen(({ basin, stream }) =>
Expand Down Expand Up @@ -253,6 +349,7 @@ export class InitializeDeploymentService extends BaseService {
imagePlatform: env.DEPLOY_IMAGE_PLATFORM,
git: payload.gitMeta ?? undefined,
commitSHA: payload.gitMeta?.commitSha ?? undefined,
externalId: payload.externalId,
runtime: payload.runtime ?? environment.project.defaultRuntime ?? undefined,
triggeredVia: payload.triggeredVia ?? undefined,
startedAt: initialStatus === "BUILDING" ? new Date() : undefined,
Expand Down Expand Up @@ -307,9 +404,11 @@ export class InitializeDeploymentService extends BaseService {
}

return {
outcome: "created",
deployment,
imageRef: deployment.imageReference ?? "",
eventStream,
canceledDeployments,
};
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { logger } from "~/services/logger.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import type { DeploymentService } from "../deployment.server";
import type { ExternalIdReuseDeployment } from "./resolveExternalIdReuse.server";

export type SupersededDeployment = Pick<ExternalIdReuseDeployment, "version" | "shortCode">;

export function supersededByForceReason(externalId: string): string {
return `Superseded by a new deploy with --force for external id "${externalId}"`;
}

type CancelSupersededDeploymentsOptions = {
deploymentService: DeploymentService;
environmentId: string;
externalId: string;
deployments: ExternalIdReuseDeployment[];
};

export async function cancelSupersededDeployments({
deploymentService,
environmentId,
externalId,
deployments,
}: CancelSupersededDeploymentsOptions): Promise<SupersededDeployment[]> {
const canceled: SupersededDeployment[] = [];

for (const deployment of deployments) {
const result = await deploymentService.cancelDeployment(
{ id: environmentId },
deployment.friendlyId,
{
canceledReason: supersededByForceReason(externalId),
}
);

if (result.isOk()) {
canceled.push({ version: deployment.version, shortCode: deployment.shortCode });
continue;
}

if (
result.error.type === "deployment_cannot_be_cancelled" ||
result.error.type === "deployment_not_found"
) {
logger.debug("Superseded deployment was already final", {
externalId,
version: deployment.version,
reason: result.error.type,
});
continue;
}

if (result.error.type === "failed_to_delete_deployment_timeout") {
logger.warn("Failed to dequeue the timeout job for a superseded deployment", {
externalId,
version: deployment.version,
error: result.error.cause,
});
canceled.push({ version: deployment.version, shortCode: deployment.shortCode });
continue;
}

throw new ServiceValidationError(
`Failed to cancel the in-progress deployment ${deployment.version} holding external id "${externalId}". Nothing was built — try again.`,
500
);
}

return canceled;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
import { type PrismaClientOrTransaction, type WorkerDeployment } from "@trigger.dev/database";
import { compareDeploymentVersions } from "../../utils/deploymentVersions";
import { FINAL_DEPLOYMENT_STATUSES } from "../failDeployment.server";

const MAX_CANDIDATES = 20;

export type ExternalIdReuseDeployment = Pick<
WorkerDeployment,
| "id"
| "friendlyId"
| "shortCode"
| "version"
| "status"
| "contentHash"
| "imageReference"
| "imagePlatform"
| "externalId"
>;

type ExternalIdReuseCandidate = ExternalIdReuseDeployment & { promotions: { id: string }[] };

export type ResolveExternalIdReuseResult =
| { action: "build" }
| { action: "short-circuit"; deployment: ExternalIdReuseDeployment; isPromoted: boolean }
| { action: "reject"; deployment: ExternalIdReuseDeployment }
| {
action: "cancel-then-build";
externalId: string;
deployments: ExternalIdReuseDeployment[];
};

export type ResolveExternalIdReuseOptions = {
prisma: PrismaClientOrTransaction;
environmentId: string;
externalId?: string;
force?: boolean;
};

export async function resolveExternalIdReuse({
prisma,
environmentId,
externalId,
force,
}: ResolveExternalIdReuseOptions): Promise<ResolveExternalIdReuseResult> {
if (!externalId) {
return { action: "build" };
}

const candidates = await prisma.workerDeployment.findMany({
where: { environmentId, externalId },
select: {
id: true,
friendlyId: true,
shortCode: true,
version: true,
status: true,
contentHash: true,
imageReference: true,
imagePlatform: true,
externalId: true,
promotions: {
where: { label: CURRENT_DEPLOYMENT_LABEL },
select: { id: true },
},
},
orderBy: { id: "desc" },
take: MAX_CANDIDATES,
});
Comment thread
0ski marked this conversation as resolved.

const inFlight = byVersionDesc(
candidates.filter((deployment) => !FINAL_DEPLOYMENT_STATUSES.includes(deployment.status))
);

if (force) {
return inFlight.length
? { action: "cancel-then-build", externalId, deployments: inFlight }
: { action: "build" };
}

if (inFlight.length) {
return { action: "reject", deployment: inFlight[0]! };
}

const deployed = byVersionDesc(
candidates.filter((deployment) => deployment.status === "DEPLOYED")
);

if (deployed.length) {
const deployment = deployed[0]!;
return { action: "short-circuit", deployment, isPromoted: deployment.promotions.length > 0 };
}

return { action: "build" };
}

function byVersionDesc(deployments: ExternalIdReuseCandidate[]): ExternalIdReuseCandidate[] {
return [...deployments].sort((a, b) => compareDeploymentVersions(b.version, a.version));
}
Loading
Loading