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

226 lines
7.7 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.

"""
Отправка метрик в Zabbix.
Два режима:
- sender: zabbix_sender (активный push).
- userparameter: запись JSON-файла + конфиг для Zabbix агента (пассивный опрос).
"""
from __future__ import annotations
import asyncio
import concurrent.futures
import json
import logging
import os
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
from ses_monitor.config import ZabbixConfig, ZabbixMode
log = logging.getLogger("ses_monitor.zabbix")
@dataclass
class ZabbixMetric:
"""Одна метрика для Zabbix."""
host: str
key: str
value: str
def to_line(self) -> str:
"""Форматирует метрику в строку для zabbix_sender."""
return f"{self.host} {self.key} {self.value}"
class ZabbixSender:
"""Асинхронная обёртка над zabbix_sender.
Отправляет метрики через stdin утилиты zabbix_sender.
Использует run_in_executor + subprocess.run (синхронный), чтобы избежать
проблем с asyncio child watcher на Python 3.7.
"""
def __init__(self, config: ZabbixConfig):
self._config = config
self._metrics: List[ZabbixMetric] = []
self._executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
@property
def enabled(self) -> bool:
"""Включена ли отправка в Zabbix."""
return self._config.enabled
def add(self, key: str, value: Any) -> None:
"""Добавляет метрику в буфер отправки.
Args:
key: Ключ метрики (например, "ses.api.liveness").
value: Значение. Числа и булевы конвертируются в строку.
"""
str_value = str(value) if not isinstance(value, bool) else str(int(value))
metric = ZabbixMetric(
host=self._config.host,
key=key,
value=str_value,
)
self._metrics.append(metric)
def _run_sender(self, input_data: str) -> bool:
"""Синхронный запуск zabbix_sender (выполняется в thread pool)."""
try:
proc = subprocess.run(
[
self._config.sender_path,
"-z", self._config.server,
"-p", str(self._config.port),
"-s", self._config.host,
"-i", "-",
],
input=input_data.encode("utf-8"),
capture_output=True,
timeout=30,
)
if proc.returncode != 0:
log.error(
"zabbix_sender завершился с кодом %d: %s",
proc.returncode,
proc.stderr.decode("utf-8", errors="replace").strip(),
)
return False
output = proc.stdout.decode("utf-8", errors="replace")
log.debug(
"Zabbix: отправлено метрик. Ответ: %s",
output.strip(),
)
return True
except FileNotFoundError:
log.error(
"zabbix_sender не найден по пути %s. Установите пакет.",
self._config.sender_path,
)
return False
except subprocess.TimeoutExpired:
log.error("zabbix_sender: таймаут отправки (30с)")
return False
except Exception as e:
log.error("zabbix_sender: ошибка отправки: %s", e)
return False
async def flush(self) -> bool:
"""Отправляет накопленные метрики в Zabbix.
Returns:
True если отправка успешна, False при ошибке.
"""
if not self._metrics:
return True
if not self._config.enabled:
self._metrics.clear()
return True
lines = "\n".join(m.to_line() for m in self._metrics) + "\n"
self._metrics.clear()
loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._executor, self._run_sender, lines)
class UserParameterWriter:
"""Запись метрик в JSON для Zabbix UserParameter.
Пишет атомарный JSON во временный файл с последующей заменой.
Zabbix агент читает файл через UserParameter-скрипт zbx_get.py.
"""
def __init__(self, config: ZabbixConfig):
self._config = config
self._metrics: Dict[str, str] = {}
self._executor: Optional[concurrent.futures.ThreadPoolExecutor] = None
@property
def enabled(self) -> bool:
"""Включена ли запись метрик."""
return self._config.enabled
def add(self, key: str, value: Any) -> None:
"""Добавляет метрику в буфер.
Args:
key: Ключ метрики (например, "ses.api.liveness").
value: Значение. Конвертируется в строку.
"""
str_value = str(value) if not isinstance(value, bool) else str(int(value))
self._metrics[key] = str_value
async def flush(self) -> bool:
"""Сбрасывает буфер метрик в JSON-файл и обновляет конфиг агента.
Returns:
True если запись успешна, False при ошибке.
"""
if not self._metrics:
return True
if not self._config.enabled:
self._metrics.clear()
return True
data = dict(self._metrics)
self._metrics.clear()
loop = asyncio.get_running_loop()
ok = await loop.run_in_executor(
self._executor, self._write_json, data
)
return ok
def _write_json(self, data: Dict[str, str]) -> bool:
"""Атомарная запись JSON-файла с метриками."""
metrics_path = Path(self._config.metrics_file)
try:
metrics_path.parent.mkdir(parents=True, exist_ok=True)
tmp = tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(metrics_path.parent),
prefix=".metrics_",
suffix=".tmp",
delete=False,
)
tmp_path = tmp.name
try:
json.dump(data, tmp, ensure_ascii=False, indent=2)
tmp.write("\n")
tmp.close()
os.replace(tmp_path, str(metrics_path))
os.chmod(str(metrics_path), 0o644)
except Exception:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
return True
except Exception as e:
log.error("Ошибка записи JSON-файла метрик: %s", e)
return False
def generate_agentd_config(self) -> str:
"""Генерирует содержимое конфигурационного файла UserParameter.
Returns:
Строка с конфигурацией для Zabbix агента.
"""
return (
"# Автоматически сгенерировано ses-monitor.\n"
"# Требуется: Include=/opt/ses-monitor/config/zbx_agentd_ses.conf\n"
f"UserParameter=ses.metrics,/opt/ses-monitor/scripts/zbx_get.py\n"
)