mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 02:10:45 +08:00
Add UME TopoNodes/TopologicalLinks daily sync into local tables.
Phase-1 docks both topology APIs without writing Fabric; expose a 24h topology_auto_sync task and domain=topology manual sync for later LLDP alignment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
db8c77a2e5
commit
fe483f51cc
17 changed files with 690 additions and 13 deletions
|
|
@ -28,6 +28,11 @@ NETX_UME_TOKEN_HANDSHAKE_PATH=/restconf/operations/zte-security:oauth_handshake
|
||||||
NETX_UME_TOKEN_LOGOUT_PATH=/restconf/operations/zte-security:oauth_token
|
NETX_UME_TOKEN_LOGOUT_PATH=/restconf/operations/zte-security:oauth_token
|
||||||
NETX_UME_NE_PATH=/restconf/data/zte-resources-module:network-elements
|
NETX_UME_NE_PATH=/restconf/data/zte-resources-module:network-elements
|
||||||
NETX_UME_ALARMS_PATH=/restconf/data/zte-alarms:alarms/alarm-list
|
NETX_UME_ALARMS_PATH=/restconf/data/zte-alarms:alarms/alarm-list
|
||||||
|
NETX_UME_TOPO_NODES_PATH=/restconf/data/zte-resources-module:TopoNodes
|
||||||
|
NETX_UME_TOPOLOGICAL_LINKS_PATH=/restconf/data/zte-resources-module:TopologicalLinks
|
||||||
|
NETX_UME_SYNC_TOPOLOGY_AUTO_ENABLED=true
|
||||||
|
NETX_UME_SYNC_TOPOLOGY_EVERY_HOURS=24
|
||||||
|
# NETX_UME_TOPOLOGY_TIMEOUT_S=120
|
||||||
NETX_UME_SYNC_ALARMS_CURRENT_ENABLED=true
|
NETX_UME_SYNC_ALARMS_CURRENT_ENABLED=true
|
||||||
NETX_UME_SYNC_ALARMS_CURRENT_INTERVAL_S=18000
|
NETX_UME_SYNC_ALARMS_CURRENT_INTERVAL_S=18000
|
||||||
NETX_UME_SYNC_ALARMS_CURRENT_SKIP_WHEN_WS=true
|
NETX_UME_SYNC_ALARMS_CURRENT_SKIP_WHEN_WS=true
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,16 @@ class Settings(BaseSettings):
|
||||||
ume_notification_topic: str = "ALARM"
|
ume_notification_topic: str = "ALARM"
|
||||||
ume_sync_inventory_auto_enabled: bool = True
|
ume_sync_inventory_auto_enabled: bool = True
|
||||||
ume_sync_inventory_every_hours: int = 48
|
ume_sync_inventory_every_hours: int = 48
|
||||||
|
ume_sync_topology_auto_enabled: bool = True
|
||||||
|
ume_sync_topology_every_hours: int = 24
|
||||||
|
ume_topology_timeout_s: float = 120.0
|
||||||
ume_token_path: str = "/restconf/operations/zte-security:oauth_token"
|
ume_token_path: str = "/restconf/operations/zte-security:oauth_token"
|
||||||
ume_token_handshake_path: str = "/restconf/operations/zte-security:oauth_handshake"
|
ume_token_handshake_path: str = "/restconf/operations/zte-security:oauth_handshake"
|
||||||
ume_token_logout_path: str = "/restconf/operations/zte-security:oauth_token"
|
ume_token_logout_path: str = "/restconf/operations/zte-security:oauth_token"
|
||||||
ume_ne_path: str = "/restconf/data/zte-resources-module:network-elements"
|
ume_ne_path: str = "/restconf/data/zte-resources-module:network-elements"
|
||||||
ume_alarms_path: str = "/restconf/data/zte-alarms:alarms/alarm-list"
|
ume_alarms_path: str = "/restconf/data/zte-alarms:alarms/alarm-list"
|
||||||
|
ume_topo_nodes_path: str = "/restconf/data/zte-resources-module:TopoNodes"
|
||||||
|
ume_topological_links_path: str = "/restconf/data/zte-resources-module:TopologicalLinks"
|
||||||
ume_sync_alarms_history_every_hours: int = 24
|
ume_sync_alarms_history_every_hours: int = 24
|
||||||
# Managed NE credentials (Fernet key; generate with cryptography.fernet.Fernet.generate_key())
|
# Managed NE credentials (Fernet key; generate with cryptography.fernet.Fernet.generate_key())
|
||||||
credential_secret_key: str = ""
|
credential_secret_key: str = ""
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from .db import Base, SessionLocal, engine
|
||||||
from .importer import aggregate_alarms, query_alarms
|
from .importer import aggregate_alarms, query_alarms
|
||||||
from .models import AlarmBatch, UmeAlarmCurrent, UmeAlarmHistory, UmeInventoryNE
|
from .models import AlarmBatch, UmeAlarmCurrent, UmeAlarmHistory, UmeInventoryNE
|
||||||
from .ume_client import UMEClient
|
from .ume_client import UMEClient
|
||||||
from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full
|
from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full, sync_topology_full
|
||||||
from .ume_token_store import (
|
from .ume_token_store import (
|
||||||
clear_shared_token,
|
clear_shared_token,
|
||||||
load_shared_token,
|
load_shared_token,
|
||||||
|
|
@ -103,7 +103,7 @@ def _tool_list() -> list[dict[str, Any]]:
|
||||||
"properties": {
|
"properties": {
|
||||||
"domains": {
|
"domains": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"type": "string", "enum": ["inventory", "alarms_current", "alarms_history"]},
|
"items": {"type": "string", "enum": ["inventory", "alarms_current", "alarms_history", "topology"]},
|
||||||
},
|
},
|
||||||
"trigger_mode": {"type": "string", "enum": ["manual", "schedule"]},
|
"trigger_mode": {"type": "string", "enum": ["manual", "schedule"]},
|
||||||
},
|
},
|
||||||
|
|
@ -299,6 +299,18 @@ def _call_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||||
"error_message": str(j.error_message or ""),
|
"error_message": str(j.error_message or ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if "topology" in domains:
|
||||||
|
j = sync_topology_full(db, client, trigger_mode=trigger_mode)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"domain": "topology",
|
||||||
|
"status": j.status,
|
||||||
|
"pulled_count": int(j.pulled_count or 0),
|
||||||
|
"inserted_count": int(j.inserted_count or 0),
|
||||||
|
"updated_count": int(j.updated_count or 0),
|
||||||
|
"error_message": str(j.error_message or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
return {"content": [{"type": "text", "text": json.dumps({"ok": True, "jobs": results}, ensure_ascii=False)}]}
|
return {"content": [{"type": "text", "text": json.dumps({"ok": True, "jobs": results}, ensure_ascii=False)}]}
|
||||||
if name == "umeListNE":
|
if name == "umeListNE":
|
||||||
stmt = db.query(UmeInventoryNE)
|
stmt = db.query(UmeInventoryNE)
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ from .ume import (
|
||||||
UmeKeyAlertRule,
|
UmeKeyAlertRule,
|
||||||
UmeSyncJob,
|
UmeSyncJob,
|
||||||
UmeTokenCache,
|
UmeTokenCache,
|
||||||
|
UmeTopoLink,
|
||||||
|
UmeTopoNode,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -74,6 +76,8 @@ __all__ = [
|
||||||
"UmeKeyAlertForwardLog",
|
"UmeKeyAlertForwardLog",
|
||||||
"UmeAlarmSubscription",
|
"UmeAlarmSubscription",
|
||||||
"UmeTokenCache",
|
"UmeTokenCache",
|
||||||
|
"UmeTopoNode",
|
||||||
|
"UmeTopoLink",
|
||||||
"ManagedNE",
|
"ManagedNE",
|
||||||
"CliConnectProfile",
|
"CliConnectProfile",
|
||||||
"UmeCliOverride",
|
"UmeCliOverride",
|
||||||
|
|
|
||||||
|
|
@ -171,3 +171,45 @@ class UmeTokenCache(Base):
|
||||||
lock_owner: Mapped[str] = mapped_column(String(128), default="", index=True)
|
lock_owner: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||||
lock_expires_at_epoch_s: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
lock_expires_at_epoch_s: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UmeTopoNode(Base):
|
||||||
|
"""UME TopoNodes snapshot (coordinates / subnet tree). Phase-1 dock only."""
|
||||||
|
|
||||||
|
__tablename__ = "ume_topo_node"
|
||||||
|
|
||||||
|
node_id: Mapped[str] = mapped_column(String(128), primary_key=True, comment="UME nodeId")
|
||||||
|
name: Mapped[str] = mapped_column(String(512), default="", index=True)
|
||||||
|
node_type: Mapped[str] = mapped_column(String(64), default="", index=True, comment="TOPO_NODE_ME|TOPO_NODE_SBN")
|
||||||
|
user_label: Mapped[str] = mapped_column(String(512), default="", index=True)
|
||||||
|
owner: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
parent_node: Mapped[str] = mapped_column(String(512), default="", index=True)
|
||||||
|
x_pos: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
y_pos: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
ume_ne_id: Mapped[str] = mapped_column(String(128), default="", index=True, comment="ME uuid when nodeType=ME")
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
||||||
|
raw_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
|
||||||
|
|
||||||
|
class UmeTopoLink(Base):
|
||||||
|
"""UME TopologicalLinks snapshot. Phase-1 dock only (no Fabric apply yet)."""
|
||||||
|
|
||||||
|
__tablename__ = "ume_topo_link"
|
||||||
|
|
||||||
|
link_id: Mapped[str] = mapped_column(String(128), primary_key=True, comment="UME linkId")
|
||||||
|
name: Mapped[str] = mapped_column(String(1024), default="", index=True)
|
||||||
|
user_label: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
owner: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
direction: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
layer_rate: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
connection_status: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
|
a_end_tp_ref: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
z_end_tp_ref: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
a_ume_ne_id: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||||
|
z_ume_ne_id: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||||
|
a_ptp: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
z_ptp: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
||||||
|
raw_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ RT_WSS_ACTIVE_SKIP_REST = "rt:wss_active_skip_rest"
|
||||||
RT_PULLING_ALARMS_CURRENT = "rt:pulling_alarms_current"
|
RT_PULLING_ALARMS_CURRENT = "rt:pulling_alarms_current"
|
||||||
RT_ALARMS_SYNC_IN_PROGRESS_SKIP = "rt:alarms_sync_in_progress_skip"
|
RT_ALARMS_SYNC_IN_PROGRESS_SKIP = "rt:alarms_sync_in_progress_skip"
|
||||||
RT_PULLING_INVENTORY = "rt:pulling_inventory"
|
RT_PULLING_INVENTORY = "rt:pulling_inventory"
|
||||||
|
RT_PULLING_TOPOLOGY = "rt:pulling_topology"
|
||||||
RT_UME_WS_DISABLED_NO_BASE_URL = "rt:ume_ws_disabled_no_base_url"
|
RT_UME_WS_DISABLED_NO_BASE_URL = "rt:ume_ws_disabled_no_base_url"
|
||||||
RT_OCLAW_FWD_DISABLED = "rt:oclaw_fwd_disabled"
|
RT_OCLAW_FWD_DISABLED = "rt:oclaw_fwd_disabled"
|
||||||
RT_RESUMED_SYNC_SOON = "rt:resumed_sync_soon"
|
RT_RESUMED_SYNC_SOON = "rt:resumed_sync_soon"
|
||||||
|
|
|
||||||
|
|
@ -344,6 +344,48 @@ def apply_domain_schema_patches(conn: Connection) -> None:
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ume_cli_override_connect_status ON ume_cli_override (connect_status)",
|
"CREATE INDEX IF NOT EXISTS ix_ume_cli_override_connect_status ON ume_cli_override (connect_status)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS ume_topo_node (
|
||||||
|
node_id VARCHAR(128) PRIMARY KEY,
|
||||||
|
name VARCHAR(512) DEFAULT '',
|
||||||
|
node_type VARCHAR(64) DEFAULT '',
|
||||||
|
user_label VARCHAR(512) DEFAULT '',
|
||||||
|
owner VARCHAR(64) DEFAULT '',
|
||||||
|
parent_node VARCHAR(512) DEFAULT '',
|
||||||
|
x_pos INTEGER,
|
||||||
|
y_pos INTEGER,
|
||||||
|
ume_ne_id VARCHAR(128) DEFAULT '',
|
||||||
|
first_seen_at TIMESTAMP,
|
||||||
|
last_seen_at TIMESTAMP,
|
||||||
|
raw_json TEXT DEFAULT '{}'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_ume_topo_node_node_type ON ume_topo_node (node_type)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_ume_topo_node_ume_ne_id ON ume_topo_node (ume_ne_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_ume_topo_node_last_seen_at ON ume_topo_node (last_seen_at)",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS ume_topo_link (
|
||||||
|
link_id VARCHAR(128) PRIMARY KEY,
|
||||||
|
name VARCHAR(1024) DEFAULT '',
|
||||||
|
user_label TEXT DEFAULT '',
|
||||||
|
owner VARCHAR(64) DEFAULT '',
|
||||||
|
direction VARCHAR(32) DEFAULT '',
|
||||||
|
layer_rate INTEGER,
|
||||||
|
connection_status VARCHAR(64) DEFAULT '',
|
||||||
|
a_end_tp_ref TEXT DEFAULT '',
|
||||||
|
z_end_tp_ref TEXT DEFAULT '',
|
||||||
|
a_ume_ne_id VARCHAR(128) DEFAULT '',
|
||||||
|
z_ume_ne_id VARCHAR(128) DEFAULT '',
|
||||||
|
a_ptp VARCHAR(256) DEFAULT '',
|
||||||
|
z_ptp VARCHAR(256) DEFAULT '',
|
||||||
|
first_seen_at TIMESTAMP,
|
||||||
|
last_seen_at TIMESTAMP,
|
||||||
|
raw_json TEXT DEFAULT '{}'
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_ume_topo_link_a_ume_ne_id ON ume_topo_link (a_ume_ne_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_ume_topo_link_z_ume_ne_id ON ume_topo_link (z_ume_ne_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_ume_topo_link_last_seen_at ON ume_topo_link (last_seen_at)",
|
||||||
"COMMENT ON TABLE ume_inventory_ne IS '网元对象详细信息'",
|
"COMMENT ON TABLE ume_inventory_ne IS '网元对象详细信息'",
|
||||||
"COMMENT ON COLUMN ume_inventory_ne.ne_id IS '网元uuid'",
|
"COMMENT ON COLUMN ume_inventory_ne.ne_id IS '网元uuid'",
|
||||||
"COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'",
|
"COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'",
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,12 @@ class UMEClient:
|
||||||
self.token_logout_path = str(token_logout_path if token_logout_path is not None else settings.ume_token_logout_path).strip()
|
self.token_logout_path = str(token_logout_path if token_logout_path is not None else settings.ume_token_logout_path).strip()
|
||||||
self.ne_path = str(ne_path if ne_path is not None else settings.ume_ne_path).strip()
|
self.ne_path = str(ne_path if ne_path is not None else settings.ume_ne_path).strip()
|
||||||
self.alarms_path = str(alarms_path if alarms_path is not None else settings.ume_alarms_path).strip()
|
self.alarms_path = str(alarms_path if alarms_path is not None else settings.ume_alarms_path).strip()
|
||||||
|
self.topo_nodes_path = str(getattr(settings, "ume_topo_nodes_path", "") or "").strip() or (
|
||||||
|
"/restconf/data/zte-resources-module:TopoNodes"
|
||||||
|
)
|
||||||
|
self.topological_links_path = str(getattr(settings, "ume_topological_links_path", "") or "").strip() or (
|
||||||
|
"/restconf/data/zte-resources-module:TopologicalLinks"
|
||||||
|
)
|
||||||
self.notification_establish_path = str(
|
self.notification_establish_path = str(
|
||||||
notification_establish_path
|
notification_establish_path
|
||||||
if notification_establish_path is not None
|
if notification_establish_path is not None
|
||||||
|
|
@ -231,11 +237,12 @@ class UMEClient:
|
||||||
headers[self.auth_header] = token
|
headers[self.auth_header] = token
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
def _client(self) -> httpx.Client:
|
def _client(self, *, timeout_s: float | None = None) -> httpx.Client:
|
||||||
# Use explicit HTTPTransport to keep behavior consistent with onsite validation.
|
# Use explicit HTTPTransport to keep behavior consistent with onsite validation.
|
||||||
# In this mode, requests run over HTTP/1.1 and avoid HTTP/2 negotiation issues.
|
# In this mode, requests run over HTTP/1.1 and avoid HTTP/2 negotiation issues.
|
||||||
transport = httpx.HTTPTransport(verify=self.verify_tls, http2=False)
|
transport = httpx.HTTPTransport(verify=self.verify_tls, http2=False)
|
||||||
return httpx.Client(transport=transport, timeout=self.timeout_s)
|
to = self.timeout_s if timeout_s is None else max(3.0, float(timeout_s))
|
||||||
|
return httpx.Client(transport=transport, timeout=to)
|
||||||
|
|
||||||
|
|
||||||
def _extract_token_and_ttl(self, payload: dict[str, Any]) -> tuple[str, int | None]:
|
def _extract_token_and_ttl(self, payload: dict[str, Any]) -> tuple[str, int | None]:
|
||||||
|
|
@ -462,6 +469,7 @@ class UMEClient:
|
||||||
*,
|
*,
|
||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
body: dict[str, Any] | None = None,
|
body: dict[str, Any] | None = None,
|
||||||
|
timeout_s: float | None = None,
|
||||||
) -> tuple[dict[str, Any], RequestDiagnostics]:
|
) -> tuple[dict[str, Any], RequestDiagnostics]:
|
||||||
self.refresh_if_needed()
|
self.refresh_if_needed()
|
||||||
url = self._build_url(path)
|
url = self._build_url(path)
|
||||||
|
|
@ -469,12 +477,12 @@ class UMEClient:
|
||||||
retry_count = 0
|
retry_count = 0
|
||||||
t0 = time()
|
t0 = time()
|
||||||
try:
|
try:
|
||||||
with self._client() as client:
|
with self._client(timeout_s=timeout_s) as client:
|
||||||
resp = client.request(m, url, params=params, json=body, headers=self._headers(include_token=True))
|
resp = client.request(m, url, params=params, json=body, headers=self._headers(include_token=True))
|
||||||
if resp.status_code in (401, 403):
|
if resp.status_code in (401, 403):
|
||||||
retry_count = 1
|
retry_count = 1
|
||||||
self.login(force=True)
|
self.login(force=True)
|
||||||
with self._client() as client:
|
with self._client(timeout_s=timeout_s) as client:
|
||||||
resp = client.request(m, url, params=params, json=body, headers=self._headers(include_token=True))
|
resp = client.request(m, url, params=params, json=body, headers=self._headers(include_token=True))
|
||||||
marker = str(resp.headers.get("marker") or "").strip()
|
marker = str(resp.headers.get("marker") or "").strip()
|
||||||
is_end_raw = str(resp.headers.get("is-end-of-reply") or "").strip().lower()
|
is_end_raw = str(resp.headers.get("is-end-of-reply") or "").strip().lower()
|
||||||
|
|
@ -604,6 +612,38 @@ class UMEClient:
|
||||||
rows = self._extract_named_list(data, ["alarm-list", "alarm"])
|
rows = self._extract_named_list(data, ["alarm-list", "alarm"])
|
||||||
return rows, diag
|
return rows, diag
|
||||||
|
|
||||||
|
def _topology_timeout_s(self) -> float:
|
||||||
|
base = float(getattr(settings, "ume_topology_timeout_s", 0) or 0)
|
||||||
|
if base > 0:
|
||||||
|
return max(3.0, base)
|
||||||
|
return max(self.timeout_s, 60.0)
|
||||||
|
|
||||||
|
def get_topo_nodes(self) -> tuple[list[dict[str, Any]], RequestDiagnostics]:
|
||||||
|
data, diag = self.request_json("GET", self.topo_nodes_path, timeout_s=self._topology_timeout_s())
|
||||||
|
rows = self._extract_named_list(data, ["TopoNodes", "TopoNode", "topo-nodes", "topo-node"])
|
||||||
|
if rows:
|
||||||
|
return rows, diag
|
||||||
|
for v in data.values():
|
||||||
|
lst = _coerce_list(v)
|
||||||
|
if lst and isinstance(lst[0], dict):
|
||||||
|
return [x for x in lst if isinstance(x, dict)], diag
|
||||||
|
return [], diag
|
||||||
|
|
||||||
|
def get_topological_links(self) -> tuple[list[dict[str, Any]], RequestDiagnostics]:
|
||||||
|
data, diag = self.request_json(
|
||||||
|
"GET", self.topological_links_path, timeout_s=self._topology_timeout_s()
|
||||||
|
)
|
||||||
|
rows = self._extract_named_list(
|
||||||
|
data, ["TopologicalLinks", "TopologicalLink", "topological-links", "topological-link"]
|
||||||
|
)
|
||||||
|
if rows:
|
||||||
|
return rows, diag
|
||||||
|
for v in data.values():
|
||||||
|
lst = _coerce_list(v)
|
||||||
|
if lst and isinstance(lst[0], dict):
|
||||||
|
return [x for x in lst if isinstance(x, dict)], diag
|
||||||
|
return [], diag
|
||||||
|
|
||||||
def _extract_subscription_output(self, payload: dict[str, Any]) -> tuple[str, str]:
|
def _extract_subscription_output(self, payload: dict[str, Any]) -> tuple[str, str]:
|
||||||
sub_id = ""
|
sub_id = ""
|
||||||
uri = ""
|
uri = ""
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,14 @@ from .ume_alarm_ws import (
|
||||||
load_persisted_subscription,
|
load_persisted_subscription,
|
||||||
start_ume_alarm_ws_consumer,
|
start_ume_alarm_ws_consumer,
|
||||||
)
|
)
|
||||||
from .ume_sync_service import sync_alarms_current, sync_inventory_full
|
from .ume_sync_service import sync_alarms_current, sync_inventory_full, sync_topology_full
|
||||||
from .runtime_task_messages import (
|
from .runtime_task_messages import (
|
||||||
RT_ALARMS_SYNC_IN_PROGRESS_SKIP,
|
RT_ALARMS_SYNC_IN_PROGRESS_SKIP,
|
||||||
RT_KEEPALIVE_FAILED,
|
RT_KEEPALIVE_FAILED,
|
||||||
RT_OCLAW_FWD_DISABLED,
|
RT_OCLAW_FWD_DISABLED,
|
||||||
RT_PULLING_ALARMS_CURRENT,
|
RT_PULLING_ALARMS_CURRENT,
|
||||||
RT_PULLING_INVENTORY,
|
RT_PULLING_INVENTORY,
|
||||||
|
RT_PULLING_TOPOLOGY,
|
||||||
RT_STARTUP_GATE_WAITING,
|
RT_STARTUP_GATE_WAITING,
|
||||||
RT_UME_WS_DISABLED_NO_BASE_URL,
|
RT_UME_WS_DISABLED_NO_BASE_URL,
|
||||||
RT_WSS_ACTIVE_SKIP_REST,
|
RT_WSS_ACTIVE_SKIP_REST,
|
||||||
|
|
@ -286,6 +287,77 @@ def start_api_sideband_threads() -> None:
|
||||||
last_run_at=datetime.now(timezone.utc),
|
last_run_at=datetime.now(timezone.utc),
|
||||||
last_error=f"startup_thread_init_failed: {str(exc)[:180]}",
|
last_error=f"startup_thread_init_failed: {str(exc)[:180]}",
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
if bool(getattr(settings, "ume_sync_topology_auto_enabled", True)):
|
||||||
|
hours = int(getattr(settings, "ume_sync_topology_every_hours", 24) or 24)
|
||||||
|
hours = max(1, min(hours, 168))
|
||||||
|
topology_interval_s = int(hours * 3600)
|
||||||
|
ume_support._refresh_runtime_task_idle("topology_auto_sync", "topology")
|
||||||
|
|
||||||
|
def _topology_auto_sync_loop() -> None:
|
||||||
|
ume_support._refresh_runtime_task_idle("topology_auto_sync", "topology")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
_schedule_log.info(
|
||||||
|
"topology_auto_sync: loop tick paused=%s",
|
||||||
|
ume_support._runtime_is_paused("topology_auto_sync"),
|
||||||
|
)
|
||||||
|
if ume_support._runtime_is_paused("topology_auto_sync"):
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
ume_support._maybe_wait_for_sync_interval(
|
||||||
|
task_id="topology_auto_sync",
|
||||||
|
domain="topology",
|
||||||
|
interval_s=topology_interval_s,
|
||||||
|
label="topology_auto_sync",
|
||||||
|
)
|
||||||
|
_schedule_log.info(
|
||||||
|
"topology_auto_sync: iteration start (interval=%ss)",
|
||||||
|
topology_interval_s,
|
||||||
|
)
|
||||||
|
ume_support._set_runtime_task(
|
||||||
|
"topology_auto_sync",
|
||||||
|
status="running",
|
||||||
|
last_run_at=datetime.now(timezone.utc),
|
||||||
|
last_error=RT_PULLING_TOPOLOGY,
|
||||||
|
)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
client = ume_support._ume_client()
|
||||||
|
sync_topology_full(db, client, trigger_mode="schedule")
|
||||||
|
_schedule_log.info("topology_auto_sync: sync finished ok")
|
||||||
|
ume_support._set_runtime_task(
|
||||||
|
"topology_auto_sync",
|
||||||
|
status="running",
|
||||||
|
last_run_at=datetime.now(timezone.utc),
|
||||||
|
last_error="",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as exc:
|
||||||
|
_schedule_log.exception("topology_auto_sync: sync failed: %s", exc)
|
||||||
|
ume_support._set_runtime_task(
|
||||||
|
"topology_auto_sync",
|
||||||
|
status="error",
|
||||||
|
last_run_at=datetime.now(timezone.utc),
|
||||||
|
last_error=str(exc)[:240],
|
||||||
|
)
|
||||||
|
|
||||||
|
t_topo = threading.Thread(
|
||||||
|
target=_topology_auto_sync_loop, name="ume-topology-auto-sync", daemon=True
|
||||||
|
)
|
||||||
|
t_topo.start()
|
||||||
|
_schedule_log.info("started thread %s alive=%s", t_topo.name, t_topo.is_alive())
|
||||||
|
if not t_topo.is_alive():
|
||||||
|
_schedule_log.error("ume-topology-auto-sync thread exited immediately (check uncaught errors above)")
|
||||||
|
except Exception as exc:
|
||||||
|
_schedule_log.exception("startup: topology_auto_sync thread init failed: %s", exc)
|
||||||
|
ume_support._set_runtime_task(
|
||||||
|
"topology_auto_sync",
|
||||||
|
status="error",
|
||||||
|
last_run_at=datetime.now(timezone.utc),
|
||||||
|
last_error=f"startup_thread_init_failed: {str(exc)[:180]}",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
if bool(getattr(settings, "ume_alarm_ws_enabled", True)) and str(getattr(settings, "ume_base_url", "") or "").strip():
|
if bool(getattr(settings, "ume_alarm_ws_enabled", True)) and str(getattr(settings, "ume_base_url", "") or "").strip():
|
||||||
if load_persisted_subscription():
|
if load_persisted_subscription():
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ from .ume_alarm_ws import (
|
||||||
is_wss_active_for_current_alarms,
|
is_wss_active_for_current_alarms,
|
||||||
)
|
)
|
||||||
from .ume_client import UMEClient
|
from .ume_client import UMEClient
|
||||||
from .ume_sync_service import sync_alarms_current, sync_inventory_full
|
from .ume_sync_service import sync_alarms_current, sync_inventory_full, sync_topology_full
|
||||||
from .ume_token_store import (
|
from .ume_token_store import (
|
||||||
clear_shared_token,
|
clear_shared_token,
|
||||||
load_shared_token,
|
load_shared_token,
|
||||||
|
|
@ -66,6 +66,7 @@ _UME_RUNTIME_TASKS: dict[str, dict[str, Any]] = {
|
||||||
"alarms_current_ws_consumer": {"task": "alarms_current_ws_consumer", "status": "init", "last_run_at": None, "last_error": ""},
|
"alarms_current_ws_consumer": {"task": "alarms_current_ws_consumer", "status": "init", "last_run_at": None, "last_error": ""},
|
||||||
"oclaw_alarm_forwarder": {"task": "oclaw_alarm_forwarder", "status": "init", "last_run_at": None, "last_error": ""},
|
"oclaw_alarm_forwarder": {"task": "oclaw_alarm_forwarder", "status": "init", "last_run_at": None, "last_error": ""},
|
||||||
"inventory_auto_sync": {"task": "inventory_auto_sync", "status": "init", "last_run_at": None, "last_error": ""},
|
"inventory_auto_sync": {"task": "inventory_auto_sync", "status": "init", "last_run_at": None, "last_error": ""},
|
||||||
|
"topology_auto_sync": {"task": "topology_auto_sync", "status": "init", "last_run_at": None, "last_error": ""},
|
||||||
}
|
}
|
||||||
_UME_WS_STOP_EVENT: threading.Event | None = None
|
_UME_WS_STOP_EVENT: threading.Event | None = None
|
||||||
_UME_RUNTIME_PAUSED: dict[str, bool] = {}
|
_UME_RUNTIME_PAUSED: dict[str, bool] = {}
|
||||||
|
|
@ -185,6 +186,13 @@ def _runtime_task_interval_fields(task_id: str) -> tuple[int | None, str]:
|
||||||
hours = max(1, min(hours, 168))
|
hours = max(1, min(hours, 168))
|
||||||
eff = int(hours * 3600)
|
eff = int(hours * 3600)
|
||||||
return eff, _format_runtime_interval_label(eff)
|
return eff, _format_runtime_interval_label(eff)
|
||||||
|
if task_id == "topology_auto_sync":
|
||||||
|
if not bool(getattr(settings, "ume_sync_topology_auto_enabled", True)):
|
||||||
|
return None, "disabled"
|
||||||
|
hours = int(getattr(settings, "ume_sync_topology_every_hours", 24) or 24)
|
||||||
|
hours = max(1, min(hours, 168))
|
||||||
|
eff = int(hours * 3600)
|
||||||
|
return eff, _format_runtime_interval_label(eff)
|
||||||
return None, "—"
|
return None, "—"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ from .ume_support import (
|
||||||
_set_runtime_task,
|
_set_runtime_task,
|
||||||
_ume_client,
|
_ume_client,
|
||||||
)
|
)
|
||||||
from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full
|
from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full, sync_topology_full
|
||||||
|
|
||||||
_log = logging.getLogger("netx.ume.router")
|
_log = logging.getLogger("netx.ume.router")
|
||||||
router = APIRouter(tags=["ume"])
|
router = APIRouter(tags=["ume"])
|
||||||
|
|
@ -65,6 +65,18 @@ def ume_sync(payload: dict[str, Any] | None = None, db: Session = Depends(get_db
|
||||||
"error_message": str(job.error_message or ""),
|
"error_message": str(job.error_message or ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if "topology" in domain_set:
|
||||||
|
job = sync_topology_full(db, client, trigger_mode=trigger_mode)
|
||||||
|
out["jobs"].append(
|
||||||
|
{
|
||||||
|
"domain": "topology",
|
||||||
|
"status": job.status,
|
||||||
|
"pulled_count": int(job.pulled_count or 0),
|
||||||
|
"inserted_count": int(job.inserted_count or 0),
|
||||||
|
"updated_count": int(job.updated_count or 0),
|
||||||
|
"error_message": str(job.error_message or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
if "alarms" in domain_set or "alarms_current" in domain_set:
|
if "alarms" in domain_set or "alarms_current" in domain_set:
|
||||||
paused_ws_for_sync = False
|
paused_ws_for_sync = False
|
||||||
if is_wss_active_for_current_alarms() and trigger_mode == "manual":
|
if is_wss_active_for_current_alarms() and trigger_mode == "manual":
|
||||||
|
|
@ -123,7 +135,7 @@ def _ume_sync_job_deleted_count(row: UmeSyncJob) -> int:
|
||||||
return 0
|
return 0
|
||||||
if not isinstance(obj, dict):
|
if not isinstance(obj, dict):
|
||||||
return 0
|
return 0
|
||||||
inv = cur = 0
|
inv = cur = topo_n = topo_l = 0
|
||||||
try:
|
try:
|
||||||
inv = max(0, int(obj.get("deleted_inventory_ne") or 0))
|
inv = max(0, int(obj.get("deleted_inventory_ne") or 0))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -132,7 +144,15 @@ def _ume_sync_job_deleted_count(row: UmeSyncJob) -> int:
|
||||||
cur = max(0, int(obj.get("deleted_stale_current_alarms") or 0))
|
cur = max(0, int(obj.get("deleted_stale_current_alarms") or 0))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return int(inv + cur)
|
try:
|
||||||
|
topo_n = max(0, int(obj.get("deleted_topo_nodes") or 0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
topo_l = max(0, int(obj.get("deleted_topo_links") or 0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return int(inv + cur + topo_n + topo_l)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/v1/ume/sync/status")
|
@router.get("/v1/ume/sync/status")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""UME sync service facade (inventory + alarms)."""
|
"""UME sync service facade (inventory + alarms + topology)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
@ -13,6 +13,7 @@ from .ume_alarm_apply import (
|
||||||
)
|
)
|
||||||
from .ume_sync_common import _pick, _s, _utc_now_naive
|
from .ume_sync_common import _pick, _s, _utc_now_naive
|
||||||
from .ume_sync_pull import sync_alarms_current, sync_alarms_history_full, sync_inventory_full
|
from .ume_sync_pull import sync_alarms_current, sync_alarms_history_full, sync_inventory_full
|
||||||
|
from .ume_sync_topology import sync_topology_full
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"_alarm_key",
|
"_alarm_key",
|
||||||
|
|
@ -29,4 +30,5 @@ __all__ = [
|
||||||
"sync_alarms_current",
|
"sync_alarms_current",
|
||||||
"sync_alarms_history_full",
|
"sync_alarms_history_full",
|
||||||
"sync_inventory_full",
|
"sync_inventory_full",
|
||||||
|
"sync_topology_full",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
263
netx_api/ume_sync_topology.py
Normal file
263
netx_api/ume_sync_topology.py
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
"""UME TopoNodes + TopologicalLinks sync (phase-1: local tables only)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import UmeSyncJob, UmeTopoLink, UmeTopoNode
|
||||||
|
from .ume_client import UMEClient
|
||||||
|
from .ume_raw import dumps_ume_raw
|
||||||
|
from .ume_sync_common import _pick, _s, _utc_now_naive
|
||||||
|
from .ume_sync_pull import _build_sync_job
|
||||||
|
|
||||||
|
_sync_log = logging.getLogger("netx.ume.sync")
|
||||||
|
|
||||||
|
_UUID_RE = re.compile(
|
||||||
|
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
||||||
|
)
|
||||||
|
_ME_BRACE_RE = re.compile(
|
||||||
|
r"ME\{([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\}",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_PTP_RE = re.compile(r"PTP=\{([^}]*)\}", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_me_uuid(text: str) -> str:
|
||||||
|
"""Extract managed-element uuid from TP ref or TOPO_NODE_ME* name."""
|
||||||
|
s = str(text or "").strip()
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
m = _ME_BRACE_RE.search(s)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
# TOPO_NODE_ME<uuid> (no braces) or trailing uuid
|
||||||
|
low = s.upper()
|
||||||
|
if "TOPO_NODE_ME" in low:
|
||||||
|
idx = low.find("TOPO_NODE_ME")
|
||||||
|
rest = s[idx + len("TOPO_NODE_ME") :]
|
||||||
|
m2 = _UUID_RE.search(rest)
|
||||||
|
if m2:
|
||||||
|
return m2.group(0)
|
||||||
|
m3 = _UUID_RE.search(s)
|
||||||
|
return m3.group(0) if m3 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_ptp(text: str) -> str:
|
||||||
|
s = str(text or "").strip()
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
m = _PTP_RE.search(s)
|
||||||
|
return (m.group(1) if m else "")[:256]
|
||||||
|
|
||||||
|
|
||||||
|
def _first_tp_ref(value: Any) -> str:
|
||||||
|
if isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
s = _s(item)
|
||||||
|
if s:
|
||||||
|
return s
|
||||||
|
return ""
|
||||||
|
return _s(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_optional_int(value: Any) -> int | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _link_id_from_row(row: dict[str, Any]) -> str:
|
||||||
|
lid = _s(_pick(row, "linkId", "link-id", "link_id", "id"))
|
||||||
|
if lid:
|
||||||
|
return lid[:128]
|
||||||
|
name = _s(_pick(row, "name"))
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
digest = hashlib.sha1(name.encode("utf-8", errors="replace")).hexdigest()[:40]
|
||||||
|
return f"name:{digest}"[:128]
|
||||||
|
|
||||||
|
|
||||||
|
def _node_id_from_row(row: dict[str, Any]) -> str:
|
||||||
|
nid = _s(_pick(row, "nodeId", "node-id", "node_id", "id"))
|
||||||
|
if nid:
|
||||||
|
return nid[:128]
|
||||||
|
name = _s(_pick(row, "name"))
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
digest = hashlib.sha1(name.encode("utf-8", errors="replace")).hexdigest()[:40]
|
||||||
|
return f"name:{digest}"[:128]
|
||||||
|
|
||||||
|
|
||||||
|
def _ume_ne_id_for_topo_node(*, node_type: str, name: str) -> str:
|
||||||
|
nt = str(node_type or "").strip().upper()
|
||||||
|
if nt != "TOPO_NODE_ME":
|
||||||
|
return ""
|
||||||
|
return extract_me_uuid(name)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_topology_full(db: Session, client: UMEClient, *, trigger_mode: str = "manual") -> UmeSyncJob:
|
||||||
|
"""Pull TopoNodes + TopologicalLinks into local tables (no Fabric apply)."""
|
||||||
|
job = _build_sync_job("topology", trigger_mode)
|
||||||
|
db.add(job)
|
||||||
|
db.flush()
|
||||||
|
db.commit()
|
||||||
|
_sync_log.info("topology sync job %s committed as running (trigger=%s)", getattr(job, "id", "?"), trigger_mode)
|
||||||
|
|
||||||
|
pulled = inserted = updated = 0
|
||||||
|
nodes_pulled = nodes_ins = nodes_upd = nodes_del = 0
|
||||||
|
links_pulled = links_ins = links_upd = links_del = 0
|
||||||
|
nodes_skip = links_skip = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = _utc_now_naive()
|
||||||
|
|
||||||
|
node_rows, node_diag = client.get_topo_nodes()
|
||||||
|
nodes_pulled = len(node_rows)
|
||||||
|
seen_nodes: set[str] = set()
|
||||||
|
for row in node_rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
nodes_skip += 1
|
||||||
|
continue
|
||||||
|
node_id = _node_id_from_row(row)
|
||||||
|
if not node_id:
|
||||||
|
nodes_skip += 1
|
||||||
|
continue
|
||||||
|
seen_nodes.add(node_id)
|
||||||
|
name = _s(_pick(row, "name"))
|
||||||
|
node_type = _s(_pick(row, "nodeType", "node-type", "node_type"))
|
||||||
|
existing = db.get(UmeTopoNode, node_id)
|
||||||
|
if existing is None:
|
||||||
|
existing = UmeTopoNode(node_id=node_id, first_seen_at=now)
|
||||||
|
db.add(existing)
|
||||||
|
nodes_ins += 1
|
||||||
|
else:
|
||||||
|
nodes_upd += 1
|
||||||
|
existing.name = name[:512]
|
||||||
|
existing.node_type = node_type[:64]
|
||||||
|
existing.user_label = _s(_pick(row, "userLabel", "user-label", "user_label"))[:512]
|
||||||
|
existing.owner = _s(_pick(row, "owner"))[:64]
|
||||||
|
existing.parent_node = _s(_pick(row, "parentNode", "parent-node", "parent_node"))[:512]
|
||||||
|
existing.x_pos = _as_optional_int(_pick(row, "xPos", "x-pos", "x_pos"))
|
||||||
|
existing.y_pos = _as_optional_int(_pick(row, "yPos", "y-pos", "y_pos"))
|
||||||
|
existing.ume_ne_id = _ume_ne_id_for_topo_node(node_type=node_type, name=name)[:128]
|
||||||
|
existing.last_seen_at = now
|
||||||
|
existing.raw_json = dumps_ume_raw(row)
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
if seen_nodes:
|
||||||
|
nodes_del = int(
|
||||||
|
db.query(UmeTopoNode)
|
||||||
|
.filter(~UmeTopoNode.node_id.in_(list(seen_nodes)))
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Successful empty snapshot → clear local table.
|
||||||
|
nodes_del = int(db.query(UmeTopoNode).delete(synchronize_session=False))
|
||||||
|
|
||||||
|
link_rows, link_diag = client.get_topological_links()
|
||||||
|
links_pulled = len(link_rows)
|
||||||
|
seen_links: set[str] = set()
|
||||||
|
for row in link_rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
links_skip += 1
|
||||||
|
continue
|
||||||
|
link_id = _link_id_from_row(row)
|
||||||
|
if not link_id:
|
||||||
|
links_skip += 1
|
||||||
|
continue
|
||||||
|
seen_links.add(link_id)
|
||||||
|
a_ref = _first_tp_ref(_pick(row, "aEndTpRefList", "a-end-tp-ref-list", "aEndTpRef"))
|
||||||
|
z_ref = _first_tp_ref(_pick(row, "zEndTpRefList", "z-end-tp-ref-list", "zEndTpRef"))
|
||||||
|
existing = db.get(UmeTopoLink, link_id)
|
||||||
|
if existing is None:
|
||||||
|
existing = UmeTopoLink(link_id=link_id, first_seen_at=now)
|
||||||
|
db.add(existing)
|
||||||
|
links_ins += 1
|
||||||
|
else:
|
||||||
|
links_upd += 1
|
||||||
|
existing.name = _s(_pick(row, "name"))[:1024]
|
||||||
|
existing.user_label = _s(_pick(row, "userLabel", "user-label", "user_label"))
|
||||||
|
existing.owner = _s(_pick(row, "owner"))[:64]
|
||||||
|
existing.direction = _s(_pick(row, "direction"))[:32]
|
||||||
|
existing.layer_rate = _as_optional_int(_pick(row, "layerRate", "layer-rate", "layer_rate"))
|
||||||
|
existing.connection_status = _s(
|
||||||
|
_pick(row, "connection-status", "connectionStatus", "connection_status")
|
||||||
|
)[:64]
|
||||||
|
existing.a_end_tp_ref = a_ref
|
||||||
|
existing.z_end_tp_ref = z_ref
|
||||||
|
existing.a_ume_ne_id = extract_me_uuid(a_ref)[:128]
|
||||||
|
existing.z_ume_ne_id = extract_me_uuid(z_ref)[:128]
|
||||||
|
existing.a_ptp = extract_ptp(a_ref)
|
||||||
|
existing.z_ptp = extract_ptp(z_ref)
|
||||||
|
existing.last_seen_at = now
|
||||||
|
existing.raw_json = dumps_ume_raw(row)
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
if seen_links:
|
||||||
|
links_del = int(
|
||||||
|
db.query(UmeTopoLink)
|
||||||
|
.filter(~UmeTopoLink.link_id.in_(list(seen_links)))
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
links_del = int(db.query(UmeTopoLink).delete(synchronize_session=False))
|
||||||
|
|
||||||
|
pulled = nodes_pulled + links_pulled
|
||||||
|
inserted = nodes_ins + links_ins
|
||||||
|
updated = nodes_upd + links_upd
|
||||||
|
|
||||||
|
job.details_json = json.dumps(
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"pulled": nodes_pulled,
|
||||||
|
"inserted": nodes_ins,
|
||||||
|
"updated": nodes_upd,
|
||||||
|
"deleted": nodes_del,
|
||||||
|
"skipped": nodes_skip,
|
||||||
|
"latency_ms": int(getattr(node_diag, "latency_ms", 0) or 0),
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"pulled": links_pulled,
|
||||||
|
"inserted": links_ins,
|
||||||
|
"updated": links_upd,
|
||||||
|
"deleted": links_del,
|
||||||
|
"skipped": links_skip,
|
||||||
|
"latency_ms": int(getattr(link_diag, "latency_ms", 0) or 0),
|
||||||
|
},
|
||||||
|
"deleted_topo_nodes": nodes_del,
|
||||||
|
"deleted_topo_links": links_del,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
job.status = "done"
|
||||||
|
_sync_log.info(
|
||||||
|
"topology sync done nodes=%s/%s/%s links=%s/%s/%s deleted_n=%s deleted_l=%s",
|
||||||
|
nodes_pulled,
|
||||||
|
nodes_ins,
|
||||||
|
nodes_upd,
|
||||||
|
links_pulled,
|
||||||
|
links_ins,
|
||||||
|
links_upd,
|
||||||
|
nodes_del,
|
||||||
|
links_del,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
job.status = "failed"
|
||||||
|
job.error_message = str(exc)[:1024]
|
||||||
|
_sync_log.exception("topology sync failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
job.pulled_count = int(pulled)
|
||||||
|
job.inserted_count = int(inserted)
|
||||||
|
job.updated_count = int(updated)
|
||||||
|
job.ended_at = _utc_now_naive()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
return job
|
||||||
|
|
@ -10,7 +10,7 @@ from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from netx_api.db import Base
|
from netx_api.db import Base
|
||||||
from netx_api.main import _extract_ume_raw_group_field, _serialize_ume_alarm_raw_row, sql_ume_query, ume_alarms_fields
|
from netx_api.main import _extract_ume_raw_group_field, _serialize_ume_alarm_raw_row, sql_ume_query, ume_alarms_fields
|
||||||
from netx_api.models import UmeAlarmCurrent, UmeInventoryNE
|
from netx_api.models import UmeAlarmCurrent, UmeInventoryNE, UmeTopoLink, UmeTopoNode
|
||||||
from netx_api.ume_client import UMEClient, _parse_json_response
|
from netx_api.ume_client import UMEClient, _parse_json_response
|
||||||
from netx_api import ume_alarm_ws
|
from netx_api import ume_alarm_ws
|
||||||
from netx_api.models import UmeAlarmSubscription
|
from netx_api.models import UmeAlarmSubscription
|
||||||
|
|
@ -36,7 +36,9 @@ from netx_api.ume_sync_service import (
|
||||||
normalize_yang_alarm,
|
normalize_yang_alarm,
|
||||||
sync_alarms_current,
|
sync_alarms_current,
|
||||||
sync_inventory_full,
|
sync_inventory_full,
|
||||||
|
sync_topology_full,
|
||||||
)
|
)
|
||||||
|
from netx_api.ume_sync_topology import extract_me_uuid, extract_ptp
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1088,5 +1090,159 @@ class UmeAlarmNotificationTests(unittest.TestCase):
|
||||||
self.assertIsNotNone(self.db.get(UmeAlarmCurrent, "AK-WS-3"))
|
self.assertIsNotNone(self.db.get(UmeAlarmCurrent, "AK-WS-3"))
|
||||||
|
|
||||||
|
|
||||||
|
class UmeTopologySyncTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||||||
|
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
self.db = TestingSessionLocal()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def test_extract_me_and_ptp(self):
|
||||||
|
tp = "ME{4e598e5d-fe42-4c79-9f62-7d3e5d4eb5b2},EQ={/r=0/sh=1/sl=1},PTP={/p=1_16}"
|
||||||
|
self.assertEqual(extract_me_uuid(tp), "4e598e5d-fe42-4c79-9f62-7d3e5d4eb5b2")
|
||||||
|
self.assertEqual(extract_ptp(tp), "/p=1_16")
|
||||||
|
self.assertEqual(
|
||||||
|
extract_me_uuid("TOPO_NODE_ME7e8ac1c7-9d34-42d3-adfb-b1031e7c145a"),
|
||||||
|
"7e8ac1c7-9d34-42d3-adfb-b1031e7c145a",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sync_topology_upsert_and_reconcile(self):
|
||||||
|
class _Diag:
|
||||||
|
latency_ms = 1
|
||||||
|
|
||||||
|
class _CWide:
|
||||||
|
def get_topo_nodes(self):
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"nodeId": "66e807c0-94b6-4c50-a02e-fcb56d08bdea",
|
||||||
|
"name": "SBN{66e807c0-94b6-4c50-a02e-fcb56d08bdea}",
|
||||||
|
"nodeType": "TOPO_NODE_SBN",
|
||||||
|
"owner": "ZTE",
|
||||||
|
"userLabel": "SC IPRAN Network",
|
||||||
|
"yPos": 126,
|
||||||
|
"xPos": 460,
|
||||||
|
"parentNode": "topLevel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nodeId": "me-node-1",
|
||||||
|
"name": "TOPO_NODE_ME4e598e5d-fe42-4c79-9f62-7d3e5d4eb5b2",
|
||||||
|
"nodeType": "TOPO_NODE_ME",
|
||||||
|
"userLabel": "RMP01",
|
||||||
|
"xPos": 10,
|
||||||
|
"yPos": 20,
|
||||||
|
"parentNode": "66e807c0-94b6-4c50-a02e-fcb56d08bdea",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
_Diag(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_topological_links(self):
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"linkId": "415a0bbb-9387-4ff3-8de9-7e36846f4b6a",
|
||||||
|
"name": "TL{/ME{4e598e5d-fe42-4c79-9f62-7d3e5d4eb5b2},EQ={/r=0/sh=1/sl=1},PTP={/p=1_16}_/ME{7508cb6c-59f6-45aa-9e62-4fda61d80553},EQ={/r=0/sh=0/sl=1/ssl=0},PTP={/p=1_20}}",
|
||||||
|
"connection-status": "Connected",
|
||||||
|
"owner": "ZTE",
|
||||||
|
"aEndTpRefList": [
|
||||||
|
"ME{4e598e5d-fe42-4c79-9f62-7d3e5d4eb5b2},EQ={/r=0/sh=1/sl=1},PTP={/p=1_16}"
|
||||||
|
],
|
||||||
|
"userLabel": "sample-label",
|
||||||
|
"direction": "BI",
|
||||||
|
"layerRate": 113,
|
||||||
|
"zEndTpRefList": [
|
||||||
|
"ME{7508cb6c-59f6-45aa-9e62-4fda61d80553},EQ={/r=0/sh=0/sl=1/ssl=0},PTP={/p=1_20}"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"linkId": "link-to-drop",
|
||||||
|
"name": "TL-drop",
|
||||||
|
"aEndTpRefList": ["ME{aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa},EQ={},PTP={/p=1}"],
|
||||||
|
"zEndTpRefList": ["ME{bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb},EQ={},PTP={/p=2}"],
|
||||||
|
"direction": "BI",
|
||||||
|
"layerRate": 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
_Diag(),
|
||||||
|
)
|
||||||
|
|
||||||
|
class _CNarrow:
|
||||||
|
def get_topo_nodes(self):
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"nodeId": "me-node-1",
|
||||||
|
"name": "TOPO_NODE_ME4e598e5d-fe42-4c79-9f62-7d3e5d4eb5b2",
|
||||||
|
"nodeType": "TOPO_NODE_ME",
|
||||||
|
"userLabel": "RMP01-upd",
|
||||||
|
"xPos": 11,
|
||||||
|
"yPos": 21,
|
||||||
|
"parentNode": "topLevel",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
_Diag(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_topological_links(self):
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"linkId": "415a0bbb-9387-4ff3-8de9-7e36846f4b6a",
|
||||||
|
"name": "TL-keep",
|
||||||
|
"connection-status": "Connected",
|
||||||
|
"aEndTpRefList": [
|
||||||
|
"ME{4e598e5d-fe42-4c79-9f62-7d3e5d4eb5b2},EQ={/r=0/sh=1/sl=1},PTP={/p=1_16}"
|
||||||
|
],
|
||||||
|
"zEndTpRefList": [
|
||||||
|
"ME{7508cb6c-59f6-45aa-9e62-4fda61d80553},EQ={/r=0/sh=0/sl=1/ssl=0},PTP={/p=1_20}"
|
||||||
|
],
|
||||||
|
"direction": "BI",
|
||||||
|
"layerRate": 113,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
_Diag(),
|
||||||
|
)
|
||||||
|
|
||||||
|
job1 = sync_topology_full(self.db, _CWide(), trigger_mode="manual")
|
||||||
|
self.assertEqual(job1.status, "done")
|
||||||
|
self.assertEqual(job1.pulled_count, 4)
|
||||||
|
self.assertEqual(job1.inserted_count, 4)
|
||||||
|
|
||||||
|
sbn = self.db.get(UmeTopoNode, "66e807c0-94b6-4c50-a02e-fcb56d08bdea")
|
||||||
|
self.assertIsNotNone(sbn)
|
||||||
|
self.assertEqual(sbn.node_type, "TOPO_NODE_SBN")
|
||||||
|
self.assertEqual(sbn.x_pos, 460)
|
||||||
|
self.assertEqual(sbn.ume_ne_id, "")
|
||||||
|
|
||||||
|
me = self.db.get(UmeTopoNode, "me-node-1")
|
||||||
|
self.assertIsNotNone(me)
|
||||||
|
self.assertEqual(me.ume_ne_id, "4e598e5d-fe42-4c79-9f62-7d3e5d4eb5b2")
|
||||||
|
|
||||||
|
link = self.db.get(UmeTopoLink, "415a0bbb-9387-4ff3-8de9-7e36846f4b6a")
|
||||||
|
self.assertIsNotNone(link)
|
||||||
|
self.assertEqual(link.a_ume_ne_id, "4e598e5d-fe42-4c79-9f62-7d3e5d4eb5b2")
|
||||||
|
self.assertEqual(link.z_ume_ne_id, "7508cb6c-59f6-45aa-9e62-4fda61d80553")
|
||||||
|
self.assertEqual(link.a_ptp, "/p=1_16")
|
||||||
|
self.assertEqual(link.z_ptp, "/p=1_20")
|
||||||
|
self.assertEqual(link.connection_status, "Connected")
|
||||||
|
self.assertIsNotNone(self.db.get(UmeTopoLink, "link-to-drop"))
|
||||||
|
|
||||||
|
job2 = sync_topology_full(self.db, _CNarrow(), trigger_mode="manual")
|
||||||
|
self.assertEqual(job2.status, "done")
|
||||||
|
self.db.expire_all()
|
||||||
|
self.assertIsNone(self.db.get(UmeTopoNode, "66e807c0-94b6-4c50-a02e-fcb56d08bdea"))
|
||||||
|
self.assertIsNotNone(self.db.get(UmeTopoNode, "me-node-1"))
|
||||||
|
self.assertEqual(self.db.get(UmeTopoNode, "me-node-1").user_label, "RMP01-upd")
|
||||||
|
self.assertIsNone(self.db.get(UmeTopoLink, "link-to-drop"))
|
||||||
|
self.assertIsNotNone(self.db.get(UmeTopoLink, "415a0bbb-9387-4ff3-8de9-7e36846f4b6a"))
|
||||||
|
details = json.loads(job2.details_json or "{}")
|
||||||
|
self.assertEqual(details.get("deleted_topo_nodes"), 1)
|
||||||
|
self.assertEqual(details.get("deleted_topo_links"), 1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -1055,6 +1055,7 @@ const en = {
|
||||||
pulling_alarms_current: "Pulling UME current alarms…",
|
pulling_alarms_current: "Pulling UME current alarms…",
|
||||||
alarms_sync_in_progress_skip: "Another current-alarm REST sync in progress; skipped",
|
alarms_sync_in_progress_skip: "Another current-alarm REST sync in progress; skipped",
|
||||||
pulling_inventory: "Pulling UME inventory…",
|
pulling_inventory: "Pulling UME inventory…",
|
||||||
|
pulling_topology: "Pulling UME topology (nodes/links)…",
|
||||||
ume_ws_disabled_no_base_url: "Disabled or UME_BASE_URL not configured",
|
ume_ws_disabled_no_base_url: "Disabled or UME_BASE_URL not configured",
|
||||||
oclaw_fwd_disabled: "Disabled or NETX_OCLAW_ALARM_WS / token / url not configured",
|
oclaw_fwd_disabled: "Disabled or NETX_OCLAW_ALARM_WS / token / url not configured",
|
||||||
resumed_sync_soon: "Resumed: skipping debounce wait, sync soon",
|
resumed_sync_soon: "Resumed: skipping debounce wait, sync soon",
|
||||||
|
|
@ -1092,6 +1093,7 @@ const en = {
|
||||||
alarms_current_ws_consumer: "UME alarm WSS",
|
alarms_current_ws_consumer: "UME alarm WSS",
|
||||||
oclaw_alarm_forwarder: "OClaw key-alert WSS",
|
oclaw_alarm_forwarder: "OClaw key-alert WSS",
|
||||||
inventory_auto_sync: "Inventory auto sync",
|
inventory_auto_sync: "Inventory auto sync",
|
||||||
|
topology_auto_sync: "Topology auto sync",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
syncStatus: {
|
syncStatus: {
|
||||||
|
|
|
||||||
|
|
@ -1048,6 +1048,7 @@ const zh = {
|
||||||
pulling_alarms_current: "正在拉取 UME 当前告警…",
|
pulling_alarms_current: "正在拉取 UME 当前告警…",
|
||||||
alarms_sync_in_progress_skip: "另一条当前告警 REST 同步进行中,已跳过",
|
alarms_sync_in_progress_skip: "另一条当前告警 REST 同步进行中,已跳过",
|
||||||
pulling_inventory: "正在拉取 UME 网元清单…",
|
pulling_inventory: "正在拉取 UME 网元清单…",
|
||||||
|
pulling_topology: "正在拉取 UME 拓扑(节点/链路)…",
|
||||||
ume_ws_disabled_no_base_url: "未启用或未配置 UME_BASE_URL",
|
ume_ws_disabled_no_base_url: "未启用或未配置 UME_BASE_URL",
|
||||||
oclaw_fwd_disabled: "未启用或未配置 NETX_OCLAW_ALARM_WS / token / url",
|
oclaw_fwd_disabled: "未启用或未配置 NETX_OCLAW_ALARM_WS / token / url",
|
||||||
resumed_sync_soon: "已恢复:将跳过本轮周期等待并尽快同步",
|
resumed_sync_soon: "已恢复:将跳过本轮周期等待并尽快同步",
|
||||||
|
|
@ -1085,6 +1086,7 @@ const zh = {
|
||||||
alarms_current_ws_consumer: "UME 告警 WSS",
|
alarms_current_ws_consumer: "UME 告警 WSS",
|
||||||
oclaw_alarm_forwarder: "OClaw 关键告警 WSS",
|
oclaw_alarm_forwarder: "OClaw 关键告警 WSS",
|
||||||
inventory_auto_sync: "Inventory 定时同步",
|
inventory_auto_sync: "Inventory 定时同步",
|
||||||
|
topology_auto_sync: "拓扑定时同步",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
syncStatus: {
|
syncStatus: {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const LEGACY_ZH_RUNTIME_ERROR: Record<string, string> = {
|
||||||
"正在拉取 UME 当前告警…": "ume.tasks.runtimeError.pulling_alarms_current",
|
"正在拉取 UME 当前告警…": "ume.tasks.runtimeError.pulling_alarms_current",
|
||||||
"另一条当前告警 REST 同步进行中,已跳过": "ume.tasks.runtimeError.alarms_sync_in_progress_skip",
|
"另一条当前告警 REST 同步进行中,已跳过": "ume.tasks.runtimeError.alarms_sync_in_progress_skip",
|
||||||
"正在拉取 UME 网元清单…": "ume.tasks.runtimeError.pulling_inventory",
|
"正在拉取 UME 网元清单…": "ume.tasks.runtimeError.pulling_inventory",
|
||||||
|
"正在拉取 UME 拓扑(节点/链路)…": "ume.tasks.runtimeError.pulling_topology",
|
||||||
"未启用或未配置 UME_BASE_URL": "ume.tasks.runtimeError.ume_ws_disabled_no_base_url",
|
"未启用或未配置 UME_BASE_URL": "ume.tasks.runtimeError.ume_ws_disabled_no_base_url",
|
||||||
"未启用或未配置 NETX_OCLAW_ALARM_WS / token / url": "ume.tasks.runtimeError.oclaw_fwd_disabled",
|
"未启用或未配置 NETX_OCLAW_ALARM_WS / token / url": "ume.tasks.runtimeError.oclaw_fwd_disabled",
|
||||||
"已恢复:将跳过本轮周期等待并尽快同步": "ume.tasks.runtimeError.resumed_sync_soon",
|
"已恢复:将跳过本轮周期等待并尽快同步": "ume.tasks.runtimeError.resumed_sync_soon",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue