feat: NAPALM drivers for VMware ESXi and vCenter

Two drivers from one package, both in the hypervisor role:

- vmware_esxi talks to one host directly: facts, vmnics and vmkernel
  NICs, CDP/LLDP neighbours, sensors, VMs, datastores and port groups.
- vmware_vcenter talks to a vCenter: every VM of every host it manages,
  with the host as the VM's node, plus distributed port groups. It
  reports no interfaces of its own; the hosts' NICs belong to the hosts.

Both implement the HypervisorDriver VM contract: get_vms, get_vm_config,
start/stop/reboot/suspend_vm and the four snapshot methods, and emit raw
device warnings (maintenance mode, disconnected host, config issues,
host managed by a vCenter, free license making the API read-only).

Every read is a PropertyCollector query for the explicit paths in
paths.py, converted by to_plain() into dicts and lists; the parsers
only ever see that. tools/harvest.py dumps exactly those paths to JSON
and tools/sanitize.py scrubs the dump, so a real host can become a test
fixture without code changes. A VM's vmid is its instance UUID, which
survives vMotion and re-registration; a MoRef does not.

Tested against govmomi's vcsim in ESXi and vCenter mode, including real
power and snapshot tasks. Not yet tested against real hardware.
This commit is contained in:
Christian Manivong
2026-09-24 09:07:30 +02:00
commit c8e472b4c6
47 changed files with 14645 additions and 0 deletions
View File
+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