chore: initial commit for phase03

This commit is contained in:
hibna
2026-02-21 13:37:46 +03:00
parent 8eb7c90958
commit d0c20581b6
12 changed files with 1175 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, count } from 'drizzle-orm';
import { randomUUID } from 'crypto';
import { servers, allocations, nodes, games } from '@source/database';
import type { PowerAction } from '@source/shared';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
import { createAuditLog } from '../../lib/audit.js';
import {
ServerParamSchema,
CreateServerSchema,
UpdateServerSchema,
PowerActionSchema,
} from './schemas.js';
export default async function serverRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
// GET /api/organizations/:orgId/servers
app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'server.read');
const { page, perPage, offset, limit } = paginate(request.query as any);
const [totalResult] = await app.db
.select({ count: count() })
.from(servers)
.where(eq(servers.organizationId, orgId));
const serverList = await app.db
.select({
id: servers.id,
uuid: servers.uuid,
name: servers.name,
description: servers.description,
status: servers.status,
memoryLimit: servers.memoryLimit,
diskLimit: servers.diskLimit,
cpuLimit: servers.cpuLimit,
port: servers.port,
createdAt: servers.createdAt,
nodeName: nodes.name,
nodeId: nodes.id,
gameName: games.name,
gameSlug: games.slug,
gameId: games.id,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.innerJoin(games, eq(servers.gameId, games.id))
.where(eq(servers.organizationId, orgId))
.limit(limit)
.offset(offset)
.orderBy(servers.createdAt);
return paginatedResponse(serverList, totalResult!.count, page, perPage);
});
// POST /api/organizations/:orgId/servers
app.post('/', { schema: CreateServerSchema }, async (request, reply) => {
const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'server.create');
const body = request.body as {
name: string;
description?: string;
nodeId: string;
gameId: string;
memoryLimit: number;
diskLimit: number;
cpuLimit?: number;
allocationId: string;
environment?: Record<string, string>;
startupOverride?: string;
};
// Verify node belongs to org
const node = await app.db.query.nodes.findFirst({
where: and(eq(nodes.id, body.nodeId), eq(nodes.organizationId, orgId)),
});
if (!node) throw AppError.notFound('Node not found in this organization');
// Verify game exists
const game = await app.db.query.games.findFirst({
where: eq(games.id, body.gameId),
});
if (!game) throw AppError.notFound('Game not found');
// Verify and claim allocation
const allocation = await app.db.query.allocations.findFirst({
where: and(
eq(allocations.id, body.allocationId),
eq(allocations.nodeId, body.nodeId),
),
});
if (!allocation) throw AppError.notFound('Allocation not found on this node');
if (allocation.serverId) throw AppError.conflict('Allocation is already in use');
const serverUuid = randomUUID().slice(0, 8);
const [server] = await app.db
.insert(servers)
.values({
uuid: serverUuid,
organizationId: orgId,
nodeId: body.nodeId,
gameId: body.gameId,
name: body.name,
description: body.description,
memoryLimit: body.memoryLimit,
diskLimit: body.diskLimit,
cpuLimit: body.cpuLimit ?? 100,
port: allocation.port,
environment: body.environment ?? {},
startupOverride: body.startupOverride,
status: 'installing',
})
.returning();
// Assign allocation to server
await app.db
.update(allocations)
.set({ serverId: server!.id, isDefault: true })
.where(eq(allocations.id, body.allocationId));
// TODO: Send gRPC CreateServer to daemon
// This will be implemented in Phase 4
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId: server!.id,
action: 'server.create',
metadata: { name: body.name, gameSlug: game.slug, nodeId: body.nodeId },
});
return reply.code(201).send(server);
});
// GET /api/organizations/:orgId/servers/:serverId
app.get('/:serverId', { schema: ServerParamSchema }, async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'server.read');
const [server] = await app.db
.select({
id: servers.id,
uuid: servers.uuid,
name: servers.name,
description: servers.description,
status: servers.status,
memoryLimit: servers.memoryLimit,
diskLimit: servers.diskLimit,
cpuLimit: servers.cpuLimit,
port: servers.port,
additionalPorts: servers.additionalPorts,
environment: servers.environment,
startupOverride: servers.startupOverride,
installedAt: servers.installedAt,
createdAt: servers.createdAt,
updatedAt: servers.updatedAt,
nodeId: nodes.id,
nodeName: nodes.name,
nodeFqdn: nodes.fqdn,
gameId: games.id,
gameName: games.name,
gameSlug: games.slug,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.innerJoin(games, eq(servers.gameId, games.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) throw AppError.notFound('Server not found');
return server;
});
// PATCH /api/organizations/:orgId/servers/:serverId
app.patch('/:serverId', { schema: { ...ServerParamSchema, ...UpdateServerSchema } }, async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'server.update');
const body = request.body as Record<string, unknown>;
const [updated] = await app.db
.update(servers)
.set({ ...body, updatedAt: new Date() })
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)))
.returning();
if (!updated) throw AppError.notFound('Server not found');
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'server.update',
metadata: body,
});
return updated;
});
// DELETE /api/organizations/:orgId/servers/:serverId
app.delete('/:serverId', { schema: ServerParamSchema }, async (request, reply) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
await requirePermission(request, orgId, 'server.delete');
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
if (!server) throw AppError.notFound('Server not found');
// Release allocations
await app.db
.update(allocations)
.set({ serverId: null })
.where(eq(allocations.serverId, serverId));
// TODO: Send gRPC DeleteServer to daemon
await app.db.delete(servers).where(eq(servers.id, serverId));
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: 'server.delete',
metadata: { name: server.name, uuid: server.uuid },
});
return reply.code(204).send();
});
// POST /api/organizations/:orgId/servers/:serverId/power
app.post('/:serverId/power', { schema: { ...ServerParamSchema, ...PowerActionSchema } }, async (request) => {
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
const { action } = request.body as { action: PowerAction };
// Check specific power permission
const permMap = {
start: 'power.start',
stop: 'power.stop',
restart: 'power.restart',
kill: 'power.kill',
} as const;
await requirePermission(request, orgId, permMap[action]);
const server = await app.db.query.servers.findFirst({
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
});
if (!server) throw AppError.notFound('Server not found');
if (server.status === 'suspended') {
throw AppError.badRequest('Cannot send power action to a suspended server');
}
// TODO: Send gRPC SetPowerState to daemon
// For now, just update status optimistically
const statusMap: Record<PowerAction, string> = {
start: 'running',
stop: 'stopped',
restart: 'running',
kill: 'stopped',
};
await app.db
.update(servers)
.set({ status: statusMap[action] as any, updatedAt: new Date() })
.where(eq(servers.id, serverId));
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId,
action: `server.power.${action}`,
});
return { success: true, action };
});
}