feat: wire daemon console/files/config/players and improve runtime fallbacks

This commit is contained in:
2026-02-22 12:09:07 +00:00
parent 614d25c189
commit 44c439e2f9
12 changed files with 1793 additions and 96 deletions
+249 -25
View File
@@ -1,12 +1,20 @@
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,
@@ -14,16 +22,123 @@ import {
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' });
@@ -128,24 +243,80 @@ export default async function serverRoutes(app: FastifyInstance) {
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 })
.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
const nodeConnection: DaemonNodeConnection = {
fqdn: node.fqdn,
grpcPort: node.grpcPort,
daemonToken: node.daemonToken,
};
await createAuditLog(app.db, request, {
organizationId: orgId,
serverId: server!.id,
action: 'server.create',
metadata: { name: body.name, gameSlug: game.slug, nodeId: body.nodeId },
});
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: [],
};
return reply.code(201).send(server);
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
@@ -217,21 +388,43 @@ export default async function serverRoutes(app: FastifyInstance) {
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)),
});
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 })
.set({ serverId: null, isDefault: false })
.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,
@@ -239,6 +432,8 @@ export default async function serverRoutes(app: FastifyInstance) {
metadata: { name: server.name, uuid: server.uuid },
});
await app.db.delete(servers).where(eq(servers.id, serverId));
return reply.code(204).send();
});
@@ -256,18 +451,43 @@ export default async function serverRoutes(app: FastifyInstance) {
} 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)),
});
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');
}
// TODO: Send gRPC SetPowerState to daemon
// For now, just update status optimistically
const statusMap: Record<PowerAction, string> = {
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',
@@ -276,7 +496,11 @@ export default async function serverRoutes(app: FastifyInstance) {
await app.db
.update(servers)
.set({ status: statusMap[action] as any, updatedAt: new Date() })
.set({
status: statusMap[action],
installedAt: statusMap[action] === 'running' ? new Date() : undefined,
updatedAt: new Date(),
})
.where(eq(servers.id, serverId));
await createAuditLog(app.db, request, {