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