Skip to content

Commit 255a73a

Browse files
authored
feat(deployments): --native-build-server support for the deploy command (#2702)
This PR adds support for CLI deployments using the native build server. **Background** The deployment command currently does the following: - bundles the code - submits the build context to our external build provider and waits for the build - triggers deployment state transitions using the platform API Upstream build provider outages cause issue with deployments, potentially blocking deployments entirely. We recently introduced the `--force-local-build` flag as a fallback to enable deployment without a dependency on the upstream build provider, though it requires users to have docker in their systems. This PR continues that work by providing a remote build path which uses our own build server and does not rely on the external provider. **Changes in this PR** Introduced the new `--native-build-server` flag, which does the following: - scans all files relevant for the Trigger deployment and evaluates ignore rules - packages it up in an archive and uploads it as a deployment artifact - queues the deployment and triggers the build - streams logs from the build server This no longer relies on external build services. Also deployment state transitions happen on the server-side, giving us more flexibility to evolve the flow and schemas of related deployment API endpoints. In general it gives us better control of the whole build and deployment process. This path will eventually become the default. The `--detach` flag is also new, allowing to trigger deployments without waiting for the result. The deployment artifacts are uploaded via pre-signed URLs to avoid unnecessary load on the platform. The new `/artifacts` endpoint generates the pre-signed URLs; size limits are enforced on s3. This endpoint is deliberately generic, we could extend it in the future to upload other artifacts client-side in a similar way, e.g., large payload packets.
1 parent c5f7a8d commit 255a73a

File tree

32 files changed

+2129
-523
lines changed

32 files changed

+2129
-523
lines changed

.changeset/calm-avocados-yawn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
The `--force-local-build` flag is now renamed to just `--local-build`

.changeset/proud-birds-change.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"trigger.dev": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Added support for native build server builds in the deploy command (`--native-build-server`)

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().optional(),
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),

apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,23 +163,25 @@ export class DeploymentPresenter {
163163
? ExternalBuildData.safeParse(deployment.externalBuildData)
164164
: undefined;
165165

166-
let s2Logs = undefined;
166+
let eventStream = undefined;
167167
if (env.S2_ENABLED === "1" && gitMetadata?.source === "trigger_github_app") {
168168
const [error, accessToken] = await tryCatch(this.getS2AccessToken(project.externalRef));
169169

170170
if (error) {
171171
logger.error("Failed getting S2 access token", { error });
172172
} else {
173-
s2Logs = {
174-
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
175-
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
176-
accessToken,
173+
eventStream = {
174+
s2: {
175+
basin: env.S2_DEPLOYMENT_LOGS_BASIN_NAME,
176+
stream: `projects/${project.externalRef}/deployments/${deployment.shortCode}`,
177+
accessToken,
178+
},
177179
};
178180
}
179181
}
180182

181183
return {
182-
s2Logs,
184+
eventStream,
183185
deployment: {
184186
id: deployment.id,
185187
shortCode: deployment.shortCode,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { cn } from "~/utils/cn";
4040
import { v3DeploymentParams, v3DeploymentsPath, v3RunsPath } from "~/utils/pathBuilder";
4141
import { capitalizeWord } from "~/utils/string";
4242
import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route";
43+
import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas";
4344

4445
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
4546
const userId = await requireUserId(request);
@@ -48,15 +49,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
4849

4950
try {
5051
const presenter = new DeploymentPresenter();
51-
const { deployment, s2Logs } = await presenter.call({
52+
const { deployment, eventStream } = await presenter.call({
5253
userId,
5354
organizationSlug,
5455
projectSlug: projectParam,
5556
environmentSlug: envParam,
5657
deploymentShortCode: deploymentParam,
5758
});
5859

59-
return typedjson({ deployment, s2Logs });
60+
return typedjson({ deployment, eventStream });
6061
} catch (error) {
6162
console.error(error);
6263
throw new Response(undefined, {
@@ -69,18 +70,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
6970
type LogEntry = {
7071
message: string;
7172
timestamp: Date;
72-
level: "info" | "error" | "warn";
73+
level: "info" | "error" | "warn" | "debug";
7374
};
7475

7576
export default function Page() {
76-
const { deployment, s2Logs } = useTypedLoaderData<typeof loader>();
77+
const { deployment, eventStream } = useTypedLoaderData<typeof loader>();
7778
const organization = useOrganization();
7879
const project = useProject();
7980
const environment = useEnvironment();
8081
const location = useLocation();
8182
const page = new URLSearchParams(location.search).get("page");
8283

83-
const logsDisabled = s2Logs === undefined;
84+
const logsDisabled = eventStream === undefined;
8485
const [logs, setLogs] = useState<LogEntry[]>([]);
8586
const [isStreaming, setIsStreaming] = useState(true);
8687
const [streamError, setStreamError] = useState<string | null>(null);
@@ -97,9 +98,9 @@ export default function Page() {
9798

9899
const streamLogs = async () => {
99100
try {
100-
const s2 = new S2({ accessToken: s2Logs.accessToken });
101-
const basin = s2.basin(s2Logs.basin);
102-
const stream = basin.stream(s2Logs.stream);
101+
const s2 = new S2({ accessToken: eventStream.s2.accessToken });
102+
const basin = s2.basin(eventStream.s2.basin);
103+
const stream = basin.stream(eventStream.s2.stream);
103104

104105
const readSession = await stream.readSession(
105106
{
@@ -113,27 +114,49 @@ export default function Page() {
113114
const decoder = new TextDecoder();
114115

115116
for await (const record of readSession) {
116-
try {
117-
const headers: Record<string, string> = {};
118-
119-
if (record.headers) {
120-
for (const [nameBytes, valueBytes] of record.headers) {
121-
headers[decoder.decode(nameBytes)] = decoder.decode(valueBytes);
117+
const decoded = decoder.decode(record.body);
118+
const result = DeploymentEventFromString.safeParse(decoded);
119+
120+
if (!result.success) {
121+
// fallback to the previous format in s2 logs for compatibility
122+
try {
123+
const headers: Record<string, string> = {};
124+
125+
if (record.headers) {
126+
for (const [nameBytes, valueBytes] of record.headers) {
127+
headers[decoder.decode(nameBytes)] = decoder.decode(valueBytes);
128+
}
122129
}
130+
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
131+
132+
setLogs((prevLogs) => [
133+
...prevLogs,
134+
{
135+
timestamp: new Date(record.timestamp),
136+
message: decoded,
137+
level,
138+
},
139+
]);
140+
} catch (err) {
141+
console.error("Failed to parse log record:", err);
123142
}
124-
const level = (headers["level"]?.toLowerCase() as LogEntry["level"]) ?? "info";
125-
126-
setLogs((prevLogs) => [
127-
...prevLogs,
128-
{
129-
timestamp: new Date(record.timestamp),
130-
message: decoder.decode(record.body),
131-
level,
132-
},
133-
]);
134-
} catch (err) {
135-
console.error("Failed to parse log record:", err);
143+
144+
continue;
136145
}
146+
147+
const event = result.data;
148+
if (event.type !== "log") {
149+
continue;
150+
}
151+
152+
setLogs((prevLogs) => [
153+
...prevLogs,
154+
{
155+
timestamp: new Date(record.timestamp),
156+
message: event.data.message,
157+
level: event.data.level,
158+
},
159+
]);
137160
}
138161
} catch (error) {
139162
if (abortController.signal.aborted) return;
@@ -158,7 +181,7 @@ export default function Page() {
158181
return () => {
159182
abortController.abort();
160183
};
161-
}, [s2Logs?.basin, s2Logs?.stream, s2Logs?.accessToken, isPending]);
184+
}, [eventStream?.s2?.basin, eventStream?.s2?.stream, eventStream?.s2?.accessToken, isPending]);
162185

163186
return (
164187
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1248,7 +1248,7 @@ function BuildSettingsForm({ buildSettings }: { buildSettings: BuildSettings })
12481248
/>
12491249
<Hint>
12501250
Native build server builds do not rely on external build providers and will become the
1251-
default in the future. Version 4.1.0 or newer is required.
1251+
default in the future. Version 4.1.3 or newer is required.
12521252
</Hint>
12531253
<FormError id={fields.useNativeBuildServer.errorId}>
12541254
{fields.useNativeBuildServer.error}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
case "artifacts_bucket_not_configured": {
67+
logger.error("Artifacts bucket not configured", { error });
68+
return json({ error: "Internal server error" }, { status: 500 });
69+
}
70+
default: {
71+
error satisfies never;
72+
logger.error("Failed creating artifact", { error });
73+
return json({ error: "Internal server error" }, { status: 500 });
74+
}
75+
}
76+
}
77+
);
78+
}

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
@@ -591,6 +591,31 @@ export async function generateRegistryCredentials(
591591
return result;
592592
}
593593

594+
export async function enqueueBuild(
595+
projectId: string,
596+
deploymentId: string,
597+
artifactKey: string,
598+
options: {
599+
skipPromotion?: boolean;
600+
configFilePath?: string;
601+
}
602+
) {
603+
if (!client) return undefined;
604+
const result = await client.enqueueBuild(projectId, { deploymentId, artifactKey, options });
605+
if (!result.success) {
606+
logger.error("Error enqueuing build", {
607+
error: result.error,
608+
projectId,
609+
deploymentId,
610+
artifactKey,
611+
options,
612+
});
613+
throw new Error("Failed to enqueue build");
614+
}
615+
616+
return result;
617+
}
618+
594619
function isCloud(): boolean {
595620
const acceptableHosts = [
596621
"https://cloud.trigger.dev",

0 commit comments

Comments
 (0)