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
+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"]) == {}