Phase2
This commit is contained in:
64
apps/api/src/nodes/node-command.subscriber.ts
Normal file
64
apps/api/src/nodes/node-command.subscriber.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { NODE_COMMANDS_CHANNEL } from '../servers/servers.service';
|
||||
|
||||
import { NodesService } from './nodes.service';
|
||||
|
||||
interface NodeCommandMessage {
|
||||
nodeId: string;
|
||||
envelope: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NodeCommandSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(NodeCommandSubscriber.name);
|
||||
private subscriber?: Redis;
|
||||
|
||||
constructor(private readonly nodesService: NodesService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
const config = getConfig();
|
||||
this.subscriber = new Redis(config.REDIS_URL, {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
|
||||
await this.subscriber.subscribe(NODE_COMMANDS_CHANNEL);
|
||||
|
||||
this.subscriber.on('message', (channel, message) => {
|
||||
if (channel !== NODE_COMMANDS_CHANNEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message) as NodeCommandMessage;
|
||||
|
||||
if (!parsed.nodeId || parsed.envelope === undefined) {
|
||||
this.logger.warn('Ignoring malformed node command message');
|
||||
return;
|
||||
}
|
||||
|
||||
this.nodesService.forwardCommand(parsed.nodeId, parsed.envelope);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(`Failed to process node command: ${detail}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (!this.subscriber) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.subscriber.unsubscribe(NODE_COMMANDS_CHANNEL);
|
||||
await this.subscriber.quit();
|
||||
}
|
||||
}
|
||||
84
apps/api/src/nodes/nodes.gateway.ts
Normal file
84
apps/api/src/nodes/nodes.gateway.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
|
||||
import { NodesService } from './nodes.service';
|
||||
|
||||
@Injectable()
|
||||
export class NodesGateway implements OnModuleInit {
|
||||
private readonly logger = new Logger(NodesGateway.name);
|
||||
|
||||
constructor(
|
||||
private readonly adapterHost: HttpAdapterHost,
|
||||
private readonly nodesService: NodesService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const fastify = this.adapterHost.httpAdapter.getInstance();
|
||||
|
||||
fastify.get(
|
||||
'/api/v1/nodes/ws',
|
||||
{ websocket: true },
|
||||
(socket: WebSocket, request: FastifyRequest) => {
|
||||
void this.handleConnection(socket, request);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async handleConnection(
|
||||
socket: WebSocket,
|
||||
request: FastifyRequest,
|
||||
): Promise<void> {
|
||||
const query = request.query as Record<string, string | string[] | undefined>;
|
||||
const nodeId = this.readQueryParam(query['nodeId']);
|
||||
const token = this.readQueryParam(query['token']);
|
||||
|
||||
if (!nodeId || !token) {
|
||||
socket.close(4400, 'nodeId and token are required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.nodesService.validateEnrollment(nodeId, token);
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof UnauthorizedException
|
||||
? 'Invalid enrollment credentials'
|
||||
: 'Enrollment validation failed';
|
||||
socket.close(4401, reason);
|
||||
return;
|
||||
}
|
||||
|
||||
this.nodesService.registerConnection(nodeId, socket);
|
||||
|
||||
socket.on('close', () => {
|
||||
this.nodesService.unregisterConnection(nodeId, socket);
|
||||
});
|
||||
|
||||
socket.on('message', (data) => {
|
||||
void this.nodesService.handleAgentMessage(nodeId, data.toString());
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
this.logger.error(`WebSocket error for node ${nodeId}: ${error.message}`);
|
||||
});
|
||||
|
||||
socket.send(JSON.stringify(this.nodesService.createHelloAck(nodeId)));
|
||||
}
|
||||
|
||||
private readQueryParam(
|
||||
value: string | string[] | undefined,
|
||||
): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
14
apps/api/src/nodes/nodes.module.ts
Normal file
14
apps/api/src/nodes/nodes.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ServersModule } from '../servers/servers.module';
|
||||
|
||||
import { NodeCommandSubscriber } from './node-command.subscriber';
|
||||
import { NodesGateway } from './nodes.gateway';
|
||||
import { NodesService } from './nodes.service';
|
||||
|
||||
@Module({
|
||||
imports: [ServersModule],
|
||||
providers: [NodesService, NodesGateway, NodeCommandSubscriber],
|
||||
exports: [NodesService],
|
||||
})
|
||||
export class NodesModule {}
|
||||
305
apps/api/src/nodes/nodes.service.ts
Normal file
305
apps/api/src/nodes/nodes.service.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import type { WebSocket } from 'ws';
|
||||
import type Redis from 'ioredis';
|
||||
|
||||
import { verifyTokenHash } from '@hexahost/auth';
|
||||
import type { GameServer, GameNode, Prisma } from '@hexahost/database';
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { REDIS_CLIENT, ServersService } from '../servers/servers.service';
|
||||
|
||||
type GameServerStatus = GameServer['status'];
|
||||
|
||||
interface AgentEnvelope {
|
||||
protocolVersion?: number;
|
||||
messageId?: string;
|
||||
correlationId?: string;
|
||||
type?: string;
|
||||
timestamp?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const AGENT_STATE_MAP: Record<string, GameServerStatus> = {
|
||||
draft: 'DRAFT',
|
||||
provisioning: 'PROVISIONING',
|
||||
installing: 'INSTALLING',
|
||||
stopped: 'STOPPED',
|
||||
starting: 'STARTING',
|
||||
running: 'RUNNING',
|
||||
stopping: 'STOPPING',
|
||||
error: 'ERROR',
|
||||
deleting: 'DELETING',
|
||||
deleted: 'DELETED',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NodesService {
|
||||
private readonly logger = new Logger(NodesService.name);
|
||||
private readonly connections = new Map<string, WebSocket>();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly serversService: ServersService,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
registerConnection(nodeId: string, socket: WebSocket): void {
|
||||
const existing = this.connections.get(nodeId);
|
||||
if (existing && existing !== socket) {
|
||||
existing.close(4000, 'replaced by new connection');
|
||||
}
|
||||
|
||||
this.connections.set(nodeId, socket);
|
||||
}
|
||||
|
||||
unregisterConnection(nodeId: string, socket: WebSocket): void {
|
||||
const current = this.connections.get(nodeId);
|
||||
if (current === socket) {
|
||||
this.connections.delete(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
forwardCommand(nodeId: string, envelope: unknown): void {
|
||||
const socket = this.connections.get(nodeId);
|
||||
if (!socket || socket.readyState !== socket.OPEN) {
|
||||
this.logger.warn(`No active WebSocket for node ${nodeId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(envelope));
|
||||
}
|
||||
|
||||
createHelloAck(nodeId: string): AgentEnvelope {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
messageId: randomUUID(),
|
||||
type: 'agent.hello',
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
accepted: true,
|
||||
nodeId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async validateEnrollment(nodeId: string, token: string): Promise<GameNode> {
|
||||
const node = await this.prisma.gameNode.findFirst({
|
||||
where: {
|
||||
OR: [{ id: nodeId }, { name: nodeId }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!node?.enrollmentTokenHash) {
|
||||
throw new UnauthorizedException('Node not enrolled');
|
||||
}
|
||||
|
||||
if (!verifyTokenHash(token, node.enrollmentTokenHash)) {
|
||||
throw new UnauthorizedException('Invalid enrollment token');
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
async markNodeOnline(nodeId: string, agentVersion?: string): Promise<void> {
|
||||
await this.prisma.gameNode.update({
|
||||
where: { id: nodeId },
|
||||
data: {
|
||||
status: 'ONLINE',
|
||||
lastHeartbeatAt: new Date(),
|
||||
enrolledAt: new Date(),
|
||||
agentVersion,
|
||||
enrollmentTokenHash: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async handleAgentMessage(nodeId: string, raw: string): Promise<void> {
|
||||
let envelope: AgentEnvelope;
|
||||
|
||||
try {
|
||||
envelope = JSON.parse(raw) as AgentEnvelope;
|
||||
} catch {
|
||||
this.logger.warn(`Invalid JSON from node ${nodeId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (envelope.type) {
|
||||
case 'agent.hello':
|
||||
await this.markNodeOnline(
|
||||
nodeId,
|
||||
typeof envelope.payload?.['agentVersion'] === 'string'
|
||||
? envelope.payload['agentVersion']
|
||||
: undefined,
|
||||
);
|
||||
break;
|
||||
case 'agent.heartbeat':
|
||||
await this.persistHeartbeat(nodeId, envelope.payload);
|
||||
break;
|
||||
case 'server.state':
|
||||
await this.handleServerState(envelope);
|
||||
break;
|
||||
case 'server.operation.completed':
|
||||
await this.handleOperationCompleted(envelope);
|
||||
break;
|
||||
case 'server.operation.failed':
|
||||
await this.handleOperationFailed(envelope);
|
||||
break;
|
||||
default:
|
||||
this.logger.debug(`Ignored agent message type ${envelope.type ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async persistHeartbeat(
|
||||
nodeId: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.gameNode.update({
|
||||
where: { id: nodeId },
|
||||
data: {
|
||||
lastHeartbeatAt: now,
|
||||
status: 'ONLINE',
|
||||
agentVersion:
|
||||
typeof payload?.['agentVersion'] === 'string'
|
||||
? payload['agentVersion']
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
this.prisma.gameNodeHeartbeat.create({
|
||||
data: {
|
||||
nodeId,
|
||||
payload: (payload ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private async handleServerState(envelope: AgentEnvelope): Promise<void> {
|
||||
const serverId = envelope.payload?.['serverId'];
|
||||
const state = envelope.payload?.['state'];
|
||||
|
||||
if (typeof serverId !== 'string' || typeof state !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapped = AGENT_STATE_MAP[state.toLowerCase()];
|
||||
if (!mapped) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.serversService.applyAgentStatus(serverId, mapped, {
|
||||
correlationId: envelope.correlationId,
|
||||
reason: 'agent server.state',
|
||||
errorCode:
|
||||
typeof envelope.payload?.['errorCode'] === 'string'
|
||||
? envelope.payload['errorCode']
|
||||
: undefined,
|
||||
errorMessage:
|
||||
typeof envelope.payload?.['errorMessage'] === 'string'
|
||||
? envelope.payload['errorMessage']
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleOperationCompleted(
|
||||
envelope: AgentEnvelope,
|
||||
): Promise<void> {
|
||||
const serverId = envelope.payload?.['serverId'];
|
||||
const operation = envelope.payload?.['operation'];
|
||||
const messageId = envelope.messageId;
|
||||
|
||||
if (typeof serverId !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.resolveCompletedStatus(operation);
|
||||
if (target) {
|
||||
await this.serversService.applyAgentStatus(serverId, target, {
|
||||
correlationId: envelope.correlationId,
|
||||
reason: `agent operation completed: ${String(operation ?? 'unknown')}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof messageId === 'string') {
|
||||
await this.publishNodeResponse(messageId, {
|
||||
success: true,
|
||||
resultCode: typeof operation === 'string' ? operation : 'completed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleOperationFailed(envelope: AgentEnvelope): Promise<void> {
|
||||
const serverId = envelope.payload?.['serverId'];
|
||||
const messageId = envelope.messageId;
|
||||
|
||||
if (typeof serverId === 'string') {
|
||||
await this.serversService.applyAgentStatus(serverId, 'ERROR', {
|
||||
correlationId: envelope.correlationId,
|
||||
reason: 'agent operation failed',
|
||||
errorCode:
|
||||
typeof envelope.payload?.['errorCode'] === 'string'
|
||||
? envelope.payload['errorCode']
|
||||
: undefined,
|
||||
errorMessage:
|
||||
typeof envelope.payload?.['errorMessage'] === 'string'
|
||||
? envelope.payload['errorMessage']
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof messageId === 'string') {
|
||||
await this.publishNodeResponse(messageId, {
|
||||
success: false,
|
||||
errorCode:
|
||||
typeof envelope.payload?.['errorCode'] === 'string'
|
||||
? envelope.payload['errorCode']
|
||||
: 'OPERATION_FAILED',
|
||||
errorMessage:
|
||||
typeof envelope.payload?.['errorMessage'] === 'string'
|
||||
? envelope.payload['errorMessage']
|
||||
: 'Agent operation failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async publishNodeResponse(
|
||||
messageId: string,
|
||||
response: {
|
||||
success: boolean;
|
||||
resultCode?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.redis.publish(
|
||||
`hgc:node:responses:${messageId}`,
|
||||
JSON.stringify(response),
|
||||
);
|
||||
}
|
||||
|
||||
private resolveCompletedStatus(operation: unknown): GameServerStatus | null {
|
||||
switch (operation) {
|
||||
case 'provision':
|
||||
return 'STOPPED';
|
||||
case 'install':
|
||||
return 'STOPPED';
|
||||
case 'start':
|
||||
return 'RUNNING';
|
||||
case 'stop':
|
||||
return 'STOPPED';
|
||||
case 'delete':
|
||||
return 'DELETED';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user