diff --git a/server/COMPANIES_BRANCHES.md b/server/COMPANIES_BRANCHES.md deleted file mode 100644 index ebe9bdd..0000000 --- a/server/COMPANIES_BRANCHES.md +++ /dev/null @@ -1,376 +0,0 @@ -# Компании и филиалы - Документация - -## Обзор - -Добавлена функциональность для управления компаниями и филиалами с поддержкой привязки пользователей к нескольким филиалам. - -## Структура базы данных - -### Таблицы - -#### `companies` - Компании -- `id` - UUID, первичный ключ -- `name` - Название компании (обязательное) -- `description` - Описание компании -- `createdAt` - Дата создания -- `updatedAt` - Дата обновления - -#### `branches` - Филиалы -- `id` - UUID, первичный ключ -- `companyId` - UUID, внешний ключ на компанию (каскадное удаление) -- `name` - Название филиала (обязательное) -- `address` - Адрес филиала -- `city` - Город -- `country` - Страна -- `createdAt` - Дата создания -- `updatedAt` - Дата обновления - -#### `user_branches` - Связь пользователей и филиалов (Many-to-Many) -- `userId` - UUID, внешний ключ на пользователя (каскадное удаление) -- `branchId` - UUID, внешний ключ на филиал (каскадное удаление) -- `createdAt` - Дата создания -- Составной первичный ключ: `(userId, branchId)` - -### Изменения в таблице `users` -Добавлено поле: -- `currentBranchId` - UUID, внешний ключ на выбранный филиал (nullable) - -## API Эндпоинты - -Все эндпоинты требуют авторизации через Bearer токен. - -### Компании (`/companies`) - -#### `GET /companies` -Получить список всех компаний с их филиалами. - -**Ответ:** -```json -{ - "companies": [ - { - "id": "uuid", - "name": "Компания 1", - "description": "Описание", - "branches": [...], - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z" - } - ] -} -``` - -#### `GET /companies/:id` -Получить компанию по ID. - -**Параметры:** -- `id` - UUID компании - -**Ответ:** -```json -{ - "company": { - "id": "uuid", - "name": "Компания 1", - "description": "Описание", - "branches": [...], - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z" - } -} -``` - -#### `POST /companies` -Создать новую компанию. - -**Body:** -```json -{ - "name": "Новая компания", - "description": "Описание (опционально)" -} -``` - -**Ответ:** -```json -{ - "company": { - "id": "uuid", - "name": "Новая компания", - "description": "Описание", - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z" - } -} -``` - -#### `PATCH /companies/:id` -Обновить компанию. - -**Body:** -```json -{ - "name": "Обновленное название (опционально)", - "description": "Обновленное описание (опционально)" -} -``` - -#### `DELETE /companies/:id` -Удалить компанию (каскадно удалит все филиалы). - -**Ответ:** -```json -{ - "message": "Company deleted successfully" -} -``` - -### Филиалы (`/branches`) - -#### `GET /branches/my` -Получить филиалы текущего пользователя. - -**Ответ:** -```json -{ - "branches": [ - { - "id": "uuid", - "companyId": "uuid", - "name": "Филиал 1", - "address": "Адрес", - "city": "Город", - "country": "Страна", - "company": {...}, - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z" - } - ] -} -``` - -#### `GET /branches/:id` -Получить филиал по ID. - -**Ответ:** -```json -{ - "branch": { - "id": "uuid", - "companyId": "uuid", - "name": "Филиал 1", - "address": "Адрес", - "city": "Город", - "country": "Страна", - "company": {...}, - "createdAt": "2024-01-01T00:00:00Z", - "updatedAt": "2024-01-01T00:00:00Z" - } -} -``` - -#### `GET /branches/:id/users` -Получить пользователей филиала. - -**Ответ:** -```json -{ - "users": [ - { - "id": "uuid", - "email": "user@example.com", - "fullName": "Иван Иванов", - "role": "manager", - "currentBranchId": "uuid" - } - ] -} -``` - -#### `POST /branches` -Создать новый филиал. - -**Body:** -```json -{ - "companyId": "uuid", - "name": "Новый филиал", - "address": "Адрес (опционально)", - "city": "Город (опционально)", - "country": "Страна (опционально)" -} -``` - -#### `PATCH /branches/:id` -Обновить филиал. - -**Body:** -```json -{ - "name": "Обновленное название (опционально)", - "address": "Обновленный адрес (опционально)", - "city": "Обновленный город (опционально)", - "country": "Обновленная страна (опционально)" -} -``` - -#### `DELETE /branches/:id` -Удалить филиал. - -#### `POST /branches/:id/users` -Привязать пользователя к филиалу. - -**Body:** -```json -{ - "userId": "uuid" -} -``` - -**Ответ:** -```json -{ - "message": "User assigned to branch successfully" -} -``` - -#### `DELETE /branches/:id/users/:userId` -Отвязать пользователя от филиала. - -**Ответ:** -```json -{ - "message": "User removed from branch successfully" -} -``` - -#### `POST /branches/:id/select` -Установить филиал как текущий для авторизованного пользователя. - -**Ответ:** -```json -{ - "message": "Current branch updated successfully", - "currentBranchId": "uuid" -} -``` - -**Ошибки:** -- `400` - Пользователь не привязан к этому филиалу - -## Использование - -### Создание компании и филиалов - -```typescript -// 1. Создать компанию -POST /companies -{ - "name": "ООО Рога и Копыта", - "description": "Крупная IT компания" -} - -// 2. Создать филиалы -POST /branches -{ - "companyId": "company-uuid", - "name": "Московский офис", - "city": "Москва", - "country": "Россия" -} - -POST /branches -{ - "companyId": "company-uuid", - "name": "Питерский офис", - "city": "Санкт-Петербург", - "country": "Россия" -} -``` - -### Привязка пользователя к филиалам - -```typescript -// Привязать пользователя к московскому офису -POST /branches/{moscow-branch-id}/users -{ - "userId": "user-uuid" -} - -// Привязать пользователя к питерскому офису -POST /branches/{spb-branch-id}/users -{ - "userId": "user-uuid" -} -``` - -### Выбор текущего филиала - -```typescript -// Установить московский офис как текущий -POST /branches/{moscow-branch-id}/select - -// Теперь в объекте пользователя currentBranchId будет указывать на московский офис -``` - -### Получение филиалов пользователя - -```typescript -// Получить все филиалы, к которым привязан пользователь -GET /branches/my -``` - -## Миграция базы данных - -После добавления схем необходимо выполнить миграцию: - -```bash -cd server -bun run drizzle-kit generate -bun run drizzle-kit migrate -``` - -## Типы TypeScript - -### Company -```typescript -type Company = { - id: string; - name: string; - description: string | null; - createdAt: Date; - updatedAt: Date; -}; -``` - -### Branch -```typescript -type Branch = { - id: string; - companyId: string; - name: string; - address: string | null; - city: string | null; - country: string | null; - createdAt: Date; - updatedAt: Date; -}; -``` - -### UserBranch -```typescript -type UserBranch = { - userId: string; - branchId: string; - createdAt: Date; -}; -``` - -## Примечания - -1. **Каскадное удаление**: При удалении компании автоматически удаляются все её филиалы -2. **Каскадное удаление связей**: При удалении пользователя или филиала автоматически удаляются все связи в таблице `user_branches` -3. **Валидация**: Пользователь может установить текущим только тот филиал, к которому он привязан -4. **Many-to-Many**: Пользователь может быть привязан к нескольким филиалам одновременно -5. **Текущий филиал**: Поле `currentBranchId` в таблице пользователей позволяет отслеживать, в каком филиале пользователь работает в данный момент - diff --git a/server/MIGRATION_GUIDE.md b/server/MIGRATION_GUIDE.md deleted file mode 100644 index 3c6e16e..0000000 --- a/server/MIGRATION_GUIDE.md +++ /dev/null @@ -1,97 +0,0 @@ -# Руководство по миграции: Упрощение системы ролей - -## Изменения - -Мы удаляем отдельную таблицу `roles` и переносим роли прямо в таблицу `users`. - -### Изменения в схеме: - -1. **Таблица `users`**: - - Переименование колонки `role_name` → `role` - - Удаление внешнего ключа на таблицу `roles` - - Добавление значения по умолчанию `'manager'` - -2. **Таблица `roles`**: - - Удаление всей таблицы (больше не нужна) - -## Шаги миграции - -### 1. Создать миграцию через Drizzle Kit - -```bash -cd server -bun run db:generate -``` - -### 2. Применить миграцию - -```bash -bun run db:migrate -``` - -### 3. Ручные SQL-команды (если нужно) - -Если автоматическая миграция не сработает, выполните следующие команды вручную: - -```sql --- 1. Удалить внешний ключ из таблицы users -ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_name_roles_name_fk; - --- 2. Переименовать колонку role_name в role -ALTER TABLE users RENAME COLUMN role_name TO role; - --- 3. Добавить значение по умолчанию -ALTER TABLE users ALTER COLUMN role SET DEFAULT 'manager'; - --- 4. Удалить таблицу roles -DROP TABLE IF EXISTS roles; -``` - -## Откат миграции (если нужно) - -Если что-то пошло не так, можно откатить изменения: - -```sql --- 1. Создать таблицу roles заново -CREATE TABLE roles ( - name TEXT PRIMARY KEY, - title VARCHAR(100) NOT NULL -); - --- 2. Заполнить таблицу roles -INSERT INTO roles (name, title) VALUES - ('admin', 'Администратор'), - ('director', 'Директор'), - ('manager', 'Менеджер'); - --- 3. Переименовать колонку обратно -ALTER TABLE users RENAME COLUMN role TO role_name; - --- 4. Удалить значение по умолчанию -ALTER TABLE users ALTER COLUMN role_name DROP DEFAULT; - --- 5. Добавить внешний ключ -ALTER TABLE users ADD CONSTRAINT users_role_name_roles_name_fk - FOREIGN KEY (role_name) REFERENCES roles(name); -``` - -## Проверка - -После миграции проверьте: - -1. Все существующие пользователи сохранили свои роли -2. API `/auth/me` возвращает корректный формат: - ```json - { - "user": { - "id": "...", - "email": "...", - "fullName": "...", - "role": "manager", - "createdAt": "..." - } - } - ``` -3. Регистрация и логин работают корректно -4. Проверка прав доступа работает (middleware) - diff --git a/server/src/controllers/server.ts b/server/src/controllers/server.ts new file mode 100644 index 0000000..7d5dd3f --- /dev/null +++ b/server/src/controllers/server.ts @@ -0,0 +1,311 @@ +import { Elysia, t } from "elysia"; +import { authMiddleware } from "../middlewares/auth"; +import { serverService } from "../services/server"; + +export const serverController = new Elysia({ prefix: "/servers" }) + // POST /servers/register - публичный endpoint для регистрации сервера (без авторизации) + .post( + "/register", + async ({ body, set }) => { + const { localIp, hostname, type, gpuFreeMb, branchId, location, tier } = + body as { + localIp: string; + hostname: string; + type: "stream" | "local"; + gpuFreeMb: number; + branchId?: string; + location?: "ru1" | "uae1"; + tier?: "demo" | "prod"; + }; + + // Валидация для stream-серверов + if (type === "stream") { + if (!location) { + set.status = 400; + return { error: "Location is required for stream servers" }; + } + } + + // Валидация для local-серверов + if (type === "local") { + if (!branchId) { + set.status = 400; + return { error: "Branch ID is required for local servers" }; + } + } + + // Установить tier по умолчанию для stream-серверов + const finalTier = type === "stream" && !tier ? "demo" : tier; + + // Проверить, существует ли сервер с таким hostname + const existingServer = await serverService.findByHostname(hostname); + + if (existingServer) { + // Если сервер существует, обновить его информацию + const updatedServer = await serverService.update(existingServer.id, { + localIp, + gpuFreeMb, + branchId, + location, + tier: finalTier, + }); + + return { server: updatedServer, registered: false }; + } + + // Создать новый сервер + const server = await serverService.create({ + localIp, + hostname, + type, + gpuFreeMb, + branchId, + location, + tier: finalTier, + }); + + return { server, registered: true }; + }, + { + body: t.Object({ + localIp: t.String({ minLength: 7, maxLength: 45 }), + hostname: t.String({ minLength: 1, maxLength: 255 }), + type: t.Union([t.Literal("stream"), t.Literal("local")]), + gpuFreeMb: t.Number({ minimum: 0 }), + branchId: t.Optional(t.String({ format: "uuid" })), + location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])), + tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])), + }), + } + ) + // PATCH /servers/:id/gpu - обновить свободную память GPU (публичный endpoint) + .patch( + "/:id/gpu", + async ({ params, body, status }) => { + const { id } = params; + const { gpuFreeMb } = body as { gpuFreeMb: number }; + + // Проверить существование сервера + const server = await serverService.findById(id); + + if (!server) { + return status(404, "Server not found"); + } + + const updatedServer = await serverService.updateGpuMemory(id, gpuFreeMb); + + return { server: updatedServer }; + }, + { + body: t.Object({ + gpuFreeMb: t.Number({ minimum: 0 }), + }), + } + ) + // GET /servers/:id/sessions - получить сессии для конкретного сервера (публичный endpoint) + .get("/:id/sessions", async ({ params, query, status }) => { + const { id } = params; + const { statusFilter, mode } = query as { + statusFilter?: "starting" | "started" | "ending" | "ended"; + mode?: "stream" | "local"; + }; + + // Проверить существование сервера + const server = await serverService.findById(id); + + if (!server) { + return status(404, "Server not found"); + } + + const { serverSessionService } = await import("../services/serverSession"); + + // Получаем только сессии, назначенные этому серверу + // Main server автоматически назначает серверы для unassigned сессий + const sessions = await serverSessionService.findByServerId(id, { + status: statusFilter, + mode, + }); + + return { sessions }; + }) + // Все остальные роуты требуют авторизации + .use(authMiddleware) + // GET /servers - получить список серверов с фильтрацией + .get("/", async ({ query }) => { + const { type, location, tier, branchId } = query as { + type?: "stream" | "local"; + location?: "ru1" | "uae1"; + tier?: "demo" | "prod"; + branchId?: string; + }; + + const servers = await serverService.findAll({ + type, + location, + tier, + branchId, + }); + + return { servers }; + }) + // GET /servers/available/stream - получить доступные stream-серверы + .get("/available/stream", async ({ query }) => { + const { tier } = query as { tier?: "demo" | "prod" }; + + const servers = await serverService.findAvailableStreamServers(tier); + + return { servers }; + }) + // GET /servers/available/local - получить доступные local-серверы + .get("/available/local", async ({ query }) => { + const { branchId } = query as { branchId?: string }; + + const servers = await serverService.findAvailableLocalServers(branchId); + + return { servers }; + }) + // GET /servers/:id - получить сервер по ID + .get("/:id", async ({ params, status }) => { + const { id } = params; + + const server = await serverService.findById(id); + + if (!server) { + return status(404, "Server not found"); + } + + return { server }; + }) + // POST /servers - создать сервер + .post( + "/", + async ({ body, set }) => { + const { localIp, hostname, type, gpuFreeMb, branchId, location, tier } = + body as { + localIp: string; + hostname: string; + type: "stream" | "local"; + gpuFreeMb: number; + branchId?: string; + location?: "ru1" | "uae1"; + tier?: "demo" | "prod"; + }; + + // Валидация для stream-серверов + if (type === "stream") { + if (!location) { + set.status = 400; + return { error: "Location is required for stream servers" }; + } + } + + // Валидация для local-серверов + if (type === "local") { + if (!branchId) { + set.status = 400; + return { error: "Branch ID is required for local servers" }; + } + } + + // Установить tier по умолчанию для stream-серверов + const finalTier = type === "stream" && !tier ? "demo" : tier; + + const server = await serverService.create({ + localIp, + hostname, + type, + gpuFreeMb, + branchId, + location, + tier: finalTier, + }); + + return { server }; + }, + { + body: t.Object({ + localIp: t.String({ minLength: 7, maxLength: 45 }), + hostname: t.String({ minLength: 1, maxLength: 255 }), + type: t.Union([t.Literal("stream"), t.Literal("local")]), + gpuFreeMb: t.Number({ minimum: 0 }), + branchId: t.Optional(t.String({ format: "uuid" })), + location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])), + tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])), + }), + } + ) + // PATCH /servers/:id - обновить сервер + .patch( + "/:id", + async ({ params, body, status, set }) => { + const { id } = params; + const { localIp, hostname, gpuFreeMb, branchId, location, tier } = + body as { + localIp?: string; + hostname?: string; + gpuFreeMb?: number; + branchId?: string; + location?: "ru1" | "uae1"; + tier?: "demo" | "prod"; + }; + + // Проверить существование сервера + const server = await serverService.findById(id); + + if (!server) { + return status(404, "Server not found"); + } + + // Валидация для stream-серверов: нельзя удалить location + if (server.type === "stream") { + if (location === undefined && server.location === null) { + set.status = 400; + return { error: "Location cannot be removed from stream servers" }; + } + } + + // Валидация для local-серверов: нельзя удалить branchId + if (server.type === "local") { + if (branchId === undefined && server.branchId === null) { + set.status = 400; + return { error: "Branch ID cannot be removed from local servers" }; + } + } + + const updatedServer = await serverService.update(id, { + localIp, + hostname, + gpuFreeMb, + branchId, + location, + tier, + }); + + return { server: updatedServer }; + }, + { + body: t.Object({ + localIp: t.Optional(t.String({ minLength: 7, maxLength: 45 })), + hostname: t.Optional(t.String({ minLength: 1, maxLength: 255 })), + gpuFreeMb: t.Optional(t.Number({ minimum: 0 })), + branchId: t.Optional(t.String({ format: "uuid" })), + location: t.Optional(t.Union([t.Literal("ru1"), t.Literal("uae1")])), + tier: t.Optional(t.Union([t.Literal("demo"), t.Literal("prod")])), + }), + } + ) + // DELETE /servers/:id - удалить сервер + .delete("/:id", async ({ params, status }) => { + const { id } = params; + + // Проверить существование сервера + const server = await serverService.findById(id); + + if (!server) { + return status(404, "Server not found"); + } + + await serverService.delete(id); + + return { message: "Server deleted successfully" }; + }); diff --git a/server/src/controllers/session.ts b/server/src/controllers/session.ts index fa82f51..b39e679 100644 --- a/server/src/controllers/session.ts +++ b/server/src/controllers/session.ts @@ -6,6 +6,78 @@ import { apps } from "../db/schema/apps"; import { serverSessionService } from "../services/serverSession"; export const sessionController = new Elysia({ prefix: "/sessions" }) + // PATCH /sessions/:id/status - обновить статус сессии (публичный endpoint для сессионного сервера) + .patch( + "/:id/status", + async ({ params, body, status }) => { + const { id } = params; + const { + status: sessionStatus, + appPid, + cirrusPid, + } = body as { + status?: "starting" | "started" | "ending" | "ended"; + appPid?: number; + cirrusPid?: number; + }; + + // Проверить, что сессия существует + const session = await serverSessionService.findById(id); + + if (!session) { + return status(404, "Session not found"); + } + + // Обновить сессию + const updatedSession = await serverSessionService.update(id, { + status: sessionStatus, + appPid, + cirrusPid, + }); + + return { session: updatedSession }; + }, + { + body: t.Object({ + status: t.Optional( + t.Union([ + t.Literal("starting"), + t.Literal("started"), + t.Literal("ending"), + t.Literal("ended"), + ]) + ), + appPid: t.Optional(t.Number()), + cirrusPid: t.Optional(t.Number()), + }), + } + ) + // POST /sessions/:id/assign-server - назначить сервер для сессии (публичный endpoint для сессионного сервера) + .post( + "/:id/assign-server", + async ({ params, body, status }) => { + const { id } = params; + const { requiredGpuMb } = body as { requiredGpuMb?: number }; + + try { + const updatedSession = await serverSessionService.assignServer( + id, + requiredGpuMb + ); + return { session: updatedSession }; + } catch (error) { + if (error instanceof Error) { + return status(400, error.message); + } + return status(500, "Failed to assign server"); + } + }, + { + body: t.Object({ + requiredGpuMb: t.Optional(t.Number({ minimum: 0 })), + }), + } + ) // Все роуты требуют авторизации .use(authMiddleware) // GET /sessions - получить список сессий пользователя diff --git a/server/src/db/schema/serverSessions.ts b/server/src/db/schema/serverSessions.ts index 576fab7..ffc9177 100644 --- a/server/src/db/schema/serverSessions.ts +++ b/server/src/db/schema/serverSessions.ts @@ -16,17 +16,17 @@ export const sessionStatusEnum = pgEnum("session_status", [ export const serverSessions = pgTable("server_sessions", { id: uuid("id").primaryKey().defaultRandom(), - serverId: uuid("server_id") - .notNull() - .references(() => servers.id), + serverId: uuid("server_id").references(() => servers.id), // Nullable - для stream сессий назначается динамически appId: uuid("app_id") .notNull() .references(() => apps.id), userId: uuid("user_id") .notNull() .references(() => users.id), - startAt: timestamp("start_at").defaultNow().notNull(), - endAt: timestamp("end_at"), // Default 30 minutes from start_at + startAt: timestamp("start_at", { withTimezone: true }) + .defaultNow() + .notNull(), + endAt: timestamp("end_at", { withTimezone: true }), // Default 30 minutes from start_at appPid: integer("app_pid"), cirrusPid: integer("cirrus_pid"), mode: sessionModeEnum("mode").notNull(), // stream, local diff --git a/server/src/db/schema/servers.ts b/server/src/db/schema/servers.ts index 4aa070e..abf49a5 100644 --- a/server/src/db/schema/servers.ts +++ b/server/src/db/schema/servers.ts @@ -1,4 +1,11 @@ -import { pgTable, uuid, varchar, pgEnum, timestamp } from "drizzle-orm/pg-core"; +import { + pgTable, + uuid, + varchar, + pgEnum, + timestamp, + integer, +} from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { serverSessions } from "./serverSessions"; @@ -14,6 +21,7 @@ export const servers = pgTable("servers", { localIp: varchar("local_ip", { length: 45 }).notNull(), // IPv6 can be up to 45 chars hostname: varchar("hostname").notNull(), // hostname сервера type: serverTypeEnum("type").notNull(), // stream, local + gpuFreeMb: integer("gpu_free_mb").notNull(), // свободная память на GPU в мегабайтах branchId: uuid("branch_id").references(() => branches.id), // филиал, на котором находится сервер (nullable для локальных серверов) location: serverLocationEnum("location"), // ru1, uae1 (только для stream) tier: serverTierEnum("tier"), // demo, prod (только для stream) @@ -26,7 +34,20 @@ export const servers = pgTable("servers", { }); // Zod schemas for validation -export const insertServerSchema = createInsertSchema(servers); +export const insertServerSchema = createInsertSchema(servers).refine( + (data) => { + // Если тип "stream", то location и tier обязательны + if (data.type === "stream") { + return data.location !== undefined && data.location !== null; + } + return true; + }, + { + message: "Location is required for stream servers", + path: ["location"], + } +); + export const selectServerSchema = createSelectSchema(servers); // Relations diff --git a/server/src/index.ts b/server/src/index.ts index 4dffc45..fe8f5d2 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,8 @@ import { authController } from "./controllers/auth"; import { sessionController } from "./controllers/session"; import { companyController } from "./controllers/company"; import { branchController } from "./controllers/branch"; +import { serverController } from "./controllers/server"; +import { serverSessionService } from "./services/serverSession"; const app = new Elysia(); @@ -18,9 +20,53 @@ app.use(authController); app.use(sessionController); app.use(companyController); app.use(branchController); +app.use(serverController); app.listen(3000); console.log( `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` ); + +// Запуск фоновой задачи для автоматического назначения серверов +const AUTO_ASSIGN_INTERVAL_MS = parseInt( + process.env.AUTO_ASSIGN_INTERVAL_MS || "1000", + 10 +); // 1 секунда по умолчанию + +async function autoAssignServersTask() { + try { + const results = await serverSessionService.autoAssignServers(); + + if (results.total > 0) { + console.log( + `[${new Date().toISOString()}] 🎯 Auto-assign: ${ + results.assigned + } assigned, ${results.failed} failed из ${results.total}` + ); + + if (results.errors.length > 0) { + console.error( + `[${new Date().toISOString()}] ❌ Ошибки назначения:`, + results.errors + ); + } + } + } catch (error) { + console.error( + `[${new Date().toISOString()}] ❌ Ошибка auto-assign:`, + error instanceof Error ? error.message : error + ); + } + + // Планируем следующий запуск + setTimeout(autoAssignServersTask, AUTO_ASSIGN_INTERVAL_MS); +} + +// Запускаем через 1 секунду после старта сервера +setTimeout(() => { + console.log( + `[${new Date().toISOString()}] 🤖 Запуск фоновой задачи auto-assign (интервал: ${AUTO_ASSIGN_INTERVAL_MS}ms)` + ); + autoAssignServersTask(); +}, 1000); diff --git a/server/src/services/server/index.ts b/server/src/services/server/index.ts new file mode 100644 index 0000000..5c81f74 --- /dev/null +++ b/server/src/services/server/index.ts @@ -0,0 +1,232 @@ +import { eq, and } from "drizzle-orm"; +import db from "../../db"; +import { servers } from "../../db/schema/servers"; + +export type ServerType = "stream" | "local"; +export type ServerLocation = "ru1" | "uae1"; +export type ServerTier = "demo" | "prod"; + +export interface CreateServerParams { + localIp: string; + hostname: string; + type: ServerType; + gpuFreeMb: number; + branchId?: string; + location?: ServerLocation; + tier?: ServerTier; +} + +export interface UpdateServerParams { + localIp?: string; + hostname?: string; + gpuFreeMb?: number; + branchId?: string; + location?: ServerLocation; + tier?: ServerTier; +} + +export interface FindServersFilters { + type?: ServerType; + location?: ServerLocation; + tier?: ServerTier; + branchId?: string; +} + +/** + * Сервис для работы с серверами + */ +export const serverService = { + /** + * Создать сервер + */ + async create(params: CreateServerParams) { + // Валидация для stream-серверов + if (params.type === "stream" && !params.location) { + throw new Error("Location is required for stream servers"); + } + + // Установить tier по умолчанию для stream-серверов + const tier = + params.type === "stream" && !params.tier ? "demo" : params.tier; + + const [server] = await db + .insert(servers) + .values({ + localIp: params.localIp, + hostname: params.hostname, + type: params.type, + gpuFreeMb: params.gpuFreeMb, + branchId: params.branchId, + location: params.location, + tier: tier, + }) + .returning(); + + return server; + }, + + /** + * Получить все серверы с фильтрацией + */ + async findAll(filters?: FindServersFilters) { + const conditions = []; + + if (filters?.type) { + conditions.push(eq(servers.type, filters.type)); + } + + if (filters?.location) { + conditions.push(eq(servers.location, filters.location)); + } + + if (filters?.tier) { + conditions.push(eq(servers.tier, filters.tier)); + } + + if (filters?.branchId) { + conditions.push(eq(servers.branchId, filters.branchId)); + } + + const allServers = await db.query.servers.findMany({ + where: conditions.length > 0 ? and(...conditions) : undefined, + orderBy: (servers, { asc }) => [asc(servers.hostname)], + }); + + return allServers; + }, + + /** + * Найти сервер по ID + */ + async findById(serverId: string) { + const server = await db.query.servers.findFirst({ + where: eq(servers.id, serverId), + }); + + return server || null; + }, + + /** + * Найти сервер по hostname + */ + async findByHostname(hostname: string) { + const server = await db.query.servers.findFirst({ + where: eq(servers.hostname, hostname), + }); + + return server || null; + }, + + /** + * Получить серверы по филиалу + */ + async findByBranchId(branchId: string) { + const branchServers = await db.query.servers.findMany({ + where: eq(servers.branchId, branchId), + orderBy: (servers, { asc }) => [asc(servers.hostname)], + }); + + return branchServers; + }, + + /** + * Получить доступные stream-серверы + */ + async findAvailableStreamServers(tier?: ServerTier) { + const conditions = [eq(servers.type, "stream")]; + + if (tier) { + conditions.push(eq(servers.tier, tier)); + } + + const streamServers = await db.query.servers.findMany({ + where: and(...conditions), + orderBy: (servers, { desc }) => [desc(servers.gpuFreeMb)], + }); + + return streamServers; + }, + + /** + * Получить доступные local-серверы + */ + async findAvailableLocalServers(branchId?: string) { + const conditions = [eq(servers.type, "local")]; + + if (branchId) { + conditions.push(eq(servers.branchId, branchId)); + } + + const localServers = await db.query.servers.findMany({ + where: and(...conditions), + orderBy: (servers, { desc }) => [desc(servers.gpuFreeMb)], + }); + + return localServers; + }, + + /** + * Обновить сервер + */ + async update(serverId: string, params: UpdateServerParams) { + const updateData: any = { + updatedAt: new Date(), + }; + + if (params.localIp) { + updateData.localIp = params.localIp; + } + + if (params.hostname) { + updateData.hostname = params.hostname; + } + + if (params.gpuFreeMb !== undefined) { + updateData.gpuFreeMb = params.gpuFreeMb; + } + + if (params.branchId !== undefined) { + updateData.branchId = params.branchId; + } + + if (params.location !== undefined) { + updateData.location = params.location; + } + + if (params.tier !== undefined) { + updateData.tier = params.tier; + } + + const [updatedServer] = await db + .update(servers) + .set(updateData) + .where(eq(servers.id, serverId)) + .returning(); + + return updatedServer; + }, + + /** + * Обновить свободную память GPU + */ + async updateGpuMemory(serverId: string, gpuFreeMb: number) { + const [updatedServer] = await db + .update(servers) + .set({ + gpuFreeMb, + updatedAt: new Date(), + }) + .where(eq(servers.id, serverId)) + .returning(); + + return updatedServer; + }, + + /** + * Удалить сервер + */ + async delete(serverId: string) { + await db.delete(servers).where(eq(servers.id, serverId)); + }, +}; + diff --git a/server/src/services/serverSession/index.ts b/server/src/services/serverSession/index.ts index c93ece0..18ea06c 100644 --- a/server/src/services/serverSession/index.ts +++ b/server/src/services/serverSession/index.ts @@ -120,6 +120,80 @@ export const serverSessionService = { return sessions; }, + /** + * Получить все сессии для конкретного сервера + */ + async findByServerId( + serverId: string, + filters?: { + status?: SessionStatus; + mode?: SessionMode; + } + ) { + const conditions = [eq(serverSessions.serverId, serverId)]; + + if (filters?.status) { + conditions.push(eq(serverSessions.status, filters.status)); + } + + if (filters?.mode) { + conditions.push(eq(serverSessions.mode, filters.mode)); + } + + const sessions = await db.query.serverSessions.findMany({ + where: and(...conditions), + with: { + app: true, + user: { + columns: { + id: true, + email: true, + role: true, + }, + }, + }, + orderBy: (serverSessions, { asc }) => [asc(serverSessions.createdAt)], + }); + + return sessions; + }, + + /** + * Получить сессии без назначенного сервера + */ + async findUnassignedSessions(filters?: { + status?: SessionStatus; + mode?: SessionMode; + }) { + const { isNull } = await import("drizzle-orm"); + const conditions = [isNull(serverSessions.serverId)]; + + if (filters?.status) { + conditions.push(eq(serverSessions.status, filters.status)); + } + + if (filters?.mode) { + conditions.push(eq(serverSessions.mode, filters.mode)); + } + + const sessions = await db.query.serverSessions.findMany({ + where: and(...conditions), + with: { + app: true, + user: { + columns: { + id: true, + email: true, + role: true, + }, + }, + }, + orderBy: (serverSessions, { asc }) => [asc(serverSessions.createdAt)], + }); + + return sessions; + }, + /** * Проверить, есть ли у пользователя активная сессия для данного приложения */ @@ -161,9 +235,10 @@ export const serverSessionService = { async create(params: CreateSessionParams) { const { appId, userId, mode, serverId } = params; - // Выбрать сервер (если не указан) + // Для local-сессий выбираем сервер сразу + // Для stream-сессий сервер будет назначен динамически при запуске let selectedServerId = serverId; - if (!selectedServerId) { + if (mode === "local" && !selectedServerId) { selectedServerId = await this.selectAvailableServer(mode); if (!selectedServerId) { throw new Error(`No available ${mode} servers`); @@ -178,7 +253,7 @@ export const serverSessionService = { const [newSession] = await db .insert(serverSessions) .values({ - serverId: selectedServerId, + serverId: selectedServerId, // Может быть null для stream-сессий appId, userId, mode, @@ -263,4 +338,210 @@ export const serverSessionService = { return updatedSession; }, + + /** + * Назначить сервер для сессии + * Выбирает сервер с максимальной свободной GPU памятью + */ + async assignServer(sessionId: string, requiredGpuMb?: number) { + const session = await this.findById(sessionId); + + if (!session) { + throw new Error("Session not found"); + } + + if (session.serverId) { + // Сервер уже назначен + return session; + } + + // Импортируем serverService динамически чтобы избежать циклических зависимостей + const { serverService } = await import("../server"); + + let selectedServer; + + if (session.mode === "stream") { + // Для stream-сессий выбираем сервер с максимальной свободной памятью + // Ищем среди всех tier (prod и demo) + const availableServers = await serverService.findAvailableStreamServers(); + + if (availableServers.length === 0) { + throw new Error( + "No available stream servers (check that stream servers are registered)" + ); + } + + console.log( + `[${new Date().toISOString()}] 📊 Найдено ${ + availableServers.length + } stream-серверов:`, + availableServers + .map((s) => `${s.hostname} (${s.tier}, ${s.gpuFreeMb}MB)`) + .join(", ") + ); + + // Фильтруем серверы по доступной GPU памяти + // Требуемая память берется из gpuLimitMb приложения или используются все доступные серверы + const memoryOkServers = requiredGpuMb + ? availableServers.filter((s) => { + const hasEnough = s.gpuFreeMb >= requiredGpuMb; + if (!hasEnough) { + console.log( + `[${new Date().toISOString()}] ⚠️ Сервер ${s.id} (${ + s.hostname + }) пропущен по памяти: ${s.gpuFreeMb}MB < ${requiredGpuMb}MB` + ); + } + return hasEnough; + }) + : availableServers; + + if (memoryOkServers.length === 0) { + const maxAvailable = Math.max( + ...availableServers.map((s) => s.gpuFreeMb) + ); + throw new Error( + `No servers with enough GPU memory (required: ${requiredGpuMb}MB, max available: ${maxAvailable}MB)` + ); + } + + // Проверяем количество активных сессий на каждом сервере + // Максимум одновременных сессий на один stream-сервер (по умолчанию 3) + const MAX_SESSIONS_PER_SERVER = parseInt( + process.env.MAX_SESSIONS_PER_STREAM_SERVER || "3", + 10 + ); + const suitableServers = []; + + for (const server of memoryOkServers) { + // Подсчитываем активные сессии (starting или started) + const activeSessions = await this.findByServerId(server.id, {}); + const activeCount = activeSessions.filter( + (s) => s.status === "starting" || s.status === "started" + ).length; + + if (activeCount >= MAX_SESSIONS_PER_SERVER) { + console.log( + `[${new Date().toISOString()}] ⚠️ Сервер ${server.id} (${ + server.hostname + }) пропущен по загрузке: ${activeCount}/${MAX_SESSIONS_PER_SERVER} активных сессий` + ); + } else { + console.log( + `[${new Date().toISOString()}] ✅ Сервер ${server.id} (${ + server.hostname + }) доступен: ${activeCount}/${MAX_SESSIONS_PER_SERVER} активных сессий` + ); + suitableServers.push(server); + } + } + + if (suitableServers.length === 0) { + throw new Error( + `No available servers (all servers have ${MAX_SESSIONS_PER_SERVER} or more active sessions)` + ); + } + + // Берем первый сервер (уже отсортирован по убыванию gpuFreeMb) + selectedServer = suitableServers[0]; + + console.log( + `[${new Date().toISOString()}] ✅ Выбран сервер ${selectedServer.id} (${ + selectedServer.hostname + }) с ${ + selectedServer.gpuFreeMb + }MB свободной памяти для сессии ${sessionId} (требуется: ${ + requiredGpuMb || "не указано" + }MB)` + ); + } else { + // Для local-сессий используем существующую логику + const serverId = await this.selectAvailableServer(session.mode); + if (!serverId) { + throw new Error("No available local servers"); + } + selectedServer = await serverService.findById(serverId); + } + + if (!selectedServer) { + throw new Error("Failed to select server"); + } + + // Назначаем сервер сессии + const [updatedSession] = await db + .update(serverSessions) + .set({ + serverId: selectedServer.id, + updatedAt: new Date(), + }) + .where(eq(serverSessions.id, sessionId)) + .returning(); + + return updatedSession; + }, + + /** + * Автоматически назначить серверы для всех unassigned сессий, готовых к запуску + * Вызывается периодически main сервером + */ + async autoAssignServers() { + const now = new Date(); + + // Находим все unassigned сессии со статусом "starting" и startAt <= now + const { isNull } = await import("drizzle-orm"); + const unassignedSessions = await db.query.serverSessions.findMany({ + where: and( + isNull(serverSessions.serverId), + eq(serverSessions.status, "starting") + ), + with: { + app: true, + }, + }); + + // Фильтруем сессии, у которых уже наступило время запуска + const readySessions = unassignedSessions.filter((session) => { + const startAt = new Date(session.startAt); + return startAt <= now; + }); + + const results = { + total: readySessions.length, + assigned: 0, + failed: 0, + errors: [] as string[], + }; + + // Назначаем сервер для каждой готовой сессии + for (const session of readySessions) { + try { + const requiredGpuMb = session.app.gpuLimitMb || undefined; + console.log( + `[${new Date().toISOString()}] 🔍 Назначение сервера для сессии ${ + session.id + } (приложение: ${session.app.name}, требуется GPU: ${ + requiredGpuMb || "не указано" + }MB)` + ); + + await this.assignServer(session.id, requiredGpuMb); + results.assigned++; + } catch (error) { + results.failed++; + const errorMsg = error instanceof Error ? error.message : String(error); + results.errors.push( + `Session ${session.id} (${session.app.name}): ${errorMsg}` + ); + + console.error( + `[${new Date().toISOString()}] ❌ Не удалось назначить сервер для сессии ${ + session.id + }:`, + errorMsg + ); + } + } + + return results; + }, }; diff --git a/session-server/.gitignore b/session-server/.gitignore new file mode 100644 index 0000000..a68d131 --- /dev/null +++ b/session-server/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build +/dist + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +**/*.trace +**/*.zip +**/*.tar.gz +**/*.tgz +**/*.log +package-lock.json +**/*.bun \ No newline at end of file diff --git a/session-server/README.md b/session-server/README.md new file mode 100644 index 0000000..32d91f4 --- /dev/null +++ b/session-server/README.md @@ -0,0 +1,397 @@ +# Session Server + +Сессионный сервер для автоматической регистрации на основном API сервере. + +## Описание + +Session Server - это приложение, которое автоматически регистрируется на основном сервере (`stream.graff.tech`) и периодически обновляет свою информацию. Это позволяет основному серверу знать о доступных сессионных серверах для распределения нагрузки. + +## Возможности + +- 🚀 Автоматическая регистрация при запуске +- 🔄 Периодическое обновление информации о сервере (по умолчанию каждые 30 секунд после завершения предыдущего запроса) +- 🎮 Автоматическое определение и обновление свободной GPU памяти через nvidia-smi (по умолчанию каждую секунду после завершения предыдущего запроса) +- 🎯 Автоматическое управление игровыми сессиями + - Запуск приложений для сессий со статусом "starting" + - Остановка приложений для сессий со статусом "ending" + - Отслеживание PID запущенных процессов + - Автоматическое обновление статусов сессий на главном сервере +- 🌐 Автоматическое определение локального IP адреса +- 💻 Автоматическое определение hostname +- ⚙️ Гибкая конфигурация через переменные окружения +- 🔁 Автоматические повторные попытки при ошибках +- 📝 Подробное логирование +- ⚡ Защита от наложения запросов для всех операций (рекурсивный вызов через setTimeout) + +## Установка + +```bash +# Установка зависимостей +bun install +``` + +## Конфигурация + +Создайте файл `.env` в корне проекта на основе `.env.example`: + +```bash +# URL основного API сервера +API_URL=http://localhost:3000 + +# Тип сервера: stream или local +SERVER_TYPE=stream + +# Расположение сервера (ОБЯЗАТЕЛЬНО для stream серверов) +# Возможные значения: ru1, uae1 +SERVER_LOCATION=ru1 + +# Уровень сервера (только для stream серверов) +# Возможные значения: demo, prod +SERVER_TIER=demo + +# ID филиала (ОБЯЗАТЕЛЬНО для local серверов) +BRANCH_ID=00000000-0000-0000-0000-000000000000 + +# Локальный IP адрес (опционально, определяется автоматически) +# LOCAL_IP=192.168.1.100 + +# Hostname сервера (опционально, определяется автоматически) +# HOSTNAME=my-session-server + +# Интервал регистрации в миллисекундах (по умолчанию 30000 = 30 секунд) +REGISTER_INTERVAL_MS=30000 + +# Интервал обновления GPU памяти в миллисекундах (по умолчанию 1000 = 1 секунда) +GPU_UPDATE_INTERVAL_MS=1000 + +# Интервал проверки сессий в миллисекундах (по умолчанию 5000 = 5 секунд) +SESSION_CHECK_INTERVAL_MS=5000 +``` + +### Переменные окружения + +| Переменная | Описание | Обязательна | По умолчанию | +|------------|----------|-------------|--------------| +| `API_URL` | URL основного API сервера | Нет | `http://localhost:3000` | +| `SERVER_TYPE` | Тип сервера (`stream` или `local`) | Нет | `stream` | +| `SERVER_LOCATION` | Расположение (`ru1`, `uae1`) - **обязательно для `stream`** | Да (для stream) | - | +| `SERVER_TIER` | Уровень (`demo`, `prod`) - только для `stream` | Нет | `demo` (для stream) | +| `BRANCH_ID` | ID филиала - **обязательно для `local`** | Да (для local) | - | +| `LOCAL_IP` | Локальный IP адрес | Нет | Определяется автоматически | +| `HOSTNAME` | Hostname сервера | Нет | Определяется автоматически | +| `REGISTER_INTERVAL_MS` | Интервал регистрации в мс | Нет | `30000` | +| `GPU_UPDATE_INTERVAL_MS` | Интервал обновления GPU памяти в мс | Нет | `1000` | +| `SESSION_CHECK_INTERVAL_MS` | Интервал проверки сессий в мс | Нет | `5000` | + +**Примечание:** Свободная память GPU (`gpuFreeMb`) автоматически определяется через `nvidia-smi` при каждой регистрации и обновляется каждую секунду (или согласно `GPU_UPDATE_INTERVAL_MS`) после завершения предыдущего запроса. Это предотвращает наложение запросов. Если `nvidia-smi` недоступен, сервер завершит работу с ошибкой. + +## Управление сессиями + +Session Server автоматически управляет игровыми сессиями на этом сервере: + +### Как это работает + +1. **Проверка сессий**: Каждую секунду (или согласно `SESSION_CHECK_INTERVAL_MS`) сервер запрашивает у основного API список сессий для этого сервера +2. **Запуск приложений**: Для сессий со статусом `starting`: + - ⏰ Проверяется время `startAt` - приложение запускается только если это время уже наступило + - **Timezone**: Все время хранится в UTC, сравнение корректно работает независимо от часового пояса сервера + - Запланированные сессии (с будущим `startAt`) логируются с информацией о времени до запуска + - 🎯 **Централизованное назначение сервера** (для stream-сессий): + - **Main server** автоматически назначает серверы каждые 5 секунд + - Выбирается сервер с максимальной свободной GPU памятью + - Учитываются требования приложения к GPU памяти (`gpuLimitMb`) + - Session-server просто проверяет, что сессия назначена ему + - Запускается соответствующее приложение + - Отслеживается PID процесса + - Статус сессии обновляется на `started` на главном сервере +3. **Остановка приложений**: Для сессий со статусом `ending`: + - Используется `taskkill /pid {PID} /T /F` для завершения всего дерева процессов + - `/T` - завершает указанный процесс и ВСЕ дочерние процессы + - `/F` - принудительное завершение + - Решает проблему с UE5 и другими приложениями, создающими дочерние процессы + - Статус сессии обновляется на `ended` на главном сервере +4. **Автоматическая очистка**: Процессы для неактивных сессий автоматически останавливаются + +### API endpoints для управления сессиями + +Session Server взаимодействует с основным сервером через следующие endpoints: + +- `GET /servers/:id/sessions` - получить список сессий для сервера +- `PATCH /sessions/:id/status` - обновить статус сессии (публичный endpoint) + +### Запуск приложений + +Session Server автоматически запускает `.exe` приложения по стандартному пути: + +``` +C:\apps\{appName}\{appName}.exe +``` + +Где `{appName}` - это значение поля `name` из таблицы `apps`. + +#### Структура директорий + +Все приложения должны быть размещены в следующей структуре: +``` +C:\apps\ +├── minecraft\ +│ └── minecraft.exe +├── fortnite\ +│ └── fortnite.exe +└── cyberpunk\ + └── cyberpunk.exe +``` + +#### Особенности запуска и остановки + +- ✅ Автоматическая проверка существования exe файла +- ✅ Рабочая директория устанавливается в папку приложения (`C:\apps\{appName}\`) +- ✅ Окно консоли скрывается (`windowsHide: true`) +- ✅ PID процесса отслеживается и передается на main server +- ✅ Автоматическое обновление статуса при завершении процесса +- ✅ **Корректное завершение дочерних процессов** - использует `taskkill /T` для завершения всего дерева процессов +- ✅ Решает проблему с UE5 приложениями, которые создают множественные процессы + +#### Логи запуска + +``` +[2025-10-06T10:00:00.000Z] 🚀 Запуск приложения "minecraft" для сессии abc-123 +[2025-10-06T10:00:00.050Z] 📂 Путь к приложению: C:\apps\minecraft\minecraft.exe +[2025-10-06T10:00:01.000Z] ✅ Приложение запущено с PID 12345 +[2025-10-06T10:00:01.100Z] ✅ Статус сессии abc-123 обновлен на "started" +``` + +#### Обработка ошибок + +Если exe файл не найден: +``` +❌ Файл приложения не найден: C:\apps\minecraft\minecraft.exe. Убедитесь, что приложение установлено. +``` + +### Примеры логов + +Запланированная сессия: +``` +[2025-10-06T10:00:00.000Z] ⏰ Сессия 123e4567-e89b-12d3-a456-426614174000 (minecraft) запланирована через 120 сек +``` + +Запуск сессии: +``` +[2025-10-06T10:02:00.000Z] 🚀 Запуск приложения "minecraft" для сессии 123e4567-e89b-12d3-a456-426614174000 +[2025-10-06T10:02:01.000Z] ✅ Приложение запущено с PID 12345 +[2025-10-06T10:02:01.100Z] ✅ Статус сессии 123e4567-e89b-12d3-a456-426614174000 обновлен на "started" +``` + +Остановка сессии: +``` +[2025-10-06T10:32:00.000Z] 🛑 Остановка приложения для сессии 123e4567-e89b-12d3-a456-426614174000 (PID: 12345) +[2025-10-06T10:32:00.500Z] ✅ Дерево процессов для PID 12345 успешно завершено +[2025-10-06T10:32:00.600Z] ✅ Приложение и все дочерние процессы остановлены для сессии 123e4567-e89b-12d3-a456-426614174000 +[2025-10-06T10:32:00.700Z] ✅ Статус сессии 123e4567-e89b-12d3-a456-426614174000 обновлен на "ended" +``` + +## Отладка проблем (Troubleshooting) + +Если приложение запускается и сразу завершается с ошибкой, см. подробное руководство по диагностике: +- **[TROUBLESHOOTING.md](./TROUBLESHOOTING.md)** + +### Новые возможности логирования + +После обновления вы увидите: +- 🔴 **STDERR логи** для диагностики ошибок приложений +- 📊 **Счётчик активных процессов** при запуске/завершении +- ⚠️ **Детальная информация** о кодах выхода и сигналах + +Пример логов с ошибкой: +``` +[10:51:56.498Z] 🚀 Запуск приложения "ShishimGorka" для сессии abc (активных процессов: 1) +[10:51:56.563Z] ✅ Приложение запущено с PID 84112 (всего активных: 2) +[10:51:57.500Z] 🔴 STDERR [abc]: Error: Port 8888 is already in use +[10:51:57.894Z] 🛑 Приложение для сессии abc завершилось с кодом 1 +[10:51:57.894Z] ⚠️ Приложение завершилось с ошибкой! Код выхода: 1 +``` + +## Работа с часовыми поясами (Timezone) + +Session Server корректно работает с разными часовыми поясами: + +### Как это работает + +1. **База данных**: PostgreSQL хранит все timestamp с timezone в UTC +2. **API**: Основной сервер возвращает время в формате ISO 8601 с timezone (например, `2025-10-06T10:00:00.000Z`) +3. **Session Server**: JavaScript автоматически парсит ISO 8601 строки в UTC +4. **Сравнение**: Все сравнения времени происходят в UTC, независимо от локального часового пояса сервера + +### Миграция базы данных + +⚠️ **Важно**: Если вы обновляете существующую систему, необходимо выполнить миграцию для добавления timezone в поля `startAt` и `endAt`: + +```bash +# Из директории server/ +psql -d $DATABASE_URL -f timezone_migration.sql +``` + +См. `server/TIMEZONE_MIGRATION.md` для подробностей. + +### Примеры + +Если сервер в России создаёт сессию на 14:00 по московскому времени (UTC+3): +- В БД сохранится: `2025-10-06T11:00:00.000Z` (UTC) +- Сервер в ОАЭ (UTC+4) увидит: 15:00 по местному времени +- Session Server запустит приложение ровно в 11:00 UTC, независимо от локального времени + +## Запуск + +### Development режим + +```bash +bun run dev +``` + +### Production режим + +```bash +# Собрать проект +bun run build + +# Запустить собранное приложение +bun run start +``` + +## Примеры использования + +### Stream сервер в России (демо) + +```env +API_URL=https://api.stream.graff.tech +SERVER_TYPE=stream +SERVER_LOCATION=ru1 +SERVER_TIER=demo +REGISTER_INTERVAL_MS=30000 +``` + +### Stream сервер в ОАЭ (продакшн) + +```env +API_URL=https://api.stream.graff.tech +SERVER_TYPE=stream +SERVER_LOCATION=uae1 +SERVER_TIER=prod +REGISTER_INTERVAL_MS=30000 +``` + +### Local сервер для филиала + +```env +API_URL=https://api.stream.graff.tech +SERVER_TYPE=local +BRANCH_ID=123e4567-e89b-12d3-a456-426614174000 +REGISTER_INTERVAL_MS=60000 +``` + +## Логирование + +Сервер выводит подробную информацию о своей работе: + +```text +============================================================ +🚀 Запуск сессионного сервера +============================================================ +Конфигурация: + API URL: http://localhost:3000 + Hostname: DESKTOP-ABC123 + Local IP: 192.168.1.100 + Type: stream + GPU Free MB: 8192 (читается из nvidia-smi) + Location: ru1 + Tier: demo + Register Interval: 30000ms + GPU Update Interval: 1000ms +============================================================ +[2025-10-06T12:00:00.000Z] Регистрация сервера... +Данные: { + "localIp": "192.168.1.100", + "hostname": "DESKTOP-ABC123", + "type": "stream", + "gpuFreeMb": 8192, + "location": "ru1", + "tier": "demo" +} +[2025-10-06T12:00:00.123Z] ✅ Сервер успешно зарегистрирован +ID сервера: 123e4567-e89b-12d3-a456-426614174000 +[2025-10-06T12:00:01.000Z] 🎮 GPU память обновлена: 8150 MB +[2025-10-06T12:00:02.000Z] 🎮 GPU память обновлена: 8120 MB +[2025-10-06T12:00:03.000Z] 🎮 GPU память обновлена: 8100 MB +``` + +## Обработка ошибок + +Сервер автоматически обрабатывает ошибки: + +- **Валидация конфигурации**: + - Stream-серверы: обязательно должен быть указан `SERVER_LOCATION` + - Local-серверы: обязательно должен быть указан `BRANCH_ID` + - При отсутствии обязательных полей сервер завершит работу с критической ошибкой +- **Сетевые ошибки**: Автоматические повторные попытки (до 3 раз) +- **Таймауты**: 10 секунд на запрос +- **Коды ошибок**: Повторная отправка при временных ошибках сервера (408, 429, 500, 502, 503, 504) +- **Ошибки GPU**: Если `nvidia-smi` недоступен или возвращает некорректные данные, сервер завершит работу с критической ошибкой +- **Критические ошибки**: Подробное логирование с завершением работы + +## Архитектура + +```text +┌──────────────────────────────────┐ +│ Session Server │ +│ │ +│ Регистрация (рекурсивно): │ +│ 1. Определение IP │ +│ 2. Определение hostname │ +│ 3. Запрос GPU (nvidia-smi) │ +│ 4. POST /servers/register │ +│ 5. setTimeout → повтор (30s) │ +│ │ +│ Обновление GPU (рекурсивно): │ +│ 1. Запрос GPU (nvidia-smi) │ +│ 2. PATCH /servers/:id/gpu │ +│ 3. setTimeout → повтор (1s) │ +└──────────┬───────────────────────┘ + │ + │ HTTP Requests + │ + ▼ +┌──────────────────────────────────┐ +│ Main API Server │ +│ │ +│ 1. Проверка по hostname │ +│ 2. Создание/обновление │ +│ 3. Обновление GPU памяти │ +│ 4. Возврат данных │ +└──────────────────────────────────┘ +``` + +## Разработка + +### Структура проекта + +```text +session-server/ +├── src/ +│ └── index.ts # Основной файл приложения +├── dist/ # Собранное приложение +├── package.json +├── tsconfig.json +├── bun.build.ts # Конфигурация сборки +└── README.md +``` + +### Требования + +- Bun >= 1.0 +- Node.js >= 18 (опционально) +- TypeScript >= 5.0 +- **NVIDIA GPU с установленным `nvidia-smi`** (обязательно) + +## Лицензия + +MIT diff --git a/session-server/bun.build.ts b/session-server/bun.build.ts new file mode 100644 index 0000000..c9c1cc1 --- /dev/null +++ b/session-server/bun.build.ts @@ -0,0 +1,32 @@ +import { $, Glob } from "bun"; +import path from "path"; +import { existsSync } from "fs"; + +// Cross-platform directory removal +if (process.platform === "win32") { + // await $`cmd /c "rmdir /s /q dist"`; +} else { + await $`rm -rf ./dist`; +} + +// await Bun.build({ +// entrypoints: ["./src/index.ts"], +// env: "inline", +// target: "bun", +// outdir: `./dist`, +// minify: true, +// }); + +// Build all files in src +for (const entrypoint of new Glob("./src/**/*.ts").scanSync()) { + const parts = entrypoint.split(path.sep); + const entrypointPath = path.join(...parts.slice(2, -1)); + + await Bun.build({ + entrypoints: [entrypoint], + target: "bun", + outdir: path.join("dist", entrypointPath), + env: "inline", + minify: true, + }); +} diff --git a/session-server/bun.lock b/session-server/bun.lock new file mode 100644 index 0000000..6de29f2 --- /dev/null +++ b/session-server/bun.lock @@ -0,0 +1,75 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "strem.graff.tech-session-server", + "dependencies": { + "got": "^14.4.9", + }, + "devDependencies": { + "bun-types": "latest", + }, + }, + }, + "packages": { + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.1.0", "", {}, "sha512-7F/yz2IphV39hiS2zB4QYVkivrptHHh0K8qJJd9HhuWSdvf8AN7NpebW3CcDZDBQsUPMoDKWsY2WWgW7bqOcfA=="], + + "@szmarczak/http-timer": ["@szmarczak/http-timer@5.0.1", "", { "dependencies": { "defer-to-connect": "^2.0.1" } }, "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw=="], + + "@types/http-cache-semantics": ["@types/http-cache-semantics@4.0.4", "", {}, "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA=="], + + "@types/node": ["@types/node@24.7.0", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw=="], + + "@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="], + + "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], + + "cacheable-lookup": ["cacheable-lookup@7.0.0", "", {}, "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w=="], + + "cacheable-request": ["cacheable-request@12.0.1", "", { "dependencies": { "@types/http-cache-semantics": "^4.0.4", "get-stream": "^9.0.1", "http-cache-semantics": "^4.1.1", "keyv": "^4.5.4", "mimic-response": "^4.0.0", "normalize-url": "^8.0.1", "responselike": "^3.0.0" } }, "sha512-Yo9wGIQUaAfIbk+qY0X4cDQgCosecfBe3V9NSyeY4qPC2SAkbCS4Xj79VP8WOzitpJUZKc/wsRCYF5ariDIwkg=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="], + + "form-data-encoder": ["form-data-encoder@4.1.0", "", {}, "sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw=="], + + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "got": ["got@14.4.9", "", { "dependencies": { "@sindresorhus/is": "^7.0.1", "@szmarczak/http-timer": "^5.0.1", "cacheable-lookup": "^7.0.0", "cacheable-request": "^12.0.1", "decompress-response": "^6.0.0", "form-data-encoder": "^4.0.2", "http2-wrapper": "^2.2.1", "lowercase-keys": "^3.0.0", "p-cancelable": "^4.0.1", "responselike": "^3.0.0", "type-fest": "^4.26.1" } }, "sha512-Dbu075Jwm3QwNCIoCenqkqY8l2gd7e/TanuhMbzZIEsb1mpAneImSusKhZ+XdqqC3S91SDV/1SdWpGXKAlm8tA=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "lowercase-keys": ["lowercase-keys@3.0.0", "", {}, "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ=="], + + "mimic-response": ["mimic-response@4.0.0", "", {}, "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg=="], + + "normalize-url": ["normalize-url@8.1.0", "", {}, "sha512-X06Mfd/5aKsRHc0O0J5CUedwnPmnDtLF2+nq+KN9KSDlJHkPuh0JUviWjEWMe0SW/9TDdSLVPuk7L5gGTIA1/w=="], + + "p-cancelable": ["p-cancelable@4.0.1", "", {}, "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg=="], + + "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + + "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], + + "responselike": ["responselike@3.0.0", "", { "dependencies": { "lowercase-keys": "^3.0.0" } }, "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + + "decompress-response/mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + } +} diff --git a/session-server/package.json b/session-server/package.json new file mode 100644 index 0000000..53f2b91 --- /dev/null +++ b/session-server/package.json @@ -0,0 +1,17 @@ +{ + "name": "strem.graff.tech-session-server", + "version": "1.0.50", + "module": "src/index.js", + "devDependencies": { + "bun-types": "latest" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "bun run --watch src/index.ts", + "build": "bun bun.build.ts", + "start": "bun ./dist/index.js" + }, + "dependencies": { + "got": "^14.4.9" + } +} diff --git a/session-server/src/index.ts b/session-server/src/index.ts new file mode 100644 index 0000000..866a833 --- /dev/null +++ b/session-server/src/index.ts @@ -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(); + +// Карта сессий в процессе запуска/остановки для предотвращения дублирования +const processingSessions = new Set(); + +// Карта последнего времени логирования запланированных сессий (для уменьшения спама в логах) +const lastScheduledLogTime = new Map(); + +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 { + // Валидация для 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(); + + // Сохраняем 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +}); diff --git a/session-server/tsconfig.json b/session-server/tsconfig.json new file mode 100644 index 0000000..36adf8e --- /dev/null +++ b/session-server/tsconfig.json @@ -0,0 +1,103 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ES2022", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}