Maksim Totmin 6d8bf29045 refactor: migrate to src-layout (PEP 517/518)
Move ses_monitor package into src/ directory for proper
Python packaging standards (src-layout).

- src/ses_monitor/ — package source
- pyproject.toml: [tool.setuptools.packages.find] where = ["src"]
- pyproject.toml: [tool.pytest.ini_options] pythonpath = ["src"]
- 59 tests pass unchanged
2026-06-19 20:29:22 +07:00

184 lines
6.9 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.

"""
Быстрые liveness-проверки: доступность API, лицензия, количество роботов.
Запускаются с высокой частотой (каждые 30 секунд) и дают первый сигнал
о проблемах с SES сервером.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from ses_monitor.config import SESConfig
from ses_monitor.session_client import SESClient, HostnameResponse, LicenseResponse
log = logging.getLogger("ses_monitor.checker")
@dataclass
class LivenessResult:
"""Результат одного цикла liveness-проверок."""
# Доступность API
api_ok: bool = True
api_response_time_ms: float = 0.0
api_error: str = ""
# Лицензия
license_ok: bool = True
license_error: str = ""
license_days_remaining: int = -1
# Количество сценариев (роботов)
robots_count: int = 0
robots_error: str = ""
# Общая метка времени
timestamp: float = 0.0
@property
def all_ok(self) -> bool:
"""Все проверки прошли успешно."""
return self.api_ok and self.license_ok and not self.robots_error
class LivenessChecker:
"""Выполняет быстрые проверки работоспособности SES.
Проверяет:
1. GET /ses/hostname — жив ли сервер.
2. GET /license/check — не истекла ли лицензия.
3. GET /ses/robot/list — не пропали ли сценарии.
"""
def __init__(self, ses_config: SESConfig, robot_names: Optional[List[str]] = None):
self._ses_config = ses_config
# Эталонный список роботов (для сравнения)
self._expected_robots = robot_names
async def check(self, client: SESClient) -> LivenessResult:
"""Выполняет полный цикл liveness-проверок.
Args:
client: HTTP-клиент для SES API.
Returns:
LivenessResult со статусом всех проверок.
"""
import time
result = LivenessResult(timestamp=time.time())
# 1. Проверка hostname (доступность API)
await self._check_hostname(client, result)
# 2. Проверка лицензии
await self._check_license(client, result)
# 3. Проверка количества роботов
await self._check_robots(client, result)
return result
async def _check_hostname(
self, client: SESClient, result: LivenessResult
) -> None:
"""Проверяет GET /ses/hostname и замеряет время ответа."""
import time
try:
t0 = time.monotonic()
resp = await client.get_hostname()
elapsed = (time.monotonic() - t0) * 1000
result.api_response_time_ms = round(elapsed, 1)
if resp.error == 0:
result.api_ok = True
log.debug(
"Liveness hostname OK: %s v%s, время=%dms",
resp.hostname, resp.version, round(elapsed),
)
else:
result.api_ok = False
result.api_error = f"API ошибка error={resp.error}: {resp.message}"
log.warning("Liveness hostname ошибка: %s", result.api_error)
except Exception as e:
result.api_ok = False
result.api_error = f"Недоступен: {e}"
log.error("Liveness hostname исключение: %s", e)
async def _check_license(
self, client: SESClient, result: LivenessResult
) -> None:
"""Проверяет GET /license/check."""
try:
resp = await client.get_license()
if resp.error == 0:
# "infinity" или число > 0 — лицензия в порядке
remaining = resp.remaining_licenses
if remaining == "infinity":
result.license_ok = True
result.license_days_remaining = -1
elif remaining.isdigit():
days = int(remaining)
result.license_days_remaining = days
if days > 0:
result.license_ok = True
else:
result.license_ok = False
result.license_error = (
f"Лицензия истекла: remaining={remaining}"
)
else:
result.license_ok = False
result.license_error = (
f"Лицензия истекла или исчерпана: remaining={remaining}"
)
log.debug("Liveness license OK: remaining=%s, days=%d", remaining, result.license_days_remaining)
else:
result.license_ok = False
result.license_error = f"Ошибка лицензии error={resp.error}"
log.warning("Liveness license ошибка: %s", result.license_error)
except Exception as e:
result.license_ok = False
result.license_error = f"Недоступна: {e}"
log.error("Liveness license исключение: %s", e)
async def _check_robots(
self, client: SESClient, result: LivenessResult
) -> None:
"""Проверяет GET /ses/robot/list — список сценариев."""
try:
robots = await client.get_robots()
result.robots_count = len(robots)
# Если задан эталонный список — проверяем имена
if self._expected_robots is not None:
actual_names = {r.get("name", "") for r in robots}
expected_set = set(self._expected_robots)
missing = expected_set - actual_names
extra = actual_names - expected_set
if missing:
result.robots_error = f"Отсутствуют роботы: {missing}"
log.warning("Liveness robots: не хватает %s", missing)
if extra:
msg = f"Новые роботы: {extra}"
if result.robots_error:
result.robots_error += "; " + msg
else:
result.robots_error = msg
log.info("Liveness robots: новые %s", extra)
log.debug("Liveness robots count: %d", result.robots_count)
except Exception as e:
result.robots_error = f"Недоступен: {e}"
log.error("Liveness robots исключение: %s", e)