From eb0574882135a7e1fee3fcb76535d2b058ea95ff Mon Sep 17 00:00:00 2001 From: 0xFEEDC0DE64 Date: Fri, 23 Oct 2026 22:37:31 +0200 Subject: [PATCH] Reconcile dynamic CAPsMAN bridge ports and verify failed changes --- README.md | 7 ++++--- reconciler.py | 39 +++++++++++++++++++++------------------ test_reconciler.py | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 41212b2..e18aec6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/reconciler.py b/reconciler.py index 64b0464..820ccce 100644 --- a/reconciler.py +++ b/reconciler.py @@ -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: diff --git a/test_reconciler.py b/test_reconciler.py index 77bf198..3c9f2f0 100644 --- a/test_reconciler.py +++ b/test_reconciler.py @@ -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()