Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 6 additions & 0 deletions .changeset/thin-pants-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---

Added support for deployments with local builds.
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import {
type GenerateRegistryCredentialsResponseBody,
ProgressDeploymentRequestBody,
tryCatch,
} 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 tryCatch(request.json());
const body = ProgressDeploymentRequestBody.safeParse(rawBody ?? {});

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

const deploymentService = new DeploymentService();

return await deploymentService.generateRegistryCredentials(authenticatedEnv, deploymentId).match(
(result) => {
return json(
{
username: result.username,
password: result.password,
expiresAt: result.expiresAt.toISOString(),
repositoryUri: result.repositoryUri,
} satisfies GenerateRegistryCredentialsResponseBody,
{ status: 200 }
);
},
(error) => {
switch (error.type) {
case "deployment_not_found":
return json({ error: "Deployment not found" }, { status: 404 });
case "deployment_has_no_image_reference":
logger.error(
"Failed to generate registry credentials: deployment_has_no_image_reference",
{ deploymentId }
);
return json({ error: "Deployment has no image reference" }, { status: 409 });
case "deployment_is_already_final":
return json(
{ error: "Failed to generate registry credentials: deployment_is_already_final" },
{ status: 409 }
);
case "missing_registry_credentials":
logger.error("Failed to generate registry credentials: missing_registry_credentials", {
deploymentId,
});
return json({ error: "Missing registry credentials" }, { status: 409 });
case "registry_not_supported":
logger.error("Failed to generate registry credentials: registry_not_supported", {
deploymentId,
});
return json({ error: "Registry not supported" }, { status: 409 });
case "registry_region_not_supported":
logger.error("Failed to generate registry credentials: registry_region_not_supported", {
deploymentId,
});
return json({ error: "Registry region not supported" }, { status: 409 });
case "other":
default:
error.type satisfies "other";
logger.error("Failed to generate registry credentials", { error: error.cause });
return json({ error: "Internal server error" }, { status: 500 });
}
}
);
}
18 changes: 18 additions & 0 deletions apps/webapp/app/services/platform.v3.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,24 @@ export async function setBillingAlert(
return result;
}

export async function generateRegistryCredentials(
projectId: string,
region: "us-east-1" | "eu-central-1"
) {
if (!client) return undefined;
const result = await client.generateRegistryCredentials(projectId, region);
if (!result.success) {
logger.error("Error generating registry credentials", {
error: result.error,
projectId,
region,
});
throw new Error("Failed to generate registry credentials");
}

return result;
}

