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,241 @@
|
||||
"""The two driver classes: identity, connection handling and getters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import entry_points
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from napalm.base.exceptions import ConnectionException
|
||||
from napalm_device_types.roles import role_keys_of
|
||||
|
||||
from napalm_vmware import VmwareEsxiDriver, VmwareVcenterDriver
|
||||
from tests.conftest import FakeInventory
|
||||
|
||||
DRIVERS = [VmwareEsxiDriver, VmwareVcenterDriver]
|
||||
|
||||
|
||||
def _driver(cls, data, hostname="esx01.example.lan"):
|
||||
driver = cls(hostname, "root", "secret")
|
||||
driver._si = MagicMock()
|
||||
driver._inventory = FakeInventory(data)
|
||||
return driver
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def esxi(esxi_data):
|
||||
return _driver(VmwareEsxiDriver, esxi_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vcenter(vcenter_data):
|
||||
return _driver(VmwareVcenterDriver, vcenter_data, hostname="vc01.example.lan")
|
||||
|
||||
|
||||
class TestIdentity:
|
||||
@pytest.mark.parametrize("cls", DRIVERS)
|
||||
def test_entry_point_name_is_the_driver_name(self, cls):
|
||||
(ep,) = [e for e in entry_points(group="napalm.drivers") if e.name == cls.DRIVER_NAME]
|
||||
assert ep.load() is cls
|
||||
|
||||
@pytest.mark.parametrize("cls", DRIVERS)
|
||||
def test_is_a_hypervisor_and_nothing_else(self, cls):
|
||||
assert role_keys_of(cls) == ["hypervisor"]
|
||||
|
||||
@pytest.mark.parametrize("cls", DRIVERS)
|
||||
def test_talks_api_not_ssh(self, cls):
|
||||
assert cls.USES_SSH is False
|
||||
|
||||
@pytest.mark.parametrize("cls", DRIVERS)
|
||||
def test_never_fingerprints_on_the_vmware_oui(self, cls):
|
||||
"""00:50:56 is the OUI of *guest* NICs; every VM would score as a host."""
|
||||
assert not any(p.startswith("00:50:56") for p in cls.OUI_PREFIXES)
|
||||
|
||||
def test_fingerprints_exclude_each_other(self):
|
||||
esxi = {r.pattern: r for r in VmwareEsxiDriver.HTTP_FINGERPRINT}
|
||||
vc = {r.pattern: r for r in VmwareVcenterDriver.HTTP_FINGERPRINT}
|
||||
assert esxi["vmware esxi"].mandatory and vc["vmware esxi"].negative
|
||||
assert vc["vcenter"].mandatory and esxi["vcenter"].negative
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method",
|
||||
[
|
||||
"get_vms",
|
||||
"get_vm_config",
|
||||
"start_vm",
|
||||
"stop_vm",
|
||||
"reboot_vm",
|
||||
"suspend_vm",
|
||||
"get_vm_snapshots",
|
||||
"create_vm_snapshot",
|
||||
"delete_vm_snapshot",
|
||||
"rollback_vm_snapshot",
|
||||
"get_vm_storage_pools",
|
||||
"get_virtual_networks",
|
||||
"get_device_warnings",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("cls", DRIVERS)
|
||||
def test_implements_the_v1_contract(self, cls, method):
|
||||
assert callable(getattr(cls, method, None))
|
||||
|
||||
@pytest.mark.parametrize("cls", DRIVERS)
|
||||
def test_does_not_claim_provisioning(self, cls):
|
||||
"""netOrk offers VM creation wherever this exists; v1 cannot do it."""
|
||||
assert not hasattr(cls, "create_vm_from_cloud_init")
|
||||
|
||||
|
||||
class TestOptionalArgs:
|
||||
def test_defaults(self):
|
||||
d = VmwareEsxiDriver("h", "u", "p")
|
||||
assert (d._port, d._verify_ssl) == (443, True)
|
||||
|
||||
def test_netork_style_args(self):
|
||||
d = VmwareEsxiDriver("h", "u", "p", optional_args={"port": "8443", "ssl_verify": False})
|
||||
assert (d._port, d._verify_ssl) == (8443, False)
|
||||
|
||||
def test_verify_ssl_wins_over_ssl_verify(self):
|
||||
d = VmwareEsxiDriver("h", "u", "p", optional_args={"ssl_verify": True, "verify_ssl": False})
|
||||
assert d._verify_ssl is False
|
||||
|
||||
def test_ssh_keys_netork_adds_are_ignored(self):
|
||||
VmwareEsxiDriver("h", "u", "p", optional_args={"ssh_username": "x", "ssh_port": 22})
|
||||
|
||||
|
||||
class TestConnection:
|
||||
def _open(self, cls, data, connect_error=None):
|
||||
driver = cls("host", "u", "p")
|
||||
si = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"napalm_vmware.base._session.connect", return_value=si, side_effect=connect_error
|
||||
) as connect,
|
||||
patch("napalm_vmware.base._session.disconnect") as disconnect,
|
||||
patch("napalm_vmware.base.Inventory", return_value=FakeInventory(data)),
|
||||
):
|
||||
try:
|
||||
driver.open()
|
||||
finally:
|
||||
self.connect, self.disconnect = connect, disconnect
|
||||
return driver, si
|
||||
|
||||
def test_open_esxi(self, esxi_data):
|
||||
driver, si = self._open(VmwareEsxiDriver, esxi_data)
|
||||
assert driver._si is si
|
||||
self.connect.assert_called_once_with("host", 443, "u", "p", verify_ssl=True, timeout=60)
|
||||
|
||||
def test_esxi_driver_refuses_a_vcenter(self, vcenter_data):
|
||||
with pytest.raises(ConnectionException, match="vmware_vcenter"):
|
||||
self._open(VmwareEsxiDriver, vcenter_data)
|
||||
self.disconnect.assert_called_once()
|
||||
|
||||
def test_vcenter_driver_refuses_an_esxi_host(self, esxi_data):
|
||||
with pytest.raises(ConnectionException, match="vmware_esxi"):
|
||||
self._open(VmwareVcenterDriver, esxi_data)
|
||||
|
||||
def test_connect_failure_is_a_connection_exception(self, esxi_data):
|
||||
with pytest.raises(ConnectionException, match="host"):
|
||||
self._open(VmwareEsxiDriver, esxi_data, connect_error=OSError("refused"))
|
||||
|
||||
def test_close(self, esxi):
|
||||
si = esxi._si
|
||||
with patch("napalm_vmware.base._session.disconnect") as disconnect:
|
||||
esxi.close()
|
||||
esxi.close() # idempotent
|
||||
disconnect.assert_called_once_with(si)
|
||||
assert esxi._inventory is None
|
||||
|
||||
def test_is_alive(self, esxi):
|
||||
with patch("napalm_vmware.base._session.alive", return_value=True):
|
||||
assert esxi.is_alive() == {"is_alive": True}
|
||||
esxi._si = None
|
||||
assert esxi.is_alive() == {"is_alive": False}
|
||||
|
||||
|
||||
class TestEsxiGetters:
|
||||
def test_facts(self, esxi):
|
||||
assert esxi.get_facts()["hostname"] == "localhost"
|
||||
|
||||
def test_interfaces(self, esxi):
|
||||
assert set(esxi.get_interfaces()) == {"vmnic0", "vmnic1", "vmk0"}
|
||||
assert esxi.get_interfaces_ip() == {}
|
||||
|
||||
def test_vms(self, esxi):
|
||||
assert [vm["name"] for vm in esxi.get_vms()] == ["ha-host_VM0", "ha-host_VM1"]
|
||||
|
||||
def test_lldp_via_query_network_hint(self, esxi, esxi_host):
|
||||
esxi_host["configManager.networkSystem"] = "networkSystem"
|
||||
network_system = MagicMock()
|
||||
network_system.QueryNetworkHint.return_value = []
|
||||
esxi._mo = MagicMock(return_value=network_system)
|
||||
assert esxi.get_lldp_neighbors() == {}
|
||||
esxi._mo.assert_called_once()
|
||||
|
||||
def test_lldp_unsupported_is_empty_not_an_error(self, esxi, esxi_host):
|
||||
from pyVmomi import vmodl
|
||||
|
||||
esxi_host["configManager.networkSystem"] = "networkSystem"
|
||||
network_system = MagicMock()
|
||||
network_system.QueryNetworkHint.side_effect = vmodl.fault.NotSupported()
|
||||
esxi._mo = MagicMock(return_value=network_system)
|
||||
assert esxi.get_lldp_neighbors() == {}
|
||||
|
||||
def test_no_host_visible(self, esxi):
|
||||
esxi._inventory.objects("HostSystem").clear()
|
||||
with pytest.raises(RuntimeError, match="no host"):
|
||||
esxi.get_facts()
|
||||
|
||||
def test_warnings(self, esxi, esxi_host):
|
||||
esxi_host["runtime.inMaintenanceMode"] = True
|
||||
assert [w["code"] for w in esxi.get_device_warnings()] == ["vmware_host_maintenance_mode"]
|
||||
|
||||
def test_storage_and_networks(self, esxi):
|
||||
assert list(esxi.get_vm_storage_pools())
|
||||
assert "VM Network" in esxi.get_virtual_networks()
|
||||
|
||||
def test_environment(self, esxi):
|
||||
assert "0" in esxi.get_environment()["cpu"]
|
||||
|
||||
|
||||
class TestVcenterGetters:
|
||||
def test_facts(self, vcenter):
|
||||
assert vcenter.get_facts()["hostname"] == "vc01"
|
||||
|
||||
def test_host_nics_are_not_the_vcenters(self, vcenter):
|
||||
"""Reporting every host's vmnics on the vCenter device would attach
|
||||
their MACs to the wrong device in netOrk's topology."""
|
||||
assert vcenter.get_interfaces() == {}
|
||||
assert vcenter.get_interfaces_ip() == {}
|
||||
assert vcenter.get_lldp_neighbors() == {}
|
||||
|
||||
def test_vms_span_all_hosts(self, vcenter):
|
||||
assert len({vm["node"] for vm in vcenter.get_vms()}) > 1
|
||||
|
||||
def test_distributed_portgroups(self, vcenter):
|
||||
assert vcenter.get_virtual_networks()["DC0_DVPG0"]["type"] == "dvportgroup"
|
||||
|
||||
|
||||
class TestFindVm:
|
||||
def test_by_name_vmid_or_moref(self, esxi, esxi_data):
|
||||
vm0 = esxi_data["objects"]["VirtualMachine"][0]
|
||||
for key in (vm0["name"], vm0["config.instanceUuid"], vm0["_moref"]):
|
||||
assert esxi.get_vm_config(key)["vmid"] == vm0["config.instanceUuid"]
|
||||
|
||||
def test_unknown(self, esxi):
|
||||
with pytest.raises(ValueError, match="no VM"):
|
||||
esxi.get_vm_config("nope")
|
||||
|
||||
def test_ambiguous_name(self, esxi, esxi_data):
|
||||
vms = esxi_data["objects"]["VirtualMachine"]
|
||||
vms[1]["name"] = vms[0]["name"]
|
||||
with pytest.raises(ValueError, match="2 VMs"):
|
||||
esxi.get_vm_config(vms[0]["name"])
|
||||
|
||||
def test_templates_are_not_found(self, esxi, esxi_data):
|
||||
vm0 = esxi_data["objects"]["VirtualMachine"][0]
|
||||
vm0["config.template"] = True
|
||||
with pytest.raises(ValueError):
|
||||
esxi.get_vm_config(vm0["name"])
|
||||
|
||||
def test_snapshots(self, esxi):
|
||||
assert esxi.get_vm_snapshots("ha-host_VM0") == []
|
||||
Reference in New Issue
Block a user