Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/multi-preset-template-creation.md
Original file line number Diff line number Diff line change
@@ -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.
67 changes: 66 additions & 1 deletion apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

const GithubAppEnvSchema = z.preprocess(
(val) => {
const obj = val as any;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
133 changes: 116 additions & 17 deletions apps/webapp/app/v3/services/computeTemplateCreation.server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) {
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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,
});
}
})
Expand Down Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -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[];
};
7 changes: 5 additions & 2 deletions internal-packages/compute/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
TemplateCreateRequest,
TemplateCreateResponse,
InstanceCreateRequest,
InstanceCreateResponse,
InstanceSnapshotRequest,
Expand Down Expand Up @@ -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);
}
}

Expand Down
8 changes: 6 additions & 2 deletions internal-packages/compute/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@ 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,
SnapshotRestoreRequestSchema,
SnapshotCallbackPayloadSchema,
} from "./types.js";
export type {
MachineConfig,
TemplateCreateRequest,
TemplateCallbackPayload,
TemplateCreateResultEntry,
TemplateCreateResponse,
InstanceCreateRequest,
InstanceCreateResponse,
InstanceSnapshotRequest,
Expand Down
25 changes: 16 additions & 9 deletions internal-packages/compute/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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(),
});
export type TemplateCreateResultEntry = z.infer<typeof TemplateCreateResultEntrySchema>;

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<typeof TemplateCallbackPayloadSchema>;
export type TemplateCreateResponse = z.infer<typeof TemplateCreateResponseSchema>;

// ── Instances ────────────────────────────────────────────────────────────────

Expand Down
Loading