function isCloud(): boolean {
const acceptableHosts = [
"https://cloud.trigger.dev",
Expand Down
89 changes: 89 additions & 0 deletions apps/webapp/app/v3/services/deployment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { env } from "~/env.server";
import { createRemoteImageBuild } from "../remoteImageBuilder.server";
import { FINAL_DEPLOYMENT_STATUSES } from "./failDeployment.server";
import { generateRegistryCredentials } from "~/services/platform.v3.server";

export class DeploymentService extends BaseService {
/**
Expand Down Expand Up @@ -231,4 +232,92 @@
.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")) {
Comment thread Dismissed
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);
}

private getDeployment(projectId: string, friendlyId: string) {
return fromPromise(
this._prisma.workerDeployment.findFirst({
where: {
friendlyId,
projectId,
},
select: {
status: true,
id: true,
imageReference: true,
},
}),
(error) => ({
type: "other" as const,
cause: error,
})
).andThen((deployment) => {
if (!deployment) {
return errAsync({ type: "deployment_not_found" as const });
}
return okAsync(deployment);
});
}
}
10 changes: 9 additions & 1 deletion apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ export class FinalizeDeploymentV2Service extends BaseService {
throw new ServiceValidationError("Worker deployment is not in DEPLOYING status");
}

const finalizeService = new FinalizeDeploymentService();

if (body.skipPushToRegistry) {
logger.debug("Skipping push to registry during deployment finalization", {
deployment,
});
return await finalizeService.call(authenticatedEnv, id, body);
}

const externalBuildData = deployment.externalBuildData
? ExternalBuildData.safeParse(deployment.externalBuildData)
: undefined;
Expand Down Expand Up @@ -134,7 +143,6 @@ export class FinalizeDeploymentV2Service extends BaseService {
pushedImage: pushResult.image,
});

const finalizeService = new FinalizeDeploymentService();
const finalizedDeployment = await finalizeService.call(authenticatedEnv, id, body);

return finalizedDeployment;
Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
"@trigger.dev/core": "workspace:*",
"@trigger.dev/database": "workspace:*",
"@trigger.dev/otlp-importer": "workspace:*",
"@trigger.dev/platform": "1.0.18",
"@trigger.dev/platform": "0.0.0-prerelease-ecr-20251021203336",
Comment thread
myftija marked this conversation as resolved.
Outdated
"@trigger.dev/redis-worker": "workspace:*",
"@trigger.dev/sdk": "workspace:*",
"@types/pg": "8.6.6",
Expand Down
17 changes: 17 additions & 0 deletions packages/cli-v3/src/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
GetJWTRequestBody,
GetJWTResponse,
ApiBranchListResponseBody,
GenerateRegistryCredentialsResponseBody,
} from "@trigger.dev/core/v3";
import {
WorkloadDebugLogRequestBody,
Expand Down Expand Up @@ -327,6 +328,22 @@ export class CliApiClient {
);
}

async generateRegistryCredentials(deploymentId: string) {
if (!this.accessToken) {
throw new Error("generateRegistryCredentials: No access token");
}

return wrapZodFetch(
GenerateRegistryCredentialsResponseBody,
`${this.apiURL}/api/v1/deployments/${deploymentId}/generate-registry-credentials`,
{
method: "POST",
headers: this.getHeaders(),
body: "{}",
}
);
}

async initializeDeployment(body: InitializeDeploymentRequestBody) {
if (!this.accessToken) {
throw new Error("initializeDeployment: No access token");
Expand Down
17 changes: 14 additions & 3 deletions packages/cli-v3/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const DeployCommandOptions = CommonCommandOptions.extend({
noCache: z.boolean().default(false),
envFile: z.string().optional(),
// Local build options
forceLocalBuild: z.boolean().optional(),
network: z.enum(["default", "none", "host"]).optional(),
push: z.boolean().optional(),
builder: z.string().default("trigger"),
Expand Down Expand Up @@ -127,6 +128,9 @@ export function configureDeployCommand(program: Command) {
).hideHelp()
)
// Local build options
.addOption(
new CommandOption("--force-local-build", "Force a local build of the image").hideHelp()
)
.addOption(new CommandOption("--push", "Push the image after local builds").hideHelp())
.addOption(
new CommandOption("--no-push", "Do not push the image after local builds").hideHelp()
Expand Down Expand Up @@ -320,7 +324,9 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
},
envVars.TRIGGER_EXISTING_DEPLOYMENT_ID
);
const isLocalBuild = !deployment.externalBuildData;
const isLocalBuild = options.forceLocalBuild || !deployment.externalBuildData;
// Would be best to actually store this separately in the deployment object. This is an okay proxy for now.
const remoteBuildExplicitlySkipped = options.forceLocalBuild && !!deployment.externalBuildData;

// Fail fast if we know local builds will fail
if (isLocalBuild) {
Expand Down Expand Up @@ -391,8 +397,10 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {

const $spinner = spinner();

const buildSuffix = isLocalBuild ? " (local)" : "";
const deploySuffix = isLocalBuild ? " (local build)" : "";
const buildSuffix =
isLocalBuild && !process.env.TRIGGER_LOCAL_BUILD_LABEL_DISABLED ? " (local)" : "";
const deploySuffix =
isLocalBuild && !process.env.TRIGGER_LOCAL_BUILD_LABEL_DISABLED ? " (local build)" : "";

if (isCI) {
log.step(`Building version ${version}\n`);
Expand Down Expand Up @@ -420,6 +428,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
projectRef: resolvedConfig.project,
apiUrl: projectClient.client.apiURL,
apiKey: projectClient.client.accessToken!,
apiClient: projectClient.client,
branchName: branch,
authAccessToken: authorization.auth.accessToken,
compilationPath: destination.path,
Expand All @@ -442,6 +451,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
network: options.network,
builder: options.builder,
push: options.push,
authenticateToRegistry: remoteBuildExplicitlySkipped,
});

logger.debug("Build result", buildResult);
Expand Down Expand Up @@ -525,6 +535,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
{
imageDigest: buildResult.digest,
skipPromotion: options.skipPromotion,
skipPushToRegistry: remoteBuildExplicitlySkipped,
},
(logMessage) => {
if (isCI) {
Expand Down
1 change: 1 addition & 0 deletions packages/cli-v3/src/commands/workers/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ async function _workerBuildCommand(dir: string, options: WorkersBuildCommandOpti
projectRef: resolvedConfig.project,
apiUrl: projectClient.client.apiURL,
apiKey: projectClient.client.accessToken!,
apiClient: projectClient.client,
branchName: branch,
authAccessToken: authorization.auth.accessToken,
compilationPath: destination.path,
Expand Down
Loading