Reconcile dynamic CAPsMAN bridge ports and verify failed changes
Test, publish and promote RouterOS Wi-Fi VLAN reconciler / validate (push) Successful in 4s
Test, publish and promote RouterOS Wi-Fi VLAN reconciler / publish (push) Successful in 8s

This commit is contained in:
feedc0de committed 2026-10-23 22:37:31 +02:00
1 parent 3244d4414b
commit eb05748821
3 files changed
+46 -21

No files matched your search

+4 -3
View File
@@ -3,9 +3,10 @@
This service applies bridge PVIDs on `wifi-qcom-ac` CAPs. CAPsMAN supplies the
SSID and MAC address for each Wi-Fi interface. The local CAP supplies the Wi-Fi
interface and bridge port. The reconciler matches by MAC address, chooses the
single enabled local interface when a stale disabled duplicate exists, disables
the CAPsMAN interface before changing a wrong bridge port, writes the PVID,
reads the port back, and only then enables CAPsMAN again. It exports distinct
single enabled local interface when a stale disabled duplicate exists, writes
the local bridge PVID, and reads the port back. Static CAPsMAN interfaces are
disabled during a repair and enabled after verification; RouterOS cannot toggle
dynamic CAPsMAN interfaces, so their bridge ports are updated directly. It exports distinct
Prometheus metrics for API write failures and VLANs still wrong after readback.
`chart/` is the portable Kubernetes contract. The deployed cluster chooses its
+21 -18
View File
@@ -94,27 +94,30 @@ def reconcile_cap(cap, controller_wifi, api, controller_api, vlans):
continue
if not desired_port(port, vlan):
# Prevent traffic from using the wrong VLAN while repairing the port.
try:
if remote.get("disabled") != "true":
# Dynamic CAPsMAN interfaces cannot be disabled through RouterOS.
# The local bridge port is still writable on wifi-qcom-ac CAPs.
can_write = True
if remote.get("dynamic") != "true" and remote.get("disabled") != "true":
try:
controller_resource.call("disable", {"numbers": remote["id"]})
disabled_now = [w for w in controller_resource.get() if w.get("id") == remote["id"]]
if len(disabled_now) != 1 or disabled_now[0].get("disabled") != "true":
raise RuntimeError("CAPsMAN interface remained enabled after disable")
except Exception as exc:
LOG.warning("%s %s: disable failed: %s", cap, name, exc)
results.append(event("apply_failure", cap, name, ssid, vlan, port.get("pvid", "missing") if port else "missing", "disable", exc))
continue
try:
fields = {"bridge": "bridgeLocal", "pvid": str(vlan),
"ingress-filtering": "yes", "frame-types": "admit-only-untagged-and-priority-tagged"}
if port is None:
local_ports.add(interface=local_name, **fields)
else:
local_ports.set(id=port["id"], **fields)
except Exception as exc:
LOG.warning("%s %s: bridge port write failed: %s", cap, name, exc)
results.append(event("apply_failure", cap, name, ssid, vlan, port.get("pvid", "missing") if port else "missing", "bridge_port", exc))
except Exception as exc:
LOG.warning("%s %s: disable failed: %s", cap, name, exc)
results.append(event("apply_failure", cap, name, ssid, vlan, port.get("pvid", "missing") if port else "missing", "disable", exc))
can_write = False
if can_write:
try:
fields = {"bridge": "bridgeLocal", "pvid": str(vlan),
"ingress-filtering": "yes", "frame-types": "admit-only-untagged-and-priority-tagged"}
if port is None:
local_ports.add(interface=local_name, **fields)
else:
local_ports.set(id=port["id"], **fields)
except Exception as exc:
LOG.warning("%s %s: bridge port write failed: %s", cap, name, exc)
results.append(event("apply_failure", cap, name, ssid, vlan, port.get("pvid", "missing") if port else "missing", "bridge_port", exc))
verified_ports = [p for p in local_ports.get() if p.get("interface") == local_name]
verified = verified_ports[0] if len(verified_ports) == 1 else None
@@ -130,7 +133,7 @@ def reconcile_cap(cap, controller_wifi, api, controller_api, vlans):
if len(current) != 1:
results.append(event("audit_failure", cap, name, ssid, vlan, "controller interface missing"))
continue
if current[0].get("disabled") == "true":
if current[0].get("disabled") == "true" and current[0].get("dynamic") != "true":
try:
controller_resource.call("enable", {"numbers": remote["id"]})
except Exception as exc:
+21
View File
@@ -30,6 +30,8 @@ class Resource:
def call(self, command, arguments):
self.calls.append((command, arguments))
if self.fail == command:
raise RuntimeError("can not change interface")
row = next(row for row in self.rows if row["id"] == arguments["numbers"])
row["disabled"] = "true" if command == "disable" else "false"
@@ -74,6 +76,25 @@ class ReconcileTest(unittest.TestCase):
self.assertEqual([], self.ports.calls)
self.assertEqual([], self.remote.calls)
def test_dynamic_controller_interface_repairs_local_port_without_toggle(self):
self.remote.rows[0]["dynamic"] = "true"
self.assertEqual([], self.reconcile())
self.assertEqual("6", self.ports.rows[0]["pvid"])
self.assertEqual([], self.remote.calls)
def test_dynamic_port_write_failure_reports_failure_and_mismatch(self):
self.remote.rows[0]["dynamic"] = "true"
self.ports.fail = "set"
self.assertEqual(["apply_failure", "mismatch"],
[entry["kind"] for entry in self.reconcile()])
self.assertEqual([], self.remote.calls)
def test_disable_failure_still_reports_mismatch(self):
self.remote.fail = "disable"
self.assertEqual(["apply_failure", "mismatch"],
[entry["kind"] for entry in self.reconcile()])
self.assertEqual([], self.ports.calls)
if __name__ == "__main__":
unittest.main()