Phase2
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 10s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 12:01:25 +02:00
parent 58961000eb
commit c4077d4673
93 changed files with 4099 additions and 165 deletions

View 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' };
}