Add DSH key-alarm subscribe hub for outbound netxops clients.

netx hosts /v1/integrations/dsh-alarm/ws; matched key alerts fan out to dialed-in DSH subscribers while the optional oclaw bridge remains.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 20:50:50 +08:00
parent bd01440e9d
commit 5e7d14f253
4 changed files with 250 additions and 10 deletions

212
netx_api/dsh_alarm_hub.py Normal file
View file

@ -0,0 +1,212 @@
"""DSH / netxops key-alarm subscribe hub (netx is the fixed-IP WebSocket server).
Clients (netxops on each DSH host) dial out to:
ws[s]://<netx-host>:<port>/v1/integrations/dsh-alarm/ws
authenticate with the same API token used for REST, then receive `netx.alarm` events.
"""
from __future__ import annotations
import asyncio
import logging
import threading
from datetime import datetime, timezone
from typing import Any
from fastapi import WebSocket, WebSocketDisconnect
from starlette.websockets import WebSocketState
from .auth_deps import resolve_user_from_token
from .auth_scopes import SCOPE_ALARMS_READ, has_scope
from .config import settings
from .db import SessionLocal
_log = logging.getLogger("netx.dsh.alarm_hub")
_LOCK = threading.Lock()
_CLIENTS: set[WebSocket] = set()
_LOOP: asyncio.AbstractEventLoop | None = None
_STATS = {
"published": 0,
"deliver_ok": 0,
"deliver_fail": 0,
"subscribers": 0,
}
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def bind_event_loop(loop: asyncio.AbstractEventLoop | None) -> None:
"""Remember the API process loop so sync publishers can schedule sends."""
global _LOOP
_LOOP = loop
def subscriber_count() -> int:
with _LOCK:
return len(_CLIENTS)
def hub_status() -> dict[str, Any]:
with _LOCK:
clients = len(_CLIENTS)
stats = dict(_STATS)
stats["subscribers"] = clients
return {
"enabled": True,
"path": "/v1/integrations/dsh-alarm/ws",
"subscribers": clients,
"published": int(stats.get("published") or 0),
"deliver_ok": int(stats.get("deliver_ok") or 0),
"deliver_fail": int(stats.get("deliver_fail") or 0),
}
def _authorize_token(token: str) -> tuple[bool, str]:
raw = str(token or "").strip()
if not bool(settings.auth_enabled):
return True, "auth_disabled"
if not raw:
return False, "token_required"
db = SessionLocal()
try:
hit = resolve_user_from_token(db, raw)
if hit is None:
return False, "invalid_token"
user, _via, scopes, _token_id, _jti = hit
if has_scope(scopes, SCOPE_ALARMS_READ) or str(user.role or "").lower() == "admin":
return True, str(user.username or user.id or "user")
return False, "missing_alarms_read_scope"
except Exception as exc: # noqa: BLE001
_log.warning("dsh alarm hub auth error: %s", exc)
return False, "auth_error"
finally:
db.close()
async def _send_json(ws: WebSocket, payload: dict[str, Any]) -> bool:
if ws.client_state != WebSocketState.CONNECTED:
return False
try:
await ws.send_json(payload)
return True
except Exception:
return False
async def _broadcast(payload: dict[str, Any]) -> int:
with _LOCK:
clients = list(_CLIENTS)
if not clients:
return 0
envelope = {
"type": "event",
"event": "netx.alarm",
"ts": _utc_now_iso(),
"payload": payload,
}
dead: list[WebSocket] = []
ok = 0
for ws in clients:
if await _send_json(ws, envelope):
ok += 1
else:
dead.append(ws)
if dead:
with _LOCK:
for ws in dead:
_CLIENTS.discard(ws)
_STATS["subscribers"] = len(_CLIENTS)
_STATS["deliver_fail"] += len(dead)
for ws in dead:
try:
await ws.close()
except Exception:
pass
with _LOCK:
_STATS["deliver_ok"] += ok
return ok
def publish_alarm(payload: dict[str, Any]) -> int:
"""Fan-out a matched key-alert payload to all connected DSH subscribers.
Safe to call from sync UME/alarm threads. Returns the number of clients that
accepted the message (0 when nobody is subscribed).
"""
if not isinstance(payload, dict):
return 0
with _LOCK:
_STATS["published"] += 1
clients = len(_CLIENTS)
loop = _LOOP
if clients <= 0:
return 0
if loop is None or not loop.is_running():
_log.warning("dsh alarm hub has subscribers but no running event loop")
return 0
future = asyncio.run_coroutine_threadsafe(_broadcast(dict(payload)), loop)
try:
return int(future.result(timeout=5))
except Exception as exc: # noqa: BLE001
_log.warning("dsh alarm hub publish failed: %s", exc)
return 0
async def dsh_alarm_ws_loop(websocket: WebSocket) -> None:
"""Accept one netxops subscriber: auth → ping/pong → receive netx.alarm pushes."""
await websocket.accept()
bind_event_loop(asyncio.get_running_loop())
authed = False
try:
while True:
raw = await websocket.receive_text()
try:
import json
msg = json.loads(raw)
except Exception:
await _send_json(websocket, {"type": "error", "error": "invalid_json"})
continue
if not isinstance(msg, dict):
await _send_json(websocket, {"type": "error", "error": "invalid_message"})
continue
mtype = str(msg.get("type") or "").strip().lower()
if not authed:
if mtype != "auth":
await _send_json(websocket, {"type": "auth-fail", "error": "auth_required"})
await websocket.close(code=4401)
return
ok, detail = _authorize_token(str(msg.get("token") or ""))
if not ok:
await _send_json(websocket, {"type": "auth-fail", "error": detail})
await websocket.close(code=4401)
return
authed = True
with _LOCK:
_CLIENTS.add(websocket)
_STATS["subscribers"] = len(_CLIENTS)
await _send_json(
websocket,
{
"type": "auth-ok",
"user": detail,
"ts": _utc_now_iso(),
},
)
_log.info("dsh alarm hub subscriber connected (%s)", detail)
continue
if mtype == "ping":
await _send_json(websocket, {"type": "pong", "ts": _utc_now_iso()})
continue
await _send_json(websocket, {"type": "error", "error": f"unknown_type:{mtype}"})
except WebSocketDisconnect:
return
finally:
with _LOCK:
_CLIENTS.discard(websocket)
_STATS["subscribers"] = len(_CLIENTS)
_log.info("dsh alarm hub subscriber disconnected")

