Phase6
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/contracts": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"@hexahost/scheduler": "workspace:*",
|
||||
"@hexahost/storage": "workspace:*",
|
||||
"@nestjs/common": "^11.0.7",
|
||||
"@nestjs/core": "^11.0.7",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
59
apps/api/src/scheduling/node-snapshots.ts
Normal file
59
apps/api/src/scheduling/node-snapshots.ts
Normal 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;
|
||||
}
|
||||
12
apps/api/src/scheduling/scheduling.module.ts
Normal file
12
apps/api/src/scheduling/scheduling.module.ts
Normal 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 {}
|
||||
175
apps/api/src/scheduling/scheduling.service.ts
Normal file
175
apps/api/src/scheduling/scheduling.service.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
41
apps/api/test/phase6.test.js
Normal file
41
apps/api/test/phase6.test.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('phase 6 api contract', () => {
|
||||
it('documents scheduler, queue and admin node routes', () => {
|
||||
const files = [
|
||||
'scheduling/scheduling.service.ts',
|
||||
'admin/admin-nodes.controller.ts',
|
||||
'admin/admin-nodes.service.ts',
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
const source = readFileSync(join(process.cwd(), 'src', file), 'utf8');
|
||||
assert.ok(source.length > 0, `${file} should exist`);
|
||||
}
|
||||
|
||||
const adminSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'admin/admin-nodes.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(adminSource.includes("@Get()"));
|
||||
assert.ok(adminSource.includes(':id/drain'));
|
||||
assert.ok(adminSource.includes(':id/maintenance'));
|
||||
|
||||
const schedulingSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'scheduling/scheduling.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(schedulingSource.includes('enqueueStart'));
|
||||
assert.ok(schedulingSource.includes('getQueuePosition'));
|
||||
|
||||
const serversSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/servers.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(serversSource.includes('start-queue'));
|
||||
assert.ok(serversSource.includes('cancel-queue'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user