From 7c62fc377336fcd926f2b7ec829133e42ac923c2 Mon Sep 17 00:00:00 2001 From: Lucian Petrut Date: Fri, 18 Sep 2026 14:39:28 +0000 Subject: [PATCH] Add Linux-guest HotAdd transport The HotAdd transport can be leveraged when using OpenVixDiskLib inside a VMware Linux VM. Instead of contacting ESXi over NBD/NFC, the disks are attached directly to the VM. --- README.md | 24 +- docs/hotadd.md | 81 +++ docs/reverse_engineering_procedure.md | 16 +- openvixdisklib/hotadd.py | 724 ++++++++++++++++++++++++++ openvixdisklib/openvixdisklib.py | 102 ++-- tests/integration/base.py | 59 ++- tests/integration/hotadd_proxy.py | 139 +++++ tests/integration/hotadd_remote.py | 84 +++ tests/integration/test_hotadd.py | 184 +++++++ tests/perf/hotadd_remote.py | 80 +++ tests/perf/test_compare.py | 148 +++++- tests/unit/test_hotadd.py | 276 ++++++++++ 12 files changed, 1844 insertions(+), 73 deletions(-) create mode 100644 docs/hotadd.md create mode 100644 openvixdisklib/hotadd.py create mode 100644 tests/integration/hotadd_proxy.py create mode 100644 tests/integration/hotadd_remote.py create mode 100644 tests/integration/test_hotadd.py create mode 100644 tests/perf/hotadd_remote.py create mode 100644 tests/unit/test_hotadd.py diff --git a/README.md b/README.md index 254794b..8b9364f 100644 --- a/README.md +++ b/README.md @@ -13,21 +13,25 @@ Python naming). VIM login and inventory use [pyVmomi](https://github.com/vmware/pyvmomi). The NFC ticket, ESXi authd handshake, and disk I/O were reverse-engineered -from VDDK 8 NBD traffic; see `docs/`. +from VDDK 8 NBD traffic; see `docs/`. Linux HotAdd uses the public +vSphere `ReconfigureVM` API (see `docs/hotadd.md`). ## Status Implemented against vCenter 8 / ESXi 8. Default transport is `nbdssl` -(`nbd` is still available): +(`nbd` is still available). Linux guests can also use `hotadd`: - `VixDiskLib_ConnectEx` (UID credentials) - `VixDiskLib_Open` (datastore path, read-only or read-write) - `VixDiskLib_Read` (optional ``skip_decompression`` packs FastLZ extras) - `VixDiskLib_Write` +- HotAdd on a Linux VMware guest (SCSI, NVMe, or SATA source disks, + attached onto a proxy SCSI controller) Not implemented: compression open flags other than FastLZ, CBT / allocated-block queries, disk geometry (`DDB_GET`), encrypted disks, -and direct ESXi `ha-nfc` without vCenter `vpxa-nfc`. +direct ESXi `ha-nfc` without vCenter `vpxa-nfc`, SAN / file transports, +Windows HotAdd, and HotAdd onto a proxy NVMe controller. Requires Python 3.10 or later. @@ -73,6 +77,7 @@ VDDK-shaped handle. | `openvixdisklib/openvixdisklib.py` | Drop-in handle (`connect` / `open` / `read` / `write`) | | `openvixdisklib/nfc_auth.py` | VIM login, NFC ticket, authd on 902 | | `openvixdisklib/nfc_open.py` | Classic NFC handshake, AIO open, sector read/write | +| `openvixdisklib/hotadd.py` | Linux-guest SCSI HotAdd attach, local block I/O | | `openvixdisklib/fastlz.py` | FastLZ NFC adapter (pip `pyfastlz`) | | `tests/integration/` | Live pytest suite against a lab vCenter | | `tests/perf/` | Throughput comparison of OpenVixDiskLib vs VDDK | @@ -96,11 +101,16 @@ password: secret allow_untrusted: true datacenter: Datacenter datastore: datastore0 +hotadd_proxy: + host: hotadd-proxy.example.com + user: root ``` A session-scoped pytest fixture creates an empty VM with a 10 GiB thin disk on that datastore and tears it down when the session ends. Tests -write known patterns and read them back. +write known patterns and read them back. HotAdd tests SSH into +`hotadd_proxy` (a Linux guest on the same datastore) and skip if SSH +fails. ```bash tox -e integration @@ -117,7 +127,10 @@ tox -e integration -- --runslow Compare write/read throughput of OpenVixDiskLib and native VDDK (`64KiB`, 129-sector, and `32MiB` transfers; `nbdssl` and `nbd`; plain, FastLZ, and OpenVixDiskLib FastLZ ``skip_decompression``; -AIO sessions 64 KiB×1, 1 MiB×1, 2 MiB×1, and 2 MiB×4). +AIO sessions 64 KiB×1, 1 MiB×1, 2 MiB×1, and 2 MiB×4). The same sizes +are also timed over Linux-guest ``hotadd`` (plain OpenVixDiskLib I/O +on `hotadd_proxy`; FastLZ and NFC AIO do not apply) and skipped if +SSH to the proxy fails. ```bash tox -e perf @@ -145,5 +158,6 @@ Lint and typecheck: `tox -e pep8`, `tox -e mypy`. | `docs/nfc_open.md` | Classic NFC and AIO open | | `docs/nfc_read.md` | AIO IO / `VixDiskLib_Read` | | `docs/nfc_write.md` | AIO IO / `VixDiskLib_Write` | +| `docs/hotadd.md` | Linux-guest SCSI HotAdd | | `docs/ssl_hook.md` | TLS intercept used for capture | | `docs/reverse_engineering_procedure.md` | How the protocol was recovered | diff --git a/docs/hotadd.md b/docs/hotadd.md new file mode 100644 index 0000000..6e14ca3 --- /dev/null +++ b/docs/hotadd.md @@ -0,0 +1,81 @@ +# HotAdd transport + +OpenVixDiskLib can SCSI-HotAdd a VMDK onto the Linux guest that is +running the library, then read and write it as a local block device. +This is not an NFC protocol: it uses public VIM `ReconfigureVM` plus +guest SCSI I/O. There is no VixTransport linked clone and no VMDK +parser; ESXi presents a single SCSI LUN. + +NBD and NBDSSL remain the default. `transport_modes=None` is still +`nbdssl`. `hotadd` is advertised and selected only when the process is +a VMware guest (`/sys/class/dmi/id/sys_vendor`). + +## Mapping from VDDK + +| VDDK behaviour | OpenVixDiskLib | +| -------------- | -------------- | +| Run inside a proxy VM | Same. DMI UUID is matched to `config.uuid`. | +| SCSI HotAdd of the source VMDK | `ReconfigureVM` add of an existing backing onto a **SCSI** controller on the proxy | +| Linked clone via VixTransport | Not implemented. The snapshot or base VMDK is attached directly. | +| Open as a whole-disk VMDK | Open `/dev/sdX` with `pread` / `pwrite` | +| IDE disks | Not supported (same as VDDK) | +| NVMe / SATA source disks | Supported. The backing file is attached onto proxy SCSI; the guest sees `/dev/sdX`, not `/dev/nvme*`. | +| HotAdd onto a proxy NVMe controller | Not implemented | + +Colon lists such as `file:san:hotadd:nbdssl:nbd` pick the first **usable** +mode. On a bare-metal host that is `nbdssl`. Inside a guest it is +`hotadd`. `"hotadd"` alone on bare metal raises `NotImplementedError`. + +## Attach and detach + +1. Find this guest in vCenter (`SearchIndex.FindByUuid`). +2. Resolve `disk_path` on the source VM. SCSI, NVMe + (`VirtualNVMEController`), and SATA (`VirtualAHCIController`) are + accepted. IDE and RDM are rejected. A powered-on source VM requires + `snapshot_ref`; a powered-off VM may attach the base disk. +3. Add the existing VMDK to a free SCSI unit on the proxy (unit 7 is + skipped). If every unit is taken, a PVSCSI controller is added. + Read-only opens use `independent_nonpersistent` (redo log, source + stays clean). Writable opens use `persistent`. +4. Rescan SCSI hosts and wait for the device. Matching prefers sysfs + `bus:0:unit:0`, then `*:0:unit:0` when `unit != 0`. +5. `close` detaches with `Operation.remove` and **no** `fileOperation`. + The source VMDK must not be deleted. Leftover attachments of the + same backing are detached before a new open. + +Never HotAdd the proxy's own boot disk. Never use “newest `sdX`” as the +only match when a unique SCSI address exists. + +Do not remove the source VM or its snapshot while the disk is still +attached. Independent-nonpersistent attaches create a redo log on the +source datastore; detach is what cleans it up. + +## API + +`VixDiskLibHandle.connect(..., transport_modes="hotadd")` then +`open` / `read` / `write` / `close` as for NBD. Compression open flags +and NFC `skip_decompression` do not apply; FastLZ flags on a HotAdd +open raise `NotImplementedError`. `readinto` returns a `ReadResult` +with empty `fragments`. + +Implementation: `openvixdisklib.hotadd`. + +## Lab + +Live tests SSH into a Linux proxy that shares the lab datastore and +run `tests/integration/hotadd_remote.py` there. Configure +`.test_config.yaml`: + +```yaml +hotadd_proxy: + host: hotadd-proxy.example.com + user: root + # identity_file: /home/user/.ssh/id_ed25519 +``` + +Tests skip when SSH is unavailable. The session lab VM (PVSCSI) and a +function-scoped NVMe VM are HotAdded onto the proxy, written, and +checked again over `nbdssl` from the runner. `tox -e perf` times the +same transfer sizes over HotAdd (plain I/O; FastLZ and NFC AIO do not +apply). Dependencies on the proxy are installed into +`/tmp/openvixdisklib-hotadd/.venv`, not the system Python. diff --git a/docs/reverse_engineering_procedure.md b/docs/reverse_engineering_procedure.md index b988f5c..c9cce38 100644 --- a/docs/reverse_engineering_procedure.md +++ b/docs/reverse_engineering_procedure.md @@ -9,9 +9,11 @@ NFC work can follow the same loop instead of rediscovering it. Scope so far: `VixDiskLib_ConnectEx` + `VixDiskLib_Open` + `VixDiskLib_Read` + `VixDiskLib_Write` against lab vCenter 8.0.1 / -ESXi 8, transports `nbd` and `nbdssl`. Validation method: -`tests/integration/` (the session-scoped `lab` fixture creates a temporary -empty VM with a 10 GiB disk and destroys it when the pytest session ends). +ESXi 8, transports `nbd`, `nbdssl`, and Linux-guest `hotadd`. +Validation method: `tests/integration/` (the session-scoped `lab` +fixture creates a temporary empty VM with a 10 GiB disk and destroys it +when the pytest session ends). HotAdd live tests also SSH into a Linux +proxy guest; see `docs/hotadd.md`. Rule from `AGENTS.md`: reuse pyVmomi for every public VIM operation. Only reimplement what pyVmomi does not expose. @@ -409,3 +411,11 @@ Not yet reversed, same loop as above: - `VixDiskLib_GetInfo` capacity - Host-switch AIO messages - Direct ESXi `ha-nfc` without vCenter `vpxa-nfc` + +## HotAdd (not NFC) + +HotAdd does not use the capture loop above. VDDK SCSI-attaches the +source VMDK to the proxy VM and opens a local whole disk. OpenVixDiskLib +reuses pyVmomi `ReconfigureVM` for attach/detach and `pread`/`pwrite` on +the Linux SCSI device. NVMe and SATA source disks are remapped onto a +proxy SCSI controller. Details: `docs/hotadd.md`. diff --git a/openvixdisklib/hotadd.py b/openvixdisklib/hotadd.py new file mode 100644 index 0000000..19be8a9 --- /dev/null +++ b/openvixdisklib/hotadd.py @@ -0,0 +1,724 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Linux-guest HotAdd transport: SCSI-attach a VMDK and read it locally. + +The source disk may sit on SCSI, NVMe, or SATA in the backup VM. This +module always HotAdds that backing onto a SCSI controller of the proxy +VM (the guest running this process), then I/Os ``/dev/sdX``. IDE disks +and RDMs are not supported. +""" + +from __future__ import annotations + +import logging +import os +import time +from collections.abc import Callable, Iterable +from dataclasses import dataclass + +from pyVmomi import vim + +from openvixdisklib.nfc_open import ReadResult + +LOG = logging.getLogger(__name__) + +SECTOR_SIZE = 512 +SCSI_RESERVED_UNIT = 7 +SCSI_MAX_UNIT = 15 +SCSI_MAX_BUS = 3 +SCSI_CHANNEL = 0 +SCSI_LUN = 0 +TASK_POLL_S = 0.5 +TASK_TIMEOUT_S = 300 +DEVICE_WAIT_S = 120 +DEVICE_POLL_S = 0.5 +DMI_VENDOR_PATH = "/sys/class/dmi/id/sys_vendor" +DMI_UUID_PATH = "/sys/class/dmi/id/product_uuid" +SCSI_HOST_DIR = "/sys/class/scsi_host" +SCSI_DEVICE_DIR = "/sys/bus/scsi/devices" + + +def is_vmware_guest() -> bool: + """Return True when this process is running in a VMware guest.""" + vendor = _read_sysfs(DMI_VENDOR_PATH) + return vendor is not None and "vmware" in vendor.lower() + + +def _read_sysfs(path: str) -> str | None: + try: + with open(path, encoding="utf-8") as handle: + return handle.read().strip() + except OSError: + return None + + +def guest_uuid() -> str: + """Return the SMBIOS UUID of this guest, or raise if it is missing.""" + uuid = _read_sysfs(DMI_UUID_PATH) + if not uuid: + raise RuntimeError(f"cannot read guest UUID from {DMI_UUID_PATH}") + return uuid + + +def _byteswap_uuid(uuid: str) -> str: + hexpart = uuid.replace("-", "") + if len(hexpart) != 32: + return uuid + + def _rev(field: str) -> str: + return "".join(reversed([field[i : i + 2] for i in range(0, len(field), 2)])) + + swapped = ( + _rev(hexpart[0:8]) + _rev(hexpart[8:12]) + _rev(hexpart[12:16]) + hexpart[16:] + ) + return ( + f"{swapped[0:8]}-{swapped[8:12]}-{swapped[12:16]}-" + f"{swapped[16:20]}-{swapped[20:]}" + ) + + +def find_proxy_vm(si: vim.ServiceInstance) -> vim.VirtualMachine: + """Locate the VM this process is running in via the BIOS UUID.""" + uuid = guest_uuid() + search = si.RetrieveContent().searchIndex + candidates = (uuid, uuid.lower(), uuid.upper(), _byteswap_uuid(uuid)) + seen: set[str] = set() + for candidate in candidates: + if candidate in seen: + continue + seen.add(candidate) + for instance_uuid in (False, True): + vm = search.FindByUuid(None, candidate, True, instance_uuid) + if vm is not None: + return vm + raise RuntimeError(f"no VM in this vCenter has UUID {uuid}") + + +def _controller_map( + devices: Iterable[vim.vm.device.VirtualDevice], +) -> dict[int, vim.vm.device.VirtualController]: + return { + device.key: device + for device in devices + if isinstance(device, vim.vm.device.VirtualController) + } + + +def _is_file_backed(disk: vim.vm.device.VirtualDisk) -> bool: + backing = disk.backing + if backing is None or not getattr(backing, "fileName", None): + return False + name = type(backing).__name__ + return "RawDisk" not in name + + +def _controller_supported(controller: vim.vm.device.VirtualController | None) -> bool: + if controller is None: + return False + if isinstance(controller, vim.vm.device.VirtualIDEController): + return False + return isinstance( + controller, + ( + vim.vm.device.VirtualSCSIController, + vim.vm.device.VirtualNVMEController, + vim.vm.device.VirtualAHCIController, + ), + ) + + +def _snapshot_moref(snapshot_ref: str) -> str: + if "=" in snapshot_ref: + kind, value = snapshot_ref.split("=", 1) + if kind.lower() != "moref" or not value: + raise ValueError(f"unsupported snapshot_ref: {snapshot_ref}") + return value + return snapshot_ref + + +def _walk_snapshots( + trees: list[vim.vm.SnapshotTree] | None, moref: str +) -> vim.vm.SnapshotTree | None: + for tree in trees or []: + if tree.snapshot._moId == moref: + return tree + found = _walk_snapshots(tree.childSnapshotList, moref) + if found is not None: + return found + return None + + +def source_devices( + vm: vim.VirtualMachine, snapshot_ref: str | None +) -> list[vim.vm.device.VirtualDevice]: + """Return hardware devices of ``vm``, or of ``snapshot_ref`` when set.""" + if snapshot_ref: + if vm.snapshot is None: + raise RuntimeError(f"{vm._moId} has no snapshots") + moref = _snapshot_moref(snapshot_ref) + tree = _walk_snapshots(vm.snapshot.rootSnapshotList, moref) + if tree is None: + raise RuntimeError(f"snapshot {moref} not found on {vm._moId}") + return list(tree.config.hardware.device) + return list(vm.config.hardware.device) + + +def find_source_disk( + devices: Iterable[vim.vm.device.VirtualDevice], disk_path: str +) -> vim.vm.device.VirtualDisk: + """Return the file-backed disk whose backing path is ``disk_path``. + + SCSI, NVMe, and SATA controllers are accepted. IDE and RDM backings + raise ``NotImplementedError``. + """ + controllers = _controller_map(devices) + for device in devices: + if not isinstance(device, vim.vm.device.VirtualDisk): + continue + backing = device.backing + if getattr(backing, "fileName", None) != disk_path: + continue + if not _is_file_backed(device): + raise NotImplementedError( + f"HotAdd does not support RDM or raw backings: {disk_path}" + ) + controller = controllers.get(device.controllerKey) + if isinstance(controller, vim.vm.device.VirtualIDEController): + raise NotImplementedError(f"HotAdd does not support IDE disks: {disk_path}") + if not _controller_supported(controller): + kind = type(controller).__name__ if controller else "missing controller" + raise NotImplementedError( + f"HotAdd does not support {kind} disks: {disk_path}" + ) + return device + raise FileNotFoundError(f"no virtual disk with backing {disk_path!r}") + + +def _scsi_controllers( + devices: Iterable[vim.vm.device.VirtualDevice], +) -> list[vim.vm.device.VirtualSCSIController]: + return [ + device + for device in devices + if isinstance(device, vim.vm.device.VirtualSCSIController) + ] + + +def _used_units( + devices: Iterable[vim.vm.device.VirtualDevice], controller_key: int +) -> set[int]: + return { + device.unitNumber + for device in devices + if isinstance(device, vim.vm.device.VirtualDisk) + and device.controllerKey == controller_key + and device.unitNumber is not None + } + + +def pick_scsi_slot( + devices: Iterable[vim.vm.device.VirtualDevice], +) -> tuple[vim.vm.device.VirtualSCSIController | None, int, int]: + """Return ``(controller, bus, unit)`` for a free SCSI slot. + + ``controller`` is ``None`` when a new PVSCSI controller must be + added on ``bus``; ``unit`` is then 0. + """ + device_list = list(devices) + for controller in sorted(_scsi_controllers(device_list), key=lambda c: c.busNumber): + used = _used_units(device_list, controller.key) + for unit in range(SCSI_MAX_UNIT + 1): + if unit == SCSI_RESERVED_UNIT: + continue + if unit not in used: + return controller, int(controller.busNumber), unit + used_buses = {controller.busNumber for controller in _scsi_controllers(device_list)} + for bus in range(SCSI_MAX_BUS + 1): + if bus not in used_buses: + return None, bus, 0 + raise RuntimeError("no free SCSI controller bus on the HotAdd proxy") + + +@dataclass +class AttachPlan: + """ReconfigureVM spec plus the SCSI address the guest should see.""" + + spec: vim.vm.ConfigSpec + bus_number: int + unit_number: int + file_name: str + + +def build_attach_spec( + devices: Iterable[vim.vm.device.VirtualDevice], + file_name: str, + read_only: bool, + capacity_kb: int | None = None, +) -> AttachPlan: + """Build a SCSI HotAdd spec for an existing VMDK backing. + + Never sets ``fileOperation`` (the VMDK already exists). Read-only + opens use ``independent_nonpersistent``; writable opens use + ``persistent``. + """ + device_list = list(devices) + controller, bus, unit = pick_scsi_slot(device_list) + changes: list[vim.vm.device.VirtualDeviceSpec] = [] + if controller is None: + new_controller = vim.vm.device.ParaVirtualSCSIController() + new_controller.key = -101 + new_controller.busNumber = bus + new_controller.sharedBus = vim.vm.device.VirtualSCSIController.Sharing.noSharing + if hasattr(new_controller, "hotAddRemove"): + new_controller.hotAddRemove = True + controller_spec = vim.vm.device.VirtualDeviceSpec() + controller_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add + controller_spec.device = new_controller + changes.append(controller_spec) + controller_key = new_controller.key + else: + controller_key = controller.key + + backing = vim.vm.device.VirtualDisk.FlatVer2BackingInfo() + backing.fileName = file_name + backing.diskMode = "independent_nonpersistent" if read_only else "persistent" + + disk = vim.vm.device.VirtualDisk() + disk.key = -201 + disk.controllerKey = controller_key + disk.unitNumber = unit + disk.backing = backing + if capacity_kb: + disk.capacityInKB = capacity_kb + disk.deviceInfo = vim.Description() + disk.deviceInfo.label = "openvixdisklib-hotadd" + disk.deviceInfo.summary = file_name + + disk_spec = vim.vm.device.VirtualDeviceSpec() + disk_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add + disk_spec.device = disk + changes.append(disk_spec) + + spec = vim.vm.ConfigSpec() + spec.deviceChange = changes + return AttachPlan(spec=spec, bus_number=bus, unit_number=unit, file_name=file_name) + + +def build_detach_spec(device: vim.vm.device.VirtualDisk) -> vim.vm.ConfigSpec: + """Build a remove spec that detaches ``device`` without deleting files.""" + change = vim.vm.device.VirtualDeviceSpec() + change.operation = vim.vm.device.VirtualDeviceSpec.Operation.remove + change.device = device + spec = vim.vm.ConfigSpec() + spec.deviceChange = [change] + return spec + + +def _wait_for_task(task: vim.Task) -> object: + 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 _boot_disk_path(devices: Iterable[vim.vm.device.VirtualDevice]) -> str | None: + for device in devices: + if isinstance(device, vim.vm.device.VirtualDisk): + return getattr(device.backing, "fileName", None) + return None + + +def _disks_with_backing( + devices: Iterable[vim.vm.device.VirtualDevice], file_name: str +) -> list[vim.vm.device.VirtualDisk]: + matches = [] + for device in devices: + if not isinstance(device, vim.vm.device.VirtualDisk): + continue + if getattr(device.backing, "fileName", None) == file_name: + matches.append(device) + return matches + + +def _reconfigure(vm: vim.VirtualMachine, spec: vim.vm.ConfigSpec) -> None: + _wait_for_task(vm.ReconfigVM_Task(spec)) + vm.Reload() + + +def _detach_device(vm: vim.VirtualMachine, device: vim.vm.device.VirtualDisk) -> None: + LOG.info( + "HotAdd detach %s unit=%s from %s", + getattr(device.backing, "fileName", None), + device.unitNumber, + vm._moId, + ) + _reconfigure(vm, build_detach_spec(device)) + + +def _scsi_sysfs(bus: int, unit: int) -> str: + return os.path.join( + SCSI_DEVICE_DIR, f"{bus}:{SCSI_CHANNEL}:{unit}:{SCSI_LUN}", "block" + ) + + +def _block_names(sysfs_dir: str) -> list[str]: + try: + return [ + name + for name in os.listdir(sysfs_dir) + if not name.startswith(".") and os.path.isdir(os.path.join(sysfs_dir, name)) + ] + except OSError: + return [] + + +def _scsi_block_dirs(unit: int) -> list[str]: + """Return sysfs ``block`` dirs for SCSI target ``unit`` (any host).""" + found: list[str] = [] + try: + names = os.listdir(SCSI_DEVICE_DIR) + except OSError: + return found + suffix = f":{SCSI_CHANNEL}:{unit}:{SCSI_LUN}" + for name in names: + if not name.endswith(suffix): + continue + block = os.path.join(SCSI_DEVICE_DIR, name, "block") + if os.path.isdir(block): + found.append(block) + return found + + +def list_scsi_block_devices() -> set[str]: + """Return guest ``/dev`` paths for every SCSI block device.""" + found: set[str] = set() + try: + names = os.listdir(SCSI_DEVICE_DIR) + except OSError: + return found + for name in names: + block = os.path.join(SCSI_DEVICE_DIR, name, "block") + for dev in _block_names(block): + found.add(f"/dev/{dev}") + return found + + +def find_scsi_block_device( + bus: int, unit: int, before: set[str] | None = None +) -> str | None: + """Return ``/dev/sdX`` for the HotAdded SCSI disk, if present. + + Linux SCSI host numbers often do not match VMware bus numbers. + Matching uses ``/sys/bus/scsi/devices/:0::0/block``. + """ + exact = _scsi_sysfs(bus, unit) + names = _block_names(exact) + if names: + return f"/dev/{names[0]}" + matches = _scsi_block_dirs(unit) + candidates: list[str] = [] + for block_dir in matches: + candidates.extend(f"/dev/{name}" for name in _block_names(block_dir)) + if before is not None: + new = [path for path in candidates if path not in before] + if len(new) == 1: + return new[0] + appeared = list_scsi_block_devices() - before + if len(appeared) == 1: + return appeared.pop() + if len(candidates) == 1: + return candidates[0] + return None + + +def rescan_scsi_hosts() -> None: + """Ask every SCSI host to scan for new LUNs.""" + if not os.path.isdir(SCSI_HOST_DIR): + return + for host in os.listdir(SCSI_HOST_DIR): + scan = os.path.join(SCSI_HOST_DIR, host, "scan") + try: + with open(scan, "w", encoding="ascii") as handle: + handle.write("- - -\n") + except OSError: + continue + + +def wait_for_scsi_device( + bus: int, + unit: int, + timeout_s: float = DEVICE_WAIT_S, + before: set[str] | None = None, +) -> str: + """Rescan SCSI and wait until the HotAdded disk has a block device.""" + deadline = time.monotonic() + timeout_s + last: str | None = None + while time.monotonic() < deadline: + rescan_scsi_hosts() + last = find_scsi_block_device(bus, unit, before=before) + if last and os.path.exists(last): + return last + time.sleep(DEVICE_POLL_S) + raise TimeoutError( + f"HotAdded disk did not appear at SCSI {bus}:0:{unit}:0 ({last})" + ) + + +def _offline_scsi_unit(unit: int) -> None: + """Ask Linux to drop SCSI devices with target ``unit``.""" + for block_dir in _scsi_block_dirs(unit): + delete_path = os.path.join(os.path.dirname(block_dir), "delete") + try: + with open(delete_path, "w", encoding="ascii") as handle: + handle.write("1\n") + except OSError: + continue + + +def wait_scsi_device_gone( + bus: int, unit: int, timeout_s: float = DEVICE_WAIT_S +) -> None: + """Wait until the SCSI device sysfs node disappears after detach.""" + del bus + _offline_scsi_unit(unit) + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if not _scsi_block_dirs(unit): + return + _offline_scsi_unit(unit) + time.sleep(DEVICE_POLL_S) + raise TimeoutError(f"HotAdded disk still present at SCSI unit {unit}") + + +def _pread_all(fd: int, size: int, offset: int) -> bytes: + chunks = bytearray() + remaining = size + pos = offset + while remaining: + data = os.pread(fd, remaining, pos) + if not data: + raise OSError(f"short read at offset {pos}: got {len(chunks)} of {size}") + chunks.extend(data) + remaining -= len(data) + pos += len(data) + return bytes(chunks) + + +def _pwrite_all(fd: int, data: bytes, offset: int) -> None: + remaining = memoryview(data) + pos = offset + while remaining: + written = os.pwrite(fd, remaining, pos) + if written <= 0: + raise OSError(f"short write at offset {pos}") + remaining = remaining[written:] + pos += written + + +class HotAddDisk: + """A locally attached HotAdd VMDK opened as a SCSI block device.""" + + def __init__( + self, + fd: int, + dev_path: str, + bus_number: int, + unit_number: int, + detach: Callable[[], None], + sector_size: int = SECTOR_SIZE, + ) -> None: + """Wrap an open block-device fd and a detach callback. + + Args: + fd: File descriptor for the SCSI disk. + dev_path: Guest path such as ``/dev/sdb``. + bus_number: VMware SCSI bus of the attached disk. + unit_number: VMware SCSI unit of the attached disk. + detach: Called from ``close`` after the fd is closed. + sector_size: Sector size in bytes (VDDK uses 512). + """ + self._fd = fd + self.dev_path = dev_path + self.bus_number = bus_number + self.unit_number = unit_number + self._detach = detach + self.sector_size = sector_size + self._closed = False + + def readinto( + self, + start_sector: int, + num_sectors: int, + buf: bytearray | memoryview, + skip_decompression: bool = False, + ) -> ReadResult: + """Read ``num_sectors`` into ``buf`` starting at ``start_sector``. + + ``skip_decompression`` is an NFC option and is ignored; HotAdd + has no compressed extras. ``fragments`` is always empty. + + Args: + start_sector: Sector offset from the start of the disk. + num_sectors: Number of sectors to read. + buf: Destination buffer. + skip_decompression: Ignored; accepted for API compatibility. + """ + del skip_decompression + if num_sectors < 1: + raise ValueError("num_sectors must be at least 1") + length = num_sectors * self.sector_size + view = buf if isinstance(buf, memoryview) else memoryview(buf) + if view.readonly: + raise TypeError("read buffer is read-only") + raw = view.cast("B") if view.format != "B" else view + if len(raw) < length: + raise RuntimeError(f"read buffer is {len(raw)} bytes, need {length}") + data = _pread_all(self._fd, length, start_sector * self.sector_size) + raw[:length] = data + return ReadResult( + uncompressed_length=length, compressed_length=length, fragments=() + ) + + def write(self, start_sector: int, num_sectors: int, data: bytes) -> None: + """Write ``num_sectors`` starting at ``start_sector``. + + Args: + start_sector: Sector offset from the start of the disk. + num_sectors: Number of sectors to write. + data: Bytes to write; length must be ``num_sectors * sector_size``. + """ + if num_sectors < 1: + raise ValueError("num_sectors must be at least 1") + length = num_sectors * self.sector_size + if len(data) != length: + raise ValueError(f"write data is {len(data)} bytes, need {length}") + _pwrite_all(self._fd, data, start_sector * self.sector_size) + os.fsync(self._fd) + + def close(self) -> None: + """Close the block device and detach the VMDK from the proxy.""" + if self._closed: + return + try: + os.close(self._fd) + except OSError: + pass + self._detach() + self._closed = True + + def __enter__(self) -> HotAddDisk: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + +def open_disk( + si: vim.ServiceInstance, + source_vm: vim.VirtualMachine, + disk_path: str, + snapshot_ref: str | None = None, + read_only: bool = True, +) -> HotAddDisk: + """HotAdd ``disk_path`` from ``source_vm`` onto this guest and open it. + + The source VM must be powered off, or ``snapshot_ref`` must name a + snapshot whose hardware contains ``disk_path``. The disk is always + attached to a SCSI controller on the proxy. + + Args: + si: Logged-in VIM session. + source_vm: VM that owns ``disk_path``. + disk_path: Datastore path of the VMDK. + snapshot_ref: Snapshot moref required when ``source_vm`` is on. + read_only: Independent-nonpersistent attach when True. + """ + if not is_vmware_guest(): + raise RuntimeError("HotAdd requires a VMware guest (the backup proxy)") + if ( + source_vm.runtime.powerState == vim.VirtualMachinePowerState.poweredOn + and not snapshot_ref + ): + raise RuntimeError( + "snapshot_ref is required to HotAdd a powered-on virtual machine" + ) + + proxy = find_proxy_vm(si) + proxy_devices = list(proxy.config.hardware.device) + boot_path = _boot_disk_path(proxy_devices) + if boot_path == disk_path: + raise RuntimeError("refusing to HotAdd the proxy VM's boot disk") + + for leftover in _disks_with_backing(proxy_devices, disk_path): + LOG.warning("detaching leftover HotAdd disk %s from %s", disk_path, proxy._moId) + _detach_device(proxy, leftover) + proxy_devices = list(proxy.config.hardware.device) + + devices = source_devices(source_vm, snapshot_ref) + source = find_source_disk(devices, disk_path) + capacity = getattr(source, "capacityInKB", None) + plan = build_attach_spec( + proxy_devices, disk_path, read_only=read_only, capacity_kb=capacity + ) + LOG.info( + "HotAdd attach %s onto %s SCSI %s:%s read_only=%s", + disk_path, + proxy._moId, + plan.bus_number, + plan.unit_number, + read_only, + ) + attached: vim.vm.device.VirtualDisk | None = None + before = list_scsi_block_devices() + try: + _reconfigure(proxy, plan.spec) + matches = _disks_with_backing(proxy.config.hardware.device, disk_path) + if len(matches) != 1: + raise RuntimeError( + f"expected one attached disk {disk_path!r}, found {len(matches)}" + ) + attached = matches[0] + bus = plan.bus_number + unit = plan.unit_number + controllers = _controller_map(proxy.config.hardware.device) + controller = controllers.get(attached.controllerKey) + if isinstance(controller, vim.vm.device.VirtualSCSIController): + bus = int(controller.busNumber) + unit = int(attached.unitNumber) + dev_path = wait_for_scsi_device(bus, unit, before=before) + flags = os.O_RDONLY if read_only else os.O_RDWR + fd = os.open(dev_path, flags) + except Exception: + victim = attached + if victim is None: + leftovers = _disks_with_backing(proxy.config.hardware.device, disk_path) + victim = leftovers[0] if leftovers else None + if victim is not None: + _detach_best_effort(proxy, victim) + raise + + def _detach() -> None: + try: + _detach_device(proxy, attached) + finally: + wait_scsi_device_gone(bus, unit) + + return HotAddDisk(fd, dev_path, bus, unit, _detach) + + +def _detach_best_effort( + vm: vim.VirtualMachine, device: vim.vm.device.VirtualDisk +) -> None: + """Detach ``device`` after a failed open; log and ignore errors.""" + try: + _detach_device(vm, device) + unit = device.unitNumber + if unit is not None: + _offline_scsi_unit(int(unit)) + except Exception: + LOG.exception("HotAdd cleanup after failed open") diff --git a/openvixdisklib/openvixdisklib.py b/openvixdisklib/openvixdisklib.py index cafb7f9..cb91948 100644 --- a/openvixdisklib/openvixdisklib.py +++ b/openvixdisklib/openvixdisklib.py @@ -10,7 +10,8 @@ ``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``. +authd, and disk I/O use ``nfc_auth`` and ``nfc_open``. Linux HotAdd uses +``hotadd``. """ from __future__ import annotations @@ -24,7 +25,7 @@ from pyVim.connect import Disconnect from pyVmomi import vim -from openvixdisklib import nfc_auth, nfc_open +from openvixdisklib import hotadd, nfc_auth, nfc_open ReadResult = nfc_open.ReadResult ReadFragment = nfc_open.ReadFragment @@ -84,20 +85,31 @@ def _parse_vm_moref(vmx_spec: str | None) -> str: return vmx_spec +def _available_transports() -> list[str]: + """Return transports this process can use, in advertisement order.""" + modes = ["nbdssl", "nbd"] + if hotadd.is_vmware_guest(): + modes.append("hotadd") + return modes + + def _select_transport(transport_modes: str | None) -> str: - """Return the first requested transport this replacement implements. + """Return the first requested transport this replacement can use. ``None`` defaults to ``nbdssl``. A colon-separated list (VDDK - style, for example ``file:nbdssl:nbd``) picks the first of - ``nbdssl`` or ``nbd``. + style, for example ``file:san:hotadd:nbdssl:nbd``) picks the first + of ``nbdssl``, ``nbd``, and ``hotadd`` that is usable here. + ``hotadd`` is usable only inside a VMware guest. """ if transport_modes is None: return "nbdssl" + usable = set(_available_transports()) for mode in transport_modes.split(":"): - if mode in ("nbdssl", "nbd"): + if mode in usable: return mode raise NotImplementedError( - f"supported transports are nbdssl and nbd, got {transport_modes!r}" + f"supported transports are {' and '.join(_available_transports())}, " + f"got {transport_modes!r}" ) @@ -124,9 +136,14 @@ def __init__( class _DiskHandle: - """Opened NFC disk plus the authd TLS socket it was taken from.""" + """Opened disk (NFC or HotAdd) plus an optional authd TLS socket.""" - def __init__(self, disk: nfc_open.NfcDisk, authd_sock, transport_mode: str) -> None: + def __init__( + self, + disk: nfc_open.NfcDisk | hotadd.HotAddDisk, + transport_mode: str, + authd_sock=None, + ) -> None: self.disk = disk self.authd_sock = authd_sock self.transport_mode = transport_mode @@ -178,8 +195,8 @@ def get_vix_disklib_name(cls) -> str: return "libvixDiskLib.so" def get_transport_modes(self) -> list[str]: - """Return the transport modes this replacement implements.""" - return ["nbdssl", "nbd"] + """Return the transport modes this process can use.""" + return _available_transports() def get_transport_mode(self, disk_handle: _DiskHandle) -> str: """Return the transport used for ``disk_handle``.""" @@ -214,11 +231,13 @@ def connect( username: VIM user name. password: VIM password. vmx_spec: VM selector, ``moref=vm-…``. - snapshot_ref: Snapshot moref; unused on the NFC ticket. + snapshot_ref: Snapshot moref. Unused on the NFC ticket. + Required for HotAdd when the source VM is powered on. read_only: When False, the disk may be opened for write. - transport_modes: ``nbdssl``, ``nbd``, or a colon list. The - first supported mode is used; ``None`` defaults to - ``nbdssl``. + transport_modes: ``nbdssl``, ``nbd``, ``hotadd``, or a colon + list. The first usable mode is used; ``None`` defaults + to ``nbdssl``. ``hotadd`` is usable only in a VMware + guest. port: HTTPS port, usually 443. allow_untrusted: Skip management TLS verification when True. When False with no ``thumbprint``, the system CA store @@ -270,13 +289,14 @@ def open( aio_buffer_size: int = nfc_open.NFC_AIO_BUFFER_SIZE, aio_buffer_count: int = nfc_open.NFC_AIO_BUFFER_COUNT, ) -> Iterator[_DiskHandle]: - """Open ``disk_path`` over NFC. Matches ``VixDiskLib_Open``. + """Open ``disk_path`` over NFC or HotAdd. Matches ``VixDiskLib_Open``. - Read-only opens request ``NfcGetVmFiles`` (VM only). The VMDK - path, including a snapshot parent such as ``…-000007.vmdk``, is - sent on NFC ``OPEN_FILE``. Writable opens use + Read-only NFC opens request ``NfcGetVmFiles`` (VM only). The + VMDK path, including a snapshot parent such as ``…-000007.vmdk``, + is sent on NFC ``OPEN_FILE``. Writable NFC opens use ``NfcRandomAccessOpenDisk`` and resolve a device key from the - disk's backing chain. + disk's backing chain. ``hotadd`` SCSI-attaches the VMDK to this + guest (Linux proxy) and opens the local block device. Args: conn: Connection from ``connect``. @@ -284,14 +304,16 @@ def open( flags: Open flags. ``VIXDISKLIB_FLAG_OPEN_READ_ONLY`` opens the disk read-only; omit it for write. ``VIXDISKLIB_FLAG_OPEN_COMPRESSION_FASTLZ`` compresses - NFC IO. zlib and skipz are not implemented. + NFC IO. zlib and skipz are not implemented. Compression + flags are not supported with ``hotadd``. aio_buffer_size: NFC AIO extra size in bytes, advertised in OPEN_SESSION. Default 64 KiB. ESXi 8 accepts 2 MiB (``2097152``) and rejects 16 MiB and 32 MiB. This is an OpenVixDiskLib extension (VDDK uses - ``vixDiskLib.nfcAio.Session.BufSizeIn64KB``). + ``vixDiskLib.nfcAio.Session.BufSizeIn64KB``). Ignored + for HotAdd. aio_buffer_count: NFC AIO buffer pool count. Default 1. - VDDK's default is 4. + VDDK's default is 4. Ignored for HotAdd. """ LOG.debug("Openning VixDiskLib disk: %s", disk_path) compression = _nfc_compression(flags) @@ -300,6 +322,25 @@ def open( raise NotImplementedError("ConnectEx was read-only; cannot open for write") vm = vim.VirtualMachine(conn.vm_moref, conn.si._stub) + if conn.transport_mode == "hotadd": + if compression != nfc_open.NFC_COMPRESSION_NONE: + raise NotImplementedError( + "NBD compression open flags are not supported with hotadd" + ) + disk = hotadd.open_disk( + conn.si, + vm, + disk_path, + snapshot_ref=conn.snapshot_ref, + read_only=read_only, + ) + handle = _DiskHandle(disk, conn.transport_mode) + try: + yield handle + finally: + self.close(handle) + return + nfc_ssl = conn.transport_mode == "nbdssl" ticket = nfc_auth.get_nfc_ticket( conn.si, vm, read_only=read_only, disk_path=None if read_only else disk_path @@ -309,7 +350,7 @@ def open( ) session = nfc_auth.NfcAuthSession(conn.si, ticket, authd_sock, nfc_ssl=nfc_ssl) try: - disk = nfc_open.open_disk( + nfc_disk = nfc_open.open_disk( session, disk_path, read_only=read_only, @@ -320,7 +361,7 @@ def open( except Exception: authd_sock.close() raise - handle = _DiskHandle(disk, authd_sock, conn.transport_mode) + handle = _DiskHandle(nfc_disk, conn.transport_mode, authd_sock) try: yield handle finally: @@ -386,7 +427,7 @@ def write( 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. + """Close the VMDK and, for NFC, the authd socket. Args: disk_handle: Handle from ``open``. @@ -395,10 +436,11 @@ def close(self, disk_handle: _DiskHandle) -> None: try: disk_handle.disk.close() finally: - try: - disk_handle.authd_sock.close() - except OSError: - pass + if disk_handle.authd_sock is not None: + try: + disk_handle.authd_sock.close() + except OSError: + pass def disconnect(self, conn: _Connection) -> None: """Logout of the VIM session. diff --git a/tests/integration/base.py b/tests/integration/base.py index 57157fc..d32efa7 100644 --- a/tests/integration/base.py +++ b/tests/integration/base.py @@ -144,6 +144,28 @@ def _load_test_config() -> dict[str, Any]: } +def load_hotadd_proxy_config() -> dict[str, str] | None: + """Return optional SSH settings for the Linux HotAdd proxy, if configured.""" + if not os.path.isfile(_CONFIG_PATH): + return None + with open(_CONFIG_PATH, encoding="utf-8") as config_file: + data = yaml.safe_load(config_file) or {} + proxy = data.get("hotadd_proxy") + if not isinstance(proxy, dict) or not proxy.get("host"): + return None + identity = proxy.get("identity_file") + if identity: + identity_file = os.path.expanduser(str(identity)) + else: + default_key = os.path.expanduser("~/.ssh/id_ed25519") + identity_file = default_key if os.path.isfile(default_key) else "" + return { + "host": str(proxy["host"]), + "user": str(proxy.get("user", "root")), + "identity_file": identity_file, + } + + def _connect_vim( host: str, username: str, @@ -199,7 +221,9 @@ def _find_datastore(datacenter: vim.Datacenter, datastore_name: str) -> vim.Data return matches[0] -def _vm_config_spec(vm_name: str, datastore_name: str) -> vim.vm.ConfigSpec: +def _vm_config_spec( + vm_name: str, datastore_name: str, disk_controller: str = "pvscsi" +) -> vim.vm.ConfigSpec: config = vim.vm.ConfigSpec() config.name = vm_name config.guestId = "otherGuest64" @@ -207,10 +231,21 @@ def _vm_config_spec(vm_name: str, datastore_name: str) -> vim.vm.ConfigSpec: 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 + if disk_controller == "nvme": + controller: vim.vm.device.VirtualController = ( + vim.vm.device.VirtualNVMEController() + ) + controller.key = 1000 + controller.busNumber = 0 + elif disk_controller == "pvscsi": + scsi = vim.vm.device.ParaVirtualSCSIController() + scsi.key = 1000 + scsi.busNumber = 0 + scsi.sharedBus = vim.vm.device.VirtualSCSIController.Sharing.noSharing + controller = scsi + else: + raise ValueError(f"unsupported disk_controller: {disk_controller}") + controller_spec = vim.vm.device.VirtualDeviceSpec() controller_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add controller_spec.device = controller @@ -234,8 +269,12 @@ def _vm_config_spec(vm_name: str, datastore_name: str) -> vim.vm.ConfigSpec: return config -def create_lab_vm() -> LabEnv: - """Create an empty VM with a 10 GiB thin disk for I/O tests.""" +def create_lab_vm(*, disk_controller: str = "pvscsi") -> LabEnv: + """Create an empty VM with a 10 GiB thin disk for I/O tests. + + Args: + disk_controller: ``pvscsi`` (default) or ``nvme``. + """ cfg = _load_test_config() thumbprint = nfc_auth.get_ssl_cert_thumbprint(cfg["host"], cfg["port"]) si = _connect_vim( @@ -260,7 +299,11 @@ def create_lab_vm() -> LabEnv: 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 + config=_vm_config_spec( + vm_name, datastore.name, disk_controller=disk_controller + ), + pool=pool, + host=host, ) ) disks = [ diff --git a/tests/integration/hotadd_proxy.py b/tests/integration/hotadd_proxy.py new file mode 100644 index 0000000..82ba0ab --- /dev/null +++ b/tests/integration/hotadd_proxy.py @@ -0,0 +1,139 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""SSH helpers for running OpenVixDiskLib HotAdd on the Linux proxy.""" + +from __future__ import annotations + +import os +import subprocess + +from tests.integration.base import load_hotadd_proxy_config + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +REMOTE_DIR = "/tmp/openvixdisklib-hotadd" +REMOTE_VENV = f"{REMOTE_DIR}/.venv" +REMOTE_PYTHON = f"{REMOTE_VENV}/bin/python" +SSH_CONNECT_TIMEOUT_S = 15 +DEFAULT_SSH_TIMEOUT_S = 300 + + +def ssh_base(proxy: dict[str, str]) -> list[str]: + """Return the ``ssh user@host`` prefix for ``proxy``.""" + cmd = [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + f"ConnectTimeout={SSH_CONNECT_TIMEOUT_S}", + ] + if proxy.get("identity_file"): + cmd.extend(["-i", proxy["identity_file"]]) + cmd.append(f"{proxy['user']}@{proxy['host']}") + return cmd + + +def ssh_proxy( + proxy: dict[str, str], + remote: str, + *, + stdin: bytes | None = None, + timeout: int = DEFAULT_SSH_TIMEOUT_S, +) -> subprocess.CompletedProcess[bytes]: + """Run ``remote`` on the HotAdd proxy and return the completed process.""" + return subprocess.run( + [*ssh_base(proxy), remote], + input=stdin, + capture_output=True, + timeout=timeout, + check=False, + ) + + +def prepare_hotadd_proxy( + extra_files: dict[str, str] | None = None, +) -> dict[str, str]: + """Probe SSH, sync ``openvixdisklib``, and return proxy settings. + + ``extra_files`` maps a remote basename under ``REMOTE_DIR`` to a + local path that is copied after the package. Raises ``RuntimeError`` + when the proxy is missing or unreachable. + """ + proxy = load_hotadd_proxy_config() + if proxy is None: + raise RuntimeError("hotadd_proxy missing from .test_config.yaml") + probe = ssh_proxy(proxy, "echo ok") + if probe.returncode != 0: + raise RuntimeError( + f"cannot ssh to {proxy['user']}@{proxy['host']}: " + f"{probe.stderr.decode(errors='replace').strip()}" + ) + _sync_package(proxy, extra_files or {}) + return proxy + + +def _sync_package(proxy: dict[str, str], extra_files: dict[str, str]) -> None: + mkdir = ssh_proxy(proxy, f"mkdir -p {REMOTE_DIR}/openvixdisklib") + if mkdir.returncode != 0: + raise RuntimeError( + f"mkdir on proxy failed: {mkdir.stderr.decode(errors='replace')}" + ) + archive = subprocess.run( + [ + "tar", + "-C", + os.path.join(_REPO_ROOT, "openvixdisklib"), + "-czf", + "-", + ".", + ], + capture_output=True, + check=False, + ) + if archive.returncode != 0: + raise RuntimeError( + f"tar package failed: {archive.stderr.decode(errors='replace')}" + ) + unpack = ssh_proxy( + proxy, + f"rm -rf {REMOTE_DIR}/openvixdisklib && mkdir -p {REMOTE_DIR}/openvixdisklib " + f"&& tar -C {REMOTE_DIR}/openvixdisklib -xzf -", + stdin=archive.stdout, + ) + if unpack.returncode != 0: + raise RuntimeError( + f"copy package to proxy failed: {unpack.stderr.decode(errors='replace')}" + ) + for remote_name, local_path in extra_files.items(): + with open(local_path, "rb") as handle: + contents = handle.read() + copy = ssh_proxy(proxy, f"cat > {REMOTE_DIR}/{remote_name}", stdin=contents) + if copy.returncode != 0: + raise RuntimeError( + f"copy {remote_name} to proxy failed: " + f"{copy.stderr.decode(errors='replace')}" + ) + venv = ssh_proxy( + proxy, + f"test -x {REMOTE_PYTHON} || python3 -m venv {REMOTE_VENV}", + ) + if venv.returncode != 0: + raise RuntimeError( + f"could not create proxy venv: {venv.stderr.decode(errors='replace')}" + ) + deps = ssh_proxy( + proxy, + f"{REMOTE_PYTHON} -c 'import pyVmomi, pyVim, fastlz'", + ) + if deps.returncode != 0: + install = ssh_proxy( + proxy, + f"{REMOTE_PYTHON} -m pip install 'pyVmomi>=7.0' pyOpenSSL pyfastlz", + ) + if install.returncode != 0: + raise RuntimeError( + "proxy venv pip install failed: " + f"{install.stderr.decode(errors='replace')}" + ) diff --git a/tests/integration/hotadd_remote.py b/tests/integration/hotadd_remote.py new file mode 100644 index 0000000..3820923 --- /dev/null +++ b/tests/integration/hotadd_remote.py @@ -0,0 +1,84 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Run HotAdd I/O inside the proxy guest. Invoked over SSH by tests.""" + +from __future__ import annotations + +import json +import sys + +from pyVmomi import vim + +from openvixdisklib import openvixdisklib as vixdisklib +from openvixdisklib.hotadd import find_proxy_vm + + +def main() -> int: + """Read connect kwargs and sector patterns from stdin, HotAdd, write/read.""" + cfg = json.load(sys.stdin) + handle = vixdisklib.VixDiskLibHandle(vixdisklib_compatibility_version="8.0") + modes = handle.get_transport_modes() + if "hotadd" not in modes: + print(json.dumps({"ok": False, "error": f"hotadd not listed: {modes}"})) + return 1 + patterns = { + int(sector): bytes.fromhex(data) for sector, data in cfg["patterns"].items() + } + sector_size = cfg.get("sector_size", vixdisklib.VIXDISKLIB_SECTOR_SIZE) + connect_kwargs = { + "server_name": cfg["server_name"], + "thumbprint": cfg["thumbprint"], + "username": cfg["username"], + "password": cfg["password"], + "vmx_spec": cfg["vmx_spec"], + "read_only": False, + "transport_modes": "hotadd", + "port": cfg.get("port", 443), + "allow_untrusted": cfg.get("allow_untrusted", False), + } + write_buf = vixdisklib.get_buffer(sector_size) + read_buf = vixdisklib.get_buffer(sector_size) + extra_after = 0 + mode = "" + with handle.connect(**connect_kwargs) as conn: + with handle.open(conn, cfg["disk_path"], flags=0) as disk: + mode = handle.get_transport_mode(disk) + if mode != "hotadd": + print(json.dumps({"ok": False, "error": f"mode {mode!r}"})) + return 1 + for start, expected in patterns.items(): + write_buf[:sector_size] = expected + handle.write(disk, start, 1, write_buf) + read_buf[:sector_size] = b"\xa5" * sector_size + handle.read(disk, start, 1, read_buf) + if read_buf.raw[:sector_size] != expected: + print( + json.dumps( + { + "ok": False, + "error": f"mismatch at sector {start}", + } + ) + ) + return 1 + extra_after = _extra_disk_count(conn.si) + print( + json.dumps({"ok": True, "mode": mode, "extra_disks_after_close": extra_after}) + ) + return 0 + + +def _extra_disk_count(si) -> int: + """Return how many non-boot disks remain on the proxy VM.""" + proxy = find_proxy_vm(si) + disks = [ + device + for device in proxy.config.hardware.device + if isinstance(device, vim.vm.device.VirtualDisk) + ] + return max(0, len(disks) - 1) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/integration/test_hotadd.py b/tests/integration/test_hotadd.py new file mode 100644 index 0000000..7479ce1 --- /dev/null +++ b/tests/integration/test_hotadd.py @@ -0,0 +1,184 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Exercise HotAdd from the Linux proxy guest over SSH.""" + +from __future__ import annotations + +import json +import os +from collections.abc import Iterator +from typing import Any + +import pytest +from pyVmomi import vim + +from openvixdisklib import openvixdisklib as vixdisklib +from tests.integration.base import ( + SECTOR_AT_1GB, + SECTOR_SIZE, + LabEnv, + _connect_vim, + create_lab_vm, + destroy_lab_vm, + load_hotadd_proxy_config, + pattern_bytes, +) +from tests.integration.hotadd_proxy import ( + REMOTE_DIR, + REMOTE_PYTHON, + prepare_hotadd_proxy, + ssh_proxy, +) + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + + +@pytest.fixture(scope="session") +def hotadd_proxy() -> dict[str, str]: + """SSH settings for the Linux HotAdd proxy, or skip.""" + remote_py = os.path.join(_REPO_ROOT, "tests", "integration", "hotadd_remote.py") + try: + return prepare_hotadd_proxy({"hotadd_remote.py": remote_py}) + except RuntimeError as exc: + pytest.skip(str(exc)) + + +@pytest.fixture +def nvme_lab() -> Iterator[LabEnv]: + """Powered-off lab VM whose disk is on an NVMe controller.""" + env = create_lab_vm(disk_controller="nvme") + try: + yield env + finally: + destroy_lab_vm(env) + + +def _proxy_extra_disk_count(lab: LabEnv) -> int: + """Count non-boot virtual disks on the HotAdd proxy VM.""" + proxy_cfg = load_hotadd_proxy_config() + if proxy_cfg is None: + return 0 + si = _connect_vim( + lab.host, + lab.username, + lab.password, + lab.port, + lab.thumbprint, + lab.allow_untrusted, + ) + try: + content = si.RetrieveContent() + container = content.viewManager.CreateContainerView( + content.rootFolder, [vim.VirtualMachine], True + ) + try: + for vm in container.view: + ips: list[str] = [] + if vm.guest and vm.guest.net: + for nic in vm.guest.net: + ips.extend(nic.ipAddress or []) + if proxy_cfg["host"] in ips: + disks = [ + device + for device in vm.config.hardware.device + if isinstance(device, vim.vm.device.VirtualDisk) + ] + return max(0, len(disks) - 1) + finally: + container.Destroy() + finally: + from pyVim.connect import Disconnect + + Disconnect(si) + return 0 + + +def _run_hotadd_remote(proxy: dict[str, str], lab: LabEnv) -> dict[str, Any]: + patterns = { + "0": pattern_bytes(SECTOR_SIZE, b"OVDL-HA0").hex(), + str(SECTOR_AT_1GB): pattern_bytes(SECTOR_SIZE, b"OVDL-HA1").hex(), + } + payload = json.dumps( + { + "server_name": lab.host, + "thumbprint": lab.thumbprint, + "username": lab.username, + "password": lab.password, + "port": lab.port, + "allow_untrusted": lab.allow_untrusted, + "vmx_spec": lab.vmx_spec, + "disk_path": lab.disk_path, + "sector_size": SECTOR_SIZE, + "patterns": patterns, + } + ).encode() + result = ssh_proxy( + proxy, + f"cd {REMOTE_DIR} && PYTHONPATH={REMOTE_DIR} {REMOTE_PYTHON} hotadd_remote.py", + stdin=payload, + ) + if result.returncode != 0: + raise AssertionError( + f"hotadd_remote failed rc={result.returncode} " + f"stdout={result.stdout.decode(errors='replace')!r} " + f"stderr={result.stderr.decode(errors='replace')!r}" + ) + report = json.loads(result.stdout.decode()) + assert report.get("ok") is True, report + return report + + +def _assert_nbdssl_matches(lab: LabEnv, patterns: dict[int, bytes]) -> None: + handle = vixdisklib.VixDiskLibHandle(vixdisklib_compatibility_version="8.0") + read_buf = vixdisklib.get_buffer(SECTOR_SIZE) + kwargs = lab.vixdisklib_connect_kwargs( + { + "allow_untrusted": lab.allow_untrusted, + "transport_modes": "nbdssl", + "read_only": True, + } + ) + with ( + handle.connect(**kwargs) as conn, + handle.open( + conn, lab.disk_path, flags=vixdisklib.VIXDISKLIB_FLAG_OPEN_READ_ONLY + ) as disk, + ): + for start, expected in patterns.items(): + read_buf[:SECTOR_SIZE] = b"\xa5" * SECTOR_SIZE + handle.read(disk, start, 1, read_buf) + assert read_buf.raw[:SECTOR_SIZE] == expected + + +class TestHotAdd: + def test_pvscsi_write_read(self, lab: LabEnv, hotadd_proxy: dict[str, str]) -> None: + """HotAdd a PVSCSI lab disk on the proxy and verify via nbdssl.""" + extra_before = _proxy_extra_disk_count(lab) + report = _run_hotadd_remote(hotadd_proxy, lab) + assert report["mode"] == "hotadd" + assert report["extra_disks_after_close"] == extra_before + assert _proxy_extra_disk_count(lab) == extra_before + _assert_nbdssl_matches( + lab, + { + 0: pattern_bytes(SECTOR_SIZE, b"OVDL-HA0"), + SECTOR_AT_1GB: pattern_bytes(SECTOR_SIZE, b"OVDL-HA1"), + }, + ) + + def test_nvme_source_write_read( + self, nvme_lab: LabEnv, hotadd_proxy: dict[str, str] + ) -> None: + """HotAdd an NVMe-backed VMDK onto the proxy's SCSI controller.""" + extra_before = _proxy_extra_disk_count(nvme_lab) + report = _run_hotadd_remote(hotadd_proxy, nvme_lab) + assert report["mode"] == "hotadd" + assert report["extra_disks_after_close"] == extra_before + _assert_nbdssl_matches( + nvme_lab, + { + 0: pattern_bytes(SECTOR_SIZE, b"OVDL-HA0"), + SECTOR_AT_1GB: pattern_bytes(SECTOR_SIZE, b"OVDL-HA1"), + }, + ) diff --git a/tests/perf/hotadd_remote.py b/tests/perf/hotadd_remote.py new file mode 100644 index 0000000..fa49a69 --- /dev/null +++ b/tests/perf/hotadd_remote.py @@ -0,0 +1,80 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Time HotAdd write/read inside the proxy guest. Invoked over SSH by perf.""" + +from __future__ import annotations + +import json +import sys +import time + +from openvixdisklib import openvixdisklib as vixdisklib + + +def _pattern_bytes(length: int, seed: bytes) -> bytes: + return (seed * ((length // len(seed)) + 1))[:length] + + +def main() -> int: + """Read connect kwargs and size from stdin, HotAdd, time write/read.""" + cfg = json.load(sys.stdin) + nbytes = int(cfg["nbytes"]) + if nbytes % vixdisklib.VIXDISKLIB_SECTOR_SIZE: + print(json.dumps({"ok": False, "error": f"unaligned size {nbytes}"})) + return 1 + n_sectors = nbytes // vixdisklib.VIXDISKLIB_SECTOR_SIZE + payload = _pattern_bytes(nbytes, f"PERF-{cfg['label']}-".encode()) + handle = vixdisklib.VixDiskLibHandle(vixdisklib_compatibility_version="8.0") + modes = handle.get_transport_modes() + if "hotadd" not in modes: + print(json.dumps({"ok": False, "error": f"hotadd not listed: {modes}"})) + return 1 + connect_kwargs = { + "server_name": cfg["server_name"], + "thumbprint": cfg["thumbprint"], + "username": cfg["username"], + "password": cfg["password"], + "vmx_spec": cfg["vmx_spec"], + "read_only": False, + "transport_modes": "hotadd", + "port": cfg.get("port", 443), + "allow_untrusted": cfg.get("allow_untrusted", False), + } + write_buf = vixdisklib.get_buffer(nbytes) + read_buf = vixdisklib.get_buffer(nbytes) + write_buf[:nbytes] = payload + mode = "" + with ( + handle.connect(**connect_kwargs) as conn, + handle.open(conn, cfg["disk_path"], flags=0) as disk, + ): + mode = handle.get_transport_mode(disk) + if mode != "hotadd": + print(json.dumps({"ok": False, "error": f"mode {mode!r}"})) + return 1 + started = time.perf_counter() + handle.write(disk, 0, n_sectors, write_buf) + write_s = time.perf_counter() - started + read_buf[:nbytes] = b"\xa5" * nbytes + started = time.perf_counter() + handle.read(disk, 0, n_sectors, read_buf) + read_s = time.perf_counter() - started + if read_buf.raw[:nbytes] != payload: + print(json.dumps({"ok": False, "error": "mismatch after read"})) + return 1 + print( + json.dumps( + { + "ok": True, + "mode": mode, + "write_s": write_s, + "read_s": read_s, + } + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/perf/test_compare.py b/tests/perf/test_compare.py index 2b608d3..7ceab17 100644 --- a/tests/perf/test_compare.py +++ b/tests/perf/test_compare.py @@ -14,6 +14,8 @@ import time from typing import Any +import pytest + from openvixdisklib import nfc_open from openvixdisklib import openvixdisklib as open_vix from tests.integration import vixdisklib @@ -23,6 +25,12 @@ ensure_vddk_library_path, pattern_bytes, ) +from tests.integration.hotadd_proxy import ( + REMOTE_DIR, + REMOTE_PYTHON, + prepare_hotadd_proxy, + ssh_proxy, +) _REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) _SIZES = ( @@ -262,6 +270,102 @@ def _mib_per_s(nbytes: int, seconds: float) -> float: return (nbytes / (1024 * 1024)) / seconds +_HOTADD_REMOTE = os.path.join(os.path.dirname(__file__), "hotadd_remote.py") +_HOTADD_SSH_TIMEOUT_S = 600 +_PerfRow = tuple[str, str, str, str, str, str, float, float, float, float] + + +def _time_hotadd_remote( + lab: LabEnv, proxy: dict[str, str], label: str, nbytes: int +) -> tuple[float, float]: + """Time OpenVixDiskLib HotAdd write/read on the Linux proxy guest.""" + payload = json.dumps( + { + "server_name": lab.host, + "thumbprint": lab.thumbprint, + "username": lab.username, + "password": lab.password, + "port": lab.port, + "allow_untrusted": lab.allow_untrusted, + "vmx_spec": lab.vmx_spec, + "disk_path": lab.disk_path, + "label": label, + "nbytes": nbytes, + } + ).encode() + result = ssh_proxy( + proxy, + f"cd {REMOTE_DIR} && PYTHONPATH={REMOTE_DIR} {REMOTE_PYTHON} " + "hotadd_perf_remote.py", + stdin=payload, + timeout=_HOTADD_SSH_TIMEOUT_S, + ) + if result.returncode != 0: + raise RuntimeError( + "hotadd perf remote failed " + f"rc={result.returncode} " + f"stdout={result.stdout.decode(errors='replace')!r} " + f"stderr={result.stderr.decode(errors='replace')!r}" + ) + report = json.loads(result.stdout.decode()) + if not report.get("ok"): + raise RuntimeError(f"hotadd perf remote error: {report}") + return float(report["write_s"]), float(report["read_s"]) + + +def _hotadd_rows(lab: LabEnv) -> list[_PerfRow]: + """Time plain HotAdd I/O for each transfer size on the proxy guest.""" + proxy = prepare_hotadd_proxy({"hotadd_perf_remote.py": _HOTADD_REMOTE}) + print(f"hotadd timings run on {proxy['user']}@{proxy['host']}") + rows: list[_PerfRow] = [] + for label, nbytes in _SIZES: + write_s, read_s = _time_hotadd_remote(lab, proxy, label, nbytes) + rows.append( + ( + label, + "-", + "-", + "hotadd", + "plain", + "openvixdisklib", + write_s, + read_s, + _mib_per_s(nbytes, write_s), + _mib_per_s(nbytes, read_s), + ) + ) + return rows + + +def _print_perf_table(rows: list[_PerfRow]) -> None: + """Print throughput rows to stdout (``tox -e perf`` uses ``-s``).""" + print() + print( + f"{'size':<14} {'aio_size':<8} {'aio_count':>9} " + f"{'transport':<10} {'flags':<12} {'library':<16} " + f"{'write_s':>10} {'read_s':>10} " + f"{'write_MiB/s':>12} {'read_MiB/s':>12}" + ) + for ( + label, + aio_label, + aio_count, + transport_mode, + mode_name, + name, + write_s, + read_s, + write_r, + read_r, + ) in rows: + print( + f"{label:<14} {aio_label:<8} {aio_count:>9} " + f"{transport_mode:<10} {mode_name:<12} {name:<16} " + f"{write_s:10.3f} {read_s:10.3f} " + f"{write_r:12.1f} {read_r:12.1f}" + ) + + class TestCompare: def test_write_read_throughput(self, lab: LabEnv, vddk: None) -> None: """Time matching write/read sizes on VDDK and openvixdisklib. @@ -287,7 +391,7 @@ def test_write_read_throughput(self, lab: LabEnv, vddk: None) -> None: True, ), ) - rows: list[tuple[str, str, int, str, str, str, float, float, float, float]] = [] + rows: list[_PerfRow] = [] for label, nbytes in _SIZES: for aio_buffer_count, aio_buffer_size in _AIO_SESSIONS: aio_label = _aio_size_label(aio_buffer_size) @@ -311,7 +415,7 @@ def test_write_read_throughput(self, lab: LabEnv, vddk: None) -> None: ( label, aio_label, - aio_buffer_count, + str(aio_buffer_count), transport_mode, mode_name, name, @@ -321,31 +425,21 @@ def test_write_read_throughput(self, lab: LabEnv, vddk: None) -> None: _mib_per_s(nbytes, read_s), ) ) - print() - print( - f"{'size':<14} {'aio_size':<8} {'aio_count':>9} " - f"{'transport':<10} {'flags':<12} {'library':<16} " - f"{'write_s':>10} {'read_s':>10} " - f"{'write_MiB/s':>12} {'read_MiB/s':>12}" - ) - for ( - label, - aio_label, - aio_buffer_count, - transport_mode, - mode_name, - name, - write_s, - read_s, - write_r, - read_r, - ) in rows: - print( - f"{label:<14} {aio_label:<8} {aio_buffer_count:>9} " - f"{transport_mode:<10} {mode_name:<12} {name:<16} " - f"{write_s:10.3f} {read_s:10.3f} " - f"{write_r:12.1f} {read_r:12.1f}" - ) + _print_perf_table(rows) + + def test_hotadd_write_read_throughput(self, lab: LabEnv) -> None: + """Time OpenVixDiskLib HotAdd write/read on the Linux proxy guest. + + Uses the same transfer sizes as ``test_write_read_throughput``. + FastLZ and NFC AIO do not apply. Native VDDK HotAdd is not + compared (it would also have to run in the guest). Skips when + ``hotadd_proxy`` is missing or SSH fails. + """ + try: + rows = _hotadd_rows(lab) + except RuntimeError as exc: + pytest.skip(str(exc)) + _print_perf_table(rows) if __name__ == "__main__": diff --git a/tests/unit/test_hotadd.py b/tests/unit/test_hotadd.py new file mode 100644 index 0000000..1636335 --- /dev/null +++ b/tests/unit/test_hotadd.py @@ -0,0 +1,276 @@ +# Copyright 2026 Cloudbase Solutions Srl +# All Rights Reserved. + +"""Unit tests for HotAdd attach specs and local block I/O.""" + +from __future__ import annotations + +import os +from unittest import mock + +import pytest +from pyVmomi import vim + +from openvixdisklib.hotadd import ( + AttachPlan, + HotAddDisk, + _byteswap_uuid, + _offline_scsi_unit, + build_attach_spec, + build_detach_spec, + find_scsi_block_device, + find_source_disk, + pick_scsi_slot, + wait_scsi_device_gone, +) +from openvixdisklib.openvixdisklib import ( + _available_transports, + _select_transport, +) + + +def _scsi(key: int = 1000, bus: int = 0) -> vim.vm.device.ParaVirtualSCSIController: + controller = vim.vm.device.ParaVirtualSCSIController() + controller.key = key + controller.busNumber = bus + return controller + + +def _lsi(key: int = 1000, bus: int = 0) -> vim.vm.device.VirtualLsiLogicController: + controller = vim.vm.device.VirtualLsiLogicController() + controller.key = key + controller.busNumber = bus + return controller + + +def _disk( + key: int, + controller_key: int, + unit: int, + file_name: str, +) -> vim.vm.device.VirtualDisk: + disk = vim.vm.device.VirtualDisk() + disk.key = key + disk.controllerKey = controller_key + disk.unitNumber = unit + backing = vim.vm.device.VirtualDisk.FlatVer2BackingInfo() + backing.fileName = file_name + disk.backing = backing + disk.capacityInKB = 1024 + return disk + + +class TestFindSourceDisk: + def test_nvme_source_is_accepted(self) -> None: + """NVMe-backed VMDKs are valid HotAdd sources.""" + nvme = vim.vm.device.VirtualNVMEController() + nvme.key = 31000 + nvme.busNumber = 0 + path = "[datastore0] nvme/nvme.vmdk" + disk = _disk(32000, 31000, 0, path) + assert find_source_disk([nvme, disk], path) is disk + + def test_sata_source_is_accepted(self) -> None: + """SATA-backed VMDKs are valid HotAdd sources.""" + sata = vim.vm.device.VirtualAHCIController() + sata.key = 15000 + sata.busNumber = 0 + path = "[datastore0] sata/sata.vmdk" + disk = _disk(16000, 15000, 0, path) + assert find_source_disk([sata, disk], path) is disk + + def test_ide_source_is_rejected(self) -> None: + """IDE disks cannot be HotAdded.""" + ide = vim.vm.device.VirtualIDEController() + ide.key = 200 + ide.busNumber = 0 + path = "[datastore0] ide/ide.vmdk" + disk = _disk(201, 200, 0, path) + with pytest.raises(NotImplementedError, match="IDE"): + find_source_disk([ide, disk], path) + + def test_missing_path_raises(self) -> None: + """Unknown backing paths raise FileNotFoundError.""" + scsi = _scsi() + disk = _disk(2000, 1000, 0, "[datastore0] vm/vm.vmdk") + with pytest.raises(FileNotFoundError): + find_source_disk([scsi, disk], "[datastore0] other/other.vmdk") + + +class TestAttachSpec: + def test_uses_free_unit_one_on_existing_scsi(self) -> None: + """A proxy with a boot disk at unit 0 HotAdds at unit 1.""" + devices = [ + _lsi(), + _disk(2000, 1000, 0, "[datastore0] proxy/boot.vmdk"), + ] + source = "[datastore0] src/src.vmdk" + plan = build_attach_spec(devices, source, read_only=True, capacity_kb=2048) + assert isinstance(plan, AttachPlan) + assert plan.bus_number == 0 + assert plan.unit_number == 1 + assert len(plan.spec.deviceChange) == 1 + change = plan.spec.deviceChange[0] + assert change.operation == vim.vm.device.VirtualDeviceSpec.Operation.add + assert change.fileOperation is None + disk = change.device + assert isinstance(disk, vim.vm.device.VirtualDisk) + assert disk.controllerKey == 1000 + assert disk.unitNumber == 1 + assert disk.backing.fileName == source + assert disk.backing.diskMode == "independent_nonpersistent" + assert disk.capacityInKB == 2048 + + def test_writable_open_uses_persistent_mode(self) -> None: + """Restore / write HotAdd attaches the VMDK persistently.""" + devices = [_scsi(), _disk(2000, 1000, 0, "[datastore0] proxy/boot.vmdk")] + plan = build_attach_spec(devices, "[datastore0] src/src.vmdk", read_only=False) + disk = plan.spec.deviceChange[0].device + assert disk.backing.diskMode == "persistent" + + def test_nvme_source_still_targets_proxy_scsi(self) -> None: + """NVMe sources are attached onto the proxy SCSI controller.""" + proxy = [_lsi(), _disk(2000, 1000, 0, "[datastore0] proxy/boot.vmdk")] + plan = build_attach_spec(proxy, "[datastore0] nvme/nvme.vmdk", read_only=True) + disk = plan.spec.deviceChange[0].device + assert disk.controllerKey == 1000 + assert not isinstance(disk, vim.vm.device.VirtualNVMEController) + + def test_full_bus_adds_pvscsi_controller(self) -> None: + """A new PVSCSI controller is added when every SCSI unit is taken.""" + devices: list[vim.vm.device.VirtualDevice] = [_scsi()] + key = 2000 + for unit in range(16): + if unit == 7: + continue + devices.append(_disk(key, 1000, unit, f"[datastore0] proxy/d{unit}.vmdk")) + key += 1 + plan = build_attach_spec(devices, "[datastore0] src/src.vmdk", read_only=True) + assert plan.bus_number == 1 + assert plan.unit_number == 0 + assert len(plan.spec.deviceChange) == 2 + ctrl_change, disk_change = plan.spec.deviceChange + assert ctrl_change.fileOperation is None + assert isinstance(ctrl_change.device, vim.vm.device.ParaVirtualSCSIController) + assert ctrl_change.device.busNumber == 1 + assert disk_change.fileOperation is None + assert disk_change.device.controllerKey == ctrl_change.device.key + assert disk_change.device.unitNumber == 0 + + def test_pick_scsi_slot_skips_reserved_unit_seven(self) -> None: + """SCSI unit 7 stays unused.""" + devices = [ + _scsi(), + _disk(2000, 1000, 0, "[datastore0] proxy/boot.vmdk"), + ] + controller, bus, unit = pick_scsi_slot(devices) + assert controller is not None + assert bus == 0 + assert unit == 1 + assert unit != 7 + + def test_detach_does_not_set_file_operation(self) -> None: + """Detach must never delete the source VMDK.""" + disk = _disk(2001, 1000, 1, "[datastore0] src/src.vmdk") + spec = build_detach_spec(disk) + change = spec.deviceChange[0] + assert change.operation == vim.vm.device.VirtualDeviceSpec.Operation.remove + assert change.fileOperation is None + assert change.device is disk + + +class TestScsiSysfs: + def test_finds_device_when_linux_host_differs_from_vmware_bus( + self, tmp_path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """VMware bus 0 can appear as Linux SCSI host 2.""" + block = tmp_path / "2:0:1:0" / "block" / "sdb" + block.mkdir(parents=True) + monkeypatch.setattr("openvixdisklib.hotadd.SCSI_DEVICE_DIR", str(tmp_path)) + assert find_scsi_block_device(0, 1) == "/dev/sdb" + + def test_offline_scsi_unit_writes_delete( + self, tmp_path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Linux keeps the LUN until sysfs delete is written.""" + device = tmp_path / "2:0:1:0" + (device / "block" / "sdb").mkdir(parents=True) + monkeypatch.setattr("openvixdisklib.hotadd.SCSI_DEVICE_DIR", str(tmp_path)) + _offline_scsi_unit(1) + assert (device / "delete").read_text(encoding="ascii") == "1\n" + + def test_wait_scsi_device_gone_returns_when_sysfs_empty( + self, tmp_path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Close succeeds after the SCSI sysfs node disappears.""" + monkeypatch.setattr("openvixdisklib.hotadd.SCSI_DEVICE_DIR", str(tmp_path)) + monkeypatch.setattr("openvixdisklib.hotadd.DEVICE_POLL_S", 0.01) + wait_scsi_device_gone(0, 1, timeout_s=1) + + def test_wait_scsi_device_gone_times_out( + self, tmp_path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Close fails if the LUN stays in sysfs after detach.""" + (tmp_path / "2:0:1:0" / "block" / "sdb").mkdir(parents=True) + monkeypatch.setattr("openvixdisklib.hotadd.SCSI_DEVICE_DIR", str(tmp_path)) + monkeypatch.setattr("openvixdisklib.hotadd.DEVICE_POLL_S", 0.01) + with pytest.raises(TimeoutError, match="still present"): + wait_scsi_device_gone(0, 1, timeout_s=0.05) + + +class TestHotAddDiskIO: + def test_read_write_sectors(self, tmp_path) -> None: + """pread/pwrite round-trip 512-byte sectors on a local file.""" + path = tmp_path / "disk.img" + path.write_bytes(b"\x00" * 1024) + fd = os.open(path, os.O_RDWR) + detached: list[bool] = [] + disk = HotAddDisk(fd, str(path), 0, 1, lambda: detached.append(True)) + pattern = b"OVDL-HA" * (512 // 7) + b"OVDL-HA"[: 512 % 7] + disk.write(1, 1, pattern) + buf = bytearray(512) + result = disk.readinto(1, 1, buf, skip_decompression=True) + assert bytes(buf) == pattern + assert result.uncompressed_length == 512 + assert result.fragments == () + disk.close() + assert detached == [True] + disk.close() + assert detached == [True] + + +class TestSelectTransport: + @mock.patch( + "openvixdisklib.openvixdisklib.hotadd.is_vmware_guest", return_value=False + ) + def test_colon_list_skips_hotadd_on_bare_metal( + self, mock_guest: mock.MagicMock + ) -> None: + """Bare metal skips hotadd and uses the next usable mode.""" + del mock_guest + assert _select_transport("file:san:hotadd:nbdssl:nbd") == "nbdssl" + assert _available_transports() == ["nbdssl", "nbd"] + with pytest.raises(NotImplementedError, match="hotadd"): + _select_transport("hotadd") + + @mock.patch( + "openvixdisklib.openvixdisklib.hotadd.is_vmware_guest", return_value=True + ) + def test_colon_list_selects_hotadd_in_guest( + self, mock_guest: mock.MagicMock + ) -> None: + """A VMware guest uses hotadd when it is first in the colon list.""" + del mock_guest + assert _select_transport("file:san:hotadd:nbdssl") == "hotadd" + assert _available_transports() == ["nbdssl", "nbd", "hotadd"] + + def test_default_is_nbdssl(self) -> None: + """None still defaults to nbdssl, even in a guest.""" + assert _select_transport(None) == "nbdssl" + + +def test_byteswap_uuid_matches_vmware_bios_uuid() -> None: + """Linux DMI UUID is byte-swapped relative to vim.vm.ConfigInfo.uuid.""" + dmi = "8ac43342-7478-0792-f6c6-131b895335ba" + bios = "4233c48a-7874-9207-f6c6-131b895335ba" + assert _byteswap_uuid(dmi).lower() == bios