357 lines
14 KiB
Python
357 lines
14 KiB
Python
"""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,
|
|
),
|
|
)
|