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:
+13
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,6 @@
|
||||
"""NAPALM drivers for VMware ESXi (``vmware_esxi``) and vCenter (``vmware_vcenter``)."""
|
||||
|
||||
from napalm_vmware.esxi import VmwareEsxiDriver
|
||||
from napalm_vmware.vcenter import VmwareVcenterDriver
|
||||
|
||||
__all__ = ["VmwareEsxiDriver", "VmwareVcenterDriver"]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Bulk reads through the vSphere PropertyCollector.
|
||||
|
||||
Every read in this package asks for an explicit list of property paths
|
||||
(``napalm_vmware.paths``) instead of touching attributes on managed objects.
|
||||
One round trip per object type rather than one per attribute, and it keeps
|
||||
working where lazy attribute access does not: pyVmomi fails to deserialize
|
||||
whole ``summary`` objects from some servers while the individual paths are
|
||||
fine.
|
||||
|
||||
This class is the one seam the tests replace. Its output is already plain
|
||||
(see :mod:`napalm_vmware._plain`), so a fake only has to return dicts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from pyVmomi import vim, vmodl
|
||||
|
||||
from napalm_vmware._plain import to_plain
|
||||
|
||||
_PC = vmodl.query.PropertyCollector
|
||||
_PAGE_SIZE = 500
|
||||
|
||||
|
||||
def _vim_type(type_name: str) -> Any:
|
||||
vim_type = getattr(vim, type_name, None) or getattr(vim.dvs, type_name, None)
|
||||
if vim_type is None:
|
||||
raise ValueError(f"Unknown vSphere managed object type {type_name!r}")
|
||||
return vim_type
|
||||
|
||||
|
||||
class Inventory:
|
||||
"""Read-only access to a connected ServiceInstance's content."""
|
||||
|
||||
def __init__(self, content: Any) -> None:
|
||||
self._content = content
|
||||
|
||||
def about(self) -> dict[str, Any]:
|
||||
"""``ServiceContent.about``: product, version and API type of the endpoint."""
|
||||
return to_plain(self._content.about)
|
||||
|
||||
def collect(self, type_name: str, paths: Sequence[str]) -> list[dict[str, Any]]:
|
||||
"""Every object of ``type_name`` in the inventory, with the given properties."""
|
||||
vim_type = _vim_type(type_name)
|
||||
view = self._content.viewManager.CreateContainerView(
|
||||
self._content.rootFolder, [vim_type], True
|
||||
)
|
||||
try:
|
||||
traversal = _PC.TraversalSpec(
|
||||
name="view", path="view", skip=False, type=vim.view.ContainerView
|
||||
)
|
||||
obj_spec = _PC.ObjectSpec(obj=view, skip=True, selectSet=[traversal])
|
||||
return self._retrieve(obj_spec, vim_type, paths)
|
||||
finally:
|
||||
view.Destroy()
|
||||
|
||||
def properties(self, obj: Any, paths: Sequence[str]) -> dict[str, Any]:
|
||||
"""The given properties of one managed object; ``{}`` if it is gone."""
|
||||
rows = self._retrieve(_PC.ObjectSpec(obj=obj), type(obj), paths)
|
||||
return rows[0] if rows else {}
|
||||
|
||||
def licenses(self) -> list[dict[str, Any]]:
|
||||
"""Licenses assigned on this endpoint (edition key, name, used/total)."""
|
||||
manager = getattr(self._content, "licenseManager", None)
|
||||
if manager is None:
|
||||
return []
|
||||
return self.properties(manager, ["licenses"]).get("licenses", [])
|
||||
|
||||
def _retrieve(self, obj_spec: Any, vim_type: Any, paths: Sequence[str]) -> list[dict[str, Any]]:
|
||||
spec = _PC.FilterSpec(
|
||||
objectSet=[obj_spec], propSet=[_PC.PropertySpec(type=vim_type, pathSet=list(paths))]
|
||||
)
|
||||
pc = self._content.propertyCollector
|
||||
result = pc.RetrievePropertiesEx([spec], _PC.RetrieveOptions(maxObjects=_PAGE_SIZE))
|
||||
rows: list[dict[str, Any]] = []
|
||||
while result is not None:
|
||||
rows.extend(_row(obj) for obj in result.objects)
|
||||
if not result.token:
|
||||
break
|
||||
result = pc.ContinueRetrievePropertiesEx(token=result.token)
|
||||
return rows
|
||||
|
||||
|
||||
def _row(obj_content: Any) -> dict[str, Any]:
|
||||
row: dict[str, Any] = {"_moref": obj_content.obj._moId}
|
||||
for prop in obj_content.propSet or []:
|
||||
row[prop.name] = to_plain(prop.val)
|
||||
return row
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Convert pyVmomi objects into plain, JSON-safe Python values.
|
||||
|
||||
The drivers never parse pyVmomi objects directly. Everything fetched from the
|
||||
vSphere API goes through :func:`to_plain` first, so a parser sees the same
|
||||
shape whether its input came from a live host or from a JSON fixture that
|
||||
``tools/harvest.py`` captured.
|
||||
|
||||
Conventions:
|
||||
|
||||
* a managed object (``HostSystem``, ``VirtualMachine`` ...) becomes its MoRef
|
||||
id string, e.g. ``"host-21"``
|
||||
* a data object becomes a dict of its set properties plus ``"_type"``, the
|
||||
WSDL type name (``"VirtualDisk"``) -- device lists are told apart by it
|
||||
* enums become ``str``, datetimes become epoch seconds, binary data ``None``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import Any
|
||||
|
||||
from pyVmomi import VmomiSupport
|
||||
|
||||
_NOISE = frozenset({"dynamicType", "dynamicProperty"})
|
||||
|
||||
|
||||
def to_plain(value: Any) -> Any:
|
||||
"""Recursively convert ``value`` into dicts, lists and scalars."""
|
||||
if value is None or isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, VmomiSupport.ManagedObject):
|
||||
return value._moId
|
||||
if isinstance(value, VmomiSupport.DataObject):
|
||||
return _data_object(value)
|
||||
if isinstance(value, str):
|
||||
return str(value)
|
||||
if isinstance(value, (int, float)):
|
||||
return value
|
||||
if isinstance(value, datetime.datetime):
|
||||
return value.timestamp()
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return None
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [to_plain(v) for v in value]
|
||||
return str(value)
|
||||
|
||||
|
||||
def _data_object(obj: Any) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {"_type": obj._wsdlName}
|
||||
for prop in obj._GetPropertyList():
|
||||
if prop.name in _NOISE:
|
||||
continue
|
||||
val = getattr(obj, prop.name, None)
|
||||
if val is None or (isinstance(val, list) and not val):
|
||||
continue
|
||||
out[prop.name] = to_plain(val)
|
||||
return out
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Opening and closing a vSphere API session.
|
||||
|
||||
Kept apart from everything else because it is the only code that opens a
|
||||
socket; the tests replace it wholesale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
from pyVim.connect import Disconnect, SmartConnect
|
||||
|
||||
|
||||
def _ssl_context(verify: bool) -> ssl.SSLContext:
|
||||
context = ssl.create_default_context()
|
||||
if not verify:
|
||||
# ESXi hosts ship a self-signed certificate; most homelab and many
|
||||
# production hosts keep it.
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
return context
|
||||
|
||||
|
||||
def connect( # pragma: no cover - opens a live session
|
||||
host: str, port: int, user: str, password: str, *, verify_ssl: bool, timeout: int
|
||||
) -> Any:
|
||||
return SmartConnect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
pwd=password,
|
||||
sslContext=_ssl_context(verify_ssl),
|
||||
httpConnectionTimeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def disconnect(si: Any) -> None: # pragma: no cover - talks to a live session
|
||||
Disconnect(si)
|
||||
|
||||
|
||||
def alive(si: Any) -> bool: # pragma: no cover - talks to a live session
|
||||
try:
|
||||
si.CurrentTime()
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,83 @@
|
||||
"""vSphere tasks and faults, in the terms of the HypervisorDriver contract.
|
||||
|
||||
Contract methods raise ``RuntimeError`` when the platform refuses an
|
||||
operation. vSphere reports refusals two ways: a ``MethodFault`` raised by the
|
||||
call itself, or a task that ends in state ``error``. Both end up here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from pyVmomi import vmodl
|
||||
|
||||
from napalm_vmware._plain import to_plain
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
_POLL_SECONDS = 0.5
|
||||
_FREE_LICENSE = (
|
||||
"the host runs the free vSphere Hypervisor license, which makes the "
|
||||
"vSphere API read-only; assign a paid license to change VMs from netOrk"
|
||||
)
|
||||
|
||||
|
||||
def fault_message(fault: dict[str, Any]) -> str:
|
||||
"""A readable sentence for a (plain) vSphere fault."""
|
||||
if fault.get("_type") == "RestrictedVersion":
|
||||
return _FREE_LICENSE
|
||||
return fault.get("localizedMessage") or fault.get("msg") or fault.get("_type", "unknown fault")
|
||||
|
||||
|
||||
def invoke(call: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
||||
"""Run a vSphere call, re-raising any ``MethodFault`` as ``RuntimeError``."""
|
||||
try:
|
||||
return call(*args, **kwargs)
|
||||
except vmodl.MethodFault as fault:
|
||||
raise RuntimeError(fault_message(to_plain(fault))) from fault
|
||||
|
||||
|
||||
def wait_for_task(
|
||||
inventory: Any,
|
||||
task: Any,
|
||||
timeout: float,
|
||||
*,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
) -> None:
|
||||
"""Block until ``task`` succeeds; raise ``RuntimeError`` otherwise."""
|
||||
deadline = clock() + timeout
|
||||
while True:
|
||||
info = inventory.properties(task, ["info.state", "info.error"])
|
||||
if not info:
|
||||
raise RuntimeError("the vSphere task disappeared before it finished")
|
||||
state = info.get("info.state")
|
||||
if state == "success":
|
||||
return
|
||||
if state == "error":
|
||||
raise RuntimeError(fault_message(info.get("info.error") or {}))
|
||||
if clock() >= deadline:
|
||||
raise RuntimeError(f"the vSphere task did not finish within {timeout:g}s")
|
||||
sleep(_POLL_SECONDS)
|
||||
|
||||
|
||||
def wait_until(
|
||||
check: Callable[[], bool],
|
||||
timeout: float,
|
||||
what: str,
|
||||
*,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
) -> None:
|
||||
"""Poll ``check`` until it is true; for operations vSphere runs without a task.
|
||||
|
||||
``ShutdownGuest`` only *asks* VMware Tools to shut the guest down and
|
||||
returns at once, so completion has to be observed from the power state.
|
||||
"""
|
||||
deadline = clock() + timeout
|
||||
while not check():
|
||||
if clock() >= deadline:
|
||||
raise RuntimeError(f"{what} did not finish within {timeout:g}s")
|
||||
sleep(_POLL_SECONDS)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Power and snapshot operations (the write half of the HypervisorDriver contract)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyVmomi import vim
|
||||
|
||||
from napalm_vmware._tasks import invoke, wait_for_task, wait_until
|
||||
from napalm_vmware.parse.snapshots import find_snapshot, snapshot_list
|
||||
|
||||
_TASK_TIMEOUT = 300
|
||||
_GUEST_SHUTDOWN_TIMEOUT = 300
|
||||
_RUNNING = "poweredOn"
|
||||
_TOOLS_RUNNING = "guestToolsRunning"
|
||||
|
||||
|
||||
class VmwareActionsMixin:
|
||||
"""Mixed into both drivers ahead of :class:`VmwareBaseDriver`."""
|
||||
|
||||
_inventory: Any
|
||||
_find_vm: Any
|
||||
_mo: Any
|
||||
|
||||
def _vm_mo(self, vm: dict[str, Any]) -> Any:
|
||||
return self._mo(vim.VirtualMachine, vm["_moref"])
|
||||
|
||||
def _run(self, call: Any, *args: Any, **kwargs: Any) -> None:
|
||||
wait_for_task(self._inventory, invoke(call, *args, **kwargs), _TASK_TIMEOUT)
|
||||
|
||||
@staticmethod
|
||||
def _require_running(vm: dict[str, Any], action: str) -> None:
|
||||
if vm.get("runtime.powerState") != _RUNNING:
|
||||
raise RuntimeError(f"Cannot {action} VM {vm.get('name')!r}: it is not running")
|
||||
|
||||
@staticmethod
|
||||
def _require_tools(vm: dict[str, Any], action: str) -> None:
|
||||
if vm.get("guest.toolsRunningStatus") != _TOOLS_RUNNING:
|
||||
raise RuntimeError(
|
||||
f"A graceful {action} of {vm.get('name')!r} needs VMware Tools running "
|
||||
"in the guest; use force to cut power instead"
|
||||
)
|
||||
|
||||
# -- power ---------------------------------------------------------------
|
||||
|
||||
def start_vm(self, name: str) -> None:
|
||||
self._run(self._vm_mo(self._find_vm(name)).PowerOnVM_Task)
|
||||
|
||||
def stop_vm(self, name: str, force: bool = False) -> None:
|
||||
vm = self._find_vm(name)
|
||||
self._require_running(vm, "stop")
|
||||
mo = self._vm_mo(vm)
|
||||
if force:
|
||||
self._run(mo.PowerOffVM_Task)
|
||||
return
|
||||
self._require_tools(vm, "shutdown")
|
||||
invoke(mo.ShutdownGuest)
|
||||
wait_until(
|
||||
lambda: (
|
||||
self._inventory.properties(mo, ["runtime.powerState"]).get("runtime.powerState")
|
||||
== "poweredOff"
|
||||
),
|
||||
_GUEST_SHUTDOWN_TIMEOUT,
|
||||
f"guest shutdown of {vm.get('name')!r}",
|
||||
)
|
||||
|
||||
def reboot_vm(self, name: str, force: bool = False) -> None:
|
||||
vm = self._find_vm(name)
|
||||
self._require_running(vm, "reboot")
|
||||
mo = self._vm_mo(vm)
|
||||
if force:
|
||||
self._run(mo.ResetVM_Task)
|
||||
return
|
||||
# RebootGuest returns once Tools accepted the request; the guest gives
|
||||
# no signal when it is back, so there is nothing further to wait for.
|
||||
self._require_tools(vm, "reboot")
|
||||
invoke(mo.RebootGuest)
|
||||
|
||||
def suspend_vm(self, name: str) -> None:
|
||||
vm = self._find_vm(name)
|
||||
self._require_running(vm, "suspend")
|
||||
self._run(self._vm_mo(vm).SuspendVM_Task)
|
||||
|
||||
# -- snapshots -------------------------------------------------------------
|
||||
|
||||
def create_vm_snapshot(
|
||||
self, name: str, snapshot: str, description: str = "", include_memory: bool = False
|
||||
) -> None:
|
||||
vm = self._find_vm(name)
|
||||
if any(s["name"] == snapshot for s in snapshot_list(vm)):
|
||||
raise ValueError(f"VM {vm.get('name')!r} already has a snapshot named {snapshot!r}")
|
||||
self._run(
|
||||
self._vm_mo(vm).CreateSnapshot_Task,
|
||||
name=snapshot,
|
||||
description=description,
|
||||
memory=include_memory and vm.get("runtime.powerState") == _RUNNING,
|
||||
quiesce=False,
|
||||
)
|
||||
|
||||
def _snapshot_mo(self, name: str, snapshot: str) -> Any:
|
||||
return self._mo(vim.vm.Snapshot, find_snapshot(self._find_vm(name), snapshot))
|
||||
|
||||
def delete_vm_snapshot(self, name: str, snapshot: str) -> None:
|
||||
# removeChildren=False consolidates the snapshot into its children
|
||||
# instead of discarding them.
|
||||
self._run(self._snapshot_mo(name, snapshot).RemoveSnapshot_Task, removeChildren=False)
|
||||
|
||||
def rollback_vm_snapshot(self, name: str, snapshot: str) -> None:
|
||||
self._run(self._snapshot_mo(name, snapshot).RevertToSnapshot_Task)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""What the ESXi and vCenter drivers share: session, VM lookup, common getters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from napalm.base.exceptions import ConnectionException
|
||||
from napalm_device_types import HypervisorDriver
|
||||
from napalm_device_types.models import (
|
||||
SnapshotDict,
|
||||
StorageVolumeDict,
|
||||
VirtualNetworkDict,
|
||||
VMConfigDict,
|
||||
)
|
||||
|
||||
from napalm_vmware import _session, paths
|
||||
from napalm_vmware._inventory import Inventory
|
||||
from napalm_vmware.parse.environment import environment
|
||||
from napalm_vmware.parse.networks import NetworkIndex, network_index, virtual_networks
|
||||
from napalm_vmware.parse.snapshots import snapshot_list
|
||||
from napalm_vmware.parse.storage import storage_pools
|
||||
from napalm_vmware.parse.vm_config import vm_config
|
||||
from napalm_vmware.parse.vms import vm_list
|
||||
from napalm_vmware.parse.warnings import host_warnings
|
||||
|
||||
_DEFAULT_PORT = 443
|
||||
#: ``about.apiType`` -> the driver that handles it, for a helpful refusal.
|
||||
_DRIVER_FOR_API = {"HostAgent": "vmware_esxi", "VirtualCenter": "vmware_vcenter"}
|
||||
|
||||
|
||||
class VmwareBaseDriver(HypervisorDriver):
|
||||
"""Shared implementation. Concrete drivers set ``API_TYPE`` and ``STANDALONE``."""
|
||||
|
||||
VENDOR = "VMware"
|
||||
USES_SSH = False
|
||||
platform = "vmware"
|
||||
|
||||
#: ``about.apiType`` this driver accepts.
|
||||
API_TYPE: ClassVar[str] = ""
|
||||
#: True when the driver talks to one host directly (ESXi), False for vCenter.
|
||||
STANDALONE: ClassVar[bool] = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hostname: str,
|
||||
username: str,
|
||||
password: str,
|
||||
timeout: int = 60,
|
||||
optional_args: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.hostname = hostname
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
args = optional_args or {}
|
||||
self._port = int(args.get("port") or _DEFAULT_PORT)
|
||||
self._verify_ssl = bool(args.get("verify_ssl", args.get("ssl_verify", True)))
|
||||
self._si: Any = None
|
||||
self._inventory: Any = None
|
||||
|
||||
# -- session -------------------------------------------------------------
|
||||
|
||||
def open(self) -> None:
|
||||
try:
|
||||
si = _session.connect(
|
||||
self.hostname,
|
||||
self._port,
|
||||
self.username,
|
||||
self.password,
|
||||
verify_ssl=self._verify_ssl,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ConnectionException(f"Cannot connect to {self.hostname}: {exc}") from exc
|
||||
inventory = Inventory(si.RetrieveContent())
|
||||
about = inventory.about()
|
||||
if about.get("apiType") != self.API_TYPE:
|
||||
_session.disconnect(si)
|
||||
other = _DRIVER_FOR_API.get(about.get("apiType", ""), "another driver")
|
||||
raise ConnectionException(
|
||||
f"{self.hostname} is {about.get('fullName', 'not a supported VMware endpoint')}"
|
||||
f"; use the {other} driver for it"
|
||||
)
|
||||
self._si, self._inventory = si, inventory
|
||||
|
||||
def close(self) -> None:
|
||||
if self._si is not None:
|
||||
_session.disconnect(self._si)
|
||||
self._si = self._inventory = None
|
||||
|
||||
def is_alive(self) -> dict[str, bool]:
|
||||
return {"is_alive": self._si is not None and _session.alive(self._si)}
|
||||
|
||||
def _mo(self, vim_type: Any, moref: str) -> Any:
|
||||
"""A live managed-object reference for a MoRef id from a plain row."""
|
||||
return vim_type(moref, self._si._stub)
|
||||
|
||||
# -- inventory reads -------------------------------------------------------
|
||||
|
||||
def _hosts(self) -> list[dict[str, Any]]:
|
||||
return self._inventory.collect("HostSystem", paths.HOST)
|
||||
|
||||
def _vm_rows(self) -> list[dict[str, Any]]:
|
||||
return self._inventory.collect("VirtualMachine", paths.VM)
|
||||
|
||||
def _index(self, hosts: list[dict[str, Any]]) -> NetworkIndex:
|
||||
dv_portgroups = self._inventory.collect("DistributedVirtualPortgroup", paths.DV_PORTGROUP)
|
||||
return network_index(hosts, dv_portgroups)
|
||||
|
||||
def _find_vm(self, name: str) -> dict[str, Any]:
|
||||
"""The VM whose instance UUID, MoRef or name is ``name``."""
|
||||
vms = [
|
||||
vm
|
||||
for vm in self._vm_rows()
|
||||
if vm.get("config.instanceUuid") and not vm.get("config.template")
|
||||
]
|
||||
for key in ("config.instanceUuid", "_moref"):
|
||||
match = [vm for vm in vms if vm.get(key) == name]
|
||||
if match:
|
||||
return match[0]
|
||||
match = [vm for vm in vms if vm.get("name") == name]
|
||||
if len(match) > 1:
|
||||
raise ValueError(f"{len(match)} VMs are named {name!r}; address one by its vmid")
|
||||
if not match:
|
||||
raise ValueError(f"There is no VM named or identified by {name!r}")
|
||||
return match[0]
|
||||
|
||||
# -- HypervisorDriver ------------------------------------------------------
|
||||
|
||||
def get_vms(self) -> list[dict[str, Any]]:
|
||||
hosts = self._hosts()
|
||||
return vm_list(self._vm_rows(), hosts, self._index(hosts))
|
||||
|
||||
def get_vm_config(self, name: str) -> VMConfigDict:
|
||||
return vm_config(self._find_vm(name), self._index(self._hosts()))
|
||||
|
||||
def get_vm_snapshots(self, name: str) -> list[SnapshotDict]:
|
||||
return snapshot_list(self._find_vm(name))
|
||||
|
||||
def get_vm_storage_pools(self) -> dict[str, StorageVolumeDict]:
|
||||
return storage_pools(self._inventory.collect("Datastore", paths.DATASTORE))
|
||||
|
||||
def get_virtual_networks(self) -> dict[str, VirtualNetworkDict]:
|
||||
return virtual_networks( # type: ignore[return-value]
|
||||
self._hosts(),
|
||||
self._inventory.collect("DistributedVirtualPortgroup", paths.DV_PORTGROUP),
|
||||
self._inventory.collect("DistributedVirtualSwitch", paths.DV_SWITCH),
|
||||
)
|
||||
|
||||
def get_environment(self) -> dict[str, Any]:
|
||||
return environment(self._hosts())
|
||||
|
||||
def get_device_warnings(self) -> list[dict[str, Any]]:
|
||||
return host_warnings(self._hosts(), self._inventory.licenses(), standalone=self.STANDALONE)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""NAPALM driver for a VMware ESXi host, addressed directly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from napalm_device_types import FingerprintRule
|
||||
from pyVmomi import vim
|
||||
|
||||
from napalm_vmware._plain import to_plain
|
||||
from napalm_vmware._tasks import invoke
|
||||
from napalm_vmware.actions import VmwareActionsMixin
|
||||
from napalm_vmware.base import VmwareBaseDriver
|
||||
from napalm_vmware.parse.facts import esxi_facts
|
||||
from napalm_vmware.parse.interfaces import host_interfaces, host_interfaces_ip
|
||||
from napalm_vmware.parse.lldp import lldp_neighbors
|
||||
|
||||
|
||||
class VmwareEsxiDriver(VmwareActionsMixin, VmwareBaseDriver):
|
||||
"""One ESXi host. Its VMs, vmnics, vmkernel NICs, datastores and port groups."""
|
||||
|
||||
DRIVER_NAME = "vmware_esxi"
|
||||
API_TYPE = "HostAgent"
|
||||
STANDALONE = True
|
||||
# A host reboot runs through hardware POST before ESXi starts its services.
|
||||
REBOOT_SETTLE_SECONDS = 300
|
||||
# Unverified against real hardware: the Host Client landing page says
|
||||
# "VMware ESXi"; a vCenter's does not, and it says "vCenter".
|
||||
HTTP_FINGERPRINT = [
|
||||
FingerprintRule("vmware esxi", weight=9.0, mandatory=True),
|
||||
FingerprintRule("vcenter", weight=9.0, negative=True),
|
||||
]
|
||||
|
||||
def _host(self) -> dict[str, Any]:
|
||||
hosts = self._hosts()
|
||||
if not hosts:
|
||||
raise RuntimeError(f"{self.hostname} shows no host in its inventory")
|
||||
return hosts[0]
|
||||
|
||||
def get_facts(self) -> dict[str, Any]:
|
||||
return esxi_facts(self._inventory.about(), self._host())
|
||||
|
||||
def get_interfaces(self) -> dict[str, dict[str, Any]]:
|
||||
return host_interfaces(self._host())
|
||||
|
||||
def get_interfaces_ip(self) -> dict[str, dict[str, Any]]:
|
||||
return host_interfaces_ip(self._host())
|
||||
|
||||
def get_lldp_neighbors(self) -> dict[str, list[dict[str, str]]]:
|
||||
network_system = self._host().get("configManager.networkSystem")
|
||||
if not network_system:
|
||||
return {}
|
||||
mo = self._mo(vim.host.NetworkSystem, network_system)
|
||||
try:
|
||||
hints = invoke(mo.QueryNetworkHint)
|
||||
except RuntimeError:
|
||||
return {}
|
||||
return lldp_neighbors(to_plain(hints) or [])
|
||||
@@ -0,0 +1,65 @@
|
||||
"""NAPALM ``get_environment`` from host quick stats and hardware sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_SENSORS = "runtime.healthSystemRuntime.systemHealthInfo.numericSensorInfo"
|
||||
_MB = 1024 * 1024
|
||||
|
||||
|
||||
def _reading(sensor: dict[str, Any]) -> float:
|
||||
return float(sensor.get("currentReading", 0)) * 10 ** int(sensor.get("unitModifier", 0))
|
||||
|
||||
|
||||
def _health(sensor: dict[str, Any]) -> str:
|
||||
return (sensor.get("healthState") or {}).get("key", "unknown")
|
||||
|
||||
|
||||
def _cpu_percent(host: dict[str, Any]) -> float:
|
||||
hardware = host.get("summary.hardware") or {}
|
||||
capacity = int(hardware.get("cpuMhz") or 0) * int(hardware.get("numCpuCores") or 0)
|
||||
used = (host.get("summary.quickStats") or {}).get("overallCpuUsage") or 0
|
||||
return round(used / capacity * 100, 2) if capacity else 0.0
|
||||
|
||||
|
||||
def _add_sensors(env: dict[str, Any], host: dict[str, Any], prefix: str) -> None:
|
||||
for sensor in host.get(_SENSORS) or []:
|
||||
name = f"{prefix}{sensor.get('name', '')}"
|
||||
kind = sensor.get("sensorType")
|
||||
health = _health(sensor)
|
||||
if kind == "temperature":
|
||||
env["temperature"][name] = {
|
||||
"temperature": _reading(sensor),
|
||||
"is_alert": health in ("yellow", "red"),
|
||||
"is_critical": health == "red",
|
||||
}
|
||||
elif kind == "fan":
|
||||
env["fans"][name] = {"status": health == "green"}
|
||||
elif kind == "power":
|
||||
env["power"][name] = {
|
||||
"status": health == "green",
|
||||
"capacity": -1.0,
|
||||
"output": _reading(sensor),
|
||||
}
|
||||
|
||||
|
||||
def environment(hosts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""One host: keyed like a single device. Several (vCenter): keyed by host."""
|
||||
env: dict[str, Any] = {
|
||||
"fans": {},
|
||||
"temperature": {},
|
||||
"power": {},
|
||||
"cpu": {},
|
||||
"memory": {"available_ram": 0, "used_ram": 0},
|
||||
}
|
||||
single = len(hosts) == 1
|
||||
for host in hosts:
|
||||
key = "0" if single else host.get("name", host["_moref"])
|
||||
env["cpu"][key] = {"%usage": _cpu_percent(host)}
|
||||
hardware = host.get("summary.hardware") or {}
|
||||
stats = host.get("summary.quickStats") or {}
|
||||
env["memory"]["available_ram"] += int(hardware.get("memorySize") or 0)
|
||||
env["memory"]["used_ram"] += int(stats.get("overallMemoryUsage") or 0) * _MB
|
||||
_add_sensors(env, host, "" if single else f"{key}/")
|
||||
return env
|
||||
@@ -0,0 +1,94 @@
|
||||
"""NAPALM ``get_facts`` from a host's properties or a vCenter's ``about``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from typing import Any
|
||||
|
||||
#: otherIdentifyingInfo keys that carry a chassis serial, most specific first.
|
||||
#: Dell reports its service tag as ``ServiceTag``; HPE and Supermicro use
|
||||
#: ``SerialNumberTag``; blades add ``EnclosureSerialNumberTag`` for the chassis.
|
||||
_SERIAL_KEYS = ("SerialNumberTag", "EnclosureSerialNumberTag", "ServiceTag")
|
||||
|
||||
|
||||
def host_serial(host: dict[str, Any]) -> str:
|
||||
"""The host's hardware serial, or ``""`` when it does not report one."""
|
||||
info = host.get("hardware.systemInfo") or {}
|
||||
if str(info.get("serialNumber", "")).strip():
|
||||
return str(info["serialNumber"]).strip()
|
||||
for source in (info, host.get("summary.hardware") or {}):
|
||||
by_key: dict[str, str] = {}
|
||||
for entry in source.get("otherIdentifyingInfo", []):
|
||||
value = str(entry.get("identifierValue", "")).strip()
|
||||
key = (entry.get("identifierType") or {}).get("key", "")
|
||||
if value and key not in by_key:
|
||||
by_key[key] = value
|
||||
for key in _SERIAL_KEYS:
|
||||
if key in by_key:
|
||||
return by_key[key]
|
||||
return ""
|
||||
|
||||
|
||||
def _host_names(host: dict[str, Any]) -> tuple[str, str]:
|
||||
dns = host.get("config.network.dnsConfig") or {}
|
||||
short = dns.get("hostName") or ""
|
||||
domain = dns.get("domainName") or ""
|
||||
if short:
|
||||
return short, f"{short}.{domain}" if domain else short
|
||||
name = host.get("name", "")
|
||||
return name.split(".", 1)[0], name
|
||||
|
||||
|
||||
def host_interface_names(host: dict[str, Any]) -> list[str]:
|
||||
nics = (host.get("config.network.pnic") or []) + (host.get("config.network.vnic") or [])
|
||||
return sorted(n["device"] for n in nics if n.get("device"))
|
||||
|
||||
|
||||
def esxi_facts(about: dict[str, Any], host: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Facts of a standalone ESXi host (or one reached directly)."""
|
||||
hostname, fqdn = _host_names(host)
|
||||
# hardware.systemInfo is read straight from SMBIOS; summary.hardware is a
|
||||
# cached copy that has been seen to disagree with it.
|
||||
system = host.get("hardware.systemInfo") or {}
|
||||
summary = host.get("summary.hardware") or {}
|
||||
product = host.get("config.product") or about
|
||||
uptime = (host.get("summary.quickStats") or {}).get("uptime")
|
||||
return {
|
||||
"hostname": hostname,
|
||||
"fqdn": fqdn,
|
||||
"vendor": system.get("vendor") or summary.get("vendor", ""),
|
||||
"model": system.get("model") or summary.get("model", ""),
|
||||
"serial_number": host_serial(host),
|
||||
"os_version": product.get("fullName", ""),
|
||||
"uptime": float(uptime) if uptime is not None else -1.0,
|
||||
"interface_list": host_interface_names(host),
|
||||
}
|
||||
|
||||
|
||||
def _is_ip(value: str) -> bool:
|
||||
try:
|
||||
ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def vcenter_facts(about: dict[str, Any], address: str) -> dict[str, Any]:
|
||||
"""Facts of a vCenter Server.
|
||||
|
||||
The vSphere API does not expose the appliance's own name, uptime or NICs;
|
||||
those belong to the appliance management API. The address netOrk used to
|
||||
connect is the best available name, and the instance UUID is the one
|
||||
identifier that is stable for the lifetime of the installation.
|
||||
"""
|
||||
hostname = address if _is_ip(address) else address.split(".", 1)[0]
|
||||
return {
|
||||
"hostname": hostname,
|
||||
"fqdn": address,
|
||||
"vendor": about.get("vendor", ""),
|
||||
"model": about.get("name", ""),
|
||||
"serial_number": about.get("instanceUuid", ""),
|
||||
"os_version": about.get("fullName", ""),
|
||||
"uptime": -1.0,
|
||||
"interface_list": [],
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""NAPALM interface getters for an ESXi host.
|
||||
|
||||
A host has two kinds of interface worth reporting: physical uplinks
|
||||
(``vmnicN``) and VMkernel adapters (``vmkN``), which carry the host's own IP
|
||||
addresses. Port groups are not interfaces; they are reported by
|
||||
``get_virtual_networks``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from typing import Any
|
||||
|
||||
_DEFAULT_MTU = 1500
|
||||
_UNASSIGNED = {"", "0.0.0.0"}
|
||||
|
||||
|
||||
def _uplink_mtus(host: dict[str, Any]) -> dict[str, int]:
|
||||
"""pnic key -> MTU of the standard vSwitch that uses it as an uplink."""
|
||||
mtus: dict[str, int] = {}
|
||||
for vswitch in host.get("config.network.vswitch") or []:
|
||||
for pnic_key in vswitch.get("pnic", []):
|
||||
mtus[pnic_key] = int(vswitch.get("mtu") or _DEFAULT_MTU)
|
||||
return mtus
|
||||
|
||||
|
||||
def _iface(up: bool, description: str, speed: float, mtu: int, mac: str) -> dict[str, Any]:
|
||||
return {
|
||||
"is_up": up,
|
||||
"is_enabled": True,
|
||||
"description": description,
|
||||
"last_flapped": -1.0,
|
||||
"speed": speed,
|
||||
"mtu": mtu,
|
||||
"mac_address": mac.upper(),
|
||||
}
|
||||
|
||||
|
||||
def host_interfaces(host: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""vmnics and vmks in the shape of NAPALM's ``get_interfaces``."""
|
||||
mtus = _uplink_mtus(host)
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for pnic in host.get("config.network.pnic") or []:
|
||||
link = pnic.get("linkSpeed") or {}
|
||||
description = " ".join(p for p in (pnic.get("driver"), pnic.get("pci")) if p)
|
||||
result[pnic["device"]] = _iface(
|
||||
up=bool(link),
|
||||
description=description,
|
||||
speed=float(link.get("speedMb", 0)),
|
||||
mtu=mtus.get(pnic.get("key", ""), _DEFAULT_MTU),
|
||||
mac=pnic.get("mac", ""),
|
||||
)
|
||||
for vnic in host.get("config.network.vnic") or []:
|
||||
spec = vnic.get("spec") or {}
|
||||
result[vnic["device"]] = _iface(
|
||||
up=True,
|
||||
description=vnic.get("portgroup") or spec.get("portgroup", ""),
|
||||
speed=0.0,
|
||||
mtu=int(spec.get("mtu") or _DEFAULT_MTU),
|
||||
mac=spec.get("mac", ""),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _ipv4(ip: dict[str, Any]) -> dict[str, dict[str, int]]:
|
||||
address = ip.get("ipAddress", "")
|
||||
if address in _UNASSIGNED:
|
||||
return {}
|
||||
network = ipaddress.IPv4Network(f"0.0.0.0/{ip.get('subnetMask') or '32'}")
|
||||
return {address: {"prefix_length": network.prefixlen}}
|
||||
|
||||
|
||||
def _ipv6(ip: dict[str, Any]) -> dict[str, dict[str, int]]:
|
||||
entries = (ip.get("ipV6Config") or {}).get("ipV6Address", [])
|
||||
return {e["ipAddress"]: {"prefix_length": int(e.get("prefixLength", 128))} for e in entries}
|
||||
|
||||
|
||||
def host_interfaces_ip(host: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""Addresses of the VMkernel adapters, as NAPALM's ``get_interfaces_ip``."""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for vnic in host.get("config.network.vnic") or []:
|
||||
ip = (vnic.get("spec") or {}).get("ip") or {}
|
||||
families = {"ipv4": _ipv4(ip), "ipv6": _ipv6(ip)}
|
||||
families = {k: v for k, v in families.items() if v}
|
||||
if families:
|
||||
result[vnic["device"]] = families
|
||||
return result
|
||||
@@ -0,0 +1,33 @@
|
||||
"""NAPALM ``get_lldp_neighbors`` from ``HostNetworkSystem.QueryNetworkHint``.
|
||||
|
||||
ESXi listens for CDP on standard vSwitches by default and for LLDP where a
|
||||
vDS enables it. Both answers come back per vmnic in one hint; LLDP is
|
||||
preferred because it names the switch port the way the switch itself does.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _lldp(info: dict[str, Any]) -> dict[str, str] | None:
|
||||
if not info.get("portId"):
|
||||
return None
|
||||
params = {p.get("key"): p.get("value") for p in info.get("parameter", [])}
|
||||
hostname = params.get("System Name") or info.get("chassisId", "")
|
||||
return {"hostname": str(hostname), "port": str(info["portId"])}
|
||||
|
||||
|
||||
def _cdp(info: dict[str, Any]) -> dict[str, str] | None:
|
||||
if not info.get("portId"):
|
||||
return None
|
||||
return {"hostname": str(info.get("devId", "")), "port": str(info["portId"])}
|
||||
|
||||
|
||||
def lldp_neighbors(hints: list[dict[str, Any]]) -> dict[str, list[dict[str, str]]]:
|
||||
result: dict[str, list[dict[str, str]]] = {}
|
||||
for hint in hints:
|
||||
neighbor = _lldp(hint.get("lldpInfo") or {}) or _cdp(hint.get("connectedSwitchPort") or {})
|
||||
if neighbor:
|
||||
result[hint["device"]] = [neighbor]
|
||||
return result
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Port groups: what exists, which VLAN each carries, and which one a NIC uses.
|
||||
|
||||
Two kinds of network can back a VM NIC. A *standard* port group lives on one
|
||||
host's vSwitch and is named in the NIC's backing directly. A *distributed*
|
||||
port group lives on a vCenter-managed vDS, and the NIC names it by key
|
||||
(``dvportgroup-12``) -- so its display name and VLAN have to be looked up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
#: vCenter's default name for a vDS's uplink port group, used when the server
|
||||
#: is too old to report ``config.uplink``.
|
||||
_UPLINK_NAME = re.compile(r"-DVUplinks-\d+$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkIndex:
|
||||
"""Port group name -> VLAN, and dvPortgroup key -> (name, VLAN)."""
|
||||
|
||||
standard: dict[str, int] = field(default_factory=dict)
|
||||
distributed: dict[str, tuple[str, int]] = field(default_factory=dict)
|
||||
|
||||
|
||||
def dv_vlan(port_config: dict[str, Any] | None) -> int:
|
||||
"""The single VLAN of a dvPortgroup, or 0 for untagged/trunk."""
|
||||
vlan = (port_config or {}).get("vlan") or {}
|
||||
kind = vlan.get("_type", "")
|
||||
if kind.endswith("PvlanSpec"):
|
||||
return int(vlan.get("pvlanId", 0))
|
||||
if kind.endswith("VlanIdSpec"):
|
||||
return int(vlan.get("vlanId", 0))
|
||||
return 0
|
||||
|
||||
|
||||
def _standard_portgroups(hosts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
pg.get("spec") or {} for host in hosts for pg in host.get("config.network.portgroup") or []
|
||||
]
|
||||
|
||||
|
||||
def network_index(hosts: list[dict[str, Any]], dv_portgroups: list[dict[str, Any]]) -> NetworkIndex:
|
||||
index = NetworkIndex()
|
||||
for spec in _standard_portgroups(hosts):
|
||||
index.standard.setdefault(spec.get("name", ""), int(spec.get("vlanId", 0)))
|
||||
for pg in dv_portgroups:
|
||||
vlan = dv_vlan(pg.get("config.defaultPortConfig"))
|
||||
index.distributed[pg["_moref"]] = (pg.get("name", pg["_moref"]), vlan)
|
||||
return index
|
||||
|
||||
|
||||
def nic_network(nic: dict[str, Any], index: NetworkIndex) -> tuple[str, int]:
|
||||
"""``(network name, VLAN)`` a virtual NIC is connected to."""
|
||||
backing = nic.get("backing") or {}
|
||||
kind = backing.get("_type", "")
|
||||
if kind.endswith("DistributedVirtualPortBackingInfo"):
|
||||
key = (backing.get("port") or {}).get("portgroupKey", "")
|
||||
return index.distributed.get(key, (key, 0))
|
||||
if kind.endswith("OpaqueNetworkBackingInfo"):
|
||||
return backing.get("opaqueNetworkId", ""), 0
|
||||
name = backing.get("deviceName", "")
|
||||
return name, index.standard.get(name, 0)
|
||||
|
||||
|
||||
def _network(name: str, kind: str, bridge: str, vlan: int) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"type": kind,
|
||||
"bridge": bridge,
|
||||
"vlan_id": vlan,
|
||||
"autostart": True,
|
||||
"active": True,
|
||||
}
|
||||
|
||||
|
||||
def _is_uplink(pg: dict[str, Any]) -> bool:
|
||||
if "config.uplink" in pg:
|
||||
return bool(pg["config.uplink"])
|
||||
return bool(_UPLINK_NAME.search(pg.get("name", "")))
|
||||
|
||||
|
||||
def virtual_networks(
|
||||
hosts: list[dict[str, Any]],
|
||||
dv_portgroups: list[dict[str, Any]],
|
||||
dv_switches: list[dict[str, Any]],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Every network a VM can attach to, as ``get_virtual_networks`` returns it."""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for spec in _standard_portgroups(hosts):
|
||||
name = spec.get("name", "")
|
||||
result.setdefault(
|
||||
name,
|
||||
_network(name, "portgroup", spec.get("vswitchName", ""), int(spec.get("vlanId", 0))),
|
||||
)
|
||||
switch_names = {s["_moref"]: s.get("name", s["_moref"]) for s in dv_switches}
|
||||
for pg in dv_portgroups:
|
||||
if _is_uplink(pg):
|
||||
continue
|
||||
dvs = pg.get("config.distributedVirtualSwitch", "")
|
||||
vlan = dv_vlan(pg.get("config.defaultPortConfig"))
|
||||
name = pg.get("name", pg["_moref"])
|
||||
result[name] = _network(name, "dvportgroup", switch_names.get(dvs, dvs), vlan)
|
||||
return result
|
||||
@@ -0,0 +1,52 @@
|
||||
"""A VM's snapshot tree, flattened, and snapshot lookup by name."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from napalm_device_types.models import SnapshotDict
|
||||
|
||||
|
||||
def _walk(nodes: list[dict[str, Any]], parent: str) -> Iterator[tuple[dict[str, Any], str]]:
|
||||
for node in nodes:
|
||||
yield node, parent
|
||||
yield from _walk(node.get("childSnapshotList", []), node.get("name", ""))
|
||||
|
||||
|
||||
def _nodes(vm: dict[str, Any]) -> Iterator[tuple[dict[str, Any], str]]:
|
||||
return _walk((vm.get("snapshot") or {}).get("rootSnapshotList", []), "")
|
||||
|
||||
|
||||
def snapshot_list(vm: dict[str, Any]) -> list[SnapshotDict]:
|
||||
"""Every snapshot, depth first, each naming its parent (``""`` for a root)."""
|
||||
return [
|
||||
{
|
||||
"name": node.get("name", ""),
|
||||
"vm": vm.get("name", ""),
|
||||
"created": float(node.get("createTime", 0.0)),
|
||||
"description": node.get("description", ""),
|
||||
# A snapshot taken with memory records the VM as powered on.
|
||||
"has_memory": node.get("state") == "poweredOn",
|
||||
"parent": parent,
|
||||
}
|
||||
for node, parent in _nodes(vm)
|
||||
]
|
||||
|
||||
|
||||
def find_snapshot(vm: dict[str, Any], name: str) -> str:
|
||||
"""MoRef of the one snapshot called ``name``.
|
||||
|
||||
Raises ``ValueError`` when there is none -- or more than one: vSphere does
|
||||
not require snapshot names to be unique, and choosing one of two
|
||||
candidates could delete or roll back to the wrong state.
|
||||
"""
|
||||
matches = [node["snapshot"] for node, _ in _nodes(vm) if node.get("name") == name]
|
||||
if not matches:
|
||||
raise ValueError(f"VM {vm.get('name')!r} has no snapshot named {name!r}")
|
||||
if len(matches) > 1:
|
||||
raise ValueError(
|
||||
f"VM {vm.get('name')!r} has {len(matches)} snapshots named {name!r}; "
|
||||
"rename one in vSphere first"
|
||||
)
|
||||
return matches[0]
|
||||
@@ -0,0 +1,27 @@
|
||||
"""``get_vm_storage_pools``: datastores."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from napalm_device_types.models import StorageVolumeDict
|
||||
|
||||
|
||||
def storage_pools(datastores: list[dict[str, Any]]) -> dict[str, StorageVolumeDict]:
|
||||
"""Datastores keyed by name. ``enabled`` means currently accessible."""
|
||||
result: dict[str, StorageVolumeDict] = {}
|
||||
for ds in datastores:
|
||||
summary = ds.get("summary") or {}
|
||||
name = ds.get("name") or summary.get("name", ds["_moref"])
|
||||
total = int(summary.get("capacity") or 0)
|
||||
free = int(summary.get("freeSpace") or 0)
|
||||
result[name] = {
|
||||
"name": name,
|
||||
"type": str(summary.get("type", "")).lower(),
|
||||
"total": total,
|
||||
"used": total - free,
|
||||
"available": free,
|
||||
"enabled": bool(summary.get("accessible", False)),
|
||||
"shared": bool(summary.get("multipleHostAccess", False)),
|
||||
}
|
||||
return result
|
||||
@@ -0,0 +1,96 @@
|
||||
"""``get_vm_config``: a VM's virtual hardware as a VMConfigDict."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from napalm_device_types.models import VMConfigDict, VMDiskDict, VMNICDict
|
||||
|
||||
from napalm_vmware.parse import vm_devices as dev
|
||||
from napalm_vmware.parse.networks import NetworkIndex, nic_network
|
||||
|
||||
_GB = 1024**3
|
||||
_BOOT_KIND = {
|
||||
"VirtualMachineBootOptionsBootableCdromDevice": "cdrom",
|
||||
"VirtualMachineBootOptionsBootableFloppyDevice": "floppy",
|
||||
}
|
||||
|
||||
|
||||
def _boot_order(vm: dict[str, Any], labels: dict[int, str]) -> list[str]:
|
||||
order: list[str] = []
|
||||
for entry in vm.get("config.bootOptions.bootOrder") or []:
|
||||
kind = entry.get("_type", "")
|
||||
if kind in _BOOT_KIND:
|
||||
order.append(_BOOT_KIND[kind])
|
||||
elif "deviceKey" in entry:
|
||||
order.append(labels.get(entry["deviceKey"], str(entry["deviceKey"])))
|
||||
return order
|
||||
|
||||
|
||||
def _disks(vm: dict[str, Any], boot_order: list[str]) -> list[VMDiskDict]:
|
||||
disks = [d for d in dev.devices(vm) if dev.is_disk(d)]
|
||||
result: list[VMDiskDict] = []
|
||||
for position, disk in enumerate(disks):
|
||||
name = dev.label(disk)
|
||||
# Without an explicit boot order the firmware boots the first disk.
|
||||
bootable = name in boot_order if boot_order else position == 0
|
||||
result.append(
|
||||
{
|
||||
"device": name,
|
||||
"storage": dev.disk_datastore(disk),
|
||||
"size": dev.disk_bytes(disk) // _GB,
|
||||
"format": "vmdk",
|
||||
"bootable": bootable,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _nics(vm: dict[str, Any], index: NetworkIndex) -> list[VMNICDict]:
|
||||
result: list[VMNICDict] = []
|
||||
for nic in filter(dev.is_nic, dev.devices(vm)):
|
||||
network, vlan = nic_network(nic, index)
|
||||
result.append(
|
||||
{
|
||||
"device": dev.label(nic),
|
||||
"mac": nic.get("macAddress", "").upper(),
|
||||
"model": dev.nic_model(nic),
|
||||
"bridge": network,
|
||||
"vlan_id": vlan,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def vm_config(vm: dict[str, Any], index: NetworkIndex) -> VMConfigDict:
|
||||
"""The VM's hardware. Raises ``ValueError`` for a VM without a config."""
|
||||
if not vm.get("config.instanceUuid"):
|
||||
raise ValueError(f"VM {vm.get('name', vm.get('_moref'))!r} has no readable configuration")
|
||||
labels = {d["key"]: dev.label(d) for d in dev.devices(vm) if "key" in d}
|
||||
boot_order = _boot_order(vm, labels)
|
||||
vcpus = int(vm.get("config.hardware.numCPU") or 0)
|
||||
cores = int(vm.get("config.hardware.numCoresPerSocket") or 1)
|
||||
result: VMConfigDict = {
|
||||
"name": vm.get("name", ""),
|
||||
"vmid": vm["config.instanceUuid"],
|
||||
"vcpus": vcpus,
|
||||
"memory": int(vm.get("config.hardware.memoryMB") or 0),
|
||||
"os_type": vm.get("config.guestId", ""),
|
||||
"boot_order": boot_order,
|
||||
"disks": _disks(vm, boot_order),
|
||||
"nics": _nics(vm, index),
|
||||
"description": vm.get("config.annotation", ""),
|
||||
"tags": [],
|
||||
"os_name": vm.get("config.guestFullName", ""),
|
||||
"sockets": max(vcpus // cores, 1),
|
||||
"cores_per_socket": cores,
|
||||
"firmware": vm.get("config.firmware", "bios"),
|
||||
"passthrough": [
|
||||
{"slot": dev.label(d), "kind": kind, "config": dev.summary(d)}
|
||||
for d in dev.devices(vm)
|
||||
if (kind := dev.passthrough_kind(d))
|
||||
],
|
||||
}
|
||||
if vm.get("config.version"):
|
||||
result["machine"] = vm["config.version"]
|
||||
return result
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Classify and read entries of a VM's ``config.hardware.device`` list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_NIC_TYPES = frozenset(
|
||||
{
|
||||
"VirtualE1000",
|
||||
"VirtualE1000e",
|
||||
"VirtualPCNet32",
|
||||
"VirtualVmxnet",
|
||||
"VirtualVmxnet2",
|
||||
"VirtualVmxnet3",
|
||||
"VirtualVmxnet3Vrdma",
|
||||
"VirtualSriovEthernetCard",
|
||||
}
|
||||
)
|
||||
_PASSTHROUGH_KIND = {"VirtualPCIPassthrough": "pci", "VirtualUSB": "usb"}
|
||||
_DATASTORE_IN_PATH = re.compile(r"^\[([^\]]+)\]")
|
||||
|
||||
|
||||
def devices(vm: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return vm.get("config.hardware.device") or []
|
||||
|
||||
|
||||
def is_nic(device: dict[str, Any]) -> bool:
|
||||
return device.get("_type") in _NIC_TYPES
|
||||
|
||||
|
||||
def is_disk(device: dict[str, Any]) -> bool:
|
||||
return device.get("_type") == "VirtualDisk"
|
||||
|
||||
|
||||
def passthrough_kind(device: dict[str, Any]) -> str | None:
|
||||
return _PASSTHROUGH_KIND.get(device.get("_type", ""))
|
||||
|
||||
|
||||
def label(device: dict[str, Any]) -> str:
|
||||
"""The name vSphere shows for a device (``"Hard disk 1"``), else its key."""
|
||||
info = device.get("deviceInfo") or {}
|
||||
return info.get("label") or str(device.get("key", ""))
|
||||
|
||||
|
||||
def summary(device: dict[str, Any]) -> str:
|
||||
return (device.get("deviceInfo") or {}).get("summary", "")
|
||||
|
||||
|
||||
def nic_model(device: dict[str, Any]) -> str:
|
||||
"""``"vmxnet3"``, ``"e1000e"`` ... -- the type name without ``Virtual``."""
|
||||
return device.get("_type", "").removeprefix("Virtual").lower()
|
||||
|
||||
|
||||
def disk_bytes(device: dict[str, Any]) -> int:
|
||||
if device.get("capacityInBytes"):
|
||||
return int(device["capacityInBytes"])
|
||||
return int(device.get("capacityInKB", 0)) * 1024
|
||||
|
||||
|
||||
def disk_datastore(device: dict[str, Any]) -> str:
|
||||
"""Datastore name from the backing file path ``"[datastore1] vm/vm.vmdk"``."""
|
||||
path = (device.get("backing") or {}).get("fileName", "")
|
||||
match = _DATASTORE_IN_PATH.match(path)
|
||||
return match.group(1) if match else ""
|
||||
@@ -0,0 +1,121 @@
|
||||
"""``get_vms``: one VMDict per virtual machine, plus the keys netOrk reads.
|
||||
|
||||
Beyond the ``VMDict`` contract, netOrk's VM sync expects ``type``,
|
||||
``interfaces`` (NAPALM shape plus ``ipv4``/``bridge``/``tag``), ``ipv4``,
|
||||
``agent_enabled``/``agent_running`` (here: VMware Tools), ``disks`` and
|
||||
``onboot``. ``moref`` is added so an operator can find the VM in vSphere.
|
||||
|
||||
``vmid`` is the VM's *instance* UUID: stable across vMotion and
|
||||
re-registration, unique within a vCenter, and the key ``SearchIndex.FindByUuid``
|
||||
looks VMs up by. The MoRef is none of those things.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from typing import Any
|
||||
|
||||
from napalm_vmware.parse import vm_devices as dev
|
||||
from napalm_vmware.parse.networks import NetworkIndex, nic_network
|
||||
|
||||
_STATUS = {"poweredOn": "running", "poweredOff": "stopped", "suspended": "suspended"}
|
||||
|
||||
|
||||
def _is_ipv4(address: str) -> bool:
|
||||
try:
|
||||
return isinstance(ipaddress.ip_address(address), ipaddress.IPv4Address)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _cpu_usage(vm: dict[str, Any], host_mhz: int) -> float:
|
||||
used = (vm.get("summary.quickStats") or {}).get("overallCpuUsage") or 0
|
||||
capacity = int(vm.get("config.hardware.numCPU") or 0) * host_mhz
|
||||
if not capacity:
|
||||
return 0.0
|
||||
return round(min(used / capacity, 1.0), 4)
|
||||
|
||||
|
||||
def _interfaces(vm: dict[str, Any], index: NetworkIndex) -> dict[str, dict[str, Any]]:
|
||||
guest_nics = {n.get("deviceConfigId"): n for n in vm.get("guest.net") or []}
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for nic in filter(dev.is_nic, dev.devices(vm)):
|
||||
network, vlan = nic_network(nic, index)
|
||||
guest = guest_nics.get(nic.get("key"), {})
|
||||
connected = guest.get("connected", (nic.get("connectable") or {}).get("connected", False))
|
||||
ipv4 = next((a for a in guest.get("ipAddress", []) if _is_ipv4(a)), "")
|
||||
result[dev.label(nic)] = {
|
||||
"is_up": bool(connected),
|
||||
"is_enabled": True,
|
||||
"description": network,
|
||||
"mac_address": nic.get("macAddress", "").upper(),
|
||||
"speed": -1.0,
|
||||
"mtu": 1500,
|
||||
"last_flapped": -1.0,
|
||||
"ipv4": ipv4,
|
||||
"bridge": network,
|
||||
"tag": vlan or None,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _autostart_vms(hosts: list[dict[str, Any]]) -> set[str]:
|
||||
"""MoRefs of VMs the host's autostart manager will power on at boot."""
|
||||
morefs: set[str] = set()
|
||||
for host in hosts:
|
||||
config = host.get("config.autoStart") or {}
|
||||
if not (config.get("defaults") or {}).get("enabled"):
|
||||
continue
|
||||
for entry in config.get("powerInfo", []):
|
||||
if str(entry.get("startAction", "")).lower() == "poweron":
|
||||
morefs.add(entry.get("key", ""))
|
||||
return morefs
|
||||
|
||||
|
||||
def _vm(
|
||||
vm: dict[str, Any], hosts: dict[str, dict[str, Any]], index: NetworkIndex, onboot: bool
|
||||
) -> dict[str, Any]:
|
||||
host = hosts.get(vm.get("runtime.host", ""), {})
|
||||
stats = vm.get("summary.quickStats") or {}
|
||||
interfaces = _interfaces(vm, index)
|
||||
primary = vm.get("guest.ipAddress") or ""
|
||||
ipv4 = (
|
||||
primary
|
||||
if _is_ipv4(primary)
|
||||
else next((i["ipv4"] for i in interfaces.values() if i["ipv4"]), "")
|
||||
)
|
||||
return {
|
||||
"name": vm.get("name", ""),
|
||||
"vmid": vm["config.instanceUuid"],
|
||||
"moref": vm["_moref"],
|
||||
"type": "vm",
|
||||
"status": _STATUS.get(vm.get("runtime.powerState", ""), "unknown"),
|
||||
"vcpus": int(vm.get("config.hardware.numCPU") or 0),
|
||||
"memory": int(vm.get("config.hardware.memoryMB") or 0),
|
||||
"cpu_usage": _cpu_usage(vm, int((host.get("summary.hardware") or {}).get("cpuMhz") or 0)),
|
||||
"memory_usage": int(stats.get("guestMemoryUsage") or 0),
|
||||
"uptime": int(stats.get("uptimeSeconds") or 0),
|
||||
"node": host.get("name", ""),
|
||||
"interfaces": interfaces,
|
||||
"ipv4": ipv4,
|
||||
"agent_enabled": vm.get("guest.toolsStatus", "toolsNotInstalled") != "toolsNotInstalled",
|
||||
"agent_running": vm.get("guest.toolsRunningStatus") == "guestToolsRunning",
|
||||
"disks": [
|
||||
{"name": dev.label(d), "size_mb": dev.disk_bytes(d) // (1024 * 1024)}
|
||||
for d in filter(dev.is_disk, dev.devices(vm))
|
||||
],
|
||||
"onboot": onboot,
|
||||
}
|
||||
|
||||
|
||||
def vm_list(
|
||||
vms: list[dict[str, Any]], hosts: list[dict[str, Any]], index: NetworkIndex
|
||||
) -> list[dict[str, Any]]:
|
||||
"""All VMs, templates and unidentifiable (config-less) VMs excluded."""
|
||||
by_moref = {h["_moref"]: h for h in hosts}
|
||||
autostart = _autostart_vms(hosts)
|
||||
return [
|
||||
_vm(vm, by_moref, index, vm["_moref"] in autostart)
|
||||
for vm in vms
|
||||
if vm.get("config.instanceUuid") and not vm.get("config.template")
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Raw device warnings: ``{"code", "meta"}`` only.
|
||||
|
||||
What a code means to a user -- severity, wording, the fix -- is decided in
|
||||
netOrk's ``WARNING_CATALOG``, never here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_CONNECTED = "connected"
|
||||
_UNMANAGED = {None, "", "0.0.0.0"}
|
||||
#: License edition of the free vSphere Hypervisor. The vSphere API refuses
|
||||
#: every write on it (power, snapshots) with a RestrictedVersion fault.
|
||||
_FREE_EDITIONS = ("esxBasic",)
|
||||
|
||||
|
||||
def _host_codes(host: dict[str, Any], standalone: bool) -> list[dict[str, Any]]:
|
||||
name = host.get("name", host.get("_moref", ""))
|
||||
found: list[dict[str, Any]] = []
|
||||
state = host.get("runtime.connectionState", _CONNECTED)
|
||||
if not standalone and state != _CONNECTED:
|
||||
found.append({"code": "vmware_host_disconnected", "meta": {"host": name, "state": state}})
|
||||
if host.get("runtime.inMaintenanceMode"):
|
||||
found.append({"code": "vmware_host_maintenance_mode", "meta": {"host": name}})
|
||||
issues = [
|
||||
i["fullFormattedMessage"]
|
||||
for i in host.get("configIssue") or []
|
||||
if i.get("fullFormattedMessage")
|
||||
]
|
||||
if issues:
|
||||
found.append({"code": "vmware_host_config_issue", "meta": {"host": name, "issues": issues}})
|
||||
manager = host.get("summary.managementServerIp")
|
||||
if standalone and manager not in _UNMANAGED:
|
||||
found.append({"code": "vmware_host_managed_by_vcenter", "meta": {"vcenter": manager}})
|
||||
return found
|
||||
|
||||
|
||||
def host_warnings(
|
||||
hosts: list[dict[str, Any]], licenses: list[dict[str, Any]], *, standalone: bool
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Warnings for the hosts this driver sees.
|
||||
|
||||
``standalone`` is True for the ESXi driver. It alone reports a host being
|
||||
managed by a vCenter -- a hint to add the vCenter instead -- and it never
|
||||
reports disconnection, since a disconnected host could not have answered.
|
||||
"""
|
||||
found = [w for host in hosts for w in _host_codes(host, standalone)]
|
||||
for lic in licenses:
|
||||
if str(lic.get("editionKey", "")).startswith(_FREE_EDITIONS):
|
||||
found.append({"code": "vmware_api_read_only", "meta": {"edition": lic.get("name", "")}})
|
||||
break
|
||||
return found
|
||||
@@ -0,0 +1,76 @@
|
||||
"""The vSphere property paths the drivers read, per managed object type.
|
||||
|
||||
Single source of truth for both the drivers and ``tools/harvest.py``: a
|
||||
fixture harvested from a real host contains exactly what the parsers are
|
||||
later fed, no more and no less. Adding a path here is the first step of
|
||||
adding a field to any getter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
HOST = (
|
||||
"name",
|
||||
"summary.hardware",
|
||||
"summary.quickStats",
|
||||
"summary.managementServerIp",
|
||||
"hardware.systemInfo",
|
||||
"runtime.inMaintenanceMode",
|
||||
"runtime.connectionState",
|
||||
"runtime.healthSystemRuntime.systemHealthInfo.numericSensorInfo",
|
||||
"config.product",
|
||||
"config.network.pnic",
|
||||
"config.network.vnic",
|
||||
"config.network.portgroup",
|
||||
"config.network.vswitch",
|
||||
"config.network.dnsConfig",
|
||||
"config.autoStart",
|
||||
"configIssue",
|
||||
"configManager.networkSystem",
|
||||
)
|
||||
|
||||
VM = (
|
||||
"name",
|
||||
"config.instanceUuid",
|
||||
"config.template",
|
||||
"config.guestId",
|
||||
"config.guestFullName",
|
||||
"config.annotation",
|
||||
"config.firmware",
|
||||
"config.version",
|
||||
"config.hardware.numCPU",
|
||||
"config.hardware.numCoresPerSocket",
|
||||
"config.hardware.memoryMB",
|
||||
"config.hardware.device",
|
||||
"config.bootOptions.bootOrder",
|
||||
"runtime.powerState",
|
||||
"runtime.host",
|
||||
"summary.quickStats",
|
||||
"guest.net",
|
||||
"guest.ipAddress",
|
||||
"guest.toolsStatus",
|
||||
"guest.toolsRunningStatus",
|
||||
"snapshot",
|
||||
)
|
||||
|
||||
DATASTORE = (
|
||||
"name",
|
||||
"summary",
|
||||
)
|
||||
|
||||
DV_PORTGROUP = (
|
||||
"name",
|
||||
"config.defaultPortConfig",
|
||||
"config.distributedVirtualSwitch",
|
||||
"config.uplink",
|
||||
)
|
||||
|
||||
DV_SWITCH = ("name",)
|
||||
|
||||
#: Managed object type name -> paths, in the order harvest.py dumps them.
|
||||
ALL = {
|
||||
"HostSystem": HOST,
|
||||
"VirtualMachine": VM,
|
||||
"Datastore": DATASTORE,
|
||||
"DistributedVirtualPortgroup": DV_PORTGROUP,
|
||||
"DistributedVirtualSwitch": DV_SWITCH,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""NAPALM driver for VMware vCenter Server.
|
||||
|
||||
netOrk treats the vCenter as one device. The ESXi hosts behind it appear as
|
||||
the ``node`` of each VM; a host can additionally be added with the
|
||||
``vmware_esxi`` driver when its own NICs and sensors are of interest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from napalm_device_types import FingerprintRule
|
||||
|
||||
from napalm_vmware.actions import VmwareActionsMixin
|
||||
from napalm_vmware.base import VmwareBaseDriver
|
||||
from napalm_vmware.parse.facts import vcenter_facts
|
||||
|
||||
|
||||
class VmwareVcenterDriver(VmwareActionsMixin, VmwareBaseDriver):
|
||||
"""A vCenter and every VM, datastore and port group it manages."""
|
||||
|
||||
DRIVER_NAME = "vmware_vcenter"
|
||||
API_TYPE = "VirtualCenter"
|
||||
STANDALONE = False
|
||||
# The appliance starts several dozen services before the API answers.
|
||||
REBOOT_SETTLE_SECONDS = 600
|
||||
# Unverified against real hardware; see VmwareEsxiDriver.
|
||||
HTTP_FINGERPRINT = [
|
||||
FingerprintRule("vcenter", weight=9.0, mandatory=True),
|
||||
FingerprintRule("vsphere", weight=3.0),
|
||||
FingerprintRule("vmware esxi", weight=9.0, negative=True),
|
||||
]
|
||||
|
||||
def get_facts(self) -> dict[str, Any]:
|
||||
return vcenter_facts(self._inventory.about(), self.hostname)
|
||||
|
||||
# The hosts' NICs are not the vCenter's; reporting them here would attach
|
||||
# their MAC addresses to the wrong device. The appliance's own NICs are
|
||||
# only visible through its management API, which this driver does not use.
|
||||
|
||||
def get_interfaces(self) -> dict[str, dict[str, Any]]:
|
||||
return {}
|
||||
|
||||
def get_interfaces_ip(self) -> dict[str, dict[str, Any]]:
|
||||
return {}
|
||||
|
||||
def get_lldp_neighbors(self) -> dict[str, list[dict[str, str]]]:
|
||||
return {}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
Vendored
+2392
File diff suppressed because it is too large
Load Diff
Vendored
+8374
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
@@ -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") == []
|
||||
@@ -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"]) == {}
|
||||
@@ -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"
|
||||
@@ -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) == []
|
||||
@@ -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}}}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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]))
|
||||
@@ -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::")
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"])
|
||||
Executable
+79
@@ -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())
|
||||
Executable
+121
@@ -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())
|
||||
Reference in New Issue
Block a user