Add openvixdisklib as an open NBD replacement for VMware VDDK.
VDDK is no longer publicly distributed, so this library reverse-engineers the vSphere NFC path and exposes ConnectEx, Open, Read, and Write without the proprietary SDK. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
# Copyright 2026 Cloudbase Solutions Srl
|
||||
# All Rights Reserved.
|
||||
|
||||
"""Drop-in replacement for ``tests.integration.vixdisklib`` that does not
|
||||
use VDDK.
|
||||
|
||||
Callers can switch with::
|
||||
|
||||
from openvixdisklib import openvixdisklib as vixdisklib
|
||||
|
||||
``VixDiskLibHandle.connect`` / ``open`` / ``read`` match the VDDK wrapper
|
||||
in ``tests/integration/vixdisklib.py``. VIM login uses pyVmomi; NFC ticket,
|
||||
authd, and disk I/O use ``nfc_auth`` and ``nfc_open``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
from typing import Iterator, Optional, Union
|
||||
|
||||
from pyVim.connect import Disconnect
|
||||
from pyVmomi import vim
|
||||
|
||||
from openvixdisklib import nfc_auth
|
||||
from openvixdisklib import nfc_open
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
VIXDISKLIB_VERSION_MAJOR = 8
|
||||
VIXDISKLIB_VERSION_MINOR = 0
|
||||
|
||||
VIXDISKLIB_SECTOR_SIZE = 512
|
||||
|
||||
VIXDISKLIB_CRED_UID = 1
|
||||
|
||||
VIXDISKLIB_FLAG_OPEN_UNBUFFERED = 1
|
||||
VIXDISKLIB_FLAG_OPEN_SINGLE_LINK = 2
|
||||
VIXDISKLIB_FLAG_OPEN_READ_ONLY = 4
|
||||
|
||||
VIXDISKLIB_FLAG_OPEN_COMPRESSION_ZLIB = 16
|
||||
VIXDISKLIB_FLAG_OPEN_COMPRESSION_FASTLZ = 32
|
||||
VIXDISKLIB_FLAG_OPEN_COMPRESSION_SKIPZ = 64
|
||||
|
||||
_COMPRESSION_FLAGS = (
|
||||
VIXDISKLIB_FLAG_OPEN_COMPRESSION_ZLIB
|
||||
| VIXDISKLIB_FLAG_OPEN_COMPRESSION_FASTLZ
|
||||
| VIXDISKLIB_FLAG_OPEN_COMPRESSION_SKIPZ)
|
||||
|
||||
VIX_SUPPORTED_COMPATIBILITY_MODES = [
|
||||
"6.0", "6.5", "6.7", "7.0", "8.0"]
|
||||
|
||||
|
||||
def get_buffer(size: int):
|
||||
"""Return a ctypes buffer of ``size`` bytes, as the VDDK wrapper did."""
|
||||
return ctypes.create_string_buffer(size)
|
||||
|
||||
|
||||
def _parse_vm_moref(vmx_spec: Optional[str]) -> str:
|
||||
if not vmx_spec:
|
||||
raise ValueError(
|
||||
"vmx_spec is required (for example 'moref=vm-13098')")
|
||||
if "=" in vmx_spec:
|
||||
kind, value = vmx_spec.split("=", 1)
|
||||
if kind.lower() != "moref" or not value:
|
||||
raise ValueError(f"unsupported vmx_spec: {vmx_spec}")
|
||||
return value
|
||||
return vmx_spec
|
||||
|
||||
|
||||
def _require_nbd(transport_modes: Optional[str]) -> None:
|
||||
if transport_modes is None:
|
||||
return
|
||||
modes = [m for m in transport_modes.split(":") if m]
|
||||
if "nbd" not in modes:
|
||||
raise NotImplementedError(
|
||||
f"only nbd transport is supported, got {transport_modes!r}")
|
||||
|
||||
|
||||
class _Connection:
|
||||
"""VIM session plus the VM moref needed to issue an NFC ticket at Open."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
si: vim.ServiceInstance,
|
||||
vm_moref: str,
|
||||
snapshot_ref: Optional[str],
|
||||
thumbprint: Optional[str],
|
||||
allow_untrusted: bool,
|
||||
read_only: bool) -> None:
|
||||
self.si = si
|
||||
self.vm_moref = vm_moref
|
||||
self.snapshot_ref = snapshot_ref
|
||||
self.thumbprint = thumbprint
|
||||
self.allow_untrusted = allow_untrusted
|
||||
self.read_only = read_only
|
||||
|
||||
|
||||
class _DiskHandle:
|
||||
"""Opened NFC disk plus the authd TLS socket it was taken from."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
disk: nfc_open.NfcDisk,
|
||||
authd_sock) -> None:
|
||||
self.disk = disk
|
||||
self.authd_sock = authd_sock
|
||||
|
||||
|
||||
class VixDiskLibHandle:
|
||||
"""VDDK-compatible handle backed by pyVmomi and the NFC replacement."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_path: Optional[str] = None,
|
||||
vixdisklib_compatibility_version: Optional[str] = None) -> None:
|
||||
"""Accept the VDDK wrapper constructor; no native library is loaded.
|
||||
|
||||
Args:
|
||||
config_path: Ignored. VDDK used this for logging plugins.
|
||||
vixdisklib_compatibility_version: Optional ``major.minor`` string
|
||||
such as ``8.0``. Validated for form only.
|
||||
"""
|
||||
del config_path
|
||||
target_versions = VIX_SUPPORTED_COMPATIBILITY_MODES
|
||||
if vixdisklib_compatibility_version:
|
||||
target_versions = [vixdisklib_compatibility_version]
|
||||
LOG.debug("vixDiskLib versions targeted: %s", target_versions)
|
||||
|
||||
version_used = None
|
||||
for version in reversed(target_versions):
|
||||
try:
|
||||
major_ver, minor_ver = version.split(".")
|
||||
int(major_ver)
|
||||
int(minor_ver)
|
||||
except ValueError as ex:
|
||||
raise ValueError(
|
||||
"Unsupported vixDiskLib version format '%s'. vixDiskLib "
|
||||
"compatibility mode must be of the form "
|
||||
"'$major.$minor'" % version) from ex
|
||||
version_used = version
|
||||
break
|
||||
|
||||
if not version_used:
|
||||
raise Exception(
|
||||
"Could not initialize vixDiskLib with any of the following "
|
||||
"versions: %s" % target_versions)
|
||||
|
||||
LOG.info(
|
||||
"Successfully initialized vixDiskLib with target version '%s'",
|
||||
version_used)
|
||||
|
||||
@classmethod
|
||||
def get_vix_disklib_name(cls) -> str:
|
||||
"""Return the native library name; this replacement does not load it."""
|
||||
if os.name == "nt":
|
||||
return "vixDiskLib.dll"
|
||||
return "libvixDiskLib.so"
|
||||
|
||||
def get_transport_modes(self) -> list[str]:
|
||||
"""Return the transport modes this replacement implements."""
|
||||
return ["nbd"]
|
||||
|
||||
def get_transport_mode(self, disk_handle: _DiskHandle) -> str:
|
||||
"""Return the transport used for ``disk_handle``."""
|
||||
del disk_handle
|
||||
return "nbd"
|
||||
|
||||
@contextlib.contextmanager
|
||||
def connect(
|
||||
self,
|
||||
server_name: str,
|
||||
thumbprint: Optional[str],
|
||||
username: str,
|
||||
password: str,
|
||||
vmx_spec: Optional[str] = None,
|
||||
snapshot_ref: Optional[str] = None,
|
||||
read_only: bool = True,
|
||||
transport_modes: Optional[str] = None,
|
||||
port: int = 443,
|
||||
allow_untrusted: bool = False) -> Iterator[_Connection]:
|
||||
"""Login to vCenter/ESXi. Matches ``VixDiskLib_ConnectEx``.
|
||||
|
||||
The NFC ticket and authd handshake are deferred to ``open``, as in
|
||||
VDDK. Writable opens use ``NfcRandomAccessOpenDisk``; read-only
|
||||
opens use ``NfcGetVmFiles``. ``snapshot_ref`` is accepted for API
|
||||
compatibility and is not sent on the ticket SOAP call.
|
||||
|
||||
Args:
|
||||
server_name: vCenter or ESXi hostname/IP.
|
||||
thumbprint: SHA-1 thumbprint of the management TLS certificate.
|
||||
username: VIM user name.
|
||||
password: VIM password.
|
||||
vmx_spec: VM selector, ``moref=vm-…``.
|
||||
snapshot_ref: Snapshot moref; unused on the NFC ticket.
|
||||
read_only: When False, the disk may be opened for write.
|
||||
transport_modes: ``nbd`` or a colon list that includes ``nbd``.
|
||||
port: HTTPS port, usually 443.
|
||||
allow_untrusted: Skip management TLS verification when True.
|
||||
"""
|
||||
LOG.debug("Connecting VixDiskLib: %s", server_name)
|
||||
_require_nbd(transport_modes)
|
||||
vm_moref = _parse_vm_moref(vmx_spec)
|
||||
si = nfc_auth.connect_vim(
|
||||
server_name,
|
||||
username,
|
||||
password,
|
||||
port=port,
|
||||
thumbprint=thumbprint,
|
||||
allow_untrusted=allow_untrusted or not thumbprint)
|
||||
conn = _Connection(
|
||||
si, vm_moref, snapshot_ref, thumbprint,
|
||||
allow_untrusted or not thumbprint, read_only)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
self.disconnect(conn)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def open(
|
||||
self,
|
||||
conn: _Connection,
|
||||
disk_path: str,
|
||||
flags: int = VIXDISKLIB_FLAG_OPEN_READ_ONLY) -> Iterator[_DiskHandle]:
|
||||
"""Open ``disk_path`` over NFC. Matches ``VixDiskLib_Open``.
|
||||
|
||||
Args:
|
||||
conn: Connection from ``connect``.
|
||||
disk_path: Datastore path of the VMDK.
|
||||
flags: Open flags. ``VIXDISKLIB_FLAG_OPEN_READ_ONLY`` opens
|
||||
the disk read-only; omit it for write. Compression flags
|
||||
are not implemented.
|
||||
"""
|
||||
LOG.debug("Openning VixDiskLib disk: %s", disk_path)
|
||||
if flags & _COMPRESSION_FLAGS:
|
||||
raise NotImplementedError(
|
||||
"NBD compression open flags are not supported")
|
||||
read_only = bool(flags & VIXDISKLIB_FLAG_OPEN_READ_ONLY)
|
||||
if not read_only and conn.read_only:
|
||||
raise NotImplementedError(
|
||||
"ConnectEx was read-only; cannot open for write")
|
||||
|
||||
vm = vim.VirtualMachine(conn.vm_moref, conn.si._stub)
|
||||
ticket = nfc_auth.get_nfc_ticket(
|
||||
conn.si, vm, read_only=read_only, disk_path=disk_path)
|
||||
authd_sock = nfc_auth.connect_authd(
|
||||
ticket, allow_untrusted=conn.allow_untrusted)
|
||||
session = nfc_auth.NfcAuthSession(conn.si, ticket, authd_sock)
|
||||
try:
|
||||
disk = nfc_open.open_disk(
|
||||
session, disk_path, read_only=read_only)
|
||||
except Exception:
|
||||
authd_sock.close()
|
||||
raise
|
||||
handle = _DiskHandle(disk, authd_sock)
|
||||
try:
|
||||
yield handle
|
||||
finally:
|
||||
self.close(handle)
|
||||
|
||||
def read(
|
||||
self,
|
||||
disk_handle: _DiskHandle,
|
||||
start_sector: int,
|
||||
num_sectors: int,
|
||||
buf: Union[ctypes.Array, bytearray, memoryview]) -> None:
|
||||
"""Read ``num_sectors`` from ``start_sector`` into ``buf``.
|
||||
|
||||
Args:
|
||||
disk_handle: Handle from ``open``.
|
||||
start_sector: First sector to read.
|
||||
num_sectors: Number of sectors to read.
|
||||
buf: Destination buffer (``get_buffer`` or a writable bytes-like).
|
||||
"""
|
||||
data = disk_handle.disk.read(start_sector, num_sectors)
|
||||
if isinstance(buf, (bytearray, memoryview)):
|
||||
if len(buf) < len(data):
|
||||
raise Exception(
|
||||
f"read buffer is {len(buf)} bytes, need {len(data)}")
|
||||
buf[:len(data)] = data
|
||||
return
|
||||
ctypes.memmove(buf, data, len(data))
|
||||
|
||||
def write(
|
||||
self,
|
||||
disk_handle: _DiskHandle,
|
||||
start_sector: int,
|
||||
num_sectors: int,
|
||||
buf: Union[ctypes.Array, bytes, bytearray, memoryview]) -> None:
|
||||
"""Write ``num_sectors`` from ``buf`` starting at ``start_sector``.
|
||||
|
||||
Args:
|
||||
disk_handle: Handle from ``open``.
|
||||
start_sector: First sector to write.
|
||||
num_sectors: Number of sectors to write.
|
||||
buf: Source buffer (``get_buffer`` or a bytes-like).
|
||||
"""
|
||||
length = num_sectors * VIXDISKLIB_SECTOR_SIZE
|
||||
if isinstance(buf, (bytes, bytearray, memoryview)):
|
||||
data = bytes(buf[:length])
|
||||
else:
|
||||
data = buf.raw[:length]
|
||||
disk_handle.disk.write(start_sector, num_sectors, data)
|
||||
|
||||
def close(self, disk_handle: _DiskHandle) -> None:
|
||||
"""Close the VMDK and the authd socket used for NFC.
|
||||
|
||||
Args:
|
||||
disk_handle: Handle from ``open``.
|
||||
"""
|
||||
LOG.debug("Closing VixDiskLib disk handle: %s", disk_handle)
|
||||
try:
|
||||
disk_handle.disk.close()
|
||||
finally:
|
||||
try:
|
||||
disk_handle.authd_sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def disconnect(self, conn: _Connection) -> None:
|
||||
"""Logout of the VIM session.
|
||||
|
||||
Args:
|
||||
conn: Connection from ``connect``.
|
||||
"""
|
||||
LOG.debug("Disconnecting VixDiskLib")
|
||||
Disconnect(conn.si)
|
||||
|
||||
def exit(self) -> None:
|
||||
"""No-op; there is no native VDDK library to tear down."""
|
||||
return
|
||||
Reference in New Issue
Block a user