diff --git a/.changeset/thin-pants-design.md b/.changeset/thin-pants-design.md new file mode 100644 index 0000000000..6480ed15c0 --- /dev/null +++ b/.changeset/thin-pants-design.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Added support for deployments with local builds. diff --git a/apps/webapp/app/routes/api.v1.deployments.$deploymentId.generate-registry-credentials.ts b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.generate-registry-credentials.ts new file mode 100644 index 0000000000..161f37f930 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.$deploymentId.generate-registry-credentials.ts @@ -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 }); + } + } + ); +} diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index c58ed1218e..cf1aa86c41 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -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", diff --git a/apps/webapp/app/v3/services/deployment.server.ts b/apps/webapp/app/v3/services/deployment.server.ts index e12256526c..47bf3c04fc 100644 --- a/apps/webapp/app/v3/services/deployment.server.ts +++ b/apps/webapp/app/v3/services/deployment.server.ts @@ -7,6 +7,7 @@ import { TimeoutDeploymentService } from "./timeoutDeployment.server"; 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 { /** @@ -231,4 +232,92 @@ export class DeploymentService extends BaseService { .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, + friendlyId: string + ) { + const validateDeployment = ( + deployment: Pick + ) => { + if (FINAL_DEPLOYMENT_STATUSES.includes(deployment.status)) { + return errAsync({ type: "deployment_is_already_final" as const }); + } + return okAsync(deployment); + }; + + const getDeploymentRegion = (deployment: Pick) => { + 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); + } + + 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); + }); + } } diff --git a/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts b/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts index 4bf0305e6f..2ad2b7b825 100644 --- a/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts +++ b/apps/webapp/app/v3/services/finalizeDeploymentV2.server.ts @@ -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; @@ -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; diff --git a/apps/webapp/package.json b/apps/webapp/package.json index d1b2dacda3..5820ac7949 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -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": "1.0.19", "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", "@types/pg": "8.6.6", diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index b5a9ed6a43..6f65bce114 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -37,6 +37,7 @@ import { GetJWTRequestBody, GetJWTResponse, ApiBranchListResponseBody, + GenerateRegistryCredentialsResponseBody, } from "@trigger.dev/core/v3"; import { WorkloadDebugLogRequestBody, @@ -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"); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index d8c27a7917..4c7bbd92bf 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -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"), @@ -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() @@ -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) { @@ -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`); @@ -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, @@ -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); @@ -525,6 +535,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { { imageDigest: buildResult.digest, skipPromotion: options.skipPromotion, + skipPushToRegistry: remoteBuildExplicitlySkipped, }, (logMessage) => { if (isCI) { diff --git a/packages/cli-v3/src/commands/workers/build.ts b/packages/cli-v3/src/commands/workers/build.ts index 960d94cdde..17b5b19999 100644 --- a/packages/cli-v3/src/commands/workers/build.ts +++ b/packages/cli-v3/src/commands/workers/build.ts @@ -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, diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 81a8311dbd..7aed546862 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -6,9 +6,12 @@ import { networkInterfaces } from "os"; import { join } from "path"; import { safeReadJSONFile } from "../utilities/fileSystem.js"; import { readFileSync } from "fs"; + import { isLinux } from "std-env"; import { z } from "zod"; import { assertExhaustive } from "../utilities/assertExhaustive.js"; +import { tryCatch } from "@trigger.dev/core"; +import { CliApiClient } from "../apiClient.js"; export interface BuildImageOptions { // Common options @@ -19,6 +22,7 @@ export interface BuildImageOptions { // Local build options push?: boolean; + authenticateToRegistry?: boolean; network?: string; builder: string; @@ -37,6 +41,7 @@ export interface BuildImageOptions { extraCACerts?: string; apiUrl: string; apiKey: string; + apiClient: CliApiClient; branchName?: string; buildEnvVars?: Record; onLog?: (log: string) => void; @@ -51,6 +56,7 @@ export async function buildImage(options: BuildImageOptions): Promise { - const { builder, imageTag } = options; + const { builder, imageTag, deploymentId, apiClient } = options; // Ensure multi-platform build is supported on the local machine let builderExists = false; @@ -414,6 +425,64 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise { + if (process.env.TRIGGER_DOCKER_USERNAME && process.env.TRIGGER_DOCKER_PASSWORD) { + return { + username: process.env.TRIGGER_DOCKER_USERNAME, + password: process.env.TRIGGER_DOCKER_PASSWORD, + }; + } + + const result = await apiClient.generateRegistryCredentials(deploymentId); + + if (!result.success) { + logger.debug("Failed to generate registry credentials", { + error: result.error, + deploymentId, + }); + throw new Error("Failed to generate registry credentials"); + } + + return { + username: result.data.username, + password: result.data.password, + }; +} + function isQemuRegistered() { try { // Check a single QEMU handler diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index f8e12f62cc..c86f302a66 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -373,6 +373,7 @@ export type StartDeploymentIndexingResponseBody = z.infer< export const FinalizeDeploymentRequestBody = z.object({ skipPromotion: z.boolean().optional(), imageDigest: z.string().optional(), + skipPushToRegistry: z.boolean().optional(), }); export type FinalizeDeploymentRequestBody = z.infer; @@ -438,6 +439,17 @@ export const InitializeDeploymentRequestBody = z.object({ export type InitializeDeploymentRequestBody = z.infer; +export const GenerateRegistryCredentialsResponseBody = z.object({ + username: z.string(), + password: z.string(), + expiresAt: z.string(), + repositoryUri: z.string(), +}); + +export type GenerateRegistryCredentialsResponseBody = z.infer< + typeof GenerateRegistryCredentialsResponseBody +>; + export const DeploymentErrorData = z.object({ name: z.string(), message: z.string(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e0fcb1881..dc41700d80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -460,8 +460,8 @@ importers: specifier: workspace:* version: link:../../internal-packages/otlp-importer '@trigger.dev/platform': - specifier: 1.0.18 - version: 1.0.18 + specifier: 1.0.19 + version: 1.0.19 '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -17780,8 +17780,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@trigger.dev/platform@1.0.18: - resolution: {integrity: sha512-7huIRYY9+QzoV9b8lIr7GGLhLSrt2mu/LX+aENO2Jch8C0SAKuztBdJk/zi9NXYhmQzkpS2ASWGukf4qOAIwXg==} + /@trigger.dev/platform@1.0.19: + resolution: {integrity: sha512-dA2FmEItCO3/7LHFkkw65OxVQQEystcL+7uCqVTMOvam7S0FR+x1qNSQ40XNqSN7W5gM/uky/IgTOfk0JmILOw==} dependencies: zod: 3.23.8 dev: false