Phase6
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
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 13:11:28 +02:00
parent d29a02f2a4
commit 49a98ee31d
81 changed files with 1462 additions and 53 deletions

View File

@@ -1,14 +1,19 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import type { NodeListResponse, NodeSummary } 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';
@@ -20,6 +25,12 @@ 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(),
maxRamMb: z.number().int().min(2048).max(1048576).optional(),
platformRamMb: z.number().int().min(512).max(65536).optional(),
});
const maintenanceRequestSchema = z.object({
enabled: z.boolean(),
});
@ApiTags('admin')
@@ -28,6 +39,13 @@ const createNodeRequestSchema = z.object({
export class AdminNodesController {
constructor(private readonly adminNodesService: AdminNodesService) {}
@Get()
@ApiOperation({ summary: 'List game nodes (super admin only)' })
list(@CurrentUser() user: { roles: string[] }): Promise<NodeListResponse> {
this.adminNodesService.assertSuperAdmin(user.roles);
return this.adminNodesService.listNodes();
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Register a new game node (super admin only)' })
@@ -42,4 +60,28 @@ export class AdminNodesController {
body as Parameters<AdminNodesService['createNode']>[0],
);
}
@Post(':id/drain')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Drain a game node (super admin only)' })
drain(
@CurrentUser() user: { roles: string[] },
@Param('id', ParseUUIDPipe) id: string,
): Promise<NodeSummary> {
this.adminNodesService.assertSuperAdmin(user.roles);
return this.adminNodesService.requestDrain(id);
}
@Post(':id/maintenance')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Toggle maintenance mode for a node (super admin only)' })
maintenance(
@CurrentUser() user: { roles: string[] },
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(maintenanceRequestSchema)) body: unknown,
): Promise<NodeSummary> {
this.adminNodesService.assertSuperAdmin(user.roles);
const input = body as z.infer<typeof maintenanceRequestSchema>;
return this.adminNodesService.setMaintenance(id, input.enabled);
}
}

View File

@@ -1,17 +1,27 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
generateSecureToken,
hashToken,
UserRole,
} from '@hexahost/auth';
import type { NodeListResponse, NodeSummary } from '@hexahost/contracts';
import { computeAvailableRamMb } from '@hexahost/scheduler';
import { PrismaService } from '../prisma/prisma.service';
import { loadNodeSnapshots } from '../scheduling/node-snapshots';
export interface CreateNodeInput {
name: string;
hostname: string;
maxServers?: number;
maxRamMb?: number;
platformRamMb?: number;
}
export interface CreateNodeResponse {
@@ -21,6 +31,7 @@ export interface CreateNodeResponse {
hostname: string;
status: string;
maxServers: number;
maxRamMb: number;
createdAt: string;
};
enrollmentToken: string;
@@ -36,6 +47,19 @@ export class AdminNodesService {
}
}
async listNodes(): Promise<NodeListResponse> {
const snapshots = await loadNodeSnapshots();
const nodes = await this.prisma.gameNode.findMany({
orderBy: { createdAt: 'asc' },
});
const snapshotById = new Map(snapshots.map((snapshot) => [snapshot.id, snapshot]));
return {
nodes: nodes.map((node) => this.toSummary(node, snapshotById.get(node.id))),
};
}
async createNode(input: CreateNodeInput): Promise<CreateNodeResponse> {
const enrollmentToken = generateSecureToken();
@@ -44,6 +68,8 @@ export class AdminNodesService {
name: input.name,
hostname: input.hostname,
maxServers: input.maxServers ?? 10,
maxRamMb: input.maxRamMb ?? 16384,
platformRamMb: input.platformRamMb ?? 2048,
enrollmentTokenHash: hashToken(enrollmentToken),
},
});
@@ -55,9 +81,109 @@ export class AdminNodesService {
hostname: node.hostname,
status: node.status,
maxServers: node.maxServers,
maxRamMb: node.maxRamMb,
createdAt: node.createdAt.toISOString(),
},
enrollmentToken,
};
}
async requestDrain(nodeId: string): Promise<NodeSummary> {
const node = await this.requireNode(nodeId);
if (node.status === 'DRAINING' || node.status === 'MAINTENANCE') {
throw new ConflictException('Node is already draining or in maintenance');
}
const updated = await this.prisma.gameNode.update({
where: { id: nodeId },
data: {
status: 'DRAINING',
drainRequestedAt: new Date(),
},
});
const snapshot = (await loadNodeSnapshots([nodeId]))[0];
return this.toSummary(updated, snapshot);
}
async setMaintenance(nodeId: string, enabled: boolean): Promise<NodeSummary> {
const node = await this.requireNode(nodeId);
if (enabled && node.status === 'MAINTENANCE') {
const snapshot = (await loadNodeSnapshots([nodeId]))[0];
return this.toSummary(node, snapshot);
}
const updated = await this.prisma.gameNode.update({
where: { id: nodeId },
data: {
status: enabled ? 'MAINTENANCE' : 'OFFLINE',
drainRequestedAt: null,
},
});
const snapshot = (await loadNodeSnapshots([nodeId]))[0];
return this.toSummary(updated, snapshot);
}
private async requireNode(nodeId: string) {
const node = await this.prisma.gameNode.findUnique({ where: { id: nodeId } });
if (!node) {
throw new NotFoundException('Node not found');
}
return node;
}
private toSummary(
node: {
id: string;
name: string;
hostname: string;
status: NodeSummary['status'];
maxServers: number;
maxRamMb: number;
platformRamMb: number;
activeServers: number;
lastHeartbeatAt: Date | null;
agentVersion: string | null;
drainRequestedAt: Date | null;
createdAt: Date;
},
snapshot?: {
reservedRamMb: number;
},
): NodeSummary {
const reservedRamMb = snapshot?.reservedRamMb ?? 0;
const availableRamMb = snapshot
? computeAvailableRamMb({
id: node.id,
status: node.status,
maxServers: node.maxServers,
maxRamMb: node.maxRamMb,
platformRamMb: node.platformRamMb,
activeServers: node.activeServers,
lastHeartbeatAt: node.lastHeartbeatAt,
reservedRamMb,
activeAllocationCount: 0,
})
: Math.max(0, node.maxRamMb - node.platformRamMb - reservedRamMb);
return {
id: node.id,
name: node.name,
hostname: node.hostname,
status: node.status,
maxServers: node.maxServers,
maxRamMb: node.maxRamMb,
platformRamMb: node.platformRamMb,
activeServers: node.activeServers,
reservedRamMb,
availableRamMb,
lastHeartbeatAt: node.lastHeartbeatAt?.toISOString() ?? null,
agentVersion: node.agentVersion,
drainRequestedAt: node.drainRequestedAt?.toISOString() ?? null,
createdAt: node.createdAt.toISOString(),
};
}
}

