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:
Christian Manivong
2026-08-18 16:22:21 +07:00
parent 0dcede037f
commit e3bbac0656
12 changed files with 272 additions and 20 deletions
+33 -20
View File
@@ -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")