Phase2
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 10s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 12:01:25 +02:00
parent 58961000eb
commit c4077d4673
93 changed files with 4099 additions and 165 deletions

View 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;
}
}
}