322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""Incident aggregation helpers for BGP anomalies."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.collected_data_fields import get_record_field
|
|
from app.models.bgp_anomaly import BGPAnomaly
|
|
from app.models.bgp_incident import BGPIncident
|
|
from app.models.collected_data import CollectedData
|
|
from app.services.cable_graph import haversine_distance
|
|
|
|
|
|
def _severity_rank(value: str | None) -> int:
|
|
mapping = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}
|
|
return mapping.get(str(value or "").lower(), 0)
|
|
|
|
|
|
def _pick_severity(values: list[str]) -> str:
|
|
ordered = sorted(values, key=_severity_rank, reverse=True)
|
|
return ordered[0] if ordered else "medium"
|
|
|
|
|
|
def _collector_regions_from_anomaly(anomaly: BGPAnomaly) -> list[dict]:
|
|
evidence = anomaly.evidence or {}
|
|
regions = evidence.get("impacted_regions") or []
|
|
if regions:
|
|
return regions
|
|
|
|
collected = []
|
|
for item in evidence.get("events") or []:
|
|
collector = item.get("collector")
|
|
location = item.get("collector_location") or {}
|
|
if collector or location:
|
|
collected.append(
|
|
{
|
|
"collector": collector,
|
|
"country": location.get("country"),
|
|
"city": location.get("city"),
|
|
"latitude": location.get("latitude"),
|
|
"longitude": location.get("longitude"),
|
|
}
|
|
)
|
|
return collected
|
|
|
|
|
|
def _dedupe_collected_records(records: list[CollectedData]) -> list[CollectedData]:
|
|
latest_by_key: dict[str, CollectedData] = {}
|
|
for record in records:
|
|
dedupe_key = str(record.source_id or record.entity_key or record.name or record.id)
|
|
existing = latest_by_key.get(dedupe_key)
|
|
if existing is None or (record.id or 0) > (existing.id or 0):
|
|
latest_by_key[dedupe_key] = record
|
|
return list(latest_by_key.values())
|
|
|
|
|
|
async def infer_related_infrastructure(
|
|
db: AsyncSession,
|
|
affected_regions: list[dict],
|
|
*,
|
|
max_matches: int = 6,
|
|
max_distance_km: float = 450.0,
|
|
) -> dict[str, list[dict[str, Any]]]:
|
|
valid_regions = [
|
|
region
|
|
for region in affected_regions
|
|
if isinstance(region, dict)
|
|
and isinstance(region.get("latitude"), (int, float))
|
|
and isinstance(region.get("longitude"), (int, float))
|
|
]
|
|
if not valid_regions:
|
|
return {"related_cables": [], "related_ixps": []}
|
|
|
|
landing_result = await db.execute(
|
|
select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
|
|
)
|
|
relation_result = await db.execute(
|
|
select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation")
|
|
)
|
|
cable_result = await db.execute(
|
|
select(CollectedData).where(CollectedData.source == "arcgis_cables")
|
|
)
|
|
|
|
landing_records = _dedupe_collected_records(list(landing_result.scalars().all()))
|
|
relation_records = _dedupe_collected_records(list(relation_result.scalars().all()))
|
|
cable_records = _dedupe_collected_records(list(cable_result.scalars().all()))
|
|
|
|
city_to_cable_ids: dict[int, list[int]] = {}
|
|
for relation in relation_records:
|
|
metadata = relation.extra_data or {}
|
|
city_id = metadata.get("city_id")
|
|
cable_id = metadata.get("cable_id")
|
|
if city_id is None or cable_id is None:
|
|
continue
|
|
city_key = int(city_id)
|
|
cable_key = int(cable_id)
|
|
city_to_cable_ids.setdefault(city_key, [])
|
|
if cable_key not in city_to_cable_ids[city_key]:
|
|
city_to_cable_ids[city_key].append(cable_key)
|
|
|
|
cable_id_to_name: dict[int, str] = {}
|
|
for cable in cable_records:
|
|
metadata = cable.extra_data or {}
|
|
cable_id = metadata.get("cable_id")
|
|
if cable_id is None or not cable.name:
|
|
continue
|
|
cable_id_to_name[int(cable_id)] = cable.name
|
|
|
|
matches: list[dict[str, Any]] = []
|
|
seen_match_keys: set[tuple[Any, ...]] = set()
|
|
|
|
for region in valid_regions:
|
|
region_coords = (float(region["longitude"]), float(region["latitude"]))
|
|
|
|
for landing in landing_records:
|
|
try:
|
|
latitude = get_record_field(landing, "latitude")
|
|
longitude = get_record_field(landing, "longitude")
|
|
landing_lat = float(latitude) if latitude is not None else None
|
|
landing_lon = float(longitude) if longitude is not None else None
|
|
except (TypeError, ValueError):
|
|
landing_lat = None
|
|
landing_lon = None
|
|
|
|
if landing_lat is None or landing_lon is None:
|
|
continue
|
|
|
|
distance_km = haversine_distance(region_coords, (landing_lon, landing_lat))
|
|
if distance_km > max_distance_km:
|
|
continue
|
|
|
|
landing_meta = landing.extra_data or {}
|
|
city_id = landing_meta.get("city_id")
|
|
cable_names = []
|
|
if city_id is not None:
|
|
for cable_id in city_to_cable_ids.get(int(city_id), []):
|
|
cable_name = cable_id_to_name.get(int(cable_id))
|
|
if cable_name and cable_name not in cable_names:
|
|
cable_names.append(cable_name)
|
|
|
|
match = {
|
|
"landing_point": landing.name or "Unknown",
|
|
"city": get_record_field(landing, "city"),
|
|
"country": get_record_field(landing, "country"),
|
|
"distance_km": round(distance_km, 1),
|
|
"collector": region.get("collector"),
|
|
"cable_names": cable_names,
|
|
}
|
|
match_key = (
|
|
match["landing_point"],
|
|
match["city"],
|
|
match["country"],
|
|
)
|
|
if match_key in seen_match_keys:
|
|
continue
|
|
seen_match_keys.add(match_key)
|
|
matches.append(match)
|
|
|
|
matches.sort(
|
|
key=lambda item: (
|
|
item.get("distance_km", 999999),
|
|
str(item.get("landing_point") or ""),
|
|
)
|
|
)
|
|
matches = matches[:max_matches]
|
|
|
|
related_ixps = []
|
|
seen_ixp_keys: set[tuple[str, str]] = set()
|
|
for item in matches:
|
|
city = str(item.get("city") or "").strip()
|
|
country = str(item.get("country") or "").strip()
|
|
if not city and not country:
|
|
continue
|
|
key = (city, country)
|
|
if key in seen_ixp_keys:
|
|
continue
|
|
seen_ixp_keys.add(key)
|
|
related_ixps.append(
|
|
{
|
|
"name": ", ".join(part for part in [city, country] if part),
|
|
"type": "regional_exchange_hint",
|
|
}
|
|
)
|
|
|
|
return {
|
|
"related_cables": matches,
|
|
"related_ixps": related_ixps,
|
|
}
|
|
|
|
|
|
async def create_bgp_incidents_for_anomalies(
|
|
db: AsyncSession,
|
|
*,
|
|
source: str,
|
|
snapshot_id: int | None,
|
|
task_id: int | None,
|
|
anomalies: list[BGPAnomaly],
|
|
) -> int:
|
|
if not anomalies:
|
|
return 0
|
|
|
|
grouped: dict[str, list[BGPAnomaly]] = {}
|
|
for anomaly in anomalies:
|
|
incident_key = f"{anomaly.anomaly_type}:{anomaly.prefix or 'unknown'}:{anomaly.new_origin_asn or anomaly.origin_asn or 'na'}"
|
|
grouped.setdefault(incident_key, []).append(anomaly)
|
|
|
|
existing_result = await db.execute(
|
|
select(BGPIncident).where(BGPIncident.incident_key.in_(sorted(grouped.keys())))
|
|
)
|
|
existing_incidents = {
|
|
incident.incident_key: incident for incident in existing_result.scalars().all()
|
|
}
|
|
|
|
created = 0
|
|
for incident_key, items in grouped.items():
|
|
items = sorted(items, key=lambda item: item.created_at or item.started_at or datetime.now(UTC))
|
|
primary = items[0]
|
|
prefixes = sorted({item.prefix for item in items if item.prefix})
|
|
asns = sorted(
|
|
{
|
|
asn
|
|
for item in items
|
|
for asn in [item.origin_asn, item.new_origin_asn]
|
|
if asn is not None
|
|
}
|
|
)
|
|
collectors = sorted(
|
|
{
|
|
collector
|
|
for item in items
|
|
for collector in (item.peer_scope or [])
|
|
if collector
|
|
}
|
|
)
|
|
regions: list[dict] = []
|
|
seen_regions: set[tuple] = set()
|
|
for item in items:
|
|
for region in _collector_regions_from_anomaly(item):
|
|
region_key = (
|
|
region.get("collector"),
|
|
region.get("country"),
|
|
region.get("city"),
|
|
)
|
|
if region_key in seen_regions:
|
|
continue
|
|
seen_regions.add(region_key)
|
|
regions.append(region)
|
|
|
|
if not collectors:
|
|
collectors = sorted(
|
|
{
|
|
region.get("collector")
|
|
for region in regions
|
|
if region.get("collector")
|
|
}
|
|
)
|
|
|
|
evidence_refs = [item.entity_key for item in items if item.entity_key]
|
|
severity = _pick_severity([item.severity for item in items])
|
|
confidence = max((item.confidence or 0.0) for item in items)
|
|
title = f"{primary.anomaly_type.replace('_', ' ').title()} incident on {primary.prefix or 'unknown prefix'}"
|
|
summary = (
|
|
f"{len(items)} anomaly signal(s) grouped into one {primary.anomaly_type} incident, "
|
|
f"affecting {len(prefixes) or 1} prefix scope(s) across {len(collectors)} collector(s)."
|
|
)
|
|
related_infrastructure = await infer_related_infrastructure(db, regions)
|
|
|
|
existing = existing_incidents.get(incident_key)
|
|
if existing is not None:
|
|
existing.snapshot_id = snapshot_id
|
|
existing.task_id = task_id
|
|
existing.source = source
|
|
existing.incident_type = primary.anomaly_type
|
|
existing.title = title
|
|
existing.summary = summary
|
|
existing.severity = severity
|
|
existing.status = "active"
|
|
existing.confidence = confidence
|
|
existing.started_at = primary.started_at or existing.started_at or datetime.now(UTC)
|
|
existing.ended_at = None
|
|
existing.affected_prefixes = prefixes
|
|
existing.affected_asns = asns
|
|
existing.affected_collectors = collectors
|
|
existing.affected_regions = regions
|
|
existing.related_cables = related_infrastructure["related_cables"]
|
|
existing.related_ixps = related_infrastructure["related_ixps"]
|
|
existing.evidence_refs = evidence_refs
|
|
continue
|
|
|
|
db.add(
|
|
BGPIncident(
|
|
snapshot_id=snapshot_id,
|
|
task_id=task_id,
|
|
source=source,
|
|
incident_key=incident_key,
|
|
incident_type=primary.anomaly_type,
|
|
title=title,
|
|
summary=summary,
|
|
severity=severity,
|
|
status="active",
|
|
confidence=confidence,
|
|
started_at=primary.started_at or datetime.now(UTC),
|
|
affected_prefixes=prefixes,
|
|
affected_asns=asns,
|
|
affected_collectors=collectors,
|
|
affected_regions=regions,
|
|
related_cables=related_infrastructure["related_cables"],
|
|
related_ixps=related_infrastructure["related_ixps"],
|
|
evidence_refs=evidence_refs,
|
|
)
|
|
)
|
|
created += 1
|
|
|
|
if created or existing_incidents:
|
|
await db.commit()
|
|
|
|
return created
|