""" Главный цикл watchdog-демона для мониторинга SES. Архитектура: - Бесконечный asyncio-цикл с тремя параллельными задачами: 1. liveness_task: быстрые проверки каждые 30 секунд. 2. scenarios_task: запуск сценариев по их индивидуальному расписанию. 3. archive_task: анализ архива каждые 10 минут. - Hot-reload сценариев из каталога каждые 60 секунд. - Все метрики отправляются в Zabbix единым батчем после проверок. - Graceful shutdown по SIGTERM/SIGINT. """ from __future__ import annotations import asyncio import logging import signal import sys import time from pathlib import Path from typing import Dict, List, Optional, Set from ses_monitor.config import ( AppConfig, ScenarioConfig, ZabbixMode, load_app_config, load_scenarios, ) from ses_monitor.utils import setup_logging from ses_monitor.session_client import SESClient from ses_monitor.checker import LivenessChecker from ses_monitor.scenario_runner import ScenarioRunner, ScenarioResult, aggregate_scenario_results from ses_monitor.log_client import LogChecker from ses_monitor.zabbix import ZabbixSender, UserParameterWriter log = logging.getLogger("ses_monitor") class SESMonitorApp: """Главный класс watchdog-приложения.""" def __init__(self, config: AppConfig): self._config = config # HTTP-клиент (один на всё приложение) self._client = SESClient(config.ses) # Компоненты — выбираем способ доставки в Zabbix по конфигу self._zabbix = self._create_zabbix_sender(config) self._liveness = LivenessChecker(config.ses) self._log_checker = LogChecker() # Сценарии загружаются динамически (hot-reload) self._scenarios: List[ScenarioConfig] = [] self._scenario_runner: Optional[ScenarioRunner] = None self._scenarios_dir = config.monitoring.scenarios_dir # Отслеживание времени последнего запуска сценариев self._last_scenario_run: Dict[str, float] = {} # Флаг graceful shutdown — создаётся в start() внутри цикла (Python 3.7 фикс) # Эталонный список роботов (заполняется при первом успешном запросе) self._known_robots: Set[str] = set() @staticmethod def _create_zabbix_sender(config: AppConfig): """Фабрика: создаёт ZabbixSender или UserParameterWriter по конфигу.""" if config.zabbix.mode == ZabbixMode.USERPARAMETER: log.info( "Режим доставки: UserParameter (файл: %s)", config.zabbix.metrics_file, ) return UserParameterWriter(config.zabbix) log.info( "Режим доставки: zabbix_sender (сервер: %s:%d, хост: %s)", config.zabbix.server, config.zabbix.port, config.zabbix.host, ) return ZabbixSender(config.zabbix) async def start(self) -> None: """Запускает watchdog-цикл.""" zbx_cfg = self._config.zabbix if zbx_cfg.enabled: if zbx_cfg.mode == ZabbixMode.USERPARAMETER: log.info( "SES Monitor v%s запускается (SES: %s, Zabbix UP: %s)", __import__("ses_monitor").__version__, self._config.ses.base_url, zbx_cfg.metrics_file, ) else: log.info( "SES Monitor v%s запускается (SES: %s, Zabbix: %s:%d, хост: %s)", __import__("ses_monitor").__version__, self._config.ses.base_url, zbx_cfg.server, zbx_cfg.port, zbx_cfg.host, ) else: log.info("SES Monitor v%s запускается (SES: %s, Zabbix отключён)", __import__("ses_monitor").__version__, self._config.ses.base_url) # Создаём Event УЖЕ внутри цикла (Python 3.7 фик: иначе self._loop # привязывается к умолчательному циклу, а не к текущему) self._shutdown_event = asyncio.Event() # Привязываем child watcher к текущему циклу (Python 3.7-3.11) loop = asyncio.get_running_loop() if sys.platform != "win32" and sys.version_info < (3, 12): watcher = asyncio.get_child_watcher() if hasattr(watcher, "attach_loop"): watcher.attach_loop(loop) # Регистрируем обработчики сигналов for sig in (signal.SIGTERM, signal.SIGINT): try: loop.add_signal_handler(sig, self._handle_shutdown) except NotImplementedError: # Сигналы не поддерживаются (например, Windows) pass try: # Основной цикл await self._main_loop() except asyncio.CancelledError: pass finally: await self._shutdown() def _handle_shutdown(self) -> None: """Обработчик SIGTERM/SIGINT.""" log.info("Получен сигнал завершения, останавливаемся...") self._shutdown_event.set() async def _shutdown(self) -> None: """Graceful shutdown: закрываем клиент, сбрасываем метрики.""" log.info("Завершение работы...") if self._zabbix.enabled: # Отправляем последний батч метрик если есть await self._zabbix.flush() await self._client.close() log.info("SES Monitor остановлен.") async def _load_scenarios(self) -> None: """Загружает или перезагружает сценарии (hot-reload).""" try: scenarios = load_scenarios(self._scenarios_dir) self._scenarios = scenarios if self._scenario_runner is None: self._scenario_runner = ScenarioRunner( self._config.ses, scenarios ) else: self._scenario_runner.update_scenarios(scenarios) log.debug("Загружено %d сценариев", len(scenarios)) except Exception as e: log.error("Ошибка загрузки сценариев: %s", e) async def _main_loop(self) -> None: """Основной цикл: liveness, сценарии, архив.""" # Первичная загрузка сценариев await self._load_scenarios() # Временные метки для планирования last_hot_reload = time.monotonic() while not self._shutdown_event.is_set(): now = time.monotonic() # ---------- 1. Liveness-проверка ---------- await self._run_liveness() # ---------- 2. Сценарии по расписанию ---------- await self._run_due_scenarios(now) # ---------- 3. Hot-reload сценариев ---------- if now - last_hot_reload >= 60.0: await self._load_scenarios() last_hot_reload = now # ---------- 4. Логи (если включено) ---------- if self._config.monitoring.log_enabled: # Логи проверяем реже — раз в 30 минут if int(now) % 1800 < self._config.monitoring.liveness_interval: await self._run_log_check() # ---------- 6. Отправка метрик в Zabbix ---------- await self._zabbix.flush() # Ждём или до следующего цикла liveness, или до shutdown sleep_time = self._config.monitoring.liveness_interval try: await asyncio.wait_for( self._shutdown_event.wait(), timeout=sleep_time ) except asyncio.TimeoutError: pass # Нормально: просто прошёл интервал async def _run_liveness(self) -> None: """Выполняет быстрые liveness-проверки.""" try: result = await self._liveness.check(self._client) if self._zabbix.enabled: self._zabbix.add("ses.api.liveness", int(result.api_ok)) self._zabbix.add("ses.api.response_time", round(result.api_response_time_ms)) self._zabbix.add("ses.license.ok", int(result.license_ok)) self._zabbix.add("ses.license.days_remaining", result.license_days_remaining) if result.robots_count > 0: self._zabbix.add("ses.robots.count", result.robots_count) if result.robots_count > 0 and not self._known_robots: log.info( "Liveness: %d сценариев, API=%s, license=%s, days_left=%d, время=%0.0fms", result.robots_count, "OK" if result.api_ok else "FAIL", "OK" if result.license_ok else "FAIL", result.license_days_remaining, result.api_response_time_ms, ) if not result.api_ok: log.error("Liveness FAIL: %s", result.api_error) if not result.license_ok: log.error("License FAIL: %s", result.license_error) except Exception as e: log.exception("Liveness: неожиданная ошибка: %s", e) async def _run_due_scenarios(self, now: float) -> None: """Запускает сценарии, у которых подошло время. Все результаты собираются и агрегируются в фиксированный набор Zabbix-метрик (ses.scenario.ok, .total, .failed, .avg_time, .max_time, .error). Это делает шаблон Zabbix универсальным — не нужно добавлять отдельный item под каждый сценарий. """ if not self._scenario_runner: return due_results: List[ScenarioResult] = [] for scenario in self._scenarios: name = scenario.name or "unknown" last_run = self._last_scenario_run.get(name, 0) if now - last_run >= scenario.interval: try: result = await self._scenario_runner.run_one( self._client, scenario ) due_results.append(result) self._last_scenario_run[name] = now if not result.passed: log.warning( "Сценарий '%s': ПРОВАЛЕН за %dms (ошибок: %d шагов)", name, round(result.total_time_ms), len(result.failed_steps), ) except Exception as e: log.exception( "Сценарий '%s': неожиданное исключение: %s", name, e ) # Агрегируем результаты и отправляем фиксированные ключи if due_results and self._zabbix.enabled: agg = aggregate_scenario_results(due_results) self._zabbix.add("ses.scenario.ok", agg["ok"]) self._zabbix.add("ses.scenario.total", agg["total"]) self._zabbix.add("ses.scenario.failed", agg["failed"]) self._zabbix.add("ses.scenario.avg_time", agg["avg_time"]) self._zabbix.add("ses.scenario.max_time", agg["max_time"]) if agg["error"]: self._zabbix.add("ses.scenario.error", agg["error"]) async def _run_log_check(self) -> None: """Выполняет проверку логов (если включено).""" try: result = await self._log_checker.check(self._client) if self._zabbix.enabled: for key, count in result.error_patterns.items(): self._zabbix.add(f"ses.log.{key}", count) except Exception as e: log.exception("Log check: неожиданная ошибка: %s", e) def main() -> int: """Точка входа в приложение. Returns: 0 при успешном завершении, 1 при ошибке. """ try: # Загружаем конфигурацию config = load_app_config() except FileNotFoundError as e: print(f"ОШИБКА: {e}", file=sys.stderr) return 1 except Exception as e: print(f"ОШИБКА загрузки конфигурации: {e}", file=sys.stderr) return 1 # Настраиваем логирование logger = setup_logging(config.logging) try: # Python 3.7: инициализируем child watcher ДО создания цикла if sys.platform != "win32": asyncio.get_child_watcher() app = SESMonitorApp(config) asyncio.run(app.start()) return 0 except KeyboardInterrupt: logger.info("Остановлено пользователем (Ctrl+C)") return 0 except Exception as e: logger.exception("Критическая ошибка: %s", e) return 1