fix: reconcile marker filters in _ubridge_apply_markers (delete/update)

The batch marker-def fan-out (PR #2848) routed create/update/delete
marker_definition through memory_only + a batch PUT /nios/batch that
re-applies markers via _ubridge_apply_markers. But _ubridge_apply_markers
was strictly add-only: it skipped any (name, link_id) already in
_marker_filter_bridges, and reset_packet_filters preserves mark filters
(contract). So:

  * delete_marker_definition left the deleted marker's filter alive in
    uBridge (still matching / signalling / writing pcap) until node restart.
  * update_marker_definition (bpf/tag/direction change) never reached
    uBridge — the live filter kept the old expression until node restart.

Make _ubridge_apply_markers a real reconcile against the desired
nio.markers:
  - installed but no longer desired  → delete_packet_filter + unlink pcap
                                       + unregister
  - desired with changed filter field → rebuild (delete + re-add)
  - desired with only enabled changed  → instant toggle (pcap preserved)
  - desired and unchanged              → skip
  - desired and new                    → add

Track installed specs in a parallel _marker_specs dict so changes can be
detected. Both base_node and the IOU iol_bridge override are updated.
Added tests for the delete-removed and rebuild-changed-bpf paths.
This commit is contained in:
YueGuobin 2026-08-11 21:00:03 +08:00
parent f045cde0da
commit 958c45b6fc
No known key found for this signature in database
3 changed files with 153 additions and 56 deletions

View File

@ -103,6 +103,10 @@ class BaseNode:
# marker filter name -> uBridge bridge_name (recorded at apply time so
# _ubridge_set_marker_filter_state can toggle on/off without an NIO rebuild).
self._marker_filter_bridges = {}
# Parallel store of the installed marker spec (bpf/tag/direction/enabled/...)
# keyed by (name, link_id) so _ubridge_apply_markers can reconcile: detect
# deletions and field changes instead of being add-only.
self._marker_specs = {}
if self._console is not None:
# use a previously allocated console port
@ -994,6 +998,7 @@ class BaseNode:
# gone too — clear the map so the next apply re-installs them all rather
# than skipping them as "already installed".
self._marker_filter_bridges.clear()
self._marker_specs.clear()
async def add_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio):
"""
@ -1176,6 +1181,7 @@ class BaseNode:
if nio is not None and getattr(nio, "markers", None):
nio.markers.pop(name, None)
bridge_name = self._marker_filter_bridges.pop((name, link_id), None)
self._marker_specs.pop((name, link_id), None)
if bridge_name is not None:
await self._ubridge_delete_marker_filter(bridge_name, name)
try:
@ -1223,34 +1229,71 @@ class BaseNode:
async def _ubridge_apply_markers(self, bridge_name, nio):
"""
Install the traffic-insight markers carried by *nio* onto bridge
*bridge_name* that aren't already there. uBridge's ``reset_packet_filters``
preserves mark filters (contract), so on an NIO update we add only the new
ones re-adding an existing marker would either duplicate it or
close/reopen its pcap. Called from ``add_ubridge_udp_connection`` (fresh
bridge, empty map installs all) and ``update_ubridge_udp_connection``
(incremental).
Reconcile the traffic-insight markers carried by *nio* onto bridge
*bridge_name* with what is already installed there.
uBridge's ``reset_packet_filters`` preserves mark filters (contract), so
a plain re-add would duplicate them; instead this diffs the desired
``nio.markers`` against the installed ``_marker_specs``:
* installed but no longer desired delete filter + unlink pcap
* desired with changed bpf/tag/direction/data_link_type rebuild
(delete + add; the marker's own pcap reopens for the new BPF)
* desired with only ``enabled`` changed instant on/off toggle
(sibling and own pcap stay open)
* desired and unchanged skip
* desired and new add
Called from ``add_ubridge_udp_connection`` (fresh bridge, empty maps
installs all) and ``update_ubridge_udp_connection`` / the batch NIO
update path (incremental reconcile).
"""
from gns3server.compute.marker.marker_manager import MarkerManager
markers = nio.markers if hasattr(nio, 'markers') else {}
if not markers:
return
manager = MarkerManager.instance()
markers_dir = self.project.markers_working_directory()
for name, spec in markers.items():
link_id = spec.get("link_id", "")
# Incremental: skip markers already on this bridge. uBridge keeps mark
# filters across reset_packet_filters, so re-adding would duplicate (or
# reopen the pcap). A fresh bridge has an empty map → installs all.
if (name, link_id) in self._marker_filter_bridges:
continue
desired = {(name, spec.get("link_id", "")): spec for name, spec in markers.items()}
# 1. Remove installed markers that are no longer desired (marker/def delete).
for key in list(self._marker_filter_bridges):
if key not in desired:
mname, link_id = key
installed_bridge = self._marker_filter_bridges.pop(key)
self._marker_specs.pop(key, None)
await self._ubridge_delete_marker_filter(installed_bridge, mname)
try:
os.remove(os.path.join(markers_dir, f"{self._id}_{link_id}_{mname}.pcap"))
except FileNotFoundError:
pass
except OSError as e:
log.warning("Could not remove marker pcap for '%s' on link %s: %s", mname, link_id, e)
manager.unregister(self._id, mname)
# 2. Add newly-desired markers; rebuild ones whose filter fields changed.
rebuild_fields = ("bpf", "tag", "direction", "data_link_type")
for (name, link_id), spec in desired.items():
bpf = spec.get("bpf", "")
tag = spec.get("tag")
pcap_path = os.path.join(
markers_dir, f"{self._id}_{link_id}_{name}.pcap"
)
enabled = spec.get("enabled", True)
if (name, link_id) in self._marker_filter_bridges:
installed_spec = self._marker_specs.get((name, link_id))
if installed_spec is None:
# Installed but no recorded spec (legacy / pre-reconcile state):
# cannot diff, skip to avoid a duplicate add.
continue
if any(installed_spec.get(f) != spec.get(f) for f in rebuild_fields):
# A filter field changed → rebuild (delete + re-add).
installed_bridge = self._marker_filter_bridges.get((name, link_id))
await self._ubridge_delete_marker_filter(installed_bridge, name)
elif installed_spec.get("enabled", True) != enabled:
# Only the on/off state changed → instant toggle, pcap preserved.
await self._ubridge_set_marker_filter_state(name, enabled)
self._marker_specs[(name, link_id)] = spec
continue
else:
continue # unchanged
pcap_path = os.path.join(markers_dir, f"{self._id}_{link_id}_{name}.pcap")
try:
await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id,
direction=spec.get("direction"),
@ -1268,22 +1311,19 @@ class BaseNode:
# A disabled marker is installed but turned off (a paused tap), not
# dropped — so the UI can flip it back on instantly with
# enable_packet_filter, no NIO rebuild (ubridge contract §3.2).
if not spec.get("enabled", True):
if not enabled:
try:
await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} off")
except UbridgeError as e:
# Old ubridge without enable_packet_filter: leave it installed
# (on) rather than fail the whole link/marker apply.
log.warning(f"Could not turn marker '{name}' off on {bridge_name}: {e}")
manager.register(
str(self.project.id), self._id, name, link_id, tag
)
manager.register(str(self.project.id), self._id, name, link_id, tag)
# Remember which bridge hosts this filter so an instant on/off toggle
# (no NIO rebuild) can resolve it by name alone.
# keyed (name, link_id) so a node that hosts markers for several links
# (e.g. IOU with one IOL-BRIDGE and many bays/units) records each
# copy independently — toggle below iterates all matching entries.
# (no NIO rebuild) can resolve it by name alone, and keep the spec so
# the next reconcile can detect changes.
self._marker_filter_bridges[name, link_id] = bridge_name
self._marker_specs[name, link_id] = spec
async def _ubridge_set_marker_filter_state(self, name, enabled):
"""

View File

@ -1266,7 +1266,9 @@ class IOUVM(BaseNode):
async def _ubridge_apply_markers(self, adapter_number, port_number, nio):
"""
(Re-)apply traffic-insight markers to the IOL bridge.
Reconcile traffic-insight markers on the IOL bridge (diff desired
``nio.markers`` against installed ``_marker_specs``): delete removed,
rebuild changed, toggle on/off-only changes, add new, skip unchanged.
IOU uses ``iol_bridge`` (not ``bridge``) and the ``add_packet_filter``
command carries extra ``{bay} {unit}`` positional arguments between the
@ -1280,41 +1282,55 @@ class IOUVM(BaseNode):
from gns3server.compute.marker.marker_manager import MarkerManager
markers = nio.markers if hasattr(nio, 'markers') else {}
if not markers:
return
manager = MarkerManager.instance()
markers_dir = self.project.markers_working_directory()
bridge_name = f"IOL-BRIDGE-{self.application_id + 512}"
location = "{bridge_name} {bay} {unit}".format(
bridge_name=bridge_name, bay=adapter_number, unit=port_number
)
for name, spec in markers.items():
link_id = spec.get("link_id", "")
# Incremental: skip markers already installed on this port. A NIO
# update carries EVERY marker on the port (e.g. an inherited
# global-* copy plus a newly added private one); uBridge's
# add_packet_filter rejects a duplicate filter name (packet_filter.c),
# so we must not re-add one already here — mirrors the generic
# _ubridge_apply_markers guard. A fresh bridge has an empty map
# (cleared on _stop_ubridge) so all are installed.
if (name, link_id) in self._marker_filter_bridges:
continue
desired = {(name, spec.get("link_id", "")): spec for name, spec in markers.items()}
# 1. Remove installed markers that are no longer desired.
for key in list(self._marker_filter_bridges):
if key not in desired:
mname, link_id = key
installed_location = self._marker_filter_bridges.pop(key)
self._marker_specs.pop(key, None)
await self._ubridge_delete_marker_filter(installed_location, mname)
try:
os.remove(os.path.join(markers_dir, f"{self._id}_{link_id}_{mname}.pcap"))
except FileNotFoundError:
pass
except OSError as e:
log.warning("Could not remove marker pcap for '%s' on link %s: %s", mname, link_id, e)
manager.unregister(self._id, mname)
# 2. Add / reconcile desired markers.
rebuild_fields = ("bpf", "tag", "direction", "data_link_type")
for (name, link_id), spec in desired.items():
bpf = spec.get("bpf", "")
tag = spec.get("tag")
pcap_path = os.path.join(
markers_dir, f"{self._id}_{link_id}_{name}.pcap"
)
# Build the iol_bridge marker filter command:
# iol_bridge add_packet_filter {br} {bay} {unit} {name} mark "{bpf}" [tag {id}] pcap "{path}"
enabled = spec.get("enabled", True)
if (name, link_id) in self._marker_filter_bridges:
installed_spec = self._marker_specs.get((name, link_id))
if installed_spec is None:
continue # installed (legacy, no spec) — skip to avoid dup
if any(installed_spec.get(f) != spec.get(f) for f in rebuild_fields):
installed_location = self._marker_filter_bridges.get((name, link_id))
await self._ubridge_delete_marker_filter(installed_location, name)
elif installed_spec.get("enabled", True) != enabled:
await self._ubridge_set_marker_filter_state(name, enabled)
self._marker_specs[(name, link_id)] = spec
continue
else:
continue
pcap_path = os.path.join(markers_dir, f"{self._id}_{link_id}_{name}.pcap")
# iol_bridge add_packet_filter {br} {bay} {unit} {name} mark "{bpf}" [tag {id}] pcap "{path}"
cmd = 'iol_bridge add_packet_filter {loc} {name} mark "{bpf}"'.format(
loc=location, name=name, bpf=bpf
)
if tag is not None:
cmd += f" tag {tag}"
# IOU uses one per-node IOL-BRIDGE for every link, so bridge+filter
# are identical across this node's links — `link` is the only way the
# controller can tell their signals apart (contract §3.2).
if link_id:
cmd += f" link {link_id}"
direction = spec.get("direction")
@ -1333,16 +1349,14 @@ class IOUVM(BaseNode):
self.project.emit("log.warning", {"message": message})
continue
raise
if not spec.get("enabled", True):
if not enabled:
try:
await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} off")
except UbridgeError as e:
log.warning(f"Could not turn marker '{name}' off on {location}: {e}")
manager.register(
str(self.project.id), self._id, name, link_id, tag
)
# Record name -> location (bridge bay unit) for instant toggle.
manager.register(str(self.project.id), self._id, name, link_id, tag)
self._marker_filter_bridges[name, link_id] = location
self._marker_specs[name, link_id] = spec
async def _ubridge_set_marker_filter_state(self, name, enabled):
"""IOU override: toggle every (name, link_id) entry via ``iol_bridge``."""

View File

@ -367,6 +367,49 @@ async def test_apply_markers_skips_already_installed(compute_project, manager):
assert not any("add_packet_filter" in c for c in cmds) # skipped, not re-added
@pytest.mark.asyncio
async def test_apply_markers_deletes_removed(compute_project, manager):
# Reconcile: a marker no longer in nio.markers is deleted from uBridge
# (filter + registry), not left as an orphan still matching packets.
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._ubridge_send = AsyncioMagicMock()
node._ubridge_hypervisor = MagicMock()
node._ubridge_hypervisor.is_running.return_value = True
node._marker_filter_bridges["m", "L1"] = "VPCS-10"
node._marker_specs["m", "L1"] = {"bpf": "icmp", "enabled": True}
nio = NIOUDP(1234, "127.0.0.1", 4321)
nio.markers = {} # marker removed
with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm:
mm.instance.return_value.unregister = MagicMock()
await node._ubridge_apply_markers("VPCS-10", nio)
cmds = [c.args[0] for c in node._ubridge_send.call_args_list]
assert any("delete_packet_filter VPCS-10 m" in c for c in cmds)
assert ("m", "L1") not in node._marker_filter_bridges
assert ("m", "L1") not in node._marker_specs
@pytest.mark.asyncio
async def test_apply_markers_rebuilds_changed_bpf(compute_project, manager):
# Reconcile: a marker whose bpf changed is rebuilt (delete + re-add), so
# uBridge ends up with the new expression — not the stale original.
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
node._ubridge_send = AsyncioMagicMock()
node._ubridge_hypervisor = MagicMock()
node._ubridge_hypervisor.is_running.return_value = True
node._marker_filter_bridges["m", "L1"] = "VPCS-10"
node._marker_specs["m", "L1"] = {"bpf": "icmp", "tag": None, "direction": None,
"data_link_type": None, "enabled": True}
nio = NIOUDP(1234, "127.0.0.1", 4321)
nio.markers = {"m": {"bpf": "tcp", "tag": None, "link_id": "L1",
"direction": None, "enabled": True}}
with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm:
mm.instance.return_value.register = MagicMock()
await node._ubridge_apply_markers("VPCS-10", nio)
cmds = [c.args[0] for c in node._ubridge_send.call_args_list]
assert any("delete_packet_filter VPCS-10 m" in c for c in cmds) # old removed
assert any("add_packet_filter VPCS-10 m mark" in c and "tcp" in c for c in cmds) # new added
@pytest.mark.asyncio
async def test_stop_ubridge_clears_marker_bridges(compute_project, manager):
# uBridge stopping drops every marker filter — the map must clear so the next