361 lines
15 KiB
Python
361 lines
15 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
CI Server Script - Запускает серверы для тестирования в неблокирующем режиме
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
import signal
|
|||
|
|
import subprocess
|
|||
|
|
import threading
|
|||
|
|
import logging
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Optional, Dict, Any
|
|||
|
|
|
|||
|
|
# Добавляем корневую папку в путь
|
|||
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|||
|
|
|
|||
|
|
# Создаем собственный логгер без дублирования
|
|||
|
|
def create_ci_logger():
|
|||
|
|
"""Создает логгер для CI без дублирования"""
|
|||
|
|
logger = logging.getLogger("ci-server")
|
|||
|
|
logger.setLevel(logging.INFO)
|
|||
|
|
|
|||
|
|
# Убираем существующие обработчики
|
|||
|
|
logger.handlers.clear()
|
|||
|
|
|
|||
|
|
# Создаем форматтер
|
|||
|
|
formatter = logging.Formatter(
|
|||
|
|
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Создаем обработчик
|
|||
|
|
handler = logging.StreamHandler()
|
|||
|
|
handler.setFormatter(formatter)
|
|||
|
|
logger.addHandler(handler)
|
|||
|
|
|
|||
|
|
# Отключаем пропагацию к root logger
|
|||
|
|
logger.propagate = False
|
|||
|
|
|
|||
|
|
return logger
|
|||
|
|
|
|||
|
|
logger = create_ci_logger()
|
|||
|
|
|
|||
|
|
|
|||
|
|
class CIServerManager:
|
|||
|
|
"""Менеджер CI серверов"""
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
self.backend_process: Optional[subprocess.Popen] = None
|
|||
|
|
self.frontend_process: Optional[subprocess.Popen] = None
|
|||
|
|
self.backend_pid_file = Path("backend.pid")
|
|||
|
|
self.frontend_pid_file = Path("frontend.pid")
|
|||
|
|
|
|||
|
|
# Настройки по умолчанию
|
|||
|
|
self.backend_host = os.getenv("BACKEND_HOST", "0.0.0.0")
|
|||
|
|
self.backend_port = int(os.getenv("BACKEND_PORT", "8000"))
|
|||
|
|
self.frontend_port = int(os.getenv("FRONTEND_PORT", "3000"))
|
|||
|
|
|
|||
|
|
# Флаги состояния
|
|||
|
|
self.backend_ready = False
|
|||
|
|
self.frontend_ready = False
|
|||
|
|
|
|||
|
|
# Обработчики сигналов для корректного завершения
|
|||
|
|
signal.signal(signal.SIGINT, self._signal_handler)
|
|||
|
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
|||
|
|
|
|||
|
|
def _signal_handler(self, signum: int, frame: Any) -> None:
|
|||
|
|
"""Обработчик сигналов для корректного завершения"""
|
|||
|
|
logger.info(f"Получен сигнал {signum}, завершаем работу...")
|
|||
|
|
self.cleanup()
|
|||
|
|
sys.exit(0)
|
|||
|
|
|
|||
|
|
def start_backend_server(self) -> bool:
|
|||
|
|
"""Запускает backend сервер"""
|
|||
|
|
try:
|
|||
|
|
logger.info(f"🚀 Запускаем backend сервер на {self.backend_host}:{self.backend_port}")
|
|||
|
|
|
|||
|
|
# Запускаем сервер в фоне
|
|||
|
|
self.backend_process = subprocess.Popen(
|
|||
|
|
[
|
|||
|
|
sys.executable, "dev.py",
|
|||
|
|
"--host", self.backend_host,
|
|||
|
|
"--port", str(self.backend_port)
|
|||
|
|
],
|
|||
|
|
stdout=subprocess.PIPE,
|
|||
|
|
stderr=subprocess.PIPE,
|
|||
|
|
text=True,
|
|||
|
|
bufsize=1,
|
|||
|
|
universal_newlines=True
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Сохраняем PID
|
|||
|
|
self.backend_pid_file.write_text(str(self.backend_process.pid))
|
|||
|
|
logger.info(f"✅ Backend сервер запущен с PID: {self.backend_process.pid}")
|
|||
|
|
|
|||
|
|
# Запускаем мониторинг в отдельном потоке
|
|||
|
|
threading.Thread(
|
|||
|
|
target=self._monitor_backend,
|
|||
|
|
daemon=True
|
|||
|
|
).start()
|
|||
|
|
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка запуска backend сервера: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def start_frontend_server(self) -> bool:
|
|||
|
|
"""Запускает frontend сервер"""
|
|||
|
|
try:
|
|||
|
|
logger.info(f"🚀 Запускаем frontend сервер на порту {self.frontend_port}")
|
|||
|
|
|
|||
|
|
# Переходим в папку panel
|
|||
|
|
panel_dir = Path("panel")
|
|||
|
|
if not panel_dir.exists():
|
|||
|
|
logger.error("❌ Папка panel не найдена")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# Запускаем npm run dev в фоне
|
|||
|
|
self.frontend_process = subprocess.Popen(
|
|||
|
|
["npm", "run", "dev"],
|
|||
|
|
cwd=panel_dir,
|
|||
|
|
stdout=subprocess.PIPE,
|
|||
|
|
stderr=subprocess.PIPE,
|
|||
|
|
text=True,
|
|||
|
|
bufsize=1,
|
|||
|
|
universal_newlines=True
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Сохраняем PID
|
|||
|
|
self.frontend_pid_file.write_text(str(self.frontend_process.pid))
|
|||
|
|
logger.info(f"✅ Frontend сервер запущен с PID: {self.frontend_process.pid}")
|
|||
|
|
|
|||
|
|
# Запускаем мониторинг в отдельном потоке
|
|||
|
|
threading.Thread(
|
|||
|
|
target=self._monitor_frontend,
|
|||
|
|
daemon=True
|
|||
|
|
).start()
|
|||
|
|
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка запуска frontend сервера: {e}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def _monitor_backend(self) -> None:
|
|||
|
|
"""Мониторит backend сервер"""
|
|||
|
|
try:
|
|||
|
|
while self.backend_process and self.backend_process.poll() is None:
|
|||
|
|
time.sleep(1)
|
|||
|
|
|
|||
|
|
# Проверяем доступность сервера
|
|||
|
|
if not self.backend_ready:
|
|||
|
|
try:
|
|||
|
|
import requests
|
|||
|
|
response = requests.get(
|
|||
|
|
f"http://{self.backend_host}:{self.backend_port}/",
|
|||
|
|
timeout=5
|
|||
|
|
)
|
|||
|
|
if response.status_code == 200:
|
|||
|
|
self.backend_ready = True
|
|||
|
|
logger.info("✅ Backend сервер готов к работе!")
|
|||
|
|
else:
|
|||
|
|
logger.debug(f"Backend отвечает с кодом: {response.status_code}")
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.debug(f"Backend еще не готов: {e}")
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка мониторинга backend: {e}")
|
|||
|
|
|
|||
|
|
def _monitor_frontend(self) -> None:
|
|||
|
|
"""Мониторит frontend сервер"""
|
|||
|
|
try:
|
|||
|
|
while self.frontend_process and self.frontend_process.poll() is None:
|
|||
|
|
time.sleep(1)
|
|||
|
|
|
|||
|
|
# Проверяем доступность сервера
|
|||
|
|
if not self.frontend_ready:
|
|||
|
|
try:
|
|||
|
|
import requests
|
|||
|
|
response = requests.get(
|
|||
|
|
f"http://localhost:{self.frontend_port}/",
|
|||
|
|
timeout=5
|
|||
|
|
)
|
|||
|
|
if response.status_code == 200:
|
|||
|
|
self.frontend_ready = True
|
|||
|
|
logger.info("✅ Frontend сервер готов к работе!")
|
|||
|
|
else:
|
|||
|
|
logger.debug(f"Frontend отвечает с кодом: {response.status_code}")
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.debug(f"Frontend еще не готов: {e}")
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка мониторинга frontend: {e}")
|
|||
|
|
|
|||
|
|
def wait_for_servers(self, timeout: int = 120) -> bool:
|
|||
|
|
"""Ждет пока серверы будут готовы"""
|
|||
|
|
logger.info(f"⏳ Ждем готовности серверов (таймаут: {timeout}с)...")
|
|||
|
|
|
|||
|
|
start_time = time.time()
|
|||
|
|
while time.time() - start_time < timeout:
|
|||
|
|
logger.debug(f"Backend готов: {self.backend_ready}, Frontend готов: {self.frontend_ready}")
|
|||
|
|
|
|||
|
|
if self.backend_ready and self.frontend_ready:
|
|||
|
|
logger.info("🎉 Все серверы готовы к работе!")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
time.sleep(2)
|
|||
|
|
|
|||
|
|
logger.error("⏰ Таймаут ожидания готовности серверов")
|
|||
|
|
logger.error(f"Backend готов: {self.backend_ready}, Frontend готов: {self.frontend_ready}")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def cleanup(self) -> None:
|
|||
|
|
"""Очищает ресурсы и завершает процессы"""
|
|||
|
|
logger.info("🧹 Очищаем ресурсы...")
|
|||
|
|
|
|||
|
|
# Завершаем процессы
|
|||
|
|
if self.backend_process:
|
|||
|
|
try:
|
|||
|
|
self.backend_process.terminate()
|
|||
|
|
self.backend_process.wait(timeout=10)
|
|||
|
|
except subprocess.TimeoutExpired:
|
|||
|
|
self.backend_process.kill()
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Ошибка завершения backend: {e}")
|
|||
|
|
|
|||
|
|
if self.frontend_process:
|
|||
|
|
try:
|
|||
|
|
self.frontend_process.terminate()
|
|||
|
|
self.frontend_process.wait(timeout=10)
|
|||
|
|
except subprocess.TimeoutExpired:
|
|||
|
|
self.frontend_process.kill()
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Ошибка завершения frontend: {e}")
|
|||
|
|
|
|||
|
|
# Удаляем PID файлы
|
|||
|
|
for pid_file in [self.backend_pid_file, self.frontend_pid_file]:
|
|||
|
|
if pid_file.exists():
|
|||
|
|
try:
|
|||
|
|
pid_file.unlink()
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Ошибка удаления {pid_file}: {e}")
|
|||
|
|
|
|||
|
|
# Убиваем все связанные процессы
|
|||
|
|
try:
|
|||
|
|
subprocess.run(["pkill", "-f", "python dev.py"], check=False)
|
|||
|
|
subprocess.run(["pkill", "-f", "npm run dev"], check=False)
|
|||
|
|
subprocess.run(["pkill", "-f", "vite"], check=False)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Ошибка принудительного завершения: {e}")
|
|||
|
|
|
|||
|
|
logger.info("✅ Очистка завершена")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
"""Основная функция"""
|
|||
|
|
logger.info("🚀 Запуск CI Server Manager")
|
|||
|
|
|
|||
|
|
# Создаем менеджер
|
|||
|
|
manager = CIServerManager()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Запускаем серверы
|
|||
|
|
if not manager.start_backend_server():
|
|||
|
|
logger.error("❌ Не удалось запустить backend сервер")
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
if not manager.start_frontend_server():
|
|||
|
|
logger.error("❌ Не удалось запустить frontend сервер")
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
# Ждем готовности
|
|||
|
|
if not manager.wait_for_servers():
|
|||
|
|
logger.error("❌ Серверы не готовы в течение таймаута")
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
logger.info("🎯 Серверы запущены и готовы к тестированию")
|
|||
|
|
|
|||
|
|
# В CI режиме запускаем тесты автоматически
|
|||
|
|
ci_mode = os.getenv("CI_MODE", "false").lower()
|
|||
|
|
logger.info(f"🔧 Проверяем CI режим: CI_MODE={ci_mode}")
|
|||
|
|
|
|||
|
|
if ci_mode in ["true", "1", "yes"]:
|
|||
|
|
logger.info("🔧 CI режим: запускаем тесты автоматически...")
|
|||
|
|
return run_tests_in_ci()
|
|||
|
|
else:
|
|||
|
|
logger.info("💡 Локальный режим: для запуска тестов нажмите Ctrl+C")
|
|||
|
|
|
|||
|
|
# Держим скрипт запущенным
|
|||
|
|
try:
|
|||
|
|
while True:
|
|||
|
|
time.sleep(1)
|
|||
|
|
# Проверяем что процессы еще живы
|
|||
|
|
if (manager.backend_process and manager.backend_process.poll() is not None):
|
|||
|
|
logger.error("❌ Backend сервер завершился неожиданно")
|
|||
|
|
break
|
|||
|
|
if (manager.frontend_process and manager.frontend_process.poll() is not None):
|
|||
|
|
logger.error("❌ Frontend сервер завершился неожиданно")
|
|||
|
|
break
|
|||
|
|
except KeyboardInterrupt:
|
|||
|
|
logger.info("👋 Получен сигнал прерывания")
|
|||
|
|
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Критическая ошибка: {e}")
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
finally:
|
|||
|
|
manager.cleanup()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_tests_in_ci() -> int:
|
|||
|
|
"""Запускает тесты в CI режиме"""
|
|||
|
|
try:
|
|||
|
|
logger.info("🧪 Запускаем unit тесты...")
|
|||
|
|
result = subprocess.run([
|
|||
|
|
"uv", "run", "pytest", "tests/", "-m", "not e2e", "-v", "--tb=short"
|
|||
|
|
], capture_output=False, text=True) # Убираем capture_output=False
|
|||
|
|
|
|||
|
|
if result.returncode != 0:
|
|||
|
|
logger.error(f"❌ Unit тесты провалились с кодом: {result.returncode}")
|
|||
|
|
return result.returncode
|
|||
|
|
|
|||
|
|
logger.info("✅ Unit тесты прошли успешно!")
|
|||
|
|
|
|||
|
|
logger.info("🧪 Запускаем integration тесты...")
|
|||
|
|
result = subprocess.run([
|
|||
|
|
"uv", "run", "pytest", "tests/", "-m", "integration", "-v", "--tb=short"
|
|||
|
|
], capture_output=False, text=True) # Убираем capture_output=False
|
|||
|
|
|
|||
|
|
if result.returncode != 0:
|
|||
|
|
logger.error(f"❌ Integration тесты провалились с кодом: {result.returncode}")
|
|||
|
|
return result.returncode
|
|||
|
|
|
|||
|
|
logger.info("✅ Integration тесты прошли успешно!")
|
|||
|
|
|
|||
|
|
logger.info("🧪 Запускаем E2E тесты...")
|
|||
|
|
result = subprocess.run([
|
|||
|
|
"uv", "run", "pytest", "tests/", "-m", "e2e", "-v", "--tb=short", "--timeout=300"
|
|||
|
|
], capture_output=False, text=True) # Убираем capture_output=False
|
|||
|
|
|
|||
|
|
if result.returncode != 0:
|
|||
|
|
logger.error(f"❌ E2E тесты провалились с кодом: {result.returncode}")
|
|||
|
|
return result.returncode
|
|||
|
|
|
|||
|
|
logger.info("✅ E2E тесты прошли успешно!")
|
|||
|
|
|
|||
|
|
logger.info("🎉 Все тесты прошли успешно!")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Ошибка при запуске тестов: {e}")
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main())
|