Skip to content

Commit a3c3595

Browse files
committed
feat(webapp): configurable deploy template presets via env (multi-preset prep)
1 parent e2b9e0f commit a3c3595

6 files changed

Lines changed: 267 additions & 21 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: 53 additions & 0 deletions
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

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

Lines changed: 132 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,24 @@ 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+
40+
// Sanity-check that required is a subset of the configured presets.
41+
// env-layer parsing already validated each name; this catches the cross-
42+
// field invariant that env-level transforms can't easily express.
43+
const presetNames = new Set(this.presets.map((p) => p.name));
44+
for (const required of env.COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED) {
45+
if (!presetNames.has(required)) {
46+
throw new Error(
47+
`COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED includes "${required}" which is not in COMPUTE_TEMPLATE_MACHINE_PRESETS`
48+
);
49+
}
50+
}
51+
this.requiredPresets = new Set(env.COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED);
2452
}
2553

2654
/**
@@ -48,12 +76,12 @@ export class ComputeTemplateCreationService {
4876

4977
if (mode === "shadow") {
5078
this.createTemplate(options.imageReference, { background: true })
51-
.then((result) => {
52-
if (!result.success) {
79+
.then((outcome) => {
80+
if (outcome.error) {
5381
logger.error("Shadow template creation failed", {
5482
id: options.deploymentFriendlyId,
5583
imageReference: options.imageReference,
56-
error: result.error,
84+
error: outcome.error,
5785
});
5886
}
5987
})
@@ -81,31 +109,39 @@ export class ComputeTemplateCreationService {
81109
logger.info("Creating compute template (required mode)", {
82110
id: options.deploymentFriendlyId,
83111
imageReference: options.imageReference,
112+
presets: this.presets.map((p) => p.name),
113+
requiredPresets: [...this.requiredPresets],
84114
});
85115

86-
const result = await this.createTemplate(options.imageReference);
116+
const outcome = await this.createTemplate(options.imageReference);
117+
const failureMessage = this.failureMessageForRequiredMode(
118+
outcome,
119+
options.deploymentFriendlyId,
120+
options.imageReference
121+
);
87122

88-
if (!result.success) {
123+
if (failureMessage) {
89124
logger.error("Compute template creation failed", {
90125
id: options.deploymentFriendlyId,
91126
imageReference: options.imageReference,
92-
error: result.error,
127+
error: failureMessage,
93128
});
94129

95130
const failService = new FailDeploymentService();
96131
await failService.call(options.authenticatedEnv, options.deploymentFriendlyId, {
97132
error: {
98133
name: "TemplateCreationFailed",
99-
message: `Failed to create compute template: ${result.error}`,
134+
message: `Failed to create compute template: ${failureMessage}`,
100135
},
101136
});
102137

103-
throw new ServiceValidationError(`Compute template creation failed: ${result.error}`);
138+
throw new ServiceValidationError(`Compute template creation failed: ${failureMessage}`);
104139
}
105140

106141
logger.info("Compute template created", {
107142
id: options.deploymentFriendlyId,
108143
imageReference: options.imageReference,
144+
results: outcome.results.length,
109145
});
110146
}
111147

@@ -154,29 +190,108 @@ export class ComputeTemplateCreationService {
154190
async createTemplate(
155191
imageReference: string,
156192
options?: { background?: boolean }
157-
): Promise<{ success: boolean; error?: string }> {
193+
): Promise<CreateTemplateOutcome> {
158194
if (!this.client) {
159-
return { success: false, error: "Compute gateway not configured" };
195+
return { error: "Compute gateway not configured", results: [] };
160196
}
161197

162198
try {
163-
// Templates are resource-agnostic - these values don't affect template content.
164-
const machine = machinePresetFromName("small-1x");
199+
const machineConfigs = this.presets.map((p) => ({
200+
cpu: p.cpu,
201+
memory_gb: p.memory_gb,
202+
}));
165203

166-
await this.client.templates.create({
204+
const response = await this.client.templates.create({
167205
image: stripImageDigest(imageReference),
168-
cpu: machine.cpu,
169-
memory_gb: machine.memory,
206+
machine_configs: machineConfigs,
170207
background: options?.background,
171208
});
172-
return { success: true };
209+
210+
// Background mode (202 Accepted): no body to inspect.
211+
if (options?.background || !response) {
212+
return { results: [] };
213+
}
214+
215+
return {
216+
error: response.error,
217+
results: response.results,
218+
};
173219
} catch (error) {
174220
const message = error instanceof Error ? error.message : "Unknown error";
175221
logger.error("Failed to create compute template", {
176222
imageReference,
177223
error: message,
178224
});
179-
return { success: false, error: message };
225+
return { error: message, results: [] };
226+
}
227+
}
228+
229+
// Returns a human-readable failure message if any required preset failed
230+
// or the request itself failed. Optional preset failures are logged and
231+
// do not contribute to the message. Returns undefined on success.
232+
//
233+
// Result-to-preset matching is by index: response.results[i] corresponds
234+
// to this.presets[i]. While the multi-config compute path is being built
235+
// out, the gateway may return fewer entries than presets requested - any
236+
// unbuilt required preset is still treated as a failure.
237+
private failureMessageForRequiredMode(
238+
outcome: CreateTemplateOutcome,
239+
deploymentFriendlyId: string,
240+
imageReference: string
241+
): string | undefined {
242+
if (this.presets.length === 0) {
243+
return undefined;
180244
}
245+
246+
const failures: string[] = [];
247+
248+
this.presets.forEach((preset, i) => {
249+
const isRequired = this.requiredPresets.has(preset.name);
250+
const result = outcome.results[i];
251+
252+
if (!result) {
253+
if (isRequired) {
254+
failures.push(`${preset.name}: not built`);
255+
} else {
256+
logger.warn("Optional compute template preset not built", {
257+
id: deploymentFriendlyId,
258+
imageReference,
259+
preset: preset.name,
260+
});
261+
}
262+
return;
263+
}
264+
265+
if (result.error) {
266+
if (isRequired) {
267+
failures.push(`${preset.name}: ${result.error}`);
268+
} else {
269+
logger.warn("Optional compute template preset failed", {
270+
id: deploymentFriendlyId,
271+
imageReference,
272+
preset: preset.name,
273+
error: result.error,
274+
});
275+
}
276+
}
277+
});
278+
279+
// Top-level error not tied to a specific preset (e.g. gateway unreachable,
280+
// image pull failed). Surface it if no per-preset failure was attributed.
281+
if (outcome.error && failures.length === 0) {
282+
failures.push(outcome.error);
283+
}
284+
285+
return failures.length > 0 ? failures.join("; ") : undefined;
181286
}
182287
}
288+
289+
type CreateTemplateOutcome = {
290+
// Top-level error - request didn't reach gateway, image pull/squashfs
291+
// failed, or any per-result failure that was promoted to a request-level
292+
// failure by the gateway. Empty if all succeeded.
293+
error?: string;
294+
// Per-preset results in input order. Empty for background mode or when
295+
// the request didn't reach the gateway.
296+
results: TemplateCreateResultEntry[];
297+
};

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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ export { ComputeClient, ComputeClientError } from "./client.js";
22
export type { ComputeClientOptions } from "./client.js";
33
export { stripImageDigest } from "./imageRef.js";
44
export {
5+
MachineConfigSchema,
6+
BootSnapshotInfoSchema,
57
TemplateCreateRequestSchema,
8+
TemplateCreateResultEntrySchema,
9+
TemplateCreateResponseSchema,
610
TemplateCallbackPayloadSchema,
711
InstanceCreateRequestSchema,
812
InstanceCreateResponseSchema,
@@ -11,7 +15,11 @@ export {
1115
SnapshotCallbackPayloadSchema,
1216
} from "./types.js";
1317
export type {
18+
MachineConfig,
19+
BootSnapshotInfo,
1420
TemplateCreateRequest,
21+
TemplateCreateResultEntry,
22+
TemplateCreateResponse,
1523
TemplateCallbackPayload,
1624
InstanceCreateRequest,
1725
InstanceCreateResponse,

0 commit comments

Comments
 (0)