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