initial commit

This commit is contained in:
Christian Manivong
2026-05-29 09:24:39 +02:00
commit f3ecf14c8d
18 changed files with 4206 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
*.pyo
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
pytest_cache/
# Virtual environments
.venv
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Logs
*.log
# Secrets / local config
*.env
.secrets
+37
View File
@@ -0,0 +1,37 @@
# 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.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.1.0] - 2024-01-01
### Added
- Initial release of the Proxmox VE NAPALM driver
- Support for classic Linux networking (`/etc/network/interfaces`, Linux bridges)
- Support for Proxmox SDN (zones: VLAN, QinQ, VXLAN, EVPN; VNets and subnets)
- Support for Open vSwitch (OVS) bridges, bonds, and internal ports
- Password and API token authentication via `proxmoxer`
- Implemented NAPALM methods:
- `open`, `close`, `is_alive`
- `get_facts`
- `get_interfaces`, `get_interfaces_ip`, `get_interfaces_counters`
- `get_environment` (CPU, memory, temperature sensors)
- `get_arp_table` (via `/proc/net/arp`)
- `get_mac_address_table` (Linux bridge fdb + OVS appctl)
- `get_vlans` (SDN VNet tags + `bridge vlan show`)
- `get_network_instances` (SDN zones as NAPALM network instances)
- `get_ntp_servers`, `get_ntp_stats`
- `get_snmp_information`
- `get_users`
- `get_config`, `load_merge_candidate`, `load_replace_candidate`
- `compare_config`, `commit_config`, `discard_config`, `rollback`
- `get_lldp_neighbors`, `get_lldp_neighbors_detail`
- `get_ipv6_neighbors_table`
- `ping`, `traceroute`, `cli`
- `get_route_to`
- 149 unit tests with ~79% code coverage
- Full type annotations
+165
View File
@@ -0,0 +1,165 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship made available under
the License, as indicated by a copyright notice that is included in
or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean, as submitted to the Licensor for inclusion
in the Work by the copyright owner or by an individual or Legal Entity
authorized to submit on behalf of the copyright owner. For the purposes
of this definition, "submitted" means any form of electronic, verbal,
or written communication sent to the Licensor or its representatives,
including but not limited to communication on electronic mailing lists,
source code control systems, and issue tracking systems that are managed
by, or on behalf of, the Licensor for the purpose of tracking and
discussing the Work, but excluding communication that is conspicuously
marked or designated in writing by the copyright owner as "Not a
Contribution."
"Contributor" shall mean Licensor and any Legal Entity on behalf of
whom a Contribution has been received by the Licensor and included
within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by the combination of their Contributions
with the Work to which such Contributions were submitted. If You
institute patent litigation against any entity (including a cross-claim
or counterclaim in a lawsuit) alleging that the Work or any
Contribution embodied within the Work constitutes direct or contributory
patent infringement, then any patent licenses granted to You under
this License for that Work shall terminate as of the date such
litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, You must include a readable copy of the
attribution notices contained within such NOTICE file, in
at least one of the following places: within a NOTICE text
file distributed as part of the Derivative Works; within
the Source form or documentation, if provided along with the
Derivative Works; or, within a display generated by the
Derivative Works, if and wherever such third-party notices
normally appear. The contents of the NOTICE file are for
informational purposes only and do not modify the License.
You may add Your own attribution notices within Derivative
Works that You distribute, alongside or in addition to the
NOTICE text from the Work, provided that such additional
attribution notices cannot be construed as modifying the License.
You may add Your own license statement for Your modifications and
may provide additional grant of rights to use, copy, modify, merge,
publish, sublicense, and/or sell copies of the Work, and to permit
persons to whom the Work is furnished to do so.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any conditions of TITLE,
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR
PURPOSE. You are solely responsible for determining the
appropriateness of using or reproducing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), strict liability, or contract
shall any Contributor be liable to You for damages, including any
direct, indirect, special, incidental, or exemplary damages of any
character arising as a result of this License or out of the use or
inability to use the Work (even if such Contributor has been advised
of the possibility of such damages).
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer, and
charge a fee for, acceptance of support, warranty, indemnity, or other
liability obligations and/or rights consistent with this License.
However, in accepting such obligations, You may offer only your own
liability and not on behalf of any other Contributor, and only if
You agree to indemnify, defend, and hold each Contributor harmless
for any liability incurred by, or claims asserted against, such
Contributor by reason of your accepting any such warranty or
additional liability.
END OF TERMS AND CONDITIONS
+176
View File
@@ -0,0 +1,176 @@
# napalm-proxmox
A [NAPALM](https://napalm.readthedocs.io/) driver for [Proxmox VE](https://www.proxmox.com/en/proxmox-virtual-environment/overview) nodes.
It supports all three networking domains of Proxmox VE:
| Domain | Description |
|--------|-------------|
| **Classic Linux** | `/etc/network/interfaces`, Linux bridges, VLANs |
| **SDN** | Zones (VLAN, QinQ, VXLAN, EVPN), VNets, subnets |
| **OVS** | Open vSwitch bridges, bonds, and internal ports |
## Requirements
- Python ≥ 3.9
- NAPALM ≥ 5.0.0
- proxmoxer ≥ 2.0.0
- netaddr ≥ 0.9.0
- requests ≥ 2.31.0
## Installation
```bash
pip install napalm-proxmox
```
Or directly from source:
```bash
git clone https://github.com/example/napalm-proxmox.git
cd napalm-proxmox
pip install -e .
```
## Usage
### Password authentication
```python
from napalm import get_network_driver
driver = get_network_driver("proxmox")
device = driver(
hostname="pve1.example.com",
username="root",
password="secret",
optional_args={
"realm": "pam", # default: "pam"
"port": 8006, # default: 8006
"verify_ssl": True, # default: True
},
)
with device:
facts = device.get_facts()
print(facts)
# {'vendor': 'Proxmox Server Solutions GmbH', 'model': 'PowerEdge R640',
# 'hostname': 'pve1', 'os_version': 'Proxmox VE 8.2.4', ...}
interfaces = device.get_interfaces()
interfaces_ip = device.get_interfaces_ip()
vlans = device.get_vlans()
network_instances = device.get_network_instances()
```
### API token authentication
```python
device = driver(
hostname="pve1.example.com",
username="napalm",
password="",
optional_args={
"token_name": "napalm@pam!napalm-token",
"token_value": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
)
```
### Targeting a specific node
In a multi-node cluster the driver auto-detects the node by matching the
hostname against the cluster's node list. You can also pin a specific node:
```python
device = driver(
hostname="pve-cluster.example.com", # cluster VIP
username="root",
password="secret",
optional_args={"node": "pve2"},
)
```
## Supported NAPALM methods
| Method | Notes |
|--------|-------|
| `open` / `close` | Password + API token auth |
| `is_alive` | Checks `/api2/json/version` |
| `get_facts` | vendor, model, hostname, os_version, uptime, interface_list, fqdn |
| `get_interfaces` | eth, bridge, OVS bridge/bond; speed, MTU, MAC, status |
| `get_interfaces_ip` | IPv4/IPv6 from classic ifaces + SDN subnet gateways |
| `get_interfaces_counters` | From `node/netstat` RRD data |
| `get_environment` | CPU %, memory (bytes), hardware temperature sensors |
| `get_arp_table` | Reads `/proc/net/arp` via exec endpoint |
| `get_mac_address_table` | `bridge fdb show` (Linux) + `ovs-appctl fdb/show` (OVS) |
| `get_vlans` | SDN VNet tags + `bridge vlan show` |
| `get_network_instances` | SDN zones (VLAN→L2VPN, EVPN→L3VRF) + default instance |
| `get_ntp_servers` | From `node/ntp` API |
| `get_ntp_stats` | Parses `chronyc` / `ntpq -pn` output |
| `get_snmp_information` | Parses `/etc/snmp/snmpd.conf` |
| `get_users` | Proxmox access/users API + local `/etc/passwd` |
| `get_config` | `/etc/network/interfaces` + SDN config; sanitize support |
| `load_merge_candidate` / `load_replace_candidate` | String or file |
| `compare_config` | Unified diff |
| `commit_config` | Writes `/etc/network/interfaces` + `ifreload -a` |
| `discard_config` / `rollback` | Revert candidate |
| `get_lldp_neighbors` | `lldpcli show neighbors` via exec |
| `get_lldp_neighbors_detail` | Full LLDP detail |
| `get_ipv6_neighbors_table` | `ip -6 neigh show` |
| `ping` | Linux `ping` via exec endpoint |
| `traceroute` | `traceroute -n` via exec endpoint |
| `cli` | Arbitrary command execution via exec endpoint |
| `get_route_to` | `ip route show` via exec endpoint |
**Not implemented** (raise `NotImplementedError`):
`get_bgp_config`, `get_bgp_neighbors`, `get_bgp_neighbors_detail`,
`get_optics`, `get_probes_config`, `get_probes_results`, `get_firewall_policies`
## Optional arguments
| Argument | Type | Default | Description |
|----------|------|---------|-------------|
| `port` | int | `8006` | Proxmox API port |
| `verify_ssl` | bool | `True` | Verify TLS certificate |
| `realm` | str | `"pam"` | PAM realm for password auth |
| `node` | str | *auto* | Override target node name |
| `token_name` | str | `None` | API token identifier (`user@realm!tokenid`) |
| `token_value` | str | `None` | API token secret |
## Development
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
# Run tests with coverage
pytest --cov=napalm_proxmox --cov-report=term-missing
# Lint
ruff check napalm_proxmox
```
## Architecture
```
napalm_proxmox/
├── __init__.py # Exports ProxmoxDriver
├── driver.py # ProxmoxDriver — all NAPALM methods
└── utils.py # MAC normalisation, CIDR parsing, ARP/OVS parsers
tests/
├── conftest.py # Shared fixtures and mock API payloads
├── test_connection.py # open / close / is_alive
├── test_get_facts.py # get_facts
├── test_interfaces.py # get_interfaces, get_interfaces_ip, get_interfaces_counters
├── test_environment.py # get_environment
├── test_sdn.py # get_vlans, get_network_instances
├── test_ovs_and_arp.py # get_arp_table, get_mac_address_table, ARP/OVS utils
├── test_misc.py # NTP, SNMP, users, config, ping, traceroute, CLI, LLDP
└── test_utils.py # Unit tests for napalm_proxmox.utils
```
## License
Apache License 2.0 — see [LICENSE](LICENSE).
+5
View File
@@ -0,0 +1,5 @@
"""NAPALM driver for Proxmox VE."""
from napalm_proxmox.driver import ProxmoxDriver
__all__ = ["ProxmoxDriver"]
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
"""Utility helpers for the Proxmox NAPALM driver.
Covers:
- Classic Linux networking (/etc/network/interfaces)
- Proxmox SDN (zones, vnets, subnets)
- Open vSwitch (OVS bridges, bonds, internal ports)
"""
from __future__ import annotations
import re
from ipaddress import IPv4Interface, IPv6Interface, ip_interface
from typing import Any
from netaddr import EUI, AddrFormatError
# ---------------------------------------------------------------------------
# MAC address helpers
# ---------------------------------------------------------------------------
_MAC_RE = re.compile(
r"(?:[0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}"
r"|[0-9a-fA-F]{12}"
)
def normalize_mac(mac: str) -> str:
"""Return a colon-separated, lower-case MAC address, or '' on failure."""
if not mac:
return ""
try:
return str(EUI(mac, dialect=_ColonDialect()))
except (AddrFormatError, ValueError):
return mac.lower()
class _ColonDialect(EUI):
"""EUI dialect that formats as aa:bb:cc:dd:ee:ff."""
word_sep = ":"
word_fmt = "%.2x"
def __new__(cls): # type: ignore[override]
from netaddr import mac_unix_expanded
return mac_unix_expanded
def normalize_mac(mac: str) -> str: # noqa: F811
"""Return lower-case colon-separated MAC or empty string."""
if not mac:
return ""
try:
eui = EUI(mac)
eui.dialect = type(
"_ColonDialect",
(),
{"word_sep": ":", "word_fmt": "%.2x", "word_size": 8, "num_words": 6},
)
return str(eui).lower()
except (AddrFormatError, ValueError):
# Best-effort normalisation
cleaned = mac.replace("-", ":").strip().lower()
return cleaned
# ---------------------------------------------------------------------------
# IP / prefix helpers
# ---------------------------------------------------------------------------
def prefix_length(cidr: str) -> int:
"""Return prefix length from a CIDR string such as '192.168.1.0/24' → 24."""
try:
return int(cidr.split("/")[1])
except (IndexError, ValueError):
# No slash present — treat as host route
return 128 if ":" in cidr else 32
def parse_cidr(cidr: str) -> tuple[str, int]:
"""Return (ip_str, prefix_len) from a CIDR address."""
try:
iface = ip_interface(cidr)
return str(iface.ip), iface.network.prefixlen
except ValueError:
return cidr, -1
def addresses_from_node_network(iface_data: dict[str, Any]) -> dict[str, dict]:
"""Build an interfaces_ip sub-dict from a Proxmox node/network entry.
Proxmox returns separate fields: ``address``, ``netmask``, ``address6``,
``netmask6``. We convert those to the NAPALM ipv4/ipv6 keyed format.
"""
result: dict[str, dict] = {}
addr4 = iface_data.get("address", "")
mask4 = iface_data.get("netmask", "")
if addr4 and mask4:
try:
prefix = IPv4Interface(f"{addr4}/{mask4}").network.prefixlen
result.setdefault("ipv4", {})[addr4] = {"prefix_length": prefix}
except ValueError:
pass
addr6 = iface_data.get("address6", "")
mask6 = iface_data.get("netmask6", "")
if addr6 and mask6:
try:
prefix = IPv6Interface(f"{addr6}/{mask6}").network.prefixlen
result.setdefault("ipv6", {})[addr6] = {"prefix_length": prefix}
except ValueError:
pass
# Also handle CIDR-style fields (e.g. from SDN)
cidr4 = iface_data.get("cidr", "")
if cidr4 and "/" in cidr4 and "ipv4" not in result:
ip, plen = parse_cidr(cidr4)
if plen >= 0:
result.setdefault("ipv4", {})[ip] = {"prefix_length": plen}
cidr6 = iface_data.get("cidr6", "")
if cidr6 and "/" in cidr6 and "ipv6" not in result:
ip, plen = parse_cidr(cidr6)
if plen >= 0:
result.setdefault("ipv6", {})[ip] = {"prefix_length": plen}
return result
# ---------------------------------------------------------------------------
# Interface type helpers (classic Linux vs OVS vs SDN bridge)
# ---------------------------------------------------------------------------
OVS_TYPES = {"OVSBridge", "OVSBond", "OVSIntPort", "OVSPort"}
SDN_BRIDGE_RE = re.compile(r"^vnet\d+$|^vmbr\d+$|^sdnbr\d+$")
def is_ovs_interface(iface_data: dict[str, Any]) -> bool:
return iface_data.get("type", "") in OVS_TYPES
def is_sdn_vnet(name: str) -> bool:
return bool(re.fullmatch(r"vnet\d+", name))
def speed_mbps(iface_data: dict[str, Any]) -> float:
"""Extract link speed (Mbit/s) from Proxmox interface data."""
raw = iface_data.get("speed", -1)
if raw is None or raw == "":
return -1.0
try:
val = float(raw)
# Proxmox reports speed in Mbit/s already for physical interfaces
return val if val > 0 else -1.0
except (TypeError, ValueError):
return -1.0
# ---------------------------------------------------------------------------
# ARP / neighbour table helpers
# ---------------------------------------------------------------------------
def parse_arp_output(raw: str) -> list[dict[str, Any]]:
"""Parse Linux ``arp -n`` text output into NAPALM ARP dicts.
Expected format::
Address HWtype HWaddress Flags Iface
192.168.1.1 ether aa:bb:cc:dd:ee:ff C eth0
"""
entries = []
for line in raw.splitlines():
line = line.strip()
if not line or line.startswith("Address") or line.startswith("?"):
continue
parts = line.split()
if len(parts) < 5:
continue
ip_addr, _, mac, _, iface = parts[0], parts[1], parts[2], parts[3], parts[4]
if mac in ("(incomplete)", "<incomplete>"):
continue
entries.append(
{
"interface": iface,
"mac": normalize_mac(mac),
"ip": ip_addr,
"age": -1.0,
}
)
return entries
# ---------------------------------------------------------------------------
# SDN helpers
# ---------------------------------------------------------------------------
ZONE_TYPE_MAP = {
"simple": "L3VRF",
"vlan": "L2VPN",
"qinq": "L2VPN",
"vxlan": "L2VPN",
"evpn": "L3VRF",
}
def sdn_zone_to_network_instance(zone: dict[str, Any]) -> dict[str, Any]:
"""Convert a Proxmox SDN zone entry to a NAPALM network instance dict."""
zone_id = zone.get("zone", zone.get("name", "unknown"))
zone_type = zone.get("type", "simple")
return {
"name": zone_id,
"type": ZONE_TYPE_MAP.get(zone_type, "DEFAULT_INSTANCE"),
"state": {
"route_distinguisher": zone.get("vrf-vxlan", None),
},
"interfaces": {"interface": {}},
}
# ---------------------------------------------------------------------------
# OVS helpers
# ---------------------------------------------------------------------------
def parse_ovs_vsctl_show(raw: str) -> dict[str, Any]:
"""Very lightweight parser for ``ovs-vsctl show`` output.
Returns a mapping of bridge_name → {ports: [port_name, …]}.
"""
bridges: dict[str, Any] = {}
current_bridge: str | None = None
for line in raw.splitlines():
stripped = line.strip()
m_bridge = re.match(r'^Bridge\s+"?([^\s"]+)"?', stripped)
if m_bridge:
current_bridge = m_bridge.group(1)
bridges[current_bridge] = {"ports": []}
continue
m_port = re.match(r'^Port\s+"?([^\s"]+)"?', stripped)
if m_port and current_bridge:
port_name = m_port.group(1)
if port_name != current_bridge: # skip the bridge self-port
bridges[current_bridge]["ports"].append(port_name)
return bridges
def parse_ovs_ofctl_dump_flows(raw: str) -> list[dict[str, Any]]:
"""Parse ``ovs-ofctl dump-flows <bridge>`` into a list of flow dicts.
Used to populate get_mac_address_table for OVS bridges.
"""
flows = []
for line in raw.splitlines():
line = line.strip()
if not line or line.startswith("NXST_FLOW") or line.startswith("OFPST_FLOW"):
continue
# Extract dl_src / dl_dst / in_port / output
mac_m = re.search(r"dl_src=([\da-f:]+)", line)
port_m = re.search(r"in_port=(\d+)", line)
if mac_m:
flows.append(
{
"mac": normalize_mac(mac_m.group(1)),
"in_port": port_m.group(1) if port_m else "",
}
)
return flows
+65
View File
@@ -0,0 +1,65 @@
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "napalm-proxmox"
version = "0.1.0"
description = "NAPALM driver for Proxmox VE (classic Linux networking, SDN, OVS)"
readme = "README.md"
license = { text = "Apache-2.0" }
authors = [{ name = "Christian Manivong" }]
keywords = ["napalm", "proxmox", "network", "sdn", "ovs", "automation"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: System :: Networking",
]
requires-python = ">=3.9"
dependencies = [
"napalm>=5.0.0",
"napalm_device_types>=0.1.0",
"proxmoxer>=2.0.0",
"netaddr>=0.9.0",
"requests>=2.31.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4",
"pytest-cov>=4.1",
"pytest-mock>=3.12",
"responses>=0.24",
"ruff>=0.1",
"mypy>=1.7",
]
[project.entry-points."napalm.drivers"]
proxmox = "napalm_proxmox:ProxmoxDriver"
[tool.setuptools.packages.find]
where = ["."]
include = ["napalm_proxmox*"]
[tool.ruff]
line-length = 100
target-version = "py39"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=napalm_proxmox --cov-report=term-missing"
[tool.mypy]
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
+1
View File
@@ -0,0 +1 @@
# tests/__init__.py
+217
View File
@@ -0,0 +1,217 @@
"""Shared fixtures and mock helpers for napalm-proxmox tests."""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, patch
from napalm_proxmox.driver import ProxmoxDriver
# ---------------------------------------------------------------------------
# Sample API payloads
# ---------------------------------------------------------------------------
NODE_STATUS = {
"uptime": 123456,
"model": "PowerEdge R640",
"cpu": 0.12,
"memory": {"total": 137438953472, "used": 68719476736},
}
VERSION_INFO = {"version": "8.2.4", "release": "8", "repoid": "abc123"}
NODE_NETWORK = [
{
"iface": "eth0",
"type": "eth",
"active": 1,
"autostart": 1,
"hwaddr": "aa:bb:cc:dd:ee:01",
"speed": 1000,
"mtu": 1500,
"address": "10.0.0.1",
"netmask": "255.255.255.0",
"comments": "Management",
},
{
"iface": "eth1",
"type": "eth",
"active": 1,
"autostart": 1,
"hwaddr": "aa:bb:cc:dd:ee:02",
"speed": 10000,
"mtu": 9000,
"comments": "",
},
{
"iface": "vmbr0",
"type": "bridge",
"active": 1,
"autostart": 1,
"hwaddr": "aa:bb:cc:dd:ee:03",
"speed": None,
"mtu": 1500,
"address": "192.168.1.1",
"netmask": "255.255.255.0",
"comments": "Linux bridge",
},
{
"iface": "vmbr1",
"type": "OVSBridge",
"active": 0,
"autostart": 1,
"hwaddr": "",
"speed": None,
"mtu": 1500,
"comments": "OVS bridge",
},
]
SDN_ZONES = [
{"zone": "zone-vlan", "type": "vlan", "nodes": "pve1"},
{"zone": "zone-vxlan", "type": "vxlan", "nodes": "pve1,pve2", "vrf-vxlan": 10000},
{"zone": "zone-evpn", "type": "evpn", "nodes": "pve1,pve2", "vrf-vxlan": 10001},
]
SDN_VNETS = [
{"vnet": "vnet0", "zone": "zone-vlan", "tag": 10},
{"vnet": "vnet1", "zone": "zone-vlan", "tag": 20},
{"vnet": "vnet2", "zone": "zone-vxlan", "tag": 100000},
]
SDN_SUBNETS_VNET0 = [
{"cidr": "10.10.0.0/24", "gateway": "10.10.0.1", "vnet": "vnet0"},
]
SDN_SUBNETS_VNET1 = [
{"cidr": "10.20.0.0/24", "gateway": "10.20.0.1", "vnet": "vnet1"},
{"cidr": "2001:db8:20::/64", "gateway": "2001:db8:20::1", "vnet": "vnet1"},
]
SDN_SUBNETS_VNET2: list = []
DNS_INFO = {"search": "pve1.example.com", "dns1": "8.8.8.8"}
NTP_INFO = {"server": "pool.ntp.org,time.cloudflare.com"}
NODE_NETSTAT = [
{
"dev": "eth0",
"rx_bytes": 1000000,
"tx_bytes": 500000,
"rx_packets": 1000,
"tx_packets": 900,
"rx_errs": 0,
"tx_errs": 0,
"rx_drop": 0,
"tx_drop": 1,
}
]
NODES_LIST = [{"node": "pve1", "status": "online"}]
PVE_USERS = [
{"userid": "root@pam", "groups": ""},
{"userid": "napalm@pam", "groups": ""},
]
# ---------------------------------------------------------------------------
# Driver fixture
# ---------------------------------------------------------------------------
def _build_mock_api(
nodes=None,
node_status=None,
version=None,
network=None,
sdn_zones=None,
sdn_vnets=None,
sdn_subnets=None,
dns=None,
ntp=None,
netstat=None,
pve_users=None,
exec_return="",
sensors=None,
):
"""Build a MagicMock ProxmoxAPI with pre-configured return values."""
api = MagicMock()
# Nodes list
api.nodes.get.return_value = nodes or NODES_LIST
# Version
api.version.get.return_value = version or VERSION_INFO
# Node sub-resource
node_res = MagicMock()
api.nodes.return_value = node_res
node_res.status.get.return_value = node_status or NODE_STATUS
node_res.network.get.return_value = network if network is not None else NODE_NETWORK
node_res.dns.get.return_value = dns or DNS_INFO
node_res.ntp.get.return_value = ntp or NTP_INFO
node_res.netstat.get.return_value = netstat or NODE_NETSTAT
node_res.execute.post.return_value = {"data": exec_return}
node_res.hardware.sensors.get.return_value = sensors or []
node_res.time.get.return_value = {}
node_res.subscription.get.return_value = {}
# SDN
cluster = MagicMock()
api.cluster = cluster
cluster.sdn.zones.get.return_value = sdn_zones or SDN_ZONES
cluster.sdn.vnets.get.return_value = sdn_vnets or SDN_VNETS
# SDN subnets per vnet
subnets_map = sdn_subnets or {
"vnet0": SDN_SUBNETS_VNET0,
"vnet1": SDN_SUBNETS_VNET1,
"vnet2": SDN_SUBNETS_VNET2,
}
def _vnet_resource(vnet_id):
vnet_res = MagicMock()
vnet_res.subnets.get.return_value = subnets_map.get(vnet_id, [])
return vnet_res
cluster.sdn.vnets.side_effect = _vnet_resource
# Access / users
api.access.users.get.return_value = pve_users or PVE_USERS
api.access.users.return_value.get.return_value = {}
return api
@pytest.fixture
def driver():
"""Return an open ProxmoxDriver with a mocked API."""
drv = ProxmoxDriver(
hostname="pve1.example.com",
username="root",
password="secret",
optional_args={"verify_ssl": False, "realm": "pam"},
)
mock_api = _build_mock_api()
with patch("napalm_proxmox.driver.ProxmoxAPI", return_value=mock_api):
drv.open()
return drv
@pytest.fixture
def driver_with_exec(request):
"""Return a driver whose exec endpoint returns a parametrised string."""
exec_output = getattr(request, "param", "")
drv = ProxmoxDriver(
hostname="pve1.example.com",
username="root",
password="secret",
optional_args={"verify_ssl": False},
)
mock_api = _build_mock_api(exec_return=exec_output)
with patch("napalm_proxmox.driver.ProxmoxAPI", return_value=mock_api):
drv.open()
return drv
+75
View File
@@ -0,0 +1,75 @@
"""Tests for ProxmoxDriver connection management."""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, patch
from napalm.base.exceptions import ConnectionException
from napalm_proxmox.driver import ProxmoxDriver
from tests.conftest import _build_mock_api, NODES_LIST
class TestOpen:
def test_open_password_auth(self):
drv = ProxmoxDriver("pve1", "root", "secret")
mock_api = _build_mock_api()
with patch("napalm_proxmox.driver.ProxmoxAPI", return_value=mock_api) as mock_cls:
drv.open()
call_kwargs = mock_cls.call_args.kwargs
assert call_kwargs["user"] == "root@pam"
assert call_kwargs["password"] == "secret"
def test_open_token_auth(self):
drv = ProxmoxDriver(
"pve1",
"root",
"",
optional_args={
"token_name": "napalm@pam!mytoken",
"token_value": "super-secret",
},
)
mock_api = _build_mock_api()
with patch("napalm_proxmox.driver.ProxmoxAPI", return_value=mock_api) as mock_cls:
drv.open()
call_kwargs = mock_cls.call_args.kwargs
assert call_kwargs["user"] == "napalm@pam!mytoken"
assert call_kwargs["token_value"] == "super-secret"
def test_open_connection_error(self):
drv = ProxmoxDriver("badhost", "root", "bad")
with patch(
"napalm_proxmox.driver.ProxmoxAPI",
side_effect=Exception("Connection refused"),
):
with pytest.raises(ConnectionException):
drv.open()
def test_node_resolution_by_hostname(self, driver):
assert driver._node_name == "pve1"
def test_node_override_via_optional_args(self):
drv = ProxmoxDriver(
"pve1.example.com",
"root",
"secret",
optional_args={"node": "custom-node"},
)
mock_api = _build_mock_api()
with patch("napalm_proxmox.driver.ProxmoxAPI", return_value=mock_api):
drv.open()
assert drv._node_name == "custom-node"
def test_close(self, driver):
driver.close()
assert driver._api is None
class TestIsAlive:
def test_alive_when_api_responds(self, driver):
assert driver.is_alive() == {"is_alive": True}
def test_not_alive_when_api_fails(self, driver):
driver._api.version.get.side_effect = Exception("timeout")
assert driver.is_alive() == {"is_alive": False}
+63
View File
@@ -0,0 +1,63 @@
"""Tests for get_environment."""
from __future__ import annotations
import pytest
from tests.conftest import _build_mock_api, NODE_STATUS
from unittest.mock import patch
from napalm_proxmox.driver import ProxmoxDriver
class TestGetEnvironment:
def test_cpu_usage_present(self, driver):
result = driver.get_environment()
assert "0" in result["cpu"]
assert result["cpu"]["0"]["%usage"] == pytest.approx(12.0, abs=0.1)
def test_memory_total(self, driver):
result = driver.get_environment()
assert result["memory"]["available_ram"] == 137438953472
assert result["memory"]["used_ram"] == 68719476736
def test_fans_empty_when_no_sensors(self, driver):
result = driver.get_environment()
assert isinstance(result["fans"], dict)
def test_temperature_with_sensors(self):
from tests.conftest import _build_mock_api, NODE_STATUS
from unittest.mock import patch
from napalm_proxmox.driver import ProxmoxDriver
sensors_data = [
{"name": "CPU Package", "value": "55.0"},
{"name": "GPU Core", "value": "92.0"},
]
mock_api = _build_mock_api(sensors=sensors_data)
drv = ProxmoxDriver("pve1", "root", "secret", optional_args={"verify_ssl": False})
with patch("napalm_proxmox.driver.ProxmoxAPI", return_value=mock_api):
drv.open()
result = drv.get_environment()
assert "CPU Package" in result["temperature"]
assert result["temperature"]["CPU Package"]["temperature"] == 55.0
assert result["temperature"]["CPU Package"]["is_alert"] is False
assert result["temperature"]["CPU Package"]["is_critical"] is False
assert "GPU Core" in result["temperature"]
assert result["temperature"]["GPU Core"]["is_alert"] is True
assert result["temperature"]["GPU Core"]["is_critical"] is False
def test_temperature_critical(self):
from tests.conftest import _build_mock_api
from unittest.mock import patch
from napalm_proxmox.driver import ProxmoxDriver
sensors_data = [{"name": "NVMe", "value": "98.0"}]
mock_api = _build_mock_api(sensors=sensors_data)
drv = ProxmoxDriver("pve1", "root", "secret", optional_args={"verify_ssl": False})
with patch("napalm_proxmox.driver.ProxmoxAPI", return_value=mock_api):
drv.open()
result = drv.get_environment()
assert result["temperature"]["NVMe"]["is_critical"] is True
+42
View File
@@ -0,0 +1,42 @@
"""Tests for get_facts."""
from __future__ import annotations
import pytest
from tests.conftest import _build_mock_api, NODE_NETWORK
from unittest.mock import patch
from napalm_proxmox.driver import ProxmoxDriver
@pytest.fixture
def facts(driver):
return driver.get_facts()
class TestGetFacts:
def test_vendor(self, facts):
assert "Proxmox" in facts["vendor"]
def test_model(self, facts):
assert facts["model"] == "PowerEdge R640"
def test_hostname(self, facts):
assert facts["hostname"] == "pve1"
def test_os_version_contains_version(self, facts):
assert "8.2.4" in facts["os_version"]
def test_uptime_is_float(self, facts):
assert isinstance(facts["uptime"], float)
assert facts["uptime"] == 123456.0
def test_interface_list_sorted(self, facts):
expected = sorted(i["iface"] for i in NODE_NETWORK if i.get("iface"))
assert facts["interface_list"] == expected
def test_fqdn(self, facts):
assert facts["fqdn"] == "pve1.example.com"
def test_serial_number_is_string(self, facts):
assert isinstance(facts["serial_number"], str)
+109
View File
@@ -0,0 +1,109 @@
"""Tests for get_interfaces, get_interfaces_ip, get_interfaces_counters."""
from __future__ import annotations
import pytest
from tests.conftest import _build_mock_api, NODE_NETWORK
from unittest.mock import patch
from napalm_proxmox.driver import ProxmoxDriver
class TestGetInterfaces:
def test_returns_all_ifaces(self, driver):
result = driver.get_interfaces()
expected_names = {i["iface"] for i in NODE_NETWORK if i.get("iface")}
assert set(result.keys()) == expected_names
def test_eth0_is_up(self, driver):
result = driver.get_interfaces()
assert result["eth0"]["is_up"] is True
assert result["eth0"]["is_enabled"] is True
def test_ovs_bridge_not_up(self, driver):
result = driver.get_interfaces()
assert result["vmbr1"]["is_up"] is False
def test_mac_address_normalized(self, driver):
result = driver.get_interfaces()
mac = result["eth0"]["mac_address"]
# should be lower-case colon-separated
assert mac == "aa:bb:cc:dd:ee:01"
def test_speed_eth0(self, driver):
result = driver.get_interfaces()
assert result["eth0"]["speed"] == 1000.0
def test_speed_unknown(self, driver):
result = driver.get_interfaces()
assert result["vmbr0"]["speed"] == -1.0
def test_mtu(self, driver):
result = driver.get_interfaces()
assert result["eth1"]["mtu"] == 9000
def test_description(self, driver):
result = driver.get_interfaces()
assert result["eth0"]["description"] == "Management"
def test_last_flapped_minus_one(self, driver):
result = driver.get_interfaces()
assert result["eth0"]["last_flapped"] == -1.0
class TestGetInterfacesIP:
def test_eth0_has_ipv4(self, driver):
result = driver.get_interfaces_ip()
assert "eth0" in result
assert "ipv4" in result["eth0"]
assert "10.0.0.1" in result["eth0"]["ipv4"]
def test_eth0_prefix_length(self, driver):
result = driver.get_interfaces_ip()
assert result["eth0"]["ipv4"]["10.0.0.1"]["prefix_length"] == 24
def test_sdn_vnet_ipv4_gateway(self, driver):
result = driver.get_interfaces_ip()
# vnet0 gateway 10.10.0.1 /24
assert "vnet0" in result
assert "10.10.0.1" in result["vnet0"].get("ipv4", {})
def test_sdn_vnet_ipv6_gateway(self, driver):
result = driver.get_interfaces_ip()
# vnet1 has IPv6 subnet
assert "vnet1" in result
assert "2001:db8:20::1" in result["vnet1"].get("ipv6", {})
def test_iface_without_address_not_in_result(self, driver):
result = driver.get_interfaces_ip()
# eth1 has no address in fixture
assert "eth1" not in result
class TestGetInterfacesCounters:
def test_returns_eth0(self, driver):
result = driver.get_interfaces_counters()
assert "eth0" in result
def test_counters_fields_present(self, driver):
result = driver.get_interfaces_counters()
eth0 = result["eth0"]
for field in (
"tx_errors", "rx_errors", "tx_discards", "rx_discards",
"tx_octets", "rx_octets", "tx_unicast_packets", "rx_unicast_packets",
"tx_multicast_packets", "rx_multicast_packets",
"tx_broadcast_packets", "rx_broadcast_packets",
):
assert field in eth0, f"Missing field: {field}"
def test_counter_values(self, driver):
result = driver.get_interfaces_counters()
eth0 = result["eth0"]
assert eth0["tx_octets"] == 500000
assert eth0["rx_octets"] == 1000000
assert eth0["tx_discards"] == 1
def test_empty_when_no_netstat(self, driver):
driver._node_api().netstat.get.return_value = []
result = driver.get_interfaces_counters()
assert result == {}
+323
View File
@@ -0,0 +1,323 @@
"""Tests for NTP, SNMP, users, config management, ping, traceroute, CLI."""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, patch
class TestGetNTPServers:
def test_returns_configured_servers(self, driver):
result = driver.get_ntp_servers()
assert "pool.ntp.org" in result
assert "time.cloudflare.com" in result
def test_empty_when_no_ntp(self, driver):
driver._node_api().ntp.get.return_value = {}
result = driver.get_ntp_servers()
assert result == {}
class TestGetNTPStats:
NTP_OUTPUT = (
"*192.168.1.1 10.0.0.1 4 u 12 64 377 1.234 0.567 0.123\n"
"+192.168.1.2 10.0.0.2 3 u 30 64 377 2.345 -0.123 0.234\n"
)
def test_synchronized_server(self, driver):
driver._node_api().execute.post.return_value = {"data": self.NTP_OUTPUT}
stats = driver.get_ntp_stats()
synced = [s for s in stats if s["synchronized"]]
assert len(synced) == 1
assert synced[0]["remote"] == "192.168.1.1"
def test_stratum(self, driver):
driver._node_api().execute.post.return_value = {"data": self.NTP_OUTPUT}
stats = driver.get_ntp_stats()
assert stats[0]["stratum"] == 4
def test_empty_when_no_ntp_command(self, driver):
driver._node_api().execute.post.return_value = {"data": ""}
stats = driver.get_ntp_stats()
assert stats == []
class TestGetSNMPInformation:
SNMP_CONF = (
"# SNMP config\n"
"rocommunity public\n"
"rwcommunity private\n"
"sysLocation Server Room A\n"
"sysContact admin@example.com\n"
)
def test_communities(self, driver):
driver._node_api().execute.post.return_value = {"data": self.SNMP_CONF}
result = driver.get_snmp_information()
assert "public" in result["community"]
assert result["community"]["public"]["mode"] == "ro"
assert "private" in result["community"]
assert result["community"]["private"]["mode"] == "rw"
def test_location(self, driver):
driver._node_api().execute.post.return_value = {"data": self.SNMP_CONF}
result = driver.get_snmp_information()
assert result["location"] == "Server Room A"
def test_contact(self, driver):
driver._node_api().execute.post.return_value = {"data": self.SNMP_CONF}
result = driver.get_snmp_information()
assert result["contact"] == "admin@example.com"
def test_chassis_id_is_node_name(self, driver):
driver._node_api().execute.post.return_value = {"data": ""}
result = driver.get_snmp_information()
assert result["chassis_id"] == "pve1"
class TestGetUsers:
def test_pve_users_returned(self, driver):
driver._node_api().execute.post.return_value = {"data": ""}
result = driver.get_users()
assert "root@pam" in result
assert "napalm@pam" in result
def test_user_has_required_keys(self, driver):
driver._node_api().execute.post.return_value = {"data": ""}
result = driver.get_users()
for user, data in result.items():
assert "level" in data
assert "password" in data
assert "sshkeys" in data
def test_root_local_user_from_passwd(self, driver):
passwd_output = "root:x:0:0:root:/root:/bin/bash\n"
driver._node_api().execute.post.return_value = {"data": passwd_output}
result = driver.get_users()
assert "root" in result
assert result["root"]["level"] == 15
class TestConfigManagement:
RUNNING = "auto eth0\niface eth0 inet static\n address 10.0.0.1/24\n"
CANDIDATE = "auto eth0\niface eth0 inet static\n address 10.0.0.2/24\n"
def test_load_merge_candidate_from_string(self, driver):
driver.load_merge_candidate(config=self.CANDIDATE)
assert driver._candidate_config == self.CANDIDATE
def test_load_replace_candidate_from_string(self, driver):
driver.load_replace_candidate(config=self.CANDIDATE)
assert driver._candidate_config == self.CANDIDATE
def test_load_merge_from_file(self, driver, tmp_path):
f = tmp_path / "candidate.txt"
f.write_text(self.CANDIDATE)
driver.load_merge_candidate(filename=str(f))
assert driver._candidate_config == self.CANDIDATE
def test_discard_clears_candidate(self, driver):
driver.load_replace_candidate(config=self.CANDIDATE)
driver.discard_config()
assert driver._candidate_config == ""
def test_compare_config_diff(self, driver):
driver._running_config = self.RUNNING
driver.load_replace_candidate(config=self.CANDIDATE)
diff = driver.compare_config()
assert "10.0.0.1" in diff
assert "10.0.0.2" in diff
def test_compare_config_no_diff_when_same(self, driver):
driver._running_config = self.RUNNING
driver.load_replace_candidate(config=self.RUNNING)
diff = driver.compare_config()
assert diff == ""
def test_get_config_running(self, driver):
driver._node_api().execute.post.return_value = {"data": self.RUNNING}
result = driver.get_config(retrieve="running")
assert self.RUNNING in result["running"]
assert result["candidate"] == ""
def test_get_config_sanitized_redacts_password(self, driver):
raw = "password mysecret\nauto eth0\n"
driver._node_api().execute.post.return_value = {"data": raw}
result = driver.get_config(retrieve="running", sanitized=True)
assert "mysecret" not in result["running"]
assert "****" in result["running"]
def test_load_merge_raises_without_input(self, driver):
with pytest.raises((ValueError, TypeError)):
driver.load_merge_candidate()
class TestPing:
PING_SUCCESS = (
"PING 8.8.8.8 (8.8.8.8) 100(128) bytes of data.\n"
"108 bytes from 8.8.8.8: icmp_seq=1 ttl=118 time=10.5 ms\n"
"108 bytes from 8.8.8.8: icmp_seq=2 ttl=118 time=11.2 ms\n"
"--- 8.8.8.8 ping statistics ---\n"
"2 packets transmitted, 2 received, 0% packet loss, time 1001ms\n"
"rtt min/avg/max/mdev = 10.500/10.850/11.200/0.350 ms\n"
)
PING_FAIL = (
"PING badhost: Name or service not known\n"
"0 packets transmitted, 0 received, 100% packet loss\n"
)
def test_success(self, driver):
driver._node_api().execute.post.return_value = {"data": self.PING_SUCCESS}
result = driver.ping("8.8.8.8")
assert "success" in result
assert result["success"]["probes_sent"] == 2
assert result["success"]["packet_loss"] == 0
assert result["success"]["rtt_min"] == pytest.approx(10.5, abs=0.01)
def test_failure(self, driver):
driver._node_api().execute.post.return_value = {"data": self.PING_FAIL}
result = driver.ping("badhost")
assert "error" in result
def test_exec_unavailable(self, driver):
driver._node_api().execute.post.return_value = {"data": ""}
result = driver.ping("8.8.8.8")
assert "error" in result
class TestTraceroute:
TR_OUTPUT = (
"traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets\n"
" 1 10.0.0.254 0.456 ms 0.423 ms 0.401 ms\n"
" 2 192.168.1.1 1.234 ms 1.210 ms 1.189 ms\n"
" 3 8.8.8.8 11.200 ms 11.150 ms 11.100 ms\n"
)
def test_success_hops(self, driver):
driver._node_api().execute.post.return_value = {"data": self.TR_OUTPUT}
result = driver.traceroute("8.8.8.8")
assert "success" in result
assert 1 in result["success"]
assert 3 in result["success"]
def test_hop_rtt(self, driver):
driver._node_api().execute.post.return_value = {"data": self.TR_OUTPUT}
result = driver.traceroute("8.8.8.8")
hop1 = result["success"][1]["probes"]
assert hop1[1]["ip_address"] == "10.0.0.254"
def test_empty_returns_error(self, driver):
driver._node_api().execute.post.return_value = {"data": ""}
result = driver.traceroute("8.8.8.8")
assert "error" in result
class TestCLI:
def test_cli_single_command(self, driver):
driver._node_api().execute.post.return_value = {"data": "hello world"}
result = driver.cli(["echo hello world"])
assert result["echo hello world"] == "hello world"
def test_cli_multiple_commands(self, driver):
responses = {"uptime": "up 2 days", "hostname": "pve1"}
call_count = 0
def _exec(command):
nonlocal call_count
cmd = command
call_count += 1
return {"data": responses.get(cmd, "")}
driver._node_api().execute.post.side_effect = _exec
result = driver.cli(["uptime", "hostname"])
assert "uptime" in result
assert "hostname" in result
class TestGetRouteTo:
ROUTE_OUTPUT = (
"10.0.0.0/24 dev eth0 proto kernel scope link src 10.0.0.1\n"
"default via 10.0.0.254 dev eth0 proto dhcp\n"
)
def test_returns_routes(self, driver):
driver._node_api().execute.post.return_value = {"data": self.ROUTE_OUTPUT}
result = driver.get_route_to(destination="10.0.0.0/24")
assert "10.0.0.0/24" in result
def test_route_fields(self, driver):
driver._node_api().execute.post.return_value = {"data": self.ROUTE_OUTPUT}
result = driver.get_route_to()
for prefix, routes in result.items():
for route in routes:
assert "protocol" in route
assert "next_hop" in route
assert "outgoing_interface" in route
def test_protocol_filter(self, driver):
driver._node_api().execute.post.return_value = {"data": self.ROUTE_OUTPUT}
result = driver.get_route_to(protocol="dhcp")
for prefix, routes in result.items():
for route in routes:
assert "dhcp" in route["protocol"].lower()
class TestLLDPNeighbors:
LLDP_SUMMARY = (
" Interface: eth0\n"
" SysName: sw01.example.com\n"
" PortID: ifname GigabitEthernet1/0/1\n"
" Interface: eth1\n"
" SysName: sw02.example.com\n"
" PortID: ifname GigabitEthernet1/0/2\n"
)
def test_neighbors_found(self, driver):
driver._node_api().execute.post.return_value = {"data": self.LLDP_SUMMARY}
result = driver.get_lldp_neighbors()
assert "eth0" in result
assert result["eth0"][0]["hostname"] == "sw01.example.com"
def test_empty_when_no_lldp(self, driver):
driver._node_api().execute.post.return_value = {"data": ""}
result = driver.get_lldp_neighbors()
assert result == {}
class TestIPv6Neighbors:
IPV6_NEIGH = (
"2001:db8::1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n"
"2001:db8::2 dev vmbr0 lladdr 11:22:33:44:55:66 STALE\n"
"fe80::1 dev eth0 FAILED\n"
)
def test_neighbors_parsed(self, driver):
driver._node_api().execute.post.return_value = {"data": self.IPV6_NEIGH}
result = driver.get_ipv6_neighbors_table()
assert len(result) == 2 # FAILED entry excluded
def test_mac_normalized(self, driver):
driver._node_api().execute.post.return_value = {"data": self.IPV6_NEIGH}
result = driver.get_ipv6_neighbors_table()
macs = {e["mac"] for e in result}
assert "aa:bb:cc:dd:ee:ff" in macs
def test_state_present(self, driver):
driver._node_api().execute.post.return_value = {"data": self.IPV6_NEIGH}
result = driver.get_ipv6_neighbors_table()
states = {e["state"] for e in result}
assert "REACHABLE" in states
class TestNotImplemented:
def test_get_bgp_config_raises(self, driver):
with pytest.raises(NotImplementedError):
driver.get_bgp_config()
def test_get_bgp_neighbors_raises(self, driver):
with pytest.raises(NotImplementedError):
driver.get_bgp_neighbors()
def test_get_optics_raises(self, driver):
with pytest.raises(NotImplementedError):
driver.get_optics()
+163
View File
@@ -0,0 +1,163 @@
"""Tests for OVS-related helpers and MAC/ARP methods."""
from __future__ import annotations
import pytest
from napalm_proxmox import utils
PROC_NET_ARP = """\
IP address HW type Flags HW address Mask Device
10.0.0.254 0x1 0x2 aa:bb:cc:dd:ee:ff * eth0
10.0.0.10 0x1 0x2 11:22:33:44:55:66 * vmbr0
10.0.0.20 0x1 0x0 00:00:00:00:00:00 * eth0
"""
OVS_VSCTL_SHOW = """\
Bridge vmbr1
Port eth1
Port vnet0
Port vmbr1
Bridge vmbr2
Port eth2
"""
BRIDGE_FDB_OUTPUT = """\
aa:bb:cc:dd:ee:01 dev eth0 vlan 10 master vmbr0 permanent
cc:dd:ee:ff:00:11 dev eth0 vlan 20 master vmbr0
"""
OVS_FDB_SHOW = """\
LOCAL 0 aa:bb:cc:dd:ee:03 0
1 10 11:22:33:44:55:66 100
2 20 66:55:44:33:22:11 200
"""
class TestNormalizeMAC:
def test_colon_separated(self):
assert utils.normalize_mac("AA:BB:CC:DD:EE:FF") == "aa:bb:cc:dd:ee:ff"
def test_dash_separated(self):
assert utils.normalize_mac("AA-BB-CC-DD-EE-FF") == "aa:bb:cc:dd:ee:ff"
def test_empty_string(self):
assert utils.normalize_mac("") == ""
def test_already_normalized(self):
assert utils.normalize_mac("aa:bb:cc:dd:ee:ff") == "aa:bb:cc:dd:ee:ff"
class TestParseARPOutput:
def test_parses_valid_entries(self):
entries = utils.parse_arp_output(PROC_NET_ARP)
# 10.0.0.20 has 00:00:00:00:00:00 → excluded by ARP parse (not by proc/net/arp parser)
# proc/net/arp in driver excludes 00:00..., parse_arp_output is for `arp -n` format
# The proc/net/arp format is handled inline in driver.
# parse_arp_output handles `arp -n` which uses "HWtype HWaddress" columns
# Let's test with arp -n format
arp_n = (
"Address HWtype HWaddress Flags Iface\n"
"10.0.0.254 ether aa:bb:cc:dd:ee:ff C eth0\n"
"10.0.0.10 ether 11:22:33:44:55:66 C vmbr0\n"
)
entries = utils.parse_arp_output(arp_n)
assert len(entries) == 2
assert entries[0]["ip"] == "10.0.0.254"
assert entries[0]["mac"] == "aa:bb:cc:dd:ee:ff"
assert entries[0]["interface"] == "eth0"
def test_skips_incomplete(self):
arp_n = (
"Address HWtype HWaddress Flags Iface\n"
"10.0.0.1 ether (incomplete) C eth0\n"
)
entries = utils.parse_arp_output(arp_n)
assert len(entries) == 0
class TestParseOVSVsctlShow:
def test_bridges_found(self):
result = utils.parse_ovs_vsctl_show(OVS_VSCTL_SHOW)
assert "vmbr1" in result
assert "vmbr2" in result
def test_ports_found(self):
result = utils.parse_ovs_vsctl_show(OVS_VSCTL_SHOW)
assert "eth1" in result["vmbr1"]["ports"]
assert "vnet0" in result["vmbr1"]["ports"]
# The bridge self-port should not appear in ports list
assert "vmbr1" not in result["vmbr1"]["ports"]
def test_second_bridge_ports(self):
result = utils.parse_ovs_vsctl_show(OVS_VSCTL_SHOW)
assert "eth2" in result["vmbr2"]["ports"]
class TestGetARPTable:
PROC_ARP = (
"IP address HW type Flags HW address Mask Device\n"
"10.0.0.254 0x1 0x2 aa:bb:cc:dd:ee:ff * eth0\n"
"10.0.0.10 0x1 0x2 11:22:33:44:55:66 * vmbr0\n"
"10.0.0.20 0x1 0x0 00:00:00:00:00:00 * eth0\n"
)
def test_arp_entries_returned(self, driver):
driver._node_api().execute.post.return_value = {"data": self.PROC_ARP}
result = driver.get_arp_table()
assert len(result) == 2 # 00:00:00:00:00:00 excluded
def test_mac_normalized(self, driver):
driver._node_api().execute.post.return_value = {"data": self.PROC_ARP}
result = driver.get_arp_table()
macs = {e["mac"] for e in result}
assert "aa:bb:cc:dd:ee:ff" in macs
def test_vrf_filter(self, driver):
driver._node_api().execute.post.return_value = {"data": self.PROC_ARP}
result = driver.get_arp_table(vrf="vmbr0")
assert all(e["interface"] == "vmbr0" for e in result)
def test_empty_when_exec_unavailable(self, driver):
driver._node_api().execute.post.return_value = {"data": ""}
result = driver.get_arp_table()
assert result == []
class TestGetMACAddressTable:
BRIDGE_FDB = (
"aa:bb:cc:dd:ee:01 dev eth0 vlan 10 master vmbr0 permanent\n"
"cc:dd:ee:ff:00:11 dev eth0 vlan 20 master vmbr0\n"
)
OVS_APPCTL = (
" LOCAL 0 aa:bb:cc:dd:ee:03 0\n"
" 1 10 11:22:33:44:55:66 100\n"
)
def test_linux_bridge_entries(self, driver):
def _exec(cmd):
if "bridge fdb" in cmd:
return self.BRIDGE_FDB
return ""
driver._node_api().execute.post.side_effect = lambda command: {
"data": _exec(command)
}
result = driver.get_mac_address_table()
macs = {e["mac"] for e in result}
assert "aa:bb:cc:dd:ee:01" in macs
def test_static_flag(self, driver):
def _exec(cmd):
if "bridge fdb" in cmd:
return self.BRIDGE_FDB
return ""
driver._node_api().execute.post.side_effect = lambda command: {
"data": _exec(command)
}
result = driver.get_mac_address_table()
static_entries = [e for e in result if e["mac"] == "aa:bb:cc:dd:ee:01"]
assert static_entries[0]["static"] is True
+89
View File
@@ -0,0 +1,89 @@
"""Tests for SDN-related methods: get_vlans, get_network_instances."""
from __future__ import annotations
import pytest
from tests.conftest import _build_mock_api, SDN_ZONES, SDN_VNETS
class TestGetVlans:
def test_sdn_vnets_appear_as_vlans(self, driver):
result = driver.get_vlans()
# vnet0 tag=10, vnet1 tag=20
assert "10" in result
assert "20" in result
def test_vlan_name_is_vnet_id(self, driver):
result = driver.get_vlans()
assert result["10"]["name"] == "vnet0"
assert result["20"]["name"] == "vnet1"
def test_vlan_100000_for_vxlan(self, driver):
result = driver.get_vlans()
assert "100000" in result
def test_bridge_vlan_show_parsing(self, driver):
# Simulate bridge vlan output
bridge_output = (
"vmbr0 1\n"
" 10\n"
" 20\n"
"eth0 1\n"
)
driver._node_api().execute.post.return_value = {"data": bridge_output}
result = driver.get_vlans()
# Interface vmbr0 should appear in vlan 1
entry = result.get("1", {})
assert "vmbr0" in entry.get("interfaces", [])
def test_empty_sdn_returns_dict(self):
from unittest.mock import patch
from napalm_proxmox.driver import ProxmoxDriver
from tests.conftest import _build_mock_api
mock_api = _build_mock_api(sdn_vnets=[], sdn_zones=[])
drv = ProxmoxDriver("pve1", "root", "secret", optional_args={"verify_ssl": False})
with patch("napalm_proxmox.driver.ProxmoxAPI", return_value=mock_api):
drv.open()
result = drv.get_vlans()
assert isinstance(result, dict)
class TestGetNetworkInstances:
def test_default_instance_always_present(self, driver):
result = driver.get_network_instances()
assert "default" in result
assert result["default"]["type"] == "DEFAULT_INSTANCE"
def test_sdn_zones_as_instances(self, driver):
result = driver.get_network_instances()
assert "zone-vlan" in result
assert "zone-vxlan" in result
assert "zone-evpn" in result
def test_zone_type_mapping(self, driver):
result = driver.get_network_instances()
assert result["zone-vlan"]["type"] == "L2VPN"
assert result["zone-evpn"]["type"] == "L3VRF"
def test_vnet_appears_in_zone_interfaces(self, driver):
result = driver.get_network_instances()
zone_ifaces = result["zone-vlan"]["interfaces"]["interface"]
assert "vnet0" in zone_ifaces
assert "vnet1" in zone_ifaces
def test_filter_by_name(self, driver):
result = driver.get_network_instances(name="zone-vlan")
assert "zone-vlan" in result
assert "zone-vxlan" not in result
def test_evpn_route_distinguisher(self, driver):
result = driver.get_network_instances()
rd = result["zone-evpn"]["state"]["route_distinguisher"]
assert rd == 10001
def test_default_interfaces_include_node_ifaces(self, driver):
result = driver.get_network_instances()
default_ifaces = result["default"]["interfaces"]["interface"]
assert "eth0" in default_ifaces
assert "vmbr0" in default_ifaces
+224
View File
@@ -0,0 +1,224 @@
"""Unit tests for napalm_proxmox.utils."""
from __future__ import annotations
import pytest
from napalm_proxmox import utils
class TestNormalizeMAC:
def test_colon_upper(self):
assert utils.normalize_mac("AA:BB:CC:DD:EE:FF") == "aa:bb:cc:dd:ee:ff"
def test_dash_separated(self):
assert utils.normalize_mac("AA-BB-CC-DD-EE-FF") == "aa:bb:cc:dd:ee:ff"
def test_lowercase_passthrough(self):
assert utils.normalize_mac("aa:bb:cc:dd:ee:ff") == "aa:bb:cc:dd:ee:ff"
def test_empty(self):
assert utils.normalize_mac("") == ""
class TestParseCIDR:
def test_ipv4(self):
ip, plen = utils.parse_cidr("192.168.1.1/24")
assert ip == "192.168.1.1"
assert plen == 24
def test_ipv6(self):
ip, plen = utils.parse_cidr("2001:db8::1/64")
assert ip == "2001:db8::1"
assert plen == 64
def test_no_prefix(self):
ip, plen = utils.parse_cidr("10.0.0.1")
assert ip == "10.0.0.1"
assert plen == 32
def test_host_route(self):
ip, plen = utils.parse_cidr("10.0.0.1/32")
assert plen == 32
class TestPrefixLength:
def test_slash24(self):
assert utils.prefix_length("10.0.0.0/24") == 24
def test_slash_16(self):
assert utils.prefix_length("192.168.0.0/16") == 16
def test_no_slash(self):
assert utils.prefix_length("10.0.0.1") == 32
class TestAddressesFromNodeNetwork:
def test_address_and_netmask(self):
data = {"address": "10.0.0.1", "netmask": "255.255.255.0"}
result = utils.addresses_from_node_network(data)
assert "ipv4" in result
assert "10.0.0.1" in result["ipv4"]
assert result["ipv4"]["10.0.0.1"]["prefix_length"] == 24
def test_cidr_ipv4(self):
data = {"cidr": "10.0.0.1/24"}
result = utils.addresses_from_node_network(data)
assert "ipv4" in result
assert "10.0.0.1" in result["ipv4"]
def test_cidr_ipv6(self):
data = {"cidr6": "2001:db8::1/64"}
result = utils.addresses_from_node_network(data)
assert "ipv6" in result
assert "2001:db8::1" in result["ipv6"]
assert result["ipv6"]["2001:db8::1"]["prefix_length"] == 64
def test_both_ipv4_and_ipv6(self):
data = {"cidr": "10.0.0.1/24", "cidr6": "2001:db8::1/64"}
result = utils.addresses_from_node_network(data)
assert "ipv4" in result
assert "ipv6" in result
def test_empty_input(self):
result = utils.addresses_from_node_network({})
assert result == {}
class TestIsOVSInterface:
def test_ovs_bridge(self):
assert utils.is_ovs_interface({"type": "OVSBridge"}) is True
def test_ovs_bond(self):
assert utils.is_ovs_interface({"type": "OVSBond"}) is True
def test_ovs_int_port(self):
assert utils.is_ovs_interface({"type": "OVSIntPort"}) is True
def test_linux_bridge(self):
assert utils.is_ovs_interface({"type": "bridge"}) is False
def test_eth(self):
assert utils.is_ovs_interface({"type": "eth"}) is False
def test_no_type_key(self):
assert utils.is_ovs_interface({}) is False
class TestIsSDNVNet:
def test_vnet_pattern(self):
assert utils.is_sdn_vnet("vnet0") is True
assert utils.is_sdn_vnet("vnet99") is True
def test_non_vnet(self):
assert utils.is_sdn_vnet("eth0") is False
assert utils.is_sdn_vnet("vmbr0") is False
def test_vnet_with_suffix(self):
assert utils.is_sdn_vnet("vnet0sub") is False
class TestSpeedMbps:
def test_speed_present(self):
assert utils.speed_mbps({"speed": "1000"}) == 1000.0
def test_speed_mbit_suffix(self):
# Some entries may have "Mbit" unit in the string — handled by parsing
assert utils.speed_mbps({"speed": "10000"}) == 10000.0
def test_no_speed(self):
assert utils.speed_mbps({}) == -1.0
def test_speed_non_numeric(self):
assert utils.speed_mbps({"speed": "unknown"}) == -1.0
class TestSDNZoneToNetworkInstance:
def test_vlan_zone(self):
zone = {"zone": "z1", "type": "vlan", "mtu": 1500}
result = utils.sdn_zone_to_network_instance(zone)
assert result["type"] == "L2VPN"
assert result["name"] == "z1"
def test_evpn_zone(self):
zone = {"zone": "z2", "type": "evpn", "peers": "10.0.0.1", "rt": 10001}
result = utils.sdn_zone_to_network_instance(zone)
assert result["type"] == "L3VRF"
def test_vxlan_zone(self):
zone = {"zone": "z3", "type": "vxlan", "mtu": 9000}
result = utils.sdn_zone_to_network_instance(zone)
assert result["type"] == "L2VPN"
def test_simple_zone(self):
zone = {"zone": "z4", "type": "simple"}
result = utils.sdn_zone_to_network_instance(zone)
assert result["type"] == "L3VRF"
def test_unknown_zone_type(self):
zone = {"zone": "z5", "type": "qinq"}
result = utils.sdn_zone_to_network_instance(zone)
assert result["type"] == "L2VPN"
def test_result_has_interfaces(self):
zone = {"zone": "z1", "type": "vlan"}
result = utils.sdn_zone_to_network_instance(zone)
assert "interfaces" in result
class TestParseARPOutput:
ARP_N = (
"Address HWtype HWaddress Flags Iface\n"
"10.0.0.254 ether aa:bb:cc:dd:ee:ff C eth0\n"
"10.0.0.10 ether 11:22:33:44:55:66 C vmbr0\n"
"? (10.0.0.20) at <incomplete> on eth0\n"
)
def test_parses_two_entries(self):
result = utils.parse_arp_output(self.ARP_N)
assert len(result) == 2
def test_entry_fields(self):
result = utils.parse_arp_output(self.ARP_N)
assert result[0]["ip"] == "10.0.0.254"
assert result[0]["mac"] == "aa:bb:cc:dd:ee:ff"
assert result[0]["interface"] == "eth0"
def test_skips_incomplete(self):
result = utils.parse_arp_output(self.ARP_N)
ips = {e["ip"] for e in result}
assert "10.0.0.20" not in ips
def test_empty_input(self):
result = utils.parse_arp_output("")
assert result == []
class TestParseOVSVsctlShow:
RAW = (
" Bridge vmbr1\n"
" Port eth1\n"
" Port vnet0\n"
" Port vmbr1\n"
" Bridge vmbr2\n"
" Port eth2\n"
" Port vmbr2\n"
)
def test_finds_both_bridges(self):
result = utils.parse_ovs_vsctl_show(self.RAW)
assert "vmbr1" in result
assert "vmbr2" in result
def test_ports_exclude_self(self):
result = utils.parse_ovs_vsctl_show(self.RAW)
assert "vmbr1" not in result["vmbr1"]["ports"]
assert "vmbr2" not in result["vmbr2"]["ports"]
def test_ports_included(self):
result = utils.parse_ovs_vsctl_show(self.RAW)
assert "eth1" in result["vmbr1"]["ports"]
assert "eth2" in result["vmbr2"]["ports"]
def test_empty_input(self):
result = utils.parse_ovs_vsctl_show("")
assert result == {}