Skip to content

Commit 1a5ad1e

Browse files
myftijaTrigger.dev RepoOps
authored andcommitted
feat(api): presign deployment artifact download URLs on demand
Adds GET /api/v1/deployments/:id/artifact-url, which returns a short-lived presigned URL for the deployment's artifact after verifying the key belongs to the caller's environment and the object still exists. Mono-RevId: a8cc8df3b0d9e6ea86247562cee63593879cac71
1 parent 05bf16a commit 1a5ad1e

6 files changed

Lines changed: 402 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Adds the `GetDeploymentArtifactUrlResponseBody` schema for the deployment artifact download URL endpoint.

‎apps/webapp/app/env.server.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,12 @@ const EnvironmentSchema = z
867867
.number()
868868
.int()
869869
.default(100 * 1024 * 1024), // 100MB
870+
DEPLOYMENT_ARTIFACT_DOWNLOAD_URL_TTL_SECONDS: z.coerce
871+
.number()
872+
.int()
873+
.positive()
874+
.max(60 * 60 * 24 * 7) // SigV4 presign limit
875+
.default(60 * 10), // 10 minutes
870876
DEPLOYMENT_BUILD_ENV_VARS_SIZE_LIMIT_BYTES: z.coerce
871877
.number()
872878
.int()
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { type GetDeploymentArtifactUrlResponseBody } from "@trigger.dev/core/v3";
3+
import { z } from "zod";
4+
import { authenticateApiKeyWithScope } from "~/services/apiAuth.server";
5+
import { logger } from "~/services/logger.server";
6+
import { ArtifactsService } from "~/v3/services/artifacts.server";
7+
8+
const ParamsSchema = z.object({
9+
deploymentId: z.string(),
10+
});
11+
12+
export async function loader({ request, params }: LoaderFunctionArgs) {
13+
const parsedParams = ParamsSchema.safeParse(params);
14+
15+
if (!parsedParams.success) {
16+
return json({ error: "Invalid params" }, { status: 400 });
17+
}
18+
19+
try {
20+
const authResult = await authenticateApiKeyWithScope(request, {
21+
action: "write",
22+
resource: { type: "deployments" },
23+
});
24+
25+
if (!authResult.ok) {
26+
logger.info("Invalid or missing api key", { url: request.url });
27+
return json({ error: authResult.error }, { status: authResult.status });
28+
}
29+
30+
const authenticatedEnv = authResult.authentication.environment;
31+
const { deploymentId } = parsedParams.data;
32+
const logContext = {
33+
deploymentId,
34+
environmentId: authenticatedEnv.id,
35+
projectId: authenticatedEnv.projectId,
36+
};
37+
38+
return await new ArtifactsService()
39+
.createDeploymentDownloadUrl(authenticatedEnv, deploymentId)
40+
.match(
41+
({ url, expiresAt }) => {
42+
logger.info("Issued deployment artifact download URL", logContext);
43+
return json(
44+
{
45+
url,
46+
expiresAt: expiresAt.toISOString(),
47+
} satisfies GetDeploymentArtifactUrlResponseBody,
48+
{ headers: { "Cache-Control": "private, no-store" } }
49+
);
50+
},
51+
(error) => {
52+
switch (error.type) {
53+
case "development_environment":
54+
return json(
55+
{ error: "Deployments are not supported in development environments" },
56+
{ status: 400 }
57+
);
58+
case "deployment_not_found":
59+
return json({ error: "deployment_not_found" }, { status: 404 });
60+
case "artifact_not_found":
61+
return json({ error: "artifact_not_found" }, { status: 404 });
62+
case "artifact_key_not_owned":
63+
logger.warn("Deployment artifact key does not belong to the environment", {
64+
...logContext,
65+
key: error.key,
66+
});
67+
return json({ error: "artifact_not_found" }, { status: 404 });
68+
case "artifacts_bucket_not_configured":
69+
logger.error("Artifacts bucket is not configured", logContext);
70+
return json({ error: "Internal server error" }, { status: 500 });
71+
case "failed_to_check_artifact":
72+
case "failed_to_create_download_url":
73+
logger.error("Failed to create deployment artifact download URL", {
74+
...logContext,
75+
error: error.cause,
76+
});
77+
return json({ error: "Internal server error" }, { status: 500 });
78+
case "other":
79+
default:
80+
error.type satisfies "other";
81+
logger.error("Failed to load deployment for artifact download URL", {
82+
...logContext,
83+
error: error.cause,
84+
});
85+
return json({ error: "Internal server error" }, { status: 500 });
86+
}
87+
}
88+
);
89+
} catch (error) {
90+
if (error instanceof Response) throw error;
91+
logger.error("Failed to create deployment artifact download URL", { error });
92+
return json({ error: "Internal Server Error" }, { status: 500 });
93+
}
94+
}

‎apps/webapp/app/v3/services/artifacts.server.ts‎

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
22
import { BaseService } from "./baseService.server";
33
import { env } from "~/env.server";
4+
import { logger } from "~/services/logger.server";
45
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
5-
import { S3Client } from "@aws-sdk/client-s3";
6+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
7+
import { GetObjectCommand, HeadObjectCommand, S3Client } from "@aws-sdk/client-s3";
8+
import { BuildServerMetadata } from "@trigger.dev/core/v3";
69
import { customAlphabet } from "nanoid";
7-
import { errAsync, fromPromise } from "neverthrow";
10+
import { errAsync, fromPromise, okAsync } from "neverthrow";
11+
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
812

