Files
beaver-agent/beaver_agent/hands/komodo_alerts.py
T

230 lines
9.1 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.
"""алерты 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