feat(observability): resolve services from registry, add health endpoints
Slice 2 of observability-service-registry. The monitoring router resolves
observability components from the service registry instead of env vars.
- routers/monitoring.py: removed _alertmanager_client/_webhook_client env
readers + the get_settings import. Added _resolve_service_record(store,
service_type, service_id?) -> ServiceRecord|None (requested instance with
type+enabled checks, else first enabled instance), plus _base_url/_timeout/
_auth_headers (Bearer from api_key)/_status_response helpers.
- /alerts + /alertmanager-status now take service_id? + Depends(store),
resolve an alertmanager service, return graceful not-configured/
unreachable payloads including service_id/name; status down-branches now
include peers:[] + error (fixes prior type drift).
- NEW /grafana-status (probes /api/health) and /prometheus-status (probes
/-/healthy then /api/v1/status/buildinfo) returning
{up,version,service_id,name,error}.
- Webhook receiver is now log-only (dropped the outbound
ALERTMANAGER_WEBHOOK_URL forward).
- tests: rewrote TestAlertmanager + TestAlertmanagerWebhook to mock
_resolve_service_record/requests.get (not-configured via empty registry);
added TestGrafanaStatus/TestPrometheusStatus and a TestResolveServiceRecord
unit class covering service_id match/type-mismatch/disabled and first-
enabled/none-enabled paths.
Orphaned config fields alertmanager_url/alertmanager_webhook_url and the
env-var removal land in Slice 5. ruff clean; 240 backend tests pass.
Reviewed fresh-context (read-only): no blockers.
This commit is contained in:
+236
-60
@@ -24,6 +24,10 @@ from media_library_viewer_api.main import app
|
||||
from media_library_viewer_api.routers.media import get_media_index
|
||||
from media_library_viewer_api.services.media_index import MediaIndex
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
# Short alias for the monitoring router module under test.
|
||||
_MON = "media_library_viewer_api.routers.monitoring"
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
@@ -626,6 +630,76 @@ class TestMonitoring:
|
||||
assert data[0]["labels"]["job"] == "node-exporter-remote"
|
||||
|
||||
|
||||
class TestResolveServiceRecord:
|
||||
"""Unit tests for _resolve_service_record (service_id + first-enabled paths)."""
|
||||
|
||||
def _store(self, rows):
|
||||
store = MagicMock()
|
||||
store.get_service = lambda sid: next((r for r in rows if r["id"] == sid), None)
|
||||
def list_filtered(service_type=None):
|
||||
return [r for r in rows if r["service_type"] == (service_type or r["service_type"])]
|
||||
|
||||
store.list_services = list_filtered
|
||||
return store
|
||||
|
||||
def test_service_id_match_returns_record(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
|
||||
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": True, "config": {}, "secrets": {}}
|
||||
store = self._store([row])
|
||||
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build:
|
||||
result = _resolve_service_record(store, "alertmanager", "am1")
|
||||
assert result == "RECORD"
|
||||
mock_build.assert_called_once_with(store, row)
|
||||
|
||||
def test_service_id_type_mismatch_returns_none(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
|
||||
row = {"id": "x1", "service_type": "grafana", "name": "G", "enabled": True, "config": {}, "secrets": {}}
|
||||
store = self._store([row])
|
||||
assert _resolve_service_record(store, "alertmanager", "x1") is None
|
||||
|
||||
def test_service_id_disabled_returns_none(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
|
||||
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": False, "config": {}, "secrets": {}}
|
||||
store = self._store([row])
|
||||
assert _resolve_service_record(store, "alertmanager", "am1") is None
|
||||
|
||||
def test_no_service_id_returns_first_enabled(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
|
||||
rows = [
|
||||
{
|
||||
"id": "am1",
|
||||
"service_type": "alertmanager",
|
||||
"name": "Disabled",
|
||||
"enabled": False,
|
||||
"config": {},
|
||||
"secrets": {},
|
||||
},
|
||||
{
|
||||
"id": "am2",
|
||||
"service_type": "alertmanager",
|
||||
"name": "Active",
|
||||
"enabled": True,
|
||||
"config": {},
|
||||
"secrets": {},
|
||||
},
|
||||
]
|
||||
store = self._store(rows)
|
||||
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build:
|
||||
result = _resolve_service_record(store, "alertmanager", None)
|
||||
assert result == "RECORD"
|
||||
mock_build.assert_called_once_with(store, rows[1])
|
||||
|
||||
def test_no_service_id_and_none_enabled_returns_none(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
|
||||
store = self._store([])
|
||||
assert _resolve_service_record(store, "alertmanager", None) is None
|
||||
|
||||
|
||||
class TestSettingsMachines:
|
||||
def test_post_machine_rewrites_prometheus_targets(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.settings.write_prometheus_targets") as write_targets:
|
||||
@@ -665,75 +739,120 @@ class TestSettingsMachines:
|
||||
write_targets.assert_called_once()
|
||||
|
||||
|
||||
def _am_service(name="Alertmanager", **config):
|
||||
cfg = {"base_url": "http://alertmanager:9093", "timeout_seconds": 5}
|
||||
cfg.update(config)
|
||||
return ServiceRecord(id="am1", service_type="alertmanager", name=name, config=cfg)
|
||||
|
||||
|
||||
class TestAlertmanager:
|
||||
def test_alerts_endpoint_when_alertmanager_unreachable(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
||||
session = MagicMock()
|
||||
session.get.side_effect = Exception("connection refused")
|
||||
mock_client.return_value = (session, "http://alertmanager:9093")
|
||||
def test_alerts_endpoint_when_not_configured(self, test_client):
|
||||
# No alertmanager service seeded -> registry resolves None.
|
||||
response = test_client.get("/api/monitoring/alerts")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["error"] == "alertmanager_not_configured"
|
||||
|
||||
def test_alerts_endpoint_when_unreachable(self, test_client):
|
||||
service = _am_service()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("connection refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/alerts")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["error"] == "alertmanager_unreachable"
|
||||
|
||||
def test_alerts_endpoint_when_not_configured(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
||||
session = MagicMock()
|
||||
mock_client.return_value = (session, "")
|
||||
response = test_client.get("/api/monitoring/alerts")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["error"] == "alertmanager_not_configured"
|
||||
session.get.assert_not_called()
|
||||
assert data["service_id"] == "am1"
|
||||
assert data["name"] == "Alertmanager"
|
||||
|
||||
def test_alerts_endpoint_returns_summary(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
||||
session = MagicMock()
|
||||
session.get.return_value.json.return_value = {
|
||||
"status": "success",
|
||||
"data": [
|
||||
{
|
||||
"labels": {"alertname": "BackupJobFailed", "severity": "critical", "job_name": "test"},
|
||||
"annotations": {"summary": "Backup failed", "description": "details"},
|
||||
"startsAt": "2026-05-11T02:00:00Z",
|
||||
"status": "firing",
|
||||
}
|
||||
],
|
||||
}
|
||||
session.get.return_value.raise_for_status = MagicMock()
|
||||
mock_client.return_value = (session, "http://alertmanager:9093")
|
||||
service = _am_service()
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {
|
||||
"status": "success",
|
||||
"data": [
|
||||
{
|
||||
"labels": {"alertname": "BackupJobFailed", "severity": "critical", "job_name": "test"},
|
||||
"annotations": {"summary": "Backup failed", "description": "details"},
|
||||
"startsAt": "2026-05-11T02:00:00Z",
|
||||
"status": "firing",
|
||||
}
|
||||
],
|
||||
}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/alerts")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 1
|
||||
assert data["by_severity"]["critical"] == 1
|
||||
assert data["alerts"][0]["name"] == "BackupJobFailed"
|
||||
assert data["service_id"] == "am1"
|
||||
|
||||
def test_alertmanager_status_endpoint_when_not_configured(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
||||
session = MagicMock()
|
||||
mock_client.return_value = (session, "")
|
||||
def test_alerts_endpoint_sends_bearer_token(self, test_client):
|
||||
service = _am_service()
|
||||
service.secrets = {"api_key": "tok"}
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"status": "success", "data": []}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp) as mock_get,
|
||||
):
|
||||
test_client.get("/api/monitoring/alerts")
|
||||
_, kwargs = mock_get.call_args
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer tok"
|
||||
|
||||
def test_alertmanager_status_when_not_configured(self, test_client):
|
||||
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "alertmanager_not_configured"
|
||||
assert data["peers"] == []
|
||||
|
||||
def test_alertmanager_status_when_unreachable(self, test_client):
|
||||
service = _am_service()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
session.get.assert_not_called()
|
||||
assert data["error"] == "alertmanager_unreachable"
|
||||
assert data["name"] == "Alertmanager"
|
||||
|
||||
def test_alertmanager_status_endpoint_when_unreachable(self, test_client):
|
||||
with patch("media_library_viewer_api.routers.monitoring._alertmanager_client") as mock_client:
|
||||
session = MagicMock()
|
||||
session.get.side_effect = Exception("connection refused")
|
||||
mock_client.return_value = (session, "http://alertmanager:9093")
|
||||
def test_alertmanager_status_returns_cluster_info(self, test_client):
|
||||
service = _am_service()
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {
|
||||
"versionInfo": {"version": "0.27.0"},
|
||||
"clusterStatus": {"createdAt": "2026-06-01T00:00:00Z"},
|
||||
"cluster": {"peers": [{"name": "am-1"}, {"name": "am-2"}]},
|
||||
}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["up"] is True
|
||||
assert data["version"] == "0.27.0"
|
||||
assert data["peers"] == ["am-1", "am-2"]
|
||||
|
||||
|
||||
class TestAlertmanagerWebhook:
|
||||
"""Tests for POST /api/monitoring/alertmanager-webhook."""
|
||||
"""Tests for POST /api/monitoring/alertmanager-webhook (log-only receiver)."""
|
||||
|
||||
def test_webhook_receives_payload_and_logs(self, test_client, caplog):
|
||||
payload = {
|
||||
@@ -753,22 +872,79 @@ class TestAlertmanagerWebhook:
|
||||
assert response.json()["status"] == "received"
|
||||
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
|
||||
|
||||
def test_webhook_forwards_when_configured(self, test_client, caplog, monkeypatch):
|
||||
payload = {"status": "resolved", "alerts": []}
|
||||
forwarded = {"captured": False}
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def fake_post(url, json, timeout):
|
||||
forwarded["captured"] = True
|
||||
forwarded["url"] = url
|
||||
forwarded["payload"] = json
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("requests.Session.post", lambda _self, url, json, timeout: fake_post(url, json, timeout))
|
||||
with caplog.at_level("INFO", logger="media_library_viewer_api.routers.monitoring"):
|
||||
response = test_client.post("/api/monitoring/alertmanager-webhook", json=payload)
|
||||
class TestGrafanaStatus:
|
||||
def test_grafana_status_when_not_configured(self, test_client):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
assert forwarded["captured"] is False
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "no_service_configured"
|
||||
|
||||
def test_grafana_status_when_unreachable(self, test_client):
|
||||
service = ServiceRecord(id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"})
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "grafana_unreachable"
|
||||
assert data["name"] == "Grafana"
|
||||
|
||||
def test_grafana_status_returns_version(self, test_client):
|
||||
service = ServiceRecord(id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"})
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is True
|
||||
assert data["version"] == "11.3.1"
|
||||
assert data["service_id"] == "g1"
|
||||
|
||||
|
||||
class TestPrometheusStatus:
|
||||
def test_prometheus_status_when_not_configured(self, test_client):
|
||||
response = test_client.get("/api/monitoring/prometheus-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "no_service_configured"
|
||||
|
||||
def test_prometheus_status_when_unreachable(self, test_client):
|
||||
service = ServiceRecord(id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"})
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/prometheus-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "prometheus_unreachable"
|
||||
|
||||
def test_prometheus_status_returns_version(self, test_client):
|
||||
service = ServiceRecord(id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"})
|
||||
health = MagicMock()
|
||||
health.raise_for_status = MagicMock()
|
||||
build_info = MagicMock()
|
||||
build_info.raise_for_status = MagicMock()
|
||||
build_info.json.return_value = {"status": "success", "data": {"version": "2.55.1"}}
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=[health, build_info]),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/prometheus-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is True
|
||||
assert data["version"] == "2.55.1"
|
||||
assert data["service_id"] == "p1"
|
||||
|
||||
@@ -380,9 +380,7 @@ async def test_alertmanager_adapter_sends_bearer_token():
|
||||
config={"base_url": "http://am:9093"},
|
||||
secrets={"api_key": "tok"},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None, json=lambda: {"status": "success", "data": []}
|
||||
)
|
||||
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"status": "success", "data": []})
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
|
||||
result = await adapter.fetch(service, "active_alerts", {})
|
||||
assert result["total"] == 0
|
||||
|
||||
Reference in New Issue
Block a user