Phase2
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -22,7 +22,13 @@ func main() {
|
||||
|
||||
log = log.With("node_id", cfg.NodeID, "component", "node-agent")
|
||||
|
||||
if err := agent.New(cfg, log).Run(context.Background()); err != nil {
|
||||
a, err := agent.New(cfg, log)
|
||||
if err != nil {
|
||||
log.Error("agent init error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := a.Run(context.Background()); err != nil {
|
||||
log.Error("agent exited with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
module github.com/hexahost/gamecloud/node-agent
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/docker/docker v28.0.0+incompatible
|
||||
github.com/docker/go-connections v0.5.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
)
|
||||
|
||||
@@ -6,28 +6,43 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/config"
|
||||
dockerpkg "github.com/hexahost/gamecloud/node-agent/internal/docker"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/health"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/runtime"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/ws"
|
||||
)
|
||||
|
||||
const Version = "0.1.0-phase0"
|
||||
const Version = "0.2.0-phase2"
|
||||
|
||||
// Agent coordinates the node agent lifecycle until shutdown.
|
||||
type Agent struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
health *health.Server
|
||||
docker *dockerpkg.Client
|
||||
ws *ws.Client
|
||||
}
|
||||
|
||||
// New creates an agent instance from configuration.
|
||||
func New(cfg *config.Config, log *slog.Logger) *Agent {
|
||||
func New(cfg *config.Config, log *slog.Logger) (*Agent, error) {
|
||||
dockerClient, err := dockerpkg.New(cfg.DockerSocket)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wsClient := ws.NewClient(cfg, log, nil, Version)
|
||||
runtimeMgr := runtime.NewManager(dockerClient, wsClient, log)
|
||||
wsClient.SetRuntime(runtimeMgr)
|
||||
|
||||
return &Agent{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
health: health.NewServer(cfg.HealthListenAddr, Version),
|
||||
}
|
||||
docker: dockerClient,
|
||||
ws: wsClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run starts the agent loop and blocks until context cancellation or signal.
|
||||
@@ -39,7 +54,7 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer signal.Stop(sigCh)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
errCh := make(chan error, 2)
|
||||
go func() {
|
||||
a.log.Info("health server listening", "addr", a.cfg.HealthListenAddr)
|
||||
if err := a.health.ListenAndServe(); err != nil {
|
||||
@@ -47,6 +62,10 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
errCh <- a.ws.Run(ctx)
|
||||
}()
|
||||
|
||||
a.health.SetReady(true)
|
||||
a.log.Info("agent started",
|
||||
"node_id", a.cfg.NodeID,
|
||||
@@ -54,34 +73,33 @@ func (a *Agent) Run(ctx context.Context) error {
|
||||
"version", Version,
|
||||
)
|
||||
|
||||
ticker := time.NewTicker(a.cfg.HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return a.shutdown(ctx.Err())
|
||||
return a.shutdown(nil)
|
||||
case sig := <-sigCh:
|
||||
a.log.Info("shutdown signal received", "signal", sig.String())
|
||||
return a.shutdown(nil)
|
||||
cancel()
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ticker.C:
|
||||
a.log.Debug("heartbeat tick",
|
||||
"node_id", a.cfg.NodeID,
|
||||
"interval", a.cfg.HeartbeatInterval.String(),
|
||||
)
|
||||
if ctx.Err() != nil {
|
||||
return a.shutdown(nil)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) shutdown(reason error) error {
|
||||
a.health.SetReady(false)
|
||||
a.log.Info("agent shutting down", "grace", a.cfg.ShutdownGracePeriod.String())
|
||||
a.log.Info("agent shutting down")
|
||||
|
||||
timer := time.NewTimer(a.cfg.ShutdownGracePeriod)
|
||||
defer timer.Stop()
|
||||
<-timer.C
|
||||
if a.docker != nil {
|
||||
if err := a.docker.Close(); err != nil {
|
||||
a.log.Warn("docker client close failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if reason != nil && reason != context.Canceled {
|
||||
a.log.Warn("shutdown with error", "error", reason)
|
||||
|
||||
191
apps/node-agent/internal/docker/client.go
Normal file
191
apps/node-agent/internal/docker/client.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/mount"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/docker/go-connections/nat"
|
||||
)
|
||||
|
||||
const MinecraftImage = "itzg/minecraft-server:java21"
|
||||
|
||||
// MinecraftServerSpec describes a Minecraft container to create.
|
||||
type MinecraftServerSpec struct {
|
||||
ServerID string
|
||||
MinecraftVersion string
|
||||
SoftwareFamily string
|
||||
HostPort int
|
||||
RamMb int
|
||||
DataPath string
|
||||
EulaAccepted bool
|
||||
}
|
||||
|
||||
// ContainerState summarizes a Docker container's runtime status.
|
||||
type ContainerState struct {
|
||||
ID string
|
||||
Status string
|
||||
Running bool
|
||||
}
|
||||
|
||||
// Client wraps the Docker Engine API for game server containers.
|
||||
type Client struct {
|
||||
cli *client.Client
|
||||
}
|
||||
|
||||
// New creates a Docker client connected via the given socket URL.
|
||||
func New(socket string) (*Client, error) {
|
||||
cli, err := client.NewClientWithOpts(
|
||||
client.FromEnv,
|
||||
client.WithHost(socket),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create docker client: %w", err)
|
||||
}
|
||||
return &Client{cli: cli}, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying Docker client.
|
||||
func (c *Client) Close() error {
|
||||
return c.cli.Close()
|
||||
}
|
||||
|
||||
// Ping verifies connectivity to the Docker daemon.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
_, err := c.cli.Ping(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateMinecraftServer creates a non-privileged Minecraft container and returns its ID.
|
||||
func (c *Client) CreateMinecraftServer(ctx context.Context, spec MinecraftServerSpec) (string, error) {
|
||||
if !spec.EulaAccepted {
|
||||
return "", fmt.Errorf("eula must be accepted before provisioning")
|
||||
}
|
||||
if spec.HostPort <= 0 {
|
||||
return "", fmt.Errorf("hostPort must be positive")
|
||||
}
|
||||
if strings.TrimSpace(spec.DataPath) == "" {
|
||||
return "", fmt.Errorf("dataPath is required")
|
||||
}
|
||||
if spec.RamMb <= 0 {
|
||||
return "", fmt.Errorf("ramMb must be positive")
|
||||
}
|
||||
|
||||
containerPort := nat.Port("25565/tcp")
|
||||
portBindings := nat.PortMap{
|
||||
containerPort: []nat.PortBinding{{
|
||||
HostIP: "0.0.0.0",
|
||||
HostPort: strconv.Itoa(spec.HostPort),
|
||||
}},
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(spec.MinecraftVersion)
|
||||
if version == "" {
|
||||
version = "LATEST"
|
||||
}
|
||||
|
||||
resp, err := c.cli.ContainerCreate(ctx,
|
||||
&container.Config{
|
||||
Image: MinecraftImage,
|
||||
Env: []string{
|
||||
"EULA=TRUE",
|
||||
"VERSION=" + version,
|
||||
"TYPE=" + mapSoftwareFamily(spec.SoftwareFamily),
|
||||
fmt.Sprintf("MEMORY=%dM", spec.RamMb),
|
||||
},
|
||||
ExposedPorts: nat.PortSet{containerPort: struct{}{}},
|
||||
},
|
||||
&container.HostConfig{
|
||||
PortBindings: portBindings,
|
||||
Mounts: []mount.Mount{{
|
||||
Type: mount.TypeBind,
|
||||
Source: spec.DataPath,
|
||||
Target: "/data",
|
||||
}},
|
||||
Resources: container.Resources{
|
||||
Memory: int64(spec.RamMb) * 1024 * 1024,
|
||||
},
|
||||
Privileged: false,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
containerName(spec.ServerID),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create container: %w", err)
|
||||
}
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
// StartContainer starts a container by ID.
|
||||
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
|
||||
if err := c.cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil {
|
||||
return fmt.Errorf("start container: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopContainer stops a running container gracefully.
|
||||
func (c *Client) StopContainer(ctx context.Context, containerID string) error {
|
||||
timeout := 30
|
||||
if err := c.cli.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}); err != nil {
|
||||
return fmt.Errorf("stop container: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveContainer removes a container, forcing removal if still running.
|
||||
func (c *Client) RemoveContainer(ctx context.Context, containerID string) error {
|
||||
if err := c.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}); err != nil {
|
||||
return fmt.Errorf("remove container: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetContainerState inspects a container and returns its current state.
|
||||
func (c *Client) GetContainerState(ctx context.Context, containerID string) (*ContainerState, error) {
|
||||
inspect, err := c.cli.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect container: %w", err)
|
||||
}
|
||||
return &ContainerState{
|
||||
ID: inspect.ID,
|
||||
Status: inspect.State.Status,
|
||||
Running: inspect.State.Running,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListManagedContainers returns container IDs labeled for a given server ID.
|
||||
func (c *Client) ListManagedContainers(ctx context.Context, serverID string) ([]string, error) {
|
||||
containers, err := c.cli.ContainerList(ctx, container.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(filters.Arg("name", containerName(serverID))),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list containers: %w", err)
|
||||
}
|
||||
ids := make([]string, 0, len(containers))
|
||||
for _, ctr := range containers {
|
||||
ids = append(ids, ctr.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func containerName(serverID string) string {
|
||||
return "hexahost-" + serverID
|
||||
}
|
||||
|
||||
func mapSoftwareFamily(family string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(family)) {
|
||||
case "PAPER":
|
||||
return "PAPER"
|
||||
default:
|
||||
return "VANILLA"
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,31 @@ type ServerStartPayload struct {
|
||||
DesiredGeneration int64 `json:"desiredGeneration"`
|
||||
}
|
||||
|
||||
// ServerProvisionPayload instructs the agent to create a Minecraft server container.
|
||||
type ServerProvisionPayload struct {
|
||||
OperationMeta
|
||||
Edition string `json:"edition"`
|
||||
SoftwareFamily string `json:"softwareFamily"`
|
||||
MinecraftVersion string `json:"minecraftVersion"`
|
||||
HostPort int `json:"hostPort"`
|
||||
RamMb int `json:"ramMb"`
|
||||
DataPath string `json:"dataPath"`
|
||||
EulaAccepted bool `json:"eulaAccepted"`
|
||||
}
|
||||
|
||||
// ServerStopPayload instructs the agent to stop a running game server.
|
||||
type ServerStopPayload struct {
|
||||
OperationMeta
|
||||
}
|
||||
|
||||
// ServerOperationResultPayload reports the outcome of a lifecycle operation.
|
||||
type ServerOperationResultPayload struct {
|
||||
OperationMeta
|
||||
ResultCode string `json:"resultCode"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
}
|
||||
|
||||
// ServerStatePayload reports the observed server state.
|
||||
type ServerStatePayload struct {
|
||||
OperationMeta
|
||||
|
||||
@@ -136,6 +136,69 @@ func TestDecodeEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerProvisionPayload_JSONRoundtrip(t *testing.T) {
|
||||
deadline := time.Date(2026, 6, 26, 12, 0, 0, 0, time.UTC)
|
||||
original := ServerProvisionPayload{
|
||||
OperationMeta: OperationMeta{
|
||||
ServerID: "srv-abc123",
|
||||
Generation: 2,
|
||||
Deadline: &deadline,
|
||||
IdempotencyKey: "idem-001",
|
||||
},
|
||||
Edition: "JAVA",
|
||||
SoftwareFamily: "PAPER",
|
||||
MinecraftVersion: "1.21.1",
|
||||
HostPort: 25565,
|
||||
RamMb: 2048,
|
||||
DataPath: "/var/lib/hexahost-gamecloud/srv-abc123",
|
||||
EulaAccepted: true,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var decoded ServerProvisionPayload
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if decoded.ServerID != original.ServerID {
|
||||
t.Fatalf("serverId: got %q want %q", decoded.ServerID, original.ServerID)
|
||||
}
|
||||
if decoded.Generation != original.Generation {
|
||||
t.Fatalf("generation: got %d want %d", decoded.Generation, original.Generation)
|
||||
}
|
||||
if decoded.Edition != original.Edition {
|
||||
t.Fatalf("edition: got %q want %q", decoded.Edition, original.Edition)
|
||||
}
|
||||
if decoded.SoftwareFamily != original.SoftwareFamily {
|
||||
t.Fatalf("softwareFamily: got %q want %q", decoded.SoftwareFamily, original.SoftwareFamily)
|
||||
}
|
||||
if decoded.MinecraftVersion != original.MinecraftVersion {
|
||||
t.Fatalf("minecraftVersion: got %q want %q", decoded.MinecraftVersion, original.MinecraftVersion)
|
||||
}
|
||||
if decoded.HostPort != original.HostPort {
|
||||
t.Fatalf("hostPort: got %d want %d", decoded.HostPort, original.HostPort)
|
||||
}
|
||||
if decoded.RamMb != original.RamMb {
|
||||
t.Fatalf("ramMb: got %d want %d", decoded.RamMb, original.RamMb)
|
||||
}
|
||||
if decoded.DataPath != original.DataPath {
|
||||
t.Fatalf("dataPath: got %q want %q", decoded.DataPath, original.DataPath)
|
||||
}
|
||||
if decoded.EulaAccepted != original.EulaAccepted {
|
||||
t.Fatalf("eulaAccepted: got %v want %v", decoded.EulaAccepted, original.EulaAccepted)
|
||||
}
|
||||
if decoded.IdempotencyKey != original.IdempotencyKey {
|
||||
t.Fatalf("idempotencyKey: got %q want %q", decoded.IdempotencyKey, original.IdempotencyKey)
|
||||
}
|
||||
if decoded.Deadline == nil || !decoded.Deadline.Equal(deadline) {
|
||||
t.Fatalf("deadline mismatch: got %v want %v", decoded.Deadline, deadline)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEnvelope(t *testing.T) {
|
||||
env, err := NewEnvelope(TypeAgentHello, "msg-new", AgentHelloPayload{
|
||||
NodeID: "n1",
|
||||
|
||||
269
apps/node-agent/internal/runtime/manager.go
Normal file
269
apps/node-agent/internal/runtime/manager.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/docker"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
ServerStateProvisioning = "provisioning"
|
||||
ServerStateStopped = "stopped"
|
||||
ServerStateStarting = "starting"
|
||||
ServerStateRunning = "running"
|
||||
ServerStateStopping = "stopping"
|
||||
ServerStateDeleting = "deleting"
|
||||
ServerStateDeleted = "deleted"
|
||||
ServerStateError = "error"
|
||||
|
||||
ResultCodeOK = "ok"
|
||||
ResultCodeError = "error"
|
||||
)
|
||||
|
||||
// Responder sends protocol responses back to the control plane.
|
||||
type Responder interface {
|
||||
SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error
|
||||
SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
|
||||
SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error
|
||||
}
|
||||
|
||||
type serverRecord struct {
|
||||
ServerID string
|
||||
ContainerID string
|
||||
HostPort int
|
||||
DataPath string
|
||||
RamMb int
|
||||
State string
|
||||
}
|
||||
|
||||
// Manager tracks provisioned servers and executes lifecycle operations.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
docker *docker.Client
|
||||
servers map[string]*serverRecord
|
||||
resp Responder
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewManager creates a runtime manager backed by the given Docker client.
|
||||
func NewManager(dockerClient *docker.Client, resp Responder, log *slog.Logger) *Manager {
|
||||
return &Manager{
|
||||
docker: dockerClient,
|
||||
servers: make(map[string]*serverRecord),
|
||||
resp: resp,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// RunningCount returns the number of servers currently in the running state.
|
||||
func (m *Manager) RunningCount() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
count := 0
|
||||
for _, rec := range m.servers {
|
||||
if rec.State == ServerStateRunning {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// HandleProvision creates a Minecraft container for the server.
|
||||
func (m *Manager) HandleProvision(ctx context.Context, correlationID string, payload protocol.ServerProvisionPayload) {
|
||||
meta := payload.OperationMeta
|
||||
m.log.Info("provision server",
|
||||
"server_id", meta.ServerID,
|
||||
"host_port", payload.HostPort,
|
||||
"version", payload.MinecraftVersion,
|
||||
)
|
||||
|
||||
if !payload.EulaAccepted {
|
||||
m.fail(ctx, correlationID, meta, "EULA_NOT_ACCEPTED", "EULA must be accepted before provisioning")
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if existing, ok := m.servers[meta.ServerID]; ok {
|
||||
m.mu.Unlock()
|
||||
m.log.Info("provision idempotent hit", "server_id", meta.ServerID, "container_id", existing.ContainerID)
|
||||
m.complete(ctx, correlationID, meta, existing.State)
|
||||
return
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emitState(ctx, correlationID, meta, ServerStateProvisioning, "", "")
|
||||
|
||||
containerID, err := m.docker.CreateMinecraftServer(ctx, docker.MinecraftServerSpec{
|
||||
ServerID: meta.ServerID,
|
||||
MinecraftVersion: payload.MinecraftVersion,
|
||||
SoftwareFamily: payload.SoftwareFamily,
|
||||
HostPort: payload.HostPort,
|
||||
RamMb: payload.RamMb,
|
||||
DataPath: payload.DataPath,
|
||||
EulaAccepted: payload.EulaAccepted,
|
||||
})
|
||||
if err != nil {
|
||||
m.fail(ctx, correlationID, meta, "PROVISION_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
rec := &serverRecord{
|
||||
ServerID: meta.ServerID,
|
||||
ContainerID: containerID,
|
||||
HostPort: payload.HostPort,
|
||||
DataPath: payload.DataPath,
|
||||
RamMb: payload.RamMb,
|
||||
State: ServerStateStopped,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.servers[meta.ServerID] = rec
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emitState(ctx, correlationID, meta, ServerStateStopped, "", "")
|
||||
m.complete(ctx, correlationID, meta, ServerStateStopped)
|
||||
}
|
||||
|
||||
// HandleStart starts a provisioned server container.
|
||||
func (m *Manager) HandleStart(ctx context.Context, correlationID string, payload protocol.ServerStartPayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.fail(ctx, correlationID, meta, "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
m.emitState(ctx, correlationID, meta, ServerStateStarting, "", "")
|
||||
|
||||
if err := m.docker.StartContainer(ctx, rec.ContainerID); err != nil {
|
||||
m.setState(meta.ServerID, ServerStateError)
|
||||
m.emitState(ctx, correlationID, meta, ServerStateError, "START_FAILED", err.Error())
|
||||
m.fail(ctx, correlationID, meta, "START_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.setState(meta.ServerID, ServerStateRunning)
|
||||
m.emitState(ctx, correlationID, meta, ServerStateRunning, "", "")
|
||||
m.complete(ctx, correlationID, meta, ServerStateRunning)
|
||||
}
|
||||
|
||||
// HandleStop stops a running server container.
|
||||
func (m *Manager) HandleStop(ctx context.Context, correlationID string, payload protocol.ServerStopPayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.fail(ctx, correlationID, meta, "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
m.emitState(ctx, correlationID, meta, ServerStateStopping, "", "")
|
||||
|
||||
if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil {
|
||||
m.setState(meta.ServerID, ServerStateError)
|
||||
m.emitState(ctx, correlationID, meta, ServerStateError, "STOP_FAILED", err.Error())
|
||||
m.fail(ctx, correlationID, meta, "STOP_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.setState(meta.ServerID, ServerStateStopped)
|
||||
m.emitState(ctx, correlationID, meta, ServerStateStopped, "", "")
|
||||
m.complete(ctx, correlationID, meta, ServerStateStopped)
|
||||
}
|
||||
|
||||
// HandleDelete removes a server container and drops local state.
|
||||
func (m *Manager) HandleDelete(ctx context.Context, correlationID string, meta protocol.OperationMeta) {
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.complete(ctx, correlationID, meta, ServerStateDeleted)
|
||||
return
|
||||
}
|
||||
|
||||
m.emitState(ctx, correlationID, meta, ServerStateDeleting, "", "")
|
||||
|
||||
if state, err := m.docker.GetContainerState(ctx, rec.ContainerID); err == nil && state.Running {
|
||||
if err := m.docker.StopContainer(ctx, rec.ContainerID); err != nil {
|
||||
m.fail(ctx, correlationID, meta, "DELETE_STOP_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.docker.RemoveContainer(ctx, rec.ContainerID); err != nil {
|
||||
m.fail(ctx, correlationID, meta, "DELETE_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
delete(m.servers, meta.ServerID)
|
||||
m.mu.Unlock()
|
||||
|
||||
m.emitState(ctx, correlationID, meta, ServerStateDeleted, "", "")
|
||||
m.complete(ctx, correlationID, meta, ServerStateDeleted)
|
||||
}
|
||||
|
||||
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
rec, ok := m.servers[serverID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
copy := *rec
|
||||
return ©, true
|
||||
}
|
||||
|
||||
func (m *Manager) setState(serverID, state string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if rec, ok := m.servers[serverID]; ok {
|
||||
rec.State = state
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) emitState(ctx context.Context, correlationID string, meta protocol.OperationMeta, state, errorCode, errorMessage string) {
|
||||
payload := protocol.ServerStatePayload{
|
||||
OperationMeta: meta,
|
||||
State: state,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMessage: errorMessage,
|
||||
}
|
||||
if err := m.resp.SendState(ctx, correlationID, payload); err != nil {
|
||||
m.log.Warn("send server.state failed",
|
||||
"server_id", meta.ServerID,
|
||||
"state", state,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) complete(ctx context.Context, correlationID string, meta protocol.OperationMeta, _ string) {
|
||||
payload := protocol.ServerOperationResultPayload{
|
||||
OperationMeta: meta,
|
||||
ResultCode: ResultCodeOK,
|
||||
}
|
||||
if err := m.resp.SendOperationComplete(ctx, correlationID, payload); err != nil {
|
||||
m.log.Warn("send operation complete failed",
|
||||
"server_id", meta.ServerID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) fail(ctx context.Context, correlationID string, meta protocol.OperationMeta, errorCode, errorMessage string) {
|
||||
m.emitState(ctx, correlationID, meta, ServerStateError, errorCode, errorMessage)
|
||||
payload := protocol.ServerOperationResultPayload{
|
||||
OperationMeta: meta,
|
||||
ResultCode: ResultCodeError,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMessage: errorMessage,
|
||||
}
|
||||
if err := m.resp.SendOperationFailed(ctx, correlationID, payload); err != nil {
|
||||
m.log.Warn("send operation failed failed",
|
||||
"server_id", meta.ServerID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
357
apps/node-agent/internal/ws/client.go
Normal file
357
apps/node-agent/internal/ws/client.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/config"
|
||||
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
|
||||
runtimepkg "github.com/hexahost/gamecloud/node-agent/internal/runtime"
|
||||
)
|
||||
|
||||
const (
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
maxReconnectDelay = 60 * time.Second
|
||||
)
|
||||
|
||||
// Client maintains an outbound WebSocket connection to the control plane.
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
runtime *runtimepkg.Manager
|
||||
agentVersion string
|
||||
|
||||
writeMu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
}
|
||||
|
||||
// NewClient creates a WebSocket client for the control plane.
|
||||
func NewClient(cfg *config.Config, log *slog.Logger, mgr *runtimepkg.Manager, agentVersion string) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
runtime: mgr,
|
||||
agentVersion: agentVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// SetRuntime attaches the runtime manager after construction to break init cycles.
|
||||
func (c *Client) SetRuntime(mgr *runtimepkg.Manager) {
|
||||
c.runtime = mgr
|
||||
}
|
||||
|
||||
// Run connects to the control plane and processes messages until ctx is cancelled.
|
||||
func (c *Client) Run(ctx context.Context) error {
|
||||
backoff := c.cfg.ReconnectBackoff
|
||||
if backoff <= 0 {
|
||||
backoff = 5 * time.Second
|
||||
}
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
err := c.session(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
c.log.Warn("websocket disconnected", "error", err, "retry_in", backoff.String())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
|
||||
backoff *= 2
|
||||
if backoff > maxReconnectDelay {
|
||||
backoff = maxReconnectDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) session(ctx context.Context) error {
|
||||
conn, err := c.dial(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
c.conn = conn
|
||||
c.writeMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.writeMu.Lock()
|
||||
c.conn = nil
|
||||
c.writeMu.Unlock()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
})
|
||||
|
||||
if err := c.sendHello(ctx); err != nil {
|
||||
return fmt.Errorf("send hello: %w", err)
|
||||
}
|
||||
|
||||
sessionCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go func() {
|
||||
errCh <- c.heartbeatLoop(sessionCtx)
|
||||
}()
|
||||
go func() {
|
||||
errCh <- c.readLoop(sessionCtx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
return ctx.Err()
|
||||
case err := <-errCh:
|
||||
cancel()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) dial(ctx context.Context) (*websocket.Conn, error) {
|
||||
endpoint, err := c.buildURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dialer := websocket.DefaultDialer
|
||||
conn, _, err := dialer.DialContext(ctx, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial websocket: %w", err)
|
||||
}
|
||||
c.log.Info("connected to control plane", "url", redactToken(endpoint))
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *Client) buildURL() (string, error) {
|
||||
u, err := url.Parse(c.cfg.ControlPlaneURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse control plane url: %w", err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("nodeId", c.cfg.NodeID)
|
||||
if c.cfg.NodeToken != "" {
|
||||
q.Set("token", c.cfg.NodeToken)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c *Client) sendHello(ctx context.Context) error {
|
||||
hostname, _ := os.Hostname()
|
||||
payload := protocol.AgentHelloPayload{
|
||||
NodeID: c.cfg.NodeID,
|
||||
AgentVersion: c.agentVersion,
|
||||
ProtocolVersion: protocol.CurrentProtocolVersion,
|
||||
Hostname: hostname,
|
||||
Capabilities: []string{"minecraft", "docker"},
|
||||
}
|
||||
return c.send(ctx, protocol.TypeAgentHello, "", payload)
|
||||
}
|
||||
|
||||
func (c *Client) heartbeatLoop(ctx context.Context) error {
|
||||
ticker := time.NewTicker(c.cfg.HeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
pingTicker := time.NewTicker(pingPeriod)
|
||||
defer pingTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if err := c.sendHeartbeat(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
case <-pingTicker.C:
|
||||
if err := c.writePing(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) sendHeartbeat(ctx context.Context) error {
|
||||
var memStats runtime.MemStats
|
||||
runtime.ReadMemStats(&memStats)
|
||||
|
||||
payload := protocol.AgentHeartbeatPayload{
|
||||
NodeID: c.cfg.NodeID,
|
||||
CPUUsagePercent: 0,
|
||||
MemoryUsedBytes: int64(memStats.Alloc),
|
||||
MemoryTotalBytes: int64(memStats.Sys),
|
||||
RunningServers: c.runtime.RunningCount(),
|
||||
}
|
||||
return c.send(ctx, protocol.TypeAgentHeartbeat, "", payload)
|
||||
}
|
||||
|
||||
func (c *Client) readLoop(ctx context.Context) error {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
conn := c.conn
|
||||
c.writeMu.Unlock()
|
||||
if conn == nil {
|
||||
return fmt.Errorf("connection closed")
|
||||
}
|
||||
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read message: %w", err)
|
||||
}
|
||||
|
||||
env, err := protocol.DecodeEnvelope(data)
|
||||
if err != nil {
|
||||
c.log.Warn("invalid inbound envelope", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
c.dispatch(ctx, env)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
|
||||
correlationID := env.CorrelationID
|
||||
if correlationID == "" {
|
||||
correlationID = env.MessageID
|
||||
}
|
||||
|
||||
switch env.Type {
|
||||
case protocol.TypeServerProvision:
|
||||
var payload protocol.ServerProvisionPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.provision", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleProvision(ctx, correlationID, payload)
|
||||
|
||||
case protocol.TypeServerStart:
|
||||
var payload protocol.ServerStartPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.start", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleStart(ctx, correlationID, payload)
|
||||
|
||||
case protocol.TypeServerStop:
|
||||
var payload protocol.ServerStopPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.stop", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleStop(ctx, correlationID, payload)
|
||||
|
||||
case protocol.TypeServerDelete:
|
||||
var meta protocol.OperationMeta
|
||||
if err := json.Unmarshal(env.Payload, &meta); err != nil {
|
||||
c.log.Warn("decode server.delete", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleDelete(ctx, correlationID, meta)
|
||||
|
||||
default:
|
||||
c.log.Debug("ignored control message", "type", env.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) send(ctx context.Context, msgType protocol.MessageType, correlationID string, payload any) error {
|
||||
env, err := protocol.NewEnvelope(msgType, uuid.NewString(), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if correlationID != "" {
|
||||
env.CorrelationID = correlationID
|
||||
}
|
||||
return c.writeEnvelope(ctx, env)
|
||||
}
|
||||
|
||||
func (c *Client) writeEnvelope(ctx context.Context, env *protocol.Envelope) error {
|
||||
data, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal envelope: %w", err)
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
conn := c.conn
|
||||
c.writeMu.Unlock()
|
||||
if conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(writeWait)
|
||||
}
|
||||
if err := conn.SetWriteDeadline(deadline); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
return c.conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
|
||||
func (c *Client) writePing() error {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
if c.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
return c.conn.WriteMessage(websocket.PingMessage, nil)
|
||||
}
|
||||
|
||||
// SendState implements runtime.Responder.
|
||||
func (c *Client) SendState(ctx context.Context, correlationID string, payload protocol.ServerStatePayload) error {
|
||||
return c.send(ctx, protocol.TypeServerState, correlationID, payload)
|
||||
}
|
||||
|
||||
// SendOperationComplete implements runtime.Responder.
|
||||
func (c *Client) SendOperationComplete(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
|
||||
return c.send(ctx, protocol.TypeServerOperationComplete, correlationID, payload)
|
||||
}
|
||||
|
||||
// SendOperationFailed implements runtime.Responder.
|
||||
func (c *Client) SendOperationFailed(ctx context.Context, correlationID string, payload protocol.ServerOperationResultPayload) error {
|
||||
return c.send(ctx, protocol.TypeServerOperationFailed, correlationID, payload)
|
||||
}
|
||||
|
||||
func redactToken(rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
if u.Query().Get("token") != "" {
|
||||
q := u.Query()
|
||||
q.Set("token", "REDACTED")
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
@@ -14,7 +14,8 @@
|
||||
"submit": "Absenden",
|
||||
"cancel": "Abbrechen",
|
||||
"back": "Zurück",
|
||||
"footer": "Selbstgehostete Minecraft-Server-Plattform."
|
||||
"footer": "Selbstgehostete Minecraft-Server-Plattform.",
|
||||
"servers": "Server"
|
||||
},
|
||||
"landing": {
|
||||
"badge": "Phase 0 · Control Plane",
|
||||
@@ -95,12 +96,20 @@
|
||||
"welcome": "Willkommen in Ihrem Kontrollbereich.",
|
||||
"servers": "Server",
|
||||
"serversEmpty": "Noch keine Server",
|
||||
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, sobald die Provisionierung verfügbar ist.",
|
||||
"serversEmptyDescription": "Erstellen Sie Ihren ersten Minecraft-Server, um loszulegen.",
|
||||
"createServer": "Server erstellen",
|
||||
"status": "Status",
|
||||
"resources": "Ressourcen",
|
||||
"activity": "Aktivität",
|
||||
"activityEmpty": "Keine aktuellen Ereignisse"
|
||||
"activityEmpty": "Keine aktuellen Ereignisse",
|
||||
"controlPlaneOnline": "Control Plane · Online",
|
||||
"activeServers": "Aktive Server",
|
||||
"resourcesSubtitle": "Zugewiesen / Kontingent",
|
||||
"allocatedRam": "Zugewiesener RAM",
|
||||
"activitySubtitle": "Übersicht",
|
||||
"totalServers": "Server gesamt",
|
||||
"viewAllServers": "Alle Server anzeigen",
|
||||
"serversSummary": "Server in Ihrem Konto"
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "E-Mail ist erforderlich",
|
||||
@@ -111,6 +120,66 @@
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"usernameInvalid": "Benutzername: 3–32 Zeichen (Buchstaben, Zahlen, _ und -)",
|
||||
"codeRequired": "Authentifizierungscode ist erforderlich",
|
||||
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein"
|
||||
"codeInvalid": "Geben Sie einen gültigen 6-stelligen Code ein",
|
||||
"serverNameRequired": "Servername ist erforderlich",
|
||||
"serverNameMax": "Servername darf höchstens 64 Zeichen lang sein",
|
||||
"planRequired": "Bitte wählen Sie einen Tarif",
|
||||
"planInvalid": "Ungültiger Tarif ausgewählt",
|
||||
"versionRequired": "Minecraft-Version ist erforderlich",
|
||||
"softwareRequired": "Software-Familie ist erforderlich",
|
||||
"eulaRequired": "Sie müssen die Minecraft-EULA akzeptieren"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Server",
|
||||
"subtitle": "Verwalten Sie Ihre Minecraft-Server.",
|
||||
"create": "Server erstellen",
|
||||
"createTitle": "Neuer Server",
|
||||
"createDescription": "Konfigurieren und provisionieren Sie einen neuen Minecraft-Server.",
|
||||
"name": "Servername",
|
||||
"plan": "Tarif",
|
||||
"selectPlan": "Tarif auswählen",
|
||||
"noPlans": "Keine Tarife verfügbar. Bitten Sie einen Administrator, einen Tarif anzulegen.",
|
||||
"software": "Software",
|
||||
"version": "Minecraft-Version",
|
||||
"eula": "Ich akzeptiere die Minecraft End User License Agreement (EULA).",
|
||||
"loading": "Wird geladen…",
|
||||
"cancel": "Abbrechen",
|
||||
"emptyTitle": "Noch keine Server",
|
||||
"emptyDescription": "Erstellen Sie Ihren ersten Server, um zu spielen.",
|
||||
"address": "Adresse",
|
||||
"addressPending": "Zuweisung ausstehend",
|
||||
"ram": "RAM",
|
||||
"start": "Starten",
|
||||
"stop": "Stoppen",
|
||||
"details": "Details",
|
||||
"detailSubtitle": "Serverdetails und Lifecycle-Steuerung",
|
||||
"backToList": "Zurück zur Serverliste",
|
||||
"retry": "Erneut versuchen",
|
||||
"status": {
|
||||
"DRAFT": "Entwurf",
|
||||
"PROVISIONING": "Provisionierung",
|
||||
"INSTALLING": "Installation",
|
||||
"STOPPED": "Gestoppt",
|
||||
"STARTING": "Startet",
|
||||
"RUNNING": "Läuft",
|
||||
"STOPPING": "Stoppt",
|
||||
"ERROR": "Fehler",
|
||||
"DELETING": "Wird gelöscht",
|
||||
"DELETED": "Gelöscht"
|
||||
},
|
||||
"softwareOptions": {
|
||||
"VANILLA": "Vanilla",
|
||||
"PAPER": "Paper",
|
||||
"PURPUR": "Purpur",
|
||||
"FABRIC": "Fabric",
|
||||
"FORGE": "Forge",
|
||||
"NEOFORGE": "NeoForge",
|
||||
"QUILT": "Quilt",
|
||||
"BEDROCK_DEDICATED": "Bedrock Dedicated"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
|
||||
"loadFailed": "Server konnten nicht geladen werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"submit": "Submit",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"footer": "Self-hosted Minecraft server platform."
|
||||
"footer": "Self-hosted Minecraft server platform.",
|
||||
"servers": "Servers"
|
||||
},
|
||||
"landing": {
|
||||
"badge": "Phase 0 · Control Plane",
|
||||
@@ -95,12 +96,20 @@
|
||||
"welcome": "Welcome to your control area.",
|
||||
"servers": "Servers",
|
||||
"serversEmpty": "No servers yet",
|
||||
"serversEmptyDescription": "Create your first Minecraft server once provisioning is available.",
|
||||
"serversEmptyDescription": "Create your first Minecraft server to get started.",
|
||||
"createServer": "Create server",
|
||||
"status": "Status",
|
||||
"resources": "Resources",
|
||||
"activity": "Activity",
|
||||
"activityEmpty": "No recent events"
|
||||
"activityEmpty": "No recent events",
|
||||
"controlPlaneOnline": "Control Plane · Online",
|
||||
"activeServers": "Active servers",
|
||||
"resourcesSubtitle": "Allocated / Quota",
|
||||
"allocatedRam": "Allocated RAM",
|
||||
"activitySubtitle": "Overview",
|
||||
"totalServers": "Total servers",
|
||||
"viewAllServers": "View all servers",
|
||||
"serversSummary": "Servers in your account"
|
||||
},
|
||||
"validation": {
|
||||
"emailRequired": "Email is required",
|
||||
@@ -111,6 +120,66 @@
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"usernameInvalid": "Username must be 3–32 characters (letters, numbers, _ and -)",
|
||||
"codeRequired": "Authentication code is required",
|
||||
"codeInvalid": "Enter a valid 6-digit code"
|
||||
"codeInvalid": "Enter a valid 6-digit code",
|
||||
"serverNameRequired": "Server name is required",
|
||||
"serverNameMax": "Server name must be at most 64 characters",
|
||||
"planRequired": "Please select a plan",
|
||||
"planInvalid": "Invalid plan selected",
|
||||
"versionRequired": "Minecraft version is required",
|
||||
"softwareRequired": "Software family is required",
|
||||
"eulaRequired": "You must accept the Minecraft EULA"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Servers",
|
||||
"subtitle": "Manage your Minecraft servers.",
|
||||
"create": "Create server",
|
||||
"createTitle": "New server",
|
||||
"createDescription": "Configure and provision a new Minecraft server.",
|
||||
"name": "Server name",
|
||||
"plan": "Plan",
|
||||
"selectPlan": "Select a plan",
|
||||
"noPlans": "No plans are available. Ask an administrator to create a plan.",
|
||||
"software": "Software",
|
||||
"version": "Minecraft version",
|
||||
"eula": "I accept the Minecraft End User License Agreement (EULA).",
|
||||
"loading": "Loading…",
|
||||
"cancel": "Cancel",
|
||||
"emptyTitle": "No servers yet",
|
||||
"emptyDescription": "Create your first server to start playing.",
|
||||
"address": "Address",
|
||||
"addressPending": "Pending allocation",
|
||||
"ram": "RAM",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"details": "Details",
|
||||
"detailSubtitle": "Server details and lifecycle controls",
|
||||
"backToList": "Back to servers",
|
||||
"retry": "Retry",
|
||||
"status": {
|
||||
"DRAFT": "Draft",
|
||||
"PROVISIONING": "Provisioning",
|
||||
"INSTALLING": "Installing",
|
||||
"STOPPED": "Stopped",
|
||||
"STARTING": "Starting",
|
||||
"RUNNING": "Running",
|
||||
"STOPPING": "Stopping",
|
||||
"ERROR": "Error",
|
||||
"DELETING": "Deleting",
|
||||
"DELETED": "Deleted"
|
||||
},
|
||||
"softwareOptions": {
|
||||
"VANILLA": "Vanilla",
|
||||
"PAPER": "Paper",
|
||||
"PURPUR": "Purpur",
|
||||
"FABRIC": "Fabric",
|
||||
"FORGE": "Forge",
|
||||
"NEOFORGE": "NeoForge",
|
||||
"QUILT": "Quilt",
|
||||
"BEDROCK_DEDICATED": "Bedrock Dedicated"
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Something went wrong. Please try again.",
|
||||
"loadFailed": "Could not load servers."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ export default async function DashboardLayout({
|
||||
{t("dashboard")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||
>
|
||||
{t("servers")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<DashboardUserInfo />
|
||||
|
||||
13
apps/web/src/app/[locale]/servers/[id]/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ServerDetailContent } from "@/components/servers/server-detail-content";
|
||||
|
||||
interface ServerDetailPageProps {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}
|
||||
|
||||
export default async function ServerDetailPage({ params }: ServerDetailPageProps) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <ServerDetailContent serverId={id} />;
|
||||
}
|
||||
45
apps/web/src/app/[locale]/servers/layout.tsx
Normal file
45
apps/web/src/app/[locale]/servers/layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info";
|
||||
|
||||
interface ServersLayoutProps {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function ServersLayout({ children, params }: ServersLayoutProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("common");
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||
<div className="mb-6 flex flex-col gap-4 border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:flex-row sm:items-center sm:justify-between">
|
||||
<nav aria-label="App navigation">
|
||||
<ul className="flex gap-4 text-sm">
|
||||
<li>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||
>
|
||||
{t("dashboard")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="font-medium text-zinc-900 dark:text-zinc-50"
|
||||
aria-current="page"
|
||||
>
|
||||
{t("servers")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<DashboardUserInfo />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
apps/web/src/app/[locale]/servers/new/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/new/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { CreateServerForm } from "@/components/servers/create-server-form";
|
||||
|
||||
interface NewServerPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function NewServerPage({ params }: NewServerPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <CreateServerForm />;
|
||||
}
|
||||
34
apps/web/src/app/[locale]/servers/page.tsx
Normal file
34
apps/web/src/app/[locale]/servers/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { ServerList } from "@/components/servers/server-list";
|
||||
|
||||
interface ServersPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export default async function ServersPage({ params }: ServersPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations("servers");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("subtitle")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/servers/new"
|
||||
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
|
||||
>
|
||||
{t("create")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ServerList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { EmptyState } from "@/components/dashboard/empty-state";
|
||||
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
|
||||
import { listServers } from "@/lib/api/servers";
|
||||
|
||||
export function DashboardContent() {
|
||||
const t = useTranslations("dashboard");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["servers"],
|
||||
queryFn: listServers,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setIsLoading(false), 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
const servers = data?.servers ?? [];
|
||||
const activeCount = servers.filter((server) =>
|
||||
["RUNNING", "STARTING", "STOPPING"].includes(server.status),
|
||||
).length;
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton />;
|
||||
@@ -32,49 +38,83 @@ export function DashboardContent() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("status")}</CardTitle>
|
||||
<CardDescription>Control Plane · Online</CardDescription>
|
||||
<CardDescription>{t("controlPlaneOnline")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-mono text-2xl font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
0
|
||||
{activeCount}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">Active servers</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activeServers")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("resources")}</CardTitle>
|
||||
<CardDescription>Allocated / Quota</CardDescription>
|
||||
<CardDescription>{t("resourcesSubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
0 / 0
|
||||
{servers.reduce((sum, server) => sum + server.ramMb, 0)} MB
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">CPU · RAM · Disk</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("allocatedRam")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("activity")}</CardTitle>
|
||||
<CardDescription>Last 24 hours</CardDescription>
|
||||
<CardDescription>{t("activitySubtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("activityEmpty")}</p>
|
||||
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{servers.length}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("totalServers")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="servers-heading">
|
||||
<h2 id="servers-heading" className="mb-4 text-lg font-medium text-zinc-900 dark:text-zinc-50">
|
||||
{t("servers")}
|
||||
</h2>
|
||||
<EmptyState
|
||||
title={t("serversEmpty")}
|
||||
description={t("serversEmptyDescription")}
|
||||
actionLabel={t("createServer")}
|
||||
/>
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 id="servers-heading" className="text-lg font-medium text-zinc-900 dark:text-zinc-50">
|
||||
{t("servers")}
|
||||
</h2>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
|
||||
>
|
||||
{t("viewAllServers")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{servers.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("serversEmpty")}
|
||||
description={t("serversEmptyDescription")}
|
||||
actionLabel={t("createServer")}
|
||||
onAction={() => {
|
||||
router.push("/servers/new");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 py-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("serversSummary")}</p>
|
||||
<p className="text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{servers.length}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/servers/new"
|
||||
className="inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
|
||||
>
|
||||
{t("createServer")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
212
apps/web/src/components/servers/create-server-form.tsx
Normal file
212
apps/web/src/components/servers/create-server-form.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link, useRouter } from "@/i18n/navigation";
|
||||
import { ApiError } from "@/lib/api/auth";
|
||||
import { createServer, listPlans } from "@/lib/api/servers";
|
||||
import {
|
||||
createServerSchema,
|
||||
MINECRAFT_VERSIONS,
|
||||
SOFTWARE_FAMILIES,
|
||||
type CreateServerFormValues,
|
||||
} from "@/lib/schemas/server";
|
||||
|
||||
const inputClassName =
|
||||
"flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100";
|
||||
|
||||
export function CreateServerForm() {
|
||||
const t = useTranslations("servers");
|
||||
const tv = useTranslations("validation");
|
||||
const router = useRouter();
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const { data: plansData, isLoading: plansLoading } = useQuery({
|
||||
queryKey: ["plans"],
|
||||
queryFn: listPlans,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const schema = createServerSchema({
|
||||
nameRequired: tv("serverNameRequired"),
|
||||
nameMax: tv("serverNameMax"),
|
||||
planRequired: tv("planRequired"),
|
||||
planInvalid: tv("planInvalid"),
|
||||
versionRequired: tv("versionRequired"),
|
||||
softwareRequired: tv("softwareRequired"),
|
||||
eulaRequired: tv("eulaRequired"),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateServerFormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
planId: "",
|
||||
minecraftVersion: MINECRAFT_VERSIONS[0],
|
||||
softwareFamily: "VANILLA",
|
||||
eulaAccepted: false,
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createServer,
|
||||
onSuccess: (server) => {
|
||||
router.push(`/servers/${server.id}`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: CreateServerFormValues) {
|
||||
setApiError(null);
|
||||
await createMutation.mutateAsync({
|
||||
...values,
|
||||
eulaAccepted: true,
|
||||
});
|
||||
}
|
||||
|
||||
const plans = plansData?.plans ?? [];
|
||||
|
||||
return (
|
||||
<Card className="max-w-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("createTitle")}</CardTitle>
|
||||
<CardDescription>{t("createDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("name")}
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
className={inputClassName}
|
||||
aria-invalid={errors.name ? "true" : "false"}
|
||||
{...register("name")}
|
||||
/>
|
||||
{errors.name ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{errors.name.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="planId" className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{t("plan")}
|
||||
</label>
|
||||
<select
|
||||
id="planId"
|
||||
className={inputClassName}
|
||||
disabled={plansLoading}
|
||||
aria-invalid={errors.planId ? "true" : "false"}
|
||||
{...register("planId")}
|
||||
>
|
||||
<option value="">{plansLoading ? t("loading") : t("selectPlan")}</option>
|
||||
{plans.map((plan) => (
|
||||
<option key={plan.id} value={plan.id}>
|
||||
{plan.name} ({plan.maxRamMb} MB)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.planId ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{errors.planId.message}</p>
|
||||
) : null}
|
||||
{!plansLoading && plans.length === 0 ? (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">{t("noPlans")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="softwareFamily"
|
||||
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
|
||||
>
|
||||
{t("software")}
|
||||
</label>
|
||||
<select
|
||||
id="softwareFamily"
|
||||
className={inputClassName}
|
||||
aria-invalid={errors.softwareFamily ? "true" : "false"}
|
||||
{...register("softwareFamily")}
|
||||
>
|
||||
{SOFTWARE_FAMILIES.map((family) => (
|
||||
<option key={family} value={family}>
|
||||
{t(`softwareOptions.${family}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.softwareFamily ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{errors.softwareFamily.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="minecraftVersion"
|
||||
className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
|
||||
>
|
||||
{t("version")}
|
||||
</label>
|
||||
<select
|
||||
id="minecraftVersion"
|
||||
className={inputClassName}
|
||||
aria-invalid={errors.minecraftVersion ? "true" : "false"}
|
||||
{...register("minecraftVersion")}
|
||||
>
|
||||
{MINECRAFT_VERSIONS.map((version) => (
|
||||
<option key={version} value={version}>
|
||||
{version}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.minecraftVersion ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{errors.minecraftVersion.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<input type="checkbox" className="mt-1" {...register("eulaAccepted")} />
|
||||
<span>{t("eula")}</span>
|
||||
</label>
|
||||
{errors.eulaAccepted ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{errors.eulaAccepted.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiError ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{apiError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" isLoading={isSubmitting || createMutation.isPending}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="inline-flex h-10 items-center rounded-md border border-zinc-300 bg-white px-4 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{t("cancel")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
113
apps/web/src/components/servers/server-card.tsx
Normal file
113
apps/web/src/components/servers/server-card.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { ApiError } from "@/lib/api/auth";
|
||||
import {
|
||||
type Server,
|
||||
startServer,
|
||||
stopServer,
|
||||
} from "@/lib/api/servers";
|
||||
import { ServerStatusBadge } from "./server-status-badge";
|
||||
|
||||
interface ServerCardProps {
|
||||
server: Server;
|
||||
showDetailsLink?: boolean;
|
||||
}
|
||||
|
||||
export function ServerCard({ server, showDetailsLink = true }: ServerCardProps) {
|
||||
const t = useTranslations("servers");
|
||||
const queryClient = useQueryClient();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => startServer(server.id),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["server", server.id] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setActionError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: () => stopServer(server.id),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["server", server.id] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setActionError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic"));
|
||||
},
|
||||
});
|
||||
|
||||
const canStart = server.status === "STOPPED";
|
||||
const canStop = server.status === "RUNNING";
|
||||
const isBusy = ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(server.status);
|
||||
const address =
|
||||
server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle className="truncate text-lg">{server.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{server.softwareFamily} · {server.minecraftVersion}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<ServerStatusBadge status={server.status} label={t(`status.${server.status}`)} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-zinc-500 dark:text-zinc-400">{t("address")}</dt>
|
||||
<dd className="font-mono text-zinc-900 dark:text-zinc-100">{address}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500 dark:text-zinc-400">{t("ram")}</dt>
|
||||
<dd className="text-zinc-900 dark:text-zinc-100">{server.ramMb} MB</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{actionError ? (
|
||||
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => startMutation.mutate()}
|
||||
disabled={!canStart || isBusy || startMutation.isPending || stopMutation.isPending}
|
||||
>
|
||||
{t("start")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => stopMutation.mutate()}
|
||||
disabled={!canStop || isBusy || startMutation.isPending || stopMutation.isPending}
|
||||
>
|
||||
{t("stop")}
|
||||
</Button>
|
||||
{showDetailsLink ? (
|
||||
<Link
|
||||
href={`/servers/${server.id}`}
|
||||
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{t("details")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
73
apps/web/src/components/servers/server-detail-content.tsx
Normal file
73
apps/web/src/components/servers/server-detail-content.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@hexahost/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { getServer } from "@/lib/api/servers";
|
||||
import { ServerCard } from "@/components/servers/server-card";
|
||||
|
||||
interface ServerDetailContentProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export function ServerDetailContent({ serverId }: ServerDetailContentProps) {
|
||||
const t = useTranslations("servers");
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["server", serverId],
|
||||
queryFn: () => getServer(serverId),
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
if (!status) {
|
||||
return false;
|
||||
}
|
||||
return ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(status)
|
||||
? 3000
|
||||
: false;
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="inline-flex h-8 items-center rounded-md border border-zinc-300 bg-white px-3 text-sm font-medium text-zinc-900 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||
{data.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{t("detailSubtitle")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/servers"
|
||||
className="inline-flex h-8 items-center text-sm font-medium text-sky-600 hover:text-sky-500 dark:text-sky-400"
|
||||
>
|
||||
{t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ServerCard server={data} showDetailsLink={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
apps/web/src/components/servers/server-list.tsx
Normal file
56
apps/web/src/components/servers/server-list.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@hexahost/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { listServers } from "@/lib/api/servers";
|
||||
import { ServerCard } from "./server-card";
|
||||
|
||||
export function ServerList() {
|
||||
const t = useTranslations("servers");
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["servers"],
|
||||
queryFn: listServers,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900 dark:bg-red-950/40">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{t("errors.loadFailed")}</p>
|
||||
<Button size="sm" variant="secondary" onClick={() => void refetch()}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const servers = data?.servers ?? [];
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center dark:border-zinc-700">
|
||||
<h2 className="text-lg font-medium text-zinc-900 dark:text-zinc-50">{t("emptyTitle")}</h2>
|
||||
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">{t("emptyDescription")}</p>
|
||||
<Link
|
||||
href="/servers/new"
|
||||
className="mt-4 inline-flex h-10 items-center rounded-md bg-sky-600 px-4 text-sm font-medium text-white hover:bg-sky-500 dark:bg-sky-500 dark:hover:bg-sky-400"
|
||||
>
|
||||
{t("create")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{servers.map((server) => (
|
||||
<ServerCard key={server.id} server={server} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/components/servers/server-status-badge.tsx
Normal file
37
apps/web/src/components/servers/server-status-badge.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@hexahost/ui";
|
||||
import type { GameServerStatus } from "@/lib/api/servers";
|
||||
|
||||
const statusStyles: Record<GameServerStatus, string> = {
|
||||
DRAFT: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
|
||||
PROVISIONING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
||||
INSTALLING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
||||
STOPPED: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
|
||||
STARTING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
|
||||
RUNNING: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
|
||||
STOPPING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
|
||||
ERROR: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
||||
DELETING: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
||||
DELETED: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-500",
|
||||
};
|
||||
|
||||
interface ServerStatusBadgeProps {
|
||||
status: GameServerStatus;
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ServerStatusBadge({ status, label, className }: ServerStatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||
statusStyles[status],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
104
apps/web/src/lib/api/servers.ts
Normal file
104
apps/web/src/lib/api/servers.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { apiFetch } from "./auth";
|
||||
|
||||
export type GameServerStatus =
|
||||
| "DRAFT"
|
||||
| "PROVISIONING"
|
||||
| "INSTALLING"
|
||||
| "STOPPED"
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPING"
|
||||
| "ERROR"
|
||||
| "DELETING"
|
||||
| "DELETED";
|
||||
|
||||
export type SoftwareFamily =
|
||||
| "VANILLA"
|
||||
| "PAPER"
|
||||
| "PURPUR"
|
||||
| "FABRIC"
|
||||
| "FORGE"
|
||||
| "NEOFORGE"
|
||||
| "QUILT"
|
||||
| "BEDROCK_DEDICATED";
|
||||
|
||||
export interface Server {
|
||||
id: string;
|
||||
userId: string;
|
||||
planId: string | null;
|
||||
nodeId: string | null;
|
||||
name: string;
|
||||
status: GameServerStatus;
|
||||
version: number;
|
||||
edition: "JAVA" | "BEDROCK";
|
||||
softwareFamily: SoftwareFamily;
|
||||
minecraftVersion: string;
|
||||
eulaAccepted: boolean;
|
||||
hostPort: number | null;
|
||||
containerId: string | null;
|
||||
dataPath: string | null;
|
||||
ramMb: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ServerListResponse {
|
||||
servers: Server[];
|
||||
}
|
||||
|
||||
export interface ServerActionResponse {
|
||||
server: Server;
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
export interface CreateServerPayload {
|
||||
name: string;
|
||||
planId?: string;
|
||||
minecraftVersion: string;
|
||||
softwareFamily: SoftwareFamily;
|
||||
eulaAccepted: true;
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
isFree: boolean;
|
||||
maxRamMb: number;
|
||||
}
|
||||
|
||||
export interface PlanListResponse {
|
||||
plans: Plan[];
|
||||
}
|
||||
|
||||
export async function listServers(): Promise<ServerListResponse> {
|
||||
return apiFetch<ServerListResponse>("/servers");
|
||||
}
|
||||
|
||||
export async function getServer(id: string): Promise<Server> {
|
||||
return apiFetch<Server>(`/servers/${id}`);
|
||||
}
|
||||
|
||||
export async function createServer(payload: CreateServerPayload): Promise<Server> {
|
||||
return apiFetch<Server>("/servers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startServer(id: string): Promise<ServerActionResponse> {
|
||||
return apiFetch<ServerActionResponse>(`/servers/${id}/start`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopServer(id: string): Promise<ServerActionResponse> {
|
||||
return apiFetch<ServerActionResponse>(`/servers/${id}/stop`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPlans(): Promise<PlanListResponse> {
|
||||
return apiFetch<PlanListResponse>("/plans");
|
||||
}
|
||||
46
apps/web/src/lib/schemas/server.ts
Normal file
46
apps/web/src/lib/schemas/server.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const softwareFamilyValues = [
|
||||
"VANILLA",
|
||||
"PAPER",
|
||||
"PURPUR",
|
||||
"FABRIC",
|
||||
"FORGE",
|
||||
"NEOFORGE",
|
||||
"QUILT",
|
||||
"BEDROCK_DEDICATED",
|
||||
] as const;
|
||||
|
||||
export function createServerSchema(messages: {
|
||||
nameRequired: string;
|
||||
nameMax: string;
|
||||
planRequired: string;
|
||||
planInvalid: string;
|
||||
versionRequired: string;
|
||||
softwareRequired: string;
|
||||
eulaRequired: string;
|
||||
}) {
|
||||
return z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, messages.nameRequired)
|
||||
.max(64, messages.nameMax),
|
||||
planId: z
|
||||
.string()
|
||||
.min(1, messages.planRequired)
|
||||
.uuid(messages.planInvalid),
|
||||
minecraftVersion: z.string().min(1, messages.versionRequired),
|
||||
softwareFamily: z.enum(softwareFamilyValues, {
|
||||
errorMap: () => ({ message: messages.softwareRequired }),
|
||||
}),
|
||||
eulaAccepted: z
|
||||
.boolean()
|
||||
.refine((value) => value === true, { message: messages.eulaRequired }),
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateServerFormValues = z.infer<ReturnType<typeof createServerSchema>>;
|
||||
|
||||
export const MINECRAFT_VERSIONS = ["1.21.1", "1.21", "1.20.6", "1.20.4"] as const;
|
||||
|
||||
export const SOFTWARE_FAMILIES = softwareFamilyValues;
|
||||
@@ -4,7 +4,7 @@ import { routing } from "./i18n/routing";
|
||||
|
||||
const intlMiddleware = createMiddleware(routing);
|
||||
|
||||
const protectedPaths = ["/dashboard", "/security"];
|
||||
const protectedPaths = ["/dashboard", "/security", "/servers"];
|
||||
|
||||
function getPathWithoutLocale(pathname: string): string {
|
||||
if (pathname === "/en" || pathname.startsWith("/en/")) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,6 +15,7 @@
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"bullmq": "^5.34.10",
|
||||
"ioredis": "^5.4.2",
|
||||
"nodemailer": "^6.10.0",
|
||||
"pino": "^9.6.0",
|
||||
"zod": "^3.24.2"
|
||||
|
||||
128
apps/worker/src/handlers/server-lifecycle.ts
Normal file
128
apps/worker/src/handlers/server-lifecycle.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Job } from 'bullmq';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { prisma } from '@hexahost/database';
|
||||
|
||||
import { logger } from '../logger';
|
||||
import { publishNodeCommand } from '../redis';
|
||||
import { transitionServerStatus } from '../server-state';
|
||||
|
||||
const lifecycleJobSchema = z.object({
|
||||
serverId: z.string().uuid(),
|
||||
});
|
||||
|
||||
async function loadServerForLifecycle(serverId: string) {
|
||||
const server = await prisma.gameServer.findUnique({
|
||||
where: { id: serverId },
|
||||
});
|
||||
|
||||
if (!server) {
|
||||
throw new Error(`Server not found: ${serverId}`);
|
||||
}
|
||||
|
||||
if (!server.nodeId) {
|
||||
throw new Error(`Server ${serverId} is not assigned to a node`);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
async function publishLifecycleCommand(
|
||||
serverId: string,
|
||||
nodeId: string,
|
||||
type: 'server.start' | 'server.stop',
|
||||
generation: number,
|
||||
): Promise<string> {
|
||||
const messageId = randomUUID();
|
||||
|
||||
await publishNodeCommand({
|
||||
nodeId,
|
||||
envelope: {
|
||||
protocolVersion: 1,
|
||||
messageId,
|
||||
type,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
serverId,
|
||||
generation,
|
||||
desiredGeneration: generation,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return messageId;
|
||||
}
|
||||
|
||||
export async function processStartServerJob(job: Job): Promise<{ status: string }> {
|
||||
if (job.name !== 'start-server') {
|
||||
logger.warn({ jobName: job.name }, 'Unknown lifecycle job');
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
|
||||
const { serverId } = lifecycleJobSchema.parse(job.data);
|
||||
const server = await loadServerForLifecycle(serverId);
|
||||
|
||||
if (server.status !== 'STOPPED') {
|
||||
throw new Error(`Cannot start server ${serverId} from status ${server.status}`);
|
||||
}
|
||||
|
||||
const messageId = await publishLifecycleCommand(
|
||||
serverId,
|
||||
server.nodeId!,
|
||||
'server.start',
|
||||
server.version,
|
||||
);
|
||||
|
||||
await transitionServerStatus(serverId, 'STARTING', {
|
||||
expectedFrom: 'STOPPED',
|
||||
reason: 'Start requested',
|
||||
correlationId: messageId,
|
||||
});
|
||||
|
||||
logger.info({ serverId, messageId }, 'Server start command published');
|
||||
return { status: 'starting' };
|
||||
}
|
||||
|
||||
export async function processStopServerJob(job: Job): Promise<{ status: string }> {
|
||||
if (job.name !== 'stop-server') {
|
||||
logger.warn({ jobName: job.name }, 'Unknown lifecycle job');
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
|
||||
const { serverId } = lifecycleJobSchema.parse(job.data);
|
||||
const server = await loadServerForLifecycle(serverId);
|
||||
|
||||
if (server.status !== 'RUNNING') {
|
||||
throw new Error(`Cannot stop server ${serverId} from status ${server.status}`);
|
||||
}
|
||||
|
||||
const messageId = await publishLifecycleCommand(
|
||||
serverId,
|
||||
server.nodeId!,
|
||||
'server.stop',
|
||||
server.version,
|
||||
);
|
||||
|
||||
await transitionServerStatus(serverId, 'STOPPING', {
|
||||
expectedFrom: 'RUNNING',
|
||||
reason: 'Stop requested',
|
||||
correlationId: messageId,
|
||||
});
|
||||
|
||||
logger.info({ serverId, messageId }, 'Server stop command published');
|
||||
return { status: 'stopping' };
|
||||
}
|
||||
|
||||
export async function processLifecycleJob(job: Job): Promise<{ status: string }> {
|
||||
switch (job.name) {
|
||||
case 'start-server':
|
||||
return processStartServerJob(job);
|
||||
case 'stop-server':
|
||||
return processStopServerJob(job);
|
||||
default:
|
||||
logger.warn({ jobName: job.name }, 'Unknown lifecycle job name');
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
}
|
||||
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' };
|
||||
}
|
||||
@@ -4,9 +4,18 @@ import { validateConfig } from '@hexahost/config';
|
||||
import { prisma } from '@hexahost/database';
|
||||
|
||||
import { processNotificationJob } from './handlers/notifications';
|
||||
import { processLifecycleJob } from './handlers/server-lifecycle';
|
||||
import { processProvisionServerJob } from './handlers/server-provisioning';
|
||||
import { startHealthServer } from './health';
|
||||
import { logger } from './logger';
|
||||
import { WORKER_QUEUES, type WorkerQueueName } from './queues';
|
||||
import { closeRedisConnections } from './redis';
|
||||
import {
|
||||
QUEUE_NOTIFICATIONS,
|
||||
QUEUE_SERVER_LIFECYCLE,
|
||||
QUEUE_SERVER_PROVISIONING,
|
||||
WORKER_QUEUES,
|
||||
type WorkerQueueName,
|
||||
} from './queues';
|
||||
|
||||
function createQueueWorker(
|
||||
queueName: WorkerQueueName,
|
||||
@@ -20,11 +29,19 @@ function createQueueWorker(
|
||||
'Processing job',
|
||||
);
|
||||
|
||||
if (queueName === 'notifications') {
|
||||
if (queueName === QUEUE_NOTIFICATIONS) {
|
||||
return processNotificationJob(job);
|
||||
}
|
||||
|
||||
return { acknowledged: true, queue: queueName };
|
||||
if (queueName === QUEUE_SERVER_PROVISIONING) {
|
||||
return processProvisionServerJob(job);
|
||||
}
|
||||
|
||||
if (queueName === QUEUE_SERVER_LIFECYCLE) {
|
||||
return processLifecycleJob(job);
|
||||
}
|
||||
|
||||
logger.warn({ queue: queueName, jobName: job.name }, 'Unhandled job');
|
||||
},
|
||||
{ connection },
|
||||
);
|
||||
@@ -62,6 +79,7 @@ async function bootstrap(): Promise<void> {
|
||||
logger.info({ signal }, 'Shutting down worker');
|
||||
|
||||
await Promise.all(workers.map((worker) => worker.close()));
|
||||
await closeRedisConnections();
|
||||
await prisma.$disconnect();
|
||||
|
||||
healthServer.close(() => {
|
||||
|
||||
109
apps/worker/src/redis.ts
Normal file
109
apps/worker/src/redis.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { validateConfig } from '@hexahost/config';
|
||||
|
||||
import { logger } from './logger';
|
||||
|
||||
export const NODE_COMMANDS_CHANNEL = 'hgc:node:commands';
|
||||
|
||||
export interface NodeCommandEnvelope {
|
||||
protocolVersion: number;
|
||||
messageId: string;
|
||||
type: string;
|
||||
timestamp: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NodeCommandMessage {
|
||||
nodeId: string;
|
||||
envelope: NodeCommandEnvelope;
|
||||
}
|
||||
|
||||
export interface NodeResponseMessage {
|
||||
success: boolean;
|
||||
resultCode?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
let publisher: Redis | null = null;
|
||||
|
||||
export function getRedisPublisher(): Redis {
|
||||
if (!publisher) {
|
||||
const config = validateConfig();
|
||||
publisher = new Redis(config.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
}
|
||||
|
||||
return publisher;
|
||||
}
|
||||
|
||||
export async function publishNodeCommand(message: NodeCommandMessage): Promise<void> {
|
||||
const redis = getRedisPublisher();
|
||||
await redis.publish(NODE_COMMANDS_CHANNEL, JSON.stringify(message));
|
||||
logger.info(
|
||||
{ nodeId: message.nodeId, type: message.envelope.type, messageId: message.envelope.messageId },
|
||||
'Published node command',
|
||||
);
|
||||
}
|
||||
|
||||
export function nodeResponseChannel(messageId: string): string {
|
||||
return `hgc:node:responses:${messageId}`;
|
||||
}
|
||||
|
||||
export async function waitForNodeResponse(
|
||||
messageId: string,
|
||||
timeoutMs: number,
|
||||
): Promise<NodeResponseMessage | null> {
|
||||
const config = validateConfig();
|
||||
const subscriber = new Redis(config.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
const channel = nodeResponseChannel(messageId);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (value: NodeResponseMessage | null): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
void subscriber.unsubscribe(channel).finally(() => {
|
||||
subscriber.disconnect();
|
||||
});
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => finish(null), timeoutMs);
|
||||
|
||||
subscriber.on('message', (receivedChannel, message) => {
|
||||
if (receivedChannel !== channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
finish(JSON.parse(message) as NodeResponseMessage);
|
||||
} catch {
|
||||
finish({
|
||||
success: false,
|
||||
errorMessage: 'Invalid node response payload',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
void subscriber.subscribe(channel).catch((error: Error) => {
|
||||
logger.error({ err: error, channel }, 'Failed to subscribe for node response');
|
||||
finish(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeRedisConnections(): Promise<void> {
|
||||
if (publisher) {
|
||||
await publisher.quit();
|
||||
publisher = null;
|
||||
}
|
||||
}
|
||||
58
apps/worker/src/server-state.ts
Normal file
58
apps/worker/src/server-state.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { type Prisma, prisma } from '@hexahost/database';
|
||||
|
||||
type GameServerStatus = Prisma.GameServerGetPayload<{ select: { status: true } }>['status'];
|
||||
|
||||
interface TransitionOptions {
|
||||
expectedFrom?: GameServerStatus | GameServerStatus[];
|
||||
reason?: string;
|
||||
correlationId?: string;
|
||||
actorId?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
extraData?: Prisma.GameServerUpdateInput;
|
||||
}
|
||||
|
||||
export async function transitionServerStatus(
|
||||
serverId: string,
|
||||
toStatus: GameServerStatus,
|
||||
options: TransitionOptions = {},
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const server = await tx.gameServer.findUniqueOrThrow({
|
||||
where: { id: serverId },
|
||||
});
|
||||
|
||||
if (options.expectedFrom) {
|
||||
const allowed = Array.isArray(options.expectedFrom)
|
||||
? options.expectedFrom
|
||||
: [options.expectedFrom];
|
||||
|
||||
if (!allowed.includes(server.status)) {
|
||||
throw new Error(
|
||||
`Invalid state transition for server ${serverId}: ${server.status} -> ${toStatus}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await tx.gameServer.update({
|
||||
where: { id: serverId },
|
||||
data: {
|
||||
status: toStatus,
|
||||
...options.extraData,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.gameServerStateTransition.create({
|
||||
data: {
|
||||
gameServerId: serverId,
|
||||
fromStatus: server.status,
|
||||
toStatus,
|
||||
reason: options.reason,
|
||||
correlationId: options.correlationId,
|
||||
actorId: options.actorId,
|
||||
errorCode: options.errorCode,
|
||||
errorMessage: options.errorMessage,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user