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,336 @@
|
||||
# Copyright 2026 Cloudbase Solutions Srl
|
||||
# All Rights Reserved.
|
||||
|
||||
"""Test base classes for openvixdisklib integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import time
|
||||
import unittest
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from pyVim.connect import Disconnect
|
||||
from pyVmomi import vim
|
||||
|
||||
from openvixdisklib import nfc_auth
|
||||
from openvixdisklib.nfc_auth import NfcAuthSession
|
||||
|
||||
_REPO_ROOT = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
_CONFIG_PATH = os.path.join(_REPO_ROOT, ".test_config.yaml")
|
||||
_CONFIG_KEYS = (
|
||||
"host",
|
||||
"port",
|
||||
"username",
|
||||
"password",
|
||||
"allow_untrusted",
|
||||
"datacenter",
|
||||
"datastore",
|
||||
)
|
||||
_VDDK_DIR = os.path.join(_REPO_ROOT, ".vddk")
|
||||
_VDDK_LIB = os.path.join(_VDDK_DIR, "libvixDiskLib.so")
|
||||
_DISK_CAPACITY_KB = 10 * 1024 * 1024
|
||||
_TASK_POLL_S = 0.5
|
||||
_TASK_TIMEOUT_S = 300
|
||||
_LAB_VM_PREFIX = "ovdl-test-"
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
"""Shared lab vSphere settings for live NFC / VDDK integration tests."""
|
||||
|
||||
HOST: str
|
||||
PORT: int
|
||||
USERNAME: str
|
||||
PASSWORD: str
|
||||
ALLOW_UNTRUSTED: bool
|
||||
DATACENTER: str
|
||||
DATASTORE: str
|
||||
THUMBPRINT: str
|
||||
VM_MOREF: str
|
||||
VMX_SPEC: str
|
||||
DISK_PATH: str
|
||||
SECTOR_SIZE = 512
|
||||
SECTOR_AT_1GB = (1024 * 1024 * 1024) // SECTOR_SIZE
|
||||
VDDK_DIR = _VDDK_DIR
|
||||
_lab_refcount = 0
|
||||
_lab_vm_moref: Optional[str] = None
|
||||
_lab_vm_name: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
"""Prepare process environment and create a temporary lab VM."""
|
||||
super().setUpClass()
|
||||
os.environ.pop("LD_PRELOAD", None)
|
||||
cls._ensure_vddk_library_path()
|
||||
TestBase._acquire_lab()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
"""Release the temporary lab VM when the last test class finishes."""
|
||||
TestBase._release_lab()
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Reset per-test state; subclasses may reuse this."""
|
||||
super().setUp()
|
||||
|
||||
@classmethod
|
||||
def _ensure_vddk_library_path(cls) -> None:
|
||||
current = os.environ.get("LD_LIBRARY_PATH", "")
|
||||
parts = [p for p in current.split(":") if p]
|
||||
if cls.VDDK_DIR not in parts:
|
||||
os.environ["LD_LIBRARY_PATH"] = (
|
||||
cls.VDDK_DIR if not current else f"{cls.VDDK_DIR}:{current}")
|
||||
|
||||
@classmethod
|
||||
def require_vddk(cls) -> None:
|
||||
"""Skip when ``libvixDiskLib`` cannot be loaded from ``.vddk``."""
|
||||
cls._ensure_vddk_library_path()
|
||||
try:
|
||||
ctypes.CDLL(_VDDK_LIB)
|
||||
except OSError as exc:
|
||||
raise unittest.SkipTest(
|
||||
f"VDDK library not available at {_VDDK_LIB}: {exc}") from exc
|
||||
|
||||
@classmethod
|
||||
def _load_test_config(cls) -> None:
|
||||
"""Load lab settings from the repo-root ``.test_config.yaml``."""
|
||||
if not os.path.isfile(_CONFIG_PATH):
|
||||
raise unittest.SkipTest(
|
||||
"integration tests need .test_config.yaml in the repo "
|
||||
"root; see README.md for a sample")
|
||||
with open(_CONFIG_PATH, encoding="utf-8") as config_file:
|
||||
data = yaml.safe_load(config_file) or {}
|
||||
missing = [key for key in _CONFIG_KEYS if key not in data]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
f"{_CONFIG_PATH} is missing keys: {', '.join(missing)}")
|
||||
TestBase.HOST = str(data["host"])
|
||||
TestBase.PORT = int(data["port"])
|
||||
TestBase.USERNAME = str(data["username"])
|
||||
TestBase.PASSWORD = str(data["password"])
|
||||
TestBase.ALLOW_UNTRUSTED = bool(data["allow_untrusted"])
|
||||
TestBase.DATACENTER = str(data["datacenter"])
|
||||
TestBase.DATASTORE = str(data["datastore"])
|
||||
|
||||
@classmethod
|
||||
def _connect_vim(cls) -> vim.ServiceInstance:
|
||||
return nfc_auth.connect_vim(
|
||||
cls.HOST,
|
||||
cls.USERNAME,
|
||||
cls.PASSWORD,
|
||||
port=cls.PORT,
|
||||
thumbprint=cls.THUMBPRINT,
|
||||
allow_untrusted=cls.ALLOW_UNTRUSTED)
|
||||
|
||||
@classmethod
|
||||
def _wait_for_task(cls, task: vim.Task) -> Any:
|
||||
deadline = time.monotonic() + _TASK_TIMEOUT_S
|
||||
while task.info.state in (
|
||||
vim.TaskInfo.State.running, vim.TaskInfo.State.queued):
|
||||
if time.monotonic() > deadline:
|
||||
raise TimeoutError(
|
||||
f"timed out waiting for vSphere task {task}")
|
||||
time.sleep(_TASK_POLL_S)
|
||||
if task.info.state != vim.TaskInfo.State.success:
|
||||
raise RuntimeError(f"vSphere task failed: {task.info.error}")
|
||||
return task.info.result
|
||||
|
||||
@classmethod
|
||||
def _find_datacenter(
|
||||
cls, content: vim.ServiceInstanceContent) -> vim.Datacenter:
|
||||
matches = [
|
||||
entity for entity in content.rootFolder.childEntity
|
||||
if isinstance(entity, vim.Datacenter)
|
||||
and entity.name == cls.DATACENTER]
|
||||
if not matches:
|
||||
raise RuntimeError(f"datacenter {cls.DATACENTER!r} not found")
|
||||
return matches[0]
|
||||
|
||||
@classmethod
|
||||
def _find_datastore(cls, datacenter: vim.Datacenter) -> vim.Datastore:
|
||||
matches = [
|
||||
datastore for datastore in datacenter.datastore
|
||||
if datastore.name == cls.DATASTORE]
|
||||
if not matches:
|
||||
raise RuntimeError(
|
||||
f"datastore {cls.DATASTORE!r} not found in "
|
||||
f"datacenter {cls.DATACENTER!r}")
|
||||
return matches[0]
|
||||
|
||||
@classmethod
|
||||
def _bind_lab_fields(cls) -> None:
|
||||
"""Copy shared lab VM fields onto the active test class."""
|
||||
cls.HOST = TestBase.HOST
|
||||
cls.PORT = TestBase.PORT
|
||||
cls.USERNAME = TestBase.USERNAME
|
||||
cls.PASSWORD = TestBase.PASSWORD
|
||||
cls.ALLOW_UNTRUSTED = TestBase.ALLOW_UNTRUSTED
|
||||
cls.DATACENTER = TestBase.DATACENTER
|
||||
cls.DATASTORE = TestBase.DATASTORE
|
||||
cls.THUMBPRINT = TestBase.THUMBPRINT
|
||||
cls.VM_MOREF = TestBase.VM_MOREF
|
||||
cls.VMX_SPEC = TestBase.VMX_SPEC
|
||||
cls.DISK_PATH = TestBase.DISK_PATH
|
||||
|
||||
@classmethod
|
||||
def _acquire_lab(cls) -> None:
|
||||
if TestBase._lab_refcount == 0:
|
||||
TestBase._load_test_config()
|
||||
TestBase.THUMBPRINT = nfc_auth.get_ssl_cert_thumbprint(
|
||||
TestBase.HOST, TestBase.PORT)
|
||||
TestBase._create_lab_vm()
|
||||
TestBase._lab_refcount += 1
|
||||
cls._bind_lab_fields()
|
||||
|
||||
@classmethod
|
||||
def _release_lab(cls) -> None:
|
||||
if TestBase._lab_refcount == 0:
|
||||
return
|
||||
TestBase._lab_refcount -= 1
|
||||
if TestBase._lab_refcount == 0:
|
||||
cls._destroy_lab_vm()
|
||||
|
||||
@classmethod
|
||||
def _create_lab_vm(cls) -> None:
|
||||
"""Create an empty VM with a 10 GiB thin disk for I/O tests."""
|
||||
si = cls._connect_vim()
|
||||
vm = None
|
||||
try:
|
||||
content = si.RetrieveContent()
|
||||
datacenter = cls._find_datacenter(content)
|
||||
datastore = cls._find_datastore(datacenter)
|
||||
if not datastore.host:
|
||||
raise RuntimeError(
|
||||
f"datastore {cls.DATASTORE!r} is not mounted on any host")
|
||||
host = datastore.host[0].key
|
||||
pool = host.parent.resourcePool
|
||||
vm_name = _LAB_VM_PREFIX + uuid.uuid4().hex[:12]
|
||||
vm = cls._wait_for_task(
|
||||
datacenter.vmFolder.CreateVM_Task(
|
||||
config=cls._vm_config_spec(vm_name, datastore.name),
|
||||
pool=pool,
|
||||
host=host))
|
||||
TestBase._lab_vm_moref = vm._moId
|
||||
TestBase._lab_vm_name = vm_name
|
||||
TestBase.VM_MOREF = vm._moId
|
||||
TestBase.VMX_SPEC = f"moref={vm._moId}"
|
||||
disks = [
|
||||
device.backing.fileName
|
||||
for device in vm.config.hardware.device
|
||||
if isinstance(device, vim.vm.device.VirtualDisk)]
|
||||
if not disks:
|
||||
raise RuntimeError(
|
||||
f"temporary VM {vm_name!r} has no virtual disks")
|
||||
TestBase.DISK_PATH = disks[0]
|
||||
except Exception:
|
||||
if vm is not None:
|
||||
try:
|
||||
cls._wait_for_task(vm.Destroy_Task())
|
||||
except Exception:
|
||||
pass
|
||||
TestBase._lab_vm_moref = None
|
||||
TestBase._lab_vm_name = None
|
||||
raise
|
||||
finally:
|
||||
Disconnect(si)
|
||||
|
||||
@classmethod
|
||||
def _vm_config_spec(
|
||||
cls, vm_name: str, datastore_name: str) -> vim.vm.ConfigSpec:
|
||||
config = vim.vm.ConfigSpec()
|
||||
config.name = vm_name
|
||||
config.guestId = "otherGuest64"
|
||||
config.memoryMB = 128
|
||||
config.numCPUs = 1
|
||||
config.files = vim.vm.FileInfo(
|
||||
vmPathName=f"[{datastore_name}]")
|
||||
|
||||
controller = vim.vm.device.ParaVirtualSCSIController()
|
||||
controller.key = 1000
|
||||
controller.busNumber = 0
|
||||
controller.sharedBus = (
|
||||
vim.vm.device.VirtualSCSIController.Sharing.noSharing)
|
||||
controller_spec = vim.vm.device.VirtualDeviceSpec()
|
||||
controller_spec.operation = (
|
||||
vim.vm.device.VirtualDeviceSpec.Operation.add)
|
||||
controller_spec.device = controller
|
||||
|
||||
backing = vim.vm.device.VirtualDisk.FlatVer2BackingInfo()
|
||||
backing.diskMode = "persistent"
|
||||
backing.thinProvisioned = True
|
||||
backing.fileName = f"[{datastore_name}]"
|
||||
disk = vim.vm.device.VirtualDisk()
|
||||
disk.key = 2000
|
||||
disk.controllerKey = 1000
|
||||
disk.unitNumber = 0
|
||||
disk.capacityInKB = _DISK_CAPACITY_KB
|
||||
disk.backing = backing
|
||||
disk_spec = vim.vm.device.VirtualDeviceSpec()
|
||||
disk_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
|
||||
disk_spec.fileOperation = (
|
||||
vim.vm.device.VirtualDeviceSpec.FileOperation.create)
|
||||
disk_spec.device = disk
|
||||
|
||||
config.deviceChange = [controller_spec, disk_spec]
|
||||
return config
|
||||
|
||||
@classmethod
|
||||
def _destroy_lab_vm(cls) -> None:
|
||||
"""Power off and delete the temporary lab VM if it still exists."""
|
||||
moref = TestBase._lab_vm_moref
|
||||
TestBase._lab_vm_moref = None
|
||||
TestBase._lab_vm_name = None
|
||||
if not moref:
|
||||
return
|
||||
si = cls._connect_vim()
|
||||
try:
|
||||
vm = vim.VirtualMachine(moref, si._stub)
|
||||
try:
|
||||
vm.Reload()
|
||||
except Exception:
|
||||
return
|
||||
if vm.runtime.powerState == vim.VirtualMachinePowerState.poweredOn:
|
||||
cls._wait_for_task(vm.PowerOffVM_Task())
|
||||
cls._wait_for_task(vm.Destroy_Task())
|
||||
finally:
|
||||
Disconnect(si)
|
||||
|
||||
def authenticate(self, read_only: bool = True) -> NfcAuthSession:
|
||||
"""Login to the lab vCenter and complete NFC authd for the temp VM."""
|
||||
return nfc_auth.authenticate(
|
||||
host=self.HOST,
|
||||
username=self.USERNAME,
|
||||
password=self.PASSWORD,
|
||||
vm_moref=self.VM_MOREF,
|
||||
thumbprint=self.THUMBPRINT,
|
||||
allow_untrusted=self.ALLOW_UNTRUSTED,
|
||||
disk_path=None if read_only else self.DISK_PATH,
|
||||
read_only=read_only)
|
||||
|
||||
def vixdisklib_connect_kwargs(
|
||||
self, extra: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
"""Return common ``VixDiskLib_ConnectEx`` arguments for the temp VM."""
|
||||
kwargs: dict[str, Any] = {
|
||||
"server_name": self.HOST,
|
||||
"port": self.PORT,
|
||||
"thumbprint": self.THUMBPRINT,
|
||||
"username": self.USERNAME,
|
||||
"password": self.PASSWORD,
|
||||
"vmx_spec": self.VMX_SPEC,
|
||||
"transport_modes": "nbd",
|
||||
"read_only": False,
|
||||
}
|
||||
if extra:
|
||||
kwargs.update(extra)
|
||||
return kwargs
|
||||
|
||||
def pattern_bytes(self, length: int, seed: bytes) -> bytes:
|
||||
"""Return ``length`` bytes by repeating ``seed``."""
|
||||
if not seed:
|
||||
raise ValueError("seed must be non-empty")
|
||||
return (seed * ((length // len(seed)) + 1))[:length]
|
||||
Reference in New Issue
Block a user