Compare commits

...
26 Commits
Author SHA1 Message Date
Christian Manivong 7f07da6857 fix(cli): the trunk gets its own row, as over REST
Since f35b59e a CLI poll reports trunk members as `3` with
trunk_group `Trk3`, but `show interfaces brief` has no line for Trk3
itself, so the trunk was missing from the interface list. The REST path
built that row on its own.

Both paths now use napalm_device_types.add_lag_interfaces, and the REST
path's copy is gone. One visible change there: `lag_members` is now in
port order (7, 10) instead of API order; the description already was.

The CLI path does not know the mode yet, so its trunks carry no
lag_mode. That needs `show trunks`, whose output is not at hand.

Needs napalm-device-types f3fa75b.

Closes #5
2026-09-25 10:44:11 +02:00
Christian Manivong f35b59ec2d fix(cli): read J.15 port status, and say why each transport failed
A 2800-series switch on J.15.09 polled over CLI came back with no
interfaces and an empty OS version, while VLANs and ARP parsed fine.

`show interfaces brief` on that firmware has an Intrusion Alert column
between the `|` and Enabled, and puts Mode before MDI rather than after:

      Port   Type      | Alert     Enabled Status Mode       Mode ...
      3-Trk3 100/1000T | No        Yes     Down   1000FDx    MDI  ...

The regex read Alert as Enabled, then failed on Yes where it wanted
Up|Down, so no line matched. It now skips the Alert column where there is
one and takes the speed from either position. Trunk members are listed as
`<port>-Trk<n>`; the port is `<port>` and the suffix becomes its
trunk_group, so the per-port `show interfaces 3` is a command the switch
knows.

The empty OS version was the alternatives list in _send_command. It moved
to the next command on "% Invalid" or "Error", but ProCurve rejects an
unknown command with "Invalid input: system-information" -- so that line
was parsed as system information. "Invalid input" now counts as failure.

Getting there took longer than it should have, because the first symptom
was "Authentication failed: Login failed". That was Telnet's error, the
last transport tried; the REST probe and both SSH attempts had failed
before it and said nothing above debug level. In fact the switch had run
out of CLI sessions and closed SSH straight after the password. open()
now records why each transport failed, and both the auth error and the
final "Cannot connect" carry that list.

Fixtures are that switch's output, with hostname, serial and MAC
replaced.

Closes #2
Closes #3
Closes #4
2026-09-25 08:55:11 +02:00
Christian Manivong e3bbac0656 fix(api): change an existing VLAN membership instead of re-creating it
set_interface() always POSTed to vlans-ports, treating every membership as
new. Moving a port that already carries the VLAN from untagged to tagged is
not a create, though, and the switch says so:

    POST vlans-ports {"vlan_id":10,"port_id":"10","port_mode":"POM_TAGGED_STATIC"}
      -> 400 {"message":"Association exists"}

Only 409 was handled as "already there"; v7 firmware answers 400. The
membership is its own resource named {vlan_id}-{port_id} and takes a PUT:

    PUT vlans-ports/10-10 -> 200

So the exact case anyone hits first failed outright — a port holding VLAN 10
untagged alongside 30/40/50 tagged, with VLAN 10 to become tagged too.

The response body is now carried into both error messages. The ports PUT just
above already did this; here it was dropped, so "Association exists" — which
names the cause outright — never reached the caller. The message read
"vlans-ports POST HTTP 400" and nothing more.

Verified against a 2530-24G-PoEP on REST v7: set_interface("10", trunk
vlan 30), where that membership already exists, now completes and leaves the
port exactly as it was.
2026-08-18 16:22:21 +07:00
Christian Manivong 0dcede037f fix(deps): pin paramiko>=5.0.0 (CVE-2026-44405) 2026-07-02 12:22:55 +02:00
Christian Manivong 348a62927e fix(get_config): use netmiko hp_procurve on port 22
HP ProCurve needs invoke_shell (not exec_channel) — exec_command via
paramiko returns 'SSH command execution is not supported'. Netmiko's
hp_procurve device type handles this correctly. Port 22 explicit.
2026-06-29 15:51:32 +02:00
Christian Manivong 6e2ac6bd94 fix(get_config): back to paramiko with port 22 — no subprocess needed
Root cause was self.port=443 (REST API port) being used for SSH.
With port 22 paramiko works fine — same as the SSH console link.
2026-06-29 15:44:33 +02:00
Christian Manivong 2bb84aca4f fix(get_config): use port 22 for SSH fallback — self.port is the REST API port
In API transport mode, self.port = 443 (HTTPS). All SSH attempts were
connecting to port 443 which speaks TLS, not SSH — hence 'banner exchange
timed out'. Fix: hardcode port 22 for the SSH config fallback.
2026-06-29 15:37:02 +02:00
Christian Manivong a7ecc6427c fix(get_config): use openssh+sshpass subprocess — bypasses paramiko Mocana compat issue
paramiko 4.x is incompatible with Mocana SSH 6.3 on HP 2530/YA firmware.
OpenSSH subprocess with +diffie-hellman-group1-sha1 kex works correctly
and connects in < 5s instead of hitting the 15s banner timeout.
2026-06-29 15:13:40 +02:00
Christian Manivong fe8df73ad6 fix(get_config): close REST session before SSH fallback
HP ProCurve switches allow only 1 session per user. The REST API session
blocks SSH — closing it before the SSH attempt allows the connection.
_collect_config() is called last so all poll data is already collected.
2026-06-29 15:05:34 +02:00
Christian Manivong a496f02aa9 fix(get_config): use SSHClient.connect() without algorithm overrides
The SSH console link works on this switch using plain paramiko
SSHClient.connect() with no disabled_algorithms and no Transport hacks.
Our previous approaches (Transport._preferred_kex, socket-level timeout)
were breaking the negotiation. Revert to the simple approach that works.
2026-06-29 14:50:34 +02:00
Christian Manivong f7eff4b3ca fix(get_config): socket-level timeout for SSH fallback — covers KEX phase
paramiko banner_timeout/auth_timeout do not cover kex negotiation.
Using raw socket with settimeout(8) ensures the entire SSH handshake
is bounded, preventing the poll from hanging and timing out.
2026-06-29 14:28:21 +02:00
Christian Manivong 831727f2af fix(get_config): use paramiko directly with group1-sha1 for Mocana SSH 6.3
- Replace netmiko with direct paramiko connection for SSH config fallback
- Explicitly set preferred_kex to include diffie-hellman-group1-sha1
  (required by old Mocana SSH 6.3 on HP 2530 / YA firmware)
