"""Typed, read-only MobilityOps (Fleet Ops) API client primitives. MobilityOps has exactly one configured target per environment (unlike Gitea's per-project repository registry) -- shape follows the simpler `unraid` broker client pattern, with Gitea's stricter settings/transport validation reused since this, like Gitea, is an external HTTPS origin authenticated by a long-lived shared secret rather than an internally-issued short-lived token. """ from __future__ import annotations import ipaddress import json import re from collections.abc import Mapping from pathlib import Path from typing import Any, Protocol, cast from urllib.parse import urljoin, urlparse import httpx from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator KNOWLEDGE_LOCALES: tuple[str, ...] = ("nl-BE", "en-GB", "fr-BE") """Locales MobilityOps (Fleet Ops) accepts on `POST /search-knowledge`.""" DEFAULT_KNOWLEDGE_LOCALE = "en-GB" """Compatible default for older clients that send no locale at all.""" CORRELATION_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$") class MobilityOpsError(RuntimeError): """Stable connector error that never contains credentials or raw headers.""" def __init__(self, code: str, message: str) -> None: self.code = code super().__init__(message) class MobilityOpsSettings(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) base_url: str = Field(min_length=1, max_length=2048) token_reference: str = Field(min_length=1, max_length=512) timeout_seconds: float = Field(default=15.0, gt=0, le=60) max_response_bytes: int = Field(default=262144, ge=1024, le=4 * 1024 * 1024) allow_insecure_http_for_tests: bool = False @field_validator("base_url") @classmethod def validate_base_url(cls, value: str) -> str: parsed = urlparse(value.strip().rstrip("/")) if parsed.scheme not in {"https", "http"} or not parsed.hostname: raise ValueError("MobilityOps base_url must be an absolute HTTP(S) URL") if parsed.username or parsed.password or parsed.query or parsed.fragment: raise ValueError("MobilityOps base_url must not contain credentials or query data") hostname = (parsed.hostname or "").casefold() if hostname in {"localhost", "metadata", "metadata.google.internal"}: raise ValueError("MobilityOps base_url host is not allowed") try: address = ipaddress.ip_address(hostname) except ValueError: address = None if address is not None and ( address.is_private or address.is_loopback or address.is_link_local or address.is_reserved or address.is_unspecified or address.is_multicast ): raise ValueError("MobilityOps base_url host is not allowed") return value.strip().rstrip("/") @model_validator(mode="after") def require_tls(self) -> MobilityOpsSettings: if urlparse(self.base_url).scheme != "https" and not self.allow_insecure_http_for_tests: raise ValueError("MobilityOps base_url must use HTTPS") if not self.token_reference.startswith(("docker-secret://", "/run/secrets/")): raise ValueError("token_reference must name a mounted secret, not contain a token") return self class TokenProvider(Protocol): def get_token(self, reference: str) -> str: ... class FileTokenProvider: """Read-only provider for Docker secrets mounted beneath /run/secrets.""" def get_token(self, reference: str) -> str: if reference.startswith("docker-secret://"): name = reference.removeprefix("docker-secret://") path = Path("/run/secrets") / name else: path = Path(reference) root = Path("/run/secrets").resolve() try: resolved = path.resolve() resolved.relative_to(root) except (OSError, ValueError) as exc: raise MobilityOpsError( "MOBILITYOPS_SECRET_REFERENCE_INVALID", "secret reference is outside /run/secrets" ) from exc try: token = resolved.read_text(encoding="utf-8").strip() except OSError as exc: raise MobilityOpsError( "MOBILITYOPS_SECRET_UNAVAILABLE", "configured MobilityOps secret is unavailable" ) from exc if not token or len(token) > 4096 or any(ord(char) < 0x20 for char in token): raise MobilityOpsError( "MOBILITYOPS_SECRET_INVALID", "configured MobilityOps secret is invalid" ) return token class StaticTokenProvider: """Deterministic fake-only provider; never serializes the token.""" def __init__(self, token: str) -> None: self._token = token def get_token(self, reference: str) -> str: if not reference.startswith(("docker-secret://", "/run/secrets/")): raise MobilityOpsError( "MOBILITYOPS_SECRET_REFERENCE_INVALID", "invalid secret reference" ) return self._token class MobilityOpsClient: """Small MobilityOps API client with same-origin redirect enforcement. Auth is a shared-secret `X-Service-Token` header (MobilityOps's own `require_mcp_service_token` dependency), not a per-repo bearer token -- there is exactly one configured MobilityOps target per environment. """ def __init__( self, settings: MobilityOpsSettings, token_provider: TokenProvider, *, transport: httpx.BaseTransport | None = None, ) -> None: self.settings = settings self._token_provider = token_provider self._client = httpx.Client( base_url=f"{settings.base_url}/api/v1/integrations/mcp", timeout=settings.timeout_seconds, follow_redirects=False, transport=transport, ) def close(self) -> None: self._client.close() def _request( self, client_id: str, method: str, path: str, *, params: Mapping[str, str | int] | None = None, json_body: Mapping[str, Any] | None = None, correlation_id: str | None = None, timeout: float | None = None, ) -> Any: token = self._token_provider.get_token(self.settings.token_reference) headers = { "X-Service-Token": token, "X-Client-Id": client_id, "Accept": "application/json", } if correlation_id is not None: # The Hub already has a trace id for this invocation; MobilityOps preserves # whatever valid correlation id it receives, so never mint a second one here. if not CORRELATION_ID.fullmatch(correlation_id): raise MobilityOpsError( "MOBILITYOPS_CORRELATION_ID_INVALID", "inbound correlation id is not a safe header value", ) headers["X-Correlation-Id"] = correlation_id request_timeout = self.settings.timeout_seconds if timeout is None else timeout try: response = self._client.request( method, path, params=params, json=json_body, headers=headers, timeout=request_timeout, ) if 300 <= response.status_code < 400: location = response.headers.get("location") if not location: raise MobilityOpsError( "MOBILITYOPS_REDIRECT_DENIED", "MobilityOps redirect did not provide a location", ) target = urlparse(urljoin(str(response.url), location)) base = urlparse(self.settings.base_url) if (target.scheme, target.hostname, target.port) != ( base.scheme, base.hostname, base.port, ): raise MobilityOpsError( "MOBILITYOPS_REDIRECT_DENIED", "MobilityOps redirect leaves the configured origin", ) response = self._client.request( method, target.geturl(), headers=headers, timeout=request_timeout ) if 300 <= response.status_code < 400: raise MobilityOpsError( "MOBILITYOPS_REDIRECT_DENIED", "MobilityOps returned multiple redirects" ) except MobilityOpsError: raise except (httpx.TimeoutException, httpx.NetworkError) as exc: raise MobilityOpsError( "MOBILITYOPS_UNAVAILABLE", "MobilityOps target is unavailable" ) from exc if len(response.content) > self.settings.max_response_bytes: raise MobilityOpsError( "MOBILITYOPS_RESPONSE_TOO_LARGE", "MobilityOps response exceeds the connector budget", ) if response.status_code == 404: raise MobilityOpsError("MOBILITYOPS_NOT_FOUND", "MobilityOps resource was not found") if response.status_code in {401, 403}: raise MobilityOpsError( "MOBILITYOPS_ACCESS_DENIED", "MobilityOps denied the read request" ) if response.status_code == 422: raise MobilityOpsError( "MOBILITYOPS_REQUEST_INVALID", "MobilityOps rejected the request shape" ) if response.status_code >= 400: raise MobilityOpsError( "MOBILITYOPS_REQUEST_FAILED", "MobilityOps returned an unsuccessful response" ) try: return response.json() except (ValueError, json.JSONDecodeError) as exc: raise MobilityOpsError( "MOBILITYOPS_INVALID_RESPONSE", "MobilityOps returned invalid JSON" ) from exc def operations_summary( self, client_id: str, *, correlation_id: str | None = None ) -> dict[str, Any]: return cast( dict[str, Any], self._request(client_id, "GET", "/operations-summary", correlation_id=correlation_id), ) READINESS_TIMEOUT_SECONDS = 5.0 def readiness_probe(self, client_id: str, *, correlation_id: str | None = None) -> None: """Short, bounded, authenticated read used as the connector's readiness signal. MobilityOps exposes no authenticated readiness route on this origin -- its only health route (`GET /health`) is unauthenticated and lives outside the `/api/v1/integrations/mcp` prefix this client is bound to -- so the narrowest authenticated read, `operations-summary`, is the readiness probe instead. Raises `MobilityOpsError` with a stable code; never leaks the token or a raw transport exception. """ payload = self._request( client_id, "GET", "/operations-summary", correlation_id=correlation_id, timeout=min(self.settings.timeout_seconds, self.READINESS_TIMEOUT_SECONDS), ) if not isinstance(payload, dict) or not isinstance(payload.get("metrics"), dict): raise MobilityOpsError( "MOBILITYOPS_INVALID_RESPONSE", "MobilityOps readiness response did not carry operational metrics", ) def attention_vehicles( self, client_id: str, *, minimum_severity: str, date: str | None, limit: int, correlation_id: str | None = None, ) -> list[dict[str, Any]]: params: dict[str, str | int] = {"minimum_severity": minimum_severity, "limit": limit} if date: params["date"] = date value = self._request( client_id, "GET", "/attention-vehicles", params=params, correlation_id=correlation_id, ) if not isinstance(value, list) or not all(isinstance(item, dict) for item in value): raise MobilityOpsError( "MOBILITYOPS_INVALID_RESPONSE", "MobilityOps returned an unexpected collection" ) return value _VEHICLE_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,63}$") def vehicle_details( self, client_id: str, vehicle_ref: str, *, correlation_id: str | None = None ) -> dict[str, Any]: if not self._VEHICLE_REF.fullmatch(vehicle_ref): raise MobilityOpsError( "MOBILITYOPS_VEHICLE_REF_INVALID", "vehicle reference is invalid" ) return cast( dict[str, Any], self._request( client_id, "GET", f"/vehicles/{vehicle_ref}", correlation_id=correlation_id, ), ) def search_knowledge( self, client_id: str, question: str, *, max_sources: int, locale: str = DEFAULT_KNOWLEDGE_LOCALE, correlation_id: str | None = None, ) -> dict[str, Any]: if locale not in KNOWLEDGE_LOCALES: raise MobilityOpsError( "MOBILITYOPS_LOCALE_INVALID", "locale must be one of nl-BE, en-GB, fr-BE", ) return cast( dict[str, Any], self._request( client_id, "POST", "/search-knowledge", json_body={ "question": question, "max_sources": max_sources, "locale": locale, }, correlation_id=correlation_id, ), )