import { randomUUID } from 'node:crypto'; import { prisma } from '@hexahost/database'; import { assignJoinAddress } from '@hexahost/dns'; import { selectNodeForAllocation } from '@hexahost/scheduler'; import { Job } from 'bullmq'; import { z } from 'zod'; import { releaseServerAllocation } from '../allocation'; import { logger } from '../logger'; import { loadNodeSnapshots } from '../node-snapshots'; import { publishNodeCommand, waitForNodeResponse } from '../redis'; import { transitionServerStatus } from '../server-state'; const PROVISION_TIMEOUT_MS = 5 * 60 * 1000; export const provisionServerJobSchema = z.object({ serverId: z.string().uuid(), }); function buildDataPath(serverId: string): string { return `/var/lib/hgc/servers/${serverId}`; } async function findAvailableHostPort(nodeId: string): Promise { const node = await prisma.gameNode.findUniqueOrThrow({ where: { id: nodeId }, select: { portRangeStart: true, portRangeEnd: true }, }); const [allocations, servers] = await Promise.all([ prisma.gameServerAllocation.findMany({ where: { nodeId, status: 'ACTIVE' }, select: { hostPort: true }, }), prisma.gameServer.findMany({ where: { nodeId, hostPort: { not: null } }, select: { hostPort: true }, }), ]); const usedPorts = new Set(); for (const allocation of allocations) { usedPorts.add(allocation.hostPort); } for (const server of servers) { if (server.hostPort !== null) { usedPorts.add(server.hostPort); } } let port = node.portRangeStart; while (usedPorts.has(port)) { port += 1; if (port > node.portRangeEnd) { throw new Error(`No free host port in range ${node.portRangeStart}-${node.portRangeEnd}`); } } return port; } export async function processProvisionServerJob(job: Job): Promise<{ status: string }> { if (job.name !== 'provision-server') { logger.warn({ jobName: job.name }, 'Unknown provisioning job'); return { status: 'ignored' }; } const { serverId } = provisionServerJobSchema.parse(job.data); const server = await prisma.gameServer.findUnique({ where: { id: serverId }, include: { plan: true }, }); if (!server) { throw new Error(`Server not found: ${serverId}`); } const nodeSnapshots = await loadNodeSnapshots(); const ramMb = server.plan?.maxRamMb ?? server.ramMb; const node = selectNodeForAllocation(nodeSnapshots, ramMb); if (!node) { await transitionServerStatus(serverId, 'ERROR', { reason: 'No online game node available', errorCode: 'NO_NODE_AVAILABLE', errorMessage: 'No online game node is available for provisioning', }); throw new Error('No online game node available'); } const hostPort = await findAvailableHostPort(node.id); const dataPath = buildDataPath(serverId); const messageId = randomUUID(); await prisma.$transaction(async (tx) => { await tx.gameServerAllocation.create({ data: { serverId, nodeId: node.id, hostPort, ramMbReserved: ramMb, status: 'ACTIVE', }, }); await tx.gameNode.update({ where: { id: node.id }, data: { activeServers: { increment: 1 } }, }); await tx.gameServer.update({ where: { id: serverId }, data: { nodeId: node.id, hostPort, ramMb, dataPath, status: 'PROVISIONING', }, }); await tx.gameServerStateTransition.create({ data: { gameServerId: serverId, fromStatus: server.status, toStatus: 'PROVISIONING', reason: 'Worker allocated node resources', correlationId: messageId, }, }); }); await publishNodeCommand({ nodeId: node.id, envelope: { protocolVersion: 1, messageId, type: 'server.provision', timestamp: new Date().toISOString(), payload: { serverId, generation: server.version, edition: server.edition, softwareFamily: server.softwareFamily, minecraftVersion: server.minecraftVersion, hostPort, ramMb, dataPath, eulaAccepted: server.eulaAccepted, }, }, }); const response = await waitForNodeResponse(messageId, PROVISION_TIMEOUT_MS); if (response === null) { await transitionServerStatus(serverId, 'INSTALLING', { reason: 'Provision command sent; awaiting agent completion', correlationId: messageId, }); logger.info({ serverId, messageId }, 'Provision timed out; marked INSTALLING'); return { status: 'installing' }; } if (!response.success) { await releaseServerAllocation(serverId); await transitionServerStatus(serverId, 'ERROR', { reason: 'Provision failed', correlationId: messageId, errorCode: response.errorCode ?? response.resultCode ?? 'PROVISION_FAILED', errorMessage: response.errorMessage ?? 'Server provisioning failed', }); throw new Error(response.errorMessage ?? 'Server provisioning failed'); } await transitionServerStatus(serverId, 'STOPPED', { reason: 'Provision completed', correlationId: messageId, }); await assignJoinAddress(serverId); logger.info({ serverId, nodeId: node.id, hostPort }, 'Server provisioned'); return { status: 'stopped' }; }