230 lines
9.1 KiB
Python
230 lines
9.1 KiB
Python
"""§4.5: алерты 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
|