From e40d9e6113d533924d38a137d4402507cb4c3c22 Mon Sep 17 00:00:00 2001 From: Fei Su Date: Mon, 17 Aug 2026 10:14:37 +0000 Subject: [PATCH 1/3] fix(ci): satisfy ruff in examples/xscert.py The ruff job has been red since xscert.py landed: examples/xscert.py:29:1 I001 Import block is un-sorted or un-formatted examples/xscert.py:262:13 B007 Loop control variable `ref` not used Sort the imports, and iterate .values() since the ref is unused. --- python/examples/xscert.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/examples/xscert.py b/python/examples/xscert.py index 1d75868..e6c7bc5 100644 --- a/python/examples/xscert.py +++ b/python/examples/xscert.py @@ -34,9 +34,10 @@ import sys from pathlib import Path -from async_xenapi import AsyncXenAPISession from xs_common import connect_async, load_env_files +from async_xenapi import AsyncXenAPISession + PROG = os.path.basename(sys.argv[0]) or "xscert.py" KEY_BITS = "4096" @@ -259,7 +260,7 @@ async def cmd_login(args) -> None: # The client-cert role sits above read-only in the role order, and every # getter is _R_READ_ONLY, so reads work without any explicit grant. pools = await session.xenapi.pool.get_all_records() - for ref, pool in pools.items(): + for pool in pools.values(): print(f"[login] pool read: {pool.get('name_label')!r} " f"(cert auth name {pool.get('client_certificate_auth_name')!r})") finally: From 01d69f2815bb076c1ffe8c783812e54339e9c252 Mon Sep 17 00:00:00 2001 From: Fei Su Date: Mon, 17 Aug 2026 10:14:37 +0000 Subject: [PATCH 2/3] feat(python): raise a typed XenAPIError that keeps the structured error _call and login_with_password formatted the JSON-RPC error into a string and threw the object away, so callers had to substring-match the message to tell an RBAC denial from a bad reference: except RuntimeError as e: if 'RBAC_PERMISSION_DENIED' in str(e): ... XenAPIError keeps it: except XenAPIError as e: if e.code == 'RBAC_PERMISSION_DENIED': ... .code XAPI's error name, which JSON-RPC carries in `message` .params XAPI's error parameters (`data`) .error the raw error object .method the failing call It subclasses RuntimeError, so existing `except RuntimeError` keeps working, and a non-dict error degrades to code=None rather than raising. Bump to 1.0.6 so this and the 3.9 annotation fix can reach PyPI: the published 1.0.5 still declares requires-python >=3.12 and cannot be imported on 3.12/3.13. --- python/pyproject.toml | 2 +- python/src/async_xenapi/__init__.py | 4 ++-- python/src/async_xenapi/session.py | 35 +++++++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 2aa06ac..6580c74 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "async-xenapi" -version = "1.0.5" +version = "1.0.6" description = "An async library for XenAPI" readme = "README.md" license = { text = "LGPL-2.1-only" } diff --git a/python/src/async_xenapi/__init__.py b/python/src/async_xenapi/__init__.py index bdc1c29..590d2b2 100644 --- a/python/src/async_xenapi/__init__.py +++ b/python/src/async_xenapi/__init__.py @@ -1,5 +1,5 @@ """async_xenapi — Async XenAPI session via JSON-RPC (stdlib only).""" -from .session import AsyncXenAPISession +from .session import AsyncXenAPISession, XenAPIError -__all__ = ["AsyncXenAPISession"] +__all__ = ["AsyncXenAPISession", "XenAPIError"] diff --git a/python/src/async_xenapi/session.py b/python/src/async_xenapi/session.py index 35a4209..aad1a5d 100644 --- a/python/src/async_xenapi/session.py +++ b/python/src/async_xenapi/session.py @@ -67,6 +67,37 @@ def _jsonrpc_req(method: str, params: list[Any]) -> dict[str, Any]: # --------------------------------------------------------------------------- +class XenAPIError(RuntimeError): + """A XAPI call returned a JSON-RPC error. + + Subclasses RuntimeError so existing ``except RuntimeError`` keeps working. + + The structured error is preserved so callers do not have to match on the + rendered string: + + try: + await session.xenapi.VM.start(vm, False, False) + except XenAPIError as e: + if e.code == "RBAC_PERMISSION_DENIED": + ... + + ``code`` is XAPI's error name (``RBAC_PERMISSION_DENIED``, + ``HANDLE_INVALID``, ...), which JSON-RPC carries in the ``message`` field; + ``params`` is XAPI's error parameter list; ``error`` is the raw object. + """ + + def __init__(self, method: str, error: Any): + self.method = method + self.error = error + if isinstance(error, dict): + self.code = error.get("message") + self.params = error.get("data") or [] + else: # a server that does not follow the shape we expect + self.code = None + self.params = [] + super().__init__(f"XAPI {method} failed: {error}") + + class _MethodProxy: """Accumulates dotted attribute access (e.g. VM.get_all) then turns the final call into an awaitable JSON-RPC request.""" @@ -133,7 +164,7 @@ async def login_with_password(self, user: str, password: str) -> str: ) ret = await self._post(payload) if "error" in ret: - raise RuntimeError(f"Login failed: {ret['error']}") + raise XenAPIError("session.login_with_password", ret["error"]) self._session_ref = ret["result"] return self._session_ref @@ -154,5 +185,5 @@ async def _call(self, method: str, params: list[Any]) -> Any: payload = _jsonrpc_req(method, [self._session_ref] + params) ret = await self._post(payload) if "error" in ret: - raise RuntimeError(f"XAPI {method} failed: {ret['error']}") + raise XenAPIError(method, ret["error"]) return ret["result"] From f106fc0be53ab383b3610fcd6493163604e8baa1 Mon Sep 17 00:00:00 2001 From: Fei Su Date: Tue, 18 Aug 2026 02:33:45 +0000 Subject: [PATCH 3/3] feat(python): add client_cert_context() so callers stop hand-rolling it Every client-certificate consumer repeats the same four lines, and gets the ordering trap wrong at least once (check_hostname must be cleared before verify_mode, or CPython raises): ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE ctx.load_cert_chain(cert, key) Now: ctx = client_cert_context('client.crt', 'client.key') session = AsyncXenAPISession(url, ssl_context=ctx) It also makes server verification a first-class option rather than an afterthought: pass cafile= to verify the pool (CERT_REQUIRED + check_hostname), omit it for a lab pool with a self-signed certificate. The docstring says plainly that omitting it leaves the channel encrypted but unauthenticated. Verified against a real XS9 pool: without cafile the certificate login succeeds (client_certificate=True, subject=OpaqueRef:NULL); with cafile the handshake fails as it should, since that CA does not sign the pool's server certificate. --- python/src/async_xenapi/__init__.py | 4 +-- python/src/async_xenapi/session.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/python/src/async_xenapi/__init__.py b/python/src/async_xenapi/__init__.py index 590d2b2..f86b27b 100644 --- a/python/src/async_xenapi/__init__.py +++ b/python/src/async_xenapi/__init__.py @@ -1,5 +1,5 @@ """async_xenapi — Async XenAPI session via JSON-RPC (stdlib only).""" -from .session import AsyncXenAPISession, XenAPIError +from .session import AsyncXenAPISession, XenAPIError, client_cert_context -__all__ = ["AsyncXenAPISession", "XenAPIError"] +__all__ = ["AsyncXenAPISession", "XenAPIError", "client_cert_context"] diff --git a/python/src/async_xenapi/session.py b/python/src/async_xenapi/session.py index aad1a5d..7e20ca8 100644 --- a/python/src/async_xenapi/session.py +++ b/python/src/async_xenapi/session.py @@ -67,6 +67,44 @@ def _jsonrpc_req(method: str, params: list[Any]) -> dict[str, Any]: # --------------------------------------------------------------------------- +def client_cert_context( + certfile: str, + keyfile: str | None = None, + *, + cafile: str | None = None, + check_hostname: bool = True, +) -> ssl.SSLContext: + """Build an SSL context that presents a TLS client certificate. + + Pass the result as ``AsyncXenAPISession(url, ssl_context=...)`` to + authenticate with a certificate instead of a password:: + + ctx = client_cert_context("client.crt", "client.key") + session = AsyncXenAPISession("https://pool", ssl_context=ctx) + await session.login_with_password("ignored", "ignored") + + The certificate's CN/SAN must equal the pool's + ``client_certificate_auth_name``; XenServer's stunnel enforces that as + ``checkHost``. + + ``cafile`` verifies the *server* against a CA bundle. Omit it and server + verification is **disabled**, which is convenient against a lab pool + presenting a self-signed certificate but leaves the channel encrypted + without authenticating the peer -- pass ``cafile`` anywhere it matters. + """ + ctx = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_CLIENT) + if cafile is not None: + ctx.load_verify_locations(cafile) + ctx.check_hostname = check_hostname + ctx.verify_mode = ssl.CERT_REQUIRED + else: + # Must be cleared before verify_mode, or CPython raises. + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.load_cert_chain(certfile=certfile, keyfile=keyfile) + return ctx + + class XenAPIError(RuntimeError): """A XAPI call returned a JSON-RPC error.