diff --git a/napalm_hpe_aruba_procurve.egg-info/PKG-INFO b/napalm_hpe_aruba_procurve.egg-info/PKG-INFO new file mode 100644 index 0000000..40b9e52 --- /dev/null +++ b/napalm_hpe_aruba_procurve.egg-info/PKG-INFO @@ -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 diff --git a/napalm_hpe_aruba_procurve.egg-info/SOURCES.txt b/napalm_hpe_aruba_procurve.egg-info/SOURCES.txt new file mode 100644 index 0000000..f15b5c3 --- /dev/null +++ b/napalm_hpe_aruba_procurve.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/napalm_hpe_aruba_procurve.egg-info/dependency_links.txt b/napalm_hpe_aruba_procurve.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/napalm_hpe_aruba_procurve.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/napalm_hpe_aruba_procurve.egg-info/entry_points.txt b/napalm_hpe_aruba_procurve.egg-info/entry_points.txt new file mode 100644 index 0000000..04560d5 --- /dev/null +++ b/napalm_hpe_aruba_procurve.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[napalm.drivers] +procurve = napalm_procurve:ProcurveDriver diff --git a/napalm_hpe_aruba_procurve.egg-info/requires.txt b/napalm_hpe_aruba_procurve.egg-info/requires.txt new file mode 100644 index 0000000..bcdacea --- /dev/null +++ b/napalm_hpe_aruba_procurve.egg-info/requires.txt @@ -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 diff --git a/napalm_hpe_aruba_procurve.egg-info/top_level.txt b/napalm_hpe_aruba_procurve.egg-info/top_level.txt new file mode 100644 index 0000000..51a2d42 --- /dev/null +++ b/napalm_hpe_aruba_procurve.egg-info/top_level.txt @@ -0,0 +1 @@ +napalm_procurve diff --git a/napalm_procurve/__pycache__/api_client.cpython-312.pyc b/napalm_procurve/__pycache__/api_client.cpython-312.pyc index 73b8162..76db29a 100644 Binary files a/napalm_procurve/__pycache__/api_client.cpython-312.pyc and b/napalm_procurve/__pycache__/api_client.cpython-312.pyc differ diff --git a/napalm_procurve/__pycache__/parsers.cpython-312.pyc b/napalm_procurve/__pycache__/parsers.cpython-312.pyc index 1cd1b72..e7329a4 100644 Binary files a/napalm_procurve/__pycache__/parsers.cpython-312.pyc and b/napalm_procurve/__pycache__/parsers.cpython-312.pyc differ diff --git a/napalm_procurve/__pycache__/procurve.cpython-312.pyc b/napalm_procurve/__pycache__/procurve.cpython-312.pyc index 0f6c04e..c435f0f 100644 Binary files a/napalm_procurve/__pycache__/procurve.cpython-312.pyc and b/napalm_procurve/__pycache__/procurve.cpython-312.pyc differ diff --git a/napalm_procurve/procurve.py b/napalm_procurve/procurve.py index 9a0a89b..313c1f5 100644 --- a/napalm_procurve/procurve.py +++ b/napalm_procurve/procurve.py @@ -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") diff --git a/tests/unit/__pycache__/test_driver.cpython-312-pytest-9.0.3.pyc b/tests/unit/__pycache__/test_driver.cpython-312-pytest-9.1.1.pyc similarity index 69% rename from tests/unit/__pycache__/test_driver.cpython-312-pytest-9.0.3.pyc rename to tests/unit/__pycache__/test_driver.cpython-312-pytest-9.1.1.pyc index 21ee7cd..e59bb8e 100644 Binary files a/tests/unit/__pycache__/test_driver.cpython-312-pytest-9.0.3.pyc and b/tests/unit/__pycache__/test_driver.cpython-312-pytest-9.1.1.pyc differ diff --git a/tests/unit/test_driver.py b/tests/unit/test_driver.py index 46956cf..950fb09 100644 --- a/tests/unit/test_driver.py +++ b/tests/unit/test_driver.py @@ -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()