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.
This commit is contained in:
@@ -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.
+33
-20
@@ -919,28 +919,41 @@ class ProcurveDriver(ConfigLifecycleMixin, 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",
|
||||
}
|
||||
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, 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 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:
|
||||
mode = config.get("mode")
|
||||
|
||||
BIN
Binary file not shown.
@@ -651,3 +651,72 @@ 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()
|
||||
|
||||
Reference in New Issue
Block a user