Skip to content

Commit 65d0d00

Browse files
committed
Support native build server for deployments with the cli
1 parent 1a7ee24 commit 65d0d00

File tree

23 files changed

+2508
-865
lines changed

23 files changed

+2508
-865
lines changed

apps/webapp/app/env.server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,12 @@ const EnvironmentSchema = z
345345
OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
346346
OBJECT_STORE_REGION: z.string().optional(),
347347
OBJECT_STORE_SERVICE: z.string().default("s3"),
348+
349+
ARTIFACTS_OBJECT_STORE_BUCKET: z.string(),
350+
ARTIFACTS_OBJECT_STORE_BASE_URL: z.string().optional(),
351+
ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(),
352+
ARTIFACTS_OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(),
353+
ARTIFACTS_OBJECT_STORE_REGION: z.string().optional(),
348354
EVENTS_BATCH_SIZE: z.coerce.number().int().default(100),
349355
EVENTS_BATCH_INTERVAL: z.coerce.number().int().default(1000),
350356
EVENTS_DEFAULT_LOG_RETENTION: z.coerce.number().int().default(7),
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import {
3+
type CreateArtifactResponseBody,
4+
CreateArtifactRequestBody,
5+
tryCatch,
6+
} from "@trigger.dev/core/v3";
7+
import { authenticateRequest } from "~/services/apiAuth.server";
8+
import { logger } from "~/services/logger.server";
9+
import { ArtifactsService } from "~/v3/services/artifacts.server";
10+
11+
export async function action({ request }: ActionFunctionArgs) {
12+
if (request.method.toUpperCase() !== "POST") {
13+
return json({ error: "Method Not Allowed" }, { status: 405 });
14+
}
15+
16+
const authenticationResult = await authenticateRequest(request, {
17+
apiKey: true,
18+
organizationAccessToken: false,
19+
personalAccessToken: false,
20+
});
21+
22+
if (!authenticationResult || !authenticationResult.result.ok) {
23+
logger.info("Invalid or missing api key", { url: request.url });
24+
return json({ error: "Invalid or Missing API key" }, { status: 401 });
25+
}
26+
27+
const [, rawBody] = await tryCatch(request.json());
28+
const body = CreateArtifactRequestBody.safeParse(rawBody ?? {});
29+
30+
if (!body.success) {
31+
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
32+
}
33+
34+
const { environment: authenticatedEnv } = authenticationResult.result;
35+
36+
const service = new ArtifactsService();
37+
return await service
38+
.createArtifact(body.data.type, authenticatedEnv, body.data.contentLength)
39+
.match(
40+
(result) => {
41+
return json(
42+
{
43+
artifactKey: result.artifactKey,
44+
uploadUrl: result.uploadUrl,
45+
uploadFields: result.uploadFields,
46+
expiresAt: result.expiresAt.toISOString(),
47+
} satisfies CreateArtifactResponseBody,
48+
{ status: 201 }
49+
);
50+
},
51+
(error) => {
52+
switch (error.type) {
53+
case "artifact_size_exceeds_limit": {
54+
logger.warn("Artifact size exceeds limit", { error });
55+
return json(
56+
{
57+
error: `Artifact size (${error.contentLength} bytes) exceeds the allowed limit of ${error.sizeLimit} bytes`,
58+
},
59+
{ status: 400 }
60+
);
61+
}
62+
case "failed_to_create_presigned_post": {
63+
logger.error("Failed to create presigned POST", { error });
64+
return json({ error: "Failed to generate artifact upload URL" }, { status: 500 });
65+
}
66+
default:
67+
error satisfies never;
68+
logger.error("Failed creating artifact", { error });
69+
return json({ error: "Internal server error" }, { status: 500 });
70+
}
71+
}
72+
);
73+
}

apps/webapp/app/routes/api.v1.deployments.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
3737
const service = new InitializeDeploymentService();
3838

