diff --git a/.server-changes/multi-preset-template-creation.md b/.server-changes/multi-preset-template-creation.md new file mode 100644 index 00000000000..fd3ba9576f1 --- /dev/null +++ b/.server-changes/multi-preset-template-creation.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +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. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 52ca0cc776e..ff27168445a 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1,8 +1,42 @@ import { z } from "zod"; +import { MachinePresetName } from "@trigger.dev/core/v3"; import { BoolEnv } from "./utils/boolEnv"; import { isValidDatabaseUrl } from "./utils/db"; import { isValidRegex } from "./utils/regex"; +// Parses a CSV of machine preset names (e.g. "small-1x,small-2x") into a +// non-empty array of MachinePresetName. Used by COMPUTE_TEMPLATE_MACHINE_PRESETS +// and its _REQUIRED variant. Adds zod issues for empty input or unknown names. +const parseMachinePresetCsv = ( + raw: string, + ctx: z.RefinementCtx +): MachinePresetName[] => { + const names = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (names.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "must list at least one machine preset", + }); + return z.NEVER; + } + const out: MachinePresetName[] = []; + for (const name of names) { + const parsed = MachinePresetName.safeParse(name); + if (!parsed.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `unknown machine preset: "${name}"`, + }); + return z.NEVER; + } + out.push(parsed.data); + } + return out; +}; + const GithubAppEnvSchema = z.preprocess( (val) => { const obj = val as any; @@ -342,6 +376,25 @@ const EnvironmentSchema = z COMPUTE_GATEWAY_URL: z.string().optional(), COMPUTE_GATEWAY_AUTH_TOKEN: z.string().optional(), COMPUTE_TEMPLATE_SHADOW_ROLLOUT_PCT: z.string().optional(), + // Comma-separated machine preset names to build boot snapshots for on + // deploy (e.g. "small-1x,small-2x,medium-1x"). Default: "small-1x". + COMPUTE_TEMPLATE_MACHINE_PRESETS: z + .string() + .default("small-1x") + .transform(parseMachinePresetCsv), + // Subset of COMPUTE_TEMPLATE_MACHINE_PRESETS that must succeed for a + // required-mode deploy to be considered successful. Failures of presets + // outside this list are logged but don't fail the deploy. Defaults to the + // full COMPUTE_TEMPLATE_MACHINE_PRESETS list when unset (everything required). + COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED: z + .string() + .optional() + .transform((v, ctx) => + parseMachinePresetCsv( + v ?? process.env.COMPUTE_TEMPLATE_MACHINE_PRESETS ?? "small-1x", + ctx + ) + ), DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"), DEPLOY_TIMEOUT_MS: z.coerce @@ -1461,7 +1514,19 @@ const EnvironmentSchema = z PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS: z.string().optional(), }) .and(GithubAppEnvSchema) - .and(S2EnvSchema); + .and(S2EnvSchema) + .superRefine((env, ctx) => { + const presets = new Set(env.COMPUTE_TEMPLATE_MACHINE_PRESETS); + for (const required of env.COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED) { + if (!presets.has(required)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED"], + message: `"${required}" is not in COMPUTE_TEMPLATE_MACHINE_PRESETS`, + }); + } + } + }); export type Environment = z.infer; export const env = EnvironmentSchema.parse(process.env); diff --git a/apps/webapp/app/v3/services/computeTemplateCreation.server.ts b/apps/webapp/app/v3/services/computeTemplateCreation.server.ts index 37235aa1617..c972952b471 100644 --- a/apps/webapp/app/v3/services/computeTemplateCreation.server.ts +++ b/apps/webapp/app/v3/services/computeTemplateCreation.server.ts @@ -1,4 +1,6 @@ import { ComputeClient, stripImageDigest } from "@internal/compute"; +import type { TemplateCreateResultEntry } from "@internal/compute"; +import { MachinePresetName } from "@trigger.dev/core/v3"; import { machinePresetFromName } from "~/v3/machinePresets.server"; import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; @@ -10,8 +12,16 @@ import { resolveComputeAccess } from "../regionAccess.server"; type TemplateCreationMode = "required" | "shadow" | "skip"; +type ResolvedPreset = { + name: MachinePresetName; + cpu: number; + memory_gb: number; +}; + export class ComputeTemplateCreationService { private client: ComputeClient | undefined; + private presets: ResolvedPreset[]; + private requiredPresets: Set; constructor() { if (env.COMPUTE_GATEWAY_URL) { @@ -21,6 +31,12 @@ export class ComputeTemplateCreationService { timeoutMs: 5 * 60 * 1000, // 5 minutes }); } + + this.presets = env.COMPUTE_TEMPLATE_MACHINE_PRESETS.map((name) => { + const machine = machinePresetFromName(name); + return { name, cpu: machine.cpu, memory_gb: machine.memory }; + }); + this.requiredPresets = new Set(env.COMPUTE_TEMPLATE_MACHINE_PRESETS_REQUIRED); } /** @@ -48,12 +64,12 @@ export class ComputeTemplateCreationService { if (mode === "shadow") { this.createTemplate(options.imageReference, { background: true }) - .then((result) => { - if (!result.success) { + .then((outcome) => { + if (outcome.error) { logger.error("Shadow template creation failed", { id: options.deploymentFriendlyId, imageReference: options.imageReference, - error: result.error, + error: outcome.error, }); } }) @@ -81,31 +97,39 @@ export class ComputeTemplateCreationService { logger.info("Creating compute template (required mode)", { id: options.deploymentFriendlyId, imageReference: options.imageReference, + presets: this.presets.map((p) => p.name), + requiredPresets: [...this.requiredPresets], }); - const result = await this.createTemplate(options.imageReference); + const outcome = await this.createTemplate(options.imageReference); + const failureMessage = this.failureMessageForRequiredMode( + outcome, + options.deploymentFriendlyId, + options.imageReference + ); - if (!result.success) { + if (failureMessage) { logger.error("Compute template creation failed", { id: options.deploymentFriendlyId, imageReference: options.imageReference, - error: result.error, + error: failureMessage, }); const failService = new FailDeploymentService(); await failService.call(options.authenticatedEnv, options.deploymentFriendlyId, { error: { name: "TemplateCreationFailed", - message: `Failed to create compute template: ${result.error}`, + message: `Failed to create compute template: ${failureMessage}`, }, }); - throw new ServiceValidationError(`Compute template creation failed: ${result.error}`); + throw new ServiceValidationError(`Compute template creation failed: ${failureMessage}`); } logger.info("Compute template created", { id: options.deploymentFriendlyId, imageReference: options.imageReference, + results: outcome.results.length, }); } @@ -154,29 +178,104 @@ export class ComputeTemplateCreationService { async createTemplate( imageReference: string, options?: { background?: boolean } - ): Promise<{ success: boolean; error?: string }> { + ): Promise { if (!this.client) { - return { success: false, error: "Compute gateway not configured" }; + return { error: "Compute gateway not configured", results: [] }; } try { - // Templates are resource-agnostic - these values don't affect template content. - const machine = machinePresetFromName("small-1x"); + const machineConfigs = this.presets.map((p) => ({ + cpu: p.cpu, + memory_gb: p.memory_gb, + })); - await this.client.templates.create({ + const response = await this.client.templates.create({ image: stripImageDigest(imageReference), - cpu: machine.cpu, - memory_gb: machine.memory, + machine_configs: machineConfigs, background: options?.background, }); - return { success: true }; + + // Background mode (202 Accepted): no body to inspect. + if (options?.background || !response) { + return { results: [] }; + } + + return { + error: response.error, + results: response.results, + }; } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; logger.error("Failed to create compute template", { imageReference, error: message, }); - return { success: false, error: message }; + return { error: message, results: [] }; + } + } + + // Returns a human-readable failure message if any required preset failed + // or the request itself failed. Optional preset failures are logged and + // do not contribute to the message. Returns undefined on success. + private failureMessageForRequiredMode( + outcome: CreateTemplateOutcome, + deploymentFriendlyId: string, + imageReference: string + ): string | undefined { + if (this.presets.length === 0) { + return undefined; + } + + const failures: string[] = []; + + this.presets.forEach((preset) => { + const isRequired = this.requiredPresets.has(preset.name); + // Match results to presets by (cpu, memory_gb) content with a small + // epsilon to tolerate float round-trip noise (memory_gb passes through + // gb -> mb -> gb conversion in the compute layer). + const result = outcome.results.find( + (r) => + Math.abs(r.machine_config.cpu - preset.cpu) < 1e-9 && + Math.abs(r.machine_config.memory_gb - preset.memory_gb) < 1e-9 + ); + + if (!result) { + if (isRequired) { + failures.push(`${preset.name}: not built`); + } else { + logger.warn("Optional compute template preset not built", { + id: deploymentFriendlyId, + imageReference, + preset: preset.name, + }); + } + return; + } + + if (result.error) { + if (isRequired) { + failures.push(`${preset.name}: ${result.error}`); + } else { + logger.warn("Optional compute template preset failed", { + id: deploymentFriendlyId, + imageReference, + preset: preset.name, + error: result.error, + }); + } + } + }); + + // Surface request-level errors only when no per-preset failure attributed. + if (outcome.error && failures.length === 0) { + failures.push(outcome.error); } + + return failures.length > 0 ? failures.join("; ") : undefined; } } + +type CreateTemplateOutcome = { + error?: string; + results: TemplateCreateResultEntry[]; +}; diff --git a/internal-packages/compute/src/client.ts b/internal-packages/compute/src/client.ts index 4f627bd2830..97585345eae 100644 --- a/internal-packages/compute/src/client.ts +++ b/internal-packages/compute/src/client.ts @@ -1,5 +1,6 @@ import type { TemplateCreateRequest, + TemplateCreateResponse, InstanceCreateRequest, InstanceCreateResponse, InstanceSnapshotRequest, @@ -106,8 +107,10 @@ class TemplatesNamespace { async create( req: TemplateCreateRequest, options?: RequestOptions - ): Promise { - await this.http.post("/api/templates", req, options); + ): Promise { + // Background mode returns 202 with no body; sync/callback mode returns + // the full result. Caller decides whether to inspect. + return this.http.post("/api/templates", req, options); } } diff --git a/internal-packages/compute/src/index.ts b/internal-packages/compute/src/index.ts index 1cec45e6484..573e7348c8e 100644 --- a/internal-packages/compute/src/index.ts +++ b/internal-packages/compute/src/index.ts @@ -2,8 +2,10 @@ export { ComputeClient, ComputeClientError } from "./client.js"; export type { ComputeClientOptions } from "./client.js"; export { stripImageDigest } from "./imageRef.js"; export { + MachineConfigSchema, TemplateCreateRequestSchema, - TemplateCallbackPayloadSchema, + TemplateCreateResultEntrySchema, + TemplateCreateResponseSchema, InstanceCreateRequestSchema, InstanceCreateResponseSchema, InstanceSnapshotRequestSchema, @@ -11,8 +13,10 @@ export { SnapshotCallbackPayloadSchema, } from "./types.js"; export type { + MachineConfig, TemplateCreateRequest, - TemplateCallbackPayload, + TemplateCreateResultEntry, + TemplateCreateResponse, InstanceCreateRequest, InstanceCreateResponse, InstanceSnapshotRequest, diff --git a/internal-packages/compute/src/types.ts b/internal-packages/compute/src/types.ts index a2aa4c97608..cb977f83a72 100644 --- a/internal-packages/compute/src/types.ts +++ b/internal-packages/compute/src/types.ts @@ -2,10 +2,15 @@ import { z } from "zod"; // ── Templates ──────────────────────────────────────────────────────────────── -export const TemplateCreateRequestSchema = z.object({ - image: z.string(), +export const MachineConfigSchema = z.object({ cpu: z.number(), memory_gb: z.number(), +}); +export type MachineConfig = z.infer; + +export const TemplateCreateRequestSchema = z.object({ + image: z.string(), + machine_configs: z.array(MachineConfigSchema), background: z.boolean().optional(), callback: z .object({ @@ -16,15 +21,17 @@ export const TemplateCreateRequestSchema = z.object({ }); export type TemplateCreateRequest = z.infer; -export const TemplateCallbackPayloadSchema = z.object({ - template_id: z.string().optional(), - image: z.string(), - status: z.enum(["completed", "failed"]), +export const TemplateCreateResultEntrySchema = z.object({ + machine_config: MachineConfigSchema, + error: z.string().optional(), +}); +export type TemplateCreateResultEntry = z.infer; + +export const TemplateCreateResponseSchema = z.object({ + results: z.array(TemplateCreateResultEntrySchema), error: z.string().optional(), - metadata: z.record(z.string()).optional(), - duration_ms: z.number().optional(), }); -export type TemplateCallbackPayload = z.infer; +export type TemplateCreateResponse = z.infer; // ── Instances ────────────────────────────────────────────────────────────────