From 4f498734dc1ba4b829f368b4c0ef1ec3929633bd Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 21 Aug 2026 01:55:20 +0000 Subject: [PATCH] Add openstack_client.py --- openstack_client.py | 91 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 openstack_client.py diff --git a/openstack_client.py b/openstack_client.py new file mode 100644 index 0000000..2a2e3c0 --- /dev/null +++ b/openstack_client.py @@ -0,0 +1,91 @@ +"""Inventory client: per-resource detail from Nova, Cinder, Neutron, Keystone. + +Endpoints come from the Keystone token catalog (public interface), so this +works both inside the cluster and externally (console.connectvm.com). +""" +import httpx + +from cloudkitty_client import KeystoneAuth +from config import Settings + +NOVA_MICROVERSION = "2.88" # embedded flavor objects in server details + + +class OpenStackClient: + def __init__(self, settings: Settings): + self._s = settings + self._auth = KeystoneAuth(settings) + self._client = httpx.AsyncClient( + verify=settings.verify_ssl, timeout=settings.http_timeout + ) + self._catalog: list | None = None + self._project_id: str | None = None + + async def close(self): + await self._client.aclose() + + async def _ensure_catalog(self): + if self._catalog is None: + body = await self._auth.token_body() + token = body.get("token", {}) + self._catalog = token.get("catalog", []) + self._project_id = token.get("project", {}).get("id") + + def _endpoint(self, service_type: str, override: str = "") -> str: + if override: + return override.rstrip("/") + for svc in self._catalog or []: + if svc.get("type") == service_type: + for ep in svc.get("endpoints", []): + if ep.get("interface") == "public": + return ep["url"].rstrip("/") + raise RuntimeError(f"no public endpoint for service type '{service_type}'") + + async def _get(self, url: str, params: dict | None = None, headers: dict | None = None): + token = await self._auth.token() + h = {"X-Auth-Token": token} + h.update(headers or {}) + r = await self._client.get(url, params=params, headers=h) + if r.status_code == 401: + h["X-Auth-Token"] = await self._auth.token(force=True) + r = await self._client.get(url, params=params, headers=h) + r.raise_for_status() + return r.json() + + async def projects(self) -> dict[str, str]: + """Map of project name -> project id.""" + data = await self._get(f"{self._s.keystone_auth_url}/v3/projects") + return {p["name"]: p["id"] for p in data.get("projects", [])} + + async def servers(self, project_id: str) -> list[dict]: + await self._ensure_catalog() + base = self._endpoint("compute", self._s.nova_url) + data = await self._get( + f"{base}/servers/detail", + params={"all_tenants": 1, "project_id": project_id, "limit": 1000}, + headers={"X-OpenStack-Nova-API-Version": NOVA_MICROVERSION}, + ) + return data.get("servers", []) + + async def volumes(self, project_id: str) -> list[dict]: + await self._ensure_catalog() + base = self._endpoint("volumev3", self._s.cinder_url).replace( + "%(project_id)s", self._project_id or "" + ) + data = await self._get( + f"{base}/volumes/detail", + params={"all_tenants": 1, "limit": 1000}, + ) + return [ + v + for v in data.get("volumes", []) + if v.get("os-vol-tenant-attr:tenant_id") == project_id + ] + + async def floatingips(self, project_id: str) -> list[dict]: + await self._ensure_catalog() + base = self._endpoint("network", self._s.neutron_url) + data = await self._get( + f"{base}/v2.0/floatingips", params={"tenant_id": project_id} + ) + return data.get("floatingips", [])