Skip to content

Commit 4ed2c68

Browse files
1stvampTrigger.dev RepoOps
authored andcommitted
feat(webapp): admin route to request template builds for deployments
Add an admin route to request compute template builds for existing deployments. Mono-RevId: adae12bcc253c0e7a19eee851da1638977b74052
1 parent 92d0b4b commit 4ed2c68

3 files changed

Lines changed: 241 additions & 3 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
2+
import { json } from "@remix-run/server-runtime";
3+
import { tryCatch } from "@trigger.dev/core/v3";
4+
import { z } from "zod";
5+
import { logger } from "~/services/logger.server";
6+
import { requireAdminApiRequest } from "~/services/personalAccessToken.server";
7+
import { TemplatePrebuildService } from "~/v3/services/templatePrebuild.server";
8+
9+
const BodySchema = z.object({
10+
// Deployment ids or friendly ids.
11+
deploymentIds: z.array(z.string().min(1)).min(1).max(50),
12+
force: z.boolean().default(false),
13+
});
14+
15+
export async function action({ request }: ActionFunctionArgs) {
16+
await requireAdminApiRequest(request);
17+
18+
if (request.method.toUpperCase() !== "POST") {
19+
return json({ error: "Method Not Allowed" }, { status: 405 });
20+
}
21+
22+
const [bodyError, body] = await tryCatch(request.json());
23+
if (bodyError) {
24+
return json({ error: bodyError.message }, { status: 400 });
25+
}
26+
27+
const parsedBody = BodySchema.safeParse(body);
28+
if (!parsedBody.success) {
29+
return json({ error: parsedBody.error.message }, { status: 400 });
30+
}
31+
32+
const deploymentIds = [...new Set(parsedBody.data.deploymentIds)];
33+
const results = await new TemplatePrebuildService().requestBuilds({
34+
deploymentIds,
35+
force: parsedBody.data.force,
36+
});
37+
38+
logger.info("Admin template builds requested", {
39+
count: deploymentIds.length,
40+
force: parsedBody.data.force,
41+
results,
42+
});
43+
44+
return json({ results });
45+
}

