Универсальный watchdog-мониторинг Service Engine Server (SES): - Liveness-проверки (API, лицензия, роботы) - Диалоговые сценарии из YAML с агрегацией в 6 фиксированных Zabbix-метрик - Доставка: zabbix_sender (active push) или UserParameter (пассивный опрос) - Поддержка Python 3.7+, Zabbix 5.0
329 lines
14 KiB
Python
329 lines
14 KiB
Python
"""
|
||
Главный цикл 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
|