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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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)
}

View File

@@ -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
)

View File

@@ -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)

View 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"
}
}

View File

@@ -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

View File

@@ -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",

View 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 &copy, 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,
)
}
}

View 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()
}

View File

@@ -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: 332 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."
}
}
}

View File

@@ -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 332 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."
}
}
}

View File

@@ -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 />

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

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

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

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

View File

@@ -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>
);

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

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

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

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

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

View 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");
}

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

View File

@@ -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

View File

@@ -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"

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

View File

@@ -0,0 +1,169 @@
import { randomUUID } from 'node:crypto';
import { prisma } from '@hexahost/database';
import { Job } from 'bullmq';
import { z } from 'zod';
import { logger } from '../logger';
import { publishNodeCommand, waitForNodeResponse } from '../redis';
import { transitionServerStatus } from '../server-state';
const BASE_HOST_PORT = 25565;
const PROVISION_TIMEOUT_MS = 5 * 60 * 1000;
export const provisionServerJobSchema = z.object({
serverId: z.string().uuid(),
});
function buildDataPath(serverId: string): string {
return `/var/lib/hgc/servers/${serverId}`;
}
async function findAvailableHostPort(nodeId: string): Promise<number> {
const [allocations, servers] = await Promise.all([
prisma.gameServerAllocation.findMany({
where: { nodeId, status: 'ACTIVE' },
select: { hostPort: true },
}),
prisma.gameServer.findMany({
where: { nodeId, hostPort: { not: null } },
select: { hostPort: true },
}),
]);
const usedPorts = new Set<number>();
for (const allocation of allocations) {
usedPorts.add(allocation.hostPort);
}
for (const server of servers) {
if (server.hostPort !== null) {
usedPorts.add(server.hostPort);
}
}
let port = BASE_HOST_PORT;
while (usedPorts.has(port)) {
port += 1;
}
return port;
}
export async function processProvisionServerJob(job: Job): Promise<{ status: string }> {
if (job.name !== 'provision-server') {
logger.warn({ jobName: job.name }, 'Unknown provisioning job');
return { status: 'ignored' };
}
const { serverId } = provisionServerJobSchema.parse(job.data);
const server = await prisma.gameServer.findUnique({
where: { id: serverId },
include: { plan: true },
});
if (!server) {
throw new Error(`Server not found: ${serverId}`);
}
const node = await prisma.gameNode.findFirst({
where: { status: 'ONLINE' },
orderBy: { createdAt: 'asc' },
});
if (!node) {
await transitionServerStatus(serverId, 'ERROR', {
reason: 'No online game node available',
errorCode: 'NO_NODE_AVAILABLE',
errorMessage: 'No online game node is available for provisioning',
});
throw new Error('No online game node available');
}
const hostPort = await findAvailableHostPort(node.id);
const ramMb = server.plan?.maxRamMb ?? server.ramMb;
const dataPath = buildDataPath(serverId);
const messageId = randomUUID();
await prisma.$transaction(async (tx) => {
await tx.gameServerAllocation.create({
data: {
serverId,
nodeId: node.id,
hostPort,
ramMbReserved: ramMb,
status: 'ACTIVE',
},
});
await tx.gameServer.update({
where: { id: serverId },
data: {
nodeId: node.id,
hostPort,
ramMb,
dataPath,
status: 'PROVISIONING',
},
});
await tx.gameServerStateTransition.create({
data: {
gameServerId: serverId,
fromStatus: server.status,
toStatus: 'PROVISIONING',
reason: 'Worker allocated node resources',
correlationId: messageId,
},
});
});
await publishNodeCommand({
nodeId: node.id,
envelope: {
protocolVersion: 1,
messageId,
type: 'server.provision',
timestamp: new Date().toISOString(),
payload: {
serverId,
generation: server.version,
edition: server.edition,
softwareFamily: server.softwareFamily,
minecraftVersion: server.minecraftVersion,
hostPort,
ramMb,
dataPath,
eulaAccepted: server.eulaAccepted,
},
},
});
const response = await waitForNodeResponse(messageId, PROVISION_TIMEOUT_MS);
if (response === null) {
await transitionServerStatus(serverId, 'INSTALLING', {
reason: 'Provision command sent; awaiting agent completion',
correlationId: messageId,
});
logger.info({ serverId, messageId }, 'Provision timed out; marked INSTALLING');
return { status: 'installing' };
}
if (!response.success) {
await transitionServerStatus(serverId, 'ERROR', {
reason: 'Provision failed',
correlationId: messageId,
errorCode: response.errorCode ?? response.resultCode ?? 'PROVISION_FAILED',
errorMessage: response.errorMessage ?? 'Server provisioning failed',
});
throw new Error(response.errorMessage ?? 'Server provisioning failed');
}
await transitionServerStatus(serverId, 'STOPPED', {
reason: 'Provision completed',
correlationId: messageId,
});
logger.info({ serverId, nodeId: node.id, hostPort }, 'Server provisioned');
return { status: 'stopped' };
}

View File

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

View 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