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:
Christian Manivong
2026-09-24 09:07:30 +02:00
commit c8e472b4c6
47 changed files with 14645 additions and 0 deletions
View File
+68
View File
@@ -0,0 +1,68 @@
"""Shared fixtures: harvested inventories and a fake Inventory that serves them.
The JSON files under ``fixtures/`` were produced by ``tools/harvest.py``
against govmomi's vcsim simulator (``vmware/vcsim`` and ``vmware/vcsim -esx``).
They have the real vSphere shapes but simulator values: vcsim labels devices
``disk-202-0`` where a real host says ``Hard disk 1``, reports no sensors to
speak of, and sets ``managementServerIp`` to ``0.0.0.0``. Tests that need a
branch vcsim does not produce copy a fixture and edit it.
"""
from __future__ import annotations
import copy
import json
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import pytest
FIXTURES = Path(__file__).parent / "fixtures"
_CACHE: dict[str, dict[str, Any]] = {}
def load(name: str) -> dict[str, Any]:
"""A private deep copy of fixture ``name``; tests may mutate it freely."""
if name not in _CACHE:
_CACHE[name] = json.loads((FIXTURES / f"{name}.json").read_text())
return copy.deepcopy(_CACHE[name])
class FakeInventory:
"""Serves a harvest dump the way ``Inventory`` serves a live endpoint."""
def __init__(self, data: dict[str, Any]) -> None:
self.data = data
self.calls: list[tuple[str, tuple[str, ...]]] = []
def about(self) -> dict[str, Any]:
return self.data["about"]
def licenses(self) -> list[dict[str, Any]]:
return self.data.get("licenses", [])
def collect(self, type_name: str, paths: Sequence[str]) -> list[dict[str, Any]]:
self.calls.append((type_name, tuple(paths)))
rows = self.data["objects"].get(type_name, [])
wanted = set(paths) | {"_moref"}
return [{k: v for k, v in row.items() if k in wanted} for row in rows]
# Convenience for tests that tweak a fixture.
def objects(self, type_name: str) -> list[dict[str, Any]]:
return self.data["objects"][type_name]
@pytest.fixture
def esxi_data() -> dict[str, Any]:
return load("vcsim-esxi")
@pytest.fixture
def vcenter_data() -> dict[str, Any]:
return load("vcsim-vcenter")
@pytest.fixture
def esxi_host(esxi_data) -> dict[str, Any]:
return esxi_data["objects"]["HostSystem"][0]
+2392
View File
File diff suppressed because it is too large Load Diff
+8374
View File
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
"""Power and snapshot actions: which vSphere call each one makes, and refusals."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from napalm_vmware import VmwareEsxiDriver
from tests.conftest import FakeInventory
@pytest.fixture
def vm0(esxi_data):
return esxi_data["objects"]["VirtualMachine"][0]
@pytest.fixture
def driver(esxi_data):
d = VmwareEsxiDriver("esx01", "root", "secret")
d._si = MagicMock()
d._inventory = FakeInventory(esxi_data)
d.mo = MagicMock(name="vm")
d._mo = MagicMock(return_value=d.mo)
return d
@pytest.fixture
def waited():
with patch("napalm_vmware.actions.wait_for_task") as wait:
yield wait
class TestPower:
def test_start(self, driver, vm0, waited):
vm0["runtime.powerState"] = "poweredOff"
driver.start_vm(vm0["name"])
driver.mo.PowerOnVM_Task.assert_called_once_with()
assert waited.call_args.args[1] is driver.mo.PowerOnVM_Task.return_value
def test_forced_stop_powers_off(self, driver, vm0, waited):
driver.stop_vm(vm0["name"], force=True)
driver.mo.PowerOffVM_Task.assert_called_once_with()
def test_graceful_stop_asks_tools_and_waits_for_power_off(self, driver, vm0):
vm0["guest.toolsRunningStatus"] = "guestToolsRunning"
with patch("napalm_vmware.actions.wait_until") as until:
driver.stop_vm(vm0["name"])
driver.mo.ShutdownGuest.assert_called_once_with()
driver._inventory.data["objects"]["VirtualMachine"][0]["runtime.powerState"] = "poweredOff"
driver._inventory.properties = lambda obj, paths: {"runtime.powerState": "poweredOff"}
check = until.call_args.args[0]
assert check() is True
def test_graceful_stop_without_tools_is_refused(self, driver, vm0):
with pytest.raises(RuntimeError, match="VMware Tools"):
driver.stop_vm(vm0["name"])
driver.mo.ShutdownGuest.assert_not_called()
def test_stopping_a_stopped_vm_is_refused(self, driver, vm0):
vm0["runtime.powerState"] = "poweredOff"
with pytest.raises(RuntimeError, match="not running"):
driver.stop_vm(vm0["name"], force=True)
def test_graceful_reboot(self, driver, vm0):
vm0["guest.toolsRunningStatus"] = "guestToolsRunning"
driver.reboot_vm(vm0["name"])
driver.mo.RebootGuest.assert_called_once_with()
def test_forced_reboot_resets(self, driver, vm0, waited):
driver.reboot_vm(vm0["name"], force=True)
driver.mo.ResetVM_Task.assert_called_once_with()
def test_suspend(self, driver, vm0, waited):
driver.suspend_vm(vm0["name"])
driver.mo.SuspendVM_Task.assert_called_once_with()
def test_suspend_requires_a_running_vm(self, driver, vm0):
vm0["runtime.powerState"] = "suspended"
with pytest.raises(RuntimeError, match="not running"):
driver.suspend_vm(vm0["name"])
def test_free_license_refusal_is_explained(self, driver, vm0):
from pyVmomi import vim
vm0["runtime.powerState"] = "poweredOff"
driver.mo.PowerOnVM_Task.side_effect = vim.fault.RestrictedVersion(msg="prohibited")
with pytest.raises(RuntimeError, match="free vSphere Hypervisor"):
driver.start_vm(vm0["name"])
def test_unknown_vm(self, driver):
with pytest.raises(ValueError):
driver.start_vm("nope")
def _with_snapshot(vm, name="before-upgrade", moref="snapshot-1"):
vm["snapshot"] = {
"rootSnapshotList": [
{"name": name, "snapshot": moref, "createTime": 1.0, "state": "poweredOff"}
]
}
class TestSnapshots:
def test_create(self, driver, vm0, waited):
driver.create_vm_snapshot(vm0["name"], "pre", description="d", include_memory=True)
driver.mo.CreateSnapshot_Task.assert_called_once_with(
name="pre", description="d", memory=True, quiesce=False
)
def test_memory_is_only_captured_from_a_running_vm(self, driver, vm0, waited):
vm0["runtime.powerState"] = "poweredOff"
driver.create_vm_snapshot(vm0["name"], "pre", include_memory=True)
assert driver.mo.CreateSnapshot_Task.call_args.kwargs["memory"] is False
def test_create_refuses_a_duplicate_name(self, driver, vm0):
_with_snapshot(vm0, "pre")
with pytest.raises(ValueError, match="already"):
driver.create_vm_snapshot(vm0["name"], "pre")
def test_delete_keeps_the_children(self, driver, vm0, waited):
_with_snapshot(vm0)
driver.delete_vm_snapshot(vm0["name"], "before-upgrade")
assert driver._mo.call_args.args[1] == "snapshot-1"
driver.mo.RemoveSnapshot_Task.assert_called_once_with(removeChildren=False)
def test_rollback(self, driver, vm0, waited):
_with_snapshot(vm0)
driver.rollback_vm_snapshot(vm0["name"], "before-upgrade")
driver.mo.RevertToSnapshot_Task.assert_called_once_with()
def test_unknown_snapshot(self, driver, vm0):
with pytest.raises(ValueError, match="no snapshot"):
driver.rollback_vm_snapshot(vm0["name"], "nope")
+241
View File
@@ -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") == []
+121
View File
@@ -0,0 +1,121 @@
"""Inventory: the single seam between the drivers and the vSphere API."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from pyVmomi import vim
from napalm_vmware._inventory import Inventory
def _content(obj, props, missing=()):
return SimpleNamespace(
obj=obj,
propSet=[SimpleNamespace(name=k, val=v) for k, v in props.items()],
missingSet=[SimpleNamespace(path=p) for p in missing],
)
@pytest.fixture
def content():
"""pyVmomi type-checks spec fields, so the objects put into them are real."""
c = MagicMock()
c.about = vim.AboutInfo(apiType="HostAgent", fullName="VMware ESXi 8.0.2")
view = vim.view.ContainerView("session[1]view-1", None)
view.Destroy = MagicMock()
c.viewManager.CreateContainerView.return_value = view
c.licenseManager = vim.LicenseManager("LicenseManager", None)
return c
class TestCollect:
def test_returns_plain_dicts_keyed_by_path_with_moref(self, content):
pc = content.propertyCollector
pc.RetrievePropertiesEx.return_value = SimpleNamespace(
objects=[
_content(
vim.VirtualMachine("vm-1", None),
{"name": "web01", "runtime.powerState": "poweredOn"},
)
],
token=None,
)
rows = Inventory(content).collect("VirtualMachine", ["name", "runtime.powerState"])
assert rows == [{"_moref": "vm-1", "name": "web01", "runtime.powerState": "poweredOn"}]
def test_follows_continuation_tokens(self, content):
pc = content.propertyCollector
pc.RetrievePropertiesEx.return_value = SimpleNamespace(
objects=[_content(vim.VirtualMachine("vm-1", None), {"name": "a"})], token="t1"
)
pc.ContinueRetrievePropertiesEx.return_value = SimpleNamespace(
objects=[_content(vim.VirtualMachine("vm-2", None), {"name": "b"})], token=None
)
rows = Inventory(content).collect("VirtualMachine", ["name"])
assert [r["_moref"] for r in rows] == ["vm-1", "vm-2"]
pc.ContinueRetrievePropertiesEx.assert_called_once_with(token="t1")
def test_empty_result(self, content):
content.propertyCollector.RetrievePropertiesEx.return_value = None
assert Inventory(content).collect("Datastore", ["name"]) == []
def test_missing_properties_are_simply_absent(self, content):
"""An inaccessible VM has no config; the row must still come back."""
content.propertyCollector.RetrievePropertiesEx.return_value = SimpleNamespace(
objects=[
_content(vim.VirtualMachine("vm-9", None), {"name": "ghost"}, ["config.template"])
],
token=None,
)
rows = Inventory(content).collect("VirtualMachine", ["name", "config.template"])
assert rows == [{"_moref": "vm-9", "name": "ghost"}]
def test_the_container_view_is_destroyed(self, content):
content.propertyCollector.RetrievePropertiesEx.return_value = None
Inventory(content).collect("HostSystem", ["name"])
content.viewManager.CreateContainerView.return_value.Destroy.assert_called_once()
def test_unknown_type_name_is_rejected(self, content):
with pytest.raises(ValueError, match="NoSuchThing"):
Inventory(content).collect("NoSuchThing", ["name"])
def test_about_is_plain(content):
assert Inventory(content).about()["apiType"] == "HostAgent"
def test_licenses_are_read_from_the_license_manager(content):
content.propertyCollector.RetrievePropertiesEx.return_value = SimpleNamespace(
objects=[
_content(
vim.LicenseManager("LicenseManager", None),
{"licenses": [vim.LicenseManager.LicenseInfo(editionKey="esxBasic", name="Free")]},
)
],
token=None,
)
assert Inventory(content).licenses()[0]["editionKey"] == "esxBasic"
def test_licenses_empty_without_a_license_manager(content):
content.licenseManager = None
assert Inventory(content).licenses() == []
def test_properties_of_a_single_object(content):
content.propertyCollector.RetrievePropertiesEx.return_value = SimpleNamespace(
objects=[_content(vim.Task("task-7", None), {"info.state": "success"})], token=None
)
task = vim.Task("task-7", None)
assert Inventory(content).properties(task, ["info.state"]) == {
"_moref": "task-7",
"info.state": "success",
}
def test_properties_of_a_vanished_object(content):
content.propertyCollector.RetrievePropertiesEx.return_value = None
assert Inventory(content).properties(vim.Task("task-7", None), ["info.state"]) == {}
+95
View File
@@ -0,0 +1,95 @@
"""get_facts for an ESXi host and for a vCenter."""
from __future__ import annotations
import pytest
from napalm_vmware.parse.facts import esxi_facts, host_serial, vcenter_facts
class TestEsxiFacts:
def test_from_vcsim(self, esxi_data, esxi_host):
facts = esxi_facts(esxi_data["about"], esxi_host)
assert facts == {
"hostname": "localhost",
"fqdn": "localhost.localdomain",
"vendor": "VMware, Inc.",
"model": "VMware Virtual Platform",
"serial_number": "VMware-56 4d 8d e8 1e 9f a1 3e-71 fa 13 a8 e1 a7 fd 70",
"os_version": "VMware ESXi 8.0.2 build-21997540",
"uptime": 77229.0,
"interface_list": ["vmk0", "vmnic0", "vmnic1"],
}
def test_falls_back_to_the_inventory_name_without_dns_config(self, esxi_data, esxi_host):
del esxi_host["config.network.dnsConfig"]
facts = esxi_facts(esxi_data["about"], esxi_host)
assert facts["hostname"] == "localhost"
assert facts["fqdn"] == "localhost.localdomain"
def test_hostname_without_domain(self, esxi_data, esxi_host):
esxi_host["config.network.dnsConfig"]["domainName"] = ""
assert esxi_facts(esxi_data["about"], esxi_host)["fqdn"] == "localhost"
def test_unknown_uptime_is_minus_one(self, esxi_data, esxi_host):
del esxi_host["summary.quickStats"]
assert esxi_facts(esxi_data["about"], esxi_host)["uptime"] == -1.0
def test_os_version_falls_back_to_about(self, esxi_data, esxi_host):
del esxi_host["config.product"]
facts = esxi_facts(esxi_data["about"], esxi_host)
assert facts["os_version"] == esxi_data["about"]["fullName"]
class TestHostSerial:
def _info(self, *pairs):
return {
"otherIdentifyingInfo": [
{"identifierType": {"key": k}, "identifierValue": v} for k, v in pairs
]
}
def test_system_info_serial_wins(self):
host = {
"hardware.systemInfo": {"serialNumber": "ABC123", **self._info(("ServiceTag", "x"))}
}
assert host_serial(host) == "ABC123"
@pytest.mark.parametrize(
("pairs", "expected"),
[
((("ServiceTag", "SVC"), ("SerialNumberTag", "SER")), "SER"),
((("EnclosureSerialNumberTag", "ENC"), ("ServiceTag", "SVC")), "ENC"),
((("AssetTag", "No Asset Tag"),), ""),
],
)
def test_identifying_info_by_preference(self, pairs, expected):
assert host_serial({"hardware.systemInfo": self._info(*pairs)}) == expected
def test_falls_back_to_summary_hardware(self):
host = {"summary.hardware": self._info(("SerialNumberTag", "OLD"))}
assert host_serial(host) == "OLD"
def test_blank_values_are_ignored(self):
host = {"hardware.systemInfo": self._info(("SerialNumberTag", " "), ("ServiceTag", "S"))}
assert host_serial(host) == "S"
class TestVcenterFacts:
def test_from_vcsim(self, vcenter_data):
facts = vcenter_facts(vcenter_data["about"], "vc01.example.lan")
assert facts == {
"hostname": "vc01",
"fqdn": "vc01.example.lan",
"vendor": "VMware, Inc.",
"model": "VMware vCenter Server",
"serial_number": "35bb7c82-8526-5aa7-a886-cd7f8e6be786",
"os_version": "VMware vCenter Server 6.5.0 build-5973321 (govmomi simulator)",
"uptime": -1.0,
"interface_list": [],
}
def test_ip_address_is_not_split_into_a_hostname(self, vcenter_data):
facts = vcenter_facts(vcenter_data["about"], "10.0.0.5")
assert facts["hostname"] == "10.0.0.5"
assert facts["fqdn"] == "10.0.0.5"
+184
View File
@@ -0,0 +1,184 @@
"""Datastores, sensors/utilisation, CDP/LLDP neighbours and raw device warnings."""
from __future__ import annotations
import pytest
from napalm_vmware.parse.environment import environment
from napalm_vmware.parse.lldp import lldp_neighbors
from napalm_vmware.parse.storage import storage_pools
from napalm_vmware.parse.warnings import host_warnings
class TestStoragePools:
def test_from_vcsim(self, esxi_data):
pools = storage_pools(esxi_data["objects"]["Datastore"])
(name,) = pools
pool = pools[name]
assert pool["name"] == name
assert pool["type"] == "other"
assert pool["used"] == pool["total"] - pool["available"]
assert pool["enabled"] is True
assert pool["shared"] is False
def test_shared_vmfs(self):
ds = {
"_moref": "datastore-1",
"name": "san01",
"summary": {
"type": "VMFS",
"capacity": 1000,
"freeSpace": 250,
"accessible": False,
"multipleHostAccess": True,
},
}
assert storage_pools([ds])["san01"] == {
"name": "san01",
"type": "vmfs",
"total": 1000,
"used": 750,
"available": 250,
"enabled": False,
"shared": True,
}
def _sensor(name, kind, reading, health="green", modifier=0):
return {
"name": name,
"sensorType": kind,
"currentReading": reading,
"unitModifier": modifier,
"healthState": {"key": health},
}
class TestEnvironment:
def test_utilisation_of_one_host(self, esxi_host):
esxi_host["summary.hardware"].update(cpuMhz=2000, numCpuCores=4, memorySize=8 * 1024**3)
esxi_host["summary.quickStats"].update(overallCpuUsage=2000, overallMemoryUsage=2048)
env = environment([esxi_host])
assert env["cpu"] == {"0": {"%usage": 25.0}}
assert env["memory"] == {"available_ram": 8 * 1024**3, "used_ram": 2048 * 1024**2}
def test_sensors(self, esxi_host):
esxi_host["runtime.healthSystemRuntime.systemHealthInfo.numericSensorInfo"] = [
_sensor("CPU1 Temp", "temperature", 4500, modifier=-2),
_sensor("Inlet Temp", "temperature", 41, health="yellow"),
_sensor("PCH Temp", "temperature", 90, health="red"),
_sensor("FAN1", "fan", 5400),
_sensor("FAN2", "fan", 0, health="red"),
_sensor("PSU1 Output", "power", 180),
_sensor("12V", "voltage", 12),
]
env = environment([esxi_host])
assert env["temperature"] == {
"CPU1 Temp": {"temperature": 45.0, "is_alert": False, "is_critical": False},
"Inlet Temp": {"temperature": 41.0, "is_alert": True, "is_critical": False},
"PCH Temp": {"temperature": 90.0, "is_alert": True, "is_critical": True},
}
assert env["fans"] == {"FAN1": {"status": True}, "FAN2": {"status": False}}
assert env["power"] == {"PSU1 Output": {"status": True, "capacity": -1.0, "output": 180.0}}
def test_several_hosts_are_keyed_by_host(self, vcenter_data):
hosts = vcenter_data["objects"]["HostSystem"]
hosts[0]["runtime.healthSystemRuntime.systemHealthInfo.numericSensorInfo"] = [
_sensor("Inlet Temp", "temperature", 30)
]
env = environment(hosts)
assert set(env["cpu"]) == {h["name"] for h in hosts}
assert f"{hosts[0]['name']}/Inlet Temp" in env["temperature"]
assert env["memory"]["available_ram"] == sum(
h["summary.hardware"]["memorySize"] for h in hosts
)
def test_host_without_stats(self):
env = environment([{"_moref": "host-1", "name": "h"}])
assert env["cpu"] == {"0": {"%usage": 0.0}}
assert env["memory"] == {"available_ram": 0, "used_ram": 0}
class TestLldpNeighbors:
def test_lldp_preferred_over_cdp(self):
hints = [
{
"device": "vmnic0",
"connectedSwitchPort": {"devId": "cdp-sw", "portId": "Gi1/0/1"},
"lldpInfo": {
"chassisId": "00:11:22:33:44:55",
"portId": "1/1/7",
"parameter": [{"key": "System Name", "value": "core-sw"}],
},
},
{"device": "vmnic1", "connectedSwitchPort": {"devId": "cdp-sw", "portId": "Gi1/0/2"}},
{"device": "vmnic2", "lldpInfo": {"chassisId": "aa:bb", "portId": "5"}},
{"device": "vmnic3"},
]
assert lldp_neighbors(hints) == {
"vmnic0": [{"hostname": "core-sw", "port": "1/1/7"}],
"vmnic1": [{"hostname": "cdp-sw", "port": "Gi1/0/2"}],
"vmnic2": [{"hostname": "aa:bb", "port": "5"}],
}
def test_nothing_heard(self):
assert lldp_neighbors([]) == {}
class TestHostWarnings:
def test_healthy_vcsim_host(self, esxi_host):
assert host_warnings([esxi_host], [], standalone=True) == []
def test_maintenance_mode(self, esxi_host):
esxi_host["runtime.inMaintenanceMode"] = True
assert host_warnings([esxi_host], [], standalone=True) == [
{"code": "vmware_host_maintenance_mode", "meta": {"host": "localhost.localdomain"}}
]
@pytest.mark.parametrize("state", ["disconnected", "notResponding"])
def test_disconnected_host_under_vcenter(self, esxi_host, state):
esxi_host["runtime.connectionState"] = state
assert host_warnings([esxi_host], [], standalone=False) == [
{
"code": "vmware_host_disconnected",
"meta": {"host": "localhost.localdomain", "state": state},
}
]
def test_config_issues(self, esxi_host):
esxi_host["configIssue"] = [
{"fullFormattedMessage": "SSH for the host has been enabled"},
{"fullFormattedMessage": ""},
]
assert host_warnings([esxi_host], [], standalone=True) == [
{
"code": "vmware_host_config_issue",
"meta": {
"host": "localhost.localdomain",
"issues": ["SSH for the host has been enabled"],
},
}
]
def test_standalone_driver_on_a_vcenter_managed_host(self, esxi_host):
esxi_host["summary.managementServerIp"] = "192.0.2.5"
assert host_warnings([esxi_host], [], standalone=True) == [
{"code": "vmware_host_managed_by_vcenter", "meta": {"vcenter": "192.0.2.5"}}
]
def test_vcenter_driver_does_not_report_its_own_management(self, esxi_host):
esxi_host["summary.managementServerIp"] = "192.0.2.5"
assert host_warnings([esxi_host], [], standalone=False) == []
def test_free_license_makes_the_api_read_only(self, esxi_host):
licenses = [{"editionKey": "esxBasic", "name": "VMware vSphere 8 Hypervisor"}]
assert host_warnings([esxi_host], licenses, standalone=True) == [
{
"code": "vmware_api_read_only",
"meta": {"edition": "VMware vSphere 8 Hypervisor"},
}
]
def test_evaluation_license_is_not_read_only(self, esxi_host):
licenses = [{"editionKey": "eval", "name": "Evaluation Mode"}]
assert host_warnings([esxi_host], licenses, standalone=True) == []
+80
View File
@@ -0,0 +1,80 @@
"""get_interfaces / get_interfaces_ip for an ESXi host: vmnics and vmkernel NICs."""
from __future__ import annotations
from napalm_vmware.parse.interfaces import host_interfaces, host_interfaces_ip
class TestHostInterfaces:
def test_physical_nic_with_link(self, esxi_host):
vmnic0 = host_interfaces(esxi_host)["vmnic0"]
assert vmnic0 == {
"is_up": True,
"is_enabled": True,
"description": "nvmxnet3 0000:0b:00.0",
"last_flapped": -1.0,
"speed": 10000.0,
"mtu": 1500,
"mac_address": "00:0C:29:81:D8:A0",
}
def test_physical_nic_without_link_is_down(self, esxi_host):
esxi_host["config.network.pnic"][1].pop("linkSpeed", None)
vmnic1 = host_interfaces(esxi_host)["vmnic1"]
assert vmnic1["is_up"] is False
assert vmnic1["speed"] == 0.0
def test_mtu_comes_from_the_vswitch_the_nic_is_uplink_of(self, esxi_host):
esxi_host["config.network.vswitch"][0]["mtu"] = 9000
assert host_interfaces(esxi_host)["vmnic0"]["mtu"] == 9000
def test_nic_on_no_vswitch_has_default_mtu(self, esxi_host):
del esxi_host["config.network.vswitch"]
assert host_interfaces(esxi_host)["vmnic0"]["mtu"] == 1500
def test_vmkernel_nic(self, esxi_host):
assert host_interfaces(esxi_host)["vmk0"] == {
"is_up": True,
"is_enabled": True,
"description": "Management Network",
"last_flapped": -1.0,
"speed": 0.0,
"mtu": 1500,
"mac_address": "00:0C:29:81:D8:A0",
}
def test_host_without_network_config(self):
assert host_interfaces({"name": "h"}) == {}
class TestHostInterfacesIp:
def test_unassigned_address_is_left_out(self, esxi_host):
"""vcsim, like a DHCP vmk without a lease, reports 0.0.0.0."""
assert host_interfaces_ip(esxi_host) == {}
def test_ipv4_and_ipv6(self, esxi_host):
ip = esxi_host["config.network.vnic"][0]["spec"]["ip"]
ip["ipAddress"] = "192.0.2.10"
ip["subnetMask"] = "255.255.255.0"
ip["ipV6Config"] = {
"ipV6Address": [
{"ipAddress": "fe80::20c:29ff:fe81:d8a0", "prefixLength": 64},
{"ipAddress": "2001:db8::10", "prefixLength": 64},
]
}
assert host_interfaces_ip(esxi_host) == {
"vmk0": {
"ipv4": {"192.0.2.10": {"prefix_length": 24}},
"ipv6": {
"fe80::20c:29ff:fe81:d8a0": {"prefix_length": 64},
"2001:db8::10": {"prefix_length": 64},
},
}
}
def test_ipv6_only(self, esxi_host):
ip = esxi_host["config.network.vnic"][0]["spec"]["ip"]
ip["ipV6Config"] = {"ipV6Address": [{"ipAddress": "2001:db8::10", "prefixLength": 64}]}
assert host_interfaces_ip(esxi_host) == {
"vmk0": {"ipv6": {"2001:db8::10": {"prefix_length": 64}}}
}
+126
View File
@@ -0,0 +1,126 @@
"""Port groups, distributed port groups, and which one a VM NIC is on."""
from __future__ import annotations
import pytest
from napalm_vmware.parse.networks import (
NetworkIndex,
dv_vlan,
network_index,
nic_network,
virtual_networks,
)
@pytest.fixture
def vc_index(vcenter_data):
objs = vcenter_data["objects"]
return network_index(objs["HostSystem"], objs["DistributedVirtualPortgroup"])
class TestDvVlan:
@pytest.mark.parametrize(
("vlan", "expected"),
[
({"_type": "VmwareDistributedVirtualSwitchVlanIdSpec", "vlanId": 30}, 30),
({"_type": "VmwareDistributedVirtualSwitchTrunkVlanSpec", "vlanId": []}, 0),
({"_type": "VmwareDistributedVirtualSwitchPvlanSpec", "pvlanId": 101}, 101),
],
)
def test_kinds(self, vlan, expected):
assert dv_vlan({"vlan": vlan}) == expected
def test_missing(self):
assert dv_vlan(None) == 0
class TestNetworkIndex:
def test_standard_and_distributed(self, vc_index):
assert vc_index.standard["VM Network"] == 0
assert vc_index.distributed["dvportgroup-12"] == ("DC0_DVPG0", 0)
def test_standard_vlan(self, esxi_host):
esxi_host["config.network.portgroup"][0]["spec"]["vlanId"] = 40
index = network_index([esxi_host], [])
assert index.standard["VM Network"] == 40
class TestNicNetwork:
def test_standard_backing(self):
index = NetworkIndex(standard={"VM Network": 20}, distributed={})
nic = {
"backing": {
"_type": "VirtualEthernetCardNetworkBackingInfo",
"deviceName": "VM Network",
}
}
assert nic_network(nic, index) == ("VM Network", 20)
def test_distributed_backing(self, vc_index):
nic = {
"backing": {
"_type": "VirtualEthernetCardDistributedVirtualPortBackingInfo",
"port": {"portgroupKey": "dvportgroup-12"},
}
}
assert nic_network(nic, vc_index) == ("DC0_DVPG0", 0)
def test_unknown_distributed_portgroup_keeps_its_key(self, vc_index):
nic = {
"backing": {
"_type": "VirtualEthernetCardDistributedVirtualPortBackingInfo",
"port": {"portgroupKey": "dvportgroup-99"},
}
}
assert nic_network(nic, vc_index) == ("dvportgroup-99", 0)
def test_nsx_opaque_network(self, vc_index):
nic = {
"backing": {
"_type": "VirtualEthernetCardOpaqueNetworkBackingInfo",
"opaqueNetworkId": "ls-web",
}
}
assert nic_network(nic, vc_index) == ("ls-web", 0)
def test_no_backing(self, vc_index):
assert nic_network({}, vc_index) == ("", 0)
class TestVirtualNetworks:
def test_esxi_standard_portgroups(self, esxi_host):
nets = virtual_networks([esxi_host], [], [])
assert nets["VM Network"] == {
"name": "VM Network",
"type": "portgroup",
"bridge": "vSwitch0",
"vlan_id": 0,
"autostart": True,
"active": True,
}
assert set(nets) == {"VM Network", "Management Network"}
def test_vcenter_adds_distributed_portgroups_but_not_uplinks(self, vcenter_data):
objs = vcenter_data["objects"]
nets = virtual_networks(
objs["HostSystem"],
objs["DistributedVirtualPortgroup"],
objs["DistributedVirtualSwitch"],
)
assert nets["DC0_DVPG0"] == {
"name": "DC0_DVPG0",
"type": "dvportgroup",
"bridge": "DVS0",
"vlan_id": 0,
"autostart": True,
"active": True,
}
assert "DVS0-DVUplinks-8" not in nets
def test_uplink_flag_wins_over_the_name(self, vcenter_data):
objs = vcenter_data["objects"]
pgs = objs["DistributedVirtualPortgroup"]
pgs[1]["config.uplink"] = True
nets = virtual_networks([], pgs, objs["DistributedVirtualSwitch"])
assert "DC0_DVPG0" not in nets
+196
View File
@@ -0,0 +1,196 @@
"""get_vm_config and snapshot trees."""
from __future__ import annotations
import pytest
from napalm_vmware.parse.networks import NetworkIndex, network_index
from napalm_vmware.parse.snapshots import find_snapshot, snapshot_list
from napalm_vmware.parse.vm_config import vm_config
@pytest.fixture
def vm(vcenter_data):
return vcenter_data["objects"]["VirtualMachine"][0]
@pytest.fixture
def index(vcenter_data):
objs = vcenter_data["objects"]
return network_index(objs["HostSystem"], objs["DistributedVirtualPortgroup"])
class TestVmConfig:
def test_core_fields_from_vcsim(self, vm, index):
cfg = vm_config(vm, index)
assert cfg["name"] == "DC0_H0_VM0"
assert cfg["vmid"] == vm["config.instanceUuid"]
assert cfg["vcpus"] == vm["config.hardware.numCPU"]
assert cfg["memory"] == vm["config.hardware.memoryMB"]
assert cfg["os_type"] == vm["config.guestId"]
assert cfg["os_name"] == vm["config.guestFullName"]
assert cfg["description"] == ""
assert cfg["tags"] == []
assert cfg["firmware"] == "bios"
assert cfg["machine"] == "vmx-13"
assert cfg["passthrough"] == []
def test_disk(self, vm, index):
assert vm_config(vm, index)["disks"] == [
{
"device": "disk-202-0",
"storage": "LocalDS_0",
"size": 10,
"format": "vmdk",
"bootable": True, # no boot order: the BIOS boots the first disk
}
]
def test_nic(self, vm, index):
assert vm_config(vm, index)["nics"] == [
{
"device": "ethernet-0",
"mac": "00:0C:29:63:65:61",
"model": "e1000",
"bridge": "DC0_DVPG0",
"vlan_id": 0,
}
]
def test_cpu_topology(self, vm, index):
vm["config.hardware.numCPU"] = 8
vm["config.hardware.numCoresPerSocket"] = 4
cfg = vm_config(vm, index)
assert (cfg["sockets"], cfg["cores_per_socket"]) == (2, 4)
def test_explicit_boot_order(self, vm, index):
disk_key = next(
d["key"] for d in vm["config.hardware.device"] if d["_type"] == "VirtualDisk"
)
nic_key = next(
d["key"] for d in vm["config.hardware.device"] if d["_type"] == "VirtualE1000"
)
vm["config.bootOptions.bootOrder"] = [
{"_type": "VirtualMachineBootOptionsBootableCdromDevice"},
{"_type": "VirtualMachineBootOptionsBootableEthernetDevice", "deviceKey": nic_key},
{"_type": "VirtualMachineBootOptionsBootableDiskDevice", "deviceKey": disk_key},
]
cfg = vm_config(vm, index)
assert cfg["boot_order"] == ["cdrom", "ethernet-0", "disk-202-0"]
assert cfg["disks"][0]["bootable"] is True
def test_disk_outside_the_boot_order_is_not_bootable(self, vm, index):
nic_key = next(
d["key"] for d in vm["config.hardware.device"] if d["_type"] == "VirtualE1000"
)
vm["config.bootOptions.bootOrder"] = [
{"_type": "VirtualMachineBootOptionsBootableEthernetDevice", "deviceKey": nic_key},
]
assert vm_config(vm, index)["disks"][0]["bootable"] is False
def test_passthrough_and_annotation(self, vm, index):
vm["config.annotation"] = "Production web server"
vm["config.hardware.device"].append(
{
"_type": "VirtualPCIPassthrough",
"key": 13000,
"deviceInfo": {"label": "PCI device 0", "summary": "NVIDIA T4"},
}
)
cfg = vm_config(vm, index)
assert cfg["description"] == "Production web server"
assert cfg["passthrough"] == [
{"slot": "PCI device 0", "kind": "pci", "config": "NVIDIA T4"}
]
def test_config_less_vm_is_refused(self, vm):
del vm["config.instanceUuid"]
with pytest.raises(ValueError):
vm_config(vm, NetworkIndex())
def _snap(name, moref, created, children=(), state="poweredOff", description=""):
return {
"name": name,
"snapshot": moref,
"createTime": created,
"state": state,
"description": description,
"childSnapshotList": list(children),
}
@pytest.fixture
def tree_vm():
return {
"name": "web01",
"snapshot": {
"currentSnapshot": "snapshot-3",
"rootSnapshotList": [
_snap(
"base",
"snapshot-1",
100.0,
[
_snap("upgrade", "snapshot-2", 200.0, state="poweredOn", description="d"),
_snap("again", "snapshot-3", 300.0),
],
),
_snap("again", "snapshot-4", 400.0),
],
},
}
class TestSnapshots:
def test_flattened_depth_first_with_parents(self, tree_vm):
assert snapshot_list(tree_vm) == [
{
"name": "base",
"vm": "web01",
"created": 100.0,
"description": "",
"has_memory": False,
"parent": "",
},
{
"name": "upgrade",
"vm": "web01",
"created": 200.0,
"description": "d",
"has_memory": True,
"parent": "base",
},
{
"name": "again",
"vm": "web01",
"created": 300.0,
"description": "",
"has_memory": False,
"parent": "base",
},
{
"name": "again",
"vm": "web01",
"created": 400.0,
"description": "",
"has_memory": False,
"parent": "",
},
]
def test_vm_without_snapshots(self):
assert snapshot_list({"name": "x"}) == []
def test_find_by_name(self, tree_vm):
assert find_snapshot(tree_vm, "upgrade") == "snapshot-2"
def test_unknown_name(self, tree_vm):
with pytest.raises(ValueError, match="no snapshot"):
find_snapshot(tree_vm, "nope")
def test_duplicate_names_are_refused_not_guessed(self, tree_vm):
"""vSphere allows two snapshots with one name. Picking one could roll
back or delete the wrong state."""
with pytest.raises(ValueError, match="2 snapshots"):
find_snapshot(tree_vm, "again")
+131
View File
@@ -0,0 +1,131 @@
"""get_vms: VMDict plus the extra keys netOrk's VM sync reads."""
from __future__ import annotations
import pytest
from napalm_vmware.parse.networks import network_index
from napalm_vmware.parse.vms import vm_list
def _vms(data):
objs = data["objects"]
index = network_index(objs["HostSystem"], objs.get("DistributedVirtualPortgroup", []))
return vm_list(objs["VirtualMachine"], objs["HostSystem"], index)
@pytest.fixture
def vm0(esxi_data):
return esxi_data["objects"]["VirtualMachine"][0]
class TestVmListFromVcsim:
def test_one_entry_per_vm(self, esxi_data):
assert [vm["name"] for vm in _vms(esxi_data)] == ["ha-host_VM0", "ha-host_VM1"]
def test_contract_fields(self, esxi_data, vm0):
vm = _vms(esxi_data)[0]
assert vm["vmid"] == vm0["config.instanceUuid"]
assert vm["moref"] == vm0["_moref"]
assert vm["status"] == "running"
assert vm["vcpus"] == vm0["config.hardware.numCPU"]
assert vm["memory"] == vm0["config.hardware.memoryMB"]
assert vm["node"] == "localhost.localdomain"
assert vm["type"] == "vm"
def test_vcenter_node_is_the_esxi_host(self, vcenter_data):
nodes = {vm["name"]: vm["node"] for vm in _vms(vcenter_data)}
assert nodes["DC0_H0_VM0"] == "DC0_H0"
def test_nic_on_a_distributed_portgroup(self, vcenter_data):
vm = _vms(vcenter_data)[0]
(iface,) = vm["interfaces"].values()
assert iface["bridge"] == "DC0_DVPG0"
assert iface["tag"] is None
assert iface["mac_address"] == "00:0C:29:63:65:61"
def test_disks_in_megabytes(self, vcenter_data):
vm = _vms(vcenter_data)[0]
assert vm["disks"] == [{"name": "disk-202-0", "size_mb": 10240}]
def test_tools_not_installed(self, esxi_data):
vm = _vms(esxi_data)[0]
assert vm["agent_enabled"] is False
assert vm["agent_running"] is False
class TestVmListBranches:
def test_templates_are_not_vms(self, esxi_data, vm0):
vm0["config.template"] = True
assert [vm["name"] for vm in _vms(esxi_data)] == ["ha-host_VM1"]
def test_vm_without_config_is_skipped(self, esxi_data, vm0):
"""An orphaned or inaccessible VM has no instanceUuid to identify it by."""
del vm0["config.instanceUuid"]
assert len(_vms(esxi_data)) == 1
@pytest.mark.parametrize(
("power", "status"),
[("poweredOn", "running"), ("poweredOff", "stopped"), ("suspended", "suspended")],
)
def test_power_state(self, esxi_data, vm0, power, status):
vm0["runtime.powerState"] = power
assert _vms(esxi_data)[0]["status"] == status
def test_usage_from_quickstats(self, esxi_data, esxi_host, vm0):
esxi_host["summary.hardware"]["cpuMhz"] = 2000
vm0["config.hardware.numCPU"] = 2
vm0["summary.quickStats"].update(
overallCpuUsage=1000, guestMemoryUsage=512, uptimeSeconds=3600
)
vm = _vms(esxi_data)[0]
assert vm["cpu_usage"] == 0.25
assert vm["memory_usage"] == 512
assert vm["uptime"] == 3600
def test_cpu_usage_is_capped_at_one(self, esxi_data, esxi_host, vm0):
esxi_host["summary.hardware"]["cpuMhz"] = 1000
vm0["config.hardware.numCPU"] = 1
vm0["summary.quickStats"]["overallCpuUsage"] = 1500
assert _vms(esxi_data)[0]["cpu_usage"] == 1.0
def test_tools_running_and_guest_ip(self, esxi_data, vm0):
vm0["guest.toolsStatus"] = "toolsOk"
vm0["guest.toolsRunningStatus"] = "guestToolsRunning"
vm0["guest.net"][0]["ipAddress"] = ["fe80::1", "192.0.2.50"]
vm = _vms(esxi_data)[0]
assert vm["agent_enabled"] is True
assert vm["agent_running"] is True
assert vm["ipv4"] == "192.0.2.50"
(iface,) = vm["interfaces"].values()
assert iface["ipv4"] == "192.0.2.50"
assert iface["is_up"] is True
def test_guest_primary_ip_wins(self, esxi_data, vm0):
vm0["guest.ipAddress"] = "192.0.2.99"
vm0["guest.net"][0]["ipAddress"] = ["192.0.2.50"]
assert _vms(esxi_data)[0]["ipv4"] == "192.0.2.99"
def test_ipv6_primary_ip_is_not_an_ipv4(self, esxi_data, vm0):
vm0["guest.ipAddress"] = "2001:db8::5"
assert _vms(esxi_data)[0]["ipv4"] == ""
def test_vlan_of_a_standard_portgroup_becomes_the_tag(self, esxi_data, esxi_host):
esxi_host["config.network.portgroup"][0]["spec"]["vlanId"] = 30
vm = _vms(esxi_data)[0]
(iface,) = vm["interfaces"].values()
assert iface["bridge"] == "VM Network"
assert iface["tag"] == 30
def test_autostart(self, esxi_data, esxi_host, vm0):
esxi_host["config.autoStart"]["defaults"]["enabled"] = True
esxi_host["config.autoStart"]["powerInfo"] = [
{"key": vm0["_moref"], "startAction": "powerOn", "startOrder": 1}
]
assert [vm["onboot"] for vm in _vms(esxi_data)] == [True, False]
def test_autostart_entry_ignored_while_the_manager_is_off(self, esxi_data, esxi_host, vm0):
esxi_host["config.autoStart"]["powerInfo"] = [
{"key": vm0["_moref"], "startAction": "powerOn", "startOrder": 1}
]
assert _vms(esxi_data)[0]["onboot"] is False
+67
View File
@@ -0,0 +1,67 @@
"""to_plain: pyVmomi objects -> JSON-safe Python values.
Every parser in this package works on the output of ``to_plain`` rather than
on pyVmomi objects. That is what lets the harvest tool dump a real host to
JSON and the tests feed that same JSON back in.
"""
from __future__ import annotations
import datetime
import json
from pyVmomi import vim
from napalm_vmware._plain import to_plain
def test_scalars_pass_through():
assert to_plain("a") == "a"
assert to_plain(3) == 3
assert to_plain(True) is True
assert to_plain(None) is None
def test_enum_becomes_plain_str():
value = to_plain(vim.VirtualMachine.PowerState.poweredOn)
assert value == "poweredOn"
assert type(value) is str
def test_managed_object_becomes_its_moref():
assert to_plain(vim.HostSystem("host-21", None)) == "host-21"
def test_data_object_becomes_dict_with_type_and_without_unset_fields():
nic = vim.host.PhysicalNic(
device="vmnic0",
linkSpeed=vim.host.PhysicalNic.LinkSpeedDuplex(speedMb=1000, duplex=True),
)
plain = to_plain(nic)
assert plain["_type"] == "PhysicalNic"
assert plain["device"] == "vmnic0"
assert plain["linkSpeed"] == {"_type": "PhysicalNicLinkInfo", "speedMb": 1000, "duplex": True}
assert "driver" not in plain # optional and unset
def test_dynamic_property_noise_is_dropped():
assert "dynamicProperty" not in to_plain(vim.host.PhysicalNic(device="vmnic0"))
def test_lists_are_converted_elementwise():
devices = [vim.vm.device.VirtualVmxnet3(key=4000), vim.vm.device.VirtualDisk(key=2000)]
assert [d["_type"] for d in to_plain(devices)] == ["VirtualVmxnet3", "VirtualDisk"]
def test_datetime_becomes_epoch_seconds():
when = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
assert to_plain(when) == 1767225600.0
def test_bytes_are_dropped_to_none():
assert to_plain(b"\x00\x01") is None
def test_result_is_json_serialisable():
nic = vim.host.PhysicalNic(device="vmnic0", pci="0000:0b:00.0")
json.dumps(to_plain([nic, vim.HostSystem("host-1", None), 1.5]))
+97
View File
@@ -0,0 +1,97 @@
"""tools/sanitize.py: scrub a harvest dump before it becomes a fixture."""
from __future__ import annotations
import importlib.util
from pathlib import Path
_spec = importlib.util.spec_from_file_location(
"sanitize", Path(__file__).parent.parent / "tools" / "sanitize.py"
)
sanitize = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(sanitize)
def _dump():
return {
"about": {"instanceUuid": "35bb7c82-8526-5aa7-a886-cd7f8e6be786"},
"licenses": [{"licenseKey": "AAAAA-BBBBB-CCCCC-DDDDD-EEEEE", "editionKey": "esxBasic"}],
"objects": {
"HostSystem": [
{
"_moref": "host-21",
"name": "esx01.corp.example.com",
"hardware.systemInfo": {"serialNumber": "CZJ1234567"},
"config.network.dnsConfig": {
"hostName": "esx01",
"domainName": "corp.example.com",
"address": ["10.1.2.3"],
},
"config.network.pnic": [{"mac": "3c:ec:ef:01:02:03"}],
"summary.managementServerIp": "0.0.0.0",
}
],
"VirtualMachine": [
{
"_moref": "vm-7",
"name": "payroll-db",
"config.instanceUuid": "5003a1b2-0000-1111-2222-333344445555",
"config.hardware.device": [
{"backing": {"fileName": "[ds1] payroll-db/payroll-db.vmdk"}},
{"macAddress": "00:50:56:aa:bb:cc"},
],
"guest.net": [{"ipAddress": ["10.1.2.50", "fe80::1"]}],
}
],
},
}
def _text(data):
import json
return json.dumps(data)
class TestSanitize:
def test_sensitive_values_are_gone_everywhere(self):
clean = _text(sanitize.sanitize(_dump()))
for secret in (
"35bb7c82",
"AAAAA-BBBBB",
"esx01",
"corp.example.com",
"CZJ1234567",
"10.1.2.3",
"3c:ec:ef",
"payroll-db",
"5003a1b2",
"00:50:56:aa:bb:cc",
"10.1.2.50",
):
assert secret not in clean, secret
def test_structure_and_harmless_values_survive(self):
clean = sanitize.sanitize(_dump())
vm = clean["objects"]["VirtualMachine"][0]
assert vm["_moref"] == "vm-7"
assert clean["licenses"][0]["editionKey"] == "esxBasic"
assert clean["objects"]["HostSystem"][0]["summary.managementServerIp"] == "0.0.0.0"
path = vm["config.hardware.device"][0]["backing"]["fileName"]
assert path.startswith("[ds1] ") and path.endswith(".vmdk")
def test_placeholders_are_stable(self):
"""The same original value maps to the same placeholder, so references
between objects (a VM's name inside its disk path) still line up."""
clean = sanitize.sanitize(_dump())
vm = clean["objects"]["VirtualMachine"][0]
path = vm["config.hardware.device"][0]["backing"]["fileName"]
assert path == f"[ds1] {vm['name']}/{vm['name']}.vmdk"
def test_valid_shapes(self):
clean = sanitize.sanitize(_dump())
host = clean["objects"]["HostSystem"][0]
assert host["config.network.pnic"][0]["mac"].count(":") == 5
ip = clean["objects"]["VirtualMachine"][0]["guest.net"][0]["ipAddress"]
assert ip[0].startswith("192.0.2.")
assert ip[1].startswith("2001:db8::")
+117
View File
@@ -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
)
+105
View File
@@ -0,0 +1,105 @@
"""End-to-end against govmomi's vcsim: real sessions, real tasks, no mocks.
Skipped unless the simulators are running::
docker run -d --rm -p 127.0.0.1:8989:8989 vmware/vcsim -l 0.0.0.0:8989
docker run -d --rm -p 127.0.0.1:8990:8989 vmware/vcsim -esx -l 0.0.0.0:8989
VCSIM_VCENTER_PORT=8989 VCSIM_ESXI_PORT=8990 pytest -m vcsim
vcsim accepts any credentials. It is a simulator: this proves the drivers speak
the vSphere API correctly, not that they understand a particular real host.
"""
from __future__ import annotations
import os
import pytest
from napalm.base.exceptions import ConnectionException
from napalm_vmware import VmwareEsxiDriver, VmwareVcenterDriver
pytestmark = pytest.mark.vcsim
def _driver(cls, env):
port = os.environ.get(env)
if not port:
pytest.skip(f"{env} not set; vcsim not running")
driver = cls("127.0.0.1", "user", "pass", optional_args={"port": port, "verify_ssl": False})
driver.open()
yield driver
driver.close()
@pytest.fixture
def esxi():
yield from _driver(VmwareEsxiDriver, "VCSIM_ESXI_PORT")
@pytest.fixture
def vcenter():
yield from _driver(VmwareVcenterDriver, "VCSIM_VCENTER_PORT")
def test_esxi_reads(esxi):
assert esxi.is_alive() == {"is_alive": True}
assert esxi.get_facts()["os_version"].startswith("VMware ESXi")
assert "vmnic0" in esxi.get_interfaces()
assert esxi.get_vms()
assert esxi.get_vm_storage_pools()
assert esxi.get_virtual_networks()
assert "cpu" in esxi.get_environment()
assert isinstance(esxi.get_device_warnings(), list)
assert isinstance(esxi.get_lldp_neighbors(), dict)
def test_vcenter_reads(vcenter):
assert vcenter.get_facts()["model"] == "VMware vCenter Server"
vms = vcenter.get_vms()
assert len({vm["node"] for vm in vms}) > 1
assert vcenter.get_vm_config(vms[0]["vmid"])["vmid"] == vms[0]["vmid"]
def test_power_cycle(esxi):
vm = esxi.get_vms()[0]
esxi.stop_vm(vm["vmid"], force=True)
assert _status(esxi, vm) == "stopped"
with pytest.raises(RuntimeError, match="not running"):
esxi.suspend_vm(vm["vmid"])
esxi.start_vm(vm["vmid"])
assert _status(esxi, vm) == "running"
esxi.suspend_vm(vm["vmid"])
assert _status(esxi, vm) == "suspended"
esxi.start_vm(vm["vmid"])
esxi.reboot_vm(vm["vmid"], force=True)
assert _status(esxi, vm) == "running"
def test_snapshot_lifecycle(vcenter):
vm = vcenter.get_vms()[0]
vcenter.create_vm_snapshot(vm["vmid"], "netork-test", description="from pytest")
try:
(snap,) = [s for s in vcenter.get_vm_snapshots(vm["vmid"]) if s["name"] == "netork-test"]
assert snap["description"] == "from pytest"
with pytest.raises(ValueError, match="already"):
vcenter.create_vm_snapshot(vm["vmid"], "netork-test")
vcenter.rollback_vm_snapshot(vm["vmid"], "netork-test")
finally:
vcenter.delete_vm_snapshot(vm["vmid"], "netork-test")
assert not [s for s in vcenter.get_vm_snapshots(vm["vmid"]) if s["name"] == "netork-test"]
def test_wrong_driver_is_refused():
port = os.environ.get("VCSIM_VCENTER_PORT")
if not port:
pytest.skip("VCSIM_VCENTER_PORT not set")
driver = VmwareEsxiDriver(
"127.0.0.1", "u", "p", optional_args={"port": port, "verify_ssl": False}
)
with pytest.raises(ConnectionException, match="vmware_vcenter"):
driver.open()
def _status(driver, vm):
return next(v["status"] for v in driver.get_vms() if v["vmid"] == vm["vmid"])