feat: NAPALM drivers for VMware ESXi and vCenter
Two drivers from one package, both in the hypervisor role: - vmware_esxi talks to one host directly: facts, vmnics and vmkernel NICs, CDP/LLDP neighbours, sensors, VMs, datastores and port groups. - vmware_vcenter talks to a vCenter: every VM of every host it manages, with the host as the VM's node, plus distributed port groups. It reports no interfaces of its own; the hosts' NICs belong to the hosts. Both implement the HypervisorDriver VM contract: get_vms, get_vm_config, start/stop/reboot/suspend_vm and the four snapshot methods, and emit raw device warnings (maintenance mode, disconnected host, config issues, host managed by a vCenter, free license making the API read-only). Every read is a PropertyCollector query for the explicit paths in paths.py, converted by to_plain() into dicts and lists; the parsers only ever see that. tools/harvest.py dumps exactly those paths to JSON and tools/sanitize.py scrubs the dump, so a real host can become a test fixture without code changes. A VM's vmid is its instance UUID, which survives vMotion and re-registration; a MoRef does not. Tested against govmomi's vcsim in ESXi and vCenter mode, including real power and snapshot tasks. Not yet tested against real hardware.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""Waiting for vSphere tasks, and turning vSphere faults into RuntimeError."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pyVmomi import vim
|
||||
|
||||
from napalm_vmware._tasks import fault_message, invoke, wait_for_task
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self):
|
||||
self.now = 0.0
|
||||
|
||||
def __call__(self):
|
||||
return self.now
|
||||
|
||||
def sleep(self, seconds):
|
||||
self.now += seconds
|
||||
|
||||
|
||||
def _inventory(*states):
|
||||
inv = MagicMock()
|
||||
inv.properties.side_effect = list(states)
|
||||
return inv
|
||||
|
||||
|
||||
class TestWaitForTask:
|
||||
def test_returns_once_the_task_succeeds(self):
|
||||
inv = _inventory({"info.state": "running"}, {"info.state": "success"})
|
||||
clock = _Clock()
|
||||
wait_for_task(inv, "task", timeout=10, clock=clock, sleep=clock.sleep)
|
||||
assert inv.properties.call_count == 2
|
||||
|
||||
def test_error_becomes_runtime_error_with_the_fault_text(self):
|
||||
inv = _inventory(
|
||||
{
|
||||
"info.state": "error",
|
||||
"info.error": {
|
||||
"_type": "InvalidPowerState",
|
||||
"msg": "The attempted operation cannot be performed "
|
||||
"in the current state (Powered off).",
|
||||
},
|
||||
}
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="current state"):
|
||||
wait_for_task(inv, "task", timeout=10)
|
||||
|
||||
def test_times_out(self):
|
||||
clock = _Clock()
|
||||
inv = MagicMock()
|
||||
inv.properties.return_value = {"info.state": "running"}
|
||||
with pytest.raises(RuntimeError, match="did not finish within 5s"):
|
||||
wait_for_task(inv, "task", timeout=5, clock=clock, sleep=clock.sleep)
|
||||
|
||||
def test_vanished_task_is_an_error(self):
|
||||
with pytest.raises(RuntimeError, match="disappeared"):
|
||||
wait_for_task(_inventory({}), "task", timeout=5)
|
||||
|
||||
|
||||
class TestFaultMessage:
|
||||
def test_free_license(self):
|
||||
msg = fault_message(
|
||||
{
|
||||
"_type": "RestrictedVersion",
|
||||
"msg": "Current license or ESXi version prohibits "
|
||||
"execution of the requested operation.",
|
||||
}
|
||||
)
|
||||
assert "free" in msg.lower()
|
||||
assert "read-only" in msg
|
||||
|
||||
def test_localized_message_preferred(self):
|
||||
assert fault_message({"_type": "X", "localizedMessage": "L", "msg": "M"}) == "L"
|
||||
|
||||
def test_bare_type(self):
|
||||
assert fault_message({"_type": "NotSupported"}) == "NotSupported"
|
||||
|
||||
|
||||
class TestInvoke:
|
||||
def test_passes_the_result_through(self):
|
||||
assert invoke(lambda x: x + 1, 1) == 2
|
||||
|
||||
def test_method_fault_becomes_runtime_error(self):
|
||||
def boom():
|
||||
raise vim.fault.RestrictedVersion(msg="prohibited")
|
||||
|
||||
with pytest.raises(RuntimeError, match="free"):
|
||||
invoke(boom)
|
||||
|
||||
def test_other_exceptions_are_left_alone(self):
|
||||
def boom():
|
||||
raise KeyError("x")
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
invoke(boom)
|
||||
|
||||
|
||||
class TestWaitUntil:
|
||||
def test_returns_when_the_check_passes(self):
|
||||
from napalm_vmware._tasks import wait_until
|
||||
|
||||
answers = iter([False, False, True])
|
||||
clock = _Clock()
|
||||
wait_until(lambda: next(answers), timeout=10, what="x", clock=clock, sleep=clock.sleep)
|
||||
assert clock.now == 1.0
|
||||
|
||||
def test_times_out_naming_what_it_waited_for(self):
|
||||
from napalm_vmware._tasks import wait_until
|
||||
|
||||
clock = _Clock()
|
||||
with pytest.raises(RuntimeError, match="guest shutdown did not finish within 3s"):
|
||||
wait_until(
|
||||
lambda: False, timeout=3, what="guest shutdown", clock=clock, sleep=clock.sleep
|
||||
)
|
||||
Reference in New Issue
Block a user