|
24 | 24 | from dataclasses import dataclass |
25 | 25 | import enum |
26 | 26 | import logging |
| 27 | +import socket |
27 | 28 | import threading |
28 | 29 | import uuid |
29 | | -from typing import Dict, List, Optional, Set |
| 30 | +from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Set, Tuple |
| 31 | + |
| 32 | +from cassandra import ConsistencyLevel |
| 33 | +from cassandra.protocol import QueryMessage |
| 34 | +from cassandra.query import dict_factory |
| 35 | + |
| 36 | +if TYPE_CHECKING: |
| 37 | + from cassandra.connection import Connection |
30 | 38 |
|
31 | 39 | log = logging.getLogger(__name__) |
32 | 40 |
|
@@ -190,3 +198,254 @@ def merge(self, new_routes: List[_Route], affected_host_ids: Set[uuid.UUID]) -> |
190 | 198 | if hid not in affected_host_ids} |
191 | 199 | updated.update(new_by_host) |
192 | 200 | self._routes_by_host_id = updated |
| 201 | + |
| 202 | + |
| 203 | +class _ClientRoutesHandler: |
| 204 | + """ |
| 205 | + Handles dynamic address translation for Private Link via system.client_routes. |
| 206 | +
|
| 207 | + Lifecycle: |
| 208 | + 1. Construction: Create with configuration |
| 209 | + 2. Initialization: Read system.client_routes after control connection established |
| 210 | + 3. Steady state: Listen for CLIENT_ROUTES_CHANGE events and update routes |
| 211 | + 4. Translation: Translate addresses using Host ID lookup |
| 212 | + """ |
| 213 | + |
| 214 | + config: 'ClientRoutesConfig' |
| 215 | + ssl_enabled: bool |
| 216 | + _routes: _RouteStore |
| 217 | + _connection_ids: Set[str] |
| 218 | + _proxy_addresses_override: Dict[str, str] |
| 219 | + |
| 220 | + def __init__(self, config: 'ClientRoutesConfig', ssl_enabled: bool = False): |
| 221 | + """ |
| 222 | + :param config: ClientRoutesConfig instance |
| 223 | + :param ssl_enabled: Whether TLS is enabled (determines port selection) |
| 224 | + """ |
| 225 | + if not isinstance(config, ClientRoutesConfig): |
| 226 | + raise TypeError("config must be a ClientRoutesConfig instance") |
| 227 | + |
| 228 | + self.config = config |
| 229 | + self.ssl_enabled = ssl_enabled |
| 230 | + self._routes = _RouteStore() |
| 231 | + self._connection_ids = {dep.connection_id for dep in config.proxies} |
| 232 | + # Precalculate proxy address mappings for efficient lookup |
| 233 | + self._proxy_addresses_override = { |
| 234 | + proxy.connection_id: proxy.connection_addr_override |
| 235 | + for proxy in config.proxies |
| 236 | + if proxy.connection_addr_override |
| 237 | + } |
| 238 | + |
| 239 | + def initialize(self, connection: 'Connection', timeout: float) -> None: |
| 240 | + """ |
| 241 | + Load all routes from system.client_routes. |
| 242 | +
|
| 243 | + Called once at startup and again whenever the control connection |
| 244 | + is re-established. Reads all configured connection IDs and |
| 245 | + replaces the in-memory route store atomically. |
| 246 | +
|
| 247 | + Raises on failure so the caller can decide how to react (e.g. |
| 248 | + abort startup or schedule a reconnect). |
| 249 | +
|
| 250 | + :param connection: The Connection instance to execute queries on |
| 251 | + :param timeout: Query timeout in seconds |
| 252 | + """ |
| 253 | + log.info("[client routes] Loading routes for %d proxies", len(self.config.proxies)) |
| 254 | + |
| 255 | + routes = self._query_all_routes_for_connections(connection, timeout, self._connection_ids) |
| 256 | + self._routes.update(routes) |
| 257 | + |
| 258 | + def handle_client_routes_change(self, connection: 'Connection', timeout: float, |
| 259 | + change_type: 'ClientRoutesChangeType', |
| 260 | + connection_ids: Sequence[str], host_ids: Sequence[str]) -> None: |
| 261 | + """ |
| 262 | + Handle CLIENT_ROUTES_CHANGE event. |
| 263 | +
|
| 264 | + Currently the protocol defines only :attr:`ClientRoutesChangeType.UPDATE_NODES`. |
| 265 | + New variants will be added to the enum if the protocol is extended. |
| 266 | +
|
| 267 | + :param connection: The Connection instance to execute queries on |
| 268 | + :param timeout: Query timeout in seconds |
| 269 | + :param change_type: A :class:`ClientRoutesChangeType` value |
| 270 | + :param connection_ids: Affected connection ID strings; empty means all. |
| 271 | + :param host_ids: Affected host ID strings; empty means all. |
| 272 | + """ |
| 273 | + |
| 274 | + full_refresh = False |
| 275 | + if not connection_ids or not host_ids: |
| 276 | + log.warning( |
| 277 | + "[client routes] CLIENT_ROUTES_CHANGE has no connection_ids or host_ids, doing full refresh") |
| 278 | + full_refresh = True |
| 279 | + elif len(connection_ids) != len(host_ids): |
| 280 | + log.warning("[client routes] CLIENT_ROUTES_CHANGE has mismatched lengths (conn: %d, host: %d), doing full refresh", |
| 281 | + len(connection_ids), len(host_ids)) |
| 282 | + full_refresh = True |
| 283 | + |
| 284 | + if full_refresh: |
| 285 | + routes = self._query_all_routes_for_connections(connection, timeout, self._connection_ids) |
| 286 | + self._routes.update(routes) |
| 287 | + return |
| 288 | + |
| 289 | + host_uuids = [uuid.UUID(hid) for hid in host_ids] |
| 290 | + pairs = [(cid, hid) for cid, hid in zip(connection_ids, host_uuids) |
| 291 | + if cid in self._connection_ids] |
| 292 | + |
| 293 | + if not pairs: |
| 294 | + return |
| 295 | + |
| 296 | + routes = self._query_routes_for_change_event(connection, timeout, pairs) |
| 297 | + self._routes.merge(routes, affected_host_ids=set(host_uuids)) |
| 298 | + |
| 299 | + def _query_all_routes_for_connections(self, connection: 'Connection', timeout: float, |
| 300 | + connection_ids: Set[str]) -> List[_Route]: |
| 301 | + """ |
| 302 | + Query all routes for the given connection IDs (complete refresh). |
| 303 | +
|
| 304 | + Used when control connection reconnects or as a fallback when |
| 305 | + CLIENT_ROUTES_CHANGE event has malformed data. |
| 306 | +
|
| 307 | + :param connection: Connection to execute query on |
| 308 | + :param timeout: Query timeout in seconds |
| 309 | + :param connection_ids: Set of connection ID strings |
| 310 | + :return: List of _Route |
| 311 | + """ |
| 312 | + if not connection_ids: |
| 313 | + return [] |
| 314 | + |
| 315 | + placeholders = ', '.join('?' for _ in connection_ids) |
| 316 | + query = f"SELECT connection_id, host_id, address, port, tls_port FROM system.client_routes WHERE connection_id IN ({placeholders})" |
| 317 | + params = [cid.encode('utf-8') for cid in connection_ids] |
| 318 | + |
| 319 | + log.debug("[client routes] Querying all routes for connection_ids=%s", connection_ids) |
| 320 | + return self._execute_routes_query(connection, timeout, query, params) |
| 321 | + |
| 322 | + def _query_routes_for_change_event(self, connection: 'Connection', timeout: float, |
| 323 | + route_pairs: List[Tuple[str, uuid.UUID]]) -> List[_Route]: |
| 324 | + """ |
| 325 | + Query specific routes affected by a CLIENT_ROUTES_CHANGE event. |
| 326 | +
|
| 327 | + Takes a list of (connection_id, host_id) pairs that represent the exact |
| 328 | + routes affected by an operation. This provides precise updates without |
| 329 | + fetching unrelated routes. |
| 330 | +
|
| 331 | + If the pairs list is empty or None, falls back to a complete refresh |
| 332 | + of all routes for safety. |
| 333 | +
|
| 334 | + :param connection: Connection to execute query on |
| 335 | + :param timeout: Query timeout in seconds |
| 336 | + :param route_pairs: List of (connection_id, host_id) tuples |
| 337 | + :return: List of _Route |
| 338 | + """ |
| 339 | + unique_pairs = list(dict.fromkeys(route_pairs)) |
| 340 | + |
| 341 | + conn_ids = list(dict.fromkeys(cid for cid, _ in unique_pairs)) |
| 342 | + host_ids = list(dict.fromkeys(hid for _, hid in unique_pairs)) |
| 343 | + |
| 344 | + log.debug("[client routes] Querying route pairs from CLIENT_ROUTES_CHANGE " |
| 345 | + "(first 5 of %d): %s", len(unique_pairs), unique_pairs[:5]) |
| 346 | + |
| 347 | + conn_ph = ', '.join('?' for _ in conn_ids) |
| 348 | + host_ph = ', '.join('?' for _ in host_ids) |
| 349 | + query = ( |
| 350 | + "SELECT connection_id, host_id, address, port, tls_port " |
| 351 | + "FROM system.client_routes " |
| 352 | + f"WHERE connection_id IN ({conn_ph}) AND host_id IN ({host_ph})" |
| 353 | + ) |
| 354 | + params: List = [cid.encode('utf-8') for cid in conn_ids] |
| 355 | + params.extend(hid.bytes for hid in host_ids) |
| 356 | + |
| 357 | + return self._execute_routes_query(connection, timeout, query, params) |
| 358 | + |
| 359 | + def _execute_routes_query(self, connection: 'Connection', timeout: float, |
| 360 | + query: str, params: List) -> List[_Route]: |
| 361 | + """ |
| 362 | + Execute a routes query and parse results. |
| 363 | +
|
| 364 | + Common helper for both complete refresh and change event queries. |
| 365 | +
|
| 366 | + :param connection: Connection to execute query on |
| 367 | + :param timeout: Query timeout in seconds |
| 368 | + :param query: CQL query string |
| 369 | + :param params: Query parameters |
| 370 | + :return: List of _Route |
| 371 | + """ |
| 372 | + log.debug("[client routes] Executing query: %s with %d parameters", query, len(params)) |
| 373 | + |
| 374 | + query_msg = QueryMessage(query=query, consistency_level=ConsistencyLevel.ONE, |
| 375 | + query_params=params if params else None) |
| 376 | + result = connection.wait_for_response( |
| 377 | + query_msg, timeout=timeout |
| 378 | + ) |
| 379 | + |
| 380 | + routes = [] |
| 381 | + broken = 0 |
| 382 | + rows = dict_factory(result.column_names, result.parsed_rows) |
| 383 | + for row in rows: |
| 384 | + try: |
| 385 | + absent = [] |
| 386 | + port = row['tls_port'] if self.ssl_enabled else row['port'] |
| 387 | + connection_id = row['connection_id'] |
| 388 | + host_id = row['host_id'] |
| 389 | + address = row['address'] |
| 390 | + |
| 391 | + if not port: |
| 392 | + absent.append("tls_port" if self.ssl_enabled else "port") |
| 393 | + if not connection_id: |
| 394 | + absent.append("connection_id") |
| 395 | + if not host_id: |
| 396 | + absent.append("host_id") |
| 397 | + if not address: |
| 398 | + absent.append("address") |
| 399 | + |
| 400 | + if absent: |
| 401 | + log.error("[client routes] read a route %s, that has no values for the following fields: %s", row, ",".join(absent)) |
| 402 | + broken += 1 |
| 403 | + continue |
| 404 | + |
| 405 | + final_address = self._proxy_addresses_override.get(connection_id, address) |
| 406 | + |
| 407 | + routes.append(_Route( |
| 408 | + connection_id=connection_id, |
| 409 | + host_id=host_id, |
| 410 | + address=final_address, |
| 411 | + port=port, |
| 412 | + )) |
| 413 | + except Exception as e: |
| 414 | + log.warning("[client routes] Failed to parse route row: %s", e) |
| 415 | + broken += 1 |
| 416 | + |
| 417 | + if broken and not routes: |
| 418 | + raise RuntimeError( |
| 419 | + "[client routes] All %d route rows failed validation; " |
| 420 | + "refusing to return empty result that would wipe the route store" % broken |
| 421 | + ) |
| 422 | + |
| 423 | + return routes |
| 424 | + |
| 425 | + def resolve_host(self, host_id: uuid.UUID) -> Optional[Tuple[str, int]]: |
| 426 | + """ |
| 427 | + Resolve a host_id to an (address, port) pair. |
| 428 | +
|
| 429 | + Looks up the current route and selects the appropriate port. |
| 430 | +
|
| 431 | + :param host_id: Host UUID to resolve |
| 432 | + :return: Tuple of (address, port) or None if no route mapping exists |
| 433 | + """ |
| 434 | + route = self._routes.get_by_host_id(host_id) |
| 435 | + if route is None: |
| 436 | + return None |
| 437 | + |
| 438 | + if not route.port: |
| 439 | + raise ValueError("Mapping for host %s has no port" % host_id) |
| 440 | + |
| 441 | + try: |
| 442 | + result = socket.getaddrinfo(route.address, route.port, |
| 443 | + socket.AF_UNSPEC, socket.SOCK_STREAM) |
| 444 | + if not result: |
| 445 | + raise socket.gaierror("No addresses found for %s" % route.address) |
| 446 | + resolved_ip = result[0][4][0] |
| 447 | + return resolved_ip, route.port |
| 448 | + except socket.gaierror as e: |
| 449 | + log.warning('[client routes] Could not resolve hostname "%s" (host_id=%s): %s', |
| 450 | + route.address, host_id, e) |
| 451 | + raise |
0 commit comments