Files
HexaHost-GameCloud/apps/worker/src/handlers/server-provisioning.ts
smueller b335f6a497
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 17s
Phase8
2026-06-26 13:33:03 +02:00

187 lines
5.2 KiB
TypeScript

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<number> {
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<number>();
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' };
}