913
const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 24);
1014
const objectStoreClient =
@@ -32,8 +36,40 @@ const artifactBytesSizeLimitByType = {
3236
deployment_bundle: env.DEPLOYMENT_BUNDLE_ARTIFACT_SIZE_LIMIT_BYTES,
3337
} as const;
3438

39+
export type ArtifactOwner = Pick<AuthenticatedEnvironment, "id" | "slug" | "type"> & {
40+
project: { externalRef: string };
41+
};
42+
43+
const ArtifactKeyMetadata = BuildServerMetadata.pick({ artifactKey: true });
44+
45+
export function isArtifactKeyOwnedBy(owner: ArtifactOwner, key: string): boolean {
46+
const [prefix, projectRef, envSlug, file, ...rest] = key.split("/");
47+
return (
48+
rest.length === 0 &&
49+
Object.values<string>(artifactKeyPrefixByType).includes(prefix) &&
50+
projectRef === owner.project.externalRef &&
51+
envSlug === owner.slug &&
52+
!!file
53+
);
54+
}
55+
3556
export class ArtifactsService extends BaseService {
36-
private readonly bucket = env.ARTIFACTS_OBJECT_STORE_BUCKET;
57+
private readonly client: S3Client;
58+
private readonly bucket: string | undefined;
59+
private readonly downloadUrlTtlSeconds: number;
60+
61+
constructor(options?: {
62+
prisma?: PrismaClientOrTransaction;
63+
client?: S3Client;
64+
bucket?: string;
65+
downloadUrlTtlSeconds?: number;
66+
}) {
67+
super(options?.prisma);
68+
this.client = options?.client ?? objectStoreClient;
69+
this.bucket = options?.bucket ?? env.ARTIFACTS_OBJECT_STORE_BUCKET;
70+
this.downloadUrlTtlSeconds =
71+
options?.downloadUrlTtlSeconds ?? env.DEPLOYMENT_ARTIFACT_DOWNLOAD_URL_TTL_SECONDS;
72+
}
3773

3874
public createArtifact(
3975
type: "deployment_context" | "deployment_bundle",
@@ -63,6 +99,74 @@ export class ArtifactsService extends BaseService {
6399
}));
64100
}
65101

102+
public createDeploymentDownloadUrl(
103+
authenticatedEnv: ArtifactOwner,
104+
deploymentFriendlyId: string
105+
) {
106+
if (authenticatedEnv.type === "DEVELOPMENT") {
107+
return errAsync({ type: "development_environment" as const });
108+
}
109+
110+
return fromPromise(
111+
this._prisma.workerDeployment.findFirst({
112+
where: { friendlyId: deploymentFriendlyId, environmentId: authenticatedEnv.id },
113+
select: { buildServerMetadata: true },
114+
}),
115+
(error) => ({ type: "other" as const, cause: error })
116+
)
117+
.andThen((deployment) =>
118+
deployment ? okAsync(deployment) : errAsync({ type: "deployment_not_found" as const })
119+
)
120+
.andThen((deployment) => {
121+
const key = ArtifactKeyMetadata.safeParse(deployment.buildServerMetadata).data?.artifactKey;
122+
return key ? okAsync(key) : errAsync({ type: "artifact_not_found" as const });
123+
})
124+
.andThen((key) => this.createDownloadUrl(authenticatedEnv, key));
125+
}
126+
127+
private createDownloadUrl(owner: ArtifactOwner, key: string) {
128+
if (!this.bucket) {
129+
return errAsync({
130+
type: "artifacts_bucket_not_configured" as const,
131+
});
132+
}
133+
134+
if (!isArtifactKeyOwnedBy(owner, key)) {
135+
return errAsync({ type: "artifact_key_not_owned" as const, key: key.slice(0, 200) });
136+
}
137+
138+
const bucket = this.bucket;
139+
const ttlSeconds = this.downloadUrlTtlSeconds;
140+
const signedAt = Date.now();
141+
142+
return fromPromise(
143+
this.client.send(new HeadObjectCommand({ Bucket: bucket, Key: key })),
144+
(error) => error
145+
)
146+
.mapErr((error) => {
147+
const status = httpStatusOf(error);
148+
// 403 is also what S3 answers for a missing key without s3:ListBucket
149+
if (status === 403) {
150+
logger.warn("Artifact HEAD returned 403; treating as missing", { key });
151+
}
152+
return status === 404 || status === 403
153+
? { type: "artifact_not_found" as const }
154+
: { type: "failed_to_check_artifact" as const, cause: error };
155+
})
156+
.andThen(() =>
157+
fromPromise(
158+
getSignedUrl(this.client, new GetObjectCommand({ Bucket: bucket, Key: key }), {
159+
expiresIn: ttlSeconds,
160+
}),
161+
(error) => ({
162+
type: "failed_to_create_download_url" as const,
163+
cause: error,
164+
})
165+
)
166+
)
167+
.map((url) => ({ url, expiresAt: new Date(signedAt + ttlSeconds * 1000) }));
168+
}
169+
66170
private createPresignedPost(key: string, sizeLimit: number, contentLength?: number) {
67171
if (!this.bucket) {
68172
return errAsync({
@@ -74,7 +178,7 @@ export class ArtifactsService extends BaseService {
74178
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
75179

76180
return fromPromise(
77-
createPresignedPost(objectStoreClient, {
181+
createPresignedPost(this.client, {
78182
Bucket: this.bucket,
79183
Key: key,
80184
Conditions: [["content-length-range", 0, sizeLimit]],
@@ -93,3 +197,7 @@ export class ArtifactsService extends BaseService {
93197
}));
94198
}
95199
}
200+
201+
function httpStatusOf(error: unknown): number | undefined {
202+
return (error as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
203+
}

0 commit comments

Comments
 (0)