Phase2
This commit is contained in:
327
apps/api/src/servers/servers.service.ts
Normal file
327
apps/api/src/servers/servers.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user