307 lines
10 KiB
Python
307 lines
10 KiB
Python
"""BGP route-collector location resolver.
|
|
|
|
Collector positions are stored in the ``bgp_collector_locations`` database
|
|
table. The old JSON registry is now only a seed payload used during database
|
|
initialization, not a runtime resolver or candidate source.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, Iterator
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.bgp_collector_location import BGPCollectorLocation
|
|
from app.services.location import (
|
|
LocationCandidate,
|
|
LocationPipeline,
|
|
LocationQuery,
|
|
NominatimResolver,
|
|
ResolutionResult,
|
|
ResolverOutput,
|
|
SourceCoordinatesResolver,
|
|
build_default_nominatim_geocoder,
|
|
coerce_str,
|
|
normalize_text,
|
|
)
|
|
|
|
SEED_PATH = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "data"
|
|
/ "seeds"
|
|
/ "ripe_ris_collector_locations_seed.json"
|
|
)
|
|
|
|
# ── Geocoder (kept at module level for monkeypatching + cache_clear) ──
|
|
|
|
_geocode_online = build_default_nominatim_geocoder()
|
|
|
|
|
|
# ── In-process compatibility cache ──────────────────────────────────
|
|
|
|
|
|
RIPE_RIS_COLLECTOR_COORDS: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
def _collector_record_to_dict(record: BGPCollectorLocation) -> dict[str, Any]:
|
|
return record.to_location_dict()
|
|
|
|
|
|
def set_bgp_collector_location_cache(
|
|
locations: dict[str, dict[str, Any]],
|
|
) -> None:
|
|
"""Replace the legacy compatibility cache in-place."""
|
|
RIPE_RIS_COLLECTOR_COORDS.clear()
|
|
RIPE_RIS_COLLECTOR_COORDS.update(
|
|
{coerce_str(key): dict(value) for key, value in locations.items()}
|
|
)
|
|
|
|
|
|
async def refresh_bgp_collector_location_cache(
|
|
session: AsyncSession,
|
|
) -> dict[str, dict[str, Any]]:
|
|
result = await session.execute(select(BGPCollectorLocation))
|
|
records = result.scalars().all()
|
|
cache = {
|
|
record.collector_id: _collector_record_to_dict(record)
|
|
for record in records
|
|
if record.collector_id
|
|
}
|
|
set_bgp_collector_location_cache(cache)
|
|
return cache
|
|
|
|
|
|
def _load_seed_payload() -> dict[str, Any]:
|
|
with SEED_PATH.open("r", encoding="utf-8") as handle:
|
|
return json.load(handle)
|
|
|
|
|
|
def _seed_entry_to_record_kwargs(entry: dict[str, Any], collector_id: str) -> dict[str, Any]:
|
|
return {
|
|
"collector_id": collector_id,
|
|
"operator": entry.get("operator") or "RIPE NCC",
|
|
"site": entry.get("site"),
|
|
"city": entry.get("city"),
|
|
"country": entry.get("country"),
|
|
"latitude": entry.get("latitude"),
|
|
"longitude": entry.get("longitude"),
|
|
"precision": entry.get("precision") or "city",
|
|
"confidence": entry.get("confidence"),
|
|
"source": "legacy_seed",
|
|
"source_url": None,
|
|
"source_note": entry.get("source_note")
|
|
or "Seeded from legacy RIPE RIS collector coordinates",
|
|
"raw_payload": entry,
|
|
"needs_confirmation": True,
|
|
"verification_status": "unverified",
|
|
"verified_at": None,
|
|
}
|
|
|
|
|
|
async def seed_default_bgp_collector_locations(session: AsyncSession) -> None:
|
|
"""Seed default RIPE RIS collector locations without overwriting users."""
|
|
payload = _load_seed_payload()
|
|
for entry in payload.get("locations", []):
|
|
aliases = entry.get("aliases") or []
|
|
collector_ids = [
|
|
coerce_str(alias)
|
|
for alias in aliases
|
|
if coerce_str(alias).startswith("rrc")
|
|
]
|
|
if not collector_ids:
|
|
continue
|
|
collector_id = collector_ids[0]
|
|
existing = await session.scalar(
|
|
select(BGPCollectorLocation).where(
|
|
BGPCollectorLocation.collector_id == collector_id
|
|
)
|
|
)
|
|
if existing:
|
|
continue
|
|
session.add(
|
|
BGPCollectorLocation(
|
|
**_seed_entry_to_record_kwargs(entry, collector_id)
|
|
)
|
|
)
|
|
await session.commit()
|
|
await refresh_bgp_collector_location_cache(session)
|
|
|
|
|
|
def get_bgp_collector_location_dict(collector_name: str) -> dict[str, Any]:
|
|
"""Return the current cached collector location dict, or ``{}`` if unknown."""
|
|
return dict(RIPE_RIS_COLLECTOR_COORDS.get(coerce_str(collector_name), {}))
|
|
|
|
|
|
def iter_known_collector_names() -> Iterator[str]:
|
|
"""Yield every collector technical name (rrcXX) known in the cache."""
|
|
return iter(sorted(RIPE_RIS_COLLECTOR_COORDS.keys()))
|
|
|
|
|
|
# ── Pipeline construction ──────────────────────────────────────────
|
|
|
|
|
|
class StoredCollectorLocationResolver:
|
|
"""Resolve a collector through the DB-backed compatibility cache."""
|
|
|
|
name = "stored_collector_location"
|
|
|
|
def resolve(self, query: LocationQuery) -> ResolverOutput:
|
|
collector = coerce_str(query.name)
|
|
if not collector:
|
|
for alias in query.aliases:
|
|
collector = coerce_str(alias)
|
|
if collector:
|
|
break
|
|
if not collector:
|
|
return ResolverOutput()
|
|
location = get_bgp_collector_location_dict(collector)
|
|
if not location:
|
|
return ResolverOutput()
|
|
latitude = location.get("latitude")
|
|
longitude = location.get("longitude")
|
|
if latitude in (None, 0.0) or longitude in (None, 0.0):
|
|
return ResolverOutput()
|
|
return ResolverOutput(
|
|
candidates=(
|
|
LocationCandidate(
|
|
latitude=float(latitude),
|
|
longitude=float(longitude),
|
|
display_name=location.get("matched_location_name") or collector,
|
|
precision=location.get("precision") or "city",
|
|
confidence=float(location.get("confidence") or 0.85),
|
|
query=f"stored_collector_location::{collector}",
|
|
source=location.get("source") or self.name,
|
|
source_note=location.get("source_note"),
|
|
matched_fields=("collector",),
|
|
needs_confirmation=bool(location.get("needs_confirmation")),
|
|
city=location.get("city"),
|
|
region=None,
|
|
country=location.get("country"),
|
|
matched_location_name=(
|
|
location.get("matched_location_name") or collector
|
|
),
|
|
location_verified_at=location.get("verified_at"),
|
|
suggested_registry_entry=None,
|
|
),
|
|
)
|
|
)
|
|
|
|
|
|
def _bgp_collector_query_plan(
|
|
query: LocationQuery,
|
|
) -> list[tuple[str, tuple[str, ...]]]:
|
|
"""Build the Nominatim query plan for a BGP collector."""
|
|
extra = query.extra or {}
|
|
site = str(extra.get("site") or "")
|
|
operator = str(extra.get("operator") or "")
|
|
city = query.city or ""
|
|
country = query.country or ""
|
|
|
|
plan: list[tuple[str, tuple[str, ...]]] = []
|
|
|
|
def add(parts: list[tuple[str, str]]) -> None:
|
|
non_empty = [(field, value) for field, value in parts if value]
|
|
if not non_empty:
|
|
return
|
|
seen: set[str] = set()
|
|
cleaned: list[str] = []
|
|
fields: list[str] = []
|
|
for field, value in non_empty:
|
|
key = normalize_text(value)
|
|
if not key or key in seen:
|
|
continue
|
|
seen.add(key)
|
|
cleaned.append(value)
|
|
fields.append(field)
|
|
if not cleaned:
|
|
return
|
|
composed = ", ".join(cleaned)
|
|
if not any(composed == existing for existing, _ in plan):
|
|
plan.append((composed, tuple(fields)))
|
|
|
|
add([("site", site), ("city", city), ("country", country)])
|
|
add([("site", site), ("country", country)])
|
|
add([("operator", operator), ("city", city), ("country", country)])
|
|
add([("city", city), ("country", country)])
|
|
return plan
|
|
|
|
|
|
BGP_COLLECTOR_PIPELINE = LocationPipeline(
|
|
[
|
|
SourceCoordinatesResolver(),
|
|
StoredCollectorLocationResolver(),
|
|
],
|
|
failure_reason=(
|
|
"Could not resolve BGP collector to renderable coordinates from"
|
|
" source coordinates or stored collector location."
|
|
),
|
|
)
|
|
|
|
BGP_COLLECTOR_COLLECTION_PIPELINE = LocationPipeline(
|
|
[
|
|
SourceCoordinatesResolver(),
|
|
NominatimResolver(
|
|
query_plan_builder=_bgp_collector_query_plan,
|
|
# Late-binding so tests can monkeypatch ``_geocode_online``.
|
|
geocoder=lambda q: _geocode_online(q),
|
|
),
|
|
],
|
|
failure_reason=(
|
|
"Could not resolve BGP collector to renderable coordinates from"
|
|
" source coordinates or online geocoding."
|
|
),
|
|
)
|
|
|
|
|
|
# ── Public API ─────────────────────────────────────────────────────
|
|
|
|
|
|
def resolve_bgp_collector_location(
|
|
collector_name: str,
|
|
*,
|
|
city: str | None = None,
|
|
country: str | None = None,
|
|
site: str | None = None,
|
|
operator: str | None = None,
|
|
) -> ResolutionResult:
|
|
"""Resolve a BGP collector to its best-known stored location."""
|
|
stored = get_bgp_collector_location_dict(collector_name)
|
|
name = coerce_str(collector_name) or None
|
|
query = LocationQuery(
|
|
name=name,
|
|
aliases=tuple(filter(None, (collector_name,))),
|
|
city=coerce_str(city or stored.get("city")) or None,
|
|
country=coerce_str(country or stored.get("country")) or None,
|
|
extra={
|
|
"site": coerce_str(site or stored.get("site")),
|
|
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
|
|
},
|
|
)
|
|
return BGP_COLLECTOR_PIPELINE.resolve_best(query)
|
|
|
|
|
|
def collect_bgp_collector_location_candidates(
|
|
*,
|
|
collector: str | None = None,
|
|
city: str | None = None,
|
|
country: str | None = None,
|
|
site: str | None = None,
|
|
operator: str | None = None,
|
|
) -> tuple[list[LocationCandidate], list[str]]:
|
|
stored = get_bgp_collector_location_dict(collector or "")
|
|
name = coerce_str(collector) or None
|
|
query = LocationQuery(
|
|
name=name,
|
|
aliases=tuple(filter(None, (collector,))),
|
|
city=coerce_str(city or stored.get("city")) or None,
|
|
country=coerce_str(country or stored.get("country")) or None,
|
|
extra={
|
|
"site": coerce_str(site or stored.get("site")),
|
|
"operator": coerce_str(operator or stored.get("operator")) or "RIPE NCC",
|
|
},
|
|
)
|
|
return BGP_COLLECTOR_COLLECTION_PIPELINE.collect_candidates(query)
|