Skip to content

Commit ee3887a

Browse files
authored
feat(webapp): configurable deploy template machine presets (#3492)
The webapp's compute template creation hardcoded a single machine preset (`small-1x`) at deploy time, regardless of which presets a project actually uses. Tasks running on any other preset paid full cold-snapshot creation cost on first run. Two new env vars: - `COMPUTE_TEMPLATE_MACHINE_PRESETS` - CSV of preset names to build boot snapshots for during deploy. Defaults to `small-1x` so existing deploys don't change behavior. - `COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED` - CSV of presets whose failure fails a required-mode deploy. Defaults to the full `PRESETS` list. Optional preset failures are logged but don't block the deploy. The compute client now sends the multi-config request shape; the service evaluates per-preset outcomes against the required set and surfaces a combined failure message when a required preset fails. Both env vars are validated at boot via the env schema - unknown preset names or `_REQUIRED` entries that aren't a subset of `_PRESETS` fail loudly at startup rather than silently per-deploy.
1 parent 39baea8 commit ee3887a

6 files changed

Lines changed: 215 additions & 31 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Configure the set of machine presets to build boot snapshots for at deploy time via `COMPUTE_TEMPLATE_MACHINE_PRESETS` (CSV of preset names, default `small-1x`). Use `COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED` (CSV, default = full PRESETS list) to scope which preset failures fail a required-mode deploy. Optional preset failures are logged and don't block the deploy.

apps/webapp/app/env.server.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,42 @@
11
import { z } from "zod";
2+
import { MachinePresetName } from "@trigger.dev/core/v3";
23
import { BoolEnv } from "./utils/boolEnv";
34
import { isValidDatabaseUrl } from "./utils/db";
45
import { isValidRegex } from "./utils/regex";
56

7+
// Parses a CSV of machine preset names (e.g. "small-1x,small-2x") into a
8+
// non-empty array of MachinePresetName. Used by COMPUTE_TEMPLATE_MACHINE_PRESETS
9+
// and its _REQUIRED variant. Adds zod issues for empty input or unknown names.
10+
const parseMachinePresetCsv = (
11+
raw: string,
12+
ctx: z.RefinementCtx
13+
): MachinePresetName[] => {
14+
const names = raw
15+
.split(",")
16+
.map((s) => s.trim())
17+
.filter(Boolean);
18+
if (names.length === 0) {
19+
ctx.addIssue({
20+
code: z.ZodIssueCode.custom,
21+
message: "must list at least one machine preset",
22+
});
23+
return z.NEVER;
24+
}
25+
const out: MachinePresetName[] = [];
26+
for (const name of names) {
27+
const parsed = MachinePresetName.safeParse(name);
28+
if (!parsed.success) {
29+
ctx.addIssue({
30+
code: z.ZodIssueCode.custom,
31+
message: `unknown machine preset: "${name}"`,
32+
});
33+
return z.NEVER;
34+
}
35+
out.push(parsed.data);
36+
}
37+
return out;
38+
};
39+
640
const GithubAppEnvSchema = z.preprocess(
741
(val) => {
842
const obj = val as any;
@@ -342,6 +376,25 @@ const EnvironmentSchema = z
342376
COMPUTE_GATEWAY_URL: z.string().optional(),
343377
COMPUTE_GATEWAY_AUTH_TOKEN: z.string().optional(),
344378
COMPUTE_TEMPLATE_SHADOW_ROLLOUT_PCT: z.string().optional(),
379+
// Comma-separated machine preset names to build boot snapshots for on
380+
// deploy (e.g. "small-1x,small-2x,medium-1x"). Default: "small-1x".
381+
COMPUTE_TEMPLATE_MACHINE_PRESETS: z
382+
.string()
383+
.default("small-1x")
384+
.transform(parseMachinePresetCsv),
385+
// Subset of COMPUTE_TEMPLATE_MACHINE_PRESETS that must succeed for a
386+
// required-mode deploy to be considered successful. Failures of presets
387+
// outside this list are logged but don't fail the deploy. Defaults to the
388+
// full COMPUTE_TEMPLATE_MACHINE_PRESETS list when unset (everything required).
389+
COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED: z
390+
.string()
391+
.optional()
392+
.transform((v, ctx) =>
393+
parseMachinePresetCsv(
394+
v ?? process.env.COMPUTE_TEMPLATE_MACHINE_PRESETS ?? "small-1x",
395+
ctx
396+
)
397+
),
345398

346399
DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"),
347400
DEPLOY_TIMEOUT_MS: z.coerce
@@ -1461,7 +1514,19 @@ const EnvironmentSchema = z
14611514
PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS: z.string().optional(),
14621515
})
14631516
.and(GithubAppEnvSchema)
1464-
.and(S2EnvSchema);
1517+
.and(S2EnvSchema)
1518+
.superRefine((env, ctx) => {
1519+
const presets = new Set(env.COMPUTE_TEMPLATE_MACHINE_PRESETS);
1520+
for (const required of env.COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED) {
1521+
if (!presets.has(required)) {
1522+
ctx.addIssue({
1523+
code: z.ZodIssueCode.custom,
1524+
path: ["COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED"],
1525+
message: `"${required}" is not in COMPUTE_TEMPLATE_MACHINE_PRESETS`,
1526+
});
1527+
}
1528+
}
1529+
});
14651530

14661531
export type Environment = z.infer<typeof EnvironmentSchema>;
14671532
export const env = EnvironmentSchema.parse(process.env);

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

Lines changed: 116 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { ComputeClient, stripImageDigest } from "@internal/compute";
2+
import type { TemplateCreateResultEntry } from "@internal/compute";
3+
import { MachinePresetName } from "@trigger.dev/core/v3";
24
import { machinePresetFromName } from "~/v3/machinePresets.server";
35
import { env } from "~/env.server";
46
import { logger } from "~/services/logger.server";
@@ -10,8 +12,16 @@ import { resolveComputeAccess } from "../regionAccess.server";
1012

1113
type TemplateCreationMode = "required" | "shadow" | "skip";
1214

15+
type ResolvedPreset = {
16+
name: MachinePresetName;
17+
cpu: number;
18+
memory_gb: number;
19+
};
20+
1321
export class ComputeTemplateCreationService {
1422
private client: ComputeClient | undefined;
23+
private presets: ResolvedPreset[];
24+
private requiredPresets: Set<MachinePresetName>;
1525

1626
constructor() {
1727
if (env.COMPUTE_GATEWAY_URL) {
@@ -21,6 +31,12 @@ export class ComputeTemplateCreationService {
2131
timeoutMs: 5 * 60 * 1000, // 5 minutes
2232
});
2333
}
34+
35+
this.presets = env.COMPUTE_TEMPLATE_MACHINE_PRESETS.map((name) => {
36+
const machine = machinePresetFromName(name);
37+
return { name, cpu: machine.cpu, memory_gb: machine.memory };
38+
});
39+
this.requiredPresets = new Set(env.COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED);
2440
}
2541

2642
/**
@@ -48,12 +64,12 @@ export class ComputeTemplateCreationService {
4864

4965
if (mode === "shadow") {
5066
this.createTemplate(options.imageReference, { background: true })
51-
.then((result) => {
52-
if (!result.success) {
67+
.then((outcome) => {
68+
if (outcome.error) {
5369
logger.error("Shadow template creation failed", {
5470
id: options.deploymentFriendlyId,
5571
imageReference: options.imageReference,
56-
error: result.error,
72+
error: outcome.error,
5773
});
5874
}
5975
})
@@ -81,31 +97,39 @@ export class ComputeTemplateCreationService {
8197
logger.info("Creating compute template (required mode)", {
8298
id: options.deploymentFriendlyId,
8399
imageReference: options.imageReference,
100+
presets: this.presets.map((p) => p.name),
101+
requiredPresets: [...this.requiredPresets],
84102
});
85103

86-
const result = await this.createTemplate(options.imageReference);
104+
const outcome = await this.createTemplate(options.imageReference);
105+
const failureMessage = this.failureMessageForRequiredMode(
106+
outcome,
107+
options.deploymentFriendlyId,
108+
options.imageReference
109+
);
87110

88-
if (!result.success) {
111+
if (failureMessage) {
89112
logger.error("Compute template creation failed", {
90113
id: options.deploymentFriendlyId,
91114
imageReference: options.imageReference,
92-
error: result.error,
115+
error: failureMessage,
93116
});
94117

95118
const failService = new FailDeploymentService();
96119
await failService.call(options.authenticatedEnv, options.deploymentFriendlyId, {
97120
error: {
98121
name: "TemplateCreationFailed",
99-
message: `Failed to create compute template: ${result.error}`,
122+
message: `Failed to create compute template: ${failureMessage}`,
100123
},
101124
});
102125

103-
throw new ServiceValidationError(`Compute template creation failed: ${result.error}`);
126+
throw new ServiceValidationError(`Compute template creation failed: ${failureMessage}`);
104127
}
105128

106129
logger.info("Compute template created", {
107130
id: options.deploymentFriendlyId,
108131
imageReference: options.imageReference,
132+
results: outcome.results.length,
109133
});
110134
}
111135

@@ -154,29 +178,104 @@ export class ComputeTemplateCreationService {
154178
async createTemplate(
155179
imageReference: string,
156180
options?: { background?: boolean }
157-
): Promise<{ success: boolean; error?: string }> {
181+
): Promise<CreateTemplateOutcome> {
158182
if (!this.client) {
159-
return { success: false, error: "Compute gateway not configured" };
183+
return { error: "Compute gateway not configured", results: [] };
160184
}
161185

162186
try {
163-
// Templates are resource-agnostic - these values don't affect template content.
164-
const machine = machinePresetFromName("small-1x");
187+
const machineConfigs = this.presets.map((p) => ({
188+
cpu: p.cpu,
189+
memory_gb: p.memory_gb,
190+
}));
165191

166-
await this.client.templates.create({
192+
const response = await this.client.templates.create({
167193
image: stripImageDigest(imageReference),
168-
cpu: machine.cpu,
169-
memory_gb: machine.memory,
194+
machine_configs: machineConfigs,
170195
background: options?.background,
171196
});
172-
return { success: true };
197+
198+
// Background mode (202 Accepted): no body to inspect.
199+
if (options?.background || !response) {
200+
return { results: [] };
201+
}
202+
203+
return {
204+
error: response.error,
205+
results: response.results,
206+
};
173207
} catch (error) {
174208
const message = error instanceof Error ? error.message : "Unknown error";
175209
logger.error("Failed to create compute template", {
176210
imageReference,
177211
error: message,
178212
});
179-
return { success: false, error: message };
213+
return { error: message, results: [] };
214+
}
215+
}
216+
217+
// Returns a human-readable failure message if any required preset failed
218+
// or the request itself failed. Optional preset failures are logged and
219+
// do not contribute to the message. Returns undefined on success.
220+
private failureMessageForRequiredMode(
221+
outcome: CreateTemplateOutcome,
222+
deploymentFriendlyId: string,
223+
imageReference: string
224+
): string | undefined {
225+
if (this.presets.length === 0) {
226+
return undefined;
227+
}
228+
229+
const failures: string[] = [];
230+
231+
this.presets.forEach((preset) => {
232+
const isRequired = this.requiredPresets.has(preset.name);
233+
// Match results to presets by (cpu, memory_gb) content with a small
234+
// epsilon to tolerate float round-trip noise (memory_gb passes through
235+
// gb -> mb -> gb conversion in the compute layer).
236+
const result = outcome.results.find(
237+
(r) =>
238+
Math.abs(r.machine_config.cpu - preset.cpu) < 1e-9 &&
239+
Math.abs(r.machine_config.memory_gb - preset.memory_gb) < 1e-9
240+
);
241+
242+
if (!result) {
243+
if (isRequired) {
244+
failures.push(`${preset.name}: not built`);
245+
} else {
246+
logger.warn("Optional compute template preset not built", {
247+
id: deploymentFriendlyId,
248+
imageReference,
249+
preset: preset.name,
250+
});
251+
}
252+
return;
253+
}
254+
255+
if (result.error) {
256+
if (isRequired) {
257+
failures.push(`${preset.name}: ${result.error}`);
258+
} else {
259+
logger.warn("Optional compute template preset failed", {
260+
id: deploymentFriendlyId,
261+
imageReference,
262+
preset: preset.name,
263+
error: result.error,
264+
});
265+
}
266+
}
267+
});
268+
269+
// Surface request-level errors only when no per-preset failure attributed.
270+
if (outcome.error && failures.length === 0) {
271+
failures.push(outcome.error);
180272
}
273+
274+
return failures.length > 0 ? failures.join("; ") : undefined;
181275
}
182276
}
277+
278+
type CreateTemplateOutcome = {
279+
error?: string;
280+
results: TemplateCreateResultEntry[];
281+
};

internal-packages/compute/src/client.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
TemplateCreateRequest,
3+
TemplateCreateResponse,
34
InstanceCreateRequest,
45
InstanceCreateResponse,
56
InstanceSnapshotRequest,
@@ -106,8 +107,10 @@ class TemplatesNamespace {
106107
async create(
107108
req: TemplateCreateRequest,
108109
options?: RequestOptions
109-
): Promise<void> {
110-
await this.http.post("/api/templates", req, options);
110+
): Promise<TemplateCreateResponse | undefined> {
111+
// Background mode returns 202 with no body; sync/callback mode returns
112+
// the full result. Caller decides whether to inspect.
113+
return this.http.post<TemplateCreateResponse>("/api/templates", req, options);
111114
}
112115
}
113116

internal-packages/compute/src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@ export { ComputeClient, ComputeClientError } from "./client.js";
22
export type { ComputeClientOptions } from "./client.js";
33
export { stripImageDigest } from "./imageRef.js";
44
export {
5+
MachineConfigSchema,
56
TemplateCreateRequestSchema,
6-
TemplateCallbackPayloadSchema,
7+
TemplateCreateResultEntrySchema,
8+
TemplateCreateResponseSchema,
79
InstanceCreateRequestSchema,
810
InstanceCreateResponseSchema,
911
InstanceSnapshotRequestSchema,
1012
SnapshotRestoreRequestSchema,
1113
SnapshotCallbackPayloadSchema,
1214
} from "./types.js";
1315
export type {
16+
MachineConfig,
1417
TemplateCreateRequest,
15-
TemplateCallbackPayload,
18+
TemplateCreateResultEntry,
19+
TemplateCreateResponse,
1620
InstanceCreateRequest,
1721
InstanceCreateResponse,
1822
InstanceSnapshotRequest,

internal-packages/compute/src/types.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import { z } from "zod";
22

33
// ── Templates ────────────────────────────────────────────────────────────────
44

5-
export const TemplateCreateRequestSchema = z.object({
6-
image: z.string(),
5+
export const MachineConfigSchema = z.object({
76
cpu: z.number(),
87
memory_gb: z.number(),
8+
});
9+
export type MachineConfig = z.infer<typeof MachineConfigSchema>;
10+
11+
export const TemplateCreateRequestSchema = z.object({
12+
image: z.string(),
13+
machine_configs: z.array(MachineConfigSchema),
914
background: z.boolean().optional(),
1015
callback: z
1116
.object({
@@ -16,15 +21,17 @@ export const TemplateCreateRequestSchema = z.object({
1621
});
1722
export type TemplateCreateRequest = z.infer<typeof TemplateCreateRequestSchema>;
1823

19-
export const TemplateCallbackPayloadSchema = z.object({
20-
template_id: z.string().optional(),
21-
image: z.string(),
22-
status: z.enum(["completed", "failed"]),
24+
export const TemplateCreateResultEntrySchema = z.object({
25+
machine_config: MachineConfigSchema,
26+
error: z.string().optional(),
27+
});
28+
export type TemplateCreateResultEntry = z.infer<typeof TemplateCreateResultEntrySchema>;
29+
30+
export const TemplateCreateResponseSchema = z.object({
31+
results: z.array(TemplateCreateResultEntrySchema),
2332
error: z.string().optional(),
24-
metadata: z.record(z.string()).optional(),
25-
duration_ms: z.number().optional(),
2633
});
27-
export type TemplateCallbackPayload = z.infer<typeof TemplateCallbackPayloadSchema>;
34+
export type TemplateCreateResponse = z.infer<typeof TemplateCreateResponseSchema>;
2835

2936
// ── Instances ────────────────────────────────────────────────────────────────
3037

0 commit comments

Comments
 (0)