3939
try {
40-
const { deployment, imageRef } = await service.call(authenticatedEnv, body.data);
40+
const { deployment, imageRef, eventStream } = await service.call(authenticatedEnv, body.data);
4141

4242
const responseBody: InitializeDeploymentResponseBody = {
4343
id: deployment.friendlyId,
@@ -48,6 +48,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
4848
deployment.externalBuildData as InitializeDeploymentResponseBody["externalBuildData"],
4949
imageTag: imageRef,
5050
imagePlatform: deployment.imagePlatform,
51+
eventStream,
5152
};
5253

5354
return json(responseBody, { status: 200 });

apps/webapp/app/services/platform.v3.server.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,31 @@ export async function generateRegistryCredentials(
529529
return result;
530530
}
531531

532+
export async function enqueueBuild(
533+
projectId: string,
534+
deploymentId: string,
535+
artifactKey: string,
536+
options: {
537+
skipPromotion?: boolean;
538+
configFilePath?: string;
539+
}
540+
) {
541+
if (!client) return undefined;
542+
const result = await client.enqueueBuild(projectId, { deploymentId, artifactKey, options });
543+
if (!result.success) {
544+
logger.error("Error enqueuing build", {
545+
error: result.error,
546+
projectId,
547+
deploymentId,
548+
artifactKey,
549+
options,
550+
});
551+
throw new Error("Failed to enqueue build");
552+
}
553+
554+
return result;
555+
}
556+
532557
function isCloud(): boolean {
533558
const acceptableHosts = [
534559
"https://cloud.trigger.dev",
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
2+
import { BaseService } from "./baseService.server";
3+
import { env } from "~/env.server";
4+
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
5+
import { S3Client } from "@aws-sdk/client-s3";
6+
import { customAlphabet } from "nanoid";
7+
import { errAsync, fromPromise } from "neverthrow";
8+
9+
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 24);
10+
const objectStoreClient =
11+
env.ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID &&
12+
env.ARTIFACTS_OBJECT_STORE_SECRET_ACCESS_KEY &&
13+
env.ARTIFACTS_OBJECT_STORE_BASE_URL
14+
? new S3Client({
15+
credentials: {
16+
accessKeyId: env.ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID,
17+
secretAccessKey: env.ARTIFACTS_OBJECT_STORE_SECRET_ACCESS_KEY,
18+
},
19+
region: env.ARTIFACTS_OBJECT_STORE_REGION,
20+
endpoint: env.ARTIFACTS_OBJECT_STORE_BASE_URL,
21+
forcePathStyle: true,
22+
})
23+
: new S3Client();
24+
25+
const artifactKeyPrefixByType = {
26+
deployment_context: "deployments",
27+
} as const;
28+
const artifactBytesSizeLimitByType = {
29+
deployment_context: 100 * 1024 * 1024, // 100MB
30+
} as const;
31+
32+
export class ArtifactsService extends BaseService {
33+
private readonly bucket = env.ARTIFACTS_OBJECT_STORE_BUCKET;
34+
35+
public createArtifact(
36+
type: "deployment_context",
37+
authenticatedEnv: AuthenticatedEnvironment,
38+
contentLength?: number
39+
) {
40+
const limit = artifactBytesSizeLimitByType[type];
41+
42+
// this is just a validation using client-side data
43+
// the actual limit will be enforced by S3
44+
if (contentLength && contentLength > limit) {
45+
return errAsync({
46+
type: "artifact_size_exceeds_limit" as const,
47+
contentLength,
48+
sizeLimit: limit,
49+
});
50+
}
51+
52+
const uniqueId = nanoid();
53+
const key = `${artifactKeyPrefixByType[type]}/${authenticatedEnv.project.externalRef}/${authenticatedEnv.slug}/${uniqueId}.tar.gz`;
54+
55+
return this.createPresignedPost(key, limit, contentLength).map((result) => ({
56+
artifactKey: key,
57+
uploadUrl: result.url,
58+
uploadFields: result.fields,
59+
expiresAt: result.expiresAt,
60+
}));
61+
}
62+
63+
private createPresignedPost(key: string, sizeLimit: number, contentLength?: number) {
64+
const ttlSeconds = 300; // 5 minutes
65+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
66+
67+
return fromPromise(
68+
createPresignedPost(objectStoreClient, {
69+
Bucket: this.bucket,
70+
Key: key,
71+
Conditions: [["content-length-range", 0, sizeLimit]],
72+
Fields: {
73+
"Content-Type": "application/gzip",
74+
},
75+
Expires: ttlSeconds,
76+
}),
77+
(error) => ({
78+
type: "failed_to_create_presigned_post" as const,
79+
cause: error,
80+
})
81+
).map((result) => ({
82+
...result,
83+
expiresAt,
84+
}));
85+
}
86+
}

0 commit comments

Comments
 (0)