test(e2e): classifier spot check and filter persistence on TAP anchors

The filter matrix (cls_bpf, the eBPF classifier) only ran on Docker veth
host ends; the IOU and IOL-container scenarios now spot-check it on their
TAP anchors and prove a filter survives a node restart:

* bpf "icmp" drops everything with clsact on the anchor, both ends, and
  clears back to an empty qdisc;
* frequency_drop 3 (the eBPF every-nth mode) measured 56 % round-trip
  loss (1 - (2/3)^2 = 55.6 % expected) in both scenarios;
* the IOU test applies delay 100 before a router stop/start: the fresh
  anchor gets netem back (tc level) and the restarted router's pings
  measure RTT >= 150 ms until the filter is cleared (a re-attach that
  dropped the filter would show ~100 ms).

IOU file 492 s, IOL file 182 s, both green.
This commit is contained in:
YueGuobin 2026-10-02 22:32:42 +08:00
parent 9d937802d5
commit 1f544c96a5
No known key found for this signature in database
5 changed files with 106 additions and 15 deletions

View File

@ -140,6 +140,10 @@ isolated instance, real `iol-xe/iol-xe:17-18-02` images, real uBridge with
* real ICMP crosses the [unix ↔ tap] port-bridge relay (100 % ping);
* `delay 100` lands as netem on both anchors, measured RTT grows ≥ 150 ms
and returns < 50 ms when cleared;
* the classifier spot check on a TAP anchor (the Docker suite runs the full
matrix on veth host ends): `bpf "icmp"` drops everything (clsact on the
anchor), `frequency_drop 3` as the eBPF every-nth mode measured 56 %
round-trip loss (theory 55.6 %), both restoring on clear;
* suspend admin-downs the anchor and kills the traffic; resume restores;
* AF_PACKET capture writes a real pcap of the ICMP exchange;
* link delete/re-create swaps the port bridge's TAP leg out and back in

View File

@ -146,10 +146,13 @@ The repository's live e2e suite carries the same scenario with **real
IOU routers** (`tests/e2e/test_iou_kernel_datapath.py`, pytest marker
`e2e`): a real L3 IOU image provides the fabric and the real IOS CLI on
the server's telnet console, driving anchors-born-with-the-node, real ICMP
through the port bridge, netem delay, suspend, capture, link
delete/re-create, node stop/start rewiring, the serial link staying on the
relay, and a relay negative control whose real `delay`/`frequency_drop`
filters ride the port's `iol_bridge add_packet_filter` list.
through the port bridge, netem delay, the TAP-anchor classifier spot check
(bpf match-drop; the eBPF every-nth mode at 56 % measured round-trip loss,
55.6 % expected), suspend, capture, link delete/re-create, node
stop/start rewiring with a delay filter restored on the fresh anchor
(tc and traffic both), the serial link staying on the relay, and a relay
negative control whose real `delay`/`frequency_drop` filters ride the
port's `iol_bridge add_packet_filter` list.
The uBridge side passed its own 30/30 (`tests/iol/test_tap_anchor.py` in the
fork), including the DOWN-anchor resilience and kernel-bridge interop this

View File