View File

@@ -110,10 +110,18 @@ export class NodesService {
}
async markNodeOnline(nodeId: string, agentVersion?: string): Promise<void> {
const node = await this.prisma.gameNode.findUnique({ where: { id: nodeId } });
const nextStatus =
node?.status === 'UNREACHABLE' || node?.status === 'OFFLINE'
? 'ONLINE'
: node?.status === 'MAINTENANCE' || node?.status === 'DRAINING'
? node.status
: 'ONLINE';
await this.prisma.gameNode.update({
where: { id: nodeId },
data: {
status: 'ONLINE',
status: nextStatus,
lastHeartbeatAt: new Date(),
enrolledAt: new Date(),
agentVersion,
@@ -172,17 +180,35 @@ export class NodesService {
payload?: Record<string, unknown>,
): Promise<void> {
const now = new Date();
const node = await this.prisma.gameNode.findUnique({ where: { id: nodeId } });
const nextStatus =
node?.status === 'UNREACHABLE'
? 'ONLINE'
: node?.status === 'MAINTENANCE' || node?.status === 'DRAINING'
? node.status
: 'ONLINE';
await this.prisma.$transaction([
this.prisma.gameNode.update({
where: { id: nodeId },
data: {
lastHeartbeatAt: now,
status: 'ONLINE',
status: nextStatus,
agentVersion:
typeof payload?.['agentVersion'] === 'string'
? payload['agentVersion']
: undefined,
metadata: payload
? ({
cpuUsagePercent: payload['cpuUsagePercent'],
memoryUsedBytes: payload['memoryUsedBytes'],
memoryTotalBytes: payload['memoryTotalBytes'],
diskUsedBytes: payload['diskUsedBytes'],
diskTotalBytes: payload['diskTotalBytes'],
runningServers: payload['runningServers'],
} as Prisma.InputJsonValue)
: undefined,
},
}),
this.prisma.gameNodeHeartbeat.create({

View File

@@ -0,0 +1,59 @@
import { prisma } from '@hexahost/database';
import type { NodeSnapshot } from '@hexahost/scheduler';
function readMemoryField(
payload: Record<string, unknown> | null | undefined,
key: 'memoryTotalBytes' | 'memoryUsedBytes',
): number | null {
const value = payload?.[key];
return typeof value === 'number' ? value : null;
}
export async function loadNodeSnapshots(
nodeIds?: string[],
): Promise<NodeSnapshot[]> {
const nodes = await prisma.gameNode.findMany({
where: nodeIds ? { id: { in: nodeIds } } : undefined,
include: {
allocations: {
where: { status: 'ACTIVE' },
select: { ramMbReserved: true },
},
heartbeats: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { payload: true },
},
},
});
return nodes.map((node) => {
const heartbeatPayload = node.heartbeats[0]?.payload as
| Record<string, unknown>
| undefined;
const reservedRamMb = node.allocations.reduce(
(sum, allocation) => sum + allocation.ramMbReserved,
0,
);
return {
id: node.id,
status: node.status,
maxServers: node.maxServers,
maxRamMb: node.maxRamMb,
platformRamMb: node.platformRamMb,
activeServers: node.activeServers,
lastHeartbeatAt: node.lastHeartbeatAt,
memoryTotalBytes: readMemoryField(heartbeatPayload, 'memoryTotalBytes'),
memoryUsedBytes: readMemoryField(heartbeatPayload, 'memoryUsedBytes'),
reservedRamMb,
activeAllocationCount: node.allocations.length,
};
});
}
export async function loadNodeSnapshot(nodeId: string): Promise<NodeSnapshot | null> {
const snapshots = await loadNodeSnapshots([nodeId]);
return snapshots[0] ?? null;
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { SchedulingService } from './scheduling.service';
@Module({
imports: [PrismaModule],
providers: [SchedulingService],
exports: [SchedulingService],
})
export class SchedulingModule {}

View File

@@ -0,0 +1,175 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import type { StartQueueResponse } from '@hexahost/contracts';
import type { GameServer } from '@hexahost/database';
import { canStartOnNode } from '@hexahost/scheduler';
import { PrismaService } from '../prisma/prisma.service';
import { loadNodeSnapshot } from './node-snapshots';
const START_QUEUE_EXPIRY_MS = 30 * 60 * 1000;
@Injectable()
export class SchedulingService {
constructor(private readonly prisma: PrismaService) {}
async canStartServer(server: GameServer): Promise<boolean> {
if (!server.nodeId) {
return false;
}
const snapshot = await loadNodeSnapshot(server.nodeId);
return snapshot ? canStartOnNode(snapshot, server.ramMb) : false;
}
async enqueueStart(
server: GameServer,
actorId: string,
correlationId: string,
): Promise<{ server: GameServer; queuePosition: number }> {
const expiresAt = new Date(Date.now() + START_QUEUE_EXPIRY_MS);
const updated = await this.prisma.$transaction(async (tx) => {
await tx.serverStartQueueEntry.upsert({
where: { serverId: server.id },
create: {
serverId: server.id,
nodeId: server.nodeId!,
status: 'QUEUED',
correlationId,
expiresAt,
},
update: {
status: 'QUEUED',
correlationId,
expiresAt,
requestedAt: new Date(),
},
});
const next = await tx.gameServer.update({
where: { id: server.id },
data: {
status: 'QUEUED',
version: { increment: 1 },
},
});
await tx.gameServerStateTransition.create({
data: {
gameServerId: server.id,
fromStatus: server.status,
toStatus: 'QUEUED',
reason: 'Start queued due to insufficient node capacity',
actorId,
correlationId,
},
});
return next;
});
const queuePosition = await this.getQueuePosition(server.id);
return { server: updated, queuePosition };
}
async getQueuePosition(serverId: string): Promise<number> {
const entry = await this.prisma.serverStartQueueEntry.findUnique({
where: { serverId },
});
if (!entry || entry.status !== 'QUEUED') {
return 0;
}
const ahead = await this.prisma.serverStartQueueEntry.count({
where: {
status: 'QUEUED',
OR: [
{ priority: { gt: entry.priority } },
{
priority: entry.priority,
requestedAt: { lt: entry.requestedAt },
},
],
},
});
return ahead + 1;
}
async getStartQueue(userId: string, serverId: string): Promise<StartQueueResponse> {
const server = await this.prisma.gameServer.findFirst({
where: { id: serverId, userId },
});
if (!server) {
return { entry: null };
}
const entry = await this.prisma.serverStartQueueEntry.findUnique({
where: { serverId },
});
if (!entry || entry.status !== 'QUEUED') {
return { entry: null };
}
const position = await this.getQueuePosition(serverId);
return {
entry: {
serverId: entry.serverId,
nodeId: entry.nodeId,
status: entry.status,
priority: entry.priority,
position,
requestedAt: entry.requestedAt.toISOString(),
expiresAt: entry.expiresAt?.toISOString() ?? null,
},
};
}
async cancelQueuedStart(userId: string, serverId: string): Promise<GameServer> {
const server = await this.prisma.gameServer.findFirst({
where: { id: serverId, userId },
});
if (!server) {
throw new Error('Server not found');
}
if (server.status !== 'QUEUED') {
return server;
}
return this.prisma.$transaction(async (tx) => {
await tx.serverStartQueueEntry.updateMany({
where: { serverId, status: 'QUEUED' },
data: { status: 'CANCELLED' },
});
const updated = await tx.gameServer.update({
where: { id: serverId },
data: { status: 'STOPPED', version: { increment: 1 } },
});
await tx.gameServerStateTransition.create({
data: {
gameServerId: serverId,
fromStatus: 'QUEUED',
toStatus: 'STOPPED',
reason: 'Queued start cancelled by user',
actorId: userId,
correlationId: randomUUID(),
},
});
return updated;
});
}
}

View File

@@ -7,13 +7,15 @@ const ALLOWED_TRANSITIONS: Record<GameServerStatus, GameServerStatus[]> = {
DRAFT: ['PROVISIONING', 'DELETING'],
PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'],
INSTALLING: ['STOPPED', 'ERROR', 'DELETING'],
STOPPED: ['STARTING', 'PROVISIONING', 'BACKING_UP', 'RESTORING', 'DELETING'],
STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'DELETING'],
RUNNING: ['STOPPING', 'BACKING_UP', 'ERROR', 'DELETING'],
STOPPING: ['STOPPED', 'ERROR', 'DELETING'],
STOPPED: ['STARTING', 'QUEUED', 'PROVISIONING', 'BACKING_UP', 'RESTORING', 'DELETING'],
QUEUED: ['STARTING', 'STOPPED', 'DELETING'],
STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'],
RUNNING: ['STOPPING', 'BACKING_UP', 'ERROR', 'UNKNOWN', 'DELETING'],
STOPPING: ['STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'],
BACKING_UP: ['RUNNING', 'STOPPED', 'ERROR'],
RESTORING: ['STOPPED', 'ERROR'],
ERROR: ['STOPPED', 'STARTING', 'PROVISIONING', 'DELETING'],
UNKNOWN: ['STOPPED', 'STARTING', 'ERROR', 'DELETING'],
ERROR: ['STOPPED', 'STARTING', 'QUEUED', 'PROVISIONING', 'DELETING'],
DELETING: ['DELETED'],
DELETED: [],
};
@@ -55,6 +57,8 @@ export class ServerStateService {
case 'ERROR':
return 'PROVISIONING';
case 'STOPPED':
case 'UNKNOWN':
case 'QUEUED':
return 'STARTING';
default:
throw new BadRequestException(

View File

@@ -85,4 +85,23 @@ export class ServersController {
) {
return this.serversService.restartServer(user.id, id, skip === 'true');
}
@Get(':id/start-queue')
@ApiOperation({ summary: 'Get start queue status for a server' })
startQueue(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.serversService.getStartQueue(user.id, id);
}
@Post(':id/cancel-queue')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Cancel a queued server start' })
cancelQueue(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.serversService.cancelQueuedStart(user.id, id);
}
}

View File

@@ -8,6 +8,8 @@ import { AuthModule } from '../auth/auth.module';
import { NodeBridgeModule } from '../nodes/node-bridge.module';
import { AppConfigModule } from '../config/app-config.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { ConsoleModule } from './console/console.module';
import { AddonsModule } from './addons/addons.module';
import { BackupsModule } from './backups/backups.module';
@@ -30,6 +32,7 @@ import { PlansService } from './plans.service';
imports: [
AppConfigModule,
AuthModule,
SchedulingModule,
forwardRef(() => ConsoleModule),
forwardRef(() => AddonsModule),
forwardRef(() => BackupsModule),

View File

@@ -19,6 +19,7 @@ import type { GameServer } from '@hexahost/database';
type GameServerStatus = GameServer['status'];
import { PrismaService } from '../prisma/prisma.service';
import { SchedulingService } from '../scheduling/scheduling.service';
import { findOwnedServer } from './shared/server-ownership.util';
import { ServerStateService } from './server-state.service';
@@ -34,6 +35,7 @@ export class ServersService {
constructor(
private readonly prisma: PrismaService,
private readonly serverState: ServerStateService,
private readonly scheduling: SchedulingService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
@Inject(SERVER_LIFECYCLE_QUEUE)
private readonly lifecycleQueue: Queue,
@@ -106,6 +108,22 @@ export class ServersService {
this.serverState.assertTransition(server.status, targetStatus);
const correlationId = randomUUID();
if (targetStatus === 'STARTING') {
const canStart = await this.scheduling.canStartServer(server);
if (!canStart) {
const queued = await this.scheduling.enqueueStart(
server,
userId,
correlationId,
);
return {
server: this.toResponse(queued.server),
queuePosition: queued.queuePosition,
};
}
}
const updated = await this.transitionServer(
server,
targetStatus,
@@ -153,6 +171,19 @@ export class ServersService {
};
}
async getStartQueue(userId: string, serverId: string) {
return this.scheduling.getStartQueue(userId, serverId);
}
async cancelQueuedStart(
userId: string,
serverId: string,
): Promise<ServerActionResponse> {
await findOwnedServer(this.prisma, userId, serverId);
const updated = await this.scheduling.cancelQueuedStart(userId, serverId);
return { server: this.toResponse(updated) };
}
async stopServer(
userId: string,
serverId: string,