feat: NAPALM drivers for VMware ESXi and vCenter

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

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

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

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

Tested against govmomi's vcsim in ESXi and vCenter mode, including real
power and snapshot tasks. Not yet tested against real hardware.
This commit is contained in:
Christian Manivong
2026-09-24 09:07:30 +02:00
commit c8e472b4c6
47 changed files with 14645 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# Raw, unsanitised harvest output — never commit device data.
tools/harvest-out/
+21
View File
@@ -0,0 +1,21 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.1.0] - 2026-09-24
### Added
- `vmware_esxi` and `vmware_vcenter` drivers (`hypervisor` role) over the
vSphere API: facts, interfaces, LLDP/CDP, environment, VMs, VM config,
power actions, snapshots, datastores, port groups and raw device warnings.
- `tools/harvest.py` and `tools/sanitize.py` to capture fixtures from real
hosts.
### Pending
- Validation against real ESXi and vCenter installations; tested against
vcsim only.
+165
View File
@@ -0,0 +1,165 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship made available under
the License, as indicated by a copyright notice that is included in
or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean, as submitted to the Licensor for inclusion
in the Work by the copyright owner or by an individual or Legal Entity
authorized to submit on behalf of the copyright owner. For the purposes
of this definition, "submitted" means any form of electronic, verbal,
or written communication sent to the Licensor or its representatives,
including but not limited to communication on electronic mailing lists,
source code control systems, and issue tracking systems that are managed
by, or on behalf of, the Licensor for the purpose of tracking and
discussing the Work, but excluding communication that is conspicuously
marked or designated in writing by the copyright owner as "Not a
Contribution."
"Contributor" shall mean Licensor and any Legal Entity on behalf of
whom a Contribution has been received by the Licensor and included
within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by the combination of their Contributions
with the Work to which such Contributions were submitted. If You
institute patent litigation against any entity (including a cross-claim
or counterclaim in a lawsuit) alleging that the Work or any
Contribution embodied within the Work constitutes direct or contributory
patent infringement, then any patent licenses granted to You under
this License for that Work shall terminate as of the date such
litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, You must include a readable copy of the
attribution notices contained within such NOTICE file, in
at least one of the following places: within a NOTICE text
file distributed as part of the Derivative Works; within
the Source form or documentation, if provided along with the
Derivative Works; or, within a display generated by the
Derivative Works, if and wherever such third-party notices
normally appear. The contents of the NOTICE file are for
informational purposes only and do not modify the License.
You may add Your own attribution notices within Derivative
Works that You distribute, alongside or in addition to the
NOTICE text from the Work, provided that such additional
attribution notices cannot be construed as modifying the License.
You may add Your own license statement for Your modifications and
may provide additional grant of rights to use, copy, modify, merge,
publish, sublicense, and/or sell copies of the Work, and to permit
persons to whom the Work is furnished to do so.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any conditions of TITLE,
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR
PURPOSE. You are solely responsible for determining the
appropriateness of using or reproducing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), strict liability, or contract
shall any Contributor be liable to You for damages, including any
direct, indirect, special, incidental, or exemplary damages of any
character arising as a result of this License or out of the use or
inability to use the Work (even if such Contributor has been advised
of the possibility of such damages).
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer, and
charge a fee for, acceptance of support, warranty, indemnity, or other
liability obligations and/or rights consistent with this License.
However, in accepting such obligations, You may offer only your own
liability and not on behalf of any other Contributor, and only if
You agree to indemnify, defend, and hold each Contributor harmless
for any liability incurred by, or claims asserted against, such
Contributor by reason of your accepting any such warranty or
additional liability.
END OF TERMS AND CONDITIONS
+116
View File
@@ -0,0 +1,116 @@
# napalm-vmware
NAPALM drivers for VMware vSphere, speaking the vSphere API through
[pyVmomi](https://github.com/vmware/pyvmomi):
| Driver | Endpoint | Device in netOrk |
|---|---|---|
| `vmware_esxi` | one ESXi host, addressed directly | the host: its vmnics, vmkernel NICs, sensors, VMs |
| `vmware_vcenter` | a vCenter Server | the vCenter: every VM it manages, with the ESXi host as the VM's `node` |
Both declare the `hypervisor` role from
[napalm-device-types](https://git.netork.io/NAPALM/napalm-device-types).
## Status
Tested against govmomi's **vcsim** simulator, in both ESXi and vCenter mode,
including real power and snapshot tasks. **Not yet tested against real
hardware**: see [Harvesting fixtures](#harvesting-fixtures).
| Method | ESXi | vCenter | Source |
|---|---|---|---|
| `get_facts` | ✅ | ✅ | host hardware + product / `about` |
| `get_interfaces`, `get_interfaces_ip` | ✅ | `{}` | vmnics + vmks |
| `get_lldp_neighbors` | ✅ | `{}` | `QueryNetworkHint` (LLDP, else CDP) |
| `get_environment` | ✅ | ✅ (per host) | quick stats + hardware sensors |
| `get_vms` | ✅ | ✅ | VMs, templates excluded |
| `get_vm_config` | ✅ | ✅ | virtual hardware |
| `start_vm`, `stop_vm`, `reboot_vm`, `suspend_vm` | ✅ | ✅ | power tasks / VMware Tools |
| `get_vm_snapshots`, `create/delete/rollback_vm_snapshot` | ✅ | ✅ | snapshot tree |
| `get_vm_storage_pools` | ✅ | ✅ | datastores |
| `get_virtual_networks` | ✅ | ✅ (+ dvPortgroups) | port groups |
| `get_device_warnings` | ✅ | ✅ | raw `{code, meta}` |
| VM provisioning, VIBs, updates, host reboot | — | — | out of scope for v1 |
Unverified assumptions, to be checked against real hardware:
- the HTTP fingerprints (`vmware esxi` on the Host Client page, `vcenter` on
the vSphere Client page)
- the free vSphere Hypervisor license reporting `editionKey` `esxBasic`
## Requirements
- HTTPS (443) to the host or vCenter. No SSH.
- An account that may read the inventory. For power and snapshot actions it
also needs *Virtual machine → Interaction → Power on/off/Reset/Suspend* and
*Virtual machine → Snapshot management*.
- **A paid license for any write.** On the free vSphere Hypervisor license
the API is read-only; the driver reports `vmware_api_read_only` and turns
the refusal into a readable error.
## Install
```bash
pip install -e vendor/napalm-device-types/ -e vendor/napalm-vmware/
```
## Usage
```python
from napalm_vmware import VmwareEsxiDriver
driver = VmwareEsxiDriver("esx01.example.lan", "root", "secret",
optional_args={"verify_ssl": False})
driver.open()
print(driver.get_facts())
for vm in driver.get_vms():
print(vm["name"], vm["status"], vm["vmid"])
driver.close()
```
`optional_args`: `port` (default 443), `verify_ssl` / `ssl_verify` (default
`True`; ESXi ships a self-signed certificate). Other keys are ignored.
VMs are addressed by name, by `vmid`, or by MoRef (`vm-42`). A name shared
by two VMs is refused rather than guessed.
## Tests
```bash
pytest # unit tests, no network
docker run -d --rm -p 127.0.0.1:8989:8989 vmware/vcsim -l 0.0.0.0:8989
docker run -d --rm -p 127.0.0.1:8990:8989 vmware/vcsim -esx -l 0.0.0.0:8989
VCSIM_VCENTER_PORT=8989 VCSIM_ESXI_PORT=8990 pytest -m vcsim
```
## Harvesting fixtures
```bash
tools/harvest.py esx01.example.lan root esxi8-dell # prompts for the password
tools/sanitize.py tools/harvest-out/esxi8-dell.json > tests/fixtures/esxi8-dell.json
```
`harvest.py` reads exactly the property paths in `napalm_vmware/paths.py`,
the ones the drivers read. `tools/harvest-out/` is gitignored. Read the
sanitised file before committing it.
## Design notes
**One seam.** Every read goes through `Inventory.collect(type, paths)`, a
PropertyCollector query with explicit paths, and every result is converted
by `to_plain()` into dicts, lists and scalars (managed objects become their
MoRef, data objects get a `_type`). The parsers in `napalm_vmware/parse/`
only ever see that plain form, so a harvested JSON file and a live host feed
them identically. Lazy attribute access on pyVmomi objects is avoided on
purpose: it fails on some servers where the individual paths work.
**`vmid` is the instance UUID** (`config.instanceUuid`). It survives vMotion
and re-registration and is unique within a vCenter; a MoRef is none of those.
The MoRef is reported alongside as `moref`.
**The vCenter device has no interfaces.** The hosts' NICs belong to the
hosts. Reporting them on the vCenter would attach their MACs to the wrong
device. Add a host with `vmware_esxi` to see its NICs.
**Warnings are raw.** `get_device_warnings()` returns `{code, meta}` only;
what a code means is decided in netOrk's `WARNING_CATALOG`.
+6
View File
@@ -0,0 +1,6 @@
"""NAPALM drivers for VMware ESXi (``vmware_esxi``) and vCenter (``vmware_vcenter``)."""
from napalm_vmware.esxi import VmwareEsxiDriver
from napalm_vmware.vcenter import VmwareVcenterDriver
__all__ = ["VmwareEsxiDriver", "VmwareVcenterDriver"]
+90
View File
@@ -0,0 +1,90 @@
"""Bulk reads through the vSphere PropertyCollector.
Every read in this package asks for an explicit list of property paths
(``napalm_vmware.paths``) instead of touching attributes on managed objects.
One round trip per object type rather than one per attribute, and it keeps
working where lazy attribute access does not: pyVmomi fails to deserialize
whole ``summary`` objects from some servers while the individual paths are
fine.
This class is the one seam the tests replace. Its output is already plain
(see :mod:`napalm_vmware._plain`), so a fake only has to return dicts.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from pyVmomi import vim, vmodl
from napalm_vmware._plain import to_plain
_PC = vmodl.query.PropertyCollector
_PAGE_SIZE = 500
def _vim_type(type_name: str) -> Any:
vim_type = getattr(vim, type_name, None) or getattr(vim.dvs, type_name, None)
if vim_type is None:
raise ValueError(f"Unknown vSphere managed object type {type_name!r}")
return vim_type
class Inventory:
"""Read-only access to a connected ServiceInstance's content."""
def __init__(self, content: Any) -> None:
self._content = content
def about(self) -> dict[str, Any]:
"""``ServiceContent.about``: product, version and API type of the endpoint."""
return to_plain(self._content.about)
def collect(self, type_name: str, paths: Sequence[str]) -> list[dict[str, Any]]:
"""Every object of ``type_name`` in the inventory, with the given properties."""
vim_type = _vim_type(type_name)
view = self._content.viewManager.CreateContainerView(
self._content.rootFolder, [vim_type], True
)
try:
traversal = _PC.TraversalSpec(
name="view", path="view", skip=False, type=vim.view.ContainerView
)
obj_spec = _PC.ObjectSpec(obj=view, skip=True, selectSet=[traversal])
return self._retrieve(obj_spec, vim_type, paths)
finally:
view.Destroy()
def properties(self, obj: Any, paths: Sequence[str]) -> dict[str, Any]:
"""The given properties of one managed object; ``{}`` if it is gone."""
rows = self._retrieve(_PC.ObjectSpec(obj=obj), type(obj), paths)
return rows[0] if rows else {}
def licenses(self) -> list[dict[str, Any]]:
"""Licenses assigned on this endpoint (edition key, name, used/total)."""
manager = getattr(self._content, "licenseManager", None)
if manager is None:
return []
return self.properties(manager, ["licenses"]).get("licenses", [])
def _retrieve(self, obj_spec: Any, vim_type: Any, paths: Sequence[str]) -> list[dict[str, Any]]:
spec = _PC.FilterSpec(
objectSet=[obj_spec], propSet=[_PC.PropertySpec(type=vim_type, pathSet=list(paths))]
)
pc = self._content.propertyCollector
result = pc.RetrievePropertiesEx([spec], _PC.RetrieveOptions(maxObjects=_PAGE_SIZE))
rows: list[dict[str, Any]] = []
while result is not None:
rows.extend(_row(obj) for obj in result.objects)
if not result.token:
break
result = pc.ContinueRetrievePropertiesEx(token=result.token)
return rows
def _row(obj_content: Any) -> dict[str, Any]:
row: dict[str, Any] = {"_moref": obj_content.obj._moId}
for prop in obj_content.propSet or []:
row[prop.name] = to_plain(prop.val)
return row
+57
View File
@@ -0,0 +1,57 @@
"""Convert pyVmomi objects into plain, JSON-safe Python values.
The drivers never parse pyVmomi objects directly. Everything fetched from the
vSphere API goes through :func:`to_plain` first, so a parser sees the same
shape whether its input came from a live host or from a JSON fixture that
``tools/harvest.py`` captured.
Conventions:
* a managed object (``HostSystem``, ``VirtualMachine`` ...) becomes its MoRef
id string, e.g. ``"host-21"``
* a data object becomes a dict of its set properties plus ``"_type"``, the
WSDL type name (``"VirtualDisk"``) -- device lists are told apart by it
* enums become ``str``, datetimes become epoch seconds, binary data ``None``
"""
from __future__ import annotations
import datetime
from typing import Any
from pyVmomi import VmomiSupport
_NOISE = frozenset({"dynamicType", "dynamicProperty"})
def to_plain(value: Any) -> Any:
"""Recursively convert ``value`` into dicts, lists and scalars."""
if value is None or isinstance(value, bool):
return value
if isinstance(value, VmomiSupport.ManagedObject):
return value._moId
if isinstance(value, VmomiSupport.DataObject):
return _data_object(value)
if isinstance(value, str):
return str(value)
if isinstance(value, (int, float)):
return value
if isinstance(value, datetime.datetime):
return value.timestamp()
if isinstance(value, (bytes, bytearray)):
return None
if isinstance(value, (list, tuple)):
return [to_plain(v) for v in value]
return str(value)
def _data_object(obj: Any) -> dict[str, Any]:
out: dict[str, Any] = {"_type": obj._wsdlName}
for prop in obj._GetPropertyList():
if prop.name in _NOISE:
continue
val = getattr(obj, prop.name, None)
if val is None or (isinstance(val, list) and not val):
continue
out[prop.name] = to_plain(val)
return out
+47
View File
@@ -0,0 +1,47 @@
"""Opening and closing a vSphere API session.
Kept apart from everything else because it is the only code that opens a
socket; the tests replace it wholesale.
"""
from __future__ import annotations
import ssl
from typing import Any
from pyVim.connect import Disconnect, SmartConnect
def _ssl_context(verify: bool) -> ssl.SSLContext:
context = ssl.create_default_context()
if not verify:
# ESXi hosts ship a self-signed certificate; most homelab and many
# production hosts keep it.
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def connect( # pragma: no cover - opens a live session
host: str, port: int, user: str, password: str, *, verify_ssl: bool, timeout: int
) -> Any:
return SmartConnect(
host=host,
port=port,
user=user,
pwd=password,
sslContext=_ssl_context(verify_ssl),
httpConnectionTimeout=timeout,
)
def disconnect(si: Any) -> None: # pragma: no cover - talks to a live session
Disconnect(si)
def alive(si: Any) -> bool: # pragma: no cover - talks to a live session
try:
si.CurrentTime()
except Exception:
return False
return True
+83
View File
@@ -0,0 +1,83 @@
"""vSphere tasks and faults, in the terms of the HypervisorDriver contract.
Contract methods raise ``RuntimeError`` when the platform refuses an
operation. vSphere reports refusals two ways: a ``MethodFault`` raised by the
call itself, or a task that ends in state ``error``. Both end up here.
"""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import Any, TypeVar
from pyVmomi import vmodl
from napalm_vmware._plain import to_plain
T = TypeVar("T")
_POLL_SECONDS = 0.5
_FREE_LICENSE = (
"the host runs the free vSphere Hypervisor license, which makes the "
"vSphere API read-only; assign a paid license to change VMs from netOrk"
)
def fault_message(fault: dict[str, Any]) -> str:
"""A readable sentence for a (plain) vSphere fault."""
if fault.get("_type") == "RestrictedVersion":
return _FREE_LICENSE
return fault.get("localizedMessage") or fault.get("msg") or fault.get("_type", "unknown fault")
def invoke(call: Callable[..., T], *args: Any, **kwargs: Any) -> T:
"""Run a vSphere call, re-raising any ``MethodFault`` as ``RuntimeError``."""
try:
return call(*args, **kwargs)
except vmodl.MethodFault as fault:
raise RuntimeError(fault_message(to_plain(fault))) from fault
def wait_for_task(
inventory: Any,
task: Any,
timeout: float,
*,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> None:
"""Block until ``task`` succeeds; raise ``RuntimeError`` otherwise."""
deadline = clock() + timeout
while True:
info = inventory.properties(task, ["info.state", "info.error"])
if not info:
raise RuntimeError("the vSphere task disappeared before it finished")
state = info.get("info.state")
if state == "success":
return
if state == "error":
raise RuntimeError(fault_message(info.get("info.error") or {}))
if clock() >= deadline:
raise RuntimeError(f"the vSphere task did not finish within {timeout:g}s")
sleep(_POLL_SECONDS)
def wait_until(
check: Callable[[], bool],
timeout: float,
what: str,
*,
clock: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> None:
"""Poll ``check`` until it is true; for operations vSphere runs without a task.
``ShutdownGuest`` only *asks* VMware Tools to shut the guest down and
returns at once, so completion has to be observed from the power state.
"""
deadline = clock() + timeout
while not check():
if clock() >= deadline:
raise RuntimeError(f"{what} did not finish within {timeout:g}s")
sleep(_POLL_SECONDS)
+109
View File
@@ -0,0 +1,109 @@
"""Power and snapshot operations (the write half of the HypervisorDriver contract)."""
from __future__ import annotations
from typing import Any
from pyVmomi import vim
from napalm_vmware._tasks import invoke, wait_for_task, wait_until
from napalm_vmware.parse.snapshots import find_snapshot, snapshot_list
_TASK_TIMEOUT = 300
_GUEST_SHUTDOWN_TIMEOUT = 300
_RUNNING = "poweredOn"
_TOOLS_RUNNING = "guestToolsRunning"
class VmwareActionsMixin:
"""Mixed into both drivers ahead of :class:`VmwareBaseDriver`."""
_inventory: Any
_find_vm: Any
_mo: Any
def _vm_mo(self, vm: dict[str, Any]) -> Any:
return self._mo(vim.VirtualMachine, vm["_moref"])
def _run(self, call: Any, *args: Any, **kwargs: Any) -> None:
wait_for_task(self._inventory, invoke(call, *args, **kwargs), _TASK_TIMEOUT)
@staticmethod
def _require_running(vm: dict[str, Any], action: str) -> None:
if vm.get("runtime.powerState") != _RUNNING:
raise RuntimeError(f"Cannot {action} VM {vm.get('name')!r}: it is not running")
@staticmethod
def _require_tools(vm: dict[str, Any], action: str) -> None:
if vm.get("guest.toolsRunningStatus") != _TOOLS_RUNNING:
raise RuntimeError(
f"A graceful {action} of {vm.get('name')!r} needs VMware Tools running "
"in the guest; use force to cut power instead"
)
# -- power ---------------------------------------------------------------
def start_vm(self, name: str) -> None:
self._run(self._vm_mo(self._find_vm(name)).PowerOnVM_Task)
def stop_vm(self, name: str, force: bool = False) -> None:
vm = self._find_vm(name)
self._require_running(vm, "stop")
mo = self._vm_mo(vm)
if force:
self._run(mo.PowerOffVM_Task)
return
self._require_tools(vm, "shutdown")
invoke(mo.ShutdownGuest)
wait_until(
lambda: (
self._inventory.properties(mo, ["runtime.powerState"]).get("runtime.powerState")
== "poweredOff"
),
_GUEST_SHUTDOWN_TIMEOUT,
f"guest shutdown of {vm.get('name')!r}",
)
def reboot_vm(self, name: str, force: bool = False) -> None:
vm = self._find_vm(name)
self._require_running(vm, "reboot")
mo = self._vm_mo(vm)
if force:
self._run(mo.ResetVM_Task)
return
# RebootGuest returns once Tools accepted the request; the guest gives
# no signal when it is back, so there is nothing further to wait for.
self._require_tools(vm, "reboot")
invoke(mo.RebootGuest)
def suspend_vm(self, name: str) -> None:
vm = self._find_vm(name)
self._require_running(vm, "suspend")
self._run(self._vm_mo(vm).SuspendVM_Task)
# -- snapshots -------------------------------------------------------------
def create_vm_snapshot(
self, name: str, snapshot: str, description: str = "", include_memory: bool = False
) -> None:
vm = self._find_vm(name)
if any(s["name"] == snapshot for s in snapshot_list(vm)):
raise ValueError(f"VM {vm.get('name')!r} already has a snapshot named {snapshot!r}")
self._run(
self._vm_mo(vm).CreateSnapshot_Task,
name=snapshot,
description=description,
memory=include_memory and vm.get("runtime.powerState") == _RUNNING,
quiesce=False,
)
def _snapshot_mo(self, name: str, snapshot: str) -> Any:
return self._mo(vim.vm.Snapshot, find_snapshot(self._find_vm(name), snapshot))
def delete_vm_snapshot(self, name: str, snapshot: str) -> None:
# removeChildren=False consolidates the snapshot into its children
# instead of discarding them.
self._run(self._snapshot_mo(name, snapshot).RemoveSnapshot_Task, removeChildren=False)
def rollback_vm_snapshot(self, name: str, snapshot: str) -> None:
self._run(self._snapshot_mo(name, snapshot).RevertToSnapshot_Task)
+154
View File
@@ -0,0 +1,154 @@
"""What the ESXi and vCenter drivers share: session, VM lookup, common getters."""
from __future__ import annotations
from typing import Any, ClassVar
from napalm.base.exceptions import ConnectionException
from napalm_device_types import HypervisorDriver
from napalm_device_types.models import (
SnapshotDict,
StorageVolumeDict,
VirtualNetworkDict,
VMConfigDict,
)
from napalm_vmware import _session, paths
from napalm_vmware._inventory import Inventory
from napalm_vmware.parse.environment import environment
from napalm_vmware.parse.networks import NetworkIndex, network_index, virtual_networks
from napalm_vmware.parse.snapshots import snapshot_list
from napalm_vmware.parse.storage import storage_pools
from napalm_vmware.parse.vm_config import vm_config
from napalm_vmware.parse.vms import vm_list
from napalm_vmware.parse.warnings import host_warnings
_DEFAULT_PORT = 443
#: ``about.apiType`` -> the driver that handles it, for a helpful refusal.
_DRIVER_FOR_API = {"HostAgent": "vmware_esxi", "VirtualCenter": "vmware_vcenter"}
class VmwareBaseDriver(HypervisorDriver):
"""Shared implementation. Concrete drivers set ``API_TYPE`` and ``STANDALONE``."""
VENDOR = "VMware"
USES_SSH = False
platform = "vmware"
#: ``about.apiType`` this driver accepts.
API_TYPE: ClassVar[str] = ""
#: True when the driver talks to one host directly (ESXi), False for vCenter.
STANDALONE: ClassVar[bool] = True
def __init__(
self,
hostname: str,
username: str,
password: str,
timeout: int = 60,
optional_args: dict[str, Any] | None = None,
) -> None:
self.hostname = hostname
self.username = username
self.password = password
self.timeout = timeout
args = optional_args or {}
self._port = int(args.get("port") or _DEFAULT_PORT)
self._verify_ssl = bool(args.get("verify_ssl", args.get("ssl_verify", True)))
self._si: Any = None
self._inventory: Any = None
# -- session -------------------------------------------------------------
def open(self) -> None:
try:
si = _session.connect(
self.hostname,
self._port,
self.username,
self.password,
verify_ssl=self._verify_ssl,
timeout=self.timeout,
)
except Exception as exc:
raise ConnectionException(f"Cannot connect to {self.hostname}: {exc}") from exc
inventory = Inventory(si.RetrieveContent())
about = inventory.about()
if about.get("apiType") != self.API_TYPE:
_session.disconnect(si)
other = _DRIVER_FOR_API.get(about.get("apiType", ""), "another driver")
raise ConnectionException(
f"{self.hostname} is {about.get('fullName', 'not a supported VMware endpoint')}"
f"; use the {other} driver for it"
)
self._si, self._inventory = si, inventory
def close(self) -> None:
if self._si is not None:
_session.disconnect(self._si)
self._si = self._inventory = None
def is_alive(self) -> dict[str, bool]:
return {"is_alive": self._si is not None and _session.alive(self._si)}
def _mo(self, vim_type: Any, moref: str) -> Any:
"""A live managed-object reference for a MoRef id from a plain row."""
return vim_type(moref, self._si._stub)
# -- inventory reads -------------------------------------------------------
def _hosts(self) -> list[dict[str, Any]]:
return self._inventory.collect("HostSystem", paths.HOST)
def _vm_rows(self) -> list[dict[str, Any]]:
return self._inventory.collect("VirtualMachine", paths.VM)
def _index(self, hosts: list[dict[str, Any]]) -> NetworkIndex:
dv_portgroups = self._inventory.collect("DistributedVirtualPortgroup", paths.DV_PORTGROUP)
return network_index(hosts, dv_portgroups)
def _find_vm(self, name: str) -> dict[str, Any]:
"""The VM whose instance UUID, MoRef or name is ``name``."""
vms = [
vm
for vm in self._vm_rows()
if vm.get("config.instanceUuid") and not vm.get("config.template")
]
for key in ("config.instanceUuid", "_moref"):
match = [vm for vm in vms if vm.get(key) == name]
if match:
return match[0]
match = [vm for vm in vms if vm.get("name") == name]
if len(match) > 1:
raise ValueError(f"{len(match)} VMs are named {name!r}; address one by its vmid")
if not match:
raise ValueError(f"There is no VM named or identified by {name!r}")
return match[0]
# -- HypervisorDriver ------------------------------------------------------
def get_vms(self) -> list[dict[str, Any]]:
hosts = self._hosts()
return vm_list(self._vm_rows(), hosts, self._index(hosts))
def get_vm_config(self, name: str) -> VMConfigDict:
return vm_config(self._find_vm(name), self._index(self._hosts()))
def get_vm_snapshots(self, name: str) -> list[SnapshotDict]:
return snapshot_list(self._find_vm(name))
def get_vm_storage_pools(self) -> dict[str, StorageVolumeDict]:
return storage_pools(self._inventory.collect("Datastore", paths.DATASTORE))
def get_virtual_networks(self) -> dict[str, VirtualNetworkDict]:
return virtual_networks( # type: ignore[return-value]
self._hosts(),
self._inventory.collect("DistributedVirtualPortgroup", paths.DV_PORTGROUP),
self._inventory.collect("DistributedVirtualSwitch", paths.DV_SWITCH),
)
def get_environment(self) -> dict[str, Any]:
return environment(self._hosts())
def get_device_warnings(self) -> list[dict[str, Any]]:
return host_warnings(self._hosts(), self._inventory.licenses(), standalone=self.STANDALONE)
+58
View File
@@ -0,0 +1,58 @@
"""NAPALM driver for a VMware ESXi host, addressed directly."""
from __future__ import annotations
from typing import Any
from napalm_device_types import FingerprintRule
from pyVmomi import vim
from napalm_vmware._plain import to_plain
from napalm_vmware._tasks import invoke
from napalm_vmware.actions import VmwareActionsMixin
from napalm_vmware.base import VmwareBaseDriver
from napalm_vmware.parse.facts import esxi_facts
from napalm_vmware.parse.interfaces import host_interfaces, host_interfaces_ip
from napalm_vmware.parse.lldp import lldp_neighbors
class VmwareEsxiDriver(VmwareActionsMixin, VmwareBaseDriver):
"""One ESXi host. Its VMs, vmnics, vmkernel NICs, datastores and port groups."""
DRIVER_NAME = "vmware_esxi"
API_TYPE = "HostAgent"
STANDALONE = True
# A host reboot runs through hardware POST before ESXi starts its services.
REBOOT_SETTLE_SECONDS = 300
# Unverified against real hardware: the Host Client landing page says
# "VMware ESXi"; a vCenter's does not, and it says "vCenter".
HTTP_FINGERPRINT = [
FingerprintRule("vmware esxi", weight=9.0, mandatory=True),
FingerprintRule("vcenter", weight=9.0, negative=True),
]
def _host(self) -> dict[str, Any]:
hosts = self._hosts()
if not hosts:
raise RuntimeError(f"{self.hostname} shows no host in its inventory")
return hosts[0]
def get_facts(self) -> dict[str, Any]:
return esxi_facts(self._inventory.about(), self._host())
def get_interfaces(self) -> dict[str, dict[str, Any]]:
return host_interfaces(self._host())
def get_interfaces_ip(self) -> dict[str, dict[str, Any]]:
return host_interfaces_ip(self._host())
def get_lldp_neighbors(self) -> dict[str, list[dict[str, str]]]:
network_system = self._host().get("configManager.networkSystem")
if not network_system:
return {}
mo = self._mo(vim.host.NetworkSystem, network_system)
try:
hints = invoke(mo.QueryNetworkHint)
except RuntimeError:
return {}
return lldp_neighbors(to_plain(hints) or [])
View File
+65
View File
@@ -0,0 +1,65 @@
"""NAPALM ``get_environment`` from host quick stats and hardware sensors."""
from __future__ import annotations
from typing import Any
_SENSORS = "runtime.healthSystemRuntime.systemHealthInfo.numericSensorInfo"
_MB = 1024 * 1024
def _reading(sensor: dict[str, Any]) -> float:
return float(sensor.get("currentReading", 0)) * 10 ** int(sensor.get("unitModifier", 0))
def _health(sensor: dict[str, Any]) -> str:
return (sensor.get("healthState") or {}).get("key", "unknown")
def _cpu_percent(host: dict[str, Any]) -> float:
hardware = host.get("summary.hardware") or {}
capacity = int(hardware.get("cpuMhz") or 0) * int(hardware.get("numCpuCores") or 0)
used = (host.get("summary.quickStats") or {}).get("overallCpuUsage") or 0
return round(used / capacity * 100, 2) if capacity else 0.0
def _add_sensors(env: dict[str, Any], host: dict[str, Any], prefix: str) -> None:
for sensor in host.get(_SENSORS) or []:
name = f"{prefix}{sensor.get('name', '')}"
kind = sensor.get("sensorType")
health = _health(sensor)
if kind == "temperature":
env["temperature"][name] = {
"temperature": _reading(sensor),
"is_alert": health in ("yellow", "red"),
"is_critical": health == "red",
}
elif kind == "fan":
env["fans"][name] = {"status": health == "green"}
elif kind == "power":
env["power"][name] = {
"status": health == "green",
"capacity": -1.0,
"output": _reading(sensor),
}
def environment(hosts: list[dict[str, Any]]) -> dict[str, Any]:
"""One host: keyed like a single device. Several (vCenter): keyed by host."""
env: dict[str, Any] = {
"fans": {},
"temperature": {},
"power": {},
"cpu": {},
"memory": {"available_ram": 0, "used_ram": 0},
}
single = len(hosts) == 1
for host in hosts:
key = "0" if single else host.get("name", host["_moref"])
env["cpu"][key] = {"%usage": _cpu_percent(host)}
hardware = host.get("summary.hardware") or {}
stats = host.get("summary.quickStats") or {}
env["memory"]["available_ram"] += int(hardware.get("memorySize") or 0)
env["memory"]["used_ram"] += int(stats.get("overallMemoryUsage") or 0) * _MB
_add_sensors(env, host, "" if single else f"{key}/")
return env
+94
View File
@@ -0,0 +1,94 @@
"""NAPALM ``get_facts`` from a host's properties or a vCenter's ``about``."""
from __future__ import annotations
import ipaddress
from typing import Any
#: otherIdentifyingInfo keys that carry a chassis serial, most specific first.
#: Dell reports its service tag as ``ServiceTag``; HPE and Supermicro use
#: ``SerialNumberTag``; blades add ``EnclosureSerialNumberTag`` for the chassis.
_SERIAL_KEYS = ("SerialNumberTag", "EnclosureSerialNumberTag", "ServiceTag")
def host_serial(host: dict[str, Any]) -> str:
"""The host's hardware serial, or ``""`` when it does not report one."""
info = host.get("hardware.systemInfo") or {}
if str(info.get("serialNumber", "")).strip():
return str(info["serialNumber"]).strip()
for source in (info, host.get("summary.hardware") or {}):
by_key: dict[str, str] = {}
for entry in source.get("otherIdentifyingInfo", []):
value = str(entry.get("identifierValue", "")).strip()
key = (entry.get("identifierType") or {}).get("key", "")
if value and key not in by_key:
by_key[key] = value
for key in _SERIAL_KEYS:
if key in by_key:
return by_key[key]
return ""
def _host_names(host: dict[str, Any]) -> tuple[str, str]:
dns = host.get("config.network.dnsConfig") or {}
short = dns.get("hostName") or ""
domain = dns.get("domainName") or ""
if short:
return short, f"{short}.{domain}" if domain else short
name = host.get("name", "")
return name.split(".", 1)[0], name
def host_interface_names(host: dict[str, Any]) -> list[str]:
nics = (host.get("config.network.pnic") or []) + (host.get("config.network.vnic") or [])
return sorted(n["device"] for n in nics if n.get("device"))
def esxi_facts(about: dict[str, Any], host: dict[str, Any]) -> dict[str, Any]:
"""Facts of a standalone ESXi host (or one reached directly)."""
hostname, fqdn = _host_names(host)
# hardware.systemInfo is read straight from SMBIOS; summary.hardware is a
# cached copy that has been seen to disagree with it.
system = host.get("hardware.systemInfo") or {}
summary = host.get("summary.hardware") or {}
product = host.get("config.product") or about
uptime = (host.get("summary.quickStats") or {}).get("uptime")
return {
"hostname": hostname,
"fqdn": fqdn,
"vendor": system.get("vendor") or summary.get("vendor", ""),
"model": system.get("model") or summary.get("model", ""),
"serial_number": host_serial(host),
"os_version": product.get("fullName", ""),
"uptime": float(uptime) if uptime is not None else -1.0,
"interface_list": host_interface_names(host),
}
def _is_ip(value: str) -> bool:
try:
ipaddress.ip_address(value)
except ValueError:
return False
return True
def vcenter_facts(about: dict[str, Any], address: str) -> dict[str, Any]:
"""Facts of a vCenter Server.
The vSphere API does not expose the appliance's own name, uptime or NICs;
those belong to the appliance management API. The address netOrk used to
connect is the best available name, and the instance UUID is the one
identifier that is stable for the lifetime of the installation.
"""
hostname = address if _is_ip(address) else address.split(".", 1)[0]
return {
"hostname": hostname,
"fqdn": address,
"vendor": about.get("vendor", ""),
"model": about.get("name", ""),
"serial_number": about.get("instanceUuid", ""),
"os_version": about.get("fullName", ""),
"uptime": -1.0,
"interface_list": [],
}
+87
View File
@@ -0,0 +1,87 @@
"""NAPALM interface getters for an ESXi host.
A host has two kinds of interface worth reporting: physical uplinks
(``vmnicN``) and VMkernel adapters (``vmkN``), which carry the host's own IP
addresses. Port groups are not interfaces; they are reported by
``get_virtual_networks``.
"""
from __future__ import annotations
import ipaddress
from typing import Any
_DEFAULT_MTU = 1500
_UNASSIGNED = {"", "0.0.0.0"}
def _uplink_mtus(host: dict[str, Any]) -> dict[str, int]:
"""pnic key -> MTU of the standard vSwitch that uses it as an uplink."""
mtus: dict[str, int] = {}
for vswitch in host.get("config.network.vswitch") or []:
for pnic_key in vswitch.get("pnic", []):
mtus[pnic_key] = int(vswitch.get("mtu") or _DEFAULT_MTU)
return mtus
def _iface(up: bool, description: str, speed: float, mtu: int, mac: str) -> dict[str, Any]:
return {
"is_up": up,
"is_enabled": True,
"description": description,
"last_flapped": -1.0,
"speed": speed,
"mtu": mtu,
"mac_address": mac.upper(),
}
def host_interfaces(host: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""vmnics and vmks in the shape of NAPALM's ``get_interfaces``."""
mtus = _uplink_mtus(host)
result: dict[str, dict[str, Any]] = {}
for pnic in host.get("config.network.pnic") or []:
link = pnic.get("linkSpeed") or {}
description = " ".join(p for p in (pnic.get("driver"), pnic.get("pci")) if p)
result[pnic["device"]] = _iface(
up=bool(link),
description=description,
speed=float(link.get("speedMb", 0)),
mtu=mtus.get(pnic.get("key", ""), _DEFAULT_MTU),
mac=pnic.get("mac", ""),
)
for vnic in host.get("config.network.vnic") or []:
spec = vnic.get("spec") or {}
result[vnic["device"]] = _iface(
up=True,
description=vnic.get("portgroup") or spec.get("portgroup", ""),
speed=0.0,
mtu=int(spec.get("mtu") or _DEFAULT_MTU),
mac=spec.get("mac", ""),
)
return result
def _ipv4(ip: dict[str, Any]) -> dict[str, dict[str, int]]:
address = ip.get("ipAddress", "")
if address in _UNASSIGNED:
return {}
network = ipaddress.IPv4Network(f"0.0.0.0/{ip.get('subnetMask') or '32'}")
return {address: {"prefix_length": network.prefixlen}}
def _ipv6(ip: dict[str, Any]) -> dict[str, dict[str, int]]:
entries = (ip.get("ipV6Config") or {}).get("ipV6Address", [])
return {e["ipAddress"]: {"prefix_length": int(e.get("prefixLength", 128))} for e in entries}
def host_interfaces_ip(host: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Addresses of the VMkernel adapters, as NAPALM's ``get_interfaces_ip``."""
result: dict[str, dict[str, Any]] = {}
for vnic in host.get("config.network.vnic") or []:
ip = (vnic.get("spec") or {}).get("ip") or {}
families = {"ipv4": _ipv4(ip), "ipv6": _ipv6(ip)}
families = {k: v for k, v in families.items() if v}
if families:
result[vnic["device"]] = families
return result
+33
View File
@@ -0,0 +1,33 @@
"""NAPALM ``get_lldp_neighbors`` from ``HostNetworkSystem.QueryNetworkHint``.
ESXi listens for CDP on standard vSwitches by default and for LLDP where a
vDS enables it. Both answers come back per vmnic in one hint; LLDP is
preferred because it names the switch port the way the switch itself does.
"""
from __future__ import annotations
from typing import Any
def _lldp(info: dict[str, Any]) -> dict[str, str] | None:
if not info.get("portId"):
return None
params = {p.get("key"): p.get("value") for p in info.get("parameter", [])}
hostname = params.get("System Name") or info.get("chassisId", "")
return {"hostname": str(hostname), "port": str(info["portId"])}
def _cdp(info: dict[str, Any]) -> dict[str, str] | None:
if not info.get("portId"):
return None
return {"hostname": str(info.get("devId", "")), "port": str(info["portId"])}
def lldp_neighbors(hints: list[dict[str, Any]]) -> dict[str, list[dict[str, str]]]:
result: dict[str, list[dict[str, str]]] = {}
for hint in hints:
neighbor = _lldp(hint.get("lldpInfo") or {}) or _cdp(hint.get("connectedSwitchPort") or {})
if neighbor:
result[hint["device"]] = [neighbor]
return result
+106
View File
@@ -0,0 +1,106 @@
"""Port groups: what exists, which VLAN each carries, and which one a NIC uses.
Two kinds of network can back a VM NIC. A *standard* port group lives on one
host's vSwitch and is named in the NIC's backing directly. A *distributed*
port group lives on a vCenter-managed vDS, and the NIC names it by key
(``dvportgroup-12``) -- so its display name and VLAN have to be looked up.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
#: vCenter's default name for a vDS's uplink port group, used when the server
#: is too old to report ``config.uplink``.
_UPLINK_NAME = re.compile(r"-DVUplinks-\d+$")
@dataclass
class NetworkIndex:
"""Port group name -> VLAN, and dvPortgroup key -> (name, VLAN)."""
standard: dict[str, int] = field(default_factory=dict)
distributed: dict[str, tuple[str, int]] = field(default_factory=dict)
def dv_vlan(port_config: dict[str, Any] | None) -> int:
"""The single VLAN of a dvPortgroup, or 0 for untagged/trunk."""
vlan = (port_config or {}).get("vlan") or {}
kind = vlan.get("_type", "")
if kind.endswith("PvlanSpec"):
return int(vlan.get("pvlanId", 0))
if kind.endswith("VlanIdSpec"):
return int(vlan.get("vlanId", 0))
return 0
def _standard_portgroups(hosts: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
pg.get("spec") or {} for host in hosts for pg in host.get("config.network.portgroup") or []
]
def network_index(hosts: list[dict[str, Any]], dv_portgroups: list[dict[str, Any]]) -> NetworkIndex:
index = NetworkIndex()
for spec in _standard_portgroups(hosts):
index.standard.setdefault(spec.get("name", ""), int(spec.get("vlanId", 0)))
for pg in dv_portgroups:
vlan = dv_vlan(pg.get("config.defaultPortConfig"))
index.distributed[pg["_moref"]] = (pg.get("name", pg["_moref"]), vlan)
return index
def nic_network(nic: dict[str, Any], index: NetworkIndex) -> tuple[str, int]:
"""``(network name, VLAN)`` a virtual NIC is connected to."""
backing = nic.get("backing") or {}
kind = backing.get("_type", "")
if kind.endswith("DistributedVirtualPortBackingInfo"):
key = (backing.get("port") or {}).get("portgroupKey", "")
return index.distributed.get(key, (key, 0))
if kind.endswith("OpaqueNetworkBackingInfo"):
return backing.get("opaqueNetworkId", ""), 0
name = backing.get("deviceName", "")
return name, index.standard.get(name, 0)
def _network(name: str, kind: str, bridge: str, vlan: int) -> dict[str, Any]:
return {
"name": name,
"type": kind,
"bridge": bridge,
"vlan_id": vlan,
"autostart": True,
"active": True,
}
def _is_uplink(pg: dict[str, Any]) -> bool:
if "config.uplink" in pg:
return bool(pg["config.uplink"])
return bool(_UPLINK_NAME.search(pg.get("name", "")))
def virtual_networks(
hosts: list[dict[str, Any]],
dv_portgroups: list[dict[str, Any]],
dv_switches: list[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
"""Every network a VM can attach to, as ``get_virtual_networks`` returns it."""
result: dict[str, dict[str, Any]] = {}
for spec in _standard_portgroups(hosts):
name = spec.get("name", "")
result.setdefault(
name,
_network(name, "portgroup", spec.get("vswitchName", ""), int(spec.get("vlanId", 0))),
)
switch_names = {s["_moref"]: s.get("name", s["_moref"]) for s in dv_switches}
for pg in dv_portgroups:
if _is_uplink(pg):
continue
dvs = pg.get("config.distributedVirtualSwitch", "")
vlan = dv_vlan(pg.get("config.defaultPortConfig"))
name = pg.get("name", pg["_moref"])
result[name] = _network(name, "dvportgroup", switch_names.get(dvs, dvs), vlan)
return result
+52
View File
@@ -0,0 +1,52 @@
"""A VM's snapshot tree, flattened, and snapshot lookup by name."""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
from napalm_device_types.models import SnapshotDict
def _walk(nodes: list[dict[str, Any]], parent: str) -> Iterator[tuple[dict[str, Any], str]]:
for node in nodes:
yield node, parent
yield from _walk(node.get("childSnapshotList", []), node.get("name", ""))
def _nodes(vm: dict[str, Any]) -> Iterator[tuple[dict[str, Any], str]]:
return _walk((vm.get("snapshot") or {}).get("rootSnapshotList", []), "")
def snapshot_list(vm: dict[str, Any]) -> list[SnapshotDict]:
"""Every snapshot, depth first, each naming its parent (``""`` for a root)."""
return [
{
"name": node.get("name", ""),
"vm": vm.get("name", ""),
"created": float(node.get("createTime", 0.0)),
"description": node.get("description", ""),
# A snapshot taken with memory records the VM as powered on.
"has_memory": node.get("state") == "poweredOn",
"parent": parent,
}
for node, parent in _nodes(vm)
]
def find_snapshot(vm: dict[str, Any], name: str) -> str:
"""MoRef of the one snapshot called ``name``.
Raises ``ValueError`` when there is none -- or more than one: vSphere does
not require snapshot names to be unique, and choosing one of two
candidates could delete or roll back to the wrong state.
"""
matches = [node["snapshot"] for node, _ in _nodes(vm) if node.get("name") == name]
if not matches:
raise ValueError(f"VM {vm.get('name')!r} has no snapshot named {name!r}")
if len(matches) > 1:
raise ValueError(
f"VM {vm.get('name')!r} has {len(matches)} snapshots named {name!r}; "
"rename one in vSphere first"
)
return matches[0]
+27
View File
@@ -0,0 +1,27 @@
"""``get_vm_storage_pools``: datastores."""
from __future__ import annotations
from typing import Any
from napalm_device_types.models import StorageVolumeDict
def storage_pools(datastores: list[dict[str, Any]]) -> dict[str, StorageVolumeDict]:
"""Datastores keyed by name. ``enabled`` means currently accessible."""
result: dict[str, StorageVolumeDict] = {}
for ds in datastores:
summary = ds.get("summary") or {}
name = ds.get("name") or summary.get("name", ds["_moref"])
total = int(summary.get("capacity") or 0)
free = int(summary.get("freeSpace") or 0)
result[name] = {
"name": name,
"type": str(summary.get("type", "")).lower(),
"total": total,
"used": total - free,
"available": free,
"enabled": bool(summary.get("accessible", False)),
"shared": bool(summary.get("multipleHostAccess", False)),
}
return result
+96
View File
@@ -0,0 +1,96 @@
"""``get_vm_config``: a VM's virtual hardware as a VMConfigDict."""
from __future__ import annotations
from typing import Any
from napalm_device_types.models import VMConfigDict, VMDiskDict, VMNICDict
from napalm_vmware.parse import vm_devices as dev
from napalm_vmware.parse.networks import NetworkIndex, nic_network
_GB = 1024**3
_BOOT_KIND = {
"VirtualMachineBootOptionsBootableCdromDevice": "cdrom",
"VirtualMachineBootOptionsBootableFloppyDevice": "floppy",
}
def _boot_order(vm: dict[str, Any], labels: dict[int, str]) -> list[str]:
order: list[str] = []
for entry in vm.get("config.bootOptions.bootOrder") or []:
kind = entry.get("_type", "")
if kind in _BOOT_KIND:
order.append(_BOOT_KIND[kind])
elif "deviceKey" in entry:
order.append(labels.get(entry["deviceKey"], str(entry["deviceKey"])))
return order
def _disks(vm: dict[str, Any], boot_order: list[str]) -> list[VMDiskDict]:
disks = [d for d in dev.devices(vm) if dev.is_disk(d)]
result: list[VMDiskDict] = []
for position, disk in enumerate(disks):
name = dev.label(disk)
# Without an explicit boot order the firmware boots the first disk.
bootable = name in boot_order if boot_order else position == 0
result.append(
{
"device": name,
"storage": dev.disk_datastore(disk),
"size": dev.disk_bytes(disk) // _GB,
"format": "vmdk",
"bootable": bootable,
}
)
return result
def _nics(vm: dict[str, Any], index: NetworkIndex) -> list[VMNICDict]:
result: list[VMNICDict] = []
for nic in filter(dev.is_nic, dev.devices(vm)):
network, vlan = nic_network(nic, index)
result.append(
{
"device": dev.label(nic),
"mac": nic.get("macAddress", "").upper(),
"model": dev.nic_model(nic),
"bridge": network,
"vlan_id": vlan,
}
)
return result
def vm_config(vm: dict[str, Any], index: NetworkIndex) -> VMConfigDict:
"""The VM's hardware. Raises ``ValueError`` for a VM without a config."""
if not vm.get("config.instanceUuid"):
raise ValueError(f"VM {vm.get('name', vm.get('_moref'))!r} has no readable configuration")
labels = {d["key"]: dev.label(d) for d in dev.devices(vm) if "key" in d}
boot_order = _boot_order(vm, labels)
vcpus = int(vm.get("config.hardware.numCPU") or 0)
cores = int(vm.get("config.hardware.numCoresPerSocket") or 1)
result: VMConfigDict = {
"name": vm.get("name", ""),
"vmid": vm["config.instanceUuid"],
"vcpus": vcpus,
"memory": int(vm.get("config.hardware.memoryMB") or 0),
"os_type": vm.get("config.guestId", ""),
"boot_order": boot_order,
"disks": _disks(vm, boot_order),
"nics": _nics(vm, index),
"description": vm.get("config.annotation", ""),
"tags": [],
"os_name": vm.get("config.guestFullName", ""),
"sockets": max(vcpus // cores, 1),
"cores_per_socket": cores,
"firmware": vm.get("config.firmware", "bios"),
"passthrough": [
{"slot": dev.label(d), "kind": kind, "config": dev.summary(d)}
for d in dev.devices(vm)
if (kind := dev.passthrough_kind(d))
],
}
if vm.get("config.version"):
result["machine"] = vm["config.version"]
return result
+65
View File
@@ -0,0 +1,65 @@
"""Classify and read entries of a VM's ``config.hardware.device`` list."""
from __future__ import annotations
import re
from typing import Any
_NIC_TYPES = frozenset(
{
"VirtualE1000",
"VirtualE1000e",
"VirtualPCNet32",
"VirtualVmxnet",
"VirtualVmxnet2",
"VirtualVmxnet3",
"VirtualVmxnet3Vrdma",
"VirtualSriovEthernetCard",
}
)
_PASSTHROUGH_KIND = {"VirtualPCIPassthrough": "pci", "VirtualUSB": "usb"}
_DATASTORE_IN_PATH = re.compile(r"^\[([^\]]+)\]")
def devices(vm: dict[str, Any]) -> list[dict[str, Any]]:
return vm.get("config.hardware.device") or []
def is_nic(device: dict[str, Any]) -> bool:
return device.get("_type") in _NIC_TYPES
def is_disk(device: dict[str, Any]) -> bool:
return device.get("_type") == "VirtualDisk"
def passthrough_kind(device: dict[str, Any]) -> str | None:
return _PASSTHROUGH_KIND.get(device.get("_type", ""))
def label(device: dict[str, Any]) -> str:
"""The name vSphere shows for a device (``"Hard disk 1"``), else its key."""
info = device.get("deviceInfo") or {}
return info.get("label") or str(device.get("key", ""))
def summary(device: dict[str, Any]) -> str:
return (device.get("deviceInfo") or {}).get("summary", "")
def nic_model(device: dict[str, Any]) -> str:
"""``"vmxnet3"``, ``"e1000e"`` ... -- the type name without ``Virtual``."""
return device.get("_type", "").removeprefix("Virtual").lower()
def disk_bytes(device: dict[str, Any]) -> int:
if device.get("capacityInBytes"):
return int(device["capacityInBytes"])
return int(device.get("capacityInKB", 0)) * 1024
def disk_datastore(device: dict[str, Any]) -> str:
"""Datastore name from the backing file path ``"[datastore1] vm/vm.vmdk"``."""
path = (device.get("backing") or {}).get("fileName", "")
match = _DATASTORE_IN_PATH.match(path)
return match.group(1) if match else ""
+121
View File
@@ -0,0 +1,121 @@
"""``get_vms``: one VMDict per virtual machine, plus the keys netOrk reads.
Beyond the ``VMDict`` contract, netOrk's VM sync expects ``type``,
``interfaces`` (NAPALM shape plus ``ipv4``/``bridge``/``tag``), ``ipv4``,
``agent_enabled``/``agent_running`` (here: VMware Tools), ``disks`` and
``onboot``. ``moref`` is added so an operator can find the VM in vSphere.
``vmid`` is the VM's *instance* UUID: stable across vMotion and
re-registration, unique within a vCenter, and the key ``SearchIndex.FindByUuid``
looks VMs up by. The MoRef is none of those things.
"""
from __future__ import annotations
import ipaddress
from typing import Any
from napalm_vmware.parse import vm_devices as dev
from napalm_vmware.parse.networks import NetworkIndex, nic_network
_STATUS = {"poweredOn": "running", "poweredOff": "stopped", "suspended": "suspended"}
def _is_ipv4(address: str) -> bool:
try:
return isinstance(ipaddress.ip_address(address), ipaddress.IPv4Address)
except ValueError:
return False
def _cpu_usage(vm: dict[str, Any], host_mhz: int) -> float:
used = (vm.get("summary.quickStats") or {}).get("overallCpuUsage") or 0
capacity = int(vm.get("config.hardware.numCPU") or 0) * host_mhz
if not capacity:
return 0.0
return round(min(used / capacity, 1.0), 4)
def _interfaces(vm: dict[str, Any], index: NetworkIndex) -> dict[str, dict[str, Any]]:
guest_nics = {n.get("deviceConfigId"): n for n in vm.get("guest.net") or []}
result: dict[str, dict[str, Any]] = {}
for nic in filter(dev.is_nic, dev.devices(vm)):
network, vlan = nic_network(nic, index)
guest = guest_nics.get(nic.get("key"), {})
connected = guest.get("connected", (nic.get("connectable") or {}).get("connected", False))
ipv4 = next((a for a in guest.get("ipAddress", []) if _is_ipv4(a)), "")
result[dev.label(nic)] = {
"is_up": bool(connected),
"is_enabled": True,
"description": network,
"mac_address": nic.get("macAddress", "").upper(),
"speed": -1.0,
"mtu": 1500,
"last_flapped": -1.0,
"ipv4": ipv4,
"bridge": network,
"tag": vlan or None,
}
return result
def _autostart_vms(hosts: list[dict[str, Any]]) -> set[str]:
"""MoRefs of VMs the host's autostart manager will power on at boot."""
morefs: set[str] = set()
for host in hosts:
config = host.get("config.autoStart") or {}
if not (config.get("defaults") or {}).get("enabled"):
continue
for entry in config.get("powerInfo", []):
if str(entry.get("startAction", "")).lower() == "poweron":
morefs.add(entry.get("key", ""))
return morefs
def _vm(
vm: dict[str, Any], hosts: dict[str, dict[str, Any]], index: NetworkIndex, onboot: bool
) -> dict[str, Any]:
host = hosts.get(vm.get("runtime.host", ""), {})
stats = vm.get("summary.quickStats") or {}
interfaces = _interfaces(vm, index)
primary = vm.get("guest.ipAddress") or ""
ipv4 = (
primary
if _is_ipv4(primary)
else next((i["ipv4"] for i in interfaces.values() if i["ipv4"]), "")
)
return {
"name": vm.get("name", ""),
"vmid": vm["config.instanceUuid"],
"moref": vm["_moref"],
"type": "vm",
"status": _STATUS.get(vm.get("runtime.powerState", ""), "unknown"),
"vcpus": int(vm.get("config.hardware.numCPU") or 0),
"memory": int(vm.get("config.hardware.memoryMB") or 0),
"cpu_usage": _cpu_usage(vm, int((host.get("summary.hardware") or {}).get("cpuMhz") or 0)),
"memory_usage": int(stats.get("guestMemoryUsage") or 0),
"uptime": int(stats.get("uptimeSeconds") or 0),
"node": host.get("name", ""),
"interfaces": interfaces,
"ipv4": ipv4,
"agent_enabled": vm.get("guest.toolsStatus", "toolsNotInstalled") != "toolsNotInstalled",
"agent_running": vm.get("guest.toolsRunningStatus") == "guestToolsRunning",
"disks": [
{"name": dev.label(d), "size_mb": dev.disk_bytes(d) // (1024 * 1024)}
for d in filter(dev.is_disk, dev.devices(vm))
],
"onboot": onboot,
}
def vm_list(
vms: list[dict[str, Any]], hosts: list[dict[str, Any]], index: NetworkIndex
) -> list[dict[str, Any]]:
"""All VMs, templates and unidentifiable (config-less) VMs excluded."""
by_moref = {h["_moref"]: h for h in hosts}
autostart = _autostart_vms(hosts)
return [
_vm(vm, by_moref, index, vm["_moref"] in autostart)
for vm in vms
if vm.get("config.instanceUuid") and not vm.get("config.template")
]
+53
View File
@@ -0,0 +1,53 @@
"""Raw device warnings: ``{"code", "meta"}`` only.
What a code means to a user -- severity, wording, the fix -- is decided in
netOrk's ``WARNING_CATALOG``, never here.
"""
from __future__ import annotations
from typing import Any
_CONNECTED = "connected"
_UNMANAGED = {None, "", "0.0.0.0"}
#: License edition of the free vSphere Hypervisor. The vSphere API refuses
#: every write on it (power, snapshots) with a RestrictedVersion fault.
_FREE_EDITIONS = ("esxBasic",)
def _host_codes(host: dict[str, Any], standalone: bool) -> list[dict[str, Any]]:
name = host.get("name", host.get("_moref", ""))
found: list[dict[str, Any]] = []
state = host.get("runtime.connectionState", _CONNECTED)
if not standalone and state != _CONNECTED:
found.append({"code": "vmware_host_disconnected", "meta": {"host": name, "state": state}})
if host.get("runtime.inMaintenanceMode"):
found.append({"code": "vmware_host_maintenance_mode", "meta": {"host": name}})
issues = [
i["fullFormattedMessage"]
for i in host.get("configIssue") or []
if i.get("fullFormattedMessage")
]
if issues:
found.append({"code": "vmware_host_config_issue", "meta": {"host": name, "issues": issues}})
manager = host.get("summary.managementServerIp")
if standalone and manager not in _UNMANAGED:
found.append({"code": "vmware_host_managed_by_vcenter", "meta": {"vcenter": manager}})
return found
def host_warnings(
hosts: list[dict[str, Any]], licenses: list[dict[str, Any]], *, standalone: bool
) -> list[dict[str, Any]]:
"""Warnings for the hosts this driver sees.
``standalone`` is True for the ESXi driver. It alone reports a host being
managed by a vCenter -- a hint to add the vCenter instead -- and it never
reports disconnection, since a disconnected host could not have answered.
"""
found = [w for host in hosts for w in _host_codes(host, standalone)]
for lic in licenses:
if str(lic.get("editionKey", "")).startswith(_FREE_EDITIONS):
found.append({"code": "vmware_api_read_only", "meta": {"edition": lic.get("name", "")}})
break
return found
+76
View File
@@ -0,0 +1,76 @@
"""The vSphere property paths the drivers read, per managed object type.
Single source of truth for both the drivers and ``tools/harvest.py``: a
fixture harvested from a real host contains exactly what the parsers are
later fed, no more and no less. Adding a path here is the first step of
adding a field to any getter.
"""
from __future__ import annotations
HOST = (
"name",
"summary.hardware",
"summary.quickStats",
"summary.managementServerIp",
"hardware.systemInfo",
"runtime.inMaintenanceMode",
"runtime.connectionState",
"runtime.healthSystemRuntime.systemHealthInfo.numericSensorInfo",
"config.product",
"config.network.pnic",
"config.network.vnic",
"config.network.portgroup",
"config.network.vswitch",
"config.network.dnsConfig",
"config.autoStart",
"configIssue",
"configManager.networkSystem",
)
VM = (
"name",
"config.instanceUuid",
"config.template",
"config.guestId",
"config.guestFullName",
"config.annotation",
"config.firmware",
"config.version",
"config.hardware.numCPU",
"config.hardware.numCoresPerSocket",
"config.hardware.memoryMB",
"config.hardware.device",
"config.bootOptions.bootOrder",
"runtime.powerState",
"runtime.host",
"summary.quickStats",
"guest.net",
"guest.ipAddress",
"guest.toolsStatus",
"guest.toolsRunningStatus",
"snapshot",
)
DATASTORE = (
"name",
"summary",
)
DV_PORTGROUP = (
"name",
"config.defaultPortConfig",
"config.distributedVirtualSwitch",
"config.uplink",
)
DV_SWITCH = ("name",)
#: Managed object type name -> paths, in the order harvest.py dumps them.
ALL = {
"HostSystem": HOST,
"VirtualMachine": VM,
"Datastore": DATASTORE,
"DistributedVirtualPortgroup": DV_PORTGROUP,
"DistributedVirtualSwitch": DV_SWITCH,
}
View File
+48
View File
@@ -0,0 +1,48 @@
"""NAPALM driver for VMware vCenter Server.
netOrk treats the vCenter as one device. The ESXi hosts behind it appear as
the ``node`` of each VM; a host can additionally be added with the
``vmware_esxi`` driver when its own NICs and sensors are of interest.
"""
from __future__ import annotations
from typing import Any
from napalm_device_types import FingerprintRule
from napalm_vmware.actions import VmwareActionsMixin
from napalm_vmware.base import VmwareBaseDriver
from napalm_vmware.parse.facts import vcenter_facts
class VmwareVcenterDriver(VmwareActionsMixin, VmwareBaseDriver):
"""A vCenter and every VM, datastore and port group it manages."""
DRIVER_NAME = "vmware_vcenter"
API_TYPE = "VirtualCenter"
STANDALONE = False
# The appliance starts several dozen services before the API answers.
REBOOT_SETTLE_SECONDS = 600
# Unverified against real hardware; see VmwareEsxiDriver.
HTTP_FINGERPRINT = [
FingerprintRule("vcenter", weight=9.0, mandatory=True),
FingerprintRule("vsphere", weight=3.0),
FingerprintRule("vmware esxi", weight=9.0, negative=True),
]
def get_facts(self) -> dict[str, Any]:
return vcenter_facts(self._inventory.about(), self.hostname)
# The hosts' NICs are not the vCenter's; reporting them here would attach
# their MAC addresses to the wrong device. The appliance's own NICs are
# only visible through its management API, which this driver does not use.
def get_interfaces(self) -> dict[str, dict[str, Any]]:
return {}
def get_interfaces_ip(self) -> dict[str, dict[str, Any]]:
return {}
def get_lldp_neighbors(self) -> dict[str, list[dict[str, str]]]:
return {}
+75
View File
@@ -0,0 +1,75 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "napalm-vmware"
version = "0.1.0"
description = "NAPALM drivers for VMware ESXi hosts and vCenter Server (vSphere API via pyVmomi)"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
authors = [
{ name = "Christian Manivong", email = "christian@manivong.de" },
]
keywords = ["napalm", "vmware", "esxi", "vcenter", "vsphere", "hypervisor", "driver"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: System :: Networking",
"Topic :: System :: Systems Administration",
"Typing :: Typed",
]
dependencies = [
"napalm>=5.0.0",
"napalm-device-types>=2.0.0",
"pyvmomi>=8.0.1",
]
[project.optional-dependencies]
dev = [
"pytest",
"pytest-cov",
"ruff",
"mypy",
]
[project.entry-points."napalm.drivers"]
vmware_esxi = "napalm_vmware:VmwareEsxiDriver"
vmware_vcenter = "napalm_vmware:VmwareVcenterDriver"
[project.urls]
Repository = "https://git.netork.io/NAPALM/napalm-vmware"
[tool.setuptools.packages.find]
where = ["."]
include = ["napalm_vmware*"]
[tool.setuptools.package-data]
napalm_vmware = ["py.typed"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"vcsim: runs against a live vcsim simulator; needs VCSIM_ESXI_PORT / VCSIM_VCENTER_PORT",
]
[tool.ruff]
line-length = 100
target-version = "py310"
# Same rule set the netOrk repo gates on.
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
[tool.mypy]
python_version = "3.10"
warn_unused_configs = true
ignore_missing_imports = true
View File
+68
View File
@@ -0,0 +1,68 @@
"""Shared fixtures: harvested inventories and a fake Inventory that serves them.
The JSON files under ``fixtures/`` were produced by ``tools/harvest.py``
against govmomi's vcsim simulator (``vmware/vcsim`` and ``vmware/vcsim -esx``).
They have the real vSphere shapes but simulator values: vcsim labels devices
``disk-202-0`` where a real host says ``Hard disk 1``, reports no sensors to
speak of, and sets ``managementServerIp`` to ``0.0.0.0``. Tests that need a
branch vcsim does not produce copy a fixture and edit it.
"""
from __future__ import annotations
import copy
import json
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import pytest
FIXTURES = Path(__file__).parent / "fixtures"
_CACHE: dict[str, dict[str, Any]] = {}
def load(name: str) -> dict[str, Any]:
"""A private deep copy of fixture ``name``; tests may mutate it freely."""
if name not in _CACHE:
_CACHE[name] = json.loads((FIXTURES / f"{name}.json").read_text())
return copy.deepcopy(_CACHE[name])
class FakeInventory:
"""Serves a harvest dump the way ``Inventory`` serves a live endpoint."""
def __init__(self, data: dict[str, Any]) -> None:
self.data = data
self.calls: list[tuple[str, tuple[str, ...]]] = []
def about(self) -> dict[str, Any]:
return self.data["about"]
def licenses(self) -> list[dict[str, Any]]:
return self.data.get("licenses", [])
def collect(self, type_name: str, paths: Sequence[str]) -> list[dict[str, Any]]:
self.calls.append((type_name, tuple(paths)))
rows = self.data["objects"].get(type_name, [])
wanted = set(paths) | {"_moref"}
return [{k: v for k, v in row.items() if k in wanted} for row in rows]
# Convenience for tests that tweak a fixture.
def objects(self, type_name: str) -> list[dict[str, Any]]:
return self.data["objects"][type_name]
@pytest.fixture
def esxi_data() -> dict[str, Any]:
return load("vcsim-esxi")
@pytest.fixture
def vcenter_data() -> dict[str, Any]:
return load("vcsim-vcenter")
@pytest.fixture
def esxi_host(esxi_data) -> dict[str, Any]:
return esxi_data["objects"]["HostSystem"][0]
+2392
View File
File diff suppressed because it is too large Load Diff
+8374
View File
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
"""Power and snapshot actions: which vSphere call each one makes, and refusals."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from napalm_vmware import VmwareEsxiDriver
from tests.conftest import FakeInventory
@pytest.fixture
def vm0(esxi_data):
return esxi_data["objects"]["VirtualMachine"][0]
@pytest.fixture
def driver(esxi_data):
d = VmwareEsxiDriver("esx01", "root", "secret")
d._si = MagicMock()
d._inventory = FakeInventory(esxi_data)
d.mo = MagicMock(name="vm")
d._mo = MagicMock(return_value=d.mo)
return d
@pytest.fixture
def waited():
with patch("napalm_vmware.actions.wait_for_task") as wait:
yield wait
class TestPower:
def test_start(self, driver, vm0, waited):
vm0["runtime.powerState"] = "poweredOff"
driver.start_vm(vm0["name"])
driver.mo.PowerOnVM_Task.assert_called_once_with()
assert waited.call_args.args[1] is driver.mo.PowerOnVM_Task.return_value
def test_forced_stop_powers_off(self, driver, vm0, waited):
driver.stop_vm(vm0["name"], force=True)
driver.mo.PowerOffVM_Task.assert_called_once_with()
def test_graceful_stop_asks_tools_and_waits_for_power_off(self, driver, vm0):
vm0["guest.toolsRunningStatus"] = "guestToolsRunning"
with patch("napalm_vmware.actions.wait_until") as until:
driver.stop_vm(vm0["name"])
driver.mo.ShutdownGuest.assert_called_once_with()
driver._inventory.data["objects"]["VirtualMachine"][0]["runtime.powerState"] = "poweredOff"
driver._inventory.properties = lambda obj, paths: {"runtime.powerState": "poweredOff"}
check = until.call_args.args[0]
assert check() is True
def test_graceful_stop_without_tools_is_refused(self, driver, vm0):
with pytest.raises(RuntimeError, match="VMware Tools"):
driver.stop_vm(vm0["name"])
driver.mo.ShutdownGuest.assert_not_called()
def test_stopping_a_stopped_vm_is_refused(self, driver, vm0):
vm0["runtime.powerState"] = "poweredOff"
with pytest.raises(RuntimeError, match="not running"):
driver.stop_vm(vm0["name"], force=True)
def test_graceful_reboot(self, driver, vm0):
vm0["guest.toolsRunningStatus"] = "guestToolsRunning"
driver.reboot_vm(vm0["name"])
driver.mo.RebootGuest.assert_called_once_with()
def test_forced_reboot_resets(self, driver, vm0, waited):
driver.reboot_vm(vm0["name"], force=True)
driver.mo.ResetVM_Task.assert_called_once_with()
def test_suspend(self, driver, vm0, waited):
driver.suspend_vm(vm0["name"])
driver.mo.SuspendVM_Task.assert_called_once_with()
def test_suspend_requires_a_running_vm(self, driver, vm0):
vm0["runtime.powerState"] = "suspended"
with pytest.raises(RuntimeError, match="not running"):
driver.suspend_vm(vm0["name"])
def test_free_license_refusal_is_explained(self, driver, vm0):
from pyVmomi import vim
vm0["runtime.powerState"] = "poweredOff"
driver.mo.PowerOnVM_Task.side_effect = vim.fault.RestrictedVersion(msg="prohibited")
with pytest.raises(RuntimeError, match="free vSphere Hypervisor"):
driver.start_vm(vm0["name"])
def test_unknown_vm(self, driver):
with pytest.raises(ValueError):
driver.start_vm("nope")
def _with_snapshot(vm, name="before-upgrade", moref="snapshot-1"):
vm["snapshot"] = {
"rootSnapshotList": [
{"name": name, "snapshot": moref, "createTime": 1.0, "state": "poweredOff"}
]
}
class TestSnapshots:
def test_create(self, driver, vm0, waited):
driver.create_vm_snapshot(vm0["name"], "pre", description="d", include_memory=True)
driver.mo.CreateSnapshot_Task.assert_called_once_with(
name="pre", description="d", memory=True, quiesce=False
)
def test_memory_is_only_captured_from_a_running_vm(self, driver, vm0, waited):
vm0["runtime.powerState"] = "poweredOff"
driver.create_vm_snapshot(vm0["name"], "pre", include_memory=True)
assert driver.mo.CreateSnapshot_Task.call_args.kwargs["memory"] is False
def test_create_refuses_a_duplicate_name(self, driver, vm0):
_with_snapshot(vm0, "pre")
with pytest.raises(ValueError, match="already"):
driver.create_vm_snapshot(vm0["name"], "pre")
def test_delete_keeps_the_children(self, driver, vm0, waited):
_with_snapshot(vm0)
driver.delete_vm_snapshot(vm0["name"], "before-upgrade")
assert driver._mo.call_args.args[1] == "snapshot-1"
driver.mo.RemoveSnapshot_Task.assert_called_once_with(removeChildren=False)
def test_rollback(self, driver, vm0, waited):
_with_snapshot(vm0)
driver.rollback_vm_snapshot(vm0["name"], "before-upgrade")
driver.mo.RevertToSnapshot_Task.assert_called_once_with()
def test_unknown_snapshot(self, driver, vm0):
with pytest.raises(ValueError, match="no snapshot"):
driver.rollback_vm_snapshot(vm0["name"], "nope")
+241
View File
@@ -0,0 +1,241 @@
"""The two driver classes: identity, connection handling and getters."""
from __future__ import annotations
from importlib.metadata import entry_points
from unittest.mock import MagicMock, patch
import pytest
from napalm.base.exceptions import ConnectionException
from napalm_device_types.roles import role_keys_of
from napalm_vmware import VmwareEsxiDriver, VmwareVcenterDriver
from tests.conftest import FakeInventory
DRIVERS = [VmwareEsxiDriver, VmwareVcenterDriver]
def _driver(cls, data, hostname="esx01.example.lan"):
driver = cls(hostname, "root", "secret")
driver._si = MagicMock()
driver._inventory = FakeInventory(data)
return driver
@pytest.fixture
def esxi(esxi_data):
return _driver(VmwareEsxiDriver, esxi_data)
@pytest.fixture
def vcenter(vcenter_data):
return _driver(VmwareVcenterDriver, vcenter_data, hostname="vc01.example.lan")
class TestIdentity:
@pytest.mark.parametrize("cls", DRIVERS)
def test_entry_point_name_is_the_driver_name(self, cls):
(ep,) = [e for e in entry_points(group="napalm.drivers") if e.name == cls.DRIVER_NAME]
assert ep.load() is cls
@pytest.mark.parametrize("cls", DRIVERS)
def test_is_a_hypervisor_and_nothing_else(self, cls):
assert role_keys_of(cls) == ["hypervisor"]
@pytest.mark.parametrize("cls", DRIVERS)
def test_talks_api_not_ssh(self, cls):
assert cls.USES_SSH is False
@pytest.mark.parametrize("cls", DRIVERS)
def test_never_fingerprints_on_the_vmware_oui(self, cls):
"""00:50:56 is the OUI of *guest* NICs; every VM would score as a host."""
assert not any(p.startswith("00:50:56") for p in cls.OUI_PREFIXES)
def test_fingerprints_exclude_each_other(self):
esxi = {r.pattern: r for r in VmwareEsxiDriver.HTTP_FINGERPRINT}
vc = {r.pattern: r for r in VmwareVcenterDriver.HTTP_FINGERPRINT}
assert esxi["vmware esxi"].mandatory and vc["vmware esxi"].negative
assert vc["vcenter"].mandatory and esxi["vcenter"].negative
@pytest.mark.parametrize(
"method",
[
"get_vms",
"get_vm_config",
"start_vm",
"stop_vm",
"reboot_vm",
"suspend_vm",
"get_vm_snapshots",
"create_vm_snapshot",
"delete_vm_snapshot",
"rollback_vm_snapshot",
"get_vm_storage_pools",
"get_virtual_networks",
"get_device_warnings",
],
)
@pytest.mark.parametrize("cls", DRIVERS)
def test_implements_the_v1_contract(self, cls, method):
assert callable(getattr(cls, method, None))
@pytest.mark.parametrize("cls", DRIVERS)
def test_does_not_claim_provisioning(self, cls):
"""netOrk offers VM creation wherever this exists; v1 cannot do it."""
assert not hasattr(cls, "create_vm_from_cloud_init")
class TestOptionalArgs:
def test_defaults(self):
d = VmwareEsxiDriver("h", "u", "p")
assert (d._port, d._verify_ssl) == (443, True)
def test_netork_style_args(self):
d = VmwareEsxiDriver("h", "u", "p", optional_args={"port": "8443", "ssl_verify": False})
assert (d._port, d._verify_ssl) == (8443, False)
def test_verify_ssl_wins_over_ssl_verify(self):
d = VmwareEsxiDriver("h", "u", "p", optional_args={"ssl_verify": True, "verify_ssl": False})
assert d._verify_ssl is False
def test_ssh_keys_netork_adds_are_ignored(self):
VmwareEsxiDriver("h", "u", "p", optional_args={"ssh_username": "x", "ssh_port": 22})
class TestConnection:
def _open(self, cls, data, connect_error=None):
driver = cls("host", "u", "p")
si = MagicMock()
with (
patch(
"napalm_vmware.base._session.connect", return_value=si, side_effect=connect_error
) as connect,
patch("napalm_vmware.base._session.disconnect") as disconnect,
patch("napalm_vmware.base.Inventory", return_value=FakeInventory(data)),
):
try:
driver.open()
finally:
self.connect, self.disconnect = connect, disconnect
return driver, si
def test_open_esxi(self, esxi_data):
driver, si = self._open(VmwareEsxiDriver, esxi_data)
assert driver._si is si
self.connect.assert_called_once_with("host", 443, "u", "p", verify_ssl=True, timeout=60)
def test_esxi_driver_refuses_a_vcenter(self, vcenter_data):
with pytest.raises(ConnectionException, match="vmware_vcenter"):
self._open(VmwareEsxiDriver, vcenter_data)
self.disconnect.assert_called_once()
def test_vcenter_driver_refuses_an_esxi_host(self, esxi_data):
with pytest.raises(ConnectionException, match="vmware_esxi"):
self._open(VmwareVcenterDriver, esxi_data)
def test_connect_failure_is_a_connection_exception(self, esxi_data):
with pytest.raises(ConnectionException, match="host"):
self._open(VmwareEsxiDriver, esxi_data, connect_error=OSError("refused"))
def test_close(self, esxi):
si = esxi._si
with patch("napalm_vmware.base._session.disconnect") as disconnect:
esxi.close()
esxi.close() # idempotent
disconnect.assert_called_once_with(si)
assert esxi._inventory is None
def test_is_alive(self, esxi):
with patch("napalm_vmware.base._session.alive", return_value=True):
assert esxi.is_alive() == {"is_alive": True}
esxi._si = None
assert esxi.is_alive() == {"is_alive": False}
class TestEsxiGetters:
def test_facts(self, esxi):
assert esxi.get_facts()["hostname"] == "localhost"
def test_interfaces(self, esxi):
assert set(esxi.get_interfaces()) == {"vmnic0", "vmnic1", "vmk0"}
assert esxi.get_interfaces_ip() == {}
def test_vms(self, esxi):
assert [vm["name"] for vm in esxi.get_vms()] == ["ha-host_VM0", "ha-host_VM1"]
def test_lldp_via_query_network_hint(self, esxi, esxi_host):
esxi_host["configManager.networkSystem"] = "networkSystem"
network_system = MagicMock()
network_system.QueryNetworkHint.return_value = []
esxi._mo = MagicMock(return_value=network_system)
assert esxi.get_lldp_neighbors() == {}
esxi._mo.assert_called_once()
def test_lldp_unsupported_is_empty_not_an_error(self, esxi, esxi_host):
from pyVmomi import vmodl
esxi_host["configManager.networkSystem"] = "networkSystem"
network_system = MagicMock()
network_system.QueryNetworkHint.side_effect = vmodl.fault.NotSupported()
esxi._mo = MagicMock(return_value=network_system)
assert esxi.get_lldp_neighbors() == {}
def test_no_host_visible(self, esxi):
esxi._inventory.objects("HostSystem").clear()
with pytest.raises(RuntimeError, match="no host"):
esxi.get_facts()
def test_warnings(self, esxi, esxi_host):
esxi_host["runtime.inMaintenanceMode"] = True
assert [w["code"] for w in esxi.get_device_warnings()] == ["vmware_host_maintenance_mode"]
def test_storage_and_networks(self, esxi):
assert list(esxi.get_vm_storage_pools())
assert "VM Network" in esxi.get_virtual_networks()
def test_environment(self, esxi):
assert "0" in esxi.get_environment()["cpu"]
class TestVcenterGetters:
def test_facts(self, vcenter):
assert vcenter.get_facts()["hostname"] == "vc01"
def test_host_nics_are_not_the_vcenters(self, vcenter):
"""Reporting every host's vmnics on the vCenter device would attach
their MACs to the wrong device in netOrk's topology."""
assert vcenter.get_interfaces() == {}
assert vcenter.get_interfaces_ip() == {}
assert vcenter.get_lldp_neighbors() == {}
def test_vms_span_all_hosts(self, vcenter):
assert len({vm["node"] for vm in vcenter.get_vms()}) > 1
def test_distributed_portgroups(self, vcenter):
assert vcenter.get_virtual_networks()["DC0_DVPG0"]["type"] == "dvportgroup"
class TestFindVm:
def test_by_name_vmid_or_moref(self, esxi, esxi_data):
vm0 = esxi_data["objects"]["VirtualMachine"][0]
for key in (vm0["name"], vm0["config.instanceUuid"], vm0["_moref"]):
assert esxi.get_vm_config(key)["vmid"] == vm0["config.instanceUuid"]
def test_unknown(self, esxi):
with pytest.raises(ValueError, match="no VM"):
esxi.get_vm_config("nope")
def test_ambiguous_name(self, esxi, esxi_data):
vms = esxi_data["objects"]["VirtualMachine"]
vms[1]["name"] = vms[0]["name"]
with pytest.raises(ValueError, match="2 VMs"):
esxi.get_vm_config(vms[0]["name"])
def test_templates_are_not_found(self, esxi, esxi_data):
vm0 = esxi_data["objects"]["VirtualMachine"][0]
vm0["config.template"] = True
with pytest.raises(ValueError):
esxi.get_vm_config(vm0["name"])
def test_snapshots(self, esxi):
assert esxi.get_vm_snapshots("ha-host_VM0") == []
+121
View File
@@ -0,0 +1,121 @@
"""Inventory: the single seam between the drivers and the vSphere API."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from pyVmomi import vim
from napalm_vmware._inventory import Inventory
def _content(obj, props, missing=()):
return SimpleNamespace(
obj=obj,
propSet=[SimpleNamespace(name=k, val=v) for k, v in props.items()],
missingSet=[SimpleNamespace(path=p) for p in missing],
)
@pytest.fixture
def content():
"""pyVmomi type-checks spec fields, so the objects put into them are real."""
c = MagicMock()
c.about = vim.AboutInfo(apiType="HostAgent", fullName="VMware ESXi 8.0.2")
view = vim.view.ContainerView("session[1]view-1", None)
view.Destroy = MagicMock()
c.viewManager.CreateContainerView.return_value = view
c.licenseManager = vim.LicenseManager("LicenseManager", None)
return c
class TestCollect:
def test_returns_plain_dicts_keyed_by_path_with_moref(self, content):
pc = content.propertyCollector
pc.RetrievePropertiesEx.return_value = SimpleNamespace(
objects=[
_content(
vim.VirtualMachine("vm-1", None),
{"name": "web01", "runtime.powerState": "poweredOn"},
)
],
token=None,
)
rows = Inventory(content).collect("VirtualMachine", ["name", "runtime.powerState"])
assert rows == [{"_moref": "vm-1", "name": "web01", "runtime.powerState": "poweredOn"}]
def test_follows_continuation_tokens(self, content):
pc = content.propertyCollector
pc.RetrievePropertiesEx.return_value = SimpleNamespace(
objects=[_content(vim.VirtualMachine("vm-1", None), {"name": "a"})], token="t1"
)
pc.ContinueRetrievePropertiesEx.return_value = SimpleNamespace(
objects=[_content(vim.VirtualMachine("vm-2", None), {"name": "b"})], token=None
)
rows = Inventory(content).collect("VirtualMachine", ["name"])
assert [r["_moref"] for r in rows] == ["vm-1", "vm-2"]
pc.ContinueRetrievePropertiesEx.assert_called_once_with(token="t1")
def test_empty_result(self, content):
content.propertyCollector.RetrievePropertiesEx.return_value = None
assert Inventory(content).collect("Datastore", ["name"]) == []
def test_missing_properties_are_simply_absent(self, content):
"""An inaccessible VM has no config; the row must still come back."""
content.propertyCollector.RetrievePropertiesEx.return_value = SimpleNamespace(
objects=[
_content(vim.VirtualMachine("vm-9", None), {"name": "ghost"}, ["config.template"])
],
token=None,
)
rows = Inventory(content).collect("VirtualMachine", ["name", "config.template"])
assert rows == [{"_moref": "vm-9", "name": "ghost"}]
def test_the_container_view_is_destroyed(self, content):
content.propertyCollector.RetrievePropertiesEx.return_value = None
Inventory(content).collect("HostSystem", ["name"])
content.viewManager.CreateContainerView.return_value.Destroy.assert_called_once()
def test_unknown_type_name_is_rejected(self, content):
with pytest.raises(ValueError, match="NoSuchThing"):
Inventory(content).collect("NoSuchThing", ["name"])
def test_about_is_plain(content):
assert Inventory(content).about()["apiType"] == "HostAgent"
def test_licenses_are_read_from_the_license_manager(content):
content.propertyCollector.RetrievePropertiesEx.return_value = SimpleNamespace(
objects=[
_content(
vim.LicenseManager("LicenseManager", None),
{"licenses": [vim.LicenseManager.LicenseInfo(editionKey="esxBasic", name="Free")]},
)
],
token=None,
)
assert Inventory(content).licenses()[0]["editionKey"] == "esxBasic"
def test_licenses_empty_without_a_license_manager(content):
content.licenseManager = None
assert Inventory(content).licenses() == []
def test_properties_of_a_single_object(content):
content.propertyCollector.RetrievePropertiesEx.return_value = SimpleNamespace(
objects=[_content(vim.Task("task-7", None), {"info.state": "success"})], token=None
)
task = vim.Task("task-7", None)
assert Inventory(content).properties(task, ["info.state"]) == {
"_moref": "task-7",
"info.state": "success",
}
def test_properties_of_a_vanished_object(content):
content.propertyCollector.RetrievePropertiesEx.return_value = None
assert Inventory(content).properties(vim.Task("task-7", None), ["info.state"]) == {}
+95
View File
@@ -0,0 +1,95 @@
"""get_facts for an ESXi host and for a vCenter."""
from __future__ import annotations
import pytest
from napalm_vmware.parse.facts import esxi_facts, host_serial, vcenter_facts
class TestEsxiFacts:
def test_from_vcsim(self, esxi_data, esxi_host):
facts = esxi_facts(esxi_data["about"], esxi_host)
assert facts == {
"hostname": "localhost",
"fqdn": "localhost.localdomain",
"vendor": "VMware, Inc.",
"model": "VMware Virtual Platform",
"serial_number": "VMware-56 4d 8d e8 1e 9f a1 3e-71 fa 13 a8 e1 a7 fd 70",
"os_version": "VMware ESXi 8.0.2 build-21997540",
"uptime": 77229.0,
"interface_list": ["vmk0", "vmnic0", "vmnic1"],
}
def test_falls_back_to_the_inventory_name_without_dns_config(self, esxi_data, esxi_host):
del esxi_host["config.network.dnsConfig"]
facts = esxi_facts(esxi_data["about"], esxi_host)
assert facts["hostname"] == "localhost"
assert facts["fqdn"] == "localhost.localdomain"
def test_hostname_without_domain(self, esxi_data, esxi_host):
esxi_host["config.network.dnsConfig"]["domainName"] = ""
assert esxi_facts(esxi_data["about"], esxi_host)["fqdn"] == "localhost"
def test_unknown_uptime_is_minus_one(self, esxi_data, esxi_host):
del esxi_host["summary.quickStats"]
assert esxi_facts(esxi_data["about"], esxi_host)["uptime"] == -1.0
def test_os_version_falls_back_to_about(self, esxi_data, esxi_host):
del esxi_host["config.product"]
facts = esxi_facts(esxi_data["about"], esxi_host)
assert facts["os_version"] == esxi_data["about"]["fullName"]
class TestHostSerial:
def _info(self, *pairs):
return {
"otherIdentifyingInfo": [
{"identifierType": {"key": k}, "identifierValue": v} for k, v in pairs
]
}
def test_system_info_serial_wins(self):
host = {
"hardware.systemInfo": {"serialNumber": "ABC123", **self._info(("ServiceTag", "x"))}
}
assert host_serial(host) == "ABC123"
@pytest.mark.parametrize(
("pairs", "expected"),
[
((("ServiceTag", "SVC"), ("SerialNumberTag", "SER")), "SER"),
((("EnclosureSerialNumberTag", "ENC"), ("ServiceTag", "SVC")), "ENC"),
((("AssetTag", "No Asset Tag"),), ""),
],
)
def test_identifying_info_by_preference(self, pairs, expected):
assert host_serial({"hardware.systemInfo": self._info(*pairs)}) == expected
def test_falls_back_to_summary_hardware(self):
host = {"summary.hardware": self._info(("SerialNumberTag", "OLD"))}
assert host_serial(host) == "OLD"
def test_blank_values_are_ignored(self):
host = {"hardware.systemInfo": self._info(("SerialNumberTag", " "), ("ServiceTag", "S"))}
assert host_serial(host) == "S"
class TestVcenterFacts:
def test_from_vcsim(self, vcenter_data):
facts = vcenter_facts(vcenter_data["about"], "vc01.example.lan")
assert facts == {
"hostname": "vc01",
"fqdn": "vc01.example.lan",
"vendor": "VMware, Inc.",
"model": "VMware vCenter Server",
"serial_number": "35bb7c82-8526-5aa7-a886-cd7f8e6be786",
"os_version": "VMware vCenter Server 6.5.0 build-5973321 (govmomi simulator)",
"uptime": -1.0,
"interface_list": [],
}
def test_ip_address_is_not_split_into_a_hostname(self, vcenter_data):
facts = vcenter_facts(vcenter_data["about"], "10.0.0.5")
assert facts["hostname"] == "10.0.0.5"
assert facts["fqdn"] == "10.0.0.5"
+184
View File
@@ -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) == []
+80
View File
@@ -0,0 +1,80 @@
"""get_interfaces / get_interfaces_ip for an ESXi host: vmnics and vmkernel NICs."""
from __future__ import annotations
from napalm_vmware.parse.interfaces import host_interfaces, host_interfaces_ip
class TestHostInterfaces:
def test_physical_nic_with_link(self, esxi_host):
vmnic0 = host_interfaces(esxi_host)["vmnic0"]
assert vmnic0 == {
"is_up": True,
"is_enabled": True,
"description": "nvmxnet3 0000:0b:00.0",
"last_flapped": -1.0,
"speed": 10000.0,
"mtu": 1500,
"mac_address": "00:0C:29:81:D8:A0",
}
def test_physical_nic_without_link_is_down(self, esxi_host):
esxi_host["config.network.pnic"][1].pop("linkSpeed", None)
vmnic1 = host_interfaces(esxi_host)["vmnic1"]
assert vmnic1["is_up"] is False
assert vmnic1["speed"] == 0.0
def test_mtu_comes_from_the_vswitch_the_nic_is_uplink_of(self, esxi_host):
esxi_host["config.network.vswitch"][0]["mtu"] = 9000
assert host_interfaces(esxi_host)["vmnic0"]["mtu"] == 9000
def test_nic_on_no_vswitch_has_default_mtu(self, esxi_host):
del esxi_host["config.network.vswitch"]
assert host_interfaces(esxi_host)["vmnic0"]["mtu"] == 1500
def test_vmkernel_nic(self, esxi_host):
assert host_interfaces(esxi_host)["vmk0"] == {
"is_up": True,
"is_enabled": True,
"description": "Management Network",
"last_flapped": -1.0,
"speed": 0.0,
"mtu": 1500,
"mac_address": "00:0C:29:81:D8:A0",
}
def test_host_without_network_config(self):
assert host_interfaces({"name": "h"}) == {}
class TestHostInterfacesIp:
def test_unassigned_address_is_left_out(self, esxi_host):
"""vcsim, like a DHCP vmk without a lease, reports 0.0.0.0."""
assert host_interfaces_ip(esxi_host) == {}
def test_ipv4_and_ipv6(self, esxi_host):
ip = esxi_host["config.network.vnic"][0]["spec"]["ip"]
ip["ipAddress"] = "192.0.2.10"
ip["subnetMask"] = "255.255.255.0"
ip["ipV6Config"] = {
"ipV6Address": [
{"ipAddress": "fe80::20c:29ff:fe81:d8a0", "prefixLength": 64},
{"ipAddress": "2001:db8::10", "prefixLength": 64},
]
}
assert host_interfaces_ip(esxi_host) == {
"vmk0": {
"ipv4": {"192.0.2.10": {"prefix_length": 24}},
"ipv6": {
"fe80::20c:29ff:fe81:d8a0": {"prefix_length": 64},
"2001:db8::10": {"prefix_length": 64},
},
}
}
def test_ipv6_only(self, esxi_host):
ip = esxi_host["config.network.vnic"][0]["spec"]["ip"]
ip["ipV6Config"] = {"ipV6Address": [{"ipAddress": "2001:db8::10", "prefixLength": 64}]}
assert host_interfaces_ip(esxi_host) == {
"vmk0": {"ipv6": {"2001:db8::10": {"prefix_length": 64}}}
}
+126
View File
@@ -0,0 +1,126 @@
"""Port groups, distributed port groups, and which one a VM NIC is on."""
from __future__ import annotations
import pytest
from napalm_vmware.parse.networks import (
NetworkIndex,
dv_vlan,
network_index,
nic_network,
virtual_networks,
)
@pytest.fixture
def vc_index(vcenter_data):
objs = vcenter_data["objects"]
return network_index(objs["HostSystem"], objs["DistributedVirtualPortgroup"])
class TestDvVlan:
@pytest.mark.parametrize(
("vlan", "expected"),
[
({"_type": "VmwareDistributedVirtualSwitchVlanIdSpec", "vlanId": 30}, 30),
({"_type": "VmwareDistributedVirtualSwitchTrunkVlanSpec", "vlanId": []}, 0),
({"_type": "VmwareDistributedVirtualSwitchPvlanSpec", "pvlanId": 101}, 101),
],
)
def test_kinds(self, vlan, expected):
assert dv_vlan({"vlan": vlan}) == expected
def test_missing(self):
assert dv_vlan(None) == 0
class TestNetworkIndex:
def test_standard_and_distributed(self, vc_index):
assert vc_index.standard["VM Network"] == 0
assert vc_index.distributed["dvportgroup-12"] == ("DC0_DVPG0", 0)
def test_standard_vlan(self, esxi_host):
esxi_host["config.network.portgroup"][0]["spec"]["vlanId"] = 40
index = network_index([esxi_host], [])
assert index.standard["VM Network"] == 40
class TestNicNetwork:
def test_standard_backing(self):
index = NetworkIndex(standard={"VM Network": 20}, distributed={})
nic = {
"backing": {
"_type": "VirtualEthernetCardNetworkBackingInfo",
"deviceName": "VM Network",
}
}
assert nic_network(nic, index) == ("VM Network", 20)
def test_distributed_backing(self, vc_index):
nic = {
"backing": {
"_type": "VirtualEthernetCardDistributedVirtualPortBackingInfo",
"port": {"portgroupKey": "dvportgroup-12"},
}
}
assert nic_network(nic, vc_index) == ("DC0_DVPG0", 0)
def test_unknown_distributed_portgroup_keeps_its_key(self, vc_index):
nic = {
"backing": {
"_type": "VirtualEthernetCardDistributedVirtualPortBackingInfo",
"port": {"portgroupKey": "dvportgroup-99"},
}
}
assert nic_network(nic, vc_index) == ("dvportgroup-99", 0)
def test_nsx_opaque_network(self, vc_index):
nic = {
"backing": {
"_type": "VirtualEthernetCardOpaqueNetworkBackingInfo",
"opaqueNetworkId": "ls-web",
}
}
assert nic_network(nic, vc_index) == ("ls-web", 0)
def test_no_backing(self, vc_index):
assert nic_network({}, vc_index) == ("", 0)
class TestVirtualNetworks:
def test_esxi_standard_portgroups(self, esxi_host):
nets = virtual_networks([esxi_host], [], [])
assert nets["VM Network"] == {
"name": "VM Network",
"type": "portgroup",
"bridge": "vSwitch0",
"vlan_id": 0,
"autostart": True,
"active": True,
}
assert set(nets) == {"VM Network", "Management Network"}
def test_vcenter_adds_distributed_portgroups_but_not_uplinks(self, vcenter_data):
objs = vcenter_data["objects"]
nets = virtual_networks(
objs["HostSystem"],
objs["DistributedVirtualPortgroup"],
objs["DistributedVirtualSwitch"],
)
assert nets["DC0_DVPG0"] == {
"name": "DC0_DVPG0",
"type": "dvportgroup",
"bridge": "DVS0",
"vlan_id": 0,
"autostart": True,
"active": True,
}
assert "DVS0-DVUplinks-8" not in nets
def test_uplink_flag_wins_over_the_name(self, vcenter_data):
objs = vcenter_data["objects"]
pgs = objs["DistributedVirtualPortgroup"]
pgs[1]["config.uplink"] = True
nets = virtual_networks([], pgs, objs["DistributedVirtualSwitch"])
assert "DC0_DVPG0" not in nets
+196
View File
@@ -0,0 +1,196 @@
"""get_vm_config and snapshot trees."""
from __future__ import annotations
import pytest
from napalm_vmware.parse.networks import NetworkIndex, network_index
from napalm_vmware.parse.snapshots import find_snapshot, snapshot_list
from napalm_vmware.parse.vm_config import vm_config
@pytest.fixture
def vm(vcenter_data):
return vcenter_data["objects"]["VirtualMachine"][0]
@pytest.fixture
def index(vcenter_data):
objs = vcenter_data["objects"]
return network_index(objs["HostSystem"], objs["DistributedVirtualPortgroup"])
class TestVmConfig:
def test_core_fields_from_vcsim(self, vm, index):
cfg = vm_config(vm, index)
assert cfg["name"] == "DC0_H0_VM0"
assert cfg["vmid"] == vm["config.instanceUuid"]
assert cfg["vcpus"] == vm["config.hardware.numCPU"]
assert cfg["memory"] == vm["config.hardware.memoryMB"]
assert cfg["os_type"] == vm["config.guestId"]
assert cfg["os_name"] == vm["config.guestFullName"]
assert cfg["description"] == ""
assert cfg["tags"] == []
assert cfg["firmware"] == "bios"
assert cfg["machine"] == "vmx-13"
assert cfg["passthrough"] == []
def test_disk(self, vm, index):
assert vm_config(vm, index)["disks"] == [
{
"device": "disk-202-0",
"storage": "LocalDS_0",
"size": 10,
"format": "vmdk",
"bootable": True, # no boot order: the BIOS boots the first disk
}
]
def test_nic(self, vm, index):
assert vm_config(vm, index)["nics"] == [
{
"device": "ethernet-0",
"mac": "00:0C:29:63:65:61",
"model": "e1000",
"bridge": "DC0_DVPG0",
"vlan_id": 0,
}
]
def test_cpu_topology(self, vm, index):
vm["config.hardware.numCPU"] = 8
vm["config.hardware.numCoresPerSocket"] = 4
cfg = vm_config(vm, index)
assert (cfg["sockets"], cfg["cores_per_socket"]) == (2, 4)
def test_explicit_boot_order(self, vm, index):
disk_key = next(
d["key"] for d in vm["config.hardware.device"] if d["_type"] == "VirtualDisk"
)
nic_key = next(
d["key"] for d in vm["config.hardware.device"] if d["_type"] == "VirtualE1000"
)
vm["config.bootOptions.bootOrder"] = [
{"_type": "VirtualMachineBootOptionsBootableCdromDevice"},
{"_type": "VirtualMachineBootOptionsBootableEthernetDevice", "deviceKey": nic_key},
{"_type": "VirtualMachineBootOptionsBootableDiskDevice", "deviceKey": disk_key},
]
cfg = vm_config(vm, index)
assert cfg["boot_order"] == ["cdrom", "ethernet-0", "disk-202-0"]
assert cfg["disks"][0]["bootable"] is True
def test_disk_outside_the_boot_order_is_not_bootable(self, vm, index):
nic_key = next(
d["key"] for d in vm["config.hardware.device"] if d["_type"] == "VirtualE1000"
)
vm["config.bootOptions.bootOrder"] = [
{"_type": "VirtualMachineBootOptionsBootableEthernetDevice", "deviceKey": nic_key},
]
assert vm_config(vm, index)["disks"][0]["bootable"] is False
def test_passthrough_and_annotation(self, vm, index):
vm["config.annotation"] = "Production web server"
vm["config.hardware.device"].append(
{
"_type": "VirtualPCIPassthrough",
"key": 13000,
"deviceInfo": {"label": "PCI device 0", "summary": "NVIDIA T4"},
}
)
cfg = vm_config(vm, index)
assert cfg["description"] == "Production web server"
assert cfg["passthrough"] == [
{"slot": "PCI device 0", "kind": "pci", "config": "NVIDIA T4"}
]
def test_config_less_vm_is_refused(self, vm):
del vm["config.instanceUuid"]
with pytest.raises(ValueError):
vm_config(vm, NetworkIndex())
def _snap(name, moref, created, children=(), state="poweredOff", description=""):
return {
"name": name,
"snapshot": moref,
"createTime": created,
"state": state,
"description": description,
"childSnapshotList": list(children),
}
@pytest.fixture
def tree_vm():
return {
"name": "web01",
"snapshot": {
"currentSnapshot": "snapshot-3",
"rootSnapshotList": [
_snap(
"base",
"snapshot-1",
100.0,
[
_snap("upgrade", "snapshot-2", 200.0, state="poweredOn", description="d"),
_snap("again", "snapshot-3", 300.0),
],
),
_snap("again", "snapshot-4", 400.0),
],
},
}
class TestSnapshots:
def test_flattened_depth_first_with_parents(self, tree_vm):
assert snapshot_list(tree_vm) == [
{
"name": "base",
"vm": "web01",
"created": 100.0,
"description": "",
"has_memory": False,
"parent": "",
},
{
"name": "upgrade",
"vm": "web01",
"created": 200.0,
"description": "d",
"has_memory": True,
"parent": "base",
},
{
"name": "again",
"vm": "web01",
"created": 300.0,
"description": "",
"has_memory": False,
"parent": "base",
},
{
"name": "again",
"vm": "web01",
"created": 400.0,
"description": "",
"has_memory": False,
"parent": "",
},
]
def test_vm_without_snapshots(self):
assert snapshot_list({"name": "x"}) == []
def test_find_by_name(self, tree_vm):
assert find_snapshot(tree_vm, "upgrade") == "snapshot-2"
def test_unknown_name(self, tree_vm):
with pytest.raises(ValueError, match="no snapshot"):
find_snapshot(tree_vm, "nope")
def test_duplicate_names_are_refused_not_guessed(self, tree_vm):
"""vSphere allows two snapshots with one name. Picking one could roll
back or delete the wrong state."""
with pytest.raises(ValueError, match="2 snapshots"):
find_snapshot(tree_vm, "again")
+131
View File
@@ -0,0 +1,131 @@
"""get_vms: VMDict plus the extra keys netOrk's VM sync reads."""
from __future__ import annotations
import pytest
from napalm_vmware.parse.networks import network_index
from napalm_vmware.parse.vms import vm_list
def _vms(data):
objs = data["objects"]
index = network_index(objs["HostSystem"], objs.get("DistributedVirtualPortgroup", []))
return vm_list(objs["VirtualMachine"], objs["HostSystem"], index)
@pytest.fixture
def vm0(esxi_data):
return esxi_data["objects"]["VirtualMachine"][0]
class TestVmListFromVcsim:
def test_one_entry_per_vm(self, esxi_data):
assert [vm["name"] for vm in _vms(esxi_data)] == ["ha-host_VM0", "ha-host_VM1"]
def test_contract_fields(self, esxi_data, vm0):
vm = _vms(esxi_data)[0]
assert vm["vmid"] == vm0["config.instanceUuid"]
assert vm["moref"] == vm0["_moref"]
assert vm["status"] == "running"
assert vm["vcpus"] == vm0["config.hardware.numCPU"]
assert vm["memory"] == vm0["config.hardware.memoryMB"]
assert vm["node"] == "localhost.localdomain"
assert vm["type"] == "vm"
def test_vcenter_node_is_the_esxi_host(self, vcenter_data):
nodes = {vm["name"]: vm["node"] for vm in _vms(vcenter_data)}
assert nodes["DC0_H0_VM0"] == "DC0_H0"
def test_nic_on_a_distributed_portgroup(self, vcenter_data):
vm = _vms(vcenter_data)[0]
(iface,) = vm["interfaces"].values()
assert iface["bridge"] == "DC0_DVPG0"
assert iface["tag"] is None
assert iface["mac_address"] == "00:0C:29:63:65:61"
def test_disks_in_megabytes(self, vcenter_data):
vm = _vms(vcenter_data)[0]
assert vm["disks"] == [{"name": "disk-202-0", "size_mb": 10240}]
def test_tools_not_installed(self, esxi_data):
vm = _vms(esxi_data)[0]
assert vm["agent_enabled"] is False
assert vm["agent_running"] is False
class TestVmListBranches:
def test_templates_are_not_vms(self, esxi_data, vm0):
vm0["config.template"] = True
assert [vm["name"] for vm in _vms(esxi_data)] == ["ha-host_VM1"]
def test_vm_without_config_is_skipped(self, esxi_data, vm0):
"""An orphaned or inaccessible VM has no instanceUuid to identify it by."""
del vm0["config.instanceUuid"]
assert len(_vms(esxi_data)) == 1
@pytest.mark.parametrize(
("power", "status"),
[("poweredOn", "running"), ("poweredOff", "stopped"), ("suspended", "suspended")],
)
def test_power_state(self, esxi_data, vm0, power, status):
vm0["runtime.powerState"] = power
assert _vms(esxi_data)[0]["status"] == status
def test_usage_from_quickstats(self, esxi_data, esxi_host, vm0):
esxi_host["summary.hardware"]["cpuMhz"] = 2000
vm0["config.hardware.numCPU"] = 2
vm0["summary.quickStats"].update(
overallCpuUsage=1000, guestMemoryUsage=512, uptimeSeconds=3600
)
vm = _vms(esxi_data)[0]
assert vm["cpu_usage"] == 0.25
assert vm["memory_usage"] == 512
assert vm["uptime"] == 3600
def test_cpu_usage_is_capped_at_one(self, esxi_data, esxi_host, vm0):
esxi_host["summary.hardware"]["cpuMhz"] = 1000
vm0["config.hardware.numCPU"] = 1
vm0["summary.quickStats"]["overallCpuUsage"] = 1500
assert _vms(esxi_data)[0]["cpu_usage"] == 1.0
def test_tools_running_and_guest_ip(self, esxi_data, vm0):
vm0["guest.toolsStatus"] = "toolsOk"
vm0["guest.toolsRunningStatus"] = "guestToolsRunning"
vm0["guest.net"][0]["ipAddress"] = ["fe80::1", "192.0.2.50"]
vm = _vms(esxi_data)[0]
assert vm["agent_enabled"] is True
assert vm["agent_running"] is True
assert vm["ipv4"] == "192.0.2.50"
(iface,) = vm["interfaces"].values()
assert iface["ipv4"] == "192.0.2.50"
assert iface["is_up"] is True
def test_guest_primary_ip_wins(self, esxi_data, vm0):
vm0["guest.ipAddress"] = "192.0.2.99"
vm0["guest.net"][0]["ipAddress"] = ["192.0.2.50"]
assert _vms(esxi_data)[0]["ipv4"] == "192.0.2.99"
def test_ipv6_primary_ip_is_not_an_ipv4(self, esxi_data, vm0):
vm0["guest.ipAddress"] = "2001:db8::5"
assert _vms(esxi_data)[0]["ipv4"] == ""
def test_vlan_of_a_standard_portgroup_becomes_the_tag(self, esxi_data, esxi_host):
esxi_host["config.network.portgroup"][0]["spec"]["vlanId"] = 30
vm = _vms(esxi_data)[0]
(iface,) = vm["interfaces"].values()
assert iface["bridge"] == "VM Network"
assert iface["tag"] == 30
def test_autostart(self, esxi_data, esxi_host, vm0):
esxi_host["config.autoStart"]["defaults"]["enabled"] = True
esxi_host["config.autoStart"]["powerInfo"] = [
{"key": vm0["_moref"], "startAction": "powerOn", "startOrder": 1}
]
assert [vm["onboot"] for vm in _vms(esxi_data)] == [True, False]
def test_autostart_entry_ignored_while_the_manager_is_off(self, esxi_data, esxi_host, vm0):
esxi_host["config.autoStart"]["powerInfo"] = [
{"key": vm0["_moref"], "startAction": "powerOn", "startOrder": 1}
]
assert _vms(esxi_data)[0]["onboot"] is False
+67
View File
@@ -0,0 +1,67 @@
"""to_plain: pyVmomi objects -> JSON-safe Python values.
Every parser in this package works on the output of ``to_plain`` rather than
on pyVmomi objects. That is what lets the harvest tool dump a real host to
JSON and the tests feed that same JSON back in.
"""
from __future__ import annotations
import datetime
import json
from pyVmomi import vim
from napalm_vmware._plain import to_plain
def test_scalars_pass_through():
assert to_plain("a") == "a"
assert to_plain(3) == 3
assert to_plain(True) is True
assert to_plain(None) is None
def test_enum_becomes_plain_str():
value = to_plain(vim.VirtualMachine.PowerState.poweredOn)
assert value == "poweredOn"
assert type(value) is str
def test_managed_object_becomes_its_moref():
assert to_plain(vim.HostSystem("host-21", None)) == "host-21"
def test_data_object_becomes_dict_with_type_and_without_unset_fields():
nic = vim.host.PhysicalNic(
device="vmnic0",
linkSpeed=vim.host.PhysicalNic.LinkSpeedDuplex(speedMb=1000, duplex=True),
)
plain = to_plain(nic)
assert plain["_type"] == "PhysicalNic"
assert plain["device"] == "vmnic0"
assert plain["linkSpeed"] == {"_type": "PhysicalNicLinkInfo", "speedMb": 1000, "duplex": True}
assert "driver" not in plain # optional and unset
def test_dynamic_property_noise_is_dropped():
assert "dynamicProperty" not in to_plain(vim.host.PhysicalNic(device="vmnic0"))
def test_lists_are_converted_elementwise():
devices = [vim.vm.device.VirtualVmxnet3(key=4000), vim.vm.device.VirtualDisk(key=2000)]
assert [d["_type"] for d in to_plain(devices)] == ["VirtualVmxnet3", "VirtualDisk"]
def test_datetime_becomes_epoch_seconds():
when = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
assert to_plain(when) == 1767225600.0
def test_bytes_are_dropped_to_none():
assert to_plain(b"\x00\x01") is None
def test_result_is_json_serialisable():
nic = vim.host.PhysicalNic(device="vmnic0", pci="0000:0b:00.0")
json.dumps(to_plain([nic, vim.HostSystem("host-1", None), 1.5]))
+97
View File
@@ -0,0 +1,97 @@
"""tools/sanitize.py: scrub a harvest dump before it becomes a fixture."""
from __future__ import annotations
import importlib.util
from pathlib import Path
_spec = importlib.util.spec_from_file_location(
"sanitize", Path(__file__).parent.parent / "tools" / "sanitize.py"
)
sanitize = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(sanitize)
def _dump():
return {
"about": {"instanceUuid": "35bb7c82-8526-5aa7-a886-cd7f8e6be786"},
"licenses": [{"licenseKey": "AAAAA-BBBBB-CCCCC-DDDDD-EEEEE", "editionKey": "esxBasic"}],
"objects": {
"HostSystem": [
{
"_moref": "host-21",
"name": "esx01.corp.example.com",
"hardware.systemInfo": {"serialNumber": "CZJ1234567"},
"config.network.dnsConfig": {
"hostName": "esx01",
"domainName": "corp.example.com",
"address": ["10.1.2.3"],
},
"config.network.pnic": [{"mac": "3c:ec:ef:01:02:03"}],
"summary.managementServerIp": "0.0.0.0",
}
],
"VirtualMachine": [
{
"_moref": "vm-7",
"name": "payroll-db",
"config.instanceUuid": "5003a1b2-0000-1111-2222-333344445555",
"config.hardware.device": [
{"backing": {"fileName": "[ds1] payroll-db/payroll-db.vmdk"}},
{"macAddress": "00:50:56:aa:bb:cc"},
],
"guest.net": [{"ipAddress": ["10.1.2.50", "fe80::1"]}],
}
],
},
}
def _text(data):
import json
return json.dumps(data)
class TestSanitize:
def test_sensitive_values_are_gone_everywhere(self):
clean = _text(sanitize.sanitize(_dump()))
for secret in (
"35bb7c82",
"AAAAA-BBBBB",
"esx01",
"corp.example.com",
"CZJ1234567",
"10.1.2.3",
"3c:ec:ef",
"payroll-db",
"5003a1b2",
"00:50:56:aa:bb:cc",
"10.1.2.50",
):
assert secret not in clean, secret
def test_structure_and_harmless_values_survive(self):
clean = sanitize.sanitize(_dump())
vm = clean["objects"]["VirtualMachine"][0]
assert vm["_moref"] == "vm-7"
assert clean["licenses"][0]["editionKey"] == "esxBasic"
assert clean["objects"]["HostSystem"][0]["summary.managementServerIp"] == "0.0.0.0"
path = vm["config.hardware.device"][0]["backing"]["fileName"]
assert path.startswith("[ds1] ") and path.endswith(".vmdk")
def test_placeholders_are_stable(self):
"""The same original value maps to the same placeholder, so references
between objects (a VM's name inside its disk path) still line up."""
clean = sanitize.sanitize(_dump())
vm = clean["objects"]["VirtualMachine"][0]
path = vm["config.hardware.device"][0]["backing"]["fileName"]
assert path == f"[ds1] {vm['name']}/{vm['name']}.vmdk"
def test_valid_shapes(self):
clean = sanitize.sanitize(_dump())
host = clean["objects"]["HostSystem"][0]
assert host["config.network.pnic"][0]["mac"].count(":") == 5
ip = clean["objects"]["VirtualMachine"][0]["guest.net"][0]["ipAddress"]
assert ip[0].startswith("192.0.2.")
assert ip[1].startswith("2001:db8::")
+117
View File
@@ -0,0 +1,117 @@
"""Waiting for vSphere tasks, and turning vSphere faults into RuntimeError."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from pyVmomi import vim
from napalm_vmware._tasks import fault_message, invoke, wait_for_task
class _Clock:
def __init__(self):
self.now = 0.0
def __call__(self):
return self.now
def sleep(self, seconds):
self.now += seconds
def _inventory(*states):
inv = MagicMock()
inv.properties.side_effect = list(states)
return inv
class TestWaitForTask:
def test_returns_once_the_task_succeeds(self):
inv = _inventory({"info.state": "running"}, {"info.state": "success"})
clock = _Clock()
wait_for_task(inv, "task", timeout=10, clock=clock, sleep=clock.sleep)
assert inv.properties.call_count == 2
def test_error_becomes_runtime_error_with_the_fault_text(self):
inv = _inventory(
{
"info.state": "error",
"info.error": {
"_type": "InvalidPowerState",
"msg": "The attempted operation cannot be performed "
"in the current state (Powered off).",
},
}
)
with pytest.raises(RuntimeError, match="current state"):
wait_for_task(inv, "task", timeout=10)
def test_times_out(self):
clock = _Clock()
inv = MagicMock()
inv.properties.return_value = {"info.state": "running"}
with pytest.raises(RuntimeError, match="did not finish within 5s"):
wait_for_task(inv, "task", timeout=5, clock=clock, sleep=clock.sleep)
def test_vanished_task_is_an_error(self):
with pytest.raises(RuntimeError, match="disappeared"):
wait_for_task(_inventory({}), "task", timeout=5)
class TestFaultMessage:
def test_free_license(self):
msg = fault_message(
{
"_type": "RestrictedVersion",
"msg": "Current license or ESXi version prohibits "
"execution of the requested operation.",
}
)
assert "free" in msg.lower()
assert "read-only" in msg
def test_localized_message_preferred(self):
assert fault_message({"_type": "X", "localizedMessage": "L", "msg": "M"}) == "L"
def test_bare_type(self):
assert fault_message({"_type": "NotSupported"}) == "NotSupported"
class TestInvoke:
def test_passes_the_result_through(self):
assert invoke(lambda x: x + 1, 1) == 2
def test_method_fault_becomes_runtime_error(self):
def boom():
raise vim.fault.RestrictedVersion(msg="prohibited")
with pytest.raises(RuntimeError, match="free"):
invoke(boom)
def test_other_exceptions_are_left_alone(self):
def boom():
raise KeyError("x")
with pytest.raises(KeyError):
invoke(boom)
class TestWaitUntil:
def test_returns_when_the_check_passes(self):
from napalm_vmware._tasks import wait_until
answers = iter([False, False, True])
clock = _Clock()
wait_until(lambda: next(answers), timeout=10, what="x", clock=clock, sleep=clock.sleep)
assert clock.now == 1.0
def test_times_out_naming_what_it_waited_for(self):
from napalm_vmware._tasks import wait_until
clock = _Clock()
with pytest.raises(RuntimeError, match="guest shutdown did not finish within 3s"):
wait_until(
lambda: False, timeout=3, what="guest shutdown", clock=clock, sleep=clock.sleep
)
+105
View File
@@ -0,0 +1,105 @@
"""End-to-end against govmomi's vcsim: real sessions, real tasks, no mocks.
Skipped unless the simulators are running::
docker run -d --rm -p 127.0.0.1:8989:8989 vmware/vcsim -l 0.0.0.0:8989
docker run -d --rm -p 127.0.0.1:8990:8989 vmware/vcsim -esx -l 0.0.0.0:8989
VCSIM_VCENTER_PORT=8989 VCSIM_ESXI_PORT=8990 pytest -m vcsim
vcsim accepts any credentials. It is a simulator: this proves the drivers speak
the vSphere API correctly, not that they understand a particular real host.
"""
from __future__ import annotations
import os
import pytest
from napalm.base.exceptions import ConnectionException
from napalm_vmware import VmwareEsxiDriver, VmwareVcenterDriver
pytestmark = pytest.mark.vcsim
def _driver(cls, env):
port = os.environ.get(env)
if not port:
pytest.skip(f"{env} not set; vcsim not running")
driver = cls("127.0.0.1", "user", "pass", optional_args={"port": port, "verify_ssl": False})
driver.open()
yield driver
driver.close()
@pytest.fixture
def esxi():
yield from _driver(VmwareEsxiDriver, "VCSIM_ESXI_PORT")
@pytest.fixture
def vcenter():
yield from _driver(VmwareVcenterDriver, "VCSIM_VCENTER_PORT")
def test_esxi_reads(esxi):
assert esxi.is_alive() == {"is_alive": True}
assert esxi.get_facts()["os_version"].startswith("VMware ESXi")
assert "vmnic0" in esxi.get_interfaces()
assert esxi.get_vms()
assert esxi.get_vm_storage_pools()
assert esxi.get_virtual_networks()
assert "cpu" in esxi.get_environment()
assert isinstance(esxi.get_device_warnings(), list)
assert isinstance(esxi.get_lldp_neighbors(), dict)
def test_vcenter_reads(vcenter):
assert vcenter.get_facts()["model"] == "VMware vCenter Server"
vms = vcenter.get_vms()
assert len({vm["node"] for vm in vms}) > 1
assert vcenter.get_vm_config(vms[0]["vmid"])["vmid"] == vms[0]["vmid"]
def test_power_cycle(esxi):
vm = esxi.get_vms()[0]
esxi.stop_vm(vm["vmid"], force=True)
assert _status(esxi, vm) == "stopped"
with pytest.raises(RuntimeError, match="not running"):
esxi.suspend_vm(vm["vmid"])
esxi.start_vm(vm["vmid"])
assert _status(esxi, vm) == "running"
esxi.suspend_vm(vm["vmid"])
assert _status(esxi, vm) == "suspended"
esxi.start_vm(vm["vmid"])
esxi.reboot_vm(vm["vmid"], force=True)
assert _status(esxi, vm) == "running"
def test_snapshot_lifecycle(vcenter):
vm = vcenter.get_vms()[0]
vcenter.create_vm_snapshot(vm["vmid"], "netork-test", description="from pytest")
try:
(snap,) = [s for s in vcenter.get_vm_snapshots(vm["vmid"]) if s["name"] == "netork-test"]
assert snap["description"] == "from pytest"
with pytest.raises(ValueError, match="already"):
vcenter.create_vm_snapshot(vm["vmid"], "netork-test")
vcenter.rollback_vm_snapshot(vm["vmid"], "netork-test")
finally:
vcenter.delete_vm_snapshot(vm["vmid"], "netork-test")
assert not [s for s in vcenter.get_vm_snapshots(vm["vmid"]) if s["name"] == "netork-test"]
def test_wrong_driver_is_refused():
port = os.environ.get("VCSIM_VCENTER_PORT")
if not port:
pytest.skip("VCSIM_VCENTER_PORT not set")
driver = VmwareEsxiDriver(
"127.0.0.1", "u", "p", optional_args={"port": port, "verify_ssl": False}
)
with pytest.raises(ConnectionException, match="vmware_vcenter"):
driver.open()
def _status(driver, vm):
return next(v["status"] for v in driver.get_vms() if v["vmid"] == vm["vmid"])
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Dump what the drivers read from an ESXi host or vCenter into one JSON file.
Usage::
tools/harvest.py <host> <user> <label> [--port 443] [--verify-ssl]
The password is prompted for (or taken from ``$VSPHERE_PASSWORD``). Output
goes to ``tools/harvest-out/<label>.json``, which is gitignored: it holds
serial numbers, MACs, IPs and names. Run ``tools/sanitize.py`` on it and read
the result before anything goes into ``tests/fixtures/``.
The file has the shape the test fake (``tests/fake_inventory.py``) serves::
{"about": {...}, "licenses": [...], "objects": {"HostSystem": [...], ...}}
It reads exactly the property paths in ``napalm_vmware.paths``, the same ones
the drivers ask for, so a fixture never contains a field no parser uses and
never lacks one a parser needs.
"""
from __future__ import annotations
import argparse
import getpass
import json
import os
import ssl
import sys
from pathlib import Path
from pyVim.connect import Disconnect, SmartConnect
from napalm_vmware import paths
from napalm_vmware._inventory import Inventory
OUT_DIR = Path(__file__).resolve().parent / "harvest-out"
def harvest(inventory: Inventory) -> dict:
"""Everything the drivers read, as one plain dict."""
return {
"about": inventory.about(),
"licenses": inventory.licenses(),
"objects": {name: inventory.collect(name, p) for name, p in paths.ALL.items()},
}
def main() -> int: # pragma: no cover - opens a live vSphere session
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("host")
parser.add_argument("user")
parser.add_argument("label")
parser.add_argument("--port", type=int, default=443)
parser.add_argument("--verify-ssl", action="store_true")
args = parser.parse_args()
password = os.environ.get("VSPHERE_PASSWORD") or getpass.getpass()
context = ssl.create_default_context()
if not args.verify_ssl:
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
si = SmartConnect(
host=args.host, port=args.port, user=args.user, pwd=password, sslContext=context
)
try:
data = harvest(Inventory(si.RetrieveContent()))
finally:
Disconnect(si)
OUT_DIR.mkdir(exist_ok=True)
out = OUT_DIR / f"{args.label}.json"
out.write_text(json.dumps(data, indent=1, sort_keys=True))
print(f"wrote {out} -- sanitise it before committing anything", file=sys.stderr)
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Scrub a harvest dump so it can be committed as a test fixture.
Usage::
tools/sanitize.py tools/harvest-out/<label>.json > tests/fixtures/<label>.json
Two passes. The first walks the dump and collects every value stored under a
sensitive key -- names, serials, UUIDs, MACs, IPs, license keys -- and assigns
each a stable placeholder. The second replaces every occurrence of those
values anywhere in the dump, so a VM name embedded in a disk path
(``[ds1] payroll-db/payroll-db.vmdk``) is caught too, and references between
objects still line up afterwards.
Read the result before committing it. A key this script does not know about
is not scrubbed.
"""
from __future__ import annotations
import ipaddress
import json
import sys
from itertools import count
from typing import Any
_NAME_KEYS = {"name", "hostName", "domainName", "searchDomain"}
_SERIAL_KEYS = {"serialNumber", "identifierValue"}
_UUID_KEYS = {"uuid", "instanceUuid", "config.instanceUuid", "switchUuid"}
_MAC_KEYS = {"mac", "macAddress", "sourceMac"}
_IP_KEYS = {"ipAddress", "address", "managementServerIp"}
_LICENSE_KEYS = {"licenseKey"}
#: Values that identify nothing and must stay as they are.
_KEEP = {"", "0.0.0.0", "::", "127.0.0.1"}
class _Placeholders:
def __init__(self) -> None:
self.mapping: dict[str, str] = {}
self._n = count(1)
def add(self, value: str, make: Any) -> None:
if value not in _KEEP and value not in self.mapping and len(value) > 2:
self.mapping[value] = make(next(self._n))
def _ip(n: int, original: str) -> str:
if isinstance(ipaddress.ip_address(original), ipaddress.IPv6Address):
return f"2001:db8::{n:x}"
return f"192.0.2.{n % 254 + 1}"
def _add(ph: _Placeholders, key: str, value: str) -> None:
if key in _NAME_KEYS:
ph.add(value, lambda n: f"name{n:03d}")
elif key in _SERIAL_KEYS:
ph.add(value.strip(), lambda n: f"SERIAL{n:04d}")
elif key in _UUID_KEYS:
ph.add(value, lambda n: f"00000000-0000-0000-0000-{n:012d}")
elif key in _MAC_KEYS:
ph.add(value.lower(), lambda n: f"00:11:22:33:{n // 256:02x}:{n % 256:02x}")
elif key in _LICENSE_KEYS:
ph.add(value, lambda n: f"00000-00000-00000-00000-{n:05d}")
elif key in _IP_KEYS:
try:
ipaddress.ip_address(value)
except ValueError:
return
ph.add(value, lambda n, v=value: _ip(n, v))
def _collect(node: Any, ph: _Placeholders, key: str = "") -> None:
if isinstance(node, dict):
# A data object's type description ("Service tag") is not sensitive.
if node.get("_type") == "ElementDescription":
return
for k, v in node.items():
_collect(v, ph, k)
elif isinstance(node, list):
for item in node:
_collect(item, ph, key)
elif isinstance(node, str):
_add(ph, key, node)
def _replace(node: Any, mapping: list[tuple[str, str]]) -> Any:
if isinstance(node, dict):
return {k: _replace(v, mapping) for k, v in node.items()}
if isinstance(node, list):
return [_replace(v, mapping) for v in node]
if isinstance(node, str):
for original, placeholder in mapping:
if original in node or original in node.lower():
node = node.replace(original, placeholder)
node = node.replace(original.upper(), placeholder)
return node
return node
def sanitize(data: dict[str, Any]) -> dict[str, Any]:
"""A scrubbed copy of a harvest dump."""
ph = _Placeholders()
_collect(data, ph)
# Longest first, so "esx01.corp.example.com" is replaced before "esx01".
ordered = sorted(ph.mapping.items(), key=lambda kv: len(kv[0]), reverse=True)
return _replace(data, ordered)
def main() -> int: # pragma: no cover - file I/O wrapper
if len(sys.argv) != 2:
print(__doc__, file=sys.stderr)
return 2
with open(sys.argv[1]) as fh:
data = json.load(fh)
json.dump(sanitize(data), sys.stdout, indent=1, sort_keys=True)
print("\nread the output before committing it", file=sys.stderr)
return 0
if __name__ == "__main__": # pragma: no cover
sys.exit(main())