@ -72,16 +72,18 @@ Current scenarios:
names), zero residue.
* `test_iol_docker_kernel_datapath.py` — two real iol-xe containers (Cisco
CML iol-runner images): kernel link through the port bridge's swappable
TAP leg (anchors at start, netem delay, suspend, capture, link
delete/re-create swap, node stop/start rewiring) and a relay negative
control.
TAP leg (anchors at start, netem delay, the TAP-anchor classifier spot
check for bpf match-drop and the eBPF every-nth mode, suspend, capture,
link delete/re-create swap, node stop/start rewiring) and a relay
negative control.
* `test_iou_kernel_datapath.py` — two real IOU routers (a real IOU image
from the images directory, the real IOS CLI on the server's telnet
console): kernel link through the [IOL fabric ↔ TAP] port bridge (same
lifecycle as the iol-xe containers), a serial link staying on the relay
(serial bays never anchor), and a relay negative control whose filters
ride the IOU-specific `iol_bridge add_packet_filter` engine (delay and
frequency_drop measured through real traffic).
lifecycle as the iol-xe containers) including the TAP-anchor classifier
spot check and a filter surviving a node restart, a serial link staying
on the relay (serial bays never anchor), and a relay negative control
whose filters ride the IOU-specific `iol_bridge add_packet_filter` engine
(delay and frequency_drop measured through real traffic).
* `test_qemu_kernel_datapath.py` — two real IOSv routers (a real qcow2
image from the images directory's `QEMU` subfolder, linked clone, the
real IOS CLI on the server's telnet console): the anchor TAP is QEMU's

View File

@ -19,6 +19,9 @@ bridge, kernel filters, suspend and capture.
before any link — and the per-link bridge enslaves exactly the two of them;
* real ICMP crosses the [unix ↔ TAP] port bridge relay;
* a ``delay 100`` filter lands as netem on both anchors, RTT grows ~200 ms;
* the classifier spot check on a TAP anchor: ``bpf`` match-drop and the
eBPF ``frequency_drop`` every-nth mode (the Docker suite runs the full
matrix on veth host ends);
* suspend admin-downs the anchor and kills the traffic; resume restores;
* link delete releases the port bridge's TAP leg (stop → delete_nio_tap) and
the anchor survives — re-creating the link swaps the leg back in (the
@ -145,6 +148,40 @@ def test_iol_docker_kernel_datapath():
fast = harness.wait_ping(c1, "10.1.1.2")
assert fast["success"] == 100 and fast["avg"] < 50, fast
# Classifier spot check on the container's TAP anchor (the Docker
# suite runs the full matrix on veth host ends): cls_bpf match-drop
# and the eBPF stateful classifier attach to a tun/tap anchor the
# same way.
tc_caps = (caps or {}).get("ubridge_tc") or {}
if tc_caps.get("cbpf"):
print(".. bpf 'icmp' drops everything on the TAP anchor")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"bpf": ["icmp"]}})
assert "clsact" in harness.qdiscs(a1) and "clsact" in harness.qdiscs(a2), (
harness.qdiscs(a1),
harness.qdiscs(a2),
)
blocked = harness.ping(c1, "10.1.1.2", repeat=4)
assert blocked["success"] == 0, blocked["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert "clsact" not in harness.qdiscs(a1), harness.qdiscs(a1)
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
else:
print(".. uBridge reports no cbpf: skipping the bpf check")
modes = tc_caps.get("ebpf_modes") or []
if "nth" in modes:
print(".. frequency_drop 3 (eBPF nth) on the TAP anchor")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"frequency_drop": [3]}})
nth = harness.ping(c1, "10.1.1.2", repeat=9)
loss = 100 - nth["success"]
print(f".. {loss} % round-trip loss")
# the kernel counts per direction: 1 - (2/3)^2 = 55.6 % round trip
assert 20 <= loss <= 90, nth["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
else:
print(".. uBridge reports no eBPF nth: skipping the frequency_drop check")
# suspend: anchor admin-down (the port bridge's TAP writes fail EIO,
# nothing comes back); resume restores
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": True})

View File

@ -21,6 +21,9 @@ Linux bridge, kernel filters, suspend and capture.
bridge enslaves exactly the two of them;
* real ICMP crosses the [IOL fabric ↔ TAP] port bridge;
* a ``delay 100`` filter lands as netem on both anchors, RTT grows ~200 ms;
* the classifier spot check on a TAP anchor: ``bpf`` match-drop and the
eBPF ``frequency_drop`` every-nth mode (the Docker suite runs the full
matrix on veth host ends);
* suspend admin-downs the anchor and kills the traffic; resume restores;
* a serial link between the same nodes never anchors (per-port exclusion):
it stays on the relay, ``kernel_datapath`` is False, and real traffic
@ -29,7 +32,7 @@ Linux bridge, kernel filters, suspend and capture.
re-creating the link swaps the leg back in and the traffic returns;
* node stop tears the anchors down (uBridge holds their fds — the IOL
bridge goes first, then ``tap delete``); a restart recreates the whole
wiring from the NIO;
wiring from the NIO, a delay filter applied before the stop included;
* deleting the project leaves no anchors and no bridges behind.
``test_iou_relay_control`` is the negative control on an isolated
@ -159,6 +162,39 @@ def test_iou_kernel_datapath():
fast = harness.wait_ping(c1, "10.1.1.2")
assert fast["success"] == 100 and fast["avg"] < 50, fast
# Classifier spot check on a TAP anchor (the Docker suite runs the
# full matrix on veth host ends): cls_bpf match-drop and the eBPF
# stateful classifier attach to a tun/tap anchor the same way.
tc_caps = (caps or {}).get("ubridge_tc") or {}
if tc_caps.get("cbpf"):
print(".. bpf 'icmp' drops everything on the TAP anchor")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"bpf": ["icmp"]}})
assert "clsact" in harness.qdiscs(a1) and "clsact" in harness.qdiscs(a2), (
harness.qdiscs(a1),
harness.qdiscs(a2),
)
blocked = harness.ping(c1, "10.1.1.2", repeat=4)
assert blocked["success"] == 0, blocked["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert "clsact" not in harness.qdiscs(a1), harness.qdiscs(a1)
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
else:
print(".. uBridge reports no cbpf: skipping the bpf check")
modes = tc_caps.get("ebpf_modes") or []
if "nth" in modes:
print(".. frequency_drop 3 (eBPF nth) on the TAP anchor")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"frequency_drop": [3]}})
nth = harness.ping(c1, "10.1.1.2", repeat=9)
loss = 100 - nth["success"]
print(f".. {loss} % round-trip loss")
# the kernel counts per direction: 1 - (2/3)^2 = 55.6 % round trip
assert 20 <= loss <= 90, nth["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
else:
print(".. uBridge reports no eBPF nth: skipping the frequency_drop check")
# suspend: anchor admin-down (the port bridge's TAP writes fail EIO,
# nothing comes back); resume restores
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": True})
@ -206,8 +242,11 @@ def test_iou_kernel_datapath():
# node stop: uBridge holds the anchor fds, so the IOL bridge goes
# first and the taps with it (a stopped node must not litter the
# host with gi devices); a restart recreates the whole wiring from
# the NIO that outlived it.
print(".. stopping and restarting router 1")
# the NIO that outlived it — *including the NIO's filters*, which
# land on the freshly created anchor. The delay filter goes on
# before the stop so the restart has something to restore.
print(".. stopping and restarting router 1 with a delay filter applied")
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}})
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/stop")
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1
# the per-link bridge lost this end's port; it may be gone entirely
@ -218,8 +257,14 @@ def test_iou_kernel_datapath():
c1 = _boot(server, pid, n1_id)
assert harness.tap_exists(a1)
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
assert harness.wait_until(lambda: "netem" in harness.qdiscs(a1), timeout=15), harness.qdiscs(a1)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1", eth_if="Ethernet0/0")
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
survivor = harness.wait_ping(c1, "10.1.1.2", attempts=5)
assert survivor["success"] == 100, survivor["raw"]
assert survivor["avg"] >= 150, (baseline, survivor)
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
cleared = harness.ping(c1, "10.1.1.2", repeat=3)
assert cleared["success"] == 100 and cleared["avg"] < 50, (baseline, cleared)
c1.close()
c2.close()