View file

@ -5,17 +5,24 @@ from __future__ import annotations
import time import time
from typing import Any from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, WebSocket
from sqlalchemy import text as sql_text from sqlalchemy import text as sql_text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config import settings from .config import settings
from .db import get_db from .db import get_db
from .dsh_alarm_hub import dsh_alarm_ws_loop, hub_status
from .oclaw_alarm_forwarder import forwarder_status from .oclaw_alarm_forwarder import forwarder_status
router = APIRouter(tags=["health"]) router = APIRouter(tags=["health"])
@router.websocket("/v1/integrations/dsh-alarm/ws")
async def dsh_alarm_subscribe(websocket: WebSocket) -> None:
"""netxops dials out here to receive matched key-alert pushes."""
await dsh_alarm_ws_loop(websocket)
@router.get("/health/live", status_code=200) @router.get("/health/live", status_code=200)
def health_live() -> dict[str, str]: def health_live() -> dict[str, str]:
"""Process liveness — no DB or upstream checks.""" """Process liveness — no DB or upstream checks."""
@ -130,4 +137,9 @@ def integrations_status(db: Session = Depends(get_db)) -> dict:
"forwarder": fwd, "forwarder": fwd,
} }
return {"netx_api": netx_api, "db": db_status, "oclaw_bridge": oclaw_status} return {
"netx_api": netx_api,
"db": db_status,
"oclaw_bridge": oclaw_status,
"dsh_alarm_hub": hub_status(),
}

