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

234 lines
8.0 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.

"""
Вспомогательные утилиты: логирование, работа с JSON-путями, тайминг.
"""
from __future__ import annotations
import logging
import re
import time
from contextlib import contextmanager
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from ses_monitor.config import LoggingConfig
def setup_logging(cfg: LoggingConfig) -> logging.Logger:
"""Настраивает и возвращает корневой логгер проекта.
Args:
cfg: Конфигурация логирования.
Returns:
Настроенный логгер 'ses_monitor'.
"""
logger = logging.getLogger("ses_monitor")
logger.setLevel(getattr(logging, cfg.level.upper(), logging.INFO))
# Убираем существующие обработчики, чтобы не дублировать
logger.handlers.clear()
formatter = logging.Formatter(cfg.format, datefmt="%Y-%m-%d %H:%M:%S")
# Всегда пишем в stdout для systemd
console = logging.StreamHandler()
console.setFormatter(formatter)
logger.addHandler(console)
# Опционально: файловый лог
if cfg.file:
from logging.handlers import RotatingFileHandler
file_handler = RotatingFileHandler(
cfg.file, maxBytes=10 * 1024 * 1024, backupCount=5,
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
def get_json_value(data: Union[Dict[str, Any], List[Any]], path: str) -> Any:
"""Извлекает значение из вложенной JSON-структуры по пути.
Args:
data: Словарь или список с данными.
path: Путь в формате "key.subkey[0].field" или "items[0].name".
Returns:
Значение по указанному пути, или None если путь не существует.
Examples:
>>> get_json_value({"a": {"b": [1, 2]}}, "a.b[0]")
1
>>> get_json_value({"answer": [{"text": "да"}]}, "answer[0].text")
'да'
"""
try:
# Разбираем путь: key, [0], .subkey
tokens = re.findall(r"(?:^|\.)(\w+)|\[(\d+)\]", path)
current: Any = data
for key, index in tokens:
if key:
if isinstance(current, dict):
current = current.get(key)
else:
return None
elif index is not None:
idx = int(index)
if isinstance(current, list) and idx < len(current):
current = current[idx]
else:
return None
return current
except (KeyError, IndexError, TypeError, AttributeError):
return None
def check_expectation(
data: Dict[str, Any],
path: str,
condition: str,
value: Any = None,
) -> Tuple[bool, str]:
"""Проверяет одно условие ожидания для JSON-ответа.
Args:
data: JSON-ответ от SES API.
path: Путь к полю в JSON (формат "answer[0].messages[0]").
condition: Тип проверки:
- "equals": поле == value
- "contains": поле содержит value
- "not_contains": поле не содержит value
- "not_empty": поле не пустое
- "regex": поле соответствует регулярному выражению в value
- "exists": поле существует и не None
value: Значение для сравнения (для equals/contains/not_contains/regex).
Returns:
(успех, сообщение_об_ошибке). При успехе сообщение пустое.
"""
actual = get_json_value(data, path)
if condition == "exists":
if actual is not None:
return True, ""
return False, f"Поле '{path}' не найдено в ответе"
if condition == "not_empty":
if actual is not None and actual != "" and actual != [] and actual != {}:
return True, ""
return False, f"Поле '{path}' пустое: {actual!r}"
if actual is None:
return False, f"Поле '{path}' не найдено в ответе"
if isinstance(actual, str) and value is not None:
actual_str = actual.lower()
if not isinstance(value, str):
value = str(value)
value_str = value.lower()
if condition == "contains":
if value_str in actual_str:
return True, ""
return False, (
f"Поле '{path}' = {actual_str[:100]!r} "
f"не содержит {value_str[:100]!r}"
)
if condition == "not_contains":
if value_str not in actual_str:
return True, ""
return False, (
f"Поле '{path}' = {actual_str[:100]!r} "
f"содержит запрещённое {value_str[:100]!r}"
)
if condition == "regex":
try:
if re.search(str(value), actual):
return True, ""
return False, (
f"Поле '{path}' = {actual[:100]!r} "
f"не соответствует regex {value!r}"
)
except re.error as e:
return False, f"Ошибка regex '{value}': {e}"
if condition == "equals":
# Приводим к строке для сравнения
actual_conv = actual
value_conv = value
if not isinstance(actual_conv, type(value_conv)):
# Пробуем сравнить через строковое представление
if str(actual_conv) == str(value_conv):
return True, ""
return False, (
f"Поле '{path}' = {actual_conv!r} "
f"не равно {value_conv!r}"
)
if actual_conv == value_conv:
return True, ""
return False, (
f"Поле '{path}' = {actual_conv!r} "
f"не равно {value_conv!r}"
)
return False, f"Неизвестное условие проверки: {condition}"
def check_all_expectations(
data: Dict[str, Any],
expectations: List[Dict[str, Any]],
) -> Tuple[bool, List[str]]:
"""Проверяет все ожидания для одного шага сценария.
Args:
data: JSON-ответ от SES API.
expectations: Список условий — каждый dict с ключами
path, condition_type_1, condition_type_2, ...
Returns:
се_прошли, список_сообщений_об_ошибках).
"""
errors: List[str] = []
for exp in expectations:
path = exp.get("path", "")
if not path:
errors.append("Ожидание без указания path")
continue
for condition, value in exp.items():
if condition == "path":
continue # path — не условие
ok, msg = check_expectation(data, path, condition, value)
if not ok:
errors.append(msg)
return len(errors) == 0, errors
@contextmanager
def timer() -> Generator[Any, None, None]:
"""Контекстный менеджер для измерения времени выполнения блока.
Yields:
Словарь, в который будет записано прошедшее время (в секундах)
по ключам 'start' и 'elapsed'.
Usage:
>>> with timer() as t:
... time.sleep(0.1)
>>> print(f"{t['elapsed']:.2f}s")
"""
result: Dict[str, float] = {"start": time.monotonic()}
try:
yield result
finally:
result["elapsed"] = time.monotonic() - result["start"]