tests: use pytest instead of unittest

pytest provides session scoped fixtures, making it much easier
to reuse resources across tests.
This commit is contained in:
Lucian Petrut
2026-09-07 11:52:46 +00:00
parent 4ddf0c015d
commit 1e9c2816ef
15 changed files with 403 additions and 454 deletions
+242 -278
View File
@@ -1,17 +1,18 @@
# Copyright 2026 Cloudbase Solutions Srl
# All Rights Reserved.
"""Test base classes for openvixdisklib integration tests."""
"""Lab helpers for openvixdisklib integration tests."""
from __future__ import annotations
import ctypes
import os
import time
import unittest
import uuid
from dataclasses import dataclass
from typing import Any, Optional
import pytest
import yaml
from pyVim.connect import Disconnect
from pyVmomi import vim
@@ -38,290 +39,49 @@ _TASK_POLL_S = 0.5
_TASK_TIMEOUT_S = 300
_LAB_VM_PREFIX = "ovdl-test-"
VDDK_DIR = _VDDK_DIR
SECTOR_SIZE = 512
SECTOR_AT_1GB = (1024 * 1024 * 1024) // SECTOR_SIZE
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
@dataclass
class LabEnv:
"""vCenter settings and the temporary VM used by a pytest session."""
@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)
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
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,
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,
"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,
}
@@ -329,8 +89,212 @@ class TestBase(unittest.TestCase):
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]
def pattern_bytes(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]
def ensure_vddk_library_path() -> None:
"""Prepend ``.vddk`` to ``LD_LIBRARY_PATH`` if it is not already there."""
current = os.environ.get("LD_LIBRARY_PATH", "")
parts = [p for p in current.split(":") if p]
if VDDK_DIR not in parts:
os.environ["LD_LIBRARY_PATH"] = (
VDDK_DIR if not current else f"{VDDK_DIR}:{current}")
def require_vddk() -> None:
"""Skip when ``libvixDiskLib`` cannot be loaded from ``.vddk``."""
ensure_vddk_library_path()
try:
ctypes.CDLL(_VDDK_LIB)
except OSError as exc:
pytest.skip(f"VDDK library not available at {_VDDK_LIB}: {exc}")
def _load_test_config() -> dict[str, Any]:
if not os.path.isfile(_CONFIG_PATH):
pytest.skip(
"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)}")
return {
"host": str(data["host"]),
"port": int(data["port"]),
"username": str(data["username"]),
"password": str(data["password"]),
"allow_untrusted": bool(data["allow_untrusted"]),
"datacenter": str(data["datacenter"]),
"datastore": str(data["datastore"]),
}
def _connect_vim(
host: str,
username: str,
password: str,
port: int,
thumbprint: str,
allow_untrusted: bool) -> vim.ServiceInstance:
return nfc_auth.connect_vim(
host,
username,
password,
port=port,
thumbprint=thumbprint,
allow_untrusted=allow_untrusted)
def _wait_for_task(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
def _find_datacenter(
content: vim.ServiceInstanceContent,
datacenter_name: str) -> vim.Datacenter:
matches = [
entity for entity in content.rootFolder.childEntity
if isinstance(entity, vim.Datacenter)
and entity.name == datacenter_name]
if not matches:
raise RuntimeError(f"datacenter {datacenter_name!r} not found")
return matches[0]
def _find_datastore(
datacenter: vim.Datacenter, datastore_name: str) -> vim.Datastore:
matches = [
datastore for datastore in datacenter.datastore
if datastore.name == datastore_name]
if not matches:
raise RuntimeError(
f"datastore {datastore_name!r} not found in "
f"datacenter {datacenter.name!r}")
return matches[0]
def _vm_config_spec(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
def create_lab_vm() -> LabEnv:
"""Create an empty VM with a 10 GiB thin disk for I/O tests."""
cfg = _load_test_config()
thumbprint = nfc_auth.get_ssl_cert_thumbprint(cfg["host"], cfg["port"])
si = _connect_vim(
cfg["host"], cfg["username"], cfg["password"], cfg["port"],
thumbprint, cfg["allow_untrusted"])
vm = None
try:
content = si.RetrieveContent()
datacenter = _find_datacenter(content, cfg["datacenter"])
datastore = _find_datastore(datacenter, cfg["datastore"])
if not datastore.host:
raise RuntimeError(
f"datastore {cfg['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 = _wait_for_task(
datacenter.vmFolder.CreateVM_Task(
config=_vm_config_spec(vm_name, datastore.name),
pool=pool,
host=host))
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")
return LabEnv(
host=cfg["host"],
port=cfg["port"],
username=cfg["username"],
password=cfg["password"],
allow_untrusted=cfg["allow_untrusted"],
datacenter=cfg["datacenter"],
datastore=cfg["datastore"],
thumbprint=thumbprint,
vm_moref=vm._moId,
vmx_spec=f"moref={vm._moId}",
disk_path=disks[0])
except Exception:
if vm is not None:
try:
_wait_for_task(vm.Destroy_Task())
except Exception:
pass
raise
finally:
Disconnect(si)
def destroy_lab_vm(lab: LabEnv) -> None:
"""Power off and delete the temporary lab VM if it still exists."""
si = _connect_vim(
lab.host, lab.username, lab.password, lab.port,
lab.thumbprint, lab.allow_untrusted)
try:
vm = vim.VirtualMachine(lab.vm_moref, si._stub)
try:
vm.Reload()
except Exception:
return
if vm.runtime.powerState == vim.VirtualMachinePowerState.poweredOn:
_wait_for_task(vm.PowerOffVM_Task())
_wait_for_task(vm.Destroy_Task())
finally:
Disconnect(si)