import { ComputeClient, stripImageDigest } from "@internal/compute"; import type { TemplateCreateResultEntry } from "@internal/compute"; import type { MachinePresetName } from "@trigger.dev/core/v3"; import { machinePresetFromName } from "~/v3/machinePresets.server"; import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; import type { PrismaClientOrTransaction } from "~/db.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { ServiceValidationError } from "./baseService.server"; import { FailDeploymentService } from "./failDeployment.server"; import { resolveComputeAccess } from "../regionAccess.server"; import { isOrgMigrated } from "~/runEngine/concerns/computeMigration.server"; import { backingForQueue, workerRegionRegistry } from "~/v3/workerRegions.server"; import { globalFlagsRegistry } from "~/v3/globalFlagsRegistry.server"; import { getEntitlement } from "~/services/platform.v3.server"; import { startActiveSpan, attributesFromAuthenticatedEnv } from "~/v3/tracer.server"; type TemplateCreationMode = "required" | "shadow" | "skip"; // Why the mode was chosen — slices the compute.template.create span by path. type TemplateModeReason = | "no-client" | "no-project" | "microvm-native" | "migrated" | "compute-access" | "rollout" | "none"; type ResolvedTemplateMode = { mode: TemplateCreationMode; migrated: boolean; reason: TemplateModeReason; }; 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) { this.client = new ComputeClient({ gatewayUrl: env.COMPUTE_GATEWAY_URL, authToken: env.COMPUTE_GATEWAY_AUTH_TOKEN, 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); } /** * Handle template creation for a deployment. Call this before setting DEPLOYED. * * - Required mode: creates template synchronously, fails deployment on error * - Shadow mode: fires background template creation (returns immediately) * - Skip: no-op * * Throws ServiceValidationError if required mode fails (caller should stop finalize). */ async handleDeployTemplate(options: { projectId: string; imageReference: string; deploymentFriendlyId: string; authenticatedEnv: AuthenticatedEnvironment; prisma: PrismaClientOrTransaction; writer?: WritableStreamDefaultWriter; }): Promise { return startActiveSpan("compute.template.create", async (span) => { const { mode, migrated, reason } = await this.resolveMode(options.projectId, options.prisma); span.setAttributes({ ...attributesFromAuthenticatedEnv(options.authenticatedEnv), "compute.template.mode": mode, "compute.template.migrated": migrated, "compute.template.reason": reason, "compute.template.deployment_id": options.deploymentFriendlyId, "compute.template.presets_total": this.presets.length, "compute.template.presets_required": this.requiredPresets.size, }); if (mode === "skip") { span.setAttribute("compute.template.result", "skipped"); return; } if (mode === "shadow") { // Shadow is fire-and-forget (background build), so the span only records // that it was dispatched — the build outcome lands server-side later. span.setAttribute("compute.template.result", "shadow_dispatched"); this.createTemplate(options.imageReference, { background: true }) .then((outcome) => { if (outcome.error) { logger.error("Shadow template creation failed", { id: options.deploymentFriendlyId, imageReference: options.imageReference, error: outcome.error, }); } }) .catch((error) => { logger.error("Shadow template creation threw unexpectedly", { id: options.deploymentFriendlyId, imageReference: options.imageReference, error: error instanceof Error ? error.message : String(error), }); }); return; } // Required mode if (options.writer) { try { await options.writer.write( `event: log\ndata: ${JSON.stringify({ message: "Building compute template..." })}\n\n` ); } catch { // Stream may be closed if client disconnected - continue with template creation } } logger.info("Creating compute template (required mode)", { id: options.deploymentFriendlyId, imageReference: options.imageReference, presets: this.presets.map((p) => p.name), requiredPresets: [...this.requiredPresets], }); const outcome = await this.createTemplate(options.imageReference); span.setAttribute("compute.template.presets_built", outcome.results.length); const failureMessage = this.failureMessageForRequiredMode( outcome, options.deploymentFriendlyId, options.imageReference ); if (failureMessage) { span.setAttributes({ "compute.template.result": "failed", "compute.template.failure": failureMessage, }); logger.error("Compute template creation failed", { id: options.deploymentFriendlyId, imageReference: options.imageReference, error: failureMessage, }); const failService = new FailDeploymentService(); await failService.call(options.authenticatedEnv, options.deploymentFriendlyId, { error: { name: "TemplateCreationFailed", message: `Failed to create compute template: ${failureMessage}`, }, }); throw new ServiceValidationError(`Compute template creation failed: ${failureMessage}`); } span.setAttribute("compute.template.result", "created"); logger.info("Compute template created", { id: options.deploymentFriendlyId, imageReference: options.imageReference, results: outcome.results.length, }); }); } async resolveMode( projectId: string, prisma: PrismaClientOrTransaction ): Promise { if (!this.client) { return { mode: "skip", migrated: false, reason: "no-client" }; } const project = await prisma.project.findFirst({ where: { id: projectId }, select: { defaultWorkerGroup: { select: { workloadType: true, masterQueue: true }, }, organization: { select: { id: true, featureFlags: true }, }, }, }); if (!project) { return { mode: "skip", migrated: false, reason: "no-project" }; } if (project.defaultWorkerGroup?.workloadType === "MICROVM") { return { mode: "required", migrated: false, reason: "microvm-native" }; } // Migrated orgs route runs to the compute backing even though their stored // default is still the container region, so they need a compute template too. // shadow mode: never fail a deploy over a backing the org didn't opt into. // A cold registry read returns no backing, so this is simply skipped until loaded. const defaultQueue = project.defaultWorkerGroup?.masterQueue; if (defaultQueue && backingForQueue(defaultQueue, workerRegionRegistry.current() ?? [])) { const decision = { orgId: project.organization.id, orgFeatureFlags: project.organization.featureFlags as Record | null, flags: globalFlagsRegistry.current(), }; // Per-org override needs no plan; only the percentage path does. So skip the // external entitlement lookup unless it could matter, and degrade gracefully // if it throws - a shadow-template check must never fail a deploy. let migrated = isOrgMigrated({ ...decision, planType: undefined }); if (!migrated && (decision.flags?.computeMigrationEnabled ?? false)) { let planType: string | undefined; try { planType = (await getEntitlement(project.organization.id))?.plan?.type; } catch (error) { logger.warn("compute migration: entitlement lookup failed; skipping shadow template", { organizationId: project.organization.id, error: error instanceof Error ? error.message : String(error), }); } migrated = isOrgMigrated({ ...decision, planType }); } if (migrated) { // required => template built at deploy (deploy fails on error); off => shadow. return { mode: decision.flags?.computeMigrationRequireTemplate ? "required" : "shadow", migrated: true, reason: "migrated", }; } } const hasComputeAccess = await resolveComputeAccess(prisma, project.organization.featureFlags); if (hasComputeAccess) { return { mode: "shadow", migrated: false, reason: "compute-access" }; } const rolloutPct = Number(env.COMPUTE_TEMPLATE_SHADOW_ROLLOUT_PCT ?? "0"); if (rolloutPct > 0 && Math.random() * 100 < rolloutPct) { return { mode: "shadow", migrated: false, reason: "rollout" }; } return { mode: "skip", migrated: false, reason: "none" }; } async createTemplate( imageReference: string, options?: { background?: boolean } ): Promise { if (!this.client) { return { error: "Compute gateway not configured", results: [] }; } try { const machineConfigs = this.presets.map((p) => ({ cpu: p.cpu, memory_gb: p.memory_gb, })); const response = await this.client.templates.create({ image: stripImageDigest(imageReference), machine_configs: machineConfigs, background: options?.background, }); // 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 { 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[]; };