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
+6
View File
@@ -0,0 +1,6 @@
"""NAPALM drivers for VMware ESXi (``vmware_esxi``) and vCenter (``vmware_vcenter``)."""
from napalm_vmware.esxi import VmwareEsxiDriver
from napalm_vmware.vcenter import VmwareVcenterDriver
__all__ = ["VmwareEsxiDriver", "VmwareVcenterDriver"]
+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
+57
View File
@@ -0,0 +1,57 @@
"""Convert pyVmomi objects into plain, JSON-safe Python values.
The drivers never parse pyVmomi objects directly. Everything fetched from the
vSphere API goes through :func:`to_plain` first, so a parser sees the same
shape whether its input came from a live host or from a JSON fixture that
``tools/harvest.py`` captured.
Conventions:
* a managed object (``HostSystem``, ``VirtualMachine`` ...) becomes its MoRef
id string, e.g. ``"host-21"``
* a data object becomes a dict of its set properties plus ``"_type"``, the
WSDL type name (``"VirtualDisk"``) -- device lists are told apart by it
* enums become ``str``, datetimes become epoch seconds, binary data ``None``
"""
from __future__ import annotations
import datetime
from typing import Any
from pyVmomi import VmomiSupport
_NOISE = frozenset({"dynamicType", "dynamicProperty"})
def to_plain(value: Any) -> Any:
"""Recursively convert ``value`` into dicts, lists and scalars."""
if value is None or isinstance(value, bool):
return value
if isinstance(value, VmomiSupport.ManagedObject):
return value._moId
if isinstance(value, VmomiSupport.DataObject):
return _data_object(value)
if isinstance(value, str):
return str(value)
if isinstance(value, (int, float)):
return value
if isinstance(value, datetime.datetime):
return value.timestamp()
if isinstance(value, (bytes, bytearray)):
return None
if isinstance(value, (list, tuple)):
return [to_plain(v) for v in value]
return str(value)
def _data_object(obj: Any) -> dict[str, Any]:
out: dict[str, Any] = {"_type": obj._wsdlName}
for prop in obj._GetPropertyList():
if prop.name in _NOISE:
continue
val = getattr(obj, prop.name, None)
if val is None or (isinstance(val, list) and not val):
continue
out[prop.name] = to_plain(val)
return out
+47
View File
@@ -0,0 +1,47 @@
"""Opening and closing a vSphere API session.
Kept apart from everything else because it is the only code that opens a
socket; the tests replace it wholesale.
"""
from __future__ import annotations
import ssl
from typing import Any
from pyVim.connect import Disconnect, SmartConnect
def _ssl_context(verify: bool) -> ssl.SSLContext:
context = ssl.create_default_context()
if not verify:
# ESXi hosts ship a self-signed certificate; most homelab and many
# production hosts keep it.
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def connect( # pragma: no cover - opens a live session
host: str, port: int, user: str, password: str, *, verify_ssl: bool, timeout: int
) -> Any:
return SmartConnect(
host=host,
port=port,
user=user,
pwd=password,
sslContext=_ssl_context(verify_ssl),
httpConnectionTimeout=timeout,
)
def disconnect(si: Any) -> None: # pragma: no cover - talks to a live session
Disconnect(si)
def alive(si: Any) -> bool: # pragma: no cover - talks to a live session
try:
si.CurrentTime()
except Exception:
return False
return True
+83
View File
@@ -0,0 +1,83 @@
"""vSphere tasks and faults, in the terms of the HypervisorDriver contract.
Contract methods raise ``RuntimeError`` when the platform refuses an
operation. vSphere reports refusals two ways: a ``MethodFault`` raised by the
call itself, or a task that ends in state ``error``. Both end up here.
"""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import Any, TypeVar
from pyVmomi import vmodl
from napalm_vmware._plain import to_plain
T = TypeVar("T")
_POLL_SECONDS = 0.5
_FREE_LICENSE = (
"the host runs the free vSphere Hypervisor license, which makes the "
"vSphere API read-only; assign a paid license to change VMs from netOrk"
)
def fault_message(fault: dict[str, Any]) -> str:
"""A readable sentence for a (plain) vSphere fault."""
if fault.get("_type") == "RestrictedVersion":
return _FREE_LICENSE
return fault.get("localizedMessage") or fault.get("msg") or fault.get("_type", "unknown fault")
def invoke(call: Callable[..., T], *args: Any, **kwargs: Any) -> T:
"""Run a vSphere call, re-raising any ``MethodFault`` as ``RuntimeError``."""
try:
return call(*args, **kwargs)
except vmodl.MethodFault as fault:
raise RuntimeError(fault_message(to_plain(fault))) from fault
def wait_for_task(
inventory: Any,
task: Any,
timeout: float,
*,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> None:
"""Block until ``task`` succeeds; raise ``RuntimeError`` otherwise."""
deadline = clock() + timeout
while True:
info = inventory.properties(task, ["info.state", "info.error"])
if not info:
raise RuntimeError("the vSphere task disappeared before it finished")
state = info.get("info.state")
if state == "success":
return
if state == "error":
raise RuntimeError(fault_message(info.get("info.error") or {}))
if clock() >= deadline:
raise RuntimeError(f"the vSphere task did not finish within {timeout:g}s")
sleep(_POLL_SECONDS)
def wait_until(
check: Callable[[], bool],
timeout: float,
what: str,
*,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> None:
"""Poll ``check`` until it is true; for operations vSphere runs without a task.
``ShutdownGuest`` only *asks* VMware Tools to shut the guest down and
returns at once, so completion has to be observed from the power state.
"""
deadline = clock() + timeout
while not check():
if clock() >= deadline:
raise RuntimeError(f"{what} did not finish within {timeout:g}s")
sleep(_POLL_SECONDS)
+109
View File
@@ -0,0 +1,109 @@
"""Power and snapshot operations (the write half of the HypervisorDriver contract)."""
from __future__ import annotations
from typing import Any
from pyVmomi import vim
from napalm_vmware._tasks import invoke, wait_for_task, wait_until
from napalm_vmware.parse.snapshots import find_snapshot, snapshot_list
_TASK_TIMEOUT = 300
_GUEST_SHUTDOWN_TIMEOUT = 300
_RUNNING = "poweredOn"
_TOOLS_RUNNING = "guestToolsRunning"
class VmwareActionsMixin:
"""Mixed into both drivers ahead of :class:`VmwareBaseDriver`."""
_inventory: Any
_find_vm: Any
_mo: Any
def _vm_mo(self, vm: dict[str, Any]) -> Any:
return self._mo(vim.VirtualMachine, vm["_moref"])
def _run(self, call: Any, *args: Any, **kwargs: Any) -> None:
wait_for_task(self._inventory, invoke(call, *args, **kwargs), _TASK_TIMEOUT)
@staticmethod
def _require_running(vm: dict[str, Any], action: str) -> None:
if vm.get("runtime.powerState") != _RUNNING:
raise RuntimeError(f"Cannot {action} VM {vm.get('name')!r}: it is not running")
@staticmethod
def _require_tools(vm: dict[str, Any], action: str) -> None:
if vm.get("guest.toolsRunningStatus") != _TOOLS_RUNNING:
raise RuntimeError(
f"A graceful {action} of {vm.get('name')!r} needs VMware Tools running "
"in the guest; use force to cut power instead"
)
# -- power ---------------------------------------------------------------
def start_vm(self, name: str) -> None:
self._run(self._vm_mo(self._find_vm(name)).PowerOnVM_Task)
def stop_vm(self, name: str, force: bool = False) -> None:
vm = self._find_vm(name)
self._require_running(vm, "stop")
mo = self._vm_mo(vm)
if force:
self._run(mo.PowerOffVM_Task)
return
self._require_tools(vm, "shutdown")
invoke(mo.ShutdownGuest)
wait_until(
lambda: (
self._inventory.properties(mo, ["runtime.powerState"]).get("runtime.powerState")
== "poweredOff"
),
_GUEST_SHUTDOWN_TIMEOUT,
f"guest shutdown of {vm.get('name')!r}",
)
def reboot_vm(self, name: str, force: bool = False) -> None:
vm = self._find_vm(name)
self._require_running(vm, "reboot")
mo = self._vm_mo(vm)
if force:
self._run(mo.ResetVM_Task)
return
# RebootGuest returns once Tools accepted the request; the guest gives
# no signal when it is back, so there is nothing further to wait for.
self._require_tools(vm, "reboot")
invoke(mo.RebootGuest)
def suspend_vm(self, name: str) -> None:
vm = self._find_vm(name)
self._require_running(vm, "suspend")
self._run(self._vm_mo(vm).SuspendVM_Task)
# -- snapshots -------------------------------------------------------------
def create_vm_snapshot(
self, name: str, snapshot: str, description: str = "", include_memory: bool = False
) -> None:
vm = self._find_vm(name)
if any(s["name"] == snapshot for s in snapshot_list(vm)):
raise ValueError(f"VM {vm.get('name')!r} already has a snapshot named {snapshot!r}")
self._run(
self._vm_mo(vm).CreateSnapshot_Task,
name=snapshot,
description=description,
memory=include_memory and vm.get("runtime.powerState") == _RUNNING,
quiesce=False,
)
def _snapshot_mo(self, name: str, snapshot: str) -> Any:
return self._mo(vim.vm.Snapshot, find_snapshot(self._find_vm(name), snapshot))
def delete_vm_snapshot(self, name: str, snapshot: str) -> None:
# removeChildren=False consolidates the snapshot into its children
# instead of discarding them.
self._run(self._snapshot_mo(name, snapshot).RemoveSnapshot_Task, removeChildren=False)
def rollback_vm_snapshot(self, name: str, snapshot: str) -> None:
self._run(self._snapshot_mo(name, snapshot).RevertToSnapshot_Task)
+154
View File
@@ -0,0 +1,154 @@
"""What the ESXi and vCenter drivers share: session, VM lookup, common getters."""
from __future__ import annotations
from typing import Any, ClassVar
from napalm.base.exceptions import ConnectionException
from napalm_device_types import HypervisorDriver
from napalm_device_types.models import (
SnapshotDict,
StorageVolumeDict,
VirtualNetworkDict,
VMConfigDict,
)
from napalm_vmware import _session, paths
from napalm_vmware._inventory import Inventory
from napalm_vmware.parse.environment import environment
from napalm_vmware.parse.networks import NetworkIndex, network_index, virtual_networks
from napalm_vmware.parse.snapshots import snapshot_list
from napalm_vmware.parse.storage import storage_pools
from napalm_vmware.parse.vm_config import vm_config
from napalm_vmware.parse.vms import vm_list
from napalm_vmware.parse.warnings import host_warnings
_DEFAULT_PORT = 443
#: ``about.apiType`` -> the driver that handles it, for a helpful refusal.
_DRIVER_FOR_API = {"HostAgent": "vmware_esxi", "VirtualCenter": "vmware_vcenter"}
class VmwareBaseDriver(HypervisorDriver):
"""Shared implementation. Concrete drivers set ``API_TYPE`` and ``STANDALONE``."""
VENDOR = "VMware"
USES_SSH = False
platform = "vmware"
#: ``about.apiType`` this driver accepts.
API_TYPE: ClassVar[str] = ""
#: True when the driver talks to one host directly (ESXi), False for vCenter.
STANDALONE: ClassVar[bool] = True
def __init__(
self,
hostname: str,
username: str,
password: str,
timeout: int = 60,
optional_args: dict[str, Any] | None = None,
) -> None:
self.hostname = hostname
self.username = username
self.password = password
self.timeout = timeout
args = optional_args or {}
self._port = int(args.get("port") or _DEFAULT_PORT)
self._verify_ssl = bool(args.get("verify_ssl", args.get("ssl_verify", True)))
self._si: Any = None
self._inventory: Any = None
# -- session -------------------------------------------------------------
def open(self) -> None:
try:
si = _session.connect(
self.hostname,
self._port,
self.username,
self.password,
verify_ssl=self._verify_ssl,
timeout=self.timeout,
)
except Exception as exc:
raise ConnectionException(f"Cannot connect to {self.hostname}: {exc}") from exc
inventory = Inventory(si.RetrieveContent())
about = inventory.about()
if about.get("apiType") != self.API_TYPE:
_session.disconnect(si)
other = _DRIVER_FOR_API.get(about.get("apiType", ""), "another driver")
raise ConnectionException(
f"{self.hostname} is {about.get('fullName', 'not a supported VMware endpoint')}"
f"; use the {other} driver for it"
)
self._si, self._inventory = si, inventory
def close(self) -> None:
if self._si is not None:
_session.disconnect(self._si)
self._si = self._inventory = None
def is_alive(self) -> dict[str, bool]:
return {"is_alive": self._si is not None and _session.alive(self._si)}
def _mo(self, vim_type: Any, moref: str) -> Any:
"""A live managed-object reference for a MoRef id from a plain row."""
return vim_type(moref, self._si._stub)
# -- inventory reads -------------------------------------------------------
def _hosts(self) -> list[dict[str, Any]]:
return self._inventory.collect("HostSystem", paths.HOST)
def _vm_rows(self) -> list[dict[str, Any]]:
return self._inventory.collect("VirtualMachine", paths.VM)
def _index(self, hosts: list[dict[str, Any]]) -> NetworkIndex:
dv_portgroups = self._inventory.collect("DistributedVirtualPortgroup", paths.DV_PORTGROUP)
return network_index(hosts, dv_portgroups)
def _find_vm(self, name: str) -> dict[str, Any]:
"""The VM whose instance UUID, MoRef or name is ``name``."""
vms = [
vm
for vm in self._vm_rows()
if vm.get("config.instanceUuid") and not vm.get("config.template")
]
for key in ("config.instanceUuid", "_moref"):
match = [vm for vm in vms if vm.get(key) == name]
if match:
return match[0]
match = [vm for vm in vms if vm.get("name") == name]
if len(match) > 1:
raise ValueError(f"{len(match)} VMs are named {name!r}; address one by its vmid")
if not match:
raise ValueError(f"There is no VM named or identified by {name!r}")
return match[0]
# -- HypervisorDriver ------------------------------------------------------
def get_vms(self) -> list[dict[str, Any]]:
hosts = self._hosts()
return vm_list(self._vm_rows(), hosts, self._index(hosts))
def get_vm_config(self, name: str) -> VMConfigDict:
return vm_config(self._find_vm(name), self._index(self._hosts()))
def get_vm_snapshots(self, name: str) -> list[SnapshotDict]:
return snapshot_list(self._find_vm(name))
def get_vm_storage_pools(self) -> dict[str, StorageVolumeDict]:
return storage_pools(self._inventory.collect("Datastore", paths.DATASTORE))
def get_virtual_networks(self) -> dict[str, VirtualNetworkDict]:
return virtual_networks( # type: ignore[return-value]
self._hosts(),
self._inventory.collect("DistributedVirtualPortgroup", paths.DV_PORTGROUP),
self._inventory.collect("DistributedVirtualSwitch", paths.DV_SWITCH),
)
def get_environment(self) -> dict[str, Any]:
return environment(self._hosts())
def get_device_warnings(self) -> list[dict[str, Any]]:
return host_warnings(self._hosts(), self._inventory.licenses(), standalone=self.STANDALONE)
+58
View File
@@ -0,0 +1,58 @@
"""NAPALM driver for a VMware ESXi host, addressed directly."""
from __future__ import annotations
from typing import Any
from napalm_device_types import FingerprintRule
from pyVmomi import vim
from napalm_vmware._plain import to_plain
from napalm_vmware._tasks import invoke
from napalm_vmware.actions import VmwareActionsMixin
from napalm_vmware.base import VmwareBaseDriver
from napalm_vmware.parse.facts import esxi_facts
from napalm_vmware.parse.interfaces import host_interfaces, host_interfaces_ip
from napalm_vmware.parse.lldp import lldp_neighbors
class VmwareEsxiDriver(VmwareActionsMixin, VmwareBaseDriver):
"""One ESXi host. Its VMs, vmnics, vmkernel NICs, datastores and port groups."""
DRIVER_NAME = "vmware_esxi"
API_TYPE = "HostAgent"
STANDALONE = True
# A host reboot runs through hardware POST before ESXi starts its services.
REBOOT_SETTLE_SECONDS = 300
# Unverified against real hardware: the Host Client landing page says
# "VMware ESXi"; a vCenter's does not, and it says "vCenter".
HTTP_FINGERPRINT = [
FingerprintRule("vmware esxi", weight=9.0, mandatory=True),
FingerprintRule("vcenter", weight=9.0, negative=True),
]
def _host(self) -> dict[str, Any]:
hosts = self._hosts()
if not hosts:
raise RuntimeError(f"{self.hostname} shows no host in its inventory")
return hosts[0]
def get_facts(self) -> dict[str, Any]:
return esxi_facts(self._inventory.about(), self._host())
def get_interfaces(self) -> dict[str, dict[str, Any]]:
return host_interfaces(self._host())
def get_interfaces_ip(self) -> dict[str, dict[str, Any]]:
return host_interfaces_ip(self._host())
def get_lldp_neighbors(self) -> dict[str, list[dict[str, str]]]:
network_system = self._host().get("configManager.networkSystem")
if not network_system:
return {}
mo = self._mo(vim.host.NetworkSystem, network_system)
try:
hints = invoke(mo.QueryNetworkHint)
except RuntimeError:
return {}
return lldp_neighbors(to_plain(hints) or [])
View File
+65
View File
@@ -0,0 +1,65 @@
"""NAPALM ``get_environment`` from host quick stats and hardware sensors."""
from __future__ import annotations
from typing import Any
_SENSORS = "runtime.healthSystemRuntime.systemHealthInfo.numericSensorInfo"
_MB = 1024 * 1024
def _reading(sensor: dict[str, Any]) -> float:
return float(sensor.get("currentReading", 0)) * 10 ** int(sensor.get("unitModifier", 0))
def _health(sensor: dict[str, Any]) -> str:
return (sensor.get("healthState") or {}).get("key", "unknown")
def _cpu_percent(host: dict[str, Any]) -> float:
hardware = host.get("summary.hardware") or {}
capacity = int(hardware.get("cpuMhz") or 0) * int(hardware.get("numCpuCores") or 0)
used = (host.get("summary.quickStats") or {}).get("overallCpuUsage") or 0
return round(used / capacity * 100, 2) if capacity else 0.0
def _add_sensors(env: dict[str, Any], host: dict[str, Any], prefix: str) -> None:
for sensor in host.get(_SENSORS) or []:
name = f"{prefix}{sensor.get('name', '')}"
kind = sensor.get("sensorType")
health = _health(sensor)
if kind == "temperature":
env["temperature"][name] = {
"temperature": _reading(sensor),
"is_alert": health in ("yellow", "red"),
"is_critical": health == "red",
}
elif kind == "fan":
env["fans"][name] = {"status": health == "green"}
elif kind == "power":
env["power"][name] = {
"status": health == "green",
"capacity": -1.0,
"output": _reading(sensor),
}
def environment(hosts: list[dict[str, Any]]) -> dict[str, Any]:
"""One host: keyed like a single device. Several (vCenter): keyed by host."""
env: dict[str, Any] = {
"fans": {},
"temperature": {},
"power": {},
"cpu": {},
"memory": {"available_ram": 0, "used_ram": 0},
}
single = len(hosts) == 1
for host in hosts:
key = "0" if single else host.get("name", host["_moref"])
env["cpu"][key] = {"%usage": _cpu_percent(host)}
hardware = host.get("summary.hardware") or {}
stats = host.get("summary.quickStats") or {}
env["memory"]["available_ram"] += int(hardware.get("memorySize") or 0)
env["memory"]["used_ram"] += int(stats.get("overallMemoryUsage") or 0) * _MB
_add_sensors(env, host, "" if single else f"{key}/")
return env
+94
View File
@@ -0,0 +1,94 @@
"""NAPALM ``get_facts`` from a host's properties or a vCenter's ``about``."""
from __future__ import annotations
import ipaddress
from typing import Any
#: otherIdentifyingInfo keys that carry a chassis serial, most specific first.
#: Dell reports its service tag as ``ServiceTag``; HPE and Supermicro use
#: ``SerialNumberTag``; blades add ``EnclosureSerialNumberTag`` for the chassis.
_SERIAL_KEYS = ("SerialNumberTag", "EnclosureSerialNumberTag", "ServiceTag")
def host_serial(host: dict[str, Any]) -> str:
"""The host's hardware serial, or ``""`` when it does not report one."""
info = host.get("hardware.systemInfo") or {}
if str(info.get("serialNumber", "")).strip():
return str(info["serialNumber"]).strip()
for source in (info, host.get("summary.hardware") or {}):
by_key: dict[str, str] = {}
for entry in source.get("otherIdentifyingInfo", []):
value = str(entry.get("identifierValue", "")).strip()
key = (entry.get("identifierType") or {}).get("key", "")
if value and key not in by_key:
by_key[key] = value
for key in _SERIAL_KEYS:
if key in by_key:
return by_key[key]
return ""
def _host_names(host: dict[str, Any]) -> tuple[str, str]:
dns = host.get("config.network.dnsConfig") or {}
short = dns.get("hostName") or ""
domain = dns.get("domainName") or ""
if short:
return short, f"{short}.{domain}" if domain else short
name = host.get("name", "")
return name.split(".", 1)[0], name
def host_interface_names(host: dict[str, Any]) -> list[str]:
nics = (host.get("config.network.pnic") or []) + (host.get("config.network.vnic") or [])
return sorted(n["device"] for n in nics if n.get("device"))
def esxi_facts(about: dict[str, Any], host: dict[str, Any]) -> dict[str, Any]:
"""Facts of a standalone ESXi host (or one reached directly)."""
hostname, fqdn = _host_names(host)
# hardware.systemInfo is read straight from SMBIOS; summary.hardware is a
# cached copy that has been seen to disagree with it.
system = host.get("hardware.systemInfo") or {}
summary = host.get("summary.hardware") or {}
product = host.get("config.product") or about
uptime = (host.get("summary.quickStats") or {}).get("uptime")
return {
"hostname": hostname,
"fqdn": fqdn,
"vendor": system.get("vendor") or summary.get("vendor", ""),
"model": system.get("model") or summary.get("model", ""),
"serial_number": host_serial(host),
"os_version": product.get("fullName", ""),
"uptime": float(uptime) if uptime is not None else -1.0,
"interface_list": host_interface_names(host),
}
def _is_ip(value: str) -> bool:
try:
ipaddress.ip_address(value)
except ValueError:
return False
return True
def vcenter_facts(about: dict[str, Any], address: str) -> dict[str, Any]:
"""Facts of a vCenter Server.
The vSphere API does not expose the appliance's own name, uptime or NICs;
those belong to the appliance management API. The address netOrk used to
connect is the best available name, and the instance UUID is the one
identifier that is stable for the lifetime of the installation.
"""
hostname = address if _is_ip(address) else address.split(".", 1)[0]
return {
"hostname": hostname,
"fqdn": address,
"vendor": about.get("vendor", ""),
"model": about.get("name", ""),
"serial_number": about.get("instanceUuid", ""),
"os_version": about.get("fullName", ""),
"uptime": -1.0,
"interface_list": [],
}
+87
View File
@@ -0,0 +1,87 @@
"""NAPALM interface getters for an ESXi host.
A host has two kinds of interface worth reporting: physical uplinks
(``vmnicN``) and VMkernel adapters (``vmkN``), which carry the host's own IP
addresses. Port groups are not interfaces; they are reported by
``get_virtual_networks``.
"""
from __future__ import annotations
import ipaddress
from typing import Any
_DEFAULT_MTU = 1500
_UNASSIGNED = {"", "0.0.0.0"}
def _uplink_mtus(host: dict[str, Any]) -> dict[str, int]:
"""pnic key -> MTU of the standard vSwitch that uses it as an uplink."""
mtus: dict[str, int] = {}
for vswitch in host.get("config.network.vswitch") or []:
for pnic_key in vswitch.get("pnic", []):
mtus[pnic_key] = int(vswitch.get("mtu") or _DEFAULT_MTU)
return mtus
def _iface(up: bool, description: str, speed: float, mtu: int, mac: str) -> dict[str, Any]:
return {
"is_up": up,
"is_enabled": True,
"description": description,
"last_flapped": -1.0,
"speed": speed,
"mtu": mtu,
"mac_address": mac.upper(),
}
def host_interfaces(host: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""vmnics and vmks in the shape of NAPALM's ``get_interfaces``."""
mtus = _uplink_mtus(host)
result: dict[str, dict[str, Any]] = {}
for pnic in host.get("config.network.pnic") or []:
link = pnic.get("linkSpeed") or {}
description = " ".join(p for p in (pnic.get("driver"), pnic.get("pci")) if p)
result[pnic["device"]] = _iface(
up=bool(link),
description=description,
speed=float(link.get("speedMb", 0)),
mtu=mtus.get(pnic.get("key", ""), _DEFAULT_MTU),
mac=pnic.get("mac", ""),
)
for vnic in host.get("config.network.vnic") or []:
spec = vnic.get("spec") or {}
result[vnic["device"]] = _iface(
up=True,
description=vnic.get("portgroup") or spec.get("portgroup", ""),
speed=0.0,
mtu=int(spec.get("mtu") or _DEFAULT_MTU),
mac=spec.get("mac", ""),
)
return result
def _ipv4(ip: dict[str, Any]) -> dict[str, dict[str, int]]:
address = ip.get("ipAddress", "")
if address in _UNASSIGNED:
return {}
network = ipaddress.IPv4Network(f"0.0.0.0/{ip.get('subnetMask') or '32'}")
return {address: {"prefix_length": network.prefixlen}}
def _ipv6(ip: dict[str, Any]) -> dict[str, dict[str, int]]:
entries = (ip.get("ipV6Config") or {}).get("ipV6Address", [])
return {e["ipAddress"]: {"prefix_length": int(e.get("prefixLength", 128))} for e in entries}
def host_interfaces_ip(host: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Addresses of the VMkernel adapters, as NAPALM's ``get_interfaces_ip``."""
result: dict[str, dict[str, Any]] = {}
for vnic in host.get("config.network.vnic") or []:
ip = (vnic.get("spec") or {}).get("ip") or {}
families = {"ipv4": _ipv4(ip), "ipv6": _ipv6(ip)}
families = {k: v for k, v in families.items() if v}
if families:
result[vnic["device"]] = families
return result
+33
View File
@@ -0,0 +1,33 @@
"""NAPALM ``get_lldp_neighbors`` from ``HostNetworkSystem.QueryNetworkHint``.
ESXi listens for CDP on standard vSwitches by default and for LLDP where a
vDS enables it. Both answers come back per vmnic in one hint; LLDP is
preferred because it names the switch port the way the switch itself does.
"""
from __future__ import annotations
from typing import Any
def _lldp(info: dict[str, Any]) -> dict[str, str] | None:
if not info.get("portId"):
return None
params = {p.get("key"): p.get("value") for p in info.get("parameter", [])}
hostname = params.get("System Name") or info.get("chassisId", "")
return {"hostname": str(hostname), "port": str(info["portId"])}
def _cdp(info: dict[str, Any]) -> dict[str, str] | None:
if not info.get("portId"):
return None
return {"hostname": str(info.get("devId", "")), "port": str(info["portId"])}
def lldp_neighbors(hints: list[dict[str, Any]]) -> dict[str, list[dict[str, str]]]:
result: dict[str, list[dict[str, str]]] = {}
for hint in hints:
neighbor = _lldp(hint.get("lldpInfo") or {}) or _cdp(hint.get("connectedSwitchPort") or {})
if neighbor:
result[hint["device"]] = [neighbor]
return result
+106
View File
@@ -0,0 +1,106 @@
"""Port groups: what exists, which VLAN each carries, and which one a NIC uses.
Two kinds of network can back a VM NIC. A *standard* port group lives on one
host's vSwitch and is named in the NIC's backing directly. A *distributed*
port group lives on a vCenter-managed vDS, and the NIC names it by key
(``dvportgroup-12``) -- so its display name and VLAN have to be looked up.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
#: vCenter's default name for a vDS's uplink port group, used when the server
#: is too old to report ``config.uplink``.
_UPLINK_NAME = re.compile(r"-DVUplinks-\d+$")
@dataclass
class NetworkIndex:
"""Port group name -> VLAN, and dvPortgroup key -> (name, VLAN)."""
standard: dict[str, int] = field(default_factory=dict)
distributed: dict[str, tuple[str, int]] = field(default_factory=dict)
def dv_vlan(port_config: dict[str, Any] | None) -> int:
"""The single VLAN of a dvPortgroup, or 0 for untagged/trunk."""
vlan = (port_config or {}).get("vlan") or {}
kind = vlan.get("_type", "")
if kind.endswith("PvlanSpec"):
return int(vlan.get("pvlanId", 0))
if kind.endswith("VlanIdSpec"):
return int(vlan.get("vlanId", 0))
return 0
def _standard_portgroups(hosts: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
pg.get("spec") or {} for host in hosts for pg in host.get("config.network.portgroup") or []
]
def network_index(hosts: list[dict[str, Any]], dv_portgroups: list[dict[str, Any]]) -> NetworkIndex:
index = NetworkIndex()
for spec in _standard_portgroups(hosts):
index.standard.setdefault(spec.get("name", ""), int(spec.get("vlanId", 0)))
for pg in dv_portgroups:
vlan = dv_vlan(pg.get("config.defaultPortConfig"))
index.distributed[pg["_moref"]] = (pg.get("name", pg["_moref"]), vlan)
return index
def nic_network(nic: dict[str, Any], index: NetworkIndex) -> tuple[str, int]:
"""``(network name, VLAN)`` a virtual NIC is connected to."""
backing = nic.get("backing") or {}
kind = backing.get("_type", "")
if kind.endswith("DistributedVirtualPortBackingInfo"):
key = (backing.get("port") or {}).get("portgroupKey", "")
return index.distributed.get(key, (key, 0))
if kind.endswith("OpaqueNetworkBackingInfo"):
return backing.get("opaqueNetworkId", ""), 0
name = backing.get("deviceName", "")
return name, index.standard.get(name, 0)
def _network(name: str, kind: str, bridge: str, vlan: int) -> dict[str, Any]:
return {
"name": name,
"type": kind,
"bridge": bridge,
"vlan_id": vlan,
"autostart": True,
"active": True,
}
def _is_uplink(pg: dict[str, Any]) -> bool:
if "config.uplink" in pg:
return bool(pg["config.uplink"])
return bool(_UPLINK_NAME.search(pg.get("name", "")))
def virtual_networks(
hosts: list[dict[str, Any]],
dv_portgroups: list[dict[str, Any]],
dv_switches: list[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
"""Every network a VM can attach to, as ``get_virtual_networks`` returns it."""
result: dict[str, dict[str, Any]] = {}
for spec in _standard_portgroups(hosts):
name = spec.get("name", "")
result.setdefault(
name,
_network(name, "portgroup", spec.get("vswitchName", ""), int(spec.get("vlanId", 0))),
)
switch_names = {s["_moref"]: s.get("name", s["_moref"]) for s in dv_switches}
for pg in dv_portgroups:
if _is_uplink(pg):
continue
dvs = pg.get("config.distributedVirtualSwitch", "")
vlan = dv_vlan(pg.get("config.defaultPortConfig"))
name = pg.get("name", pg["_moref"])
result[name] = _network(name, "dvportgroup", switch_names.get(dvs, dvs), vlan)
return result
+52
View File
@@ -0,0 +1,52 @@
"""A VM's snapshot tree, flattened, and snapshot lookup by name."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
from napalm_device_types.models import SnapshotDict
def _walk(nodes: list[dict[str, Any]], parent: str) -> Iterator[tuple[dict[str, Any], str]]:
for node in nodes:
yield node, parent
yield from _walk(node.get("childSnapshotList", []), node.get("name", ""))
def _nodes(vm: dict[str, Any]) -> Iterator[tuple[dict[str, Any], str]]:
return _walk((vm.get("snapshot") or {}).get("rootSnapshotList", []), "")
def snapshot_list(vm: dict[str, Any]) -> list[SnapshotDict]:
"""Every snapshot, depth first, each naming its parent (``""`` for a root)."""
return [
{
"name": node.get("name", ""),
"vm": vm.get("name", ""),
"created": float(node.get("createTime", 0.0)),
"description": node.get("description", ""),
# A snapshot taken with memory records the VM as powered on.
"has_memory": node.get("state") == "poweredOn",
"parent": parent,
}
for node, parent in _nodes(vm)
]
def find_snapshot(vm: dict[str, Any], name: str) -> str:
"""MoRef of the one snapshot called ``name``.
Raises ``ValueError`` when there is none -- or more than one: vSphere does
not require snapshot names to be unique, and choosing one of two
candidates could delete or roll back to the wrong state.
"""
matches = [node["snapshot"] for node, _ in _nodes(vm) if node.get("name") == name]
if not matches:
raise ValueError(f"VM {vm.get('name')!r} has no snapshot named {name!r}")
if len(matches) > 1:
raise ValueError(
f"VM {vm.get('name')!r} has {len(matches)} snapshots named {name!r}; "
"rename one in vSphere first"
)
return matches[0]
+27
View File
@@ -0,0 +1,27 @@
"""``get_vm_storage_pools``: datastores."""
from __future__ import annotations
from typing import Any
from napalm_device_types.models import StorageVolumeDict
def storage_pools(datastores: list[dict[str, Any]]) -> dict[str, StorageVolumeDict]:
"""Datastores keyed by name. ``enabled`` means currently accessible."""
result: dict[str, StorageVolumeDict] = {}
for ds in datastores:
summary = ds.get("summary") or {}
name = ds.get("name") or summary.get("name", ds["_moref"])
total = int(summary.get("capacity") or 0)
free = int(summary.get("freeSpace") or 0)
result[name] = {
"name": name,
"type": str(summary.get("type", "")).lower(),
"total": total,
"used": total - free,
"available": free,
"enabled": bool(summary.get("accessible", False)),
"shared": bool(summary.get("multipleHostAccess", False)),
}
return result
+96
View File
@@ -0,0 +1,96 @@
"""``get_vm_config``: a VM's virtual hardware as a VMConfigDict."""
from __future__ import annotations
from typing import Any
from napalm_device_types.models import VMConfigDict, VMDiskDict, VMNICDict
from napalm_vmware.parse import vm_devices as dev
from napalm_vmware.parse.networks import NetworkIndex, nic_network
_GB = 1024**3
_BOOT_KIND = {
"VirtualMachineBootOptionsBootableCdromDevice": "cdrom",
"VirtualMachineBootOptionsBootableFloppyDevice": "floppy",
}
def _boot_order(vm: dict[str, Any], labels: dict[int, str]) -> list[str]:
order: list[str] = []
for entry in vm.get("config.bootOptions.bootOrder") or []:
kind = entry.get("_type", "")
if kind in _BOOT_KIND:
order.append(_BOOT_KIND[kind])
elif "deviceKey" in entry:
order.append(labels.get(entry["deviceKey"], str(entry["deviceKey"])))
return order
def _disks(vm: dict[str, Any], boot_order: list[str]) -> list[VMDiskDict]:
disks = [d for d in dev.devices(vm) if dev.is_disk(d)]
result: list[VMDiskDict] = []
for position, disk in enumerate(disks):
name = dev.label(disk)
# Without an explicit boot order the firmware boots the first disk.
bootable = name in boot_order if boot_order else position == 0
result.append(
{
"device": name,
"storage": dev.disk_datastore(disk),
"size": dev.disk_bytes(disk) // _GB,
"format": "vmdk",
"bootable": bootable,
}
)
return result
def _nics(vm: dict[str, Any], index: NetworkIndex) -> list[VMNICDict]:
result: list[VMNICDict] = []
for nic in filter(dev.is_nic, dev.devices(vm)):
network, vlan = nic_network(nic, index)
result.append(
{
"device": dev.label(nic),
"mac": nic.get("macAddress", "").upper(),
"model": dev.nic_model(nic),
"bridge": network,
"vlan_id": vlan,
}
)
return result
def vm_config(vm: dict[str, Any], index: NetworkIndex) -> VMConfigDict:
"""The VM's hardware. Raises ``ValueError`` for a VM without a config."""
if not vm.get("config.instanceUuid"):
raise ValueError(f"VM {vm.get('name', vm.get('_moref'))!r} has no readable configuration")
labels = {d["key"]: dev.label(d) for d in dev.devices(vm) if "key" in d}
boot_order = _boot_order(vm, labels)
vcpus = int(vm.get("config.hardware.numCPU") or 0)
cores = int(vm.get("config.hardware.numCoresPerSocket") or 1)
result: VMConfigDict = {
"name": vm.get("name", ""),
"vmid": vm["config.instanceUuid"],
"vcpus": vcpus,
"memory": int(vm.get("config.hardware.memoryMB") or 0),
"os_type": vm.get("config.guestId", ""),
"boot_order": boot_order,
"disks": _disks(vm, boot_order),
"nics": _nics(vm, index),
"description": vm.get("config.annotation", ""),
"tags": [],
"os_name": vm.get("config.guestFullName", ""),
"sockets": max(vcpus // cores, 1),
"cores_per_socket": cores,
"firmware": vm.get("config.firmware", "bios"),
"passthrough": [
{"slot": dev.label(d), "kind": kind, "config": dev.summary(d)}
for d in dev.devices(vm)
if (kind := dev.passthrough_kind(d))
],
}
if vm.get("config.version"):
result["machine"] = vm["config.version"]
return result
+65
View File
@@ -0,0 +1,65 @@
"""Classify and read entries of a VM's ``config.hardware.device`` list."""
from __future__ import annotations
import re
from typing import Any
_NIC_TYPES = frozenset(
{
"VirtualE1000",
"VirtualE1000e",
"VirtualPCNet32",
"VirtualVmxnet",
"VirtualVmxnet2",
"VirtualVmxnet3",
"VirtualVmxnet3Vrdma",
"VirtualSriovEthernetCard",
}
)
_PASSTHROUGH_KIND = {"VirtualPCIPassthrough": "pci", "VirtualUSB": "usb"}
_DATASTORE_IN_PATH = re.compile(r"^\[([^\]]+)\]")
def devices(vm: dict[str, Any]) -> list[dict[str, Any]]:
return vm.get("config.hardware.device") or []
def is_nic(device: dict[str, Any]) -> bool:
return device.get("_type") in _NIC_TYPES
def is_disk(device: dict[str, Any]) -> bool:
return device.get("_type") == "VirtualDisk"
def passthrough_kind(device: dict[str, Any]) -> str | None:
return _PASSTHROUGH_KIND.get(device.get("_type", ""))
def label(device: dict[str, Any]) -> str:
"""The name vSphere shows for a device (``"Hard disk 1"``), else its key."""
info = device.get("deviceInfo") or {}
return info.get("label") or str(device.get("key", ""))
def summary(device: dict[str, Any]) -> str:
return (device.get("deviceInfo") or {}).get("summary", "")
def nic_model(device: dict[str, Any]) -> str:
"""``"vmxnet3"``, ``"e1000e"`` ... -- the type name without ``Virtual``."""
return device.get("_type", "").removeprefix("Virtual").lower()
def disk_bytes(device: dict[str, Any]) -> int:
if device.get("capacityInBytes"):
return int(device["capacityInBytes"])
return int(device.get("capacityInKB", 0)) * 1024
def disk_datastore(device: dict[str, Any]) -> str:
"""Datastore name from the backing file path ``"[datastore1] vm/vm.vmdk"``."""
path = (device.get("backing") or {}).get("fileName", "")
match = _DATASTORE_IN_PATH.match(path)
return match.group(1) if match else ""
+121
View File
@@ -0,0 +1,121 @@
"""``get_vms``: one VMDict per virtual machine, plus the keys netOrk reads.
Beyond the ``VMDict`` contract, netOrk's VM sync expects ``type``,
``interfaces`` (NAPALM shape plus ``ipv4``/``bridge``/``tag``), ``ipv4``,
``agent_enabled``/``agent_running`` (here: VMware Tools), ``disks`` and
``onboot``. ``moref`` is added so an operator can find the VM in vSphere.
``vmid`` is the VM's *instance* UUID: stable across vMotion and
re-registration, unique within a vCenter, and the key ``SearchIndex.FindByUuid``
looks VMs up by. The MoRef is none of those things.
"""
from __future__ import annotations
import ipaddress
from typing import Any
from napalm_vmware.parse import vm_devices as dev
from napalm_vmware.parse.networks import NetworkIndex, nic_network
_STATUS = {"poweredOn": "running", "poweredOff": "stopped", "suspended": "suspended"}
def _is_ipv4(address: str) -> bool:
try:
return isinstance(ipaddress.ip_address(address), ipaddress.IPv4Address)
except ValueError:
return False
def _cpu_usage(vm: dict[str, Any], host_mhz: int) -> float:
used = (vm.get("summary.quickStats") or {}).get("overallCpuUsage") or 0
capacity = int(vm.get("config.hardware.numCPU") or 0) * host_mhz
if not capacity:
return 0.0
return round(min(used / capacity, 1.0), 4)
def _interfaces(vm: dict[str, Any], index: NetworkIndex) -> dict[str, dict[str, Any]]:
guest_nics = {n.get("deviceConfigId"): n for n in vm.get("guest.net") or []}
result: dict[str, dict[str, Any]] = {}
for nic in filter(dev.is_nic, dev.devices(vm)):
network, vlan = nic_network(nic, index)
guest = guest_nics.get(nic.get("key"), {})
connected = guest.get("connected", (nic.get("connectable") or {}).get("connected", False))
ipv4 = next((a for a in guest.get("ipAddress", []) if _is_ipv4(a)), "")
result[dev.label(nic)] = {
"is_up": bool(connected),
"is_enabled": True,
"description": network,
"mac_address": nic.get("macAddress", "").upper(),
"speed": -1.0,
"mtu": 1500,
"last_flapped": -1.0,
"ipv4": ipv4,
"bridge": network,
"tag": vlan or None,
}
return result
def _autostart_vms(hosts: list[dict[str, Any]]) -> set[str]:
"""MoRefs of VMs the host's autostart manager will power on at boot."""
morefs: set[str] = set()
for host in hosts:
config = host.get("config.autoStart") or {}
if not (config.get("defaults") or {}).get("enabled"):
continue
for entry in config.get("powerInfo", []):
if str(entry.get("startAction", "")).lower() == "poweron":
morefs.add(entry.get("key", ""))
return morefs
def _vm(
vm: dict[str, Any], hosts: dict[str, dict[str, Any]], index: NetworkIndex, onboot: bool
) -> dict[str, Any]:
host = hosts.get(vm.get("runtime.host", ""), {})
stats = vm.get("summary.quickStats") or {}
interfaces = _interfaces(vm, index)
primary = vm.get("guest.ipAddress") or ""
ipv4 = (
primary
if _is_ipv4(primary)
else next((i["ipv4"] for i in interfaces.values() if i["ipv4"]), "")
)
return {
"name": vm.get("name", ""),
"vmid": vm["config.instanceUuid"],
"moref": vm["_moref"],
"type": "vm",
"status": _STATUS.get(vm.get("runtime.powerState", ""), "unknown"),
"vcpus": int(vm.get("config.hardware.numCPU") or 0),
"memory": int(vm.get("config.hardware.memoryMB") or 0),
"cpu_usage": _cpu_usage(vm, int((host.get("summary.hardware") or {}).get("cpuMhz") or 0)),
"memory_usage": int(stats.get("guestMemoryUsage") or 0),
"uptime": int(stats.get("uptimeSeconds") or 0),
"node": host.get("name", ""),
"interfaces": interfaces,
"ipv4": ipv4,
"agent_enabled": vm.get("guest.toolsStatus", "toolsNotInstalled") != "toolsNotInstalled",
"agent_running": vm.get("guest.toolsRunningStatus") == "guestToolsRunning",
"disks": [
{"name": dev.label(d), "size_mb": dev.disk_bytes(d) // (1024 * 1024)}
for d in filter(dev.is_disk, dev.devices(vm))
],
"onboot": onboot,
}
def vm_list(
vms: list[dict[str, Any]], hosts: list[dict[str, Any]], index: NetworkIndex
) -> list[dict[str, Any]]:
"""All VMs, templates and unidentifiable (config-less) VMs excluded."""
by_moref = {h["_moref"]: h for h in hosts}
autostart = _autostart_vms(hosts)
return [
_vm(vm, by_moref, index, vm["_moref"] in autostart)
for vm in vms
if vm.get("config.instanceUuid") and not vm.get("config.template")
]
+53
View File
@@ -0,0 +1,53 @@
"""Raw device warnings: ``{"code", "meta"}`` only.
What a code means to a user -- severity, wording, the fix -- is decided in
netOrk's ``WARNING_CATALOG``, never here.
"""
from __future__ import annotations
from typing import Any
_CONNECTED = "connected"
_UNMANAGED = {None, "", "0.0.0.0"}
#: License edition of the free vSphere Hypervisor. The vSphere API refuses
#: every write on it (power, snapshots) with a RestrictedVersion fault.
_FREE_EDITIONS = ("esxBasic",)
def _host_codes(host: dict[str, Any], standalone: bool) -> list[dict[str, Any]]:
name = host.get("name", host.get("_moref", ""))
found: list[dict[str, Any]] = []
state = host.get("runtime.connectionState", _CONNECTED)
if not standalone and state != _CONNECTED:
found.append({"code": "vmware_host_disconnected", "meta": {"host": name, "state": state}})
if host.get("runtime.inMaintenanceMode"):
found.append({"code": "vmware_host_maintenance_mode", "meta": {"host": name}})
issues = [
i["fullFormattedMessage"]
for i in host.get("configIssue") or []
if i.get("fullFormattedMessage")
]
if issues:
found.append({"code": "vmware_host_config_issue", "meta": {"host": name, "issues": issues}})
manager = host.get("summary.managementServerIp")
if standalone and manager not in _UNMANAGED:
found.append({"code": "vmware_host_managed_by_vcenter", "meta": {"vcenter": manager}})
return found
def host_warnings(
hosts: list[dict[str, Any]], licenses: list[dict[str, Any]], *, standalone: bool
) -> list[dict[str, Any]]:
"""Warnings for the hosts this driver sees.
``standalone`` is True for the ESXi driver. It alone reports a host being
managed by a vCenter -- a hint to add the vCenter instead -- and it never
reports disconnection, since a disconnected host could not have answered.
"""
found = [w for host in hosts for w in _host_codes(host, standalone)]
for lic in licenses:
if str(lic.get("editionKey", "")).startswith(_FREE_EDITIONS):
found.append({"code": "vmware_api_read_only", "meta": {"edition": lic.get("name", "")}})
break
return found
+76
View File
@@ -0,0 +1,76 @@
"""The vSphere property paths the drivers read, per managed object type.
Single source of truth for both the drivers and ``tools/harvest.py``: a
fixture harvested from a real host contains exactly what the parsers are
later fed, no more and no less. Adding a path here is the first step of
adding a field to any getter.
"""
from __future__ import annotations
HOST = (
"name",
"summary.hardware",
"summary.quickStats",
"summary.managementServerIp",
"hardware.systemInfo",
"runtime.inMaintenanceMode",
"runtime.connectionState",
"runtime.healthSystemRuntime.systemHealthInfo.numericSensorInfo",
"config.product",
"config.network.pnic",
"config.network.vnic",
"config.network.portgroup",
"config.network.vswitch",
"config.network.dnsConfig",
"config.autoStart",
"configIssue",
"configManager.networkSystem",
)
VM = (
"name",
"config.instanceUuid",
"config.template",
"config.guestId",
"config.guestFullName",
"config.annotation",
"config.firmware",
"config.version",
"config.hardware.numCPU",
"config.hardware.numCoresPerSocket",
"config.hardware.memoryMB",
"config.hardware.device",
"config.bootOptions.bootOrder",
"runtime.powerState",
"runtime.host",
"summary.quickStats",
"guest.net",
"guest.ipAddress",
"guest.toolsStatus",
"guest.toolsRunningStatus",
"snapshot",
)
DATASTORE = (
"name",
"summary",
)
DV_PORTGROUP = (
"name",
"config.defaultPortConfig",
"config.distributedVirtualSwitch",
"config.uplink",
)
DV_SWITCH = ("name",)
#: Managed object type name -> paths, in the order harvest.py dumps them.
ALL = {
"HostSystem": HOST,
"VirtualMachine": VM,
"Datastore": DATASTORE,
"DistributedVirtualPortgroup": DV_PORTGROUP,
"DistributedVirtualSwitch": DV_SWITCH,
}
View File
+48
View File
@@ -0,0 +1,48 @@
"""NAPALM driver for VMware vCenter Server.
netOrk treats the vCenter as one device. The ESXi hosts behind it appear as
the ``node`` of each VM; a host can additionally be added with the
``vmware_esxi`` driver when its own NICs and sensors are of interest.
"""
from __future__ import annotations
from typing import Any
from napalm_device_types import FingerprintRule
from napalm_vmware.actions import VmwareActionsMixin
from napalm_vmware.base import VmwareBaseDriver
from napalm_vmware.parse.facts import vcenter_facts
class VmwareVcenterDriver(VmwareActionsMixin, VmwareBaseDriver):
"""A vCenter and every VM, datastore and port group it manages."""
DRIVER_NAME = "vmware_vcenter"
API_TYPE = "VirtualCenter"
STANDALONE = False
# The appliance starts several dozen services before the API answers.
REBOOT_SETTLE_SECONDS = 600
# Unverified against real hardware; see VmwareEsxiDriver.
HTTP_FINGERPRINT = [
FingerprintRule("vcenter", weight=9.0, mandatory=True),
FingerprintRule("vsphere", weight=3.0),
FingerprintRule("vmware esxi", weight=9.0, negative=True),
]
def get_facts(self) -> dict[str, Any]:
return vcenter_facts(self._inventory.about(), self.hostname)
# The hosts' NICs are not the vCenter's; reporting them here would attach
# their MAC addresses to the wrong device. The appliance's own NICs are
# only visible through its management API, which this driver does not use.
def get_interfaces(self) -> dict[str, dict[str, Any]]:
return {}
def get_interfaces_ip(self) -> dict[str, dict[str, Any]]:
return {}
def get_lldp_neighbors(self) -> dict[str, list[dict[str, str]]]:
return {}