Phase2
This commit is contained in:
45
apps/api/src/admin/admin-nodes.controller.ts
Normal file
45
apps/api/src/admin/admin-nodes.controller.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
}
|
||||
63
apps/api/src/admin/admin-nodes.service.ts
Normal file
63
apps/api/src/admin/admin-nodes.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
13
apps/api/src/admin/admin.module.ts
Normal file
13
apps/api/src/admin/admin.module.ts
Normal 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 {}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
|
||||
64
apps/api/src/nodes/node-command.subscriber.ts
Normal file
64
apps/api/src/nodes/node-command.subscriber.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
84
apps/api/src/nodes/nodes.gateway.ts
Normal file
84
apps/api/src/nodes/nodes.gateway.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
14
apps/api/src/nodes/nodes.module.ts
Normal file
14
apps/api/src/nodes/nodes.module.ts
Normal 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 {}
|
||||
305
apps/api/src/nodes/nodes.service.ts
Normal file
305
apps/api/src/nodes/nodes.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
apps/api/src/servers/plans.controller.ts
Normal file
19
apps/api/src/servers/plans.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
28
apps/api/src/servers/plans.service.ts
Normal file
28
apps/api/src/servers/plans.service.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
71
apps/api/src/servers/server-state.service.ts
Normal file
71
apps/api/src/servers/server-state.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
76
apps/api/src/servers/servers.controller.ts
Normal file
76
apps/api/src/servers/servers.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
64
apps/api/src/servers/servers.module.ts
Normal file
64
apps/api/src/servers/servers.module.ts
Normal 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 {}
|
||||
327
apps/api/src/servers/servers.service.ts
Normal file
327
apps/api/src/servers/servers.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user