c1adb94abb
CI has been running `prettier --check` against a tree that was never formatted, so the check reported 63 files and failed every run. Nothing here is a behaviour change: `pnpm lint` and the four typecheck builds pass exactly as before. conduit-bringup-artifacts is added to .prettierignore instead. Those files are captured bring-up reports, not maintained sources; reflowing them would only churn a record of what happened.
282 lines
8.4 KiB
TypeScript
282 lines
8.4 KiB
TypeScript
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<string, unknown>;
|
|
|
|
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 });
|
|
},
|
|
);
|
|
}
|