feat(config,mcps,policy,compose): komodo alerts with hold, vibegram and ha python_tools, vault rw home zones

This commit is contained in:
hh
2026-08-30 18:16:15 +02:00
parent 002230137e
commit 83531e3ef1
11 changed files with 1186 additions and 47 deletions
+229
View File
@@ -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