Files
HexaHost-GameCloud/apps/api/src/servers/servers.service.ts
smueller 9b061c3ee7
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 13s
Phase3
2026-06-26 12:17:26 +02:00

323 lines
8.2 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import {
ConflictException,
Inject,
Injectable,
} 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 { findOwnedServer } from './shared/server-ownership.util';
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 findOwnedServer(this.prisma, userId, serverId);
return this.toResponse(server);
}
async restartServer(
userId: string,
serverId: string,
skipStop = false,
): Promise<ServerActionResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
this.serverState.assertNotInProgress(server.status);
if (!skipStop && server.status === 'RUNNING') {
await this.stopServer(userId, serverId);
}
return this.startServer(userId, serverId);
}
async startServer(
userId: string,
serverId: string,
): Promise<ServerActionResponse> {
const server = await findOwnedServer(this.prisma, 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 findOwnedServer(this.prisma, 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 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(),
};
}
}