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

340 lines
13 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-конфигурации монитора.
Основной конфигурационный файл: config/config.yaml
Сценарии загружаются из config/scenarios/*.yaml
"""
from __future__ import annotations
import enum
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional
import yaml
log = logging.getLogger("ses_monitor.config")
class ZabbixMode(enum.Enum):
"""Режим доставки метрик в Zabbix."""
SENDER = "sender" # zabbix_sender — активный push
USERPARAMETER = "userparameter" # UserParameter — пассивный опрос агентом
@classmethod
def from_str(cls, value: str) -> ZabbixMode:
"""Создаёт режим из строки (регистронезависимо)."""
for mode in cls:
if mode.value.lower() == value.strip().lower():
return mode
raise ValueError(f"Неизвестный режим Zabbix: '{value}'. Допустимые: {[m.value for m in cls]}")
@dataclass
class SESConfig:
"""Настройки подключения к SES серверу."""
base_url: str = "http://10.101.60.31:6190"
robot_id: str = "" # ID робота задаётся в config.yaml под конкретный проект
channel: str = "monitoring"
request_timeout: int = 30
robot_list_endpoint: str = "/ses/robot/list" # или "/ses/robot/listall"
@dataclass
class ZabbixConfig:
"""Настройки отправки метрик в Zabbix."""
enabled: bool = True
mode: ZabbixMode = ZabbixMode.SENDER # "sender" или "userparameter"
# ---- Общие поля для обоих режимов ----
host: str = "" # Задаётся в config.yaml: zabbix.host (техническое имя узла сети)
# ---- Поля режима sender ----
server: str = "127.0.0.1"
port: int = 10051
sender_path: str = "/usr/bin/zabbix_sender"
# ---- Поля режима userparameter ----
metrics_file: str = "/var/tmp/ses_monitor/metrics.json"
agentd_config_dir: str = "/opt/ses-monitor/config"
@dataclass
class MonitoringConfig:
"""Общие настройки мониторинга."""
liveness_interval: int = 30 # Интервал liveness-проверок, секунды
archive_check_interval: int = 600 # Интервал проверки архива, секунды
log_enabled: bool = False # Пока отключено — POST /ses/log тяжёлый
scenarios_dir: str = "config/scenarios"
scenario_default_timeout: int = 30
step_time_monitoring: bool = True # Мониторинг времени ответа из архива
@dataclass
class LoggingConfig:
"""Настройки логирования."""
level: str = "INFO"
file: Optional[str] = None
format: str = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
@dataclass
class ModelsMonitoringConfig:
"""Настройки мониторинга внутренних моделей SES (SEE, SMC, TTS, QAS, SPR)."""
enabled: bool = True
# Порог уверенности SEE ниже которого считаем проблемой (0..1)
see_confidence_threshold: float = 0.5
# Порог уверенности SMC ниже которого считаем проблемой (0..1)
smc_confidence_threshold: float = 0.5
# Отслеживать ли голосовые ответы TTS
tts_enabled: bool = True
# Отслеживать ли наполненность QAS
qas_enabled: bool = True
# Отслеживать ли наполненность SPR (распознавание речи: аудио→текст)
spr_enabled: bool = True
@dataclass
class AppConfig:
"""Корневая конфигурация приложения."""
ses: SESConfig = field(default_factory=SESConfig)
zabbix: ZabbixConfig = field(default_factory=ZabbixConfig)
monitoring: MonitoringConfig = field(default_factory=MonitoringConfig)
models: ModelsMonitoringConfig = field(default_factory=ModelsMonitoringConfig)
logging: LoggingConfig = field(default_factory=LoggingConfig)
@dataclass
class ScenarioStep:
"""Один шаг диалогового сценария."""
send: str # Текст сообщения, которое отправляем роботу
expect: List[Dict[str, Any]] = field(default_factory=list)
# Каждое ожидание — dict с ключами:
# path: str (путь в JSON-ответе, например "answer[0].messages[0]")
# contains/equals/not_contains/not_empty/regex: условие
@dataclass
class ArchiveCheckConfig:
"""Настройки проверки архива для сценария."""
enabled: bool = False
lookback_minutes: int = 10
expect: List[Dict[str, Any]] = field(default_factory=list)
@dataclass
class ScenarioConfig:
"""Конфигурация одного сценария мониторинга."""
name: str = ""
description: str = ""
enabled: bool = True
interval: int = 300 # Интервал запуска, секунды
timeout: int = 30
robot_id: Optional[str] = None # Если None — берётся из config.yaml
channel: Optional[str] = None
restart_closed: bool = False
steps: List[ScenarioStep] = field(default_factory=list)
archive_check: Optional[ArchiveCheckConfig] = None
def _dict_to_scenario_step(data: Dict[str, Any]) -> ScenarioStep:
"""Преобразует словарь из YAML в ScenarioStep."""
return ScenarioStep(
send=data["send"],
expect=data.get("expect", []),
)
def _dict_to_scenario_config(data: Dict[str, Any]) -> ScenarioConfig:
"""Преобразует словарь из YAML в ScenarioConfig."""
steps = [_dict_to_scenario_step(s) for s in data.get("steps", [])]
archive_data = data.get("archive_check")
archive_check = None
if archive_data:
archive_check = ArchiveCheckConfig(
enabled=archive_data.get("enabled", False),
lookback_minutes=archive_data.get("lookback_minutes", 10),
expect=archive_data.get("expect", []),
)
return ScenarioConfig(
name=data.get("name", ""),
description=data.get("description", ""),
enabled=data.get("enabled", True),
interval=data.get("interval", 300),
timeout=data.get("timeout", 30),
robot_id=data.get("robot_id"),
channel=data.get("channel"),
restart_closed=data.get("restart_closed", False),
steps=steps,
archive_check=archive_check,
)
def _build_config(data: Dict[str, Any]) -> AppConfig:
"""Собирает AppConfig из распарсенного YAML-словаря."""
ses_data = data.get("ses", {})
ses = SESConfig(
base_url=ses_data.get("base_url", SESConfig.base_url),
robot_id=ses_data.get("robot_id", SESConfig.robot_id),
channel=ses_data.get("channel", SESConfig.channel),
request_timeout=ses_data.get("request_timeout", SESConfig.request_timeout),
robot_list_endpoint=ses_data.get("robot_list_endpoint", SESConfig.robot_list_endpoint),
)
zbx_data = data.get("zabbix", {})
mode_raw = zbx_data.get("mode", "sender")
try:
mode = ZabbixMode.from_str(mode_raw)
except ValueError:
mode = ZabbixMode.SENDER
log.warning("Неизвестный режим Zabbix '%s', используется 'sender'.", mode_raw)
zabbix = ZabbixConfig(
enabled=zbx_data.get("enabled", ZabbixConfig.enabled),
mode=mode,
host=zbx_data.get("host", ZabbixConfig.host),
server=zbx_data.get("server", ZabbixConfig.server),
port=zbx_data.get("port", ZabbixConfig.port),
sender_path=zbx_data.get("sender_path", ZabbixConfig.sender_path),
metrics_file=zbx_data.get("metrics_file", ZabbixConfig.metrics_file),
agentd_config_dir=zbx_data.get("agentd_config_dir", ZabbixConfig.agentd_config_dir),
)
mon_data = data.get("monitoring", {})
monitoring = MonitoringConfig(
liveness_interval=mon_data.get("liveness_interval", MonitoringConfig.liveness_interval),
archive_check_interval=mon_data.get("archive_check_interval", MonitoringConfig.archive_check_interval),
log_enabled=mon_data.get("log_enabled", MonitoringConfig.log_enabled),
scenarios_dir=mon_data.get("scenarios_dir", MonitoringConfig.scenarios_dir),
scenario_default_timeout=mon_data.get("scenario_default_timeout", MonitoringConfig.scenario_default_timeout),
step_time_monitoring=mon_data.get("step_time_monitoring", MonitoringConfig.step_time_monitoring),
)
models_data = data.get("models_monitoring", {})
models_cfg = ModelsMonitoringConfig(
enabled=models_data.get("enabled", ModelsMonitoringConfig.enabled),
see_confidence_threshold=models_data.get(
"see_confidence_threshold", ModelsMonitoringConfig.see_confidence_threshold
),
smc_confidence_threshold=models_data.get(
"smc_confidence_threshold", ModelsMonitoringConfig.smc_confidence_threshold
),
tts_enabled=models_data.get("tts_enabled", ModelsMonitoringConfig.tts_enabled),
qas_enabled=models_data.get("qas_enabled", ModelsMonitoringConfig.qas_enabled),
spr_enabled=models_data.get("spr_enabled", ModelsMonitoringConfig.spr_enabled),
)
log_data = data.get("logging", {})
logging_cfg = LoggingConfig(
level=log_data.get("level", LoggingConfig.level),
file=log_data.get("file"),
format=log_data.get("format", LoggingConfig.format),
)
return AppConfig(ses=ses, zabbix=zabbix, monitoring=monitoring, models=models_cfg, logging=logging_cfg)
def load_app_config(config_path: Optional[str] = None) -> AppConfig:
"""Загружает основную конфигурацию из YAML-файла.
Args:
config_path: Путь к config.yaml. Если None — ищет
config/config.yaml относительно корня проекта.
Returns:
AppConfig с заполненными полями.
Raises:
FileNotFoundError: Файл конфигурации не найден.
yaml.YAMLError: Ошибка парсинга YAML.
"""
if config_path is None:
# Автоопределение пути: от места запуска ищем config/config.yaml
candidates = [
Path("config/config.yaml"),
Path("/opt/ses-monitor/config/config.yaml"),
]
# Добавляем относительный путь от этого файла
this_dir = Path(__file__).resolve().parent.parent
candidates.insert(0, this_dir / "config" / "config.yaml")
found = None
for c in candidates:
if c.exists():
found = c
break
if found is None:
raise FileNotFoundError(
"Файл конфигурации не найден. Проверьте config/config.yaml "
"или укажите путь явно."
)
config_path = str(found)
with open(config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if data is None:
data = {}
return _build_config(data)
def load_scenarios(scenarios_dir: str) -> List[ScenarioConfig]:
"""Загружает все YAML-файлы сценариев из указанного каталога.
Args:
scenarios_dir: Путь к каталогу со сценариями.
Returns:
Список конфигураций сценариев.
Notes:
Файлы сценариев читаются из `*.yaml` и `*.yml` в указанном каталоге.
Сценарии с `enabled: false` пропускаются.
"""
dir_path = Path(scenarios_dir)
if not dir_path.exists():
raise FileNotFoundError(f"Каталог сценариев не найден: {scenarios_dir}")
scenarios: List[ScenarioConfig] = []
for yaml_file in sorted(dir_path.glob("*.yaml")):
try:
with open(yaml_file, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if data is None:
continue
scenario = _dict_to_scenario_config(data)
if scenario.enabled:
scenarios.append(scenario)
except (yaml.YAMLError, KeyError) as e:
raise RuntimeError(f"Ошибка чтения сценария {yaml_file}: {e}") from e
for yml_file in sorted(dir_path.glob("*.yml")):
try:
with open(yml_file, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if data is None:
continue
scenario = _dict_to_scenario_config(data)
if scenario.enabled:
scenarios.append(scenario)
except (yaml.YAMLError, KeyError) as e:
raise RuntimeError(f"Ошибка чтения сценария {yml_file}: {e}") from e
return scenarios