release: v1.9.0 — IRM-алерты отдельно от инцидентов
- Alembic 005: таблицы irm_alerts и incident_alert_links - Модуль alerts: API/UI, Ack/Resolve, привязка к инциденту через alert_ids - Вебхук Grafana: одна транзакция ingress + irm_alerts; разбор payload в grafana_payload - По умолчанию инцидент из вебхука не создаётся (AUTO_INCIDENT_FROM_ALERT) - Документация IRM_GRAFANA_PARITY.md, обновления IRM.md и CHANGELOG Made-with: Cursor
This commit is contained in:
@ -25,15 +25,28 @@ class Row:
|
||||
return self._data.get(key, default)
|
||||
|
||||
|
||||
class _FakeTxn:
|
||||
async def __aenter__(self) -> None:
|
||||
return None
|
||||
|
||||
async def __aexit__(self, *args: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class IrmFakeConn:
|
||||
def __init__(self, store: IrmFakeStore) -> None:
|
||||
self.store = store
|
||||
|
||||
def transaction(self) -> _FakeTxn:
|
||||
return _FakeTxn()
|
||||
|
||||
def _q(self, query: str) -> str:
|
||||
return " ".join(query.split())
|
||||
|
||||
async def execute(self, query: str, *args: Any) -> str:
|
||||
q = self._q(query)
|
||||
if "INSERT INTO incident_alert_links" in q:
|
||||
return "INSERT 0 1"
|
||||
if "INSERT INTO incidents" in q and "ingress_event_id" in q:
|
||||
self.store.insert_incident_alert(
|
||||
args[0], args[1], args[2], args[3], args[4]
|
||||
|
||||
9
tests/test_alerts_api.py
Normal file
9
tests/test_alerts_api.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""API модуля алертов без БД."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_alerts_list_no_db(client: TestClient) -> None:
|
||||
r = client.get("/api/v1/modules/alerts/")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"items": [], "database": "disabled"}
|
||||
26
tests/test_grafana_payload.py
Normal file
26
tests/test_grafana_payload.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""Парсинг полей из тела вебхука Grafana."""
|
||||
|
||||
from onguard24.ingress.grafana_payload import extract_alert_row_from_grafana_body
|
||||
|
||||
|
||||
def test_extract_title_and_severity_from_unified() -> None:
|
||||
body = {
|
||||
"title": "RuleName",
|
||||
"alerts": [
|
||||
{
|
||||
"labels": {"severity": "critical", "alertname": "X"},
|
||||
"fingerprint": "abc",
|
||||
}
|
||||
],
|
||||
}
|
||||
title, sev, labels, fp = extract_alert_row_from_grafana_body(body)
|
||||
assert title == "RuleName"
|
||||
assert sev == "critical"
|
||||
assert labels.get("alertname") == "X"
|
||||
assert fp == "abc"
|
||||
|
||||
|
||||
def test_extract_empty_title_uses_alertname() -> None:
|
||||
body = {"alerts": [{"labels": {"alertname": "HostDown"}}]}
|
||||
title, _, _, _ = extract_alert_row_from_grafana_body(body)
|
||||
assert title == "HostDown"
|
||||
@ -1,9 +1,23 @@
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _webhook_mock_pool(mock_conn: AsyncMock) -> MagicMock:
|
||||
"""Пул с транзакцией и execute — как после вставки ingress + irm_alerts."""
|
||||
tx = AsyncMock()
|
||||
tx.__aenter__ = AsyncMock(return_value=None)
|
||||
tx.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_conn.transaction = MagicMock(return_value=tx)
|
||||
mock_conn.execute = AsyncMock()
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
||||
return mock_pool
|
||||
|
||||
|
||||
def test_grafana_webhook_no_db(client: TestClient) -> None:
|
||||
"""Без пула БД — 202, запись не падает."""
|
||||
r = client.post(
|
||||
@ -41,12 +55,7 @@ def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None:
|
||||
mock_conn = AsyncMock()
|
||||
uid = uuid4()
|
||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
||||
mock_pool = _webhook_mock_pool(mock_conn)
|
||||
|
||||
app = client.app
|
||||
real_pool = app.state.pool
|
||||
@ -59,6 +68,7 @@ def test_grafana_webhook_inserts_with_mock_pool(client: TestClient) -> None:
|
||||
)
|
||||
assert r.status_code == 202
|
||||
mock_conn.fetchrow.assert_called_once()
|
||||
mock_conn.execute.assert_called_once()
|
||||
finally:
|
||||
app.state.pool = real_pool
|
||||
|
||||
@ -69,11 +79,7 @@ def test_grafana_webhook_auto_org_from_external_url(client: TestClient) -> None:
|
||||
mock_conn = AsyncMock()
|
||||
uid = uuid4()
|
||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
||||
mock_pool = _webhook_mock_pool(mock_conn)
|
||||
|
||||
app = client.app
|
||||
real_pool = app.state.pool
|
||||
@ -99,11 +105,7 @@ def test_grafana_webhook_publishes_alert_received(client: TestClient) -> None:
|
||||
mock_conn = AsyncMock()
|
||||
uid = uuid4()
|
||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
||||
mock_pool = _webhook_mock_pool(mock_conn)
|
||||
|
||||
app = client.app
|
||||
bus = app.state.event_bus
|
||||
@ -130,11 +132,7 @@ def test_grafana_webhook_org_any_slug_without_json_config(client: TestClient) ->
|
||||
mock_conn = AsyncMock()
|
||||
uid = uuid4()
|
||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
||||
mock_pool = _webhook_mock_pool(mock_conn)
|
||||
|
||||
app = client.app
|
||||
real_pool = app.state.pool
|
||||
@ -157,11 +155,7 @@ def test_grafana_webhook_org_ok(client: TestClient) -> None:
|
||||
mock_conn = AsyncMock()
|
||||
uid = uuid4()
|
||||
mock_conn.fetchrow = AsyncMock(return_value={"id": uid})
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
||||
mock_pool = _webhook_mock_pool(mock_conn)
|
||||
|
||||
app = client.app
|
||||
real_json = app.state.settings.grafana_sources_json
|
||||
|
||||
@ -29,8 +29,41 @@ def test_escalations_api_list_no_db(client: TestClient) -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incident_inserted_on_alert_received() -> None:
|
||||
"""При пуле БД подписка создаёт инцидент (INSERT)."""
|
||||
async def test_incident_not_created_from_alert_by_default() -> None:
|
||||
"""По умолчанию AUTO_INCIDENT_FROM_ALERT выкл — инцидент из вебхука не создаётся."""
|
||||
calls: list = []
|
||||
|
||||
async def fake_execute(_query, *args):
|
||||
calls.append(args)
|
||||
return "INSERT 0 1"
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.execute = fake_execute
|
||||
mock_cm = AsyncMock()
|
||||
mock_cm.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_cm.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire = MagicMock(return_value=mock_cm)
|
||||
|
||||
from onguard24.domain.events import InMemoryEventBus
|
||||
from onguard24.modules import incidents as inc_mod
|
||||
|
||||
bus = InMemoryEventBus()
|
||||
inc_mod.register_events(bus, mock_pool)
|
||||
|
||||
uid = uuid4()
|
||||
ev = AlertReceived(
|
||||
alert=Alert(source="grafana", title="CPU high", severity=Severity.WARNING),
|
||||
raw_payload_ref=uid,
|
||||
)
|
||||
await bus.publish(ev)
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incident_inserted_on_alert_when_auto_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""При AUTO_INCIDENT_FROM_ALERT=1 подписка снова создаёт инцидент (legacy)."""
|
||||
monkeypatch.setenv("AUTO_INCIDENT_FROM_ALERT", "1")
|
||||
inserted: dict = {}
|
||||
|
||||
async def fake_execute(_query, *args):
|
||||
|
||||
@ -33,6 +33,7 @@ def test_rail_lists_all_registered_ui_modules(client: TestClient) -> None:
|
||||
t = r.text
|
||||
expected = (
|
||||
("grafana-catalog", "Каталог Grafana"),
|
||||
("alerts", "Алерты"),
|
||||
("incidents", "Инциденты"),
|
||||
("tasks", "Задачи"),
|
||||
("escalations", "Эскалации"),
|
||||
@ -49,6 +50,7 @@ def test_each_module_page_single_active_nav_item(client: TestClient) -> None:
|
||||
"""На странице модуля ровно один пункт с aria-current (текущий раздел)."""
|
||||
for slug in (
|
||||
"grafana-catalog",
|
||||
"alerts",
|
||||
"incidents",
|
||||
"tasks",
|
||||
"escalations",
|
||||
|
||||
Reference in New Issue
Block a user