515 lines
16 KiB
TypeScript
515 lines
16 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import { eq, and, count } from 'drizzle-orm';
|
|
import { randomUUID } from 'crypto';
|
|
import { setTimeout as sleep } from 'timers/promises';
|
|
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 {
|
|
daemonCreateServer,
|
|
daemonDeleteServer,
|
|
daemonGetServerStatus,
|
|
daemonSetPowerState,
|
|
type DaemonNodeConnection,
|
|
} from '../../lib/daemon.js';
|
|
import {
|
|
ServerParamSchema,
|
|
CreateServerSchema,
|
|
UpdateServerSchema,
|
|
PowerActionSchema,
|
|
} from './schemas.js';
|
|
import configRoutes from './config.js';
|
|
import fileRoutes from './files.js';
|
|
import pluginRoutes from './plugins.js';
|
|
import playerRoutes from './players.js';
|
|
import scheduleRoutes from './schedules.js';
|
|
import backupRoutes from './backups.js';
|
|
|
|
type MutableServerStatus = 'installing' | 'running' | 'stopped' | 'error';
|
|
|
|
function mapDaemonStatus(rawStatus: string): MutableServerStatus | null {
|
|
switch (rawStatus.toLowerCase()) {
|
|
case 'installing':
|
|
return 'installing';
|
|
case 'running':
|
|
return 'running';
|
|
case 'stopped':
|
|
return 'stopped';
|
|
case 'starting':
|
|
return 'running';
|
|
case 'stopping':
|
|
return 'stopped';
|
|
case 'error':
|
|
return 'error';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function buildDaemonEnvironment(
|
|
gameEnvVarsRaw: unknown,
|
|
overrides: Record<string, string> | undefined,
|
|
memoryLimitBytes: number,
|
|
): Record<string, string> {
|
|
const environment: Record<string, string> = {};
|
|
|
|
if (Array.isArray(gameEnvVarsRaw)) {
|
|
for (const item of gameEnvVarsRaw) {
|
|
if (!item || typeof item !== 'object') continue;
|
|
const record = item as Record<string, unknown>;
|
|
const key = typeof record.key === 'string' ? record.key.trim() : '';
|
|
if (!key) continue;
|
|
|
|
const defaultValue = record.default;
|
|
if (defaultValue === undefined || defaultValue === null) continue;
|
|
|
|
environment[key] = String(defaultValue);
|
|
}
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
environment[key] = String(value);
|
|
}
|
|
|
|
if ('MEMORY' in environment && !(overrides && 'MEMORY' in overrides)) {
|
|
const memoryMiB = Math.max(256, Math.floor(memoryLimitBytes / (1024 * 1024)));
|
|
environment.MEMORY = `${memoryMiB}M`;
|
|
}
|
|
|
|
return environment;
|
|
}
|
|
|
|
async function syncServerInstallStatus(
|
|
app: FastifyInstance,
|
|
node: DaemonNodeConnection,
|
|
serverId: string,
|
|
serverUuid: string,
|
|
): Promise<void> {
|
|
const maxAttempts = 120;
|
|
const intervalMs = 5_000;
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
if (attempt > 1) {
|
|
await sleep(intervalMs);
|
|
}
|
|
|
|
try {
|
|
const daemonStatus = await daemonGetServerStatus(node, serverUuid);
|
|
const mapped = mapDaemonStatus(daemonStatus.state);
|
|
if (!mapped) continue;
|
|
if (mapped === 'installing') continue;
|
|
|
|
const now = new Date();
|
|
await app.db
|
|
.update(servers)
|
|
.set({
|
|
status: mapped,
|
|
installedAt: mapped === 'running' || mapped === 'stopped' ? now : null,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(servers.id, serverId));
|
|
|
|
app.log.info(
|
|
{ serverId, serverUuid, status: mapped, attempt },
|
|
'Synchronized install status from daemon',
|
|
);
|
|
return;
|
|
} catch (error) {
|
|
app.log.warn(
|
|
{ error, serverId, serverUuid, attempt },
|
|
'Failed to poll daemon server status',
|
|
);
|
|
}
|
|
}
|
|
|
|
app.log.warn(
|
|
{ serverId, serverUuid },
|
|
'Timed out while waiting for daemon install completion',
|
|
);
|
|
}
|
|
|
|
export default async function serverRoutes(app: FastifyInstance) {
|
|
app.addHook('onRequest', app.authenticate);
|
|
|
|
// Register sub-routes
|
|
await app.register(configRoutes, { prefix: '/:serverId/config' });
|
|
await app.register(fileRoutes, { prefix: '/:serverId/files' });
|
|
await app.register(pluginRoutes, { prefix: '/:serverId/plugins' });
|
|
await app.register(playerRoutes, { prefix: '/:serverId/players' });
|
|
await app.register(scheduleRoutes, { prefix: '/:serverId/schedules' });
|
|
await app.register(backupRoutes, { prefix: '/:serverId/backups' });
|
|
|
|
// 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();
|
|
if (!server) {
|
|
throw new AppError(500, 'Failed to create server record', 'SERVER_CREATE_FAILED');
|
|
}
|
|
|
|
// Assign allocation to server
|
|
await app.db
|
|
.update(allocations)
|
|
.set({ serverId: server.id, isDefault: true })
|
|
.where(eq(allocations.id, body.allocationId));
|
|
|
|
const nodeConnection: DaemonNodeConnection = {
|
|
fqdn: node.fqdn,
|
|
grpcPort: node.grpcPort,
|
|
daemonToken: node.daemonToken,
|
|
};
|
|
|
|
const daemonRequest = {
|
|
uuid: server.uuid,
|
|
docker_image: game.dockerImage,
|
|
memory_limit: server.memoryLimit,
|
|
disk_limit: server.diskLimit,
|
|
cpu_limit: server.cpuLimit,
|
|
startup_command: body.startupOverride ?? game.startupCommand,
|
|
environment: buildDaemonEnvironment(game.environmentVars, body.environment, server.memoryLimit),
|
|
ports: [
|
|
{
|
|
host_port: allocation.port,
|
|
container_port: game.defaultPort,
|
|
protocol: 'tcp' as const,
|
|
},
|
|
],
|
|
install_plugin_urls: [],
|
|
};
|
|
|
|
try {
|
|
const daemonResponse = await daemonCreateServer(nodeConnection, daemonRequest);
|
|
const daemonStatus = mapDaemonStatus(daemonResponse.status) ?? 'installing';
|
|
const now = new Date();
|
|
|
|
const [updatedServer] = await app.db
|
|
.update(servers)
|
|
.set({
|
|
status: daemonStatus,
|
|
installedAt: daemonStatus === 'running' || daemonStatus === 'stopped' ? now : null,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(servers.id, server.id))
|
|
.returning();
|
|
|
|
if (daemonStatus === 'installing') {
|
|
void syncServerInstallStatus(app, nodeConnection, server.id, server.uuid);
|
|
}
|
|
|
|
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(updatedServer ?? server);
|
|
} catch (error) {
|
|
app.log.error(
|
|
{ error, serverId: server.id, serverUuid: server.uuid, nodeId: node.id },
|
|
'Failed to provision server on daemon',
|
|
);
|
|
|
|
await app.db
|
|
.update(servers)
|
|
.set({ status: 'error', updatedAt: new Date() })
|
|
.where(eq(servers.id, server.id));
|
|
|
|
throw new AppError(502, 'Failed to provision server on daemon', 'DAEMON_CREATE_FAILED');
|
|
}
|
|
});
|
|
|
|
// 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
|
|
.select({
|
|
id: servers.id,
|
|
uuid: servers.uuid,
|
|
name: servers.name,
|
|
nodeFqdn: nodes.fqdn,
|
|
nodeGrpcPort: nodes.grpcPort,
|
|
nodeDaemonToken: nodes.daemonToken,
|
|
})
|
|
.from(servers)
|
|
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
|
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
|
if (!server) throw AppError.notFound('Server not found');
|
|
|
|
try {
|
|
await daemonDeleteServer(
|
|
{
|
|
fqdn: server.nodeFqdn,
|
|
grpcPort: server.nodeGrpcPort,
|
|
daemonToken: server.nodeDaemonToken,
|
|
},
|
|
server.uuid,
|
|
);
|
|
} catch (error) {
|
|
app.log.error(
|
|
{ error, serverId: server.id, serverUuid: server.uuid },
|
|
'Failed to delete server on daemon',
|
|
);
|
|
throw new AppError(502, 'Failed to delete server on daemon', 'DAEMON_DELETE_FAILED');
|
|
}
|
|
|
|
// Release allocations
|
|
await app.db
|
|
.update(allocations)
|
|
.set({ serverId: null, isDefault: false })
|
|
.where(eq(allocations.serverId, serverId));
|
|
|
|
await createAuditLog(app.db, request, {
|
|
organizationId: orgId,
|
|
serverId,
|
|
action: 'server.delete',
|
|
metadata: { name: server.name, uuid: server.uuid },
|
|
});
|
|
|
|
await app.db.delete(servers).where(eq(servers.id, serverId));
|
|
|
|
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
|
|
.select({
|
|
id: servers.id,
|
|
uuid: servers.uuid,
|
|
status: servers.status,
|
|
nodeFqdn: nodes.fqdn,
|
|
nodeGrpcPort: nodes.grpcPort,
|
|
nodeDaemonToken: nodes.daemonToken,
|
|
})
|
|
.from(servers)
|
|
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
|
.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');
|
|
}
|
|
|
|
try {
|
|
await daemonSetPowerState(
|
|
{
|
|
fqdn: server.nodeFqdn,
|
|
grpcPort: server.nodeGrpcPort,
|
|
daemonToken: server.nodeDaemonToken,
|
|
},
|
|
server.uuid,
|
|
action,
|
|
);
|
|
} catch (error) {
|
|
app.log.error(
|
|
{ error, serverId: server.id, serverUuid: server.uuid, action },
|
|
'Failed to send power action to daemon',
|
|
);
|
|
throw new AppError(502, 'Failed to send power action to daemon', 'DAEMON_POWER_FAILED');
|
|
}
|
|
|
|
const statusMap: Record<PowerAction, MutableServerStatus> = {
|
|
start: 'running',
|
|
stop: 'stopped',
|
|
restart: 'running',
|
|
kill: 'stopped',
|
|
};
|
|
|
|
await app.db
|
|
.update(servers)
|
|
.set({
|
|
status: statusMap[action],
|
|
installedAt: statusMap[action] === 'running' ? new Date() : undefined,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(servers.id, serverId));
|
|
|
|
await createAuditLog(app.db, request, {
|
|
organizationId: orgId,
|
|
serverId,
|
|
action: `server.power.${action}`,
|
|
});
|
|
|
|
return { success: true, action };
|
|
});
|
|
}
|