Универсальный watchdog-мониторинг Service Engine Server (SES): - Liveness-проверки (API, лицензия, роботы) - Диалоговые сценарии из YAML с агрегацией в 6 фиксированных Zabbix-метрик - Доставка: zabbix_sender (active push) или UserParameter (пассивный опрос) - Поддержка Python 3.7+, Zabbix 5.0
241 lines
8.8 KiB
Python
241 lines
8.8 KiB
Python
"""
|
||
Движок выполнения диалоговых сценариев из YAML-файлов.
|
||
|
||
Каждый сценарий — это последовательность шагов:
|
||
1. Отправить сообщение роботу (POST /ses/ask)
|
||
2. Проверить ответ по условиям (expect)
|
||
3. Перейти к следующему шагу
|
||
|
||
Сценарии загружаются из config/scenarios/ во время старта и автоматически
|
||
перечитываются каждые 60 секунд (hot-reload).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import time
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from ses_monitor.config import ScenarioConfig, ScenarioStep, SESConfig
|
||
from ses_monitor.session_client import SESClient, AskResponse
|
||
from ses_monitor.utils import check_all_expectations, timer
|
||
|
||
log = logging.getLogger("ses_monitor.scenario")
|
||
|
||
|
||
@dataclass
|
||
class StepResult:
|
||
"""Результат выполнения одного шага сценария."""
|
||
|
||
step_index: int
|
||
text_sent: str
|
||
response: Optional[AskResponse] = None
|
||
passed: bool = True
|
||
errors: List[str] = field(default_factory=list)
|
||
response_time_ms: float = 0.0
|
||
|
||
|
||
@dataclass
|
||
class ScenarioResult:
|
||
"""Результат выполнения полного сценария."""
|
||
|
||
scenario_name: str
|
||
passed: bool = True
|
||
total_time_ms: float = 0.0
|
||
step_results: List[StepResult] = field(default_factory=list)
|
||
error_message: str = ""
|
||
|
||
@property
|
||
def failed_steps(self) -> List[StepResult]:
|
||
"""Шаги, которые не прошли проверку."""
|
||
return [s for s in self.step_results if not s.passed]
|
||
|
||
|
||
def aggregate_scenario_results(
|
||
results: List[ScenarioResult],
|
||
) -> Dict[str, Any]:
|
||
"""Агрегирует результаты всех сценариев в фиксированный набор Zabbix-метрик.
|
||
|
||
Возвращает словарь с ключами:
|
||
ok (int): 1 если все прошли, 0 если есть упавшие.
|
||
total (int): общее количество выполненных сценариев.
|
||
failed (int): количество упавших сценариев.
|
||
avg_time (int): среднее время выполнения (мс), округлённое.
|
||
max_time (int): максимальное время выполнения (мс), округлённое.
|
||
error (str): текст ошибки ("N сценариев упали") или пустая строка.
|
||
"""
|
||
if not results:
|
||
return {
|
||
"ok": 1,
|
||
"total": 0,
|
||
"failed": 0,
|
||
"avg_time": 0,
|
||
"max_time": 0,
|
||
"error": "",
|
||
}
|
||
|
||
failed = sum(1 for r in results if not r.passed)
|
||
total = len(results)
|
||
times = [r.total_time_ms for r in results]
|
||
|
||
return {
|
||
"ok": 0 if failed else 1,
|
||
"total": total,
|
||
"failed": failed,
|
||
"avg_time": round(sum(times) / total),
|
||
"max_time": round(max(times)),
|
||
"error": f"{failed} сценариев упали" if failed else "",
|
||
}
|
||
|
||
|
||
class ScenarioRunner:
|
||
"""Выполняет диалоговые сценарии мониторинга.
|
||
|
||
Поддерживает:
|
||
- Переиспользование HTTP-клиента между проверками.
|
||
- Hot-reload сценариев из каталога.
|
||
- Автоматическое создание и отслеживание сессий.
|
||
"""
|
||
|
||
def __init__(self, ses_config: SESConfig, scenarios: List[ScenarioConfig]):
|
||
self._ses_config = ses_config
|
||
self._scenarios = scenarios
|
||
|
||
def update_scenarios(self, scenarios: List[ScenarioConfig]) -> None:
|
||
"""Обновляет список сценариев (hot-reload)."""
|
||
self._scenarios = scenarios
|
||
|
||
async def run_all(
|
||
self, client: SESClient
|
||
) -> List[ScenarioResult]:
|
||
"""Запускает все сценарии последовательно.
|
||
|
||
Args:
|
||
client: HTTP-клиент для SES API.
|
||
|
||
Returns:
|
||
Список результатов выполнения сценариев.
|
||
"""
|
||
results: List[ScenarioResult] = []
|
||
for scenario in self._scenarios:
|
||
result = await self.run_one(client, scenario)
|
||
results.append(result)
|
||
return results
|
||
|
||
async def run_one(
|
||
self, client: SESClient, scenario: ScenarioConfig
|
||
) -> ScenarioResult:
|
||
"""Выполняет один сценарий.
|
||
|
||
Args:
|
||
client: HTTP-клиент для SES API.
|
||
scenario: Конфигурация сценария.
|
||
|
||
Returns:
|
||
Результат выполнения.
|
||
"""
|
||
result = ScenarioResult(
|
||
scenario_name=scenario.name,
|
||
)
|
||
|
||
# Уникальный ID мониторинговой сессии
|
||
session_id = f"monitoring_{scenario.name.replace(' ', '_')}_{int(time.time())}"
|
||
log.info("Запуск сценария '%s' (сессия %s)", scenario.name, session_id)
|
||
|
||
with timer() as total_time:
|
||
try:
|
||
for i, step in enumerate(scenario.steps):
|
||
step_result = await self._execute_step(
|
||
client, scenario, session_id, i, step
|
||
)
|
||
result.step_results.append(step_result)
|
||
|
||
if not step_result.passed:
|
||
result.passed = False
|
||
log.warning(
|
||
"Сценарий '%s': шаг %d НЕ ПРОЙДЕН: %s",
|
||
scenario.name, i + 1, step_result.errors,
|
||
)
|
||
break # Прерываем сценарий при первой ошибке
|
||
|
||
if result.passed and result.step_results:
|
||
log.info(
|
||
"Сценарий '%s': УСПЕХ (%d шагов)",
|
||
scenario.name, len(result.step_results),
|
||
)
|
||
|
||
except Exception as e:
|
||
result.passed = False
|
||
result.error_message = f"Исключение: {e}"
|
||
log.exception("Сценарий '%s': исключение", scenario.name)
|
||
|
||
result.total_time_ms = total_time.get("elapsed", 0) * 1000
|
||
return result
|
||
|
||
async def _execute_step(
|
||
self,
|
||
client: SESClient,
|
||
scenario: ScenarioConfig,
|
||
session_id: str,
|
||
step_index: int,
|
||
step: ScenarioStep,
|
||
) -> StepResult:
|
||
"""Выполняет один шаг сценария: отправка → проверка ответа.
|
||
|
||
Args:
|
||
client: HTTP-клиент.
|
||
scenario: Конфигурация сценария.
|
||
session_id: ID текущей сессии.
|
||
step_index: Номер шага (0-based).
|
||
step: Определение шага.
|
||
|
||
Returns:
|
||
Результат шага.
|
||
"""
|
||
result = StepResult(
|
||
step_index=step_index,
|
||
text_sent=step.send,
|
||
)
|
||
|
||
try:
|
||
# Отправляем сообщение роботу
|
||
with timer() as step_time:
|
||
response = await client.ask(
|
||
text=step.send,
|
||
session_id=session_id,
|
||
robot_id=scenario.robot_id,
|
||
channel=scenario.channel,
|
||
restart_closed=scenario.restart_closed,
|
||
)
|
||
result.response = response
|
||
result.response_time_ms = step_time.get("elapsed", 0) * 1000
|
||
|
||
# Проверяем базовую ошибку API
|
||
if response.error != 0:
|
||
result.passed = False
|
||
result.errors.append(
|
||
f"SES API error={response.error} на сообщение '{step.send[:80]}'"
|
||
)
|
||
return result
|
||
|
||
# Проверяем ожидания
|
||
if step.expect:
|
||
passed, errors = check_all_expectations(response.raw, step.expect)
|
||
result.passed = passed
|
||
result.errors = errors
|
||
|
||
if not result.passed and not step.expect:
|
||
# Если ожиданий не было — считаем что ответ error=0 это успех
|
||
result.passed = True
|
||
|
||
except Exception as e:
|
||
result.passed = False
|
||
result.errors.append(f"HTTP ошибка: {e}")
|
||
log.exception(
|
||
"Сценарий '%s', шаг %d: исключение",
|
||
scenario.name, step_index + 1,
|
||
)
|
||
|
||
return result
|