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
+90
View File
@@ -0,0 +1,90 @@
"""Bulk reads through the vSphere PropertyCollector.
Every read in this package asks for an explicit list of property paths
(``napalm_vmware.paths``) instead of touching attributes on managed objects.
One round trip per object type rather than one per attribute, and it keeps
working where lazy attribute access does not: pyVmomi fails to deserialize
whole ``summary`` objects from some servers while the individual paths are
fine.
This class is the one seam the tests replace. Its output is already plain
(see :mod:`napalm_vmware._plain`), so a fake only has to return dicts.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from pyVmomi import vim, vmodl
from napalm_vmware._plain import to_plain
_PC = vmodl.query.PropertyCollector
_PAGE_SIZE = 500
def _vim_type(type_name: str) -> Any:
vim_type = getattr(vim, type_name, None) or getattr(vim.dvs, type_name, None)
if vim_type is None:
raise ValueError(f"Unknown vSphere managed object type {type_name!r}")
return vim_type
class Inventory:
"""Read-only access to a connected ServiceInstance's content."""
def __init__(self, content: Any) -> None:
self._content = content
def about(self) -> dict[str, Any]:
"""``ServiceContent.about``: product, version and API type of the endpoint."""
return to_plain(self._content.about)
def collect(self, type_name: str, paths: Sequence[str]) -> list[dict[str, Any]]:
"""Every object of ``type_name`` in the inventory, with the given properties."""
vim_type = _vim_type(type_name)
view = self._content.viewManager.CreateContainerView(
self._content.rootFolder, [vim_type], True
)
try:
traversal = _PC.TraversalSpec(
name="view", path="view", skip=False, type=vim.view.ContainerView
)
obj_spec = _PC.ObjectSpec(obj=view, skip=True, selectSet=[traversal])
return self._retrieve(obj_spec, vim_type, paths)
finally:
view.Destroy()
def properties(self, obj: Any, paths: Sequence[str]) -> dict[str, Any]:
"""The given properties of one managed object; ``{}`` if it is gone."""
rows = self._retrieve(_PC.ObjectSpec(obj=obj), type(obj), paths)
return rows[0] if rows else {}
def licenses(self) -> list[dict[str, Any]]:
"""Licenses assigned on this endpoint (edition key, name, used/total)."""
manager = getattr(self._content, "licenseManager", None)
if manager is None:
return []
return self.properties(manager, ["licenses"]).get("licenses", [])
def _retrieve(self, obj_spec: Any, vim_type: Any, paths: Sequence[str]) -> list[dict[str, Any]]:
spec = _PC.FilterSpec(
objectSet=[obj_spec], propSet=[_PC.PropertySpec(type=vim_type, pathSet=list(paths))]
)
pc = self._content.propertyCollector
result = pc.RetrievePropertiesEx([spec], _PC.RetrieveOptions(maxObjects=_PAGE_SIZE))
rows: list[dict[str, Any]] = []
while result is not None:
rows.extend(_row(obj) for obj in result.objects)
if not result.token:
break
result = pc.ContinueRetrievePropertiesEx(token=result.token)
return rows
def _row(obj_content: Any) -> dict[str, Any]:
row: dict[str, Any] = {"_moref": obj_content.obj._moId}
for prop in obj_content.propSet or []:
row[prop.name] = to_plain(prop.val)
return row