import { parentPort } from 'node:worker_threads'; import { posix } from 'node:path'; import { applyActivationFiles } from './activation-files.js'; import { db } from '../db/index.js'; import { relDestForKind } from '../config.js'; import { captureRecipe, recipeForDeployment, summarizeRecipe, type LaunchRecipe } from '../fleet/launch-recipes.js'; import { sha256Buffer } from '../fleet/files.js'; import type { NativeLaunchInput } from './launch-preparation.js'; import { choosePlacement, type PlacementOptions } from '../hosts/capacity.js'; import { isPrivateConfigPath } from '../fleet/private-config.js'; parentPort!.on( 'message', ( message: | { id: number; kind: 'launch'; input: NativeLaunchInput } | { id: number; kind: 'placement'; input: PlacementOptions } ) => { const { id } = message; try { if (message.kind === 'placement') { const decision = db().transaction(() => choosePlacement(message.input))(); parentPort!.postMessage({ id, value: { hostId: decision.host.id, lastHeartbeat: decision.host.lastHeartbeat } }); return; } const input = message.input; // All file versions and composed configuration belong to one snapshot. const { recipe, password } = db().transaction(() => { if (!input.frozenRecipeId) { const recipe = { ...captureRecipe(input.game, input.mapSlug, input.rconPassword), native: input.native, image: input.instance.image }; if (input.activationId) applyActivationFiles(recipe, input.activationId, true); return { recipe, password: input.rconPassword }; } const stored = recipeForDeployment(input.frozenRecipeId)?.recipe; const migration = db() .prepare( 'SELECT runtime_password FROM ec_host_migrations WHERE deployment_id=?' ) .get(input.frozenRecipeId) as { runtime_password: string | null } | undefined; if ( !stored || !migration?.runtime_password || stored.files.some((file) => file.content === undefined) ) { throw new Error( 'The preserved deployed recipe or runtime credentials are unavailable.' ); } for (const file of stored.files) { const bytes = Buffer.from(file.content!, 'base64'); if (bytes.length !== file.size || sha256Buffer(bytes) !== file.sha256) throw new Error('Preserved deployed file verification failed.'); } return { recipe: { ...stored, native: input.native, image: input.instance.image } as LaunchRecipe, password: migration.runtime_password }; })(); const instance = input.instance; // Credential files travel only in this host's private operation payload, never // shared recipes, promoted files, or the content-addressed download store. const bankPath = posix.join( relDestForKind('config', recipe.game), 'electric_cherry_bank.cfg' ); if ( recipe.files.some((file) => isPrivateConfigPath( posix.join(relDestForKind(file.kind, recipe.game), file.path), recipe.game ) ) ) throw new Error( 'Private configuration files are reserved for instance credentials.' ); const payload = { ...(input.activationId ? { stageId: input.activationId } : {}), name: instance.server, game: recipe.game, libraryId: instance.library_id, image: instance.image, port: instance.port, bindAddress: instance.bind_address, rconPassword: password, bank: { token: input.bankToken, configPath: bankPath }, ...(input.moderation ? { moderation: input.moderation } : {}), env: { PLUTAINER_SERVER_NAME: instance.server, PLUTAINER_PORT: String(instance.port), PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp', PLUTAINER_CONFIG_FILE: recipe.configFile, PLUTAINER_MAP_ROTATE: 'false', PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +exec electric_cherry_bank.cfg${input.moderation ? ' +exec electric_cherry_moderation.cfg' : ''} +set tws_server_id ${instance.server} +map_rotate`, PLUTO_SERVER_KEY: input.serverKey }, files: recipe.files.map((file) => ({ path: posix.join(relDestForKind(file.kind, recipe.game), file.path), contentSha256: file.sha256, sha256: file.sha256 })), ...(input.rebuild ? { nextGeneration: instance.generation + 1, ...(input.safetyOverride ? { safetyOverride: true } : {}), ...(input.unhealthyRecovery ? { unhealthyRecovery: true } : {}) } : {}) }; const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)); const blobs = [ ...new Map( recipe.files.map((file) => [ file.sha256, { sha256: file.sha256, content: new Uint8Array(Buffer.from(file.content, 'base64')) } ]) ).values() ]; const storedRecipe = { ...recipe, files: recipe.files.map(({ content, ...file }) => ({ ...file, contentHash: file.sha256 })) }; const recipeJson = encode(storedRecipe); const payloadJson = encode(payload); const plan = { ...recipe, files: recipe.files.map(({ content: _content, ...file }) => file) }; const recipeSummary = JSON.stringify( summarizeRecipe(plan, sha256Buffer(Buffer.from(JSON.stringify(recipe)))) ); // Transfer ownership of the encoded bytes; do not clone giant strings or objects. parentPort!.postMessage( { id, value: { recipe: plan, recipeSummary, recipeJson, payloadJson, blobs } }, [recipeJson.buffer, payloadJson.buffer, ...blobs.map((blob) => blob.content.buffer)] ); } catch (error) { parentPort!.postMessage({ id, error: error instanceof Error ? error.message : 'Launch preparation failed.' }); } } );