"""Cluster discovery built on top of the PLS API.""" from __future__ import annotations import asyncio import re from app.config import ALL_NFS from app.services import pls, prometheus _last_inventory: dict | None = None ROLE_NF_MAP = { "5GALL": {"amf", "smf", "pcf", "udr", "udm", "nrf", "eir", "ausf", "dra", "upf", "chf", "smsf", "aaa", "bmsc"}, "CP": {"amf", "smf", "pcf", "udr", "udm", "nrf", "eir", "ausf", "dra", "chf", "smsf", "aaa", "bmsc"}, "UP": {"upf"}, "DCP": {"amf", "smf", "pcf", "chf", "smsf", "bmsc"}, "DLF": {"udr", "udm", "nrf", "eir", "ausf", "aaa"}, "SIG": {"dra"}, "4GALL": {"mme", "sgwc", "smf", "pcf", "chf", "udr", "udm", "nrf", "eir", "ausf", "dra", "upf", "smsf", "aaa", "bmsc"}, "4GCP": {"mme", "sgwc", "smf", "pcf", "chf", "udr", "udm", "nrf", "eir", "ausf", "dra", "smsf", "aaa", "bmsc"}, "4GDCP": {"mme", "sgwc", "smf", "pcf", "chf", "smsf", "bmsc"}, "COMBOALL": {"amf", "mme", "sgwc", "smf", "pcf", "chf", "udr", "udm", "nrf", "eir", "ausf", "dra", "upf", "smsf", "aaa", "bmsc"}, "COMBOCP": {"amf", "mme", "sgwc", "smf", "pcf", "chf", "udr", "udm", "nrf", "eir", "ausf", "dra", "smsf", "aaa", "bmsc"}, "COMBODCP": {"amf", "mme", "sgwc", "smf", "pcf", "chf", "aaa"}, } ROLE_ALIASES = { "UPF": "UP", } ROLE_PRIORITY = ["COMBOALL", "COMBOCP", "COMBODCP", "5GALL", "4GALL", "4GCP", "4GDCP", "DCP", "DLF", "SIG", "CP", "UP"] def _infer_role(hostname: str) -> str: tokens = [t for t in re.split(r"[^A-Za-z0-9]+", hostname.upper()) if t] normalized = [ROLE_ALIASES.get(token, token) for token in tokens] for role in ROLE_PRIORITY: if role in normalized: return role for token in normalized: if token.endswith("UPF"): return "UP" return "AP" async def get_cluster_inventory() -> dict: global _last_inventory cluster = await pls.get_cluster_status() if not cluster: return _last_inventory or { "enabled": False, "current_node": None, "fully_established": False, "nodes": [], } node_names = [node.get("name", "") for node in cluster.get("nodes", [])] info_tasks = [asyncio.create_task(pls.get_system_info(pls.node_host(name))) for name in node_names] service_tasks = [asyncio.create_task(pls.get_services(pls.node_host(name))) for name in node_names] infos = await asyncio.gather(*info_tasks, return_exceptions=True) services = await asyncio.gather(*service_tasks, return_exceptions=True) nodes: list[dict] = [] for idx, node in enumerate(cluster.get("nodes", [])): info = infos[idx] if isinstance(infos[idx], dict) else {} node_services = services[idx] if isinstance(services[idx], list) else [] started = {svc["name"] for svc in node_services if svc.get("state") == "started"} hostname = info.get("hostname") or pls.node_host(node.get("name", "")) role = _infer_role(hostname) nodes.append( { "name": node.get("name", ""), "address": pls.node_host(node.get("name", "")), "hostname": hostname, "current": node.get("name") == cluster.get("current_node"), "repositories": node.get("repositories", []), "role": role, "roles": [role], "expected_nfs": sorted(ROLE_NF_MAP.get(role, set())), "services": node_services, "started_services": sorted(started), } ) inventory = { "enabled": True, "current_node": cluster.get("current_node"), "fully_established": bool(cluster.get("fully_established")), "nodes": nodes, } _last_inventory = inventory return inventory def _aggregate_nf_state(nf_name: str, nodes: list[dict], prom_states: dict[str, dict]) -> dict: service_name = nf_name.lower() placements = [] seen_service = False for node in nodes: for service in node.get("services", []): if service.get("name") != service_name: continue seen_service = True if service.get("state") == "started": placements.append( { "hostname": node["hostname"], "address": node["address"], "roles": node["roles"], } ) prom_state = prom_states.get(nf_name, {"state": "unknown", "instance": ""}) if placements: state = prom_state["state"] if prom_state["state"] in {"up", "down"} else "up" instance = ", ".join(p["hostname"] for p in placements) elif seen_service: state = "down" instance = "" else: state = prom_state["state"] instance = prom_state["instance"] return { "name": nf_name, "state": state, "instance": instance, "nodes": placements, } def _node_nf_state(node: dict, nf_name: str) -> dict: service_name = nf_name.lower() service = next((svc for svc in node.get("services", []) if svc.get("name") == service_name), None) if not service: return {"name": nf_name, "state": "unknown"} if service.get("state") == "started": return {"name": nf_name, "state": "up"} return {"name": nf_name, "state": "down"} def _attach_node_nf_status(nodes: list[dict]) -> list[dict]: enriched = [] for node in nodes: node_copy = dict(node) expected_nfs = {nf.upper() for nf in node_copy.get("expected_nfs", [])} started_nf_services = { svc.get("name", "").upper() for svc in node_copy.get("services", []) if svc.get("type") == "nf" and svc.get("name") } visible_nfs = sorted(expected_nfs | started_nf_services) node_copy["nfs"] = [_node_nf_state(node_copy, nf_name.upper()) for nf_name in visible_nfs] enriched.append(node_copy) return enriched async def get_network_status() -> dict: inventory_task = asyncio.create_task(get_cluster_inventory()) prom_task = asyncio.create_task(prometheus.get_nf_status_map()) inventory, prom_states = await asyncio.gather(inventory_task, prom_task) nodes = _attach_node_nf_status(inventory.get("nodes", [])) inventory["nodes"] = nodes nfs = [_aggregate_nf_state(nf_name, nodes, prom_states) for nf_name in ALL_NFS] up = sum(1 for nf in nfs if nf["state"] == "up") down = sum(1 for nf in nfs if nf["state"] == "down") return { "nfs": nfs, "summary": {"up": up, "down": down, "total": len(nfs)}, "cluster": inventory, } def find_nf_nodes(cluster: dict, nf_name: str) -> list[dict]: nodes = cluster.get("nodes", []) matches = [] for node in nodes: for nf in node.get("nfs", []): if nf.get("name") == nf_name: matches.append( { "hostname": node["hostname"], "address": node["address"], "role": node.get("role", "AP"), "current": node.get("current", False), "state": nf.get("state", "unknown"), } ) break return matches