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.
This commit is contained in:
@@ -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.
|
||||
@@ -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<typeof EnvironmentSchema>;
|
||||
export const env = EnvironmentSchema.parse(process.env);
|
||||
|
||||
@@ -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<MachinePresetName>;
|
||||
|
||||
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<CreateTemplateOutcome> {
|
||||
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[];
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
TemplateCreateRequest,
|
||||
TemplateCreateResponse,
|
||||
InstanceCreateRequest,
|
||||
InstanceCreateResponse,
|
||||
InstanceSnapshotRequest,
|
||||
@@ -106,8 +107,10 @@ class TemplatesNamespace {
|
||||
async create(
|
||||
req: TemplateCreateRequest,
|
||||
options?: RequestOptions
|
||||
): Promise<void> {
|
||||
await this.http.post("/api/templates", req, options);
|
||||
): Promise<TemplateCreateResponse | undefined> {
|
||||
// Background mode returns 202 with no body; sync/callback mode returns
|
||||
// the full result. Caller decides whether to inspect.
|
||||
return this.http.post<TemplateCreateResponse>("/api/templates", req, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof MachineConfigSchema>;
|
||||
|
||||
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<typeof TemplateCreateRequestSchema>;
|
||||
|
||||
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(),
|
||||
metadata: z.record(z.string()).optional(),
|
||||
duration_ms: z.number().optional(),
|
||||
});
|
||||
export type TemplateCallbackPayload = z.infer<typeof TemplateCallbackPayloadSchema>;
|
||||
export type TemplateCreateResultEntry = z.infer<typeof TemplateCreateResultEntrySchema>;
|
||||
|
||||
export const TemplateCreateResponseSchema = z.object({
|
||||
results: z.array(TemplateCreateResultEntrySchema),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type TemplateCreateResponse = z.infer<typeof TemplateCreateResponseSchema>;
|
||||
|
||||
// ── Instances ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user