"""BGP event location resolver. A BGP event (announcement / withdrawal / RIB entry) is geographically tied to the route collector that observed it. This module defines the pipeline that turns an event payload into renderable coordinates. Current resolver chain: SourceCoordinates → event payload itself carries lat/lon (rare; some enriched feeds do). InheritFromCollector → look up the owning collector via :func:`resolve_bgp_collector_location`. Future plug-ins (no consumer changes required, just append to the list): ASNFacilityResolver — origin/peer ASN → peeringdb facility. PrefixGeoResolver — prefix → IP range geo lookup (iptoasn / opengeofeed). """ from __future__ import annotations from typing import Any from app.services.bgp_collector_locations import ( get_bgp_collector_location_dict, ) from app.services.location import ( InheritFromAnotherEntityResolver, LocationCandidate, LocationPipeline, LocationQuery, ResolutionResult, SourceCoordinatesResolver, coerce_str, ) def _inherit_from_owning_collector( query: LocationQuery, ) -> LocationCandidate | None: """Look up the event's owning collector by exact name in the DB-backed cache.""" extra = query.extra or {} collector_name = coerce_str(extra.get("collector")) if not collector_name: return None legacy = get_bgp_collector_location_dict(collector_name) if not legacy: return None latitude = legacy.get("latitude") longitude = legacy.get("longitude") if latitude in (None, 0.0) or longitude in (None, 0.0): return None return LocationCandidate( latitude=float(latitude), longitude=float(longitude), display_name=legacy.get("matched_location_name") or collector_name, precision=legacy.get("precision") or "city", confidence=float(legacy.get("confidence") or 0.85), query=f"inherit_from_collector::{collector_name}", source="inherited_from_collector", source_note=( f"Inherited from owning collector {collector_name}" ), matched_fields=("collector",), needs_confirmation=bool(legacy.get("needs_confirmation")), city=legacy.get("city"), region=None, country=legacy.get("country"), matched_location_name=legacy.get("matched_location_name"), location_verified_at=legacy.get("verified_at"), suggested_registry_entry=None, ) BGP_EVENT_PIPELINE = LocationPipeline( [ SourceCoordinatesResolver(), InheritFromAnotherEntityResolver( source_lookup=_inherit_from_owning_collector, name="inherited_from_collector", ), # Plug new resolvers (peeringdb / ASN facility / prefix-geo) here. ], failure_reason=( "Could not resolve BGP event coordinates: no source coords, owning" " collector unknown, and no fallback resolver matched." ), ) def resolve_bgp_event_location( *, collector: str, source_latitude: float | None = None, source_longitude: float | None = None, site: str | None = None, operator: str | None = None, peer_asn: int | None = None, origin_asn: int | None = None, prefix: str | None = None, ) -> ResolutionResult: """Resolve a BGP event to its renderable coordinates. The ``peer_asn`` / ``origin_asn`` / ``prefix`` arguments are accepted today so future resolvers (ASN→facility, prefix→geo) can consume them without callers needing to change. """ query = LocationQuery( name=collector or None, aliases=tuple(filter(None, (collector,))), source_latitude=source_latitude, source_longitude=source_longitude, extra={ "collector": collector or "", "site": coerce_str(site), "operator": coerce_str(operator), "peer_asn": peer_asn, "origin_asn": origin_asn, "prefix": coerce_str(prefix), }, ) return BGP_EVENT_PIPELINE.resolve_best(query) def resolve_bgp_event_geo_dict( collector: str, *, source_latitude: float | None = None, source_longitude: float | None = None, ) -> dict[str, Any]: """Convenience wrapper returning the legacy ``collector_geo`` dict shape. Preserves ``city``/``country``/``latitude``/``longitude`` keys (consumed by existing detectors / enrichment / DB serialization) and adds ``precision``/``source``/``needs_confirmation`` for richer downstream use. """ result = resolve_bgp_event_location( collector=collector, source_latitude=source_latitude, source_longitude=source_longitude, ) candidate = result.location if candidate is None: return {} return { "city": candidate.city, "country": candidate.country, "latitude": candidate.latitude, "longitude": candidate.longitude, "precision": candidate.precision, "source": candidate.source, "needs_confirmation": candidate.needs_confirmation, "matched_location_name": candidate.matched_location_name, "confidence": candidate.confidence, }