Phase2
This commit is contained in:
169
apps/worker/src/handlers/server-provisioning.ts
Normal file
169
apps/worker/src/handlers/server-provisioning.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { prisma } from '@hexahost/database';
|
||||
import { Job } from 'bullmq';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import { publishNodeCommand, waitForNodeResponse } from '../redis';
|
||||
import { transitionServerStatus } from '../server-state';
|
||||
|
||||
const BASE_HOST_PORT = 25565;
|
||||
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 [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 = BASE_HOST_PORT;
|
||||
while (usedPorts.has(port)) {
|
||||
port += 1;
|
||||
}
|
||||
|
||||
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 node = await prisma.gameNode.findFirst({
|
||||
where: { status: 'ONLINE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
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 ramMb = server.plan?.maxRamMb ?? server.ramMb;
|
||||
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.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 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,
|
||||
});
|
||||
|
||||
logger.info({ serverId, nodeId: node.id, hostPort }, 'Server provisioned');
|
||||
return { status: 'stopped' };
|
||||
}
|
||||
Reference in New Issue
Block a user