324 lines
8.2 KiB
TypeScript
324 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,
|
|
activeWorldName: server.activeWorldName,
|
|
ramMb: server.ramMb,
|
|
createdAt: server.createdAt.toISOString(),
|
|
updatedAt: server.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
}
|