View file

@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from .key_alert_matcher import match_key_alert_rule from .key_alert_matcher import match_key_alert_rule
from .models import UmeInventoryNE, UmeKeyAlertForwardLog from .models import UmeInventoryNE, UmeKeyAlertForwardLog
from .oclaw_alarm_forwarder import enqueue_alarm_forward, is_forwarder_operational from .oclaw_alarm_forwarder import enqueue_alarm_forward, is_forwarder_operational
from .dsh_alarm_hub import publish_alarm
from .ume_sync_service import ( from .ume_sync_service import (
_derive_ne_id_from_alarm, _derive_ne_id_from_alarm,
_pick, _pick,
@ -69,8 +70,6 @@ def maybe_forward_key_alert(
alarm_key: str, alarm_key: str,
action: str, action: str,
) -> bool: ) -> bool:
if not is_forwarder_operational():
return False
rule = match_key_alert_rule(db, norm=norm, action=action) rule = match_key_alert_rule(db, norm=norm, action=action)
if rule is None: if rule is None:
return False return False
@ -96,10 +95,23 @@ def maybe_forward_key_alert(
payload["ne"] = _ne_payload(db, ne_id) payload["ne"] = _ne_payload(db, ne_id)
payload["rule_key"] = str(rule.notification_id or "") payload["rule_key"] = str(rule.notification_id or "")
queued = enqueue_alarm_forward(payload) hub_sent = publish_alarm(payload)
if not queued: oclaw_queued = False
if is_forwarder_operational():
oclaw_queued = bool(enqueue_alarm_forward(payload))
if hub_sent <= 0 and not oclaw_queued:
return False return False
# Hub-only delivery already reached DSH clients — mark ok for dedup.
# Oclaw path stays pending until the bridge records a result.
delivered_ok = hub_sent > 0 and not oclaw_queued
status = []
if hub_sent > 0:
status.append(f"dsh_hub:{hub_sent}")
if oclaw_queued:
status.append("oclaw_queued")
status_text = ",".join(status) if status else "queued"
row = existing row = existing
if row is None: if row is None:
row = UmeKeyAlertForwardLog( row = UmeKeyAlertForwardLog(
@ -108,16 +120,16 @@ def maybe_forward_key_alert(
rule_key=str(rule.notification_id or ""), rule_key=str(rule.notification_id or ""),
notification_id=notification_id_from_norm(norm), notification_id=notification_id_from_norm(norm),
forwarded_at=_utc_now_naive(), forwarded_at=_utc_now_naive(),
oclaw_ok=0, oclaw_ok=1 if delivered_ok else 0,
error="queued", error="" if delivered_ok else status_text,
) )
db.add(row) db.add(row)
else: else:
row.notification_id = notification_id_from_norm(norm) row.notification_id = notification_id_from_norm(norm)
row.rule_key = str(rule.notification_id or "") row.rule_key = str(rule.notification_id or "")
row.forwarded_at = _utc_now_naive() row.forwarded_at = _utc_now_naive()
row.oclaw_ok = 0 row.oclaw_ok = 1 if delivered_ok else 0
row.error = "queued" row.error = "" if delivered_ok else status_text
try: try:
db.commit() db.commit()
except IntegrityError: except IntegrityError:

View file

@ -45,10 +45,14 @@ _BOOT_MONO = time.monotonic()
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]: async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
import asyncio
from .app_startup import run_api_startup from .app_startup import run_api_startup
from .app_shutdown import shutdown_runtime from .app_shutdown import shutdown_runtime
from .dsh_alarm_hub import bind_event_loop
run_api_startup() run_api_startup()
bind_event_loop(asyncio.get_running_loop())
try: try:
yield yield
finally: finally: