"""Typed HTTP client for the internal Docker read broker.""" from __future__ import annotations from collections.abc import Mapping from pathlib import Path from typing import Any, Protocol import httpx from packages.connector_kit import ConnectorContext from packages.hub_runtime import issue_broker_service_token class BrokerClientError(RuntimeError): def __init__(self, code: str, message: str, status: int = 502) -> None: self.code = code self.status = status super().__init__(message) class BrokerTokenProvider(Protocol): def get_token(self, context: ConnectorContext) -> str: ... class FileBrokerTokenProvider: def __init__(self, path: str = "/run/secrets/docker_broker_service_token") -> None: self.path = Path(path) def get_token(self, context: ConnectorContext) -> str: try: secret = self.path.read_text(encoding="utf-8").strip() except OSError as exc: raise BrokerClientError( "BROKER_TOKEN_UNAVAILABLE", "broker token is unavailable", 503 ) from exc if len(secret) < 32: raise BrokerClientError("BROKER_TOKEN_INVALID", "broker signing key is invalid", 503) return issue_broker_service_token( secret, context.project_slug, context.environment, ttl_seconds=60, ) class HttpBrokerClient: """Only named broker operations exist; arbitrary paths are not accepted.""" def __init__( self, base_url: str, token_provider: BrokerTokenProvider, *, transport: httpx.BaseTransport | None = None, ) -> None: if not base_url.startswith("http://") or any(char in base_url for char in "?#"): raise ValueError("broker base URL must be an internal HTTP origin without query data") self._client = httpx.Client(base_url=base_url.rstrip("/"), timeout=10, transport=transport) self._token_provider = token_provider def close(self) -> None: self._client.close() def _get( self, context: ConnectorContext, path: str, params: Mapping[str, str | int] | None = None ) -> dict[str, Any]: token = self._token_provider.get_token(context) try: response = self._client.get( path, params=params, headers={"Authorization": f"Bearer {token}"} ) except (httpx.TimeoutException, httpx.NetworkError) as exc: raise BrokerClientError( "BROKER_UNAVAILABLE", "Docker read broker is unavailable", 503 ) from exc if response.status_code >= 400: try: detail = response.json() except ValueError: detail = {} raise BrokerClientError( str(detail.get("code", "BROKER_REQUEST_FAILED")), "Docker read broker denied the request", response.status_code, ) try: value = response.json() except ValueError as exc: raise BrokerClientError( "BROKER_INVALID_RESPONSE", "Docker read broker returned invalid JSON" ) from exc if not isinstance(value, dict): raise BrokerClientError( "BROKER_INVALID_RESPONSE", "Docker read broker returned an unexpected response" ) return value def host(self, context: ConnectorContext) -> dict[str, Any]: return self._get(context, "/v1/host") def storage(self, context: ConnectorContext) -> dict[str, Any]: return self._get(context, "/v1/storage") def containers(self, context: ConnectorContext, state: str | None = None) -> dict[str, Any]: return self._get(context, "/v1/containers", {"state": state} if state else None) def inspect(self, context: ConnectorContext, registered_id: str) -> dict[str, Any]: return self._get(context, f"/v1/containers/{registered_id}") def health(self, context: ConnectorContext, registered_id: str) -> dict[str, Any]: return self._get(context, f"/v1/containers/{registered_id}/health") def logs( self, context: ConnectorContext, registered_id: str, *, tail: int, since_seconds: int | None, contains: str | None, ) -> dict[str, Any]: params: dict[str, str | int] = {"tail": tail} if since_seconds is not None: params["sinceSeconds"] = since_seconds if contains: params["contains"] = contains return self._get(context, f"/v1/containers/{registered_id}/logs", params) def networks(self, context: ConnectorContext) -> dict[str, Any]: return self._get(context, "/v1/networks")