feat(config,mcps,policy,compose): komodo alerts with hold, vibegram and ha python_tools, vault rw home zones
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
"""§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
|
||||
Reference in New Issue
Block a user