‎apps/webapp/app/v3/services/templatePrebuild.server.test.ts‎

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,12 @@ async function createProject(
9595
async function createDeployment(
9696
prisma: any,
9797
project: { id: string; organizationId: string },
98-
options: { status?: "DEPLOYING" | "DEPLOYED"; imageReference?: string; templateKey?: string } = {}
98+
options: {
99+
status?: "DEPLOYING" | "DEPLOYED";
100+
imageReference?: string;
101+
templateKey?: string;
102+
deployedAt?: Date;
103+
} = {}
99104
) {
100105
const id = randomUUID();
101106
const environment = await prisma.runtimeEnvironment.create({
@@ -120,6 +125,7 @@ async function createDeployment(
120125
status: options.status ?? "DEPLOYING",
121126
imageReference: options.imageReference ?? IMAGE,
122127
templateKey: options.templateKey,
128+
deployedAt: options.deployedAt,
123129
},
124130
});
125131
}
@@ -667,3 +673,119 @@ describe("prebuild", () => {
667673
).resolves.toEqual({ mode: "skip" });
668674
});
669675
});
676+
677+
describe("requestBuilds", () => {
678+
const PINNED = `${IMAGE}@${DIGEST}`;
679+
680+
postgresTest(
681+
"builds deployments by id or friendly id and reports each outcome",
682+
async ({ prisma }) => {
683+
const server = await builder(reply(200, { template_id: TEMPLATE_KEY }));
684+
try {
685+
const project = await createProject(prisma);
686+
const byId = await createDeployment(prisma, project, {
687+
status: "DEPLOYED",
688+
imageReference: PINNED,
689+
});
690+
const old = await createDeployment(prisma, project, {
691+
status: "DEPLOYED",
692+
imageReference: PINNED,
693+
deployedAt: new Date("2025-01-01T00:00:00Z"),
694+
});
695+
const keyed = await createDeployment(prisma, project, {
696+
status: "DEPLOYED",
697+
imageReference: PINNED,
698+
templateKey: "b".repeat(64),
699+
});
700+
const unpinned = await createDeployment(prisma, project, { status: "DEPLOYED" });
701+
const service = new TemplatePrebuildService(prisma, config({ url: server.url }));
702+
703+
const results = await service.requestBuilds({
704+
deploymentIds: [byId.id, old.friendlyId, keyed.id, unpinned.id, "deployment_missing"],
705+
});
706+
707+
expect(results).toEqual([
708+
{ deploymentId: byId.id, friendlyId: byId.friendlyId, outcome: "built" },
709+
{ deploymentId: old.friendlyId, friendlyId: old.friendlyId, outcome: "built" },
710+
{
711+
deploymentId: keyed.id,
712+
friendlyId: keyed.friendlyId,
713+
outcome: "skipped",
714+
reason: "has_key",
715+
},
716+
{
717+
deploymentId: unpinned.id,
718+
friendlyId: unpinned.friendlyId,
719+
outcome: "skipped",
720+
reason: "no_pinned_image",
721+
},
722+
{ deploymentId: "deployment_missing", outcome: "not_found" },
723+
]);
724+
expect(server.requests).toHaveLength(2);
725+
expect(JSON.parse(server.requests[0].body).image).toBe(PINNED);
726+
expect(await storedTemplateKey(prisma, byId.id)).toBe(TEMPLATE_KEY);
727+
expect(await storedTemplateKey(prisma, old.id)).toBe(TEMPLATE_KEY);
728+
expect(await storedTemplateKey(prisma, keyed.id)).toBe("b".repeat(64));
729+
} finally {
730+
await server.close();
731+
}
732+
}
733+
);
734+
735+
postgresTest("force rebuilds a deployment that has a key", async ({ prisma }) => {
736+
const server = await builder(reply(200, { template_id: TEMPLATE_KEY }));
737+
try {
738+
const project = await createProject(prisma);
739+
const keyed = await createDeployment(prisma, project, {
740+
status: "DEPLOYED",
741+
imageReference: PINNED,
742+
templateKey: "b".repeat(64),
743+
});
744+
const service = new TemplatePrebuildService(prisma, config({ url: server.url }));
745+
746+
const [result] = await service.requestBuilds({ deploymentIds: [keyed.id], force: true });
747+
748+
expect(result).toMatchObject({ outcome: "built" });
749+
expect(await storedTemplateKey(prisma, keyed.id)).toBe(TEMPLATE_KEY);
750+
} finally {
751+
await server.close();
752+
}
753+
});
754+
755+
postgresTest("reports a 429 from the builder as rate_limited", async ({ prisma }) => {
756+
const server = await builder(reply(429, { error: "slow down" }));
757+
try {
758+
const project = await createProject(prisma);
759+
const row = await createDeployment(prisma, project, {
760+
status: "DEPLOYED",
761+
imageReference: PINNED,
762+
});
763+
const service = new TemplatePrebuildService(prisma, config({ url: server.url }));
764+
765+
const [result] = await service.requestBuilds({ deploymentIds: [row.id] });
766+
767+
expect(result).toMatchObject({ outcome: "rate_limited" });
768+
expect(await storedTemplateKey(prisma, row.id)).toBeNull();
769+
} finally {
770+
await server.close();
771+
}
772+
});
773+
774+
postgresTest("skips every deployment when no builder is configured", async ({ prisma }) => {
775+
const project = await createProject(prisma);
776+
const row = await createDeployment(prisma, project, {
777+
status: "DEPLOYED",
778+
imageReference: PINNED,
779+
});
780+
const service = new TemplatePrebuildService(prisma, config({ url: undefined }));
781+
782+
await expect(service.requestBuilds({ deploymentIds: [row.id] })).resolves.toEqual([
783+
{
784+
deploymentId: row.id,
785+
friendlyId: row.friendlyId,
786+
outcome: "skipped",
787+
reason: "no_builder",
788+
},
789+
]);
790+
});
791+
});

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

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,25 @@ export type TemplatePrebuildReason =
2626
| "rollout"
2727
| "none";
2828

