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,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
|
||||
@@ -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": [],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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")
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user