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,184 @@
|
||||
"""Datastores, sensors/utilisation, CDP/LLDP neighbours and raw device warnings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_vmware.parse.environment import environment
|
||||
from napalm_vmware.parse.lldp import lldp_neighbors
|
||||
from napalm_vmware.parse.storage import storage_pools
|
||||
from napalm_vmware.parse.warnings import host_warnings
|
||||
|
||||
|
||||
class TestStoragePools:
|
||||
def test_from_vcsim(self, esxi_data):
|
||||
pools = storage_pools(esxi_data["objects"]["Datastore"])
|
||||
(name,) = pools
|
||||
pool = pools[name]
|
||||
assert pool["name"] == name
|
||||
assert pool["type"] == "other"
|
||||
assert pool["used"] == pool["total"] - pool["available"]
|
||||
assert pool["enabled"] is True
|
||||
assert pool["shared"] is False
|
||||
|
||||
def test_shared_vmfs(self):
|
||||
ds = {
|
||||
"_moref": "datastore-1",
|
||||
"name": "san01",
|
||||
"summary": {
|
||||
"type": "VMFS",
|
||||
"capacity": 1000,
|
||||
"freeSpace": 250,
|
||||
"accessible": False,
|
||||
"multipleHostAccess": True,
|
||||
},
|
||||
}
|
||||
assert storage_pools([ds])["san01"] == {
|
||||
"name": "san01",
|
||||
"type": "vmfs",
|
||||
"total": 1000,
|
||||
"used": 750,
|
||||
"available": 250,
|
||||
"enabled": False,
|
||||
"shared": True,
|
||||
}
|
||||
|
||||
|
||||
def _sensor(name, kind, reading, health="green", modifier=0):
|
||||
return {
|
||||
"name": name,
|
||||
"sensorType": kind,
|
||||
"currentReading": reading,
|
||||
"unitModifier": modifier,
|
||||
"healthState": {"key": health},
|
||||
}
|
||||
|
||||
|
||||
class TestEnvironment:
|
||||
def test_utilisation_of_one_host(self, esxi_host):
|
||||
esxi_host["summary.hardware"].update(cpuMhz=2000, numCpuCores=4, memorySize=8 * 1024**3)
|
||||
esxi_host["summary.quickStats"].update(overallCpuUsage=2000, overallMemoryUsage=2048)
|
||||
env = environment([esxi_host])
|
||||
assert env["cpu"] == {"0": {"%usage": 25.0}}
|
||||
assert env["memory"] == {"available_ram": 8 * 1024**3, "used_ram": 2048 * 1024**2}
|
||||
|
||||
def test_sensors(self, esxi_host):
|
||||
esxi_host["runtime.healthSystemRuntime.systemHealthInfo.numericSensorInfo"] = [
|
||||
_sensor("CPU1 Temp", "temperature", 4500, modifier=-2),
|
||||
_sensor("Inlet Temp", "temperature", 41, health="yellow"),
|
||||
_sensor("PCH Temp", "temperature", 90, health="red"),
|
||||
_sensor("FAN1", "fan", 5400),
|
||||
_sensor("FAN2", "fan", 0, health="red"),
|
||||
_sensor("PSU1 Output", "power", 180),
|
||||
_sensor("12V", "voltage", 12),
|
||||
]
|
||||
env = environment([esxi_host])
|
||||
assert env["temperature"] == {
|
||||
"CPU1 Temp": {"temperature": 45.0, "is_alert": False, "is_critical": False},
|
||||
"Inlet Temp": {"temperature": 41.0, "is_alert": True, "is_critical": False},
|
||||
"PCH Temp": {"temperature": 90.0, "is_alert": True, "is_critical": True},
|
||||
}
|
||||
assert env["fans"] == {"FAN1": {"status": True}, "FAN2": {"status": False}}
|
||||
assert env["power"] == {"PSU1 Output": {"status": True, "capacity": -1.0, "output": 180.0}}
|
||||
|
||||
def test_several_hosts_are_keyed_by_host(self, vcenter_data):
|
||||
hosts = vcenter_data["objects"]["HostSystem"]
|
||||
hosts[0]["runtime.healthSystemRuntime.systemHealthInfo.numericSensorInfo"] = [
|
||||
_sensor("Inlet Temp", "temperature", 30)
|
||||
]
|
||||
env = environment(hosts)
|
||||
assert set(env["cpu"]) == {h["name"] for h in hosts}
|
||||
assert f"{hosts[0]['name']}/Inlet Temp" in env["temperature"]
|
||||
assert env["memory"]["available_ram"] == sum(
|
||||
h["summary.hardware"]["memorySize"] for h in hosts
|
||||
)
|
||||
|
||||
def test_host_without_stats(self):
|
||||
env = environment([{"_moref": "host-1", "name": "h"}])
|
||||
assert env["cpu"] == {"0": {"%usage": 0.0}}
|
||||
assert env["memory"] == {"available_ram": 0, "used_ram": 0}
|
||||
|
||||
|
||||
class TestLldpNeighbors:
|
||||
def test_lldp_preferred_over_cdp(self):
|
||||
hints = [
|
||||
{
|
||||
"device": "vmnic0",
|
||||
"connectedSwitchPort": {"devId": "cdp-sw", "portId": "Gi1/0/1"},
|
||||
"lldpInfo": {
|
||||
"chassisId": "00:11:22:33:44:55",
|
||||
"portId": "1/1/7",
|
||||
"parameter": [{"key": "System Name", "value": "core-sw"}],
|
||||
},
|
||||
},
|
||||
{"device": "vmnic1", "connectedSwitchPort": {"devId": "cdp-sw", "portId": "Gi1/0/2"}},
|
||||
{"device": "vmnic2", "lldpInfo": {"chassisId": "aa:bb", "portId": "5"}},
|
||||
{"device": "vmnic3"},
|
||||
]
|
||||
assert lldp_neighbors(hints) == {
|
||||
"vmnic0": [{"hostname": "core-sw", "port": "1/1/7"}],
|
||||
"vmnic1": [{"hostname": "cdp-sw", "port": "Gi1/0/2"}],
|
||||
"vmnic2": [{"hostname": "aa:bb", "port": "5"}],
|
||||
}
|
||||
|
||||
def test_nothing_heard(self):
|
||||
assert lldp_neighbors([]) == {}
|
||||
|
||||
|
||||
class TestHostWarnings:
|
||||
def test_healthy_vcsim_host(self, esxi_host):
|
||||
assert host_warnings([esxi_host], [], standalone=True) == []
|
||||
|
||||
def test_maintenance_mode(self, esxi_host):
|
||||
esxi_host["runtime.inMaintenanceMode"] = True
|
||||
assert host_warnings([esxi_host], [], standalone=True) == [
|
||||
{"code": "vmware_host_maintenance_mode", "meta": {"host": "localhost.localdomain"}}
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("state", ["disconnected", "notResponding"])
|
||||
def test_disconnected_host_under_vcenter(self, esxi_host, state):
|
||||
esxi_host["runtime.connectionState"] = state
|
||||
assert host_warnings([esxi_host], [], standalone=False) == [
|
||||
{
|
||||
"code": "vmware_host_disconnected",
|
||||
"meta": {"host": "localhost.localdomain", "state": state},
|
||||
}
|
||||
]
|
||||
|
||||
def test_config_issues(self, esxi_host):
|
||||
esxi_host["configIssue"] = [
|
||||
{"fullFormattedMessage": "SSH for the host has been enabled"},
|
||||
{"fullFormattedMessage": ""},
|
||||
]
|
||||
assert host_warnings([esxi_host], [], standalone=True) == [
|
||||
{
|
||||
"code": "vmware_host_config_issue",
|
||||
"meta": {
|
||||
"host": "localhost.localdomain",
|
||||
"issues": ["SSH for the host has been enabled"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def test_standalone_driver_on_a_vcenter_managed_host(self, esxi_host):
|
||||
esxi_host["summary.managementServerIp"] = "192.0.2.5"
|
||||
assert host_warnings([esxi_host], [], standalone=True) == [
|
||||
{"code": "vmware_host_managed_by_vcenter", "meta": {"vcenter": "192.0.2.5"}}
|
||||
]
|
||||
|
||||
def test_vcenter_driver_does_not_report_its_own_management(self, esxi_host):
|
||||
esxi_host["summary.managementServerIp"] = "192.0.2.5"
|
||||
assert host_warnings([esxi_host], [], standalone=False) == []
|
||||
|
||||
def test_free_license_makes_the_api_read_only(self, esxi_host):
|
||||
licenses = [{"editionKey": "esxBasic", "name": "VMware vSphere 8 Hypervisor"}]
|
||||
assert host_warnings([esxi_host], licenses, standalone=True) == [
|
||||
{
|
||||
"code": "vmware_api_read_only",
|
||||
"meta": {"edition": "VMware vSphere 8 Hypervisor"},
|
||||
}
|
||||
]
|
||||
|
||||
def test_evaluation_license_is_not_read_only(self, esxi_host):
|
||||
licenses = [{"editionKey": "eval", "name": "Evaluation Mode"}]
|
||||
assert host_warnings([esxi_host], licenses, standalone=True) == []
|
||||
Reference in New Issue
Block a user