Files
billing/openstack_client.py
2026-08-21 01:55:20 +00:00

92 lines
3.5 KiB
Python

"""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", [])