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,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]
|
||||
Vendored
+2392
File diff suppressed because it is too large
Load Diff
Vendored
+8374
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
@@ -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") == []
|
||||
@@ -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"]) == {}
|
||||
@@ -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"
|
||||
@@ -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) == []
|
||||
@@ -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}}}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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]))
|
||||
@@ -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::")
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user