"""PLS API client for cluster, per-node discovery, and site-wide config.""" from __future__ import annotations from urllib.parse import urlsplit, urlunsplit import httpx from app.config import PLS_AUTH_BACKEND, PLS_BASE_URL, PLS_PASSWORD, PLS_USERNAME, PLS_VERIFY_TLS _token: str | None = None class PlsRequestError(RuntimeError): pass def _base_url_for_host(host: str | None = None) -> str: if not host: return PLS_BASE_URL.rstrip("/") parts = urlsplit(PLS_BASE_URL) return urlunsplit((parts.scheme, host, parts.path.rstrip("/"), "", "")) async def _login(force: bool = False) -> str | None: global _token if _token and not force: return _token if not PLS_USERNAME or not PLS_PASSWORD: return None try: async with httpx.AsyncClient(timeout=5, verify=PLS_VERIFY_TLS) as client: response = await client.post( f"{_base_url_for_host()}/auth/login", json={ "username": PLS_USERNAME, "password": PLS_PASSWORD, "backend": PLS_AUTH_BACKEND, }, ) response.raise_for_status() data = response.json() _token = data.get("access_token") return _token except Exception: return None async def _get(path: str, host: str | None = None) -> dict | list | None: token = await _login() if not token: return None url = f"{_base_url_for_host(host)}/{path.lstrip('/')}" try: async with httpx.AsyncClient(timeout=5, verify=PLS_VERIFY_TLS) as client: response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) if response.status_code in {401, 403}: refreshed = await _login(force=True) if not refreshed: return None response = await client.get(url, headers={"Authorization": f"Bearer {refreshed}"}) response.raise_for_status() return response.json() except Exception: return None async def _put(path: str, payload: dict, host: str | None = None) -> dict | list | None: token = await _login() if not token: raise PlsRequestError("PLS authentication is not configured or login failed") url = f"{_base_url_for_host(host)}/{path.lstrip('/')}" try: async with httpx.AsyncClient(timeout=8, verify=PLS_VERIFY_TLS) as client: response = await client.put(url, headers={"Authorization": f"Bearer {token}"}, json=payload) if response.status_code in {401, 403}: refreshed = await _login(force=True) if not refreshed: raise PlsRequestError("PLS token expired and re-login failed") response = await client.put(url, headers={"Authorization": f"Bearer {refreshed}"}, json=payload) if response.is_error: detail = response.text.strip() raise PlsRequestError(f"HTTP {response.status_code}: {detail or 'unknown PLS validation error'}") return response.json() except PlsRequestError: raise except Exception as exc: raise PlsRequestError(str(exc)) from exc def node_host(node_name: str) -> str: return node_name.split("@", 1)[1] if "@" in node_name else node_name async def get_cluster_status() -> dict | None: data = await _get("data_layer/cluster/status") return data if isinstance(data, dict) else None async def get_system_info(host: str | None = None) -> dict | None: data = await _get("system/info", host=host) return data if isinstance(data, dict) else None async def get_services(host: str | None = None) -> list[dict]: data = await _get("services", host=host) return data if isinstance(data, list) else [] async def get_fluentbit_config() -> dict | None: data = await _get("fluent-bit/config") return data if isinstance(data, dict) else None async def put_fluentbit_config(config: dict) -> dict | None: data = await _put("fluent-bit/config", config) return data if isinstance(data, dict) else None async def get_log_config(host: str | None = None) -> dict | None: data = await _get("mgmt/config/logs", host=host) return data if isinstance(data, dict) else None async def put_log_config(config: dict, host: str | None = None) -> dict | None: data = await _put("mgmt/config/logs", config, host=host) return data if isinstance(data, dict) else None