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

@@ -14,6 +14,7 @@
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.4",
"@fastify/websocket": "^11.0.2",
"@hexahost/auth": "workspace:*",
"@hexahost/config": "workspace:*",
"@hexahost/contracts": "workspace:*",
@@ -31,6 +32,7 @@
"pino-http": "^10.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"ws": "^8.18.0",
"zod": "^3.24.1"
},
"devDependencies": {
@@ -38,6 +40,7 @@
"@nestjs/cli": "^11.0.2",
"@nestjs/schematics": "^11.0.0",
"@types/node": "^22.10.7",
"@types/ws": "^8.5.14",
"pino-pretty": "^13.0.0",
"typescript": "^5.7.3"
}

View File

@@ -0,0 +1,45 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { AdminNodesService } from './admin-nodes.service';
import type { CreateNodeResponse } from './admin-nodes.service';
const createNodeRequestSchema = z.object({
name: z.string().min(1).max(64),
hostname: z.string().min(1).max(255),
maxServers: z.number().int().min(1).max(1000).optional(),
});
@ApiTags('admin')
@Controller('admin/nodes')
@UseGuards(SessionGuard)
export class AdminNodesController {
constructor(private readonly adminNodesService: AdminNodesService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new game node (super admin only)' })
@ApiResponse({ status: 201, description: 'Node created with enrollment token' })
create(
@CurrentUser() user: { roles: string[] },
@Body(new ZodValidationPipe(createNodeRequestSchema)) body: unknown,
): Promise<CreateNodeResponse> {
this.adminNodesService.assertSuperAdmin(user.roles);
return this.adminNodesService.createNode(
body as Parameters<AdminNodesService['createNode']>[0],
);
}
}

View File

@@ -0,0 +1,63 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import {
generateSecureToken,
hashToken,
UserRole,
} from '@hexahost/auth';
import { PrismaService } from '../prisma/prisma.service';
export interface CreateNodeInput {
name: string;
hostname: string;
maxServers?: number;
}
export interface CreateNodeResponse {
node: {
id: string;
name: string;
hostname: string;
status: string;
maxServers: number;
createdAt: string;
};
enrollmentToken: string;
}
@Injectable()
export class AdminNodesService {
constructor(private readonly prisma: PrismaService) {}
assertSuperAdmin(roles: string[]): void {
if (!roles.includes(UserRole.SUPER_ADMIN)) {
throw new ForbiddenException('Super administrator access required');
}
}
async createNode(input: CreateNodeInput): Promise<CreateNodeResponse> {
const enrollmentToken = generateSecureToken();
const node = await this.prisma.gameNode.create({
data: {
name: input.name,
hostname: input.hostname,
maxServers: input.maxServers ?? 10,
enrollmentTokenHash: hashToken(enrollmentToken),
},
});
return {
node: {
id: node.id,
name: node.name,
hostname: node.hostname,
status: node.status,
maxServers: node.maxServers,
createdAt: node.createdAt.toISOString(),
},
enrollmentToken,
};
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AdminNodesController } from './admin-nodes.controller';
import { AdminNodesService } from './admin-nodes.service';
@Module({
imports: [AuthModule],
controllers: [AdminNodesController],
providers: [AdminNodesService],
})
export class AdminModule {}

View File

@@ -4,6 +4,9 @@ import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { AdminModule } from './admin/admin.module';
import { NodesModule } from './nodes/nodes.module';
import { ServersModule } from './servers/servers.module';
import { AppConfigModule } from './config/app-config.module';
import { APP_GUARD } from '@nestjs/core';
@@ -37,6 +40,9 @@ import { LoggerModule } from 'nestjs-pino';
AppConfigModule,
HealthModule,
AuthModule,
ServersModule,
NodesModule,
AdminModule,
],
providers: [
{

View File

@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import cookie from '@fastify/cookie';
import websocket from '@fastify/websocket';
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
@@ -41,6 +42,8 @@ async function bootstrap(): Promise<void> {
secret: config.SESSION_SECRET,
});
await app.register(websocket);
const swaggerConfig = new DocumentBuilder()
.setTitle(config.APP_NAME)
.setDescription('HexaHost GameCloud REST API')

View File

@@ -0,0 +1,64 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import Redis from 'ioredis';
import { getConfig } from '@hexahost/config';
import { NODE_COMMANDS_CHANNEL } from '../servers/servers.service';
import { NodesService } from './nodes.service';
interface NodeCommandMessage {
nodeId: string;
envelope: unknown;
}
@Injectable()
export class NodeCommandSubscriber implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(NodeCommandSubscriber.name);
private subscriber?: Redis;
constructor(private readonly nodesService: NodesService) {}
async onModuleInit(): Promise<void> {
const config = getConfig();
this.subscriber = new Redis(config.REDIS_URL, {
maxRetriesPerRequest: null,
});
await this.subscriber.subscribe(NODE_COMMANDS_CHANNEL);
this.subscriber.on('message', (channel, message) => {
if (channel !== NODE_COMMANDS_CHANNEL) {
return;
}
try {
const parsed = JSON.parse(message) as NodeCommandMessage;
if (!parsed.nodeId || parsed.envelope === undefined) {
this.logger.warn('Ignoring malformed node command message');
return;
}
this.nodesService.forwardCommand(parsed.nodeId, parsed.envelope);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to process node command: ${detail}`);
}
});
}
async onModuleDestroy(): Promise<void> {
if (!this.subscriber) {
return;
}
await this.subscriber.unsubscribe(NODE_COMMANDS_CHANNEL);
await this.subscriber.quit();
}
}

View File

@@ -0,0 +1,84 @@
import {
Injectable,
Logger,
OnModuleInit,
UnauthorizedException,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import type { FastifyRequest } from 'fastify';
import type { WebSocket } from 'ws';
import { NodesService } from './nodes.service';
@Injectable()
export class NodesGateway implements OnModuleInit {
private readonly logger = new Logger(NodesGateway.name);
constructor(
private readonly adapterHost: HttpAdapterHost,
private readonly nodesService: NodesService,
) {}
onModuleInit(): void {
const fastify = this.adapterHost.httpAdapter.getInstance();
fastify.get(
'/api/v1/nodes/ws',
{ websocket: true },
(socket: WebSocket, request: FastifyRequest) => {
void this.handleConnection(socket, request);
},
);
}
private async handleConnection(
socket: WebSocket,
request: FastifyRequest,
): Promise<void> {
const query = request.query as Record<string, string | string[] | undefined>;
const nodeId = this.readQueryParam(query['nodeId']);
const token = this.readQueryParam(query['token']);
if (!nodeId || !token) {
socket.close(4400, 'nodeId and token are required');
return;
}
try {
await this.nodesService.validateEnrollment(nodeId, token);
} catch (error) {
const reason =
error instanceof UnauthorizedException
? 'Invalid enrollment credentials'
: 'Enrollment validation failed';
socket.close(4401, reason);
return;
}
this.nodesService.registerConnection(nodeId, socket);
socket.on('close', () => {
this.nodesService.unregisterConnection(nodeId, socket);
});
socket.on('message', (data) => {
void this.nodesService.handleAgentMessage(nodeId, data.toString());
});
socket.on('error', (error) => {
this.logger.error(`WebSocket error for node ${nodeId}: ${error.message}`);
});
socket.send(JSON.stringify(this.nodesService.createHelloAck(nodeId)));
}
private readQueryParam(
value: string | string[] | undefined,
): string | undefined {
if (Array.isArray(value)) {
return value[0];
}
return value;
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { ServersModule } from '../servers/servers.module';
import { NodeCommandSubscriber } from './node-command.subscriber';
import { NodesGateway } from './nodes.gateway';
import { NodesService } from './nodes.service';
@Module({
imports: [ServersModule],
providers: [NodesService, NodesGateway, NodeCommandSubscriber],
exports: [NodesService],
})
export class NodesModule {}

View File

@@ -0,0 +1,305 @@
import { randomUUID } from 'node:crypto';
import {
Injectable,
Logger,
UnauthorizedException,
Inject,
} from '@nestjs/common';
import type { WebSocket } from 'ws';
import type Redis from 'ioredis';
import { verifyTokenHash } from '@hexahost/auth';
import type { GameServer, GameNode, Prisma } from '@hexahost/database';
import { PrismaService } from '../prisma/prisma.service';
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
type GameServerStatus = GameServer['status'];
interface AgentEnvelope {
protocolVersion?: number;
messageId?: string;
correlationId?: string;
type?: string;
timestamp?: string;
payload?: Record<string, unknown>;
}
const AGENT_STATE_MAP: Record<string, GameServerStatus> = {
draft: 'DRAFT',
provisioning: 'PROVISIONING',
installing: 'INSTALLING',
stopped: 'STOPPED',
starting: 'STARTING',
running: 'RUNNING',
stopping: 'STOPPING',
error: 'ERROR',
deleting: 'DELETING',
deleted: 'DELETED',
};
@Injectable()
export class NodesService {
private readonly logger = new Logger(NodesService.name);
private readonly connections = new Map<string, WebSocket>();
constructor(
private readonly prisma: PrismaService,
private readonly serversService: ServersService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
registerConnection(nodeId: string, socket: WebSocket): void {
const existing = this.connections.get(nodeId);
if (existing && existing !== socket) {
existing.close(4000, 'replaced by new connection');
}
this.connections.set(nodeId, socket);
}
unregisterConnection(nodeId: string, socket: WebSocket): void {
const current = this.connections.get(nodeId);
if (current === socket) {
this.connections.delete(nodeId);
}
}
forwardCommand(nodeId: string, envelope: unknown): void {
const socket = this.connections.get(nodeId);
if (!socket || socket.readyState !== socket.OPEN) {
this.logger.warn(`No active WebSocket for node ${nodeId}`);
return;
}
socket.send(JSON.stringify(envelope));
}
createHelloAck(nodeId: string): AgentEnvelope {
return {
protocolVersion: 1,
messageId: randomUUID(),
type: 'agent.hello',
timestamp: new Date().toISOString(),
payload: {
accepted: true,
nodeId,
},
};
}
async validateEnrollment(nodeId: string, token: string): Promise<GameNode> {
const node = await this.prisma.gameNode.findFirst({
where: {
OR: [{ id: nodeId }, { name: nodeId }],
},
});
if (!node?.enrollmentTokenHash) {
throw new UnauthorizedException('Node not enrolled');
}
if (!verifyTokenHash(token, node.enrollmentTokenHash)) {
throw new UnauthorizedException('Invalid enrollment token');
}
return node;
}
async markNodeOnline(nodeId: string, agentVersion?: string): Promise<void> {
await this.prisma.gameNode.update({
where: { id: nodeId },
data: {
status: 'ONLINE',
lastHeartbeatAt: new Date(),
enrolledAt: new Date(),
agentVersion,
enrollmentTokenHash: null,
},
});
}
async handleAgentMessage(nodeId: string, raw: string): Promise<void> {
let envelope: AgentEnvelope;
try {
envelope = JSON.parse(raw) as AgentEnvelope;
} catch {
this.logger.warn(`Invalid JSON from node ${nodeId}`);
return;
}
switch (envelope.type) {
case 'agent.hello':
await this.markNodeOnline(
nodeId,
typeof envelope.payload?.['agentVersion'] === 'string'
? envelope.payload['agentVersion']
: undefined,
);
break;
case 'agent.heartbeat':
await this.persistHeartbeat(nodeId, envelope.payload);
break;
case 'server.state':
await this.handleServerState(envelope);
break;
case 'server.operation.completed':
await this.handleOperationCompleted(envelope);
break;
case 'server.operation.failed':
await this.handleOperationFailed(envelope);
break;
default:
this.logger.debug(`Ignored agent message type ${envelope.type ?? 'unknown'}`);
}
}
private async persistHeartbeat(
nodeId: string,
payload?: Record<string, unknown>,
): Promise<void> {
const now = new Date();
await this.prisma.$transaction([
this.prisma.gameNode.update({
where: { id: nodeId },
data: {
lastHeartbeatAt: now,
status: 'ONLINE',
agentVersion:
typeof payload?.['agentVersion'] === 'string'
? payload['agentVersion']
: undefined,
},
}),
this.prisma.gameNodeHeartbeat.create({
data: {
nodeId,
payload: (payload ?? undefined) as Prisma.InputJsonValue | undefined,
},
}),
]);
}
private async handleServerState(envelope: AgentEnvelope): Promise<void> {
const serverId = envelope.payload?.['serverId'];
const state = envelope.payload?.['state'];
if (typeof serverId !== 'string' || typeof state !== 'string') {
return;
}
const mapped = AGENT_STATE_MAP[state.toLowerCase()];
if (!mapped) {
return;
}
await this.serversService.applyAgentStatus(serverId, mapped, {
correlationId: envelope.correlationId,
reason: 'agent server.state',
errorCode:
typeof envelope.payload?.['errorCode'] === 'string'
? envelope.payload['errorCode']
: undefined,
errorMessage:
typeof envelope.payload?.['errorMessage'] === 'string'
? envelope.payload['errorMessage']
: undefined,
});
}
private async handleOperationCompleted(
envelope: AgentEnvelope,
): Promise<void> {
const serverId = envelope.payload?.['serverId'];
const operation = envelope.payload?.['operation'];
const messageId = envelope.messageId;
if (typeof serverId !== 'string') {
return;
}
const target = this.resolveCompletedStatus(operation);
if (target) {
await this.serversService.applyAgentStatus(serverId, target, {
correlationId: envelope.correlationId,
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
});
}
if (typeof messageId === 'string') {
await this.publishNodeResponse(messageId, {
success: true,
resultCode: typeof operation === 'string' ? operation : 'completed',
});
}
}
private async handleOperationFailed(envelope: AgentEnvelope): Promise<void> {
const serverId = envelope.payload?.['serverId'];
const messageId = envelope.messageId;
if (typeof serverId === 'string') {
await this.serversService.applyAgentStatus(serverId, 'ERROR', {
correlationId: envelope.correlationId,
reason: 'agent operation failed',
errorCode:
typeof envelope.payload?.['errorCode'] === 'string'
? envelope.payload['errorCode']
: undefined,
errorMessage:
typeof envelope.payload?.['errorMessage'] === 'string'
? envelope.payload['errorMessage']
: undefined,
});
}
if (typeof messageId === 'string') {
await this.publishNodeResponse(messageId, {
success: false,
errorCode:
typeof envelope.payload?.['errorCode'] === 'string'
? envelope.payload['errorCode']
: 'OPERATION_FAILED',
errorMessage:
typeof envelope.payload?.['errorMessage'] === 'string'
? envelope.payload['errorMessage']
: 'Agent operation failed',
});
}
}
private async publishNodeResponse(
messageId: string,
response: {
success: boolean;
resultCode?: string;
errorCode?: string;
errorMessage?: string;
},
): Promise<void> {
await this.redis.publish(
`hgc:node:responses:${messageId}`,
JSON.stringify(response),
);
}
private resolveCompletedStatus(operation: unknown): GameServerStatus | null {
switch (operation) {
case 'provision':
return 'STOPPED';
case 'install':
return 'STOPPED';
case 'start':
return 'RUNNING';
case 'stop':
return 'STOPPED';
case 'delete':
return 'DELETED';
default:
return null;
}
}
}

View File

@@ -0,0 +1,19 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { SessionGuard } from '../auth/guards/session.guard';
import { PlansService } from './plans.service';
@ApiTags('plans')
@Controller('plans')
@UseGuards(SessionGuard)
export class PlansController {
constructor(private readonly plansService: PlansService) {}
@Get()
@ApiOperation({ summary: 'List active server plans' })
list() {
return this.plansService.listActivePlans();
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class PlansService {
constructor(private readonly prisma: PrismaService) {}
async listActivePlans() {
const plans = await this.prisma.plan.findMany({
where: { isActive: true },
orderBy: { maxRamMb: 'asc' },
});
return {
plans: plans.map((plan) => ({
id: plan.id,
name: plan.name,
slug: plan.slug,
description: plan.description,
isFree: plan.isFree,
maxRamMb: plan.maxRamMb,
maxCpuCores: plan.maxCpuCores,
maxStorageMb: plan.maxStorageMb,
})),
};
}
}

View File

@@ -0,0 +1,71 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { GameServer } from '@hexahost/database';
type GameServerStatus = GameServer['status'];
const ALLOWED_TRANSITIONS: Record<GameServerStatus, GameServerStatus[]> = {
DRAFT: ['PROVISIONING', 'DELETING'],
PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'],
INSTALLING: ['STOPPED', 'ERROR', 'DELETING'],
STOPPED: ['STARTING', 'PROVISIONING', 'DELETING'],
STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'DELETING'],
RUNNING: ['STOPPING', 'ERROR', 'DELETING'],
STOPPING: ['STOPPED', 'ERROR', 'DELETING'],
ERROR: ['STOPPED', 'STARTING', 'PROVISIONING', 'DELETING'],
DELETING: ['DELETED'],
DELETED: [],
};
const IN_PROGRESS_STATUSES: GameServerStatus[] = [
'PROVISIONING',
'INSTALLING',
'STARTING',
'STOPPING',
'DELETING',
];
@Injectable()
export class ServerStateService {
assertTransition(
fromStatus: GameServerStatus,
toStatus: GameServerStatus,
): void {
const allowed = ALLOWED_TRANSITIONS[fromStatus];
if (!allowed.includes(toStatus)) {
throw new BadRequestException(
`Invalid state transition from ${fromStatus} to ${toStatus}`,
);
}
}
assertNotInProgress(status: GameServerStatus): void {
if (IN_PROGRESS_STATUSES.includes(status)) {
throw new BadRequestException('Server operation already in progress');
}
}
resolveStartTarget(status: GameServerStatus): GameServerStatus {
switch (status) {
case 'DRAFT':
case 'ERROR':
return 'PROVISIONING';
case 'STOPPED':
return 'STARTING';
default:
throw new BadRequestException(
`Cannot start server while in status ${status}`,
);
}
}
resolveStopTarget(status: GameServerStatus): GameServerStatus {
if (status !== 'RUNNING') {
throw new BadRequestException(
`Cannot stop server while in status ${status}`,
);
}
return 'STOPPING';
}
}

View File

@@ -0,0 +1,76 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { createServerRequestSchema } from '@hexahost/contracts';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { ServersService } from './servers.service';
@ApiTags('servers')
@Controller('servers')
@UseGuards(SessionGuard)
export class ServersController {
constructor(private readonly serversService: ServersService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a game server' })
@ApiResponse({ status: 201, description: 'Server created' })
create(
@CurrentUser() user: { id: string },
@Body(new ZodValidationPipe(createServerRequestSchema)) body: unknown,
) {
return this.serversService.createServer(
user.id,
body as Parameters<ServersService['createServer']>[1],
);
}
@Get()
@ApiOperation({ summary: 'List owned game servers' })
list(@CurrentUser() user: { id: string }) {
return this.serversService.listServers(user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Get a game server by id' })
get(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.serversService.getServer(user.id, id);
}
@Post(':id/start')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({ summary: 'Start a game server' })
start(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.serversService.startServer(user.id, id);
}
@Post(':id/stop')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({ summary: 'Stop a game server' })
stop(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.serversService.stopServer(user.id, id);
}
}

View File

@@ -0,0 +1,64 @@
import { Module } from '@nestjs/common';
import { Queue } from 'bullmq';
import Redis from 'ioredis';
import { getConfig } from '@hexahost/config';
import { AuthModule } from '../auth/auth.module';
import { AppConfigModule } from '../config/app-config.module';
import { ServerStateService } from './server-state.service';
import {
REDIS_CLIENT,
SERVER_LIFECYCLE_QUEUE,
SERVER_PROVISIONING_QUEUE,
ServersService,
} from './servers.service';
import { ServersController } from './servers.controller';
import { PlansController } from './plans.controller';
import { PlansService } from './plans.service';
@Module({
imports: [AppConfigModule, AuthModule],
controllers: [ServersController, PlansController],
providers: [
ServersService,
ServerStateService,
PlansService,
{
provide: REDIS_CLIENT,
useFactory: () => {
const config = getConfig();
return new Redis(config.REDIS_URL, {
maxRetriesPerRequest: null,
});
},
},
{
provide: SERVER_LIFECYCLE_QUEUE,
useFactory: () => {
const config = getConfig();
return new Queue('server-lifecycle', {
connection: {
url: config.REDIS_URL,
maxRetriesPerRequest: null,
},
});
},
},
{
provide: SERVER_PROVISIONING_QUEUE,
useFactory: () => {
const config = getConfig();
return new Queue('server-provisioning', {
connection: {
url: config.REDIS_URL,
maxRetriesPerRequest: null,
},
});
},
},
],
exports: [ServersService, REDIS_CLIENT],
})
export class ServersModule {}

View File

@@ -0,0 +1,327 @@
import { randomUUID } from 'node:crypto';
import {
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import type Redis from 'ioredis';
import type {
CreateServerRequest,
ServerActionResponse,
ServerListResponse,
ServerResponse,
} from '@hexahost/contracts';
import type { GameServer } from '@hexahost/database';
type GameServerStatus = GameServer['status'];
import { PrismaService } from '../prisma/prisma.service';
import { ServerStateService } from './server-state.service';
export const REDIS_CLIENT = Symbol('SERVERS_REDIS_CLIENT');
export const SERVER_LIFECYCLE_QUEUE = Symbol('SERVER_LIFECYCLE_QUEUE');
export const SERVER_PROVISIONING_QUEUE = Symbol('SERVER_PROVISIONING_QUEUE');
export const NODE_COMMANDS_CHANNEL = 'hgc:node:commands' as const;
@Injectable()
export class ServersService {
constructor(
private readonly prisma: PrismaService,
private readonly serverState: ServerStateService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
@Inject(SERVER_LIFECYCLE_QUEUE)
private readonly lifecycleQueue: Queue,
@Inject(SERVER_PROVISIONING_QUEUE)
private readonly provisioningQueue: Queue,
) {}
async createServer(
userId: string,
input: CreateServerRequest,
): Promise<ServerResponse> {
const server = await this.prisma.gameServer.create({
data: {
userId,
name: input.name,
edition: input.edition,
softwareFamily: input.softwareFamily,
minecraftVersion: input.minecraftVersion,
eulaAccepted: input.eulaAccepted,
ramMb: input.ramMb ?? 1024,
planId: input.planId,
status: 'DRAFT',
},
});
await this.recordTransition(server.id, null, 'DRAFT', 'created', userId);
return this.toResponse(server);
}
async listServers(userId: string): Promise<ServerListResponse> {
const servers = await this.prisma.gameServer.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
return {
servers: servers.map((server) => this.toResponse(server)),
};
}
async getServer(userId: string, serverId: string): Promise<ServerResponse> {
const server = await this.findOwnedServer(userId, serverId);
return this.toResponse(server);
}
async startServer(
userId: string,
serverId: string,
): Promise<ServerActionResponse> {
const server = await this.findOwnedServer(userId, serverId);
this.serverState.assertNotInProgress(server.status);
const targetStatus = this.serverState.resolveStartTarget(server.status);
this.serverState.assertTransition(server.status, targetStatus);
const correlationId = randomUUID();
const updated = await this.transitionServer(
server,
targetStatus,
'start requested',
userId,
correlationId,
);
let jobId: string | undefined;
if (targetStatus === 'PROVISIONING') {
const job = await this.provisioningQueue.add(
'provision-server',
{ serverId, correlationId },
{ jobId: correlationId },
);
jobId = job.id;
} else {
const job = await this.lifecycleQueue.add(
'start-server',
{ serverId, action: 'start', correlationId },
{ jobId: correlationId },
);
jobId = job.id;
if (updated.nodeId) {
await this.publishNodeCommand(updated.nodeId, {
protocolVersion: 1,
messageId: randomUUID(),
correlationId,
type: 'server.start',
timestamp: new Date().toISOString(),
payload: {
serverId,
generation: updated.version,
desiredGeneration: updated.version,
},
});
}
}
return {
server: this.toResponse(updated),
jobId,
};
}
async stopServer(
userId: string,
serverId: string,
): Promise<ServerActionResponse> {
const server = await this.findOwnedServer(userId, serverId);
this.serverState.assertNotInProgress(server.status);
const targetStatus = this.serverState.resolveStopTarget(server.status);
this.serverState.assertTransition(server.status, targetStatus);
const correlationId = randomUUID();
const updated = await this.transitionServer(
server,
targetStatus,
'stop requested',
userId,
correlationId,
);
const job = await this.lifecycleQueue.add(
'stop-server',
{ serverId, action: 'stop', correlationId },
{ jobId: correlationId },
);
if (updated.nodeId) {
await this.publishNodeCommand(updated.nodeId, {
protocolVersion: 1,
messageId: randomUUID(),
correlationId,
type: 'server.stop',
timestamp: new Date().toISOString(),
payload: {
serverId,
generation: updated.version,
},
});
}
return {
server: this.toResponse(updated),
jobId: job.id,
};
}
async publishNodeCommand(
nodeId: string,
envelope: Record<string, unknown>,
): Promise<void> {
await this.redis.publish(
NODE_COMMANDS_CHANNEL,
JSON.stringify({ nodeId, envelope }),
);
}
async applyAgentStatus(
serverId: string,
nextStatus: GameServerStatus,
metadata?: {
correlationId?: string;
reason?: string;
errorCode?: string;
errorMessage?: string;
},
): Promise<void> {
const server = await this.prisma.gameServer.findUnique({
where: { id: serverId },
});
if (!server || server.status === nextStatus) {
return;
}
this.serverState.assertTransition(server.status, nextStatus);
await this.transitionServer(
server,
nextStatus,
metadata?.reason ?? 'agent reported state',
null,
metadata?.correlationId,
metadata?.errorCode,
metadata?.errorMessage,
);
}
private async findOwnedServer(
userId: string,
serverId: string,
): Promise<GameServer> {
const server = await this.prisma.gameServer.findUnique({
where: { id: serverId },
});
if (!server) {
throw new NotFoundException('Server not found');
}
if (server.userId !== userId) {
throw new ForbiddenException('You do not own this server');
}
return server;
}
private async transitionServer(
server: GameServer,
toStatus: GameServerStatus,
reason: string,
actorId: string | null,
correlationId?: string,
errorCode?: string,
errorMessage?: string,
): Promise<GameServer> {
if (server.status === toStatus) {
throw new ConflictException('Server is already in the requested state');
}
const updated = await this.prisma.gameServer.update({
where: { id: server.id },
data: {
status: toStatus,
version: { increment: 1 },
},
});
await this.recordTransition(
server.id,
server.status,
toStatus,
reason,
actorId,
correlationId,
errorCode,
errorMessage,
);
return updated;
}
private async recordTransition(
gameServerId: string,
fromStatus: GameServerStatus | null,
toStatus: GameServerStatus,
reason: string,
actorId: string | null,
correlationId?: string,
errorCode?: string,
errorMessage?: string,
): Promise<void> {
await this.prisma.gameServerStateTransition.create({
data: {
gameServerId,
fromStatus,
toStatus,
reason,
actorId,
correlationId,
errorCode,
errorMessage,
},
});
}
private toResponse(server: GameServer): ServerResponse {
return {
id: server.id,
userId: server.userId,
planId: server.planId,
nodeId: server.nodeId,
name: server.name,
status: server.status,
version: server.version,
edition: server.edition,
softwareFamily: server.softwareFamily,
minecraftVersion: server.minecraftVersion,
eulaAccepted: server.eulaAccepted,
hostPort: server.hostPort,
containerId: server.containerId,
dataPath: server.dataPath,
ramMb: server.ramMb,
createdAt: server.createdAt.toISOString(),
updatedAt: server.updatedAt.toISOString(),
};
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long