initial commit
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.eggs/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# napalm-netgear
|
||||||
|
|
||||||
|
NAPALM community driver for **Netgear Smart Managed** and **Netgear Plus Managed** switches
|
||||||
|
(GS108EPP, GS110TP, GS316EPP and compatible Smart Managed Pro series).
|
||||||
|
|
||||||
|
## Tested devices
|
||||||
|
|
||||||
|
| Model | Series | Tested |
|
||||||
|
|---|---|---|
|
||||||
|
| GS110TP | Smart Managed Pro | ✅ |
|
||||||
|
| GS108EPP | Smart Managed Pro | ✅ |
|
||||||
|
| GS316EPP | Smart Managed Pro | ✅ |
|
||||||
|
|
||||||
|
> Other models from the Netgear Smart Managed Pro series should work as well —
|
||||||
|
> contributions welcome.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Dependency | Minimum version |
|
||||||
|
|---|---|
|
||||||
|
| Python | 3.8 |
|
||||||
|
| NAPALM | 4.0 |
|
||||||
|
| Netmiko | 4.0 |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install napalm-netgear
|
||||||
|
```
|
||||||
|
|
||||||
|
Or from source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/napalm-automation-community/napalm-netgear
|
||||||
|
cd napalm-netgear
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```python
|
||||||
|
from napalm import get_network_driver
|
||||||
|
|
||||||
|
driver = get_network_driver("netgear_smart")
|
||||||
|
with driver("192.168.0.239", "admin", "password") as device:
|
||||||
|
facts = device.get_facts()
|
||||||
|
print(facts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implemented getters
|
||||||
|
|
||||||
|
| Getter | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `get_facts` | ✅ | model, hostname, os_version, serial, uptime |
|
||||||
|
| `get_interfaces` | ✅ | status, speed, description, MTU |
|
||||||
|
| `get_interfaces_ip` | ✅ | management VLAN IP/mask |
|
||||||
|
| `get_config` | ✅ | running + startup |
|
||||||
|
| `get_arp_table` | ✅ | |
|
||||||
|
| `get_mac_address_table` | ✅ | |
|
||||||
|
| `get_lldp_neighbors` | ✅ | |
|
||||||
|
| `get_lldp_neighbors_detail` | ✅ | |
|
||||||
|
| `get_vlans` | ✅ | |
|
||||||
|
| `get_environment` | ✅ | CPU + memory only |
|
||||||
|
| `get_interfaces_counters` | ✅ | |
|
||||||
|
| `get_users` | ✅ | |
|
||||||
|
| `get_snmp_information` | ✅ | |
|
||||||
|
| `get_ntp_servers` | ✅ | |
|
||||||
|
| `get_ntp_peers` | ✅ | same as servers |
|
||||||
|
| `get_route_to` | ✅ | static/connected only |
|
||||||
|
| `ping` | ✅ | |
|
||||||
|
| `cli` | ✅ | |
|
||||||
|
| `is_alive` | ✅ | |
|
||||||
|
| `load_merge_candidate` | ✅ | |
|
||||||
|
| `load_replace_candidate` | ✅ | additive — no atomic replace |
|
||||||
|
| `compare_config` | ✅ | |
|
||||||
|
| `commit_config` | ✅ | |
|
||||||
|
| `discard_config` | ✅ | |
|
||||||
|
| `rollback` | ✅ | session-scoped backup |
|
||||||
|
| `get_bgp_neighbors` | ❌ | not applicable |
|
||||||
|
| `get_optics` | ❌ | DDM not exposed via CLI |
|
||||||
|
| `get_ipv6_neighbors_table` | ❌ | not exposed via CLI |
|
||||||
|
| `get_ntp_stats` | ❌ | not exposed via CLI |
|
||||||
|
|
||||||
|
## Optional arguments
|
||||||
|
|
||||||
|
| Argument | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `port` | `22` | SSH port |
|
||||||
|
| `force_no_enable` | `False` | Skip `enable` after login |
|
||||||
|
| `canonical_int_fmt` | `False` | Use canonical interface names |
|
||||||
|
|
||||||
|
Any additional keyword arguments are forwarded to Netmiko.
|
||||||
|
|
||||||
|
## CLI notes
|
||||||
|
|
||||||
|
* Netmiko device type: `netgear_prosafe`
|
||||||
|
* Prompt pattern: `(hostname) >` / `(hostname) #` / `(hostname) (Config)#`
|
||||||
|
* Interface naming: `0/1`, `0/2`, … (slot/port); LAG channels: `ch1`, `ch2`, …
|
||||||
|
* Config is persisted with `write memory`
|
||||||
|
* Key-value output uses `Key: value` **or** `Key..... value` notation
|
||||||
|
depending on firmware generation — both are handled automatically
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache 2.0 — see [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""NAPALM driver for Netgear Smart Managed switches."""
|
||||||
|
|
||||||
|
from napalm_netgear_plus.netgear_smart import NetgearSmartDriver
|
||||||
|
from napalm_netgear_plus.netgear_plus import NetgearPlusDriver
|
||||||
|
|
||||||
|
__all__ = ["NetgearSmartDriver", "NetgearPlusDriver"]
|
||||||
@@ -0,0 +1,787 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""NAPALM driver for Netgear Plus Smart Managed switches (HTTP-based).
|
||||||
|
|
||||||
|
Tested against: GS308EP and compatible Plus Smart Managed series.
|
||||||
|
Transport: HTTP web interface via py-netgear-plus library.
|
||||||
|
|
||||||
|
These switches use password-only web authentication (no username).
|
||||||
|
The ``username`` parameter is accepted but ignored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from napalm_device_types import SwitchDriver
|
||||||
|
from napalm.base.exceptions import ConnectionException
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Firmware update check — module-level constants and cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Netgear CDN base URL for firmware zip files.
|
||||||
|
# Pattern: {_FW_CDN_BASE}/{MODEL}/{MODEL}_{VERSION}.zip
|
||||||
|
_FW_CDN_BASE = "https://www.downloads.netgear.com/files/GDC"
|
||||||
|
|
||||||
|
# Known-latest firmware versions per CDN model ID.
|
||||||
|
# Verified via HEAD requests against downloads.netgear.com on 2026-05-24.
|
||||||
|
# Update this table when Netgear releases new firmware.
|
||||||
|
_FW_KNOWN_LATEST: Dict[str, str] = {
|
||||||
|
"GS305EP": "V2.0.0.10",
|
||||||
|
"GS305EPP": "V2.0.0.10",
|
||||||
|
"GS308EP": "V2.0.0.11",
|
||||||
|
"GS308EPP": "V2.0.0.11",
|
||||||
|
}
|
||||||
|
|
||||||
|
# In-memory cache: model_id -> (latest_version, unix_timestamp_of_check)
|
||||||
|
_fw_cache: Dict[str, Tuple[str, float]] = {}
|
||||||
|
_fw_cache_lock = threading.Lock()
|
||||||
|
|
||||||
|
# How long (seconds) to reuse a cached firmware-version result (24 hours).
|
||||||
|
_FW_CACHE_TTL = 86400
|
||||||
|
|
||||||
|
|
||||||
|
def _fw_version_tuple(version: str) -> Tuple[int, ...]:
|
||||||
|
"""Parse 'V1.0.1.4' or '1.0.1.4' into (1, 0, 1, 4). Raises ValueError on bad input."""
|
||||||
|
v = version.strip().lstrip("Vv")
|
||||||
|
return tuple(int(x) for x in v.split("."))
|
||||||
|
|
||||||
|
|
||||||
|
def _fw_cdn_exists(model: str, version: str) -> bool:
|
||||||
|
"""Return True if a firmware zip exists on the Netgear CDN (HEAD request)."""
|
||||||
|
import urllib.request
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
url = f"{_FW_CDN_BASE}/{model}/{model}_{version}.zip"
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
req = urllib.request.Request(url, method="HEAD",
|
||||||
|
headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=3, context=ctx):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _fw_probe_newer(model: str, baseline: str) -> str:
|
||||||
|
"""Probe the Netgear CDN for firmware newer than *baseline*.
|
||||||
|
|
||||||
|
Checks up to ~22 candidate versions (patch increments, next micro, next
|
||||||
|
major). Returns the highest confirmed version string, which may equal
|
||||||
|
*baseline* if nothing newer is found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
base_t = _fw_version_tuple(baseline)
|
||||||
|
except ValueError:
|
||||||
|
return baseline
|
||||||
|
|
||||||
|
if len(base_t) != 4:
|
||||||
|
return baseline
|
||||||
|
|
||||||
|
major, minor, micro, patch = base_t
|
||||||
|
best_t = base_t
|
||||||
|
best_v = baseline
|
||||||
|
|
||||||
|
def _try(candidate: Tuple[int, ...]) -> None:
|
||||||
|
nonlocal best_t, best_v
|
||||||
|
v_str = "V" + ".".join(str(x) for x in candidate)
|
||||||
|
if _fw_cdn_exists(model, v_str) and candidate > best_t:
|
||||||
|
best_t = candidate
|
||||||
|
best_v = v_str
|
||||||
|
|
||||||
|
# Probe the next 20 patch versions
|
||||||
|
for p in range(patch + 1, patch + 21):
|
||||||
|
_try((major, minor, micro, p))
|
||||||
|
|
||||||
|
# Probe next micro version (reset patch to 0)
|
||||||
|
_try((major, minor, micro + 1, 0))
|
||||||
|
|
||||||
|
# Probe next major version (e.g. V2.0.0.0 when current is V1.x.x.x)
|
||||||
|
_try((major + 1, 0, 0, 0))
|
||||||
|
|
||||||
|
return best_v
|
||||||
|
|
||||||
|
|
||||||
|
def _fw_get_latest(model: str) -> Optional[str]:
|
||||||
|
"""Return the latest known firmware version for *model*, using a 24-hour
|
||||||
|
in-memory cache. Returns ``None`` when the model is not in the known table.
|
||||||
|
"""
|
||||||
|
model_upper = model.upper()
|
||||||
|
baseline = _FW_KNOWN_LATEST.get(model_upper)
|
||||||
|
if baseline is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
with _fw_cache_lock:
|
||||||
|
cached = _fw_cache.get(model_upper)
|
||||||
|
if cached is not None:
|
||||||
|
version, ts = cached
|
||||||
|
if now - ts < _FW_CACHE_TTL:
|
||||||
|
return version
|
||||||
|
|
||||||
|
# Cache miss or expired — probe CDN (outside the lock to avoid blocking
|
||||||
|
# other threads during potentially slow HTTP requests).
|
||||||
|
latest = _fw_probe_newer(model_upper, baseline)
|
||||||
|
|
||||||
|
with _fw_cache_lock:
|
||||||
|
_fw_cache[model_upper] = (latest, now)
|
||||||
|
|
||||||
|
return latest
|
||||||
|
|
||||||
|
|
||||||
|
class NetgearPlusDriver(SwitchDriver):
|
||||||
|
"""HTTP-based NAPALM driver for Netgear Plus Smart Managed switches."""
|
||||||
|
|
||||||
|
VENDOR = "Netgear"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
timeout: int = 60,
|
||||||
|
optional_args: Optional[Dict] = None,
|
||||||
|
) -> None:
|
||||||
|
# username is accepted but ignored — these switches have no username
|
||||||
|
self.hostname = hostname
|
||||||
|
self.password = password
|
||||||
|
self.timeout = timeout
|
||||||
|
self._connector = None
|
||||||
|
self._switch_infos_cache: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
if optional_args is None:
|
||||||
|
optional_args = {}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Connection management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
"""Authenticate to the switch via HTTP."""
|
||||||
|
try:
|
||||||
|
from py_netgear_plus import NetgearSwitchConnector
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ConnectionException(
|
||||||
|
"py-netgear-plus is not installed. "
|
||||||
|
"Add 'py-netgear-plus' to your dependencies."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
connector = NetgearSwitchConnector(self.hostname, self.password)
|
||||||
|
connector.autodetect_model()
|
||||||
|
if not connector.get_login_cookie():
|
||||||
|
raise ConnectionException(
|
||||||
|
f"Login failed for {self.hostname}: bad password or unreachable."
|
||||||
|
)
|
||||||
|
self._connector = connector
|
||||||
|
self._switch_infos_cache = None
|
||||||
|
except Exception as exc:
|
||||||
|
if isinstance(exc, ConnectionException):
|
||||||
|
raise
|
||||||
|
raise ConnectionException(
|
||||||
|
f"Cannot connect to {self.hostname}: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Log out from the switch."""
|
||||||
|
if self._connector is not None:
|
||||||
|
try:
|
||||||
|
self._connector.delete_login_cookie()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._connector = None
|
||||||
|
self._switch_infos_cache = None
|
||||||
|
|
||||||
|
def is_alive(self) -> Dict[str, bool]:
|
||||||
|
return {"is_alive": self._connector is not None}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_switch_infos(self) -> Dict[str, Any]:
|
||||||
|
"""Fetch and cache switch infos for this poll cycle."""
|
||||||
|
if self._switch_infos_cache is None:
|
||||||
|
self._switch_infos_cache = self._connector.get_switch_infos()
|
||||||
|
return self._switch_infos_cache
|
||||||
|
|
||||||
|
def _get_port_names(self) -> Dict[int, str]:
|
||||||
|
"""Fetch port names/descriptions from dashboard.cgi.
|
||||||
|
|
||||||
|
The GS30x series embeds ``<input class="portName" value="...">``
|
||||||
|
hidden inputs in the dashboard HTML. py-netgear-plus does not
|
||||||
|
parse them, so we do it here. Returns a mapping of
|
||||||
|
{port_number: name_string} (1-indexed, empty string if not set).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"http://{self.hostname}/dashboard.cgi"
|
||||||
|
resp = self._connector.fetch_page("get", url, {})
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
if not resp or resp.status_code != 200 or not resp.text:
|
||||||
|
return {}
|
||||||
|
portname_re = re.compile(
|
||||||
|
r'<input[^>]+class="portName"[^>]+value="([^"]*)"',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
names = portname_re.findall(resp.text)
|
||||||
|
return {i + 1: name for i, name in enumerate(names)}
|
||||||
|
|
||||||
|
def _port_name(self, port_number: int) -> str:
|
||||||
|
return f"port{port_number}"
|
||||||
|
|
||||||
|
def _interface_to_port_number(self, interface: str) -> int:
|
||||||
|
"""Convert an interface name like 'port3' to the integer port number 3."""
|
||||||
|
name = interface.strip().lower()
|
||||||
|
if name.startswith("port"):
|
||||||
|
try:
|
||||||
|
return int(name[4:])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot resolve interface {interface!r} to a port number. "
|
||||||
|
"Expected 'portN' (e.g. 'port1')."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# NAPALM getters
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_facts(self) -> Dict[str, Any]:
|
||||||
|
"""Return a dictionary of general device facts."""
|
||||||
|
infos = self._get_switch_infos()
|
||||||
|
model = getattr(self._connector.switch_model, "MODEL_NAME", "")
|
||||||
|
hostname = infos.get("switch_name", self.hostname)
|
||||||
|
os_version = infos.get("switch_firmware", "")
|
||||||
|
serial_number = infos.get("switch_serial_number", "")
|
||||||
|
interface_list = [
|
||||||
|
self._port_name(i) for i in range(1, self._connector.ports + 1)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"vendor": self.VENDOR,
|
||||||
|
"model": str(model),
|
||||||
|
"hostname": hostname,
|
||||||
|
"fqdn": hostname,
|
||||||
|
"os_version": os_version,
|
||||||
|
"serial_number": serial_number,
|
||||||
|
"uptime": 0.0,
|
||||||
|
"interface_list": interface_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_interfaces(self) -> Dict[str, Dict]:
|
||||||
|
"""Return a dictionary of interface details.
|
||||||
|
|
||||||
|
Each interface entry contains:
|
||||||
|
``is_up``, ``is_enabled``, ``description``, ``last_flapped``,
|
||||||
|
``speed``, ``mtu``, ``mac_address``.
|
||||||
|
"""
|
||||||
|
infos = self._get_switch_infos()
|
||||||
|
port_names = self._get_port_names()
|
||||||
|
interfaces: Dict[str, Dict] = {}
|
||||||
|
for port in range(1, self._connector.ports + 1):
|
||||||
|
name = self._port_name(port)
|
||||||
|
status = infos.get(f"port_{port}_status", "off") == "on"
|
||||||
|
speed = float(infos.get(f"port_{port}_connection_speed", 0) or 0)
|
||||||
|
description = port_names.get(port, "") or infos.get(f"port_{port}_description", "") or ""
|
||||||
|
interfaces[name] = {
|
||||||
|
"is_up": status,
|
||||||
|
"is_enabled": True,
|
||||||
|
"description": description,
|
||||||
|
"last_flapped": -1.0,
|
||||||
|
"speed": speed,
|
||||||
|
"mtu": 1518,
|
||||||
|
"mac_address": "",
|
||||||
|
}
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
def get_interfaces_ip(self) -> Dict[str, Dict]:
|
||||||
|
"""Return IP addresses per interface.
|
||||||
|
|
||||||
|
Netgear Plus switches are L2 only — no routed interfaces.
|
||||||
|
Always returns an empty dict.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_interfaces_counters(self) -> Dict[str, Dict]:
|
||||||
|
"""Return interface traffic counters.
|
||||||
|
|
||||||
|
Uses cumulative RX/TX totals (``sum_rx_mbytes`` / ``sum_tx_mbytes``)
|
||||||
|
from ``get_switch_infos()`` converted to octets, plus CRC errors.
|
||||||
|
"""
|
||||||
|
infos = self._get_switch_infos()
|
||||||
|
counters: Dict[str, Dict] = {}
|
||||||
|
for port in range(1, self._connector.ports + 1):
|
||||||
|
name = self._port_name(port)
|
||||||
|
# sum_*_mbytes are lifetime totals in megabytes; convert to bytes
|
||||||
|
rx_mb = float(infos.get(f"port_{port}_sum_rx_mbytes", 0) or 0)
|
||||||
|
tx_mb = float(infos.get(f"port_{port}_sum_tx_mbytes", 0) or 0)
|
||||||
|
crc = int(infos.get(f"port_{port}_crc_errors", 0) or 0)
|
||||||
|
counters[name] = {
|
||||||
|
"tx_errors": 0,
|
||||||
|
"rx_errors": crc,
|
||||||
|
"tx_discards": 0,
|
||||||
|
"rx_discards": 0,
|
||||||
|
"tx_octets": int(tx_mb * 1_000_000),
|
||||||
|
"rx_octets": int(rx_mb * 1_000_000),
|
||||||
|
"tx_unicast_packets": 0,
|
||||||
|
"rx_unicast_packets": 0,
|
||||||
|
"tx_multicast_packets": 0,
|
||||||
|
"rx_multicast_packets": 0,
|
||||||
|
"tx_broadcast_packets": 0,
|
||||||
|
"rx_broadcast_packets": 0,
|
||||||
|
}
|
||||||
|
return counters
|
||||||
|
|
||||||
|
def get_environment(self) -> Dict[str, Any]:
|
||||||
|
"""Return environment data.
|
||||||
|
|
||||||
|
Reports PoE power status per PoE port. Temperature, fans, CPU and
|
||||||
|
memory are not available on these switches and are returned as empty
|
||||||
|
dicts.
|
||||||
|
"""
|
||||||
|
infos = self._get_switch_infos()
|
||||||
|
power: Dict[str, Any] = {}
|
||||||
|
poe_max_single = getattr(
|
||||||
|
self._connector.switch_model, "POE_MAX_POWER_SINGLE_PORT", None
|
||||||
|
)
|
||||||
|
for port in self._connector.poe_ports:
|
||||||
|
poe_active = infos.get(f"port_{port}_poe_power_active")
|
||||||
|
poe_status = bool(poe_active) if poe_active is not None else False
|
||||||
|
power[self._port_name(port)] = {
|
||||||
|
"status": poe_status,
|
||||||
|
"capacity": float(poe_max_single) if poe_max_single else 0.0,
|
||||||
|
"used": 0.0,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"fans": {},
|
||||||
|
"temperature": {},
|
||||||
|
"power": power,
|
||||||
|
"cpu": {},
|
||||||
|
"memory": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_mac_address_table(self) -> List[Dict]:
|
||||||
|
"""Return the MAC address table.
|
||||||
|
|
||||||
|
Not available via the HTTP web interface of Plus switches.
|
||||||
|
Returns an empty list.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_arp_table(self, vrf: str = "") -> List[Dict]:
|
||||||
|
"""Return the ARP table.
|
||||||
|
|
||||||
|
Netgear Plus switches are L2 only and have no ARP table.
|
||||||
|
Returns an empty list.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_lldp_neighbors(self) -> Dict[str, Any]:
|
||||||
|
"""Return LLDP neighbors.
|
||||||
|
|
||||||
|
Not supported on Netgear Plus switches.
|
||||||
|
Returns an empty dict.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_lldp_neighbors_detail(self, interface: str = "") -> Dict[str, Any]:
|
||||||
|
"""Return detailed LLDP neighbor info.
|
||||||
|
|
||||||
|
Not supported on Netgear Plus switches.
|
||||||
|
Returns an empty dict.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_vlans_detail(self) -> Dict[str, Any]:
|
||||||
|
"""Return VLAN configuration by scraping the switch's /vlan.cgi page.
|
||||||
|
|
||||||
|
Returns a dict keyed by VLAN ID string:
|
||||||
|
{"1": {"name": "Default", "tagged": ["port1"], "untagged": ["port2"]}}
|
||||||
|
|
||||||
|
Parses 802.1Q Advanced VLAN data from the switch web interface.
|
||||||
|
Each port's PVID (native VLAN) is listed with a trailing "*" in the
|
||||||
|
per-port VLAN membership spans.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
url = f"http://{self.hostname}/vlan.cgi"
|
||||||
|
try:
|
||||||
|
resp = self._connector.fetch_page("get", url, {})
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not resp or resp.status_code != 200 or not resp.text:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
text = resp.text
|
||||||
|
|
||||||
|
# Parse VLAN IDs and names from vid-4 / vnm-4 span pairs.
|
||||||
|
# Example: <span vid-4>...<i ...></i>24</span><span vnm-4 ...>Guest</span>
|
||||||
|
vlan_names: Dict[str, str] = {}
|
||||||
|
vlan_entry_re = re.compile(
|
||||||
|
r'<span\s+vid-4[^>]*>.*?(\d+)\s*</span>\s*'
|
||||||
|
r'<span\s+vnm-4[^>]*>([^<]*)</span>',
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
for m in vlan_entry_re.finditer(text):
|
||||||
|
vid = m.group(1).strip()
|
||||||
|
name = m.group(2).strip()
|
||||||
|
vlan_names[vid] = name
|
||||||
|
|
||||||
|
if not vlan_names:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
vlans: Dict[str, Any] = {
|
||||||
|
vid: {"name": name, "tagged": [], "untagged": []}
|
||||||
|
for vid, name in vlan_names.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse per-port PVID membership.
|
||||||
|
# Each <span pvid-str ...>1*, 24, 25,</span> corresponds to one port
|
||||||
|
# in order (port 1, port 2, ...). A VID followed by "*" is the native
|
||||||
|
# (untagged) VLAN for that port; all others are tagged.
|
||||||
|
pvid_span_re = re.compile(r'<span\s[^>]*pvid-str[^>]*>([^<]+)</span>')
|
||||||
|
for port_idx, pvid_str in enumerate(pvid_span_re.finditer(text)):
|
||||||
|
port_num = port_idx + 1
|
||||||
|
if port_num > self._connector.ports:
|
||||||
|
break
|
||||||
|
port_name = self._port_name(port_num)
|
||||||
|
for token in pvid_str.group(1).split(","):
|
||||||
|
token = token.strip()
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
if token.endswith("*"):
|
||||||
|
vid = token[:-1]
|
||||||
|
role = "untagged"
|
||||||
|
else:
|
||||||
|
vid = token
|
||||||
|
role = "tagged"
|
||||||
|
if vid in vlans:
|
||||||
|
vlans[vid][role].append(port_name)
|
||||||
|
|
||||||
|
return vlans
|
||||||
|
|
||||||
|
def get_vlans(self) -> Dict[str, Any]:
|
||||||
|
"""Return VLAN information (standard NAPALM format).
|
||||||
|
|
||||||
|
Delegates to get_vlans_detail() and merges tagged and untagged ports
|
||||||
|
into a single ``interfaces`` list.
|
||||||
|
"""
|
||||||
|
detail = self.get_vlans_detail()
|
||||||
|
return {
|
||||||
|
vid: {
|
||||||
|
"name": info["name"],
|
||||||
|
"interfaces": info["tagged"] + info["untagged"],
|
||||||
|
}
|
||||||
|
for vid, info in detail.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_vlan(self, vlan_id: int, config: Dict) -> None:
|
||||||
|
"""Create or update a VLAN.
|
||||||
|
|
||||||
|
Not supported via HTTP API.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"VLAN management is not supported for Netgear Plus switches via HTTP."
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_vlan(self, vlan_id: int) -> None:
|
||||||
|
"""Delete a VLAN.
|
||||||
|
|
||||||
|
Not supported via HTTP API.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"VLAN management is not supported for Netgear Plus switches via HTTP."
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_config(
|
||||||
|
self,
|
||||||
|
retrieve: str = "all",
|
||||||
|
full: bool = False,
|
||||||
|
sanitized: bool = False,
|
||||||
|
format: str = "text",
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Return device configuration.
|
||||||
|
|
||||||
|
Netgear Plus switches do not expose running/startup config via HTTP.
|
||||||
|
Returns empty strings for all config slots.
|
||||||
|
"""
|
||||||
|
return {"running": "", "startup": "", "candidate": ""}
|
||||||
|
|
||||||
|
def load_merge_candidate(self, filename: Optional[str] = None, config: Optional[str] = None) -> None:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Config management is not supported for Netgear Plus switches."
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_replace_candidate(self, filename: Optional[str] = None, config: Optional[str] = None) -> None:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Config management is not supported for Netgear Plus switches."
|
||||||
|
)
|
||||||
|
|
||||||
|
def compare_config(self) -> str:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Config management is not supported for Netgear Plus switches."
|
||||||
|
)
|
||||||
|
|
||||||
|
def commit_config(self, message: str = "") -> None:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Config management is not supported for Netgear Plus switches."
|
||||||
|
)
|
||||||
|
|
||||||
|
def discard_config(self) -> None:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Config management is not supported for Netgear Plus switches."
|
||||||
|
)
|
||||||
|
|
||||||
|
def rollback(self) -> None:
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Config management is not supported for Netgear Plus switches."
|
||||||
|
)
|
||||||
|
|
||||||
|
def has_pending_commit(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def ping(
|
||||||
|
self,
|
||||||
|
destination: str,
|
||||||
|
source: str = "",
|
||||||
|
ttl: int = 255,
|
||||||
|
timeout: int = 2,
|
||||||
|
size: int = 100,
|
||||||
|
count: int = 5,
|
||||||
|
vrf: str = "",
|
||||||
|
source_interface: str = "",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Execute ping.
|
||||||
|
|
||||||
|
Not supported on Netgear Plus switches via HTTP.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"ping() is not supported for Netgear Plus switches."
|
||||||
|
)
|
||||||
|
|
||||||
|
def traceroute(
|
||||||
|
self,
|
||||||
|
destination: str,
|
||||||
|
source: str = "",
|
||||||
|
ttl: int = 255,
|
||||||
|
timeout: int = 2,
|
||||||
|
vrf: str = "",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Execute traceroute.
|
||||||
|
|
||||||
|
Not supported on Netgear Plus switches via HTTP.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"traceroute() is not supported for Netgear Plus switches."
|
||||||
|
)
|
||||||
|
|
||||||
|
def cli(self, commands: List[str], encoding: str = "text") -> Dict[str, Any]:
|
||||||
|
"""Send CLI commands.
|
||||||
|
|
||||||
|
Netgear Plus switches have no SSH CLI — only HTTP.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"cli() is not supported for Netgear Plus switches (HTTP-only device)."
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_users(self) -> Dict[str, Any]:
|
||||||
|
"""Return user accounts.
|
||||||
|
|
||||||
|
Not available via HTTP API. Returns an empty dict.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_snmp_information(self) -> Dict[str, Any]:
|
||||||
|
"""Return SNMP configuration.
|
||||||
|
|
||||||
|
Not available via HTTP API. Returns an empty dict.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_ntp_servers(self) -> Dict[str, Any]:
|
||||||
|
"""Return configured NTP servers.
|
||||||
|
|
||||||
|
Not available via HTTP API. Returns an empty dict.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_ntp_peers(self) -> Dict[str, Any]:
|
||||||
|
"""Return NTP peers.
|
||||||
|
|
||||||
|
Not available via HTTP API. Returns an empty dict.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_ntp_stats(self) -> List[Dict]:
|
||||||
|
"""Return NTP stats.
|
||||||
|
|
||||||
|
Not available via HTTP API. Returns an empty list.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# SwitchDriver abstract methods (not applicable for this device)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_spanning_tree(self) -> Dict[str, Any]:
|
||||||
|
"""Return spanning tree status.
|
||||||
|
|
||||||
|
Not available via HTTP API. Returns an empty dict.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_port_channels(self) -> Dict[str, Any]:
|
||||||
|
"""Return port-channel information.
|
||||||
|
|
||||||
|
Not available via HTTP API. Returns an empty dict.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_dot1x_config(self) -> Dict[str, Any]:
|
||||||
|
"""Return 802.1X port configuration.
|
||||||
|
|
||||||
|
Not available via HTTP API. Returns an empty dict.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_mac_acl(self) -> Dict[str, Any]:
|
||||||
|
"""Return MAC ACL information.
|
||||||
|
|
||||||
|
Not available via HTTP API. Returns an empty dict.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def get_poe_status(self) -> Dict[str, Any]:
|
||||||
|
"""Return PoE status.
|
||||||
|
|
||||||
|
Reports PoE power status from switch infos.
|
||||||
|
"""
|
||||||
|
infos = self._get_switch_infos()
|
||||||
|
poe_max_all = getattr(
|
||||||
|
self._connector.switch_model, "POE_MAX_POWER_ALL_PORTS", None
|
||||||
|
)
|
||||||
|
poe_max_single = getattr(
|
||||||
|
self._connector.switch_model, "POE_MAX_POWER_SINGLE_PORT", None
|
||||||
|
)
|
||||||
|
ports: Dict[str, Any] = {}
|
||||||
|
for p in self._connector.poe_ports:
|
||||||
|
poe_active = infos.get(f"port_{p}_poe_power_active")
|
||||||
|
is_delivering = bool(poe_active) if poe_active is not None else False
|
||||||
|
ports[self._port_name(p)] = {
|
||||||
|
"enabled": True,
|
||||||
|
"status": "delivering" if is_delivering else "searching",
|
||||||
|
"poe_class": "unknown",
|
||||||
|
"power_draw": 0.0,
|
||||||
|
"power_budget": float(poe_max_single) if poe_max_single else 0.0,
|
||||||
|
"voltage": 0.0,
|
||||||
|
"current": 0.0,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"total_power_budget": float(poe_max_all) if poe_max_all else 0.0,
|
||||||
|
"total_power_draw": 0.0,
|
||||||
|
"ports": ports,
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_poe_enabled(self, interface: str, enabled: bool) -> None:
|
||||||
|
"""Enable or disable PoE on a port.
|
||||||
|
|
||||||
|
Uses py-netgear-plus to switch the PoE port state.
|
||||||
|
"""
|
||||||
|
port_number = self._interface_to_port_number(interface)
|
||||||
|
if port_number not in self._connector.poe_ports:
|
||||||
|
raise ValueError(f"{interface} is not a PoE port on this switch.")
|
||||||
|
if enabled:
|
||||||
|
self._connector.turn_on_poe_port(port_number)
|
||||||
|
else:
|
||||||
|
self._connector.turn_off_poe_port(port_number)
|
||||||
|
|
||||||
|
def power_cycle_port(self, interface: str, delay: int = 5) -> None:
|
||||||
|
"""Power-cycle a PoE port.
|
||||||
|
|
||||||
|
Uses py-netgear-plus power cycle functionality.
|
||||||
|
"""
|
||||||
|
port_number = self._interface_to_port_number(interface)
|
||||||
|
if port_number not in self._connector.poe_ports:
|
||||||
|
raise ValueError(f"{interface} is not a PoE port on this switch.")
|
||||||
|
self._connector.power_cycle_poe_port(port_number)
|
||||||
|
|
||||||
|
def set_interface(self, interface: str, config: Dict) -> None:
|
||||||
|
"""Configure an interface.
|
||||||
|
|
||||||
|
Not supported via HTTP API.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
"set_interface() is not supported for Netgear Plus switches."
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_device_warnings(self) -> list:
|
||||||
|
"""Check for available firmware updates and return a warning if the
|
||||||
|
device is not running the latest known firmware.
|
||||||
|
|
||||||
|
The latest version is looked up once per 24 hours (per model, per
|
||||||
|
worker process) via lightweight HEAD requests against Netgear's
|
||||||
|
download CDN. Returns an empty list when the model is unknown or
|
||||||
|
when the check fails.
|
||||||
|
"""
|
||||||
|
warnings: list = []
|
||||||
|
try:
|
||||||
|
model_name: str = getattr(
|
||||||
|
self._connector.switch_model, "MODEL_NAME", ""
|
||||||
|
) or self._connector.switch_model.__class__.__name__
|
||||||
|
current_fw: str = self._get_switch_infos().get("switch_firmware", "")
|
||||||
|
|
||||||
|
if not model_name or not current_fw:
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
latest_fw = _fw_get_latest(model_name)
|
||||||
|
if latest_fw is None:
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_t = _fw_version_tuple(current_fw)
|
||||||
|
latest_t = _fw_version_tuple(latest_fw)
|
||||||
|
except ValueError:
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
if latest_t > current_t:
|
||||||
|
warnings.append(
|
||||||
|
{
|
||||||
|
"code": "firmware_update_available",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": "Firmware update available",
|
||||||
|
"message": (
|
||||||
|
f"Version {latest_fw} is available for {model_name} "
|
||||||
|
f"(installed: {current_fw})."
|
||||||
|
),
|
||||||
|
"action": None,
|
||||||
|
"action_label": None,
|
||||||
|
"meta": {
|
||||||
|
"download_url": (
|
||||||
|
f"{_FW_CDN_BASE}/{model_name}"
|
||||||
|
f"/{model_name}_{latest_fw}.zip"
|
||||||
|
),
|
||||||
|
"latest_version": latest_fw,
|
||||||
|
"current_version": current_fw,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return warnings
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=64", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "napalm-netgear"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "NAPALM driver for Netgear switches (Plus/Smart)"
|
||||||
|
readme = "README.md"
|
||||||
|
license = { text = "Apache-2.0" }
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
authors = [
|
||||||
|
{ name = "Christian Manivong" },
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Topic :: Utilities",
|
||||||
|
"License :: OSI Approved :: Apache Software License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"Operating System :: MacOS",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"napalm>=4.0.0",
|
||||||
|
"netmiko>=4.0.0",
|
||||||
|
"netaddr",
|
||||||
|
"py-netgear-plus>=0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest",
|
||||||
|
"pytest-cov",
|
||||||
|
"black",
|
||||||
|
"ruff",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.entry-points."napalm.drivers"]
|
||||||
|
netgear_smart = "napalm_netgear:NetgearSmartDriver"
|
||||||
|
netgear_plus = "napalm_netgear:NetgearPlusDriver"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://github.com/napalm-automation-community/napalm-netgear"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["napalm_netgear*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py38"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
napalm>=4.0.0
|
||||||
|
netmiko>=4.0.0
|
||||||
|
netaddr
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
"""Unit tests for NetgearSmartDriver — no real device required."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from napalm_netgear_plus.netgear_smart import NetgearSmartDriver
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def driver():
|
||||||
|
"""Return a driver instance with a mocked Netmiko connection."""
|
||||||
|
with patch("napalm_netgear_plus.netgear_smart.ConnectHandler"):
|
||||||
|
drv = NetgearSmartDriver(
|
||||||
|
hostname="192.168.0.239",
|
||||||
|
username="admin",
|
||||||
|
password="password",
|
||||||
|
)
|
||||||
|
drv.device = MagicMock()
|
||||||
|
drv.device.base_prompt = "(GS110TP) "
|
||||||
|
yield drv
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sample CLI output fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SHOW_SYSINFO = """\
|
||||||
|
System Description: GS110TP Gigabit Smart Managed Pro Switch
|
||||||
|
Machine Model: GS110TP
|
||||||
|
Machine Type: Ethernet Switch
|
||||||
|
Burned In MAC Address: C4:AD:34:AB:12:34
|
||||||
|
System OID String: 1.3.6.1.4.1.4526.100.5.4
|
||||||
|
System Uptime: 2 days 3 hrs 15 mins 42 secs
|
||||||
|
System Location: Server Room
|
||||||
|
System Contact: admin@example.com
|
||||||
|
System Name: myswitch
|
||||||
|
"""
|
||||||
|
|
||||||
|
SHOW_VERSION = """\
|
||||||
|
Software Version............................. 6.6.3
|
||||||
|
Loader Version............................... 1.0.0.15
|
||||||
|
Boot ROM Version............................. B1.0.0.15
|
||||||
|
Hardware Version............................. V1
|
||||||
|
Serial Number................................ 1FE2A0B1C2
|
||||||
|
"""
|
||||||
|
|
||||||
|
SHOW_PORT_ALL = """\
|
||||||
|
Intf Type Admin Physical Physical Link Link LACP Actor Partner Admin
|
||||||
|
Mode Mode Status Status Trap Mode Port Port LACP
|
||||||
|
Priority Priority Timeout
|
||||||
|
--------- ------ ------- ---------- ---------- ------ ----- ----- ------ ------ -------
|
||||||
|
0/1 Enable Auto 1G/Full Up Enable Disable 128 0 Long
|
||||||
|
0/2 Enable Auto - Down Enable Disable 128 0 Long
|
||||||
|
0/3 Disable Auto - Down Enable Disable 128 0 Long
|
||||||
|
"""
|
||||||
|
|
||||||
|
SHOW_INTERFACE_ALL = """\
|
||||||
|
Interface................................ 0/1
|
||||||
|
Description.............................. uplink-to-router
|
||||||
|
MTU...................................... 1518
|
||||||
|
|
||||||
|
Interface................................ 0/2
|
||||||
|
Description.............................. server-1
|
||||||
|
MTU...................................... 1518
|
||||||
|
|
||||||
|
Interface................................ 0/3
|
||||||
|
MTU...................................... 1518
|
||||||
|
"""
|
||||||
|
|
||||||
|
SHOW_IP_INTERFACE = """\
|
||||||
|
IP Address....................................... 192.168.0.239
|
||||||
|
Subnet Mask...................................... 255.255.255.0
|
||||||
|
Default Gateway.................................. 192.168.0.1
|
||||||
|
"""
|
||||||
|
|
||||||
|
SHOW_ARP = """\
|
||||||
|
IP Address MAC Address Interface Age (min) Type
|
||||||
|
--------- ------------------ --------- --------- -------
|
||||||
|
192.168.0.1 00:11:22:33:44:55 0/0 - Local
|
||||||
|
192.168.0.100 aa:bb:cc:dd:ee:ff 0/0 5 Dynamic
|
||||||
|
"""
|
||||||
|
|
||||||
|
SHOW_MAC = """\
|
||||||
|
VLAN ID MAC Address Type Port
|
||||||
|
------- ------------------ ---------- ------
|
||||||
|
1 00:11:22:33:44:55 Dynamic 0/1
|
||||||
|
1 aa:bb:cc:dd:ee:ff Static 0/2
|
||||||
|
1 ff:ff:ff:ff:ff:ff Management CPU
|
||||||
|
"""
|
||||||
|
|
||||||
|
SHOW_VLAN = """\
|
||||||
|
VLAN ID VLAN Name VLAN Type Interface(s)
|
||||||
|
------- --------------- ---------- -----------------------------------
|
||||||
|
1 Default Default 0/1, 0/2, 0/3
|
||||||
|
10 Management Static 0/1
|
||||||
|
20 Servers Static 0/2, 0/3
|
||||||
|
"""
|
||||||
|
|
||||||
|
SHOW_LLDP_ALL = """\
|
||||||
|
Local RemID Chassis ID Port ID System Name
|
||||||
|
Interface
|
||||||
|
--------- ----- -------------------- --------------- ---------------
|
||||||
|
0/1 1 00:1a:2b:3c:4d:5e Gi0/1 core-router
|
||||||
|
"""
|
||||||
|
|
||||||
|
SHOW_LLDP_DETAIL = """\
|
||||||
|
Chassis ID Subtype............................. MAC Address
|
||||||
|
Chassis ID................................... 00:1a:2b:3c:4d:5e
|
||||||
|
Port ID Subtype.............................. Interface Name
|
||||||
|
Port ID...................................... Gi0/1
|
||||||
|
Port Description............................. WAN uplink
|
||||||
|
System Name.................................. core-router
|
||||||
|
System Description........................... Cisco IOS XE
|
||||||
|
System Capabilities.......................... Bridge, Router
|
||||||
|
Enabled Capabilities......................... Router
|
||||||
|
"""
|
||||||
|
|
||||||
|
SHOW_SNMP = """\
|
||||||
|
SNMP Community
|
||||||
|
|
||||||
|
Community Name Access Mode Status
|
||||||
|
-------------- ----------- ------
|
||||||
|
public Read Only Enable
|
||||||
|
private Read Write Enable
|
||||||
|
"""
|
||||||
|
|
||||||
|
SHOW_USERS = """\
|
||||||
|
User Access Level Session Timeout Password Strength
|
||||||
|
-------- ------------ --------------- -----------------
|
||||||
|
admin Read/Write 5 Enabled
|
||||||
|
guest Read Only 5 Enabled
|
||||||
|
"""
|
||||||
|
|
||||||
|
SHOW_SNTP = """\
|
||||||
|
SNTP Server
|
||||||
|
SNTP Server Address....................... 192.168.0.1
|
||||||
|
SNTP Server Port......................... 123
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: get_facts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetFacts:
|
||||||
|
def test_returns_required_keys(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_SYSINFO
|
||||||
|
driver._get_interface_list = lambda: []
|
||||||
|
facts = driver.get_facts()
|
||||||
|
for key in ("vendor", "model", "hostname", "os_version", "serial_number",
|
||||||
|
"uptime", "interface_list", "fqdn"):
|
||||||
|
assert key in facts
|
||||||
|
|
||||||
|
def test_vendor(self, driver):
|
||||||
|
driver._send_command = lambda cmd: (
|
||||||
|
SHOW_SYSINFO if "sysinfo" in cmd else SHOW_VERSION
|
||||||
|
)
|
||||||
|
driver._get_interface_list = lambda: []
|
||||||
|
facts = driver.get_facts()
|
||||||
|
assert facts["vendor"] == "Netgear"
|
||||||
|
|
||||||
|
def test_model(self, driver):
|
||||||
|
driver._send_command = lambda cmd: (
|
||||||
|
SHOW_SYSINFO if "sysinfo" in cmd else SHOW_VERSION
|
||||||
|
)
|
||||||
|
driver._get_interface_list = lambda: []
|
||||||
|
facts = driver.get_facts()
|
||||||
|
assert facts["model"] == "GS110TP"
|
||||||
|
|
||||||
|
def test_hostname(self, driver):
|
||||||
|
driver._send_command = lambda cmd: (
|
||||||
|
SHOW_SYSINFO if "sysinfo" in cmd else SHOW_VERSION
|
||||||
|
)
|
||||||
|
driver._get_interface_list = lambda: []
|
||||||
|
facts = driver.get_facts()
|
||||||
|
assert facts["hostname"] == "myswitch"
|
||||||
|
|
||||||
|
def test_os_version(self, driver):
|
||||||
|
driver._send_command = lambda cmd: (
|
||||||
|
SHOW_SYSINFO if "sysinfo" in cmd else SHOW_VERSION
|
||||||
|
)
|
||||||
|
driver._get_interface_list = lambda: []
|
||||||
|
facts = driver.get_facts()
|
||||||
|
assert facts["os_version"] == "6.6.3"
|
||||||
|
|
||||||
|
def test_serial_number(self, driver):
|
||||||
|
driver._send_command = lambda cmd: (
|
||||||
|
SHOW_SYSINFO if "sysinfo" in cmd else SHOW_VERSION
|
||||||
|
)
|
||||||
|
driver._get_interface_list = lambda: []
|
||||||
|
facts = driver.get_facts()
|
||||||
|
assert facts["serial_number"] == "1FE2A0B1C2"
|
||||||
|
|
||||||
|
def test_uptime_parsing(self, driver):
|
||||||
|
driver._send_command = lambda cmd: (
|
||||||
|
SHOW_SYSINFO if "sysinfo" in cmd else SHOW_VERSION
|
||||||
|
)
|
||||||
|
driver._get_interface_list = lambda: []
|
||||||
|
facts = driver.get_facts()
|
||||||
|
expected = 2 * 86400 + 3 * 3600 + 15 * 60 + 42
|
||||||
|
assert facts["uptime"] == float(expected)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: _parse_uptime_seconds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseUptimeSeconds:
|
||||||
|
def test_full(self):
|
||||||
|
assert NetgearSmartDriver._parse_uptime_seconds(
|
||||||
|
"2 days 3 hrs 15 mins 42 secs"
|
||||||
|
) == float(2 * 86400 + 3 * 3600 + 15 * 60 + 42)
|
||||||
|
|
||||||
|
def test_zero(self):
|
||||||
|
assert NetgearSmartDriver._parse_uptime_seconds(
|
||||||
|
"0 days 0 hrs 0 mins 0 secs"
|
||||||
|
) == 0.0
|
||||||
|
|
||||||
|
def test_hours_only(self):
|
||||||
|
assert NetgearSmartDriver._parse_uptime_seconds("1 hrs 0 mins 0 secs") == 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: _parse_key_value
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseKeyValue:
|
||||||
|
def test_colon_separator(self):
|
||||||
|
output = "System Name: myswitch"
|
||||||
|
assert NetgearSmartDriver._parse_key_value(output, "System Name") == "myswitch"
|
||||||
|
|
||||||
|
def test_dot_separator(self):
|
||||||
|
output = "Software Version............................. 6.6.3"
|
||||||
|
assert NetgearSmartDriver._parse_key_value(output, "Software Version") == "6.6.3"
|
||||||
|
|
||||||
|
def test_missing_key_returns_empty(self):
|
||||||
|
assert NetgearSmartDriver._parse_key_value("some output", "Nonexistent Key") == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: get_interfaces
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetInterfaces:
|
||||||
|
def _mock(self, driver):
|
||||||
|
def _send(cmd):
|
||||||
|
if "port" in cmd:
|
||||||
|
return SHOW_PORT_ALL
|
||||||
|
return SHOW_INTERFACE_ALL
|
||||||
|
driver._send_command = _send
|
||||||
|
|
||||||
|
def test_all_ports_present(self, driver):
|
||||||
|
self._mock(driver)
|
||||||
|
ifaces = driver.get_interfaces()
|
||||||
|
assert "0/1" in ifaces
|
||||||
|
assert "0/2" in ifaces
|
||||||
|
assert "0/3" in ifaces
|
||||||
|
|
||||||
|
def test_link_up(self, driver):
|
||||||
|
self._mock(driver)
|
||||||
|
assert driver.get_interfaces()["0/1"]["is_up"] is True
|
||||||
|
|
||||||
|
def test_link_down(self, driver):
|
||||||
|
self._mock(driver)
|
||||||
|
assert driver.get_interfaces()["0/2"]["is_up"] is False
|
||||||
|
|
||||||
|
def test_admin_disabled(self, driver):
|
||||||
|
self._mock(driver)
|
||||||
|
assert driver.get_interfaces()["0/3"]["is_enabled"] is False
|
||||||
|
|
||||||
|
def test_speed_1g(self, driver):
|
||||||
|
self._mock(driver)
|
||||||
|
assert driver.get_interfaces()["0/1"]["speed"] == 1000.0
|
||||||
|
|
||||||
|
def test_description_from_interface_all(self, driver):
|
||||||
|
self._mock(driver)
|
||||||
|
assert driver.get_interfaces()["0/1"]["description"] == "uplink-to-router"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: get_interfaces_ip
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetInterfacesIp:
|
||||||
|
def test_management_ip(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_IP_INTERFACE
|
||||||
|
result = driver.get_interfaces_ip()
|
||||||
|
assert "vlan1" in result
|
||||||
|
assert "192.168.0.239" in result["vlan1"]["ipv4"]
|
||||||
|
|
||||||
|
def test_prefix_length(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_IP_INTERFACE
|
||||||
|
result = driver.get_interfaces_ip()
|
||||||
|
assert result["vlan1"]["ipv4"]["192.168.0.239"]["prefix_length"] == 24
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: get_arp_table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetArpTable:
|
||||||
|
def test_entry_count(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_ARP
|
||||||
|
arp = driver.get_arp_table()
|
||||||
|
assert len(arp) == 2
|
||||||
|
|
||||||
|
def test_entry_keys(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_ARP
|
||||||
|
entry = driver.get_arp_table()[0]
|
||||||
|
for k in ("interface", "mac", "ip", "age"):
|
||||||
|
assert k in entry
|
||||||
|
|
||||||
|
def test_mac_normalised(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_ARP
|
||||||
|
macs = {e["mac"] for e in driver.get_arp_table()}
|
||||||
|
# napalm_helpers.mac normalises to "AA:BB:CC:DD:EE:FF" format
|
||||||
|
assert any(":" in mac for mac in macs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: get_mac_address_table
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetMacAddressTable:
|
||||||
|
def test_entry_count(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_MAC
|
||||||
|
mac_table = driver.get_mac_address_table()
|
||||||
|
assert len(mac_table) == 3
|
||||||
|
|
||||||
|
def test_static_flag(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_MAC
|
||||||
|
statics = [e for e in driver.get_mac_address_table() if e["static"]]
|
||||||
|
# "Static" and "Management" rows are static
|
||||||
|
assert len(statics) == 2
|
||||||
|
|
||||||
|
def test_dynamic_flag(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_MAC
|
||||||
|
dynamics = [e for e in driver.get_mac_address_table() if not e["static"]]
|
||||||
|
assert len(dynamics) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: get_vlans
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetVlans:
|
||||||
|
def test_vlan_ids(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_VLAN
|
||||||
|
vlans = driver.get_vlans()
|
||||||
|
assert set(vlans.keys()) == {"1", "10", "20"}
|
||||||
|
|
||||||
|
def test_vlan_name(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_VLAN
|
||||||
|
assert driver.get_vlans()["10"]["name"] == "Management"
|
||||||
|
|
||||||
|
def test_vlan_interfaces(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_VLAN
|
||||||
|
assert "0/1" in driver.get_vlans()["1"]["interfaces"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: _parse_vlan_ports
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseVlanPorts:
|
||||||
|
def test_single_port(self):
|
||||||
|
assert NetgearSmartDriver._parse_vlan_ports("0/1") == ["0/1"]
|
||||||
|
|
||||||
|
def test_range(self):
|
||||||
|
assert NetgearSmartDriver._parse_vlan_ports("0/1-0/4") == [
|
||||||
|
"0/1", "0/2", "0/3", "0/4"
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_short_range(self):
|
||||||
|
assert NetgearSmartDriver._parse_vlan_ports("0/1-4") == [
|
||||||
|
"0/1", "0/2", "0/3", "0/4"
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_comma_separated(self):
|
||||||
|
result = NetgearSmartDriver._parse_vlan_ports("0/1, 0/3, 0/5")
|
||||||
|
assert result == ["0/1", "0/3", "0/5"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: get_lldp_neighbors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLldpNeighbors:
|
||||||
|
def test_neighbor_present(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_LLDP_ALL
|
||||||
|
neighbors = driver.get_lldp_neighbors()
|
||||||
|
assert "0/1" in neighbors
|
||||||
|
|
||||||
|
def test_neighbor_hostname(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_LLDP_ALL
|
||||||
|
neighbors = driver.get_lldp_neighbors()
|
||||||
|
assert neighbors["0/1"][0]["hostname"] == "core-router"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: _parse_lldp_detail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseLldpDetail:
|
||||||
|
def test_chassis_id(self):
|
||||||
|
r = NetgearSmartDriver._parse_lldp_detail(SHOW_LLDP_DETAIL)
|
||||||
|
assert r["remote_chassis_id"] == "00:1a:2b:3c:4d:5e"
|
||||||
|
|
||||||
|
def test_port_id(self):
|
||||||
|
r = NetgearSmartDriver._parse_lldp_detail(SHOW_LLDP_DETAIL)
|
||||||
|
assert r["remote_port"] == "Gi0/1"
|
||||||
|
|
||||||
|
def test_system_name(self):
|
||||||
|
r = NetgearSmartDriver._parse_lldp_detail(SHOW_LLDP_DETAIL)
|
||||||
|
assert r["remote_system_name"] == "core-router"
|
||||||
|
|
||||||
|
def test_capabilities(self):
|
||||||
|
r = NetgearSmartDriver._parse_lldp_detail(SHOW_LLDP_DETAIL)
|
||||||
|
assert "bridge" in r["remote_system_capab"]
|
||||||
|
assert "router" in r["remote_system_capab"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: get_snmp_information
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSnmpInformation:
|
||||||
|
def test_communities(self, driver):
|
||||||
|
def _send(cmd):
|
||||||
|
if "sysinfo" in cmd:
|
||||||
|
return SHOW_SYSINFO
|
||||||
|
return SHOW_SNMP
|
||||||
|
driver._send_command = _send
|
||||||
|
snmp = driver.get_snmp_information()
|
||||||
|
assert "public" in snmp["community"]
|
||||||
|
assert snmp["community"]["public"]["mode"] == "ro"
|
||||||
|
assert snmp["community"]["private"]["mode"] == "rw"
|
||||||
|
|
||||||
|
def test_location(self, driver):
|
||||||
|
def _send(cmd):
|
||||||
|
if "sysinfo" in cmd:
|
||||||
|
return SHOW_SYSINFO
|
||||||
|
return SHOW_SNMP
|
||||||
|
driver._send_command = _send
|
||||||
|
snmp = driver.get_snmp_information()
|
||||||
|
assert snmp["location"] == "Server Room"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: get_users
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetUsers:
|
||||||
|
def test_users_present(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_USERS
|
||||||
|
users = driver.get_users()
|
||||||
|
assert "admin" in users
|
||||||
|
assert "guest" in users
|
||||||
|
|
||||||
|
def test_admin_level(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_USERS
|
||||||
|
assert driver.get_users()["admin"]["level"] == 15
|
||||||
|
|
||||||
|
def test_guest_level(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_USERS
|
||||||
|
assert driver.get_users()["guest"]["level"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: get_ntp_servers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetNtpServers:
|
||||||
|
def test_server_found(self, driver):
|
||||||
|
driver._send_command = lambda cmd: SHOW_SNTP
|
||||||
|
servers = driver.get_ntp_servers()
|
||||||
|
assert "192.168.0.1" in servers
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: config management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigManagement:
|
||||||
|
def test_load_merge_from_string(self, driver):
|
||||||
|
driver.load_merge_candidate(config="interface 0/1\n description test\nexit")
|
||||||
|
assert driver._candidate_config is not None
|
||||||
|
assert driver._candidate_mode == "merge"
|
||||||
|
|
||||||
|
def test_load_replace_from_string(self, driver):
|
||||||
|
driver.load_replace_candidate(config="! running config\nvlan 10\n name test\nexit")
|
||||||
|
assert driver._candidate_mode == "replace"
|
||||||
|
|
||||||
|
def test_discard_clears_candidate(self, driver):
|
||||||
|
driver.load_merge_candidate(config="vlan 99\n name test\nexit")
|
||||||
|
driver.discard_config()
|
||||||
|
assert driver._candidate_config is None
|
||||||
|
assert driver._candidate_mode is None
|
||||||
|
|
||||||
|
def test_has_pending_commit_false(self, driver):
|
||||||
|
assert driver.has_pending_commit() is False
|
||||||
|
|
||||||
|
def test_has_pending_commit_true(self, driver):
|
||||||
|
driver.load_merge_candidate(config="vlan 99\n name test\nexit")
|
||||||
|
assert driver.has_pending_commit() is True
|
||||||
|
|
||||||
|
def test_compare_config_merge(self, driver):
|
||||||
|
driver.load_merge_candidate(config="vlan 99\n name test\nexit")
|
||||||
|
diff = driver.compare_config()
|
||||||
|
assert diff.startswith("+")
|
||||||
|
|
||||||
|
def test_compare_config_empty_when_no_candidate(self, driver):
|
||||||
|
assert driver.compare_config() == ""
|
||||||
|
|
||||||
|
def test_load_merge_from_file(self, driver, tmp_path):
|
||||||
|
cfg_file = tmp_path / "candidate.txt"
|
||||||
|
cfg_file.write_text("vlan 99\n name test\nexit\n")
|
||||||
|
driver.load_merge_candidate(filename=str(cfg_file))
|
||||||
|
assert "vlan 99" in driver._candidate_config
|
||||||
|
|
||||||
|
def test_load_merge_raises_without_input(self, driver):
|
||||||
|
from napalm.base.exceptions import MergeConfigException
|
||||||
|
with pytest.raises(MergeConfigException):
|
||||||
|
driver.load_merge_candidate()
|
||||||
|
|
||||||
|
def test_load_replace_raises_without_input(self, driver):
|
||||||
|
from napalm.base.exceptions import ReplaceConfigException
|
||||||
|
with pytest.raises(ReplaceConfigException):
|
||||||
|
driver.load_replace_candidate()
|
||||||
|
|
||||||
|
def test_rollback_raises_without_backup(self, driver):
|
||||||
|
from napalm.base.exceptions import CommandErrorException
|
||||||
|
with pytest.raises(CommandErrorException):
|
||||||
|
driver.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: _parse_config_blocks / _diff_to_commands
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigDiff:
|
||||||
|
BACKUP = """\
|
||||||
|
vlan database
|
||||||
|
vlan 10
|
||||||
|
name Management
|
||||||
|
exit
|
||||||
|
interface 0/1
|
||||||
|
description uplink
|
||||||
|
exit
|
||||||
|
"""
|
||||||
|
CURRENT = """\
|
||||||
|
vlan database
|
||||||
|
vlan 10
|
||||||
|
name Management
|
||||||
|
exit
|
||||||
|
interface 0/1
|
||||||
|
description changed
|
||||||
|
exit
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_diff_detects_change(self):
|
||||||
|
cmds = NetgearSmartDriver._diff_to_commands(
|
||||||
|
TestConfigDiff.BACKUP, TestConfigDiff.CURRENT
|
||||||
|
)
|
||||||
|
# Should contain a "no description" and "description uplink" line
|
||||||
|
joined = " ".join(cmds)
|
||||||
|
assert "description" in joined
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: get_config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetConfig:
|
||||||
|
def test_running_retrieved(self, driver):
|
||||||
|
driver._send_command = lambda cmd: "! running config"
|
||||||
|
cfg = driver.get_config(retrieve="running")
|
||||||
|
assert cfg["running"] == "! running config"
|
||||||
|
assert cfg["startup"] == ""
|
||||||
|
|
||||||
|
def test_startup_retrieved(self, driver):
|
||||||
|
driver._send_command = lambda cmd: "! startup config"
|
||||||
|
cfg = driver.get_config(retrieve="startup")
|
||||||
|
assert cfg["startup"] == "! startup config"
|
||||||
|
assert cfg["running"] == ""
|
||||||
|
|
||||||
|
def test_candidate_always_empty(self, driver):
|
||||||
|
driver._send_command = lambda cmd: ""
|
||||||
|
cfg = driver.get_config()
|
||||||
|
assert cfg["candidate"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: is_alive
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsAlive:
|
||||||
|
def test_alive_when_connected(self, driver):
|
||||||
|
driver.device.remote_conn.transport.is_active.return_value = True
|
||||||
|
assert driver.is_alive() == {"is_alive": True}
|
||||||
|
|
||||||
|
def test_dead_when_no_device(self, driver):
|
||||||
|
driver.device = None
|
||||||
|
assert driver.is_alive() == {"is_alive": False}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests: cli
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
def test_returns_output_per_command(self, driver):
|
||||||
|
driver._send_command = lambda cmd: f"output of {cmd}"
|
||||||
|
result = driver.cli(["show sysinfo", "show version"])
|
||||||
|
assert result["show sysinfo"] == "output of show sysinfo"
|
||||||
|
assert result["show version"] == "output of show version"
|
||||||
|
|
||||||
|
def test_raises_on_non_text_encoding(self, driver):
|
||||||
|
with pytest.raises(NotImplementedError):
|
||||||
|
driver.cli(["show sysinfo"], encoding="json")
|
||||||
Reference in New Issue
Block a user