- Hard timeout caps: banner_timeout=auth_timeout=8s, no keys/agent
2026-06-29 13:53:09 +02:00
Christian Manivong 8a182c4fa4 fix(get_config): cap SSH fallback timeout at 8s to prevent poll timeout
Each SSH attempt was using driver timeout (30s). With 2 attempts that
consumed the entire poll budget → device marked OFFLINE. Now capped at
8s per attempt (max 16s total), well within the 30s poll limit.
2026-06-29 13:42:05 +02:00
Christian Manivong 0d53426636 fix(get_config): try legacy KEX first in SSH fallback for HP 2530/Mocana SSH 2026-06-29 12:54:39 +02:00
Christian Manivong 87db7b2873 fix(get_config): SSH fallback when REST API returns empty (HP 2530/YA firmware)
HP 2530 switches with YA firmware do not expose /rest/v7/running-config.
_get_config_via_ssh() opens a temporary netmiko session to run
'show running-config' as fallback when the API endpoint returns 404.
2026-06-29 12:36:09 +02:00
Christian Manivong 46aadbbe3b fix(get_config): use plain text GET for running/startup config
AOS-Switch returns the config body as text/plain, not JSON.
The previous get_config() called self.get() which calls resp.json(),
silently caught the JSONDecodeError, and returned ''. Now uses _get_text()
which reads resp.text directly.
2026-06-29 12:11:35 +02:00
Christian Manivong d1db7a452d fix: falsche OUI-Prefixes entfernen (gegen IEEE verifiziert) 2026-06-24 17:06:45 +02:00
Christian ManivongandClaude Sonnet 4.6 ef5e453f5d feat: OUI_PREFIXES für MAC/ARP-Fingerprinting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:41:45 +02:00
Christian ManivongandClaude Sonnet 4.6 247964178a feat: Fingerprint-Attribute für Discovery-Scoring
Ergänzt DRIVER_NAME, HTTP_FINGERPRINT, SNMP_FINGERPRINT, SSH_FINGERPRINT,
PORT_SPECS und SNMP_OBJECT_ID_PREFIX gemäß docs/DISCOVERY_FINGERPRINTING.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 14:47:08 +02:00
Christian ManivongandClaude Sonnet 4.6 232936162e fix: try 'show system' as fallback for old ProCurve (no system-information)
Firmware J.15.x (2520G-8-PoE) only supports 'show system', not
'show system-information'. Adding it as the third fallback so serial
number and OS version can still be parsed via parse_system_info().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:46:58 +02:00
Christian ManivongandClaude Sonnet 4.6 55fe8b28f9 fix: search all lines for HP banner, not just first line
Old ProCurve show version starts with "Image stamp:" — the HP product
banner (e.g. "HP J9298A Switch 2520G-8-PoE") appears on a later line.
Scan all lines and use the first HP/HPE/Aruba banner found.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:40:44 +02:00
Christian ManivongandClaude Sonnet 4.6 ff37f3c59f fix: only parse HP/Aruba banner lines in parse_model_from_version
Guard against show version outputs that start with "Image stamp:" or
other non-banner lines — return ("", "") when the first line doesn't
begin with HP/HPE/Aruba.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:33:05 +02:00
Christian ManivongandClaude Sonnet 4.6 da5920a9bc fix: parse_model_from_version handles both HP banner orderings
Old switches show: "HP J9298A Switch 2520G-8-PoE" (model after Switch)
Newer switches:    "HP J9565A 2520G-8-PoE Switch" (model before Switch)

New approach: strip vendor prefix + J-code + "Switch" keyword, leaving
just the product name. Returns tuple (model, part_number).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:27:15 +02:00
Christian ManivongandClaude Sonnet 4.6 d2915f5829 fix: extract J-code part numbers in REST API get_facts() path
api_client.get_facts() now applies the same J-code extraction as the
CLI path: product_model "HP2530-8G Switch(J9777A)" → model="HP2530-8G
Switch", part_number="J9777A".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 23:10:07 +02:00
Christian ManivongandClaude Sonnet 4.6 2be268a65e fix: extract J-code part numbers from ProCurve model strings
parse_system_info() now extracts HPE/HP J-codes (e.g. J9298A, J9777A)
from the System Model field and returns them as part_number separately.
The model name is cleaned of the J-code and surrounding punctuation:

  "HP J9298A Switch 2520G-8-PoE" → model="HP 2520G-8-PoE Switch", pn="J9298A"
  "HP2530-8G Switch(J9777A)"     → model="HP2530-8G Switch",       pn="J9777A"

