Remove outdated documentation files for companies and migration guide; implement server session management features including server assignment and session status updates; enhance database schema for servers and server sessions with new fields and validation; add auto-assign functionality for unassigned sessions.

This commit is contained in:
2025-10-06 15:59:55 +05:00
parent 9e4bc7b0f8
commit a49129f643
16 changed files with 2332 additions and 483 deletions
+691
View File
@@ -0,0 +1,691 @@
import got, { RequestError } from "got";
import os from "os";
import { execSync, spawn, ChildProcess } from "child_process";
import { existsSync } from "fs";
// Конфигурация
const API_URL = process.env.API_URL || "http://localhost:3000";
const SERVER_TYPE = (process.env.SERVER_TYPE || "stream") as "stream" | "local";
const SERVER_LOCATION =
(process.env.SERVER_LOCATION as "ru1" | "uae1" | undefined) || undefined;
const SERVER_TIER =
(process.env.SERVER_TIER as "demo" | "prod" | undefined) || undefined;
const BRANCH_ID = process.env.BRANCH_ID || undefined;
const LOCAL_IP = getLocalIp();
const HOSTNAME = os.hostname();
const REGISTER_INTERVAL_MS = parseInt(
process.env.REGISTER_INTERVAL_MS || "30000",
10
); // 30 секунд по умолчанию
const GPU_UPDATE_INTERVAL_MS = parseInt(
process.env.GPU_UPDATE_INTERVAL_MS || "1000",
10
); // 1 секунда по умолчанию
const SESSION_CHECK_INTERVAL_MS = parseInt(
process.env.SESSION_CHECK_INTERVAL_MS || "1000",
10
); // 1 секунда по умолчанию
// ID зарегистрированного сервера (заполняется после регистрации)
let SERVER_ID: string | null = null;
// Карта активных процессов: sessionId -> процесс приложения
const activeProcesses = new Map<string, ChildProcess>();
// Карта сессий в процессе запуска/остановки для предотвращения дублирования
const processingSessions = new Set<string>();
// Карта последнего времени логирования запланированных сессий (для уменьшения спама в логах)
const lastScheduledLogTime = new Map<string, number>();
interface ServerRegistrationData {
localIp: string;
hostname: string;
type: "stream" | "local";
gpuFreeMb: number;
branchId?: string;
location?: "ru1" | "uae1";
tier?: "demo" | "prod";
}
interface ServerRegistrationResponse {
server: {
id: string;
localIp: string;
hostname: string;
type: "stream" | "local";
gpuFreeMb: number;
branchId?: string;
location?: "ru1" | "uae1";
tier?: "demo" | "prod";
createdAt: string;
updatedAt: string;
};
registered: boolean;
}
interface SessionData {
id: string;
serverId: string | null; // Nullable - для stream сессий назначается динамически
appId: string;
userId: string;
startAt: string;
endAt: string | null;
appPid: number | null;
cirrusPid: number | null;
mode: "stream" | "local";
status: "starting" | "started" | "ending" | "ended";
createdAt: string;
updatedAt: string;
app: {
id: string;
name: string;
title: string;
gpuLimitMb: number | null;
psVersion: number | null;
};
user: {
id: string;
email: string;
role: string;
};
}
/**
* Получить локальный IP адрес
*/
function getLocalIp(): string {
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
const netInterface = interfaces[name];
if (!netInterface) continue;
for (const iface of netInterface) {
// Пропустить внутренние и non-IPv4 адреса
if (iface.family === "IPv4" && !iface.internal) {
return iface.address;
}
}
}
return "127.0.0.1";
}
/**
* Получить свободную память GPU через nvidia-smi
* Возвращает количество свободной памяти в МБ
* Выбрасывает ошибку, если не удалось получить данные
*/
function getGpuFreeMb(): number {
try {
// Выполняем nvidia-smi с форматом вывода только свободной памяти
// --query-gpu=memory.free - запрашиваем свободную память
// --format=csv,noheader,nounits - CSV формат без заголовков и единиц измерения
const output = execSync(
"nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits",
{
encoding: "utf-8",
timeout: 5000, // 5 секунд таймаут
}
);
// Парсим вывод (может быть несколько GPU, берём первый)
const lines = output.trim().split("\n");
if (lines.length > 0 && lines[0]) {
const freeMb = parseInt(lines[0].trim(), 10);
if (!isNaN(freeMb) && freeMb >= 0) {
return freeMb;
}
}
throw new Error("Не удалось распарсить вывод nvidia-smi");
} catch (error) {
console.error(
`[${new Date().toISOString()}] ❌ Критическая ошибка при получении данных GPU:`,
error instanceof Error ? error.message : error
);
throw new Error(
`Невозможно получить данные о GPU через nvidia-smi: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Регистрация сервера на главном сервере
*/
async function registerServer(isRecursive: boolean = false): Promise<void> {
// Валидация для stream-серверов
if (SERVER_TYPE === "stream" && !SERVER_LOCATION) {
console.error(
`[${new Date().toISOString()}] ❌ Ошибка: для stream-серверов обязательно должен быть указан SERVER_LOCATION`
);
process.exit(1);
}
// Валидация для local-серверов
if (SERVER_TYPE === "local" && !BRANCH_ID) {
console.error(
`[${new Date().toISOString()}] ❌ Ошибка: для local-серверов обязательно должен быть указан BRANCH_ID`
);
process.exit(1);
}
// Установить tier по умолчанию для stream-серверов
const tier = SERVER_TYPE === "stream" && !SERVER_TIER ? "demo" : SERVER_TIER;
// Получаем актуальное значение свободной GPU памяти
const gpuFreeMb = getGpuFreeMb();
const registrationData: ServerRegistrationData = {
localIp: LOCAL_IP,
hostname: HOSTNAME,
type: SERVER_TYPE,
gpuFreeMb: gpuFreeMb,
branchId: BRANCH_ID,
location: SERVER_LOCATION,
tier: tier,
};
console.log(`[${new Date().toISOString()}] Регистрация сервера...`);
console.log("Данные:", JSON.stringify(registrationData, null, 2));
try {
const response = await got
.post(`${API_URL}/servers/register`, {
json: registrationData,
timeout: {
request: 10000, // 10 секунд таймаут
},
retry: {
limit: 3,
methods: ["POST"],
statusCodes: [408, 413, 429, 500, 502, 503, 504],
},
})
.json<ServerRegistrationResponse>();
// Сохраняем ID сервера для дальнейших обновлений
SERVER_ID = response.server.id;
if (response.registered) {
console.log(
`[${new Date().toISOString()}] ✅ Сервер успешно зарегистрирован`
);
console.log("ID сервера:", response.server.id);
} else {
console.log(
`[${new Date().toISOString()}] 🔄 Информация о сервере обновлена`
);
}
// При первом запуске запускаем обновление GPU и проверку сессий после успешной регистрации
if (!isRecursive && SERVER_ID) {
updateGpuMemory();
checkSessions();
}
} catch (error: unknown) {
console.error(
`[${new Date().toISOString()}] ❌ Ошибка регистрации сервера:`,
error
);
if (error instanceof RequestError) {
console.error("Детали ошибки:", {
code: error.code,
message: error.message,
});
} else if (error instanceof Error) {
console.error("Ошибка:", error.message);
}
}
// Планируем следующую регистрацию после завершения текущей
setTimeout(() => registerServer(true), REGISTER_INTERVAL_MS);
}
/**
* Обновить информацию о свободной памяти GPU на сервере
*/
async function updateGpuMemory(): Promise<void> {
try {
// Получаем актуальное значение свободной GPU памяти
const gpuFreeMb = getGpuFreeMb();
await got.patch(`${API_URL}/servers/${SERVER_ID}/gpu`, {
json: { gpuFreeMb },
timeout: {
request: 5000, // 5 секунд таймаут
},
retry: {
limit: 2,
methods: ["PATCH"],
statusCodes: [408, 429, 500, 502, 503, 504],
},
});
console.log(
`[${new Date().toISOString()}] 🎮 GPU память обновлена: ${gpuFreeMb} MB`
);
} catch (error: unknown) {
console.error(
`[${new Date().toISOString()}] ❌ Ошибка обновления GPU памяти:`,
error instanceof RequestError ? error.message : error
);
}
// Планируем следующее обновление после завершения текущего
setTimeout(() => updateGpuMemory(), GPU_UPDATE_INTERVAL_MS);
}
/**
* Получить сессии для этого сервера
*/
async function fetchSessions(): Promise<SessionData[]> {
if (!SERVER_ID) {
return [];
}
try {
const response = await got
.get(`${API_URL}/servers/${SERVER_ID}/sessions`, {
timeout: {
request: 5000,
},
})
.json<{ sessions: SessionData[] }>();
return response.sessions;
} catch (error: unknown) {
console.error(
`[${new Date().toISOString()}] ❌ Ошибка получения сессий:`,
error instanceof RequestError ? error.message : error
);
return [];
}
}
/**
* Обновить статус сессии на главном сервере
*/
async function updateSessionStatus(
sessionId: string,
status: "starting" | "started" | "ending" | "ended",
appPid?: number,
cirrusPid?: number
): Promise<void> {
try {
await got.patch(`${API_URL}/sessions/${sessionId}/status`, {
json: {
status,
appPid,
cirrusPid,
},
timeout: {
request: 5000,
},
});
console.log(
`[${new Date().toISOString()}] ✅ Статус сессии ${sessionId} обновлен на "${status}"`
);
} catch (error: unknown) {
console.error(
`[${new Date().toISOString()}] ❌ Ошибка обновления статуса сессии:`,
error instanceof RequestError ? error.message : error
);
}
}
/**
* Запустить приложение для сессии
*/
async function startApplication(session: SessionData): Promise<void> {
const { id: sessionId, app, serverId } = session;
// Проверить, не обрабатывается ли уже эта сессия
if (processingSessions.has(sessionId)) {
return;
}
// Проверить, не запущено ли уже приложение для этой сессии
if (activeProcesses.has(sessionId)) {
console.log(
`[${new Date().toISOString()}] ⚠️ Приложение для сессии ${sessionId} уже запущено`
);
return;
}
// Проверить, что сервер назначен
// Main server автоматически назначает серверы для готовых к запуску сессий
if (!serverId) {
console.log(
`[${new Date().toISOString()}] ⏳ Сессия ${sessionId} ожидает назначения сервера main сервером`
);
return;
}
// Проверить, что сессия назначена именно этому серверу
if (serverId !== SERVER_ID) {
// Это нормально - сессия назначена другому серверу
return;
}
processingSessions.add(sessionId);
try {
console.log(
`[${new Date().toISOString()}] 🚀 Запуск приложения "${app.name}" для сессии ${sessionId} (активных процессов: ${activeProcesses.size})`
);
// Формируем путь к exe файлу приложения
// Путь: C:\apps\{appName}\{appName}.exe
const appPath = `C:\\apps\\${app.name}\\${app.name}.exe`;
console.log(
`[${new Date().toISOString()}] 📂 Путь к приложению: ${appPath}`
);
// Проверяем существование exe файла
if (!existsSync(appPath)) {
throw new Error(
`Файл приложения не найден: ${appPath}. Убедитесь, что приложение установлено.`
);
}
// Запускаем exe приложение
// Используем 'pipe' для stderr чтобы видеть ошибки, но 'ignore' для stdin/stdout
const appProcess = spawn(appPath, [], {
detached: false,
stdio: ["ignore", "ignore", "pipe"], // stdin: ignore, stdout: ignore, stderr: pipe
windowsHide: true, // Скрывать окно консоли на Windows
cwd: `C:\\apps\\${app.name}`, // Устанавливаем рабочую директорию приложения
});
const appPid = appProcess.pid;
if (!appPid) {
throw new Error("Не удалось получить PID процесса");
}
// Сохранить процесс в карте активных процессов
activeProcesses.set(sessionId, appProcess);
// Логирование stderr для диагностики
if (appProcess.stderr) {
appProcess.stderr.on("data", (data) => {
console.error(
`[${new Date().toISOString()}] 🔴 STDERR [${sessionId}]: ${data.toString().trim()}`
);
});
}
// Обработка завершения процесса
appProcess.on("exit", async (code, signal) => {
console.log(
`[${new Date().toISOString()}] 🛑 Приложение для сессии ${sessionId} завершилось с кодом ${code}${signal ? ` (сигнал: ${signal})` : ""}`
);
if (code !== 0 && code !== null) {
console.error(
`[${new Date().toISOString()}] ⚠️ Приложение завершилось с ошибкой! Код выхода: ${code}`
);
}
activeProcesses.delete(sessionId);
// Обновить статус на "ended"
await updateSessionStatus(sessionId, "ended");
});
appProcess.on("error", async (error) => {
console.error(
`[${new Date().toISOString()}] ❌ Ошибка процесса для сессии ${sessionId}:`,
error
);
activeProcesses.delete(sessionId);
// Обновить статус на "ended" в случае ошибки
await updateSessionStatus(sessionId, "ended");
});
// Обновить статус на "started" с PID
await updateSessionStatus(sessionId, "started", appPid);
console.log(
`[${new Date().toISOString()}] ✅ Приложение запущено с PID ${appPid} (всего активных: ${activeProcesses.size})`
);
} catch (error) {
console.error(
`[${new Date().toISOString()}] ❌ Ошибка запуска приложения:`,
error instanceof Error ? error.message : error
);
// Обновить статус на "ended" в случае ошибки
await updateSessionStatus(sessionId, "ended");
} finally {
processingSessions.delete(sessionId);
}
}
/**
* Убить процесс и всё его дерево дочерних процессов (для Windows)
*/
function killProcessTree(pid: number): void {
try {
// На Windows используем taskkill с флагом /T для убийства дерева процессов
// /F - принудительное завершение
// /T - завершить указанный процесс и все дочерние процессы
execSync(`taskkill /pid ${pid} /T /F`, {
stdio: "ignore",
timeout: 10000,
});
console.log(
`[${new Date().toISOString()}] ✅ Дерево процессов для PID ${pid} успешно завершено`
);
} catch (error) {
console.error(
`[${new Date().toISOString()}] ⚠️ Ошибка при завершении дерева процессов PID ${pid}:`,
error instanceof Error ? error.message : error
);
}
}
/**
* Остановить приложение для сессии
*/
async function stopApplication(session: SessionData): Promise<void> {
const { id: sessionId, appPid } = session;
// Проверить, не обрабатывается ли уже эта сессия
if (processingSessions.has(sessionId)) {
return;
}
const appProcess = activeProcesses.get(sessionId);
if (!appProcess && !appPid) {
console.log(
`[${new Date().toISOString()}] ⚠️ Приложение для сессии ${sessionId} не найдено в активных процессах`
);
// Всё равно обновляем статус на "ended"
await updateSessionStatus(sessionId, "ended");
return;
}
processingSessions.add(sessionId);
try {
console.log(
`[${new Date().toISOString()}] 🛑 Остановка приложения для сессии ${sessionId} (PID: ${appPid || appProcess?.pid || "неизвестен"})`
);
// Используем PID из базы данных если он есть, иначе из процесса
const pidToKill = appPid || appProcess?.pid;
if (pidToKill) {
// Убиваем весь процесс и все его дочерние процессы
killProcessTree(pidToKill);
} else if (appProcess) {
// Если по какой-то причине нет PID, пробуем стандартный способ
appProcess.kill("SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 2000));
if (!appProcess.killed) {
appProcess.kill("SIGKILL");
}
}
activeProcesses.delete(sessionId);
// Обновить статус на "ended"
await updateSessionStatus(sessionId, "ended");
console.log(
`[${new Date().toISOString()}] ✅ Приложение и все дочерние процессы остановлены для сессии ${sessionId}`
);
} catch (error) {
console.error(
`[${new Date().toISOString()}] ❌ Ошибка остановки приложения:`,
error instanceof Error ? error.message : error
);
} finally {
processingSessions.delete(sessionId);
}
}
/**
* Проверить и обработать сессии
*/
async function checkSessions(): Promise<void> {
if (!SERVER_ID) {
return;
}
try {
const sessions = await fetchSessions();
// Получаем текущее время в UTC
const now = new Date();
// Обработать сессии со статусом "starting"
// Запускать только если время startAt уже наступило
// Примечание: PostgreSQL возвращает timestamp with timezone как ISO 8601 строку,
// которая автоматически парсится в UTC при создании Date объекта
const allStartingSessions = sessions.filter((s) => s.status === "starting");
const startingSessions = allStartingSessions.filter((s) => {
const startAt = new Date(s.startAt);
return startAt <= now;
});
// Логировать запланированные сессии, которые ещё не пришло время запускать
// Логируем не чаще раза в 10 секунд для каждой сессии, чтобы не спамить логами
const scheduledSessions = allStartingSessions.filter((s) => {
const startAt = new Date(s.startAt);
return startAt > now;
});
if (scheduledSessions.length > 0) {
for (const session of scheduledSessions) {
const lastLogTime = lastScheduledLogTime.get(session.id) || 0;
const timeSinceLastLog = now.getTime() - lastLogTime;
// Логируем только если прошло больше 10 секунд с последнего лога
if (timeSinceLastLog > 10000) {
const startAt = new Date(session.startAt);
const timeUntilStart = Math.round((startAt.getTime() - now.getTime()) / 1000);
console.log(
`[${new Date().toISOString()}] ⏰ Сессия ${session.id} (${session.app.name}) запланирована через ${timeUntilStart} сек`
);
lastScheduledLogTime.set(session.id, now.getTime());
}
}
}
// Очистить карту логирования для сессий, которые больше не запланированы
const scheduledSessionIds = new Set(scheduledSessions.map((s) => s.id));
for (const sessionId of lastScheduledLogTime.keys()) {
if (!scheduledSessionIds.has(sessionId)) {
lastScheduledLogTime.delete(sessionId);
}
}
for (const session of startingSessions) {
await startApplication(session);
}
// Обработать сессии со статусом "ending"
const endingSessions = sessions.filter((s) => s.status === "ending");
for (const session of endingSessions) {
await stopApplication(session);
}
// Проверить, что все активные процессы соответствуют активным сессиям
// Сессия считается активной, если:
// 1. Статус "started"
// 2. Статус "starting" И время startAt уже наступило
const activeSessions = sessions.filter((s) => {
if (s.status === "started") return true;
if (s.status === "starting") {
const startAt = new Date(s.startAt);
return startAt <= now;
}
return false;
});
const activeSessionIds = new Set(activeSessions.map((s) => s.id));
// Остановить процессы для сессий, которые больше не активны
for (const [sessionId, process] of activeProcesses.entries()) {
if (!activeSessionIds.has(sessionId)) {
console.log(
`[${new Date().toISOString()}] ⚠️ Найден процесс для неактивной сессии ${sessionId}, остановка`
);
process.kill("SIGTERM");
activeProcesses.delete(sessionId);
}
}
} catch (error: unknown) {
console.error(
`[${new Date().toISOString()}] ❌ Ошибка проверки сессий:`,
error instanceof Error ? error.message : error
);
}
// Планируем следующую проверку
setTimeout(() => checkSessions(), SESSION_CHECK_INTERVAL_MS);
}
/**
* Основная функция
*/
async function main() {
console.log("=".repeat(60));
console.log("🚀 Запуск сессионного сервера");
console.log("=".repeat(60));
console.log("Конфигурация:");
console.log(` API URL: ${API_URL}`);
console.log(` Hostname: ${HOSTNAME}`);
console.log(` Local IP: ${LOCAL_IP}`);
console.log(` Type: ${SERVER_TYPE}`);
console.log(` GPU Free MB: ${getGpuFreeMb()} (читается из nvidia-smi)`);
if (SERVER_LOCATION) console.log(` Location: ${SERVER_LOCATION}`);
if (SERVER_TIER) console.log(` Tier: ${SERVER_TIER}`);
if (BRANCH_ID) console.log(` Branch ID: ${BRANCH_ID}`);
console.log(` Register Interval: ${REGISTER_INTERVAL_MS}ms`);
console.log(` GPU Update Interval: ${GPU_UPDATE_INTERVAL_MS}ms`);
console.log(` Session Check Interval: ${SESSION_CHECK_INTERVAL_MS}ms`);
console.log("=".repeat(60));
// Запуск рекурсивной регистрации
// Использует setTimeout вместо setInterval, чтобы избежать наложения запросов
// После первой успешной регистрации запускается обновление GPU
await registerServer();
}
// Запуск
main().catch((error: unknown) => {
console.error(
"Критическая ошибка:",
error instanceof Error ? error.message : error
);
process.exit(1);
});