ses-monitor/ses_monitor/scenario_runner.py
Maksim Totmin 7394b14b52 Initial commit: ses-monitor watchdog for SES
Универсальный watchdog-мониторинг Service Engine Server (SES):
- Liveness-проверки (API, лицензия, роботы)
- Диалоговые сценарии из YAML с агрегацией в 6 фиксированных Zabbix-метрик
- Доставка: zabbix_sender (active push) или UserParameter (пассивный опрос)
- Поддержка Python 3.7+, Zabbix 5.0
2026-06-19 20:26:26 +07:00

241 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Движок выполнения диалоговых сценариев из 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