get_facts() includes part_number in the returned dict so NetOrk can
store it on Device.part_number and pass it to NetBox device types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 22:57:33 +02:00
Christian ManivongandClaude Sonnet 4.6 f3f70cbf74 fix: normalize MAC address format in get_mac_address_table()
Raw MAC addresses from the ArubaOS-Switch API use a non-standard
format; normalize via napalm.base.helpers.mac() for consistency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 17:17:23 +02:00
18 changed files with 700 additions and 145 deletions
+142
View File
@@ -0,0 +1,142 @@
Metadata-Version: 2.4
Name: napalm-hpe-aruba-procurve
Version: 0.2.0
Summary: NAPALM driver for HPE/Aruba ProCurve switches with auto-detecting transport (REST API, SSH, legacy SSH, Telnet)
Author: Christian Manivong
License: Apache-2.0
Project-URL: Repository, https://github.com/chrismanivong/napalm-hpe-aruba-procurve
Classifier: Topic :: Utilities
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Requires-Dist: napalm>=4.0.0
Requires-Dist: netmiko>=4.0.0
Requires-Dist: paramiko>=5.0.0
Requires-Dist: netaddr
Requires-Dist: requests>=2.25.0
Requires-Dist: urllib3
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: black; extra == "dev"
Requires-Dist: ruff; extra == "dev"
# napalm-hpe-aruba-procurve
NAPALM driver for **HPE / Aruba ProCurve** switches — from ancient 2520G-8-PoE
to modern 2530/2540 series.
## Transport auto-detection
The driver probes transports in order and uses the first one that succeeds:
| Priority | Transport | Notes |
|----------|-----------|-------|
| 1 | REST API (HTTPS → HTTP, v7 → v6) | Newer 2530/2540 with `rest-interface` enabled |
| 2 | SSH (standard) | hp_procurve netmiko driver |
| 3 | SSH (legacy KEX) | Forces older kex/cipher negotiation for 2520G-8-PoE etc. |
| 4 | Telnet | Oldest devices without SSH or with broken SSH |
Override the transport with `optional_args={"transport": "ssh_legacy"}` to skip
auto-detection.
## Switch prerequisites
### REST API (newer switches, e.g. 2530, 2540)
```
web-management ssl
rest-interface
rest-interface session-idle-timeout 120
```
### SSH (all ProCurve)
```
crypto key generate ssh rsa
ip ssh
```
Telnet is enabled by default on most ProCurve switches.
## Installation
```bash
pip install napalm napalm-hpe-aruba-procurve
```
Or from source:
```bash
git clone https://github.com/chrismanivong/napalm-hpe-aruba-procurve
pip install -e napalm-hpe-aruba-procurve/
```
## Quick start
```python
from napalm import get_network_driver
Driver = get_network_driver("procurve")
with Driver(
"10.0.0.1",
"manager",
"secret",
optional_args={
# "transport": "ssh", # force transport (api/ssh/ssh_legacy/telnet)
# "port": 22,
# "ssl_verify": False, # disable SSL cert check for API
# "api_version": "v7", # API version hint (v3/v6/v7)
# "secret": "enablepassword", # enable password for CLI
},
) as dev:
print(dev.get_facts())
print(dev.get_interfaces())
```
## Supported NAPALM methods
| Method | API | SSH/Telnet |
|--------|-----|------------|
| `open()` | ✅ | ✅ |
| `close()` | ✅ | ✅ |
| `is_alive()` | ✅ | ✅ |
| `get_facts()` | ✅ | ✅ |
| `get_interfaces()` | ✅ | ✅ |
| `get_interfaces_ip()` | ✅ | ✅ |
| `get_arp_table()` | ✅ | ✅ |
| `get_mac_address_table()` | ✅ | ✅ |
| `get_lldp_neighbors()` | ✅ | ✅ |
| `get_lldp_neighbors_detail()` | ✅ | ✅ |
| `get_config()` | ✅ | ✅ |
| `get_ntp_servers()` | ✅ | ✅ |
| `get_environment()` | ❌ | ✅ |
| `get_users()` | ❌ | ✅ |
| `get_snmp_information()` | ❌ | ✅ |
| `ping()` | ✅ | ✅ |
| `cli()` | ✅ | ✅ |
| `load_merge_candidate()` | ❌ | ✅ |
| `load_replace_candidate()` | ❌ | ✅ |
| `compare_config()` | ❌ | ✅ |
| `commit_config()` | ❌ | ✅ |
| `discard_config()` | ❌ | ✅ |
| `rollback()` | ❌ | ✅ |
## Tested devices
- HP ProCurve 2520G-8-PoE (J9565A) — SSH legacy / Telnet
- HP ProCurve 2920-48G — SSH
- Aruba 2530-8-PoE+ — SSH + REST API
## License
Apache 2.0
@@ -0,0 +1,12 @@
README.md
pyproject.toml
napalm_hpe_aruba_procurve.egg-info/PKG-INFO
napalm_hpe_aruba_procurve.egg-info/SOURCES.txt
napalm_hpe_aruba_procurve.egg-info/dependency_links.txt
napalm_hpe_aruba_procurve.egg-info/entry_points.txt
napalm_hpe_aruba_procurve.egg-info/requires.txt
napalm_hpe_aruba_procurve.egg-info/top_level.txt
napalm_procurve/__init__.py
napalm_procurve/api_client.py
napalm_procurve/parsers.py
napalm_procurve/procurve.py
@@ -0,0 +1 @@
@@ -0,0 +1,2 @@
[napalm.drivers]
procurve = napalm_procurve:ProcurveDriver
@@ -0,0 +1,12 @@
napalm>=4.0.0
netmiko>=4.0.0
paramiko>=5.0.0
netaddr
requests>=2.25.0
urllib3
[dev]
pytest
pytest-cov
black
ruff
@@ -0,0 +1 @@
napalm_procurve
Binary file not shown.
Binary file not shown.
Binary file not shown.
+47 -23
View File
@@ -13,7 +13,9 @@ from typing import Any, Dict, Optional, Tuple
import requests
import urllib3
from napalm.base import helpers as napalm_helpers
from napalm.base.exceptions import ConnectionException, ConnectAuthError
from napalm_device_types import add_lag_interfaces
logger = logging.getLogger("napalm_procurve.api")
@@ -225,9 +227,24 @@ class ProcurveApiClient:
for port in blade.get("data_ports", []):
iface_list.append(port.get("port_name", ""))
model_raw = system.get("product_model", "")
# Extract J-code part numbers (e.g. J9777A) from the model string
import re as _re
_PN_RE = _re.compile(r"\b(J\d{4}[A-Z]{1,2})\b")
pn_match = _PN_RE.search(model_raw)
part_number = pn_match.group(1) if pn_match else ""
if pn_match:
clean = _PN_RE.sub("", model_raw)
clean = _re.sub(r"\(\s*\)", "", clean)
clean = _re.sub(r"\s{2,}", " ", clean).strip(" -()")
model = clean if clean else model_raw
else:
model = model_raw
return {
"vendor": "HPE Aruba",
"model": system.get("product_model", ""),
"model": model,
"part_number": part_number,
"hostname": hostname,
"fqdn": fqdn,
"os_version": system.get("firmware_version", ""),
@@ -291,22 +308,11 @@ class ProcurveApiClient:
if pid in output:
output[pid]["speed"] = float(stat.get("port_speed_mbps", 0))
# Synthesize a logical interface entry for each configured LAG/trunk
# group so it shows up as its own row alongside its member ports.
for group, members in trunk_groups.items():
output[group] = {
"is_up": any(output[m]["is_up"] for m in members),
"is_enabled": any(output[m]["is_enabled"] for m in members),
"description": f"LAG ({', '.join(sorted(members, key=lambda s: int(s) if s.isdigit() else 0))})",
"last_flapped": -1.0,
"speed": sum(output[m]["speed"] for m in members),
"mtu": -1,
"mac_address": "",
"lag_members": members,
"lag_mode": "lacp" if trunk_modes.get(group) == "PTT_LACP" else "trunk",
}
return output
# One row per LAG/trunk group alongside its member ports.
return add_lag_interfaces(output, {
group: "lacp" if trunk_modes.get(group) == "PTT_LACP" else "trunk"
for group in trunk_groups
})
def get_interfaces_ip(self) -> Dict[str, Dict]:
"""Return NAPALM interfaces IP from the REST API."""
@@ -366,9 +372,14 @@ class ProcurveApiClient:
mac_data = self.get("mac-table")
table = []
for entry in mac_data.get("mac_table_entry_element", []):
raw_mac = entry.get("mac_address", "")
try:
mac = napalm_helpers.mac(raw_mac)
except Exception:
mac = raw_mac
table.append(
{
"mac": entry.get("mac_address", ""),
"mac": mac,
"interface": entry.get("port_id", ""),
"vlan": int(entry.get("vlan_id", 0)),
"static": entry.get("mac_addr_type", "").lower() == "static",
@@ -414,15 +425,28 @@ class ProcurveApiClient:
return result
def get_config(self) -> Dict[str, str]:
"""Return running and startup configuration via REST API."""
running = self.get("running-config")
startup = self.get("startup-config")
"""Return running and startup configuration via REST API.
AOS-Switch returns the config as plain text, not JSON.
"""
return {
"running": running.get("config", ""),
"startup": startup.get("config", ""),
"running": self._get_text("running-config"),
"startup": self._get_text("startup-config"),
"candidate": "",
}
def _get_text(self, endpoint: str) -> str:
"""GET *endpoint* and return the response body as plain text."""
url = self._base_url + endpoint
try:
resp = self._session.get(url, timeout=self.timeout)
if resp.ok:
return resp.text
logger.warning("GET %s returned HTTP %s: %s", url, resp.status_code, resp.text[:200])
except Exception as exc:
logger.warning("GET %s error: %s", url, exc)
return ""
def get_ntp_servers(self) -> Dict[str, Dict]:
"""Return NTP servers from REST API."""
data = self.get("ntp/server")
+62 -22
View File
@@ -89,7 +89,23 @@ def parse_system_info(output: str) -> Dict:
)
serial = get("Serial Number") or get("Serial Num")
base_mac = procurve_mac_to_std(get("Base MAC Addr") or get("MAC Address") or "")
model = get("System Model") or get("product_model") or ""
model_raw = get("System Model") or get("product_model") or ""
# Extract HPE/HP J-code part numbers (e.g. "J9298A") from the model string.
# The J-code is the PN; the remaining text is the clean model name.
# Examples:
# "HP J9298A Switch 2520G-8-PoE" → model="HP 2520G-8-PoE Switch", pn="J9298A"
# "HP2530-8G Switch(J9777A)" → model="HP2530-8G Switch", pn="J9777A"
_PN_RE = re.compile(r"\b(J\d{4}[A-Z]{1,2})\b")
pn_match = _PN_RE.search(model_raw)
part_number = pn_match.group(1) if pn_match else ""
if pn_match:
clean = _PN_RE.sub("", model_raw)
clean = re.sub(r"\(\s*\)", "", clean)
clean = re.sub(r"\s{2,}", " ", clean).strip(" -()")
model = clean if clean else model_raw
else:
model = model_raw
# Parse uptime — ProCurve formats: "5 days 3:14" or "0 days 0:05"
# Also: "5 day(s), 3:14:00"
@@ -101,6 +117,7 @@ def parse_system_info(output: str) -> Dict:
"serial_number": serial,
"base_mac": base_mac,
"model": model,
"part_number": part_number,
"uptime_seconds": uptime_seconds,
}
@@ -171,20 +188,40 @@ def parse_version(output: str) -> str:
return ""
def parse_model_from_version(output: str) -> str:
"""Extract model from ``show version`` first line.
def parse_model_from_version(output: str) -> tuple[str, str]:
"""Extract (model, part_number) from ``show version`` first line.
Example: ``HP J9565A 2520G-8-PoE Switch`` → ``2520G-8-PoE``
Handles both orderings of the HP banner:
``HP J9565A 2520G-8-PoE Switch`` → ("2520G-8-PoE", "J9565A")
``HP J9298A Switch 2520G-8-PoE`` → ("2520G-8-PoE", "J9298A")
``Aruba 2530-8-PoE+ Switch`` → ("2530-8-PoE+", "")
Returns ("", "") when the first line is not an HP/Aruba device banner
(e.g. when show version starts with "Image stamp:" on newer Aruba).
"""
first = output.strip().splitlines()[0] if output.strip() else ""
m = re.search(r"HP\s+\S+\s+(\S+)\s+Switch", first, re.I)
if m:
return m.group(1)
# Aruba format: "Aruba 2530-8-PoE+ Switch"
m = re.search(r"(?:Aruba|HP)\s+(\d\S+)\s+(?:Switch|)", first, re.I)
if m:
return m.group(1)
return ""
# Find the HP/Aruba product banner line — may not be the first line
# (older ProCurve shows "Image stamp:" first, banner follows later)
banner = ""
for line in output.splitlines():
if re.match(r"\s*(?:HP|HPE|Aruba)\b", line, re.I):
banner = line.strip()
break
if not banner:
return "", ""
first = banner
# Extract J-code part number
_PN_RE_V = re.compile(r"\b(J\d{4}[A-Z]{1,2})\b")
pn_match = _PN_RE_V.search(first)
part_number = pn_match.group(1) if pn_match else ""
# Strip vendor prefix, J-code and "Switch" keyword to get the product name
cleaned = re.sub(r"\b(?:HP|HPE|Aruba)\b", "", first, flags=re.I)
cleaned = _PN_RE_V.sub("", cleaned)
cleaned = re.sub(r"\bSwitch\b", "", cleaned, flags=re.I)
cleaned = re.sub(r"\s{2,}", " ", cleaned).strip(" -()")
return cleaned, part_number
# ---------------------------------------------------------------------------
@@ -197,12 +234,13 @@ def parse_model_from_version(output: str) -> str:
# 1 100/1000T | Yes Down Auto Unknown off 0
_INTF_BRIEF_RE = re.compile(
r"^\s*(\S+)\s+" # Port
r"^\s*([^\s-]+)" # Port
r"(?:-(Trk\d+))?\s+" # Trunk group of a member port ("3-Trk3")
r"(\S+)\s+\|" # Type |
r"\s+(Yes|No)\s+" # Enabled
r"\s+(?:(?:Yes|No)\s+)?" # Intrusion Alert, on firmware that has the column
r"(Yes|No)\s+" # Enabled
r"(Up|Down)\s+" # Link
r"\S+\s+" # MDI (ignored)
r"(\S+)", # Mode (speed/duplex)
r"(\S+)\s+(\S+)", # Mode and MDI, in either order depending on firmware
re.IGNORECASE,
)
@@ -218,13 +256,13 @@ def parse_interfaces_brief(output: str) -> Dict[str, Dict]:
m = _INTF_BRIEF_RE.match(line)
if not m:
continue
port, itype, enabled, link, mode = (
m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
)
port, trunk_group, itype, enabled, link, *mode_or_mdi = m.groups()
speed = 0.0
duplex = ""
# Mode examples: "1000FDx", "100HDx", "Unknown", "Auto"
sm = re.match(r"(\d+)(FDx|HDx)?", mode, re.I)
# Mode examples: "1000FDx", "100HDx", "Unknown"; MDI is "Auto", "MDI", "MDIX", "NA"
sm = next(
filter(None, (re.match(r"(\d+)(FDx|HDx)?", t, re.I) for t in mode_or_mdi)), None
)
if sm:
speed = float(sm.group(1))
duplex = "full" if (sm.group(2) or "").lower() == "fdx" else "half"
@@ -239,6 +277,8 @@ def parse_interfaces_brief(output: str) -> Dict[str, Dict]:
"mtu": -1,
"mac_address": "",
}
if trunk_group:
interfaces[port]["trunk_group"] = trunk_group
return interfaces
+127 -95
View File
@@ -27,7 +27,12 @@ from netmiko.exceptions import (
NetmikoAuthenticationException,
NetmikoTimeoutException,
)
from napalm_device_types import SwitchDriver
from napalm_device_types import (
ConfigLifecycleMixin,
FingerprintRule,
SwitchDriver,
add_lag_interfaces,
)
from napalm_device_types.models import InterfaceConfigDict, VlanConfigDict
from napalm.base import helpers as napalm_helpers
from napalm.base.exceptions import (
@@ -71,13 +76,31 @@ _SSH_DISABLED_LEGACY = {
}
class ProcurveDriver(SwitchDriver):
class ProcurveDriver(ConfigLifecycleMixin, SwitchDriver):
"""NAPALM driver for HPE/Aruba ProCurve switches.
Auto-detects transport on ``open()``. See module docstring for details.
"""
VENDOR = "HPE"
DRIVER_NAME = "procurve"
SNMP_OBJECT_ID_PREFIX = "1.3.6.1.4.1.11"
OUI_PREFIXES = [
"3C:D9:2B", # Hewlett Packard — IEEE
"3C:52:82", # Hewlett Packard — IEEE
"44:31:92", # Hewlett Packard — IEEE
"00:17:A4", # Hewlett Packard — IEEE
]
SSH_FINGERPRINT = [
FingerprintRule("hp", weight=5.0),
FingerprintRule("aruba", weight=4.0),
]
HTTP_FINGERPRINT = [
FingerprintRule("hp procurve", weight=8.0, mandatory=True),
FingerprintRule("procurve", weight=6.0),
FingerprintRule("ehttp", weight=5.0),
FingerprintRule("comware", weight=5.0),
]
NETMIKO_DEVICE_TYPE = "hp_procurve"
NETMIKO_DEVICE_TYPE_TELNET = "hp_procurve_telnet"
@@ -120,6 +143,8 @@ class ProcurveDriver(SwitchDriver):
self._device: Optional[ConnectHandler] = None
# REST API backend
self._api: Optional[ProcurveApiClient] = None
# Why each transport failed during the current open(), as "<transport>: <reason>"
self._attempts: List[str] = []
# Config management state (CLI only)
self._candidate_config: Optional[str] = None
@@ -141,38 +166,45 @@ class ProcurveDriver(SwitchDriver):
4. Telnet
"""
forced = self.force_transport
tried: List[str] = []
self._attempts = []
# --- 1. REST API ---
if not forced or forced == "api":
if self._try_api():
return
tried.append("api")
# --- 2. SSH standard ---
if not forced or forced == "ssh":
if self._try_ssh(legacy=False):
return
tried.append("ssh")
# --- 3. SSH legacy KEX ---
if not forced or forced == "ssh_legacy":
if self._try_ssh(legacy=True):
return
tried.append("ssh_legacy")
# --- 4. Telnet ---
if not forced or forced == "telnet":
if self._try_telnet():
return
tried.append("telnet")
raise ConnectionException(
f"Cannot connect to {self.hostname}. "
f"Tried transports: {', '.join(tried)}. "
f"Tried transports: {'; '.join(self._attempts)}. "
"Check connectivity, credentials and whether SSH/Telnet/API is enabled."
)
def _auth_failure(self, transport: str, exc: Exception) -> ConnectionException:
"""An authentication error that also says why the earlier transports failed.
Without them, a Telnet "Login failed" reads as a wrong password when SSH
was merely refused and Telnet was the only transport left to answer (#2).
"""
msg = f"Authentication failed for {self.hostname} via {transport}: {exc}"
if self._attempts:
msg += f" (earlier: {'; '.join(self._attempts)})"
return ConnectionException(msg)
def close(self) -> None:
"""Close the active connection."""
if self._api:
@@ -216,10 +248,12 @@ class ProcurveDriver(SwitchDriver):
if not ver:
logger.warning("REST API not detected on %s — falling back to CLI", self.hostname)
self._attempts.append("api: not detected")
return False
# Try connecting with the requested SSL setting first; if it fails due to a
# self-signed certificate (ssl_verify=True), transparently retry unverified.
error: Optional[Exception] = None
for ssl_verify in ([self.ssl_verify] if not self.ssl_verify else [True, False]):
client = ProcurveApiClient(
hostname=self.hostname,
@@ -234,6 +268,7 @@ class ProcurveDriver(SwitchDriver):
client.connect()
except Exception as exc:
logger.debug("REST API connect failed (ssl_verify=%s): %s", ssl_verify, exc)
error = exc
continue
self._api = client
self._transport = "api"
@@ -242,6 +277,7 @@ class ProcurveDriver(SwitchDriver):
return True
logger.warning("REST API connect failed for %s — falling back to CLI", self.hostname)
self._attempts.append(f"api: {error}")
return False
def _netmiko_kwargs(self, legacy: bool = False) -> dict:
@@ -267,22 +303,23 @@ class ProcurveDriver(SwitchDriver):
def _try_ssh(self, legacy: bool = False) -> bool:
"""Probe and connect via SSH. Returns True on success."""
label = "SSH-legacy" if legacy else "SSH"
transport = "ssh_legacy" if legacy else "ssh"
logger.debug("Trying %s for %s", label, self.hostname)
try:
conn = ConnectHandler(**self._netmiko_kwargs(legacy))
self._device = conn
self._transport = "ssh_legacy" if legacy else "ssh"
self._transport = transport
logger.info("Connected to %s via %s", self.hostname, label)
return True
except NetmikoAuthenticationException as exc:
raise ConnectionException(
f"Authentication failed for {self.hostname}: {exc}"
) from exc
raise self._auth_failure(transport, exc) from exc
except NetmikoTimeoutException:
logger.debug("%s timeout for %s", label, self.hostname)
self._attempts.append(f"{transport}: timed out")
return False
except Exception as exc:
logger.debug("%s failed for %s: %s", label, self.hostname, exc)
self._attempts.append(f"{transport}: {exc}")
return False
def _try_telnet(self) -> bool:
@@ -303,11 +340,10 @@ class ProcurveDriver(SwitchDriver):
logger.info("Connected to %s via Telnet", self.hostname)
return True
except NetmikoAuthenticationException as exc:
raise ConnectionException(
f"Authentication failed for {self.hostname}: {exc}"
) from exc
raise self._auth_failure("telnet", exc) from exc
except Exception as exc:
logger.debug("Telnet failed for %s: %s", self.hostname, exc)
self._attempts.append(f"telnet: {exc}")
return False
# ------------------------------------------------------------------
@@ -337,7 +373,8 @@ class ProcurveDriver(SwitchDriver):
last = ""
for cmd in command:
last = _do(cmd)
if "% Invalid" not in last and "Error" not in last:
# ProCurve says "Invalid input: …"; other firmware "% Invalid …"
if "Invalid input" not in last and "% Invalid" not in last and "Error" not in last:
return last
return last
return _do(command)
@@ -409,14 +446,16 @@ class ProcurveDriver(SwitchDriver):
# CLI path
sysinfo = self._send_command(
["show system-information", "show system information"]
["show system-information", "show system information", "show system"]
)
parsed = parsers.parse_system_info(sysinfo)
# get model from show version if not in system-info
# get model (and part_number) from show version if not in system-info
if not parsed["model"]:
ver_out = self._send_command("show version")
parsed["model"] = parsers.parse_model_from_version(ver_out)
parsed["model"], ver_pn = parsers.parse_model_from_version(ver_out)
if ver_pn and not parsed.get("part_number"):
parsed["part_number"] = ver_pn
if not parsed["os_version"]:
parsed["os_version"] = parsers.parse_version(ver_out)
@@ -425,6 +464,7 @@ class ProcurveDriver(SwitchDriver):
return {
"vendor": self.VENDOR,
"model": parsed["model"],
"part_number": parsed.get("part_number", ""),
"hostname": parsed["hostname"],
"fqdn": parsed["hostname"],
"os_version": parsed["os_version"],
@@ -467,7 +507,8 @@ class ProcurveDriver(SwitchDriver):
except Exception:
pass
return ifaces
# The CLI lists member ports only ("3-Trk3"); the trunk gets its own row.
return add_lag_interfaces(ifaces)
# ------------------------------------------------------------------
# NAPALM: get_interfaces_ip
@@ -544,7 +585,18 @@ class ProcurveDriver(SwitchDriver):
) -> Dict[str, str]:
"""Return device configuration."""
if self._transport == "api":
return self._api.get_config()
result = self._api.get_config()
if result.get("running"):
return result
# REST endpoint not available on this firmware (e.g. HP 2530 / YA series).
# HP switches allow only one session per user — close the REST session
# first so the switch accepts the SSH connection.
try:
self._api._session.close()
except Exception:
pass
running = self._get_config_via_ssh()
return {"running": running, "startup": "", "candidate": ""}
running = ""
startup = ""
@@ -559,6 +611,26 @@ class ProcurveDriver(SwitchDriver):
return {"running": running, "startup": startup, "candidate": candidate}
def _get_config_via_ssh(self) -> str:
"""Retrieve running-config via netmiko on port 22.
HP ProCurve switches do not support exec_channel SSH; they require
an interactive shell (invoke_shell), which netmiko's hp_procurve
device type handles correctly. Port 22 is used explicitly because
self.port is the REST API port (443) in API transport mode.
"""
try:
kwargs = self._netmiko_kwargs()
kwargs["port"] = 22 # always SSH — self.port is the REST API port
conn = ConnectHandler(**kwargs)
try:
return conn.send_command("show running-config")
finally:
conn.disconnect()
except Exception as exc:
logger.warning("SSH config fallback failed for %s: %s", self.hostname, exc)
return ""
# ------------------------------------------------------------------
# NAPALM: get_environment
# ------------------------------------------------------------------
@@ -867,27 +939,40 @@ class ProcurveDriver(SwitchDriver):
mode = config.get("mode")
if mode == "trunk":
for vid in config.get("trunk_vlans", []):
payload = {
"vlan_id": vid,
"port_id": interface,
"port_mode": "POM_TAGGED_STATIC",
}
resp = self._api.post("vlans-ports", json=payload)
if not resp.ok and resp.status_code != 409:
raise ConnectionException(
f"set_interface({interface}): vlans-ports POST HTTP {resp.status_code}"
)
self._api_set_port_vlan(interface, vid, "POM_TAGGED_STATIC")
elif mode == "access":
if "access_vlan" in config:
payload = {
"vlan_id": config["access_vlan"],
"port_id": interface,
"port_mode": "POM_UNTAGGED",
}
self._api_set_port_vlan(interface, config["access_vlan"], "POM_UNTAGGED")
# Statuses the switch uses to say "that association is already there".
# v7 firmware answers 400 with {"message":"Association exists"}; others
# use the more conventional 409.
_ASSOCIATION_EXISTS = (400, 409)
def _api_set_port_vlan(self, interface: str, vid: int, port_mode: str) -> None:
"""Put *interface* into VLAN *vid* with *port_mode*.
Creating the membership and changing an existing one are different
operations here. A port that already carries the VLAN — untagged, say,
while it is meant to become tagged — cannot be POSTed again: the switch
refuses the duplicate. The membership is its own resource, named
``{vlan_id}-{port_id}``, and changing it is a PUT.
"""
payload = {"vlan_id": vid, "port_id": interface, "port_mode": port_mode}
resp = self._api.post("vlans-ports", json=payload)
if not resp.ok and resp.status_code != 409:
if resp.ok:
return
if resp.status_code not in self._ASSOCIATION_EXISTS:
raise ConnectionException(
f"set_interface({interface}): vlans-ports POST HTTP {resp.status_code}"
f" – {resp.text[:200]}"
)
resp = self._api.put(f"vlans-ports/{vid}-{interface}", json=payload)
if not resp.ok:
raise ConnectionException(
f"set_interface({interface}): vlans-ports/{vid}-{interface} PUT HTTP "
f"{resp.status_code} – {resp.text[:200]}"
)
def _cli_set_interface(self, interface: str, config: InterfaceConfigDict) -> None:
@@ -1033,61 +1118,26 @@ class ProcurveDriver(SwitchDriver):
# NAPALM: Configuration management (CLI only)
# ------------------------------------------------------------------
def _get_running_config(self) -> str:
return self._send_command("show running-config")
def load_merge_candidate(
self, filename: Optional[str] = None, config: Optional[str] = None
) -> None:
"""Load a candidate configuration for merging."""
if self._transport == "api":
raise MergeConfigException(
"Merge config is not supported via REST API transport. Use SSH."
)
if filename and config:
raise MergeConfigException("Provide either filename or config, not both.")
if filename:
with open(filename) as f:
config = f.read()
if config is None:
raise MergeConfigException("No configuration provided.")
self._candidate_config = config
self._candidate_mode = "merge"
super().load_merge_candidate(filename, config)
def load_replace_candidate(
self, filename: Optional[str] = None, config: Optional[str] = None
) -> None:
"""Load a candidate configuration for replacing.
Note: ProCurve does not natively support atomic config replace.
The candidate is applied line-by-line (same as merge) when committed.
"""
if self._transport == "api":
raise ReplaceConfigException(
"Replace config is not supported via REST API transport. Use SSH."
)
if filename and config:
raise ReplaceConfigException("Provide either filename or config, not both.")
if filename:
with open(filename) as f:
config = f.read()
if config is None:
raise ReplaceConfigException("No configuration provided.")
self._candidate_config = config
self._candidate_mode = "replace"
def compare_config(self) -> str:
"""Return a diff between the running config and the candidate config."""
if self._candidate_config is None:
return ""
import difflib
running = self._send_command("show running-config")
diff = difflib.unified_diff(
running.splitlines(),
self._candidate_config.splitlines(),
fromfile="running-config",
tofile="candidate-config",
lineterm="",
)
return "\n".join(diff)
super().load_replace_candidate(filename, config)
def commit_config(self, message: str = "", revert_in: Optional[int] = None) -> None:
"""Apply the candidate configuration to the device."""
@@ -1114,24 +1164,6 @@ class ProcurveDriver(SwitchDriver):
self._candidate_config = None
self._candidate_mode = None
def discard_config(self) -> None:
"""Discard the loaded candidate configuration."""
self._candidate_config = None
self._candidate_mode = None
def rollback(self) -> None:
"""Re-apply the backup configuration saved before the last commit."""
if self._backup_config is None:
raise MergeConfigException("No backup config available for rollback.")
self._candidate_config = self._backup_config
self._candidate_mode = "merge"
self.commit_config()
self._backup_config = None
def has_pending_commit(self) -> bool:
"""Return True if a candidate config is loaded."""
return self._candidate_config is not None
# ── SNMP / Health ──────────────────────────────────────────────────────────
def get_device_warnings(self) -> list:
+1
View File
@@ -27,6 +27,7 @@ classifiers = [
dependencies = [
"napalm>=4.0.0",
"netmiko>=4.0.0",
"paramiko>=5.0.0", # CVE-2026-44405
"netaddr",
"requests>=2.25.0",
"urllib3",
Binary file not shown.
Binary file not shown.
+289 -1
View File
@@ -3,7 +3,9 @@
import pytest
from unittest.mock import MagicMock, patch
from napalm_procurve.procurve import ProcurveDriver, _parse_ping_output
from napalm.base.exceptions import ConnectionException
from napalm_procurve.procurve import _SSH_DISABLED_STANDARD, ProcurveDriver, _parse_ping_output
from napalm_procurve import parsers
@@ -43,6 +45,30 @@ Status and Counters - General System Information
Base MAC Addr : aabbcc-ddeeff
"""
# J.15.09 firmware (#3/#4): no model anywhere, uptime in minutes.
SHOW_SYSTEM_J15 = """\
Status and Counters - General System Information
System Name : myswitch
System Contact :
System Location :
MAC Age Time (sec) : 300
Time Zone : 120
Daylight Time Rule : None
Software revision : J.15.09.0028 Base MAC Addr : a1b2c3-d4e5f6
ROM Version : J.14.05 Serial Number : SG12345678
Allow V1 Modules : Yes
Up Time : 5 mins Memory - Total : 58,720,256
CPU Util (%) : 98 Free : 39,550,272
"""
SHOW_SYSTEM_INFORMATION_INVALID = "Invalid input: system-information"
SHOW_VERSION_2520G = """\
HP J9565A 2520G-8-PoE Switch
Software revision : R.11.27
@@ -59,6 +85,25 @@ Status and Counters - Port Status
3 100/1000T | No Down Auto Unknown off 0
"""
# Layout with the Intrusion Alert column; trunk members are "<port>-Trk<n>" (#3).
SHOW_INTERFACES_BRIEF_INTRUSION = """\
Status and Counters - Port Status
| Intrusion MDI Flow Bcast
Port Type | Alert Enabled Status Mode Mode Ctrl Limit
------ --------- + --------- ------- ------ ---------- ---- ---- -----
1 100/1000T | No Yes Up 1000FDx MDI on 0
2 100/1000T | No Yes Down 1000FDx MDI off 0
3-Trk3 100/1000T | No Yes Down 1000FDx MDI off 0
4-Trk3 100/1000T | No Yes Down 1000FDx MDI off 0
5 100/1000T | No Yes Up 1000FDx MDI on 0
6-Trk6 100/1000T | No No Down 1000FDx NA off 0
7-Trk6 100/1000T | No Yes Up 1000FDx MDI off 0
8 100/1000T | No Yes Up 1000FDx MDIX off 0
9 100/1000T | No Yes Down 1000FDx MDIX off 0
10 1000SX | No Yes Up 1000FDx NA off 0
"""
SHOW_INTERFACES_PORT = """\
Status and Counters - Port Counters for port 1
@@ -212,6 +257,13 @@ class TestParseSystemInfo:
assert info["os_version"] == "YA.16.04.0006"
assert info["serial_number"] == "SG87654321"
def test_j15(self):
info = parsers.parse_system_info(SHOW_SYSTEM_J15)
assert info["hostname"] == "myswitch"
assert info["os_version"] == "J.15.09.0028"
assert info["serial_number"] == "SG12345678"
assert info["base_mac"] == "a1:b2:c3:d4:e5:f6"
class TestParseInterfacesBrief:
def test_parses_ports(self):
@@ -234,6 +286,30 @@ class TestParseInterfacesBrief:
def test_port_3_disabled(self):
ifaces = parsers.parse_interfaces_brief(SHOW_INTERFACES_BRIEF)
assert ifaces["3"]["is_enabled"] is False
assert "trunk_group" not in ifaces["3"]
class TestParseInterfacesBriefIntrusionAlert:
"""The layout with an Intrusion Alert column before Enabled (#3)."""
def test_parses_every_port(self):
ifaces = parsers.parse_interfaces_brief(SHOW_INTERFACES_BRIEF_INTRUSION)
assert sorted(ifaces, key=int) == [str(n) for n in range(1, 11)]
def test_alert_column_is_not_read_as_enabled(self):
ifaces = parsers.parse_interfaces_brief(SHOW_INTERFACES_BRIEF_INTRUSION)
assert ifaces["1"]["is_enabled"] is True
assert ifaces["1"]["is_up"] is True
assert ifaces["1"]["speed"] == 1000.0
assert ifaces["2"]["is_up"] is False
assert ifaces["6"]["is_enabled"] is False
def test_trunk_suffix_becomes_trunk_group(self):
ifaces = parsers.parse_interfaces_brief(SHOW_INTERFACES_BRIEF_INTRUSION)
assert ifaces["3"]["trunk_group"] == "Trk3"
assert ifaces["4"]["trunk_group"] == "Trk3"
assert ifaces["7"]["trunk_group"] == "Trk6"
assert "trunk_group" not in ifaces["1"]
class TestParseArpTable:
@@ -349,6 +425,40 @@ class TestDriverGetInterfaces:
assert "1" in ifaces
assert ifaces["1"]["is_up"] is True
def test_trunk_member_detail_uses_bare_port_name(self, driver):
"""`show interfaces 3-Trk3` is not a command; the port is `3` (#3)."""
driver._send_command = MagicMock(return_value=SHOW_INTERFACES_BRIEF_INTRUSION)
driver.get_interfaces()
sent = [c.args[0] for c in driver._send_command.call_args_list]
assert "show interfaces 3" in sent
assert not any("Trk" in c for c in sent)
class TestSendCommandAlternatives:
"""A list of commands falls through to the next on a CLI error (#4)."""
def test_invalid_input_tries_next_command(self, driver):
outputs = {
"show system-information": SHOW_SYSTEM_INFORMATION_INVALID,
"show system information": SHOW_SYSTEM_J15,
}
driver._device.send_command.side_effect = lambda cmd, **kw: outputs[cmd]
out = driver._send_command(["show system-information", "show system information"])
assert out == SHOW_SYSTEM_J15.strip()
def test_facts_on_j15_firmware(self, driver):
outputs = {
"show system-information": SHOW_SYSTEM_INFORMATION_INVALID,
"show system information": SHOW_SYSTEM_J15,
"show interfaces brief": SHOW_INTERFACES_BRIEF_INTRUSION,
"show version": "Image stamp: /ws/swbuildm/J_rel/code/build\n J.15.09.0028",
}
driver._device.send_command.side_effect = lambda cmd, **kw: outputs[cmd]
facts = driver.get_facts()
assert facts["os_version"] == "J.15.09.0028"
assert facts["serial_number"] == "SG12345678"
assert len(facts["interface_list"]) == 10
class TestDriverGetArpTable:
def test_arp_from_cli(self, driver):
@@ -425,6 +535,60 @@ class TestDriverTransportDetection:
with pytest.raises(Exception):
drv.open()
def test_auth_failure_names_why_earlier_transports_failed(self):
"""A Telnet login failure reports the transport and the earlier failures.
Otherwise "Login failed" reads as a wrong password when SSH was merely
refused and Telnet was the only transport left to answer (#2).
"""
from netmiko.exceptions import NetmikoAuthenticationException, NetmikoTimeoutException
def connect(**kwargs):
if kwargs["device_type"] == ProcurveDriver.NETMIKO_DEVICE_TYPE_TELNET:
raise NetmikoAuthenticationException("Login failed: 192.168.0.1")
if kwargs["disabled_algorithms"] == _SSH_DISABLED_STANDARD:
raise ConnectionRefusedError("[Errno 111] Connection refused")
raise NetmikoTimeoutException("TCP connection to device failed")
with patch("napalm_procurve.procurve.ProcurveApiClient.probe", return_value=(None, None)):
with patch("napalm_procurve.procurve.ConnectHandler", side_effect=connect):
drv = ProcurveDriver("192.168.0.1", "manager", "secret")
with pytest.raises(ConnectionException) as exc_info:
drv.open()
msg = str(exc_info.value)
assert "Authentication failed for 192.168.0.1 via telnet: Login failed: 192.168.0.1" in msg
assert "api: not detected" in msg
assert "ssh: [Errno 111] Connection refused" in msg
assert "ssh_legacy: timed out" in msg
def test_all_transports_fail_names_each_reason(self):
"""The final error gives a reason per transport, not just its name."""
with patch("napalm_procurve.procurve.ProcurveApiClient.probe", return_value=(None, None)):
with patch("napalm_procurve.procurve.ConnectHandler", side_effect=OSError("no route to host")):
drv = ProcurveDriver("192.168.0.1", "manager", "secret")
with pytest.raises(ConnectionException) as exc_info:
drv.open()
msg = str(exc_info.value)
assert "api: not detected" in msg
assert "ssh: no route to host" in msg
assert "ssh_legacy: no route to host" in msg
assert "telnet: no route to host" in msg
def test_reopen_does_not_carry_earlier_attempts(self):
"""Each open() reports only its own attempts."""
with patch("napalm_procurve.procurve.ProcurveApiClient.probe", return_value=(None, None)):
with patch("napalm_procurve.procurve.ConnectHandler", side_effect=OSError("first")):
drv = ProcurveDriver("192.168.0.1", "manager", "secret")
with pytest.raises(ConnectionException):
drv.open()
with patch("napalm_procurve.procurve.ConnectHandler", side_effect=OSError("second")):
with pytest.raises(ConnectionException) as exc_info:
drv.open()
assert "first" not in str(exc_info.value)
# ===========================================================================
# VLAN parser tests
@@ -651,3 +815,127 @@ class TestDriverGetVlans:
# Ports 1-4 are untagged in VLAN 1
assert pvids["1"] == 1
assert pvids["4"] == 1
# ===========================================================================
# set_interface over the REST API — changing an existing VLAN membership
# ===========================================================================
def _api_driver(post_status: int = 400, post_body: str = '{"message":"Association exists"}'):
"""Driver on the REST transport with a scripted API client."""
with patch("napalm_procurve.procurve.ConnectHandler"):
drv = ProcurveDriver(hostname="192.168.0.1", username="manager", password="secret")
drv._transport = "api"
api = MagicMock()
post_resp = MagicMock(ok=post_status < 400, status_code=post_status, text=post_body)
api.post.return_value = post_resp
api.put.return_value = MagicMock(ok=True, status_code=200, text="{}")
drv._api = api
return drv, api
class TestApiSetInterfaceExistingMembership:
"""A port that already carries the VLAN needs its mode changed, not a new row.
Reproduced on a 2530-24G-PoEP (REST v7): port 10 holds VLAN 10 untagged
alongside 30/40/50 tagged. Posting the tagged membership answers
`400 {"message":"Association exists"}` — only `409` was treated as
"already there". The resource is `{vlan_id}-{port_id}` and takes a PUT.
"""
def test_trunk_falls_back_to_put_on_association_exists(self):
drv, api = _api_driver(post_status=400)
drv.set_interface("10", {"mode": "trunk", "trunk_vlans": [10]})
api.put.assert_called_once()
path = api.put.call_args[0][0]
assert path == "vlans-ports/10-10"
assert api.put.call_args[1]["json"]["port_mode"] == "POM_TAGGED_STATIC"
def test_access_falls_back_to_put_on_association_exists(self):
drv, api = _api_driver(post_status=400)
drv.set_interface("10", {"mode": "access", "access_vlan": 20})
assert api.put.call_args[0][0] == "vlans-ports/20-10"
assert api.put.call_args[1]["json"]["port_mode"] == "POM_UNTAGGED"
def test_conflict_status_also_falls_back(self):
"""Other firmware answers 409 for the same situation."""
drv, api = _api_driver(post_status=409)
drv.set_interface("10", {"mode": "trunk", "trunk_vlans": [10]})
api.put.assert_called_once()
def test_a_new_membership_still_uses_post_alone(self):
drv, api = _api_driver(post_status=200)
drv.set_interface("11", {"mode": "trunk", "trunk_vlans": [30]})
api.post.assert_called_once()
api.put.assert_not_called()
def test_a_failing_put_reports_the_response_body(self):
"""The message used to drop it, so "Association exists" never surfaced."""
drv, api = _api_driver(post_status=400)
api.put.return_value = MagicMock(ok=False, status_code=403, text='{"message":"denied"}')
with pytest.raises(Exception) as exc:
drv.set_interface("10", {"mode": "trunk", "trunk_vlans": [10]})
assert "denied" in str(exc.value)
def test_an_unrelated_post_failure_reports_the_response_body(self):
drv, api = _api_driver(post_status=500, post_body='{"message":"boom"}')
with pytest.raises(Exception) as exc:
drv.set_interface("10", {"mode": "trunk", "trunk_vlans": [10]})
assert "boom" in str(exc.value)
api.put.assert_not_called()
# ===========================================================================
# LAG rows (#5)
# ===========================================================================
class TestApiGetInterfacesLag:
"""The REST path's trunk rows, kept as they were when it moved to add_lag_interfaces."""
def _client(self, ports):
from napalm_procurve.api_client import ProcurveApiClient
client = ProcurveApiClient(hostname="192.168.0.1", username="manager", password="secret")
responses = {
"ports": {"port_element": ports},
"port-statistics": {"port_statistics_element": [
{"id": p["id"], "port_speed_mbps": 1000} for p in ports
]},
"system/status/switch": {},
}
client.get = MagicMock(side_effect=lambda endpoint, **kw: responses[endpoint])
return client
def test_trunk_rows_with_mode(self):
client = self._client([
{"id": "1", "is_port_up": True, "is_port_enabled": True, "trunk_group": ""},
{"id": "3", "is_port_up": False, "is_port_enabled": True, "trunk_group": "trk3", "trunk_mode": "PTT_LACP"},
{"id": "4", "is_port_up": True, "is_port_enabled": True, "trunk_group": "trk3", "trunk_mode": "PTT_LACP"},
{"id": "10", "is_port_up": False, "is_port_enabled": True, "trunk_group": "trk6"},
{"id": "7", "is_port_up": False, "is_port_enabled": True, "trunk_group": "trk6"},
])
ifaces = client.get_interfaces()
assert ifaces["trk3"]["lag_members"] == ["3", "4"]
assert ifaces["trk3"]["lag_mode"] == "lacp"
assert ifaces["trk3"]["is_up"] is True
assert ifaces["trk3"]["speed"] == 2000.0
assert ifaces["trk6"]["lag_members"] == ["7", "10"]
assert ifaces["trk6"]["lag_mode"] == "trunk"
assert ifaces["trk6"]["description"] == "LAG (7, 10)"
assert ifaces["3"]["trunk_group"] == "trk3"
class TestCliGetInterfacesLag:
def test_trunk_rows_from_member_ports(self, driver):
driver._send_command = MagicMock(
side_effect=lambda cmd: SHOW_INTERFACES_BRIEF_INTRUSION if cmd == "show interfaces brief" else ""
)
ifaces = driver.get_interfaces()
assert ifaces["Trk3"]["lag_members"] == ["3", "4"]
assert ifaces["Trk6"]["lag_members"] == ["6", "7"]
assert ifaces["Trk6"]["is_up"] is True
assert ifaces["Trk6"]["is_enabled"] is True