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
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 { 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 {
/**
Expand Down Expand Up @@ -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<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);
}

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": "1.0.19",
"@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