29-
export type TemplateBuildOutcome = "built" | "already_there" | "failed" | "timeout";
29+
export type TemplateBuildOutcome =
30+
| "built"
31+
| "already_there"
32+
| "failed"
33+
| "timeout"
34+
| "rate_limited";
35+
36+
type TemplateBuildRequestSkip = "no_builder" | "has_key" | "no_pinned_image";
37+
38+
export type TemplateBuildRequestResult = {
39+
deploymentId: string;
40+
friendlyId?: string;
41+
} & (
42+
| { outcome: TemplateBuildOutcome }
43+
| { outcome: "skipped"; reason: TemplateBuildRequestSkip }
44+
| { outcome: "not_found" }
45+
);
46+
47+
const BUILD_REQUEST_CONCURRENCY = 10;
3048

3149
export type TemplateBuilderConfig = {
3250
url?: string;
@@ -257,6 +275,59 @@ export class TemplatePrebuildService {
257275
return "none";
258276
}
259277

278+
/**
279+
* Requests a shadow build for each deployment, by id or friendly id, whatever its age. A
280+
* deployment that has a key is skipped unless `force` is set. The store guard still applies.
281+
*/
282+
async requestBuilds({
283+
deploymentIds,
284+
force = false,
285+
}: {
286+
deploymentIds: string[];
287+
force?: boolean;
288+
}): Promise<TemplateBuildRequestResult[]> {
289+
const results: TemplateBuildRequestResult[] = [];
290+
for (let i = 0; i < deploymentIds.length; i += BUILD_REQUEST_CONCURRENCY) {
291+
const batch = deploymentIds.slice(i, i + BUILD_REQUEST_CONCURRENCY);
292+
results.push(
293+
...(await Promise.all(batch.map((deploymentId) => this.requestBuild(deploymentId, force))))
294+
);
295+
}
296+
return results;
297+
}
298+
299+
private async requestBuild(
300+
deploymentId: string,
301+
force: boolean
302+
): Promise<TemplateBuildRequestResult> {
303+
const deployment = await this.prisma.workerDeployment.findFirst({
304+
where: { OR: [{ id: deploymentId }, { friendlyId: deploymentId }] },
305+
select: { id: true, friendlyId: true, imageReference: true, templateKey: true },
306+
});
307+
if (!deployment) {
308+
return { deploymentId, outcome: "not_found" };
309+
}
310+
const base = { deploymentId, friendlyId: deployment.friendlyId };
311+
if (!this.config.url) {
312+
return { ...base, outcome: "skipped", reason: "no_builder" };
313+
}
314+
if (deployment.templateKey && !force) {
315+
return { ...base, outcome: "skipped", reason: "has_key" };
316+
}
317+
// Finalisation stores the image pinned by digest; without one there is nothing to build.
318+
const image = deployment.imageReference
319+
? pinnedImageReference(deployment.imageReference, undefined)
320+
: undefined;
321+
if (!image) {
322+
return { ...base, outcome: "skipped", reason: "no_pinned_image" };
323+
}
324+
const outcome = await this.build(image, "shadow", {
325+
id: deployment.id,
326+
friendlyId: deployment.friendlyId,
327+
});
328+
return { ...base, outcome };
329+
}
330+
260331
/**
261332
* Call the builder once and store the template key on success. Resolves with
262333
* the outcome; never rejects.
@@ -303,7 +374,7 @@ export class TemplatePrebuildService {
303374
this.config.timeoutMs - (performance.now() - startedAt)
304375
);
305376
} else {
306-
outcome = "failed";
377+
outcome = response.status === 429 ? "rate_limited" : "failed";
307378
logger.error("Template prebuild failed", {
308379
...log,
309380
status: response.status,

0 commit comments

Comments
 (0)