"""алерты Komodo для диспетчера - что доехать до мастера, когда и как. Вебхук `/hooks/komodo` получает всё, что шлёт Komodo (типы алертов у алертера не перечислены намеренно). Сюда мастеру нужна малая часть, и не сразу: `ServerUnreachable` и смены состояния стеков - флап, который через минуту сам отбивается («отбой» от Komodo приходит следом), а срочный инжект прерывает тёрн и стоит токенов. Поэтому такие алерты выдерживаются (`HOLD`), и по истечении выдержки состояние перепроверяется по API: если всё уже running - алерт молча выбрасывается; если нет - мастер получает срочный инжект. Информационные типы (обновления образов, расписания, дрейф синка) не доезжают вообще - их читает Бобёр в healthbot. Komodo создаёт `StackStateChange`/`ContainerStateChange` сразу с `resolved=true` (`resolved_ts == ts`): это факт «состояние сменилось», а не тревога, которую снимают. У них смотрим на `to`, не на `resolved`. """ from __future__ import annotations from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from typing import Any, Literal __all__ = ["HOLD", "Alert", "AlertMemory", "Verdict", "describe", "parse", "still_bad"] HOLD = timedelta(minutes=5) # Не для диспетчера: Бобёр видит их в healthbot, действия по ним - его. INFORMATIONAL = frozenset( { "StackImageUpdateAvailable", "DeploymentImageUpdateAvailable", "StackAutoUpdated", "DeploymentAutoUpdated", "ResourceSyncPendingUpdates", "ScheduleRun", "Test", "Custom", } ) # Флапает: ждать HOLD и перепроверить, прежде чем будить мастера. FLAPPY = frozenset( {"ServerUnreachable", "StackStateChange", "ContainerStateChange", "ServerCpu"} ) STATE_CHANGE = frozenset({"StackStateChange", "ContainerStateChange"}) HEALTHY_STATES = frozenset({"running", "healthy"}) FAILURE = frozenset( {"ProcedureFailed", "ActionFailed", "BuildFailed", "RepoBuildFailed"} ) Action = Literal["drop", "hold", "inject", "resolved"] @dataclass(frozen=True, slots=True) class Alert: kind: str level: str resolved: bool name: str server: str | None data: dict[str, Any] @property def key(self) -> str: return f"{self.kind}:{self.server or '-'}:{self.name}" @property def bad(self) -> bool: """Тревога, а не снятие. У смен состояния - по `to`, у прочих - по `resolved`. """ if self.kind in STATE_CHANGE: return str(self.data.get("to", "")).lower() not in HEALTHY_STATES return not self.resolved @dataclass(frozen=True, slots=True) class Verdict: action: Action urgency: Literal["urgent", "normal"] = "normal" @dataclass(slots=True) class AlertMemory: """Что выдерживается и о чём мастеру уже сказали. Повторы во время выдержки не дублируются, а «отбой» доезжает только после тревоги, которую мастер видел. """ holding: set[str] = field(default_factory=set) told: dict[str, datetime] = field(default_factory=dict) def verdict(self, alert: Alert) -> Verdict: if alert.kind in INFORMATIONAL: return Verdict("drop") if not alert.bad: if alert.key in self.holding: # Снялось, пока ждали: ни тревоги, ни отбоя. self.holding.discard(alert.key) return Verdict("drop") if self.told.pop(alert.key, None) is not None: return Verdict("resolved") return Verdict("drop") if alert.kind in FLAPPY: if alert.key in self.holding: return Verdict("drop") self.holding.add(alert.key) return Verdict("hold") return Verdict("inject", "normal") def after_hold(self, alert: Alert, *, still_bad: bool) -> Verdict: self.holding.discard(alert.key) if not still_bad: return Verdict("drop") return Verdict("inject", "urgent") def mark_told(self, alert: Alert) -> None: self.told[alert.key] = datetime.now(UTC) def parse(payload: dict[str, Any]) -> Alert: data = payload.get("data") or {} inner = data.get("data") if isinstance(data, dict) else None inner = inner if isinstance(inner, dict) else {} return Alert( kind=str(data.get("type") or "Unknown") if isinstance(data, dict) else "Unknown", level=str(payload.get("level") or "").upper(), resolved=bool(payload.get("resolved")), name=str(inner.get("name") or inner.get("id") or "?"), server=inner.get("server_name"), data=inner, ) def _gb(value: Any) -> str: try: return f"{float(value):.1f} ГБ" except (TypeError, ValueError): return str(value) def _pct(value: Any) -> str: try: return f"{float(value):.0f}%" except (TypeError, ValueError): return str(value) def _err(value: Any) -> str: if isinstance(value, dict): return str(value.get("error") or value) return str(value or "без деталей") def describe(alert: Alert, *, held: timedelta | None = None) -> str: """Одна-две строки для инжекта: что, где, с каких пор.""" d = alert.data name, server = alert.name, alert.server where = f" на {server}" if server else "" since = f" уже {int(held.total_seconds() // 60)} мин" if held else "" match alert.kind: case "ServerUnreachable": return f"сервер {name} не отвечает{since}: {_err(d.get('err'))}" case "StackStateChange": return f"стек {name}{where}: {d.get('from')} → {d.get('to')}{since}" case "ContainerStateChange": return f"контейнер {name}{where}: {d.get('from')} → {d.get('to')}{since}" case "ServerCpu": return f"CPU на {name}: {_pct(d.get('percentage'))}{since}" case "ServerMem": return ( f"память на {name}: {_gb(d.get('used_gb'))} из {_gb(d.get('total_gb'))}" ) case "ServerDisk": return ( f"диск на {name} ({d.get('path', '/')}): " f"{_gb(d.get('used_gb'))} из {_gb(d.get('total_gb'))}" ) case "ServerVersionMismatch": return ( f"версии разъехались на {name}: periphery {d.get('version')}, " f"core {d.get('core_version')} - чинится только по ssh, это к Бобру" ) case k if k in FAILURE: what = {"ProcedureFailed": "процедура", "ActionFailed": "действие"}.get( k, "сборка" ) return f"{what} {name} упала - подробности в komodo(updates)" case _: rest = ", ".join( f"{k}={v}" for k, v in d.items() if k not in {"id", "server_id"} ) return f"{alert.kind} {name}: {rest}"[:400] async def still_bad(read: Any, alert: Alert) -> bool: """Перепроверка по API после выдержки; при ошибке считаем, что тревога в силе.""" try: match alert.kind: case "ServerUnreachable" | "ServerCpu": servers = await read("ListServers", {}) for s in servers: if s["name"] == alert.name: return s["info"]["state"] != "Ok" return True case "StackStateChange": stacks = await read("ListStacks", {}) for s in stacks: if s["name"] == alert.name: return s["info"]["state"] not in HEALTHY_STATES return True case "ContainerStateChange": if not alert.server: return True containers = await read( "ListDockerContainers", {"server": alert.server} ) for c in containers: if c["name"] == alert.name: return str(c.get("state", "")).lower() not in HEALTHY_STATES return True case _: return True except Exception: # noqa: BLE001 - недоступный Komodo сам по себе тревога return True