import type { FastifyInstance } from 'fastify'; import { eq, and } from 'drizzle-orm'; import { randomBytes } from 'crypto'; import { nodes, allocations, servers, games } from '@source/database'; import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { createAuditLog } from '../../lib/audit.js'; import { daemonGetNodeStats, daemonGetNodeStatus, type DaemonNodeConnection, } from '../../lib/daemon.js'; import { NodeParamSchema, CreateNodeSchema, UpdateNodeSchema, CreateAllocationSchema, } from './schemas.js'; export default async function nodeRoutes(app: FastifyInstance) { app.addHook('onRequest', app.authenticate); // GET /api/organizations/:orgId/nodes app.get('/', async (request) => { const { orgId } = request.params as { orgId: string }; await requirePermission(request, orgId, 'node.read'); const nodeList = await app.db .select() .from(nodes) .where(eq(nodes.organizationId, orgId)) .orderBy(nodes.createdAt); const total = nodeList.length; return { data: nodeList, meta: { total, page: 1, perPage: total, totalPages: total === 0 ? 0 : 1, }, }; }); // POST /api/organizations/:orgId/nodes app.post('/', { schema: CreateNodeSchema }, async (request, reply) => { const { orgId } = request.params as { orgId: string }; await requirePermission(request, orgId, 'node.manage'); const body = request.body as { name: string; fqdn: string; daemonPort?: number; grpcPort?: number; location?: string; memoryTotal: number; diskTotal: number; memoryOveralloc?: number; diskOveralloc?: number; }; const daemonToken = randomBytes(32).toString('hex'); const [node] = await app.db .insert(nodes) .values({ organizationId: orgId, ...body, daemonToken, }) .returning(); await createAuditLog(app.db, request, { organizationId: orgId, action: 'node.create', metadata: { nodeId: node!.id, name: body.name }, }); return reply.code(201).send(node); }); // GET /api/organizations/:orgId/nodes/:nodeId app.get('/:nodeId', { schema: NodeParamSchema }, async (request) => { const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; await requirePermission(request, orgId, 'node.read'); const node = await app.db.query.nodes.findFirst({ where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)), }); if (!node) throw AppError.notFound('Node not found'); return node; }); // PATCH /api/organizations/:orgId/nodes/:nodeId app.patch( '/:nodeId', { schema: { ...NodeParamSchema, ...UpdateNodeSchema } }, async (request) => { const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; await requirePermission(request, orgId, 'node.manage'); const body = request.body as Record; const [updated] = await app.db .update(nodes) .set({ ...body, updatedAt: new Date() }) .where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId))) .returning(); if (!updated) throw AppError.notFound('Node not found'); await createAuditLog(app.db, request, { organizationId: orgId, action: 'node.update', metadata: { nodeId, ...body }, }); return updated; }, ); // DELETE /api/organizations/:orgId/nodes/:nodeId app.delete('/:nodeId', { schema: NodeParamSchema }, async (request, reply) => { const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; await requirePermission(request, orgId, 'node.manage'); const node = await app.db.query.nodes.findFirst({ where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)), }); if (!node) throw AppError.notFound('Node not found'); await app.db.delete(nodes).where(eq(nodes.id, nodeId)); await createAuditLog(app.db, request, { organizationId: orgId, action: 'node.delete', metadata: { nodeId, name: node.name }, }); return reply.code(204).send(); }); // GET /api/organizations/:orgId/nodes/:nodeId/servers app.get('/:nodeId/servers', { schema: NodeParamSchema }, async (request) => { const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; await requirePermission(request, orgId, 'node.read'); const serverList = await app.db .select({ id: servers.id, name: servers.name, status: servers.status, memoryLimit: servers.memoryLimit, cpuLimit: servers.cpuLimit, gameName: games.name, }) .from(servers) .leftJoin(games, eq(servers.gameId, games.id)) .where(and(eq(servers.nodeId, nodeId), eq(servers.organizationId, orgId))); return { data: serverList }; }); // GET /api/organizations/:orgId/nodes/:nodeId/stats // Returns real-time stats from daemon when available, with DB fallback. app.get('/:nodeId/stats', { schema: NodeParamSchema }, async (request) => { const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; await requirePermission(request, orgId, 'node.read'); const node = await app.db.query.nodes.findFirst({ where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)), }); if (!node) throw AppError.notFound('Node not found'); const serverList = await app.db .select({ id: servers.id, status: servers.status }) .from(servers) .where(eq(servers.nodeId, nodeId)); const totalServers = serverList.length; let activeServers = serverList.filter((s) => s.status === 'running').length; let cpuPercent = 0; let memoryUsed = 0; let memoryTotal = node.memoryTotal; let diskUsed = 0; let diskTotal = node.diskTotal; let uptime = 0; const daemonNode: DaemonNodeConnection = { fqdn: node.fqdn, grpcPort: node.grpcPort, daemonToken: node.daemonToken, }; try { const [liveStats, liveStatus] = await Promise.all([ daemonGetNodeStats(daemonNode), daemonGetNodeStatus(daemonNode), ]); cpuPercent = Number.isFinite(liveStats.cpuPercent) ? Math.max(0, Math.min(100, liveStats.cpuPercent)) : 0; memoryUsed = Math.max(0, liveStats.memoryUsed); memoryTotal = liveStats.memoryTotal > 0 ? liveStats.memoryTotal : node.memoryTotal; diskUsed = Math.max(0, liveStats.diskUsed); diskTotal = liveStats.diskTotal > 0 ? liveStats.diskTotal : node.diskTotal; uptime = Math.max(0, liveStatus.uptimeSeconds); if (Number.isFinite(liveStatus.activeServers)) { activeServers = Math.max(0, Math.min(totalServers, liveStatus.activeServers)); } } catch (error) { request.log.warn( { error, nodeId, orgId }, 'Failed to fetch live node stats from daemon, returning fallback values', ); } return { cpuPercent, memoryUsed, memoryTotal, diskUsed, diskTotal, activeServers, totalServers, uptime, }; }); // === Allocations === // GET /api/organizations/:orgId/nodes/:nodeId/allocations app.get('/:nodeId/allocations', { schema: NodeParamSchema }, async (request) => { const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; await requirePermission(request, orgId, 'node.read'); const allocs = await app.db .select() .from(allocations) .where(eq(allocations.nodeId, nodeId)) .orderBy(allocations.port); return { data: allocs }; }); // POST /api/organizations/:orgId/nodes/:nodeId/allocations app.post( '/:nodeId/allocations', { schema: { ...NodeParamSchema, ...CreateAllocationSchema } }, async (request, reply) => { const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; await requirePermission(request, orgId, 'node.manage'); const { ip, ports } = request.body as { ip: string; ports: number[] }; const values = ports.map((port) => ({ nodeId, ip, port, })); const created = await app.db .insert(allocations) .values(values) .onConflictDoNothing() .returning(); await createAuditLog(app.db, request, { organizationId: orgId, action: 'allocation.create', metadata: { nodeId, ip, ports }, }); return reply.code(201).send({ data: created }); }, ); }