feat(esw): wire peer anchors straight into the switch bridge

The Ethernet switch's kernel fast path: instead of a link crossing the
switch's per-port uBridge relay, the switch absorbs the peer's anchor into
its own kernel bridge. The peer's interface (Docker veth host end, QEMU
TAP, IOU port TAP, Dynamips port TAP) becomes the switch port, and frames
cross anchor -> switch bridge -> switch ports entirely in the kernel.

Wire format: the switch end receives nio_anchor ('join this interface to
my own bridge' — the mirror of nio_bridge), the peer end nio_bridge with a
null bridge ('your anchor is bridged elsewhere': carrier, markers and
impairments only — never bridge membership, and never a bridge delete, on
an interface this node does not own). Anchor names come from the shared
naming contract (utils.kernel_anchor), a pure function of node type, id,
adapter and port — the controller computes the peer's anchor while the
node may be stopped, which is what makes links to stopped nodes wireable.

Stopped peers defer the join; after any node of a link starts
(UDPLink.node_started) the controller re-pushes the switch NIO, and the
switch completes — or repairs, when a peer restart replaced its interface
under the same name — the join against the kernel's actual membership
instead of assuming it.

VLAN changes now edit membership in place: only the ports whose mode/VLAN
actually changed are touched, each losing exactly the previous mode's
entries the new mode does not want (the dot1q admits-all entry is one
range operation in both directions), and no port ever leaves the bridge to
change its VLANs. This replaces the old 'reset by re-enslaving' shortcut
(delif+addif on every wired port, added when the ports were the switch's
own TAPs): on absorbed anchors, re-enslaving an interface owned by another
node is where live testing watched links die — anchors occasionally lost
their bridge membership right after a ports_mapping update (later updates
then failed with delif EINVAL), reproducibly with qemu, IOU and Dynamips
peers and never with Docker's veths.

Eligibility: the switch side needs no capability of its own (its bridge
exists by construction); the peer must be able to anchor. Switch-to-switch
cascades stay on the relay — a kernel interface belongs to one bridge, and
joining two bridges needs a veth pair, deferred.

Verified live on a real server: qemu peers through four VLAN cycles with
both anchors enslaved throughout; a mixed switch (2 IOU + 2 Dynamips, four
kernel ports); Docker peers with real ICMP isolating and recovering (with
both anchors still enslaved); VPCS relay ports likewise in place.
This commit is contained in:
YueGuobin committed 2026-10-06 00:21:09 +08:00
1 parent 3910a6b585
commit c343ce0b8f
25 files changed
+1902 -110

No files matched your search

@@ -14,7 +14,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from unittest.mock import MagicMock, call
import os
from unittest.mock import MagicMock, call, patch
import pytest
import pytest_asyncio
@@ -535,3 +536,507 @@ class TestEthernetSwitchNodesRoutes:
)
assert response.status_code == status.HTTP_204_NO_CONTENT
node._ubridge_send.assert_any_call(f"bridge stop_capture {relay}")
# ------------------------------------------------------------------ #
# kernel datapath: absorbed anchors (nio_anchor)
# ------------------------------------------------------------------ #
@staticmethod
def _anchor_params(anchor: str = "gv00010203e0p0", **extra) -> dict:
params = {"type": "nio_anchor", "anchor": anchor}
params.update(extra)
return params
@staticmethod
def _sysfs(anchor_exists: bool = True, bridge_member: bool = True):
"""An ``os.path.exists`` that answers the /sys/class/net probes of the
kernel-port wiring, delegating everything else to the real one."""
real = os.path.exists
def fake(path):
value = str(path)
if "/brif/" in value:
return bridge_member
if value.startswith("/sys/class/net/"):
return anchor_exists
return real(path)
return patch("os.path.exists", new=fake)
async def test_ethernet_switch_create_anchor_nio(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
A kernel link joins the peer's anchor straight to the switch bridge
with the port's VLAN mode applied — no relay, no per-port TAP.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=True):
response = await compute_client.post(url, json=self._anchor_params(anchor))
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["type"] == "nio_anchor"
node = compute_project.get_node(ethernet_switch["node_id"])
br = node._bridge_name
# access VLAN 1 (default): the anchor replaces the port TAP as the
# bridge port, so it carries the port's membership
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
node._ubridge_send.assert_any_call(f'brctl vlan_del "{br}" "{anchor}" 1')
node._ubridge_send.assert_any_call(f'brctl vlan_add "{br}" "{anchor}" 1 pvid untagged')
node._ubridge_send.assert_any_call(f'link set "{anchor}" up')
# no relay was created for this port
assert not any("bridge create" in str(c) for c in node._ubridge_send.call_args_list)
assert node._kernel_ports[0] == anchor
assert node.get_nio(0).anchor == anchor
async def test_ethernet_switch_create_anchor_nio_defers_when_anchor_missing(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
The peer's anchor only exists while the peer runs: a link created
against a stopped peer must not fail (nor stop the switch's
uBridge) — the join is deferred to the re-push after the peer
starts.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=False):
response = await compute_client.post(url, json=self._anchor_params(anchor))
assert response.status_code == status.HTTP_201_CREATED
node = compute_project.get_node(ethernet_switch["node_id"])
assert node._kernel_ports[0] == anchor
assert node.get_nio(0).anchor == anchor
assert not any("addif" in str(c) for c in node._ubridge_send.call_args_list)
assert not any("brctl" in str(c) for c in node._ubridge_send.call_args_list)
async def test_ethernet_switch_update_anchor_nio_reconciles(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
Updating a joined kernel port reconciles on the anchor: tc
impairments, markers and the carrier the suspend flag drives.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=True):
await compute_client.post(url, json=self._anchor_params(anchor))
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
update_url = app.url_path_for(
"compute:update_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=True, bridge_member=True):
response = await compute_client.put(
update_url, json=self._anchor_params(anchor, filters={"delay": [10]}, suspend=True)
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["suspend"] is True
node._ubridge_send.assert_any_call(f'tc reset "{anchor}"')
node._ubridge_send.assert_any_call(f'tc netem set "{anchor}" delay 10')
node._ubridge_send.assert_any_call(f'link set "{anchor}" down')
# no re-join on a plain reconcile
assert not any("addif" in str(c) for c in node._ubridge_send.call_args_list)
async def test_ethernet_switch_update_anchor_nio_completes_deferred_join(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
The controller re-pushes the switch NIO once the peer starts: a join
deferred at link creation completes (the anchor exists but is not a
bridge member), and a peer restart that replaced its anchor under
the same name re-joins.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=False):
await compute_client.post(url, json=self._anchor_params(anchor))
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
update_url = app.url_path_for(
"compute:update_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
# the anchor exists now (peer started) but is not a bridge member
with self._sysfs(anchor_exists=True, bridge_member=False):
response = await compute_client.put(update_url, json=self._anchor_params(anchor))
assert response.status_code == status.HTTP_201_CREATED
br = node._bridge_name
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
node._ubridge_send.assert_any_call(f'brctl vlan_add "{br}" "{anchor}" 1 pvid untagged')
node._ubridge_send.assert_any_call(f'link set "{anchor}" up')
async def test_ethernet_switch_delete_anchor_nio(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
Removing a kernel link resets the anchor's qdisc, takes it out of
the switch bridge and drops its carrier — the anchor itself survives
(it belongs to the peer).
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=True):
await compute_client.post(url, json=self._anchor_params(anchor))
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
with self._sysfs(anchor_exists=True):
response = await compute_client.delete(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
br = node._bridge_name
node._ubridge_send.assert_any_call(f'tc reset "{anchor}"')
node._ubridge_send.assert_any_call(f'brctl delif "{br}" "{anchor}"')
node._ubridge_send.assert_any_call(f'link set "{anchor}" down')
assert 0 not in node._kernel_ports
assert 0 not in node._nios
async def test_ethernet_switch_capture_anchor_nio(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
Capture on a kernel port runs on the absorbed anchor (AF_PACKET),
not on a relay.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=True):
await compute_client.post(url, json=self._anchor_params(anchor))
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
response = await compute_client.post(
app.url_path_for(
"compute:start_ethernet_switch_capture",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
),
json={"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"},
)
assert response.status_code == status.HTTP_200_OK
assert any(
c.args and str(c.args[0]).startswith(f'capture start_kernel {anchor} "')
for c in node._ubridge_send.call_args_list
), node._ubridge_send.call_args_list
response = await compute_client.post(
app.url_path_for(
"compute:stop_ethernet_switch_capture",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
)
assert response.status_code == status.HTTP_204_NO_CONTENT
node._ubridge_send.assert_any_call("capture stop_kernel")
async def test_ethernet_switch_batch_create_anchor_nio(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
The project-open batch endpoint accepts anchor NIOs (a reopen must
not 422 the switch's kernel links).
"""
anchor = "gv00010203e0p3"
params = {
"nios": [
{
"node_id": ethernet_switch["node_id"],
"adapter_number": 0,
"port_number": 3,
"nio": {"type": "nio_anchor", "anchor": anchor},
}
]
}
with self._sysfs(anchor_exists=False):
response = await compute_client.post(
app.url_path_for("compute:create_batch_nios", project_id=compute_project.id), json=params
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["added"] == 1
node = compute_project.get_node(ethernet_switch["node_id"])
assert node._kernel_ports[3] == anchor
# ------------------------------------------------------------------ #
# in-place VLAN reconfiguration (no port ever leaves the bridge)
# ------------------------------------------------------------------ #
async def test_ethernet_switch_update_ports_reconfigures_in_place(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
Changing a wired port's VLAN edits its membership in place: delete the
old membership, add the new one — the port is never detached from the
bridge (a relay TAP here; the TAP is the cloud/relay-side port).
"""
node = compute_project.get_node(ethernet_switch["node_id"])
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
await compute_client.post(url, json=self._udp_params())
node._ubridge_send.reset_mock()
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
ports[0]["vlan"] = 10
response = await compute_client.put(
app.url_path_for(
"compute:update_ethernet_switch",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
),
json={"ports_mapping": ports},
)
assert response.status_code == status.HTTP_200_OK
br = node._bridge_name
tap = f"{br}-0"
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
assert commands == [
f'brctl vlan_del "{br}" "{tap}" 1',
f'brctl vlan_add "{br}" "{tap}" 10 pvid untagged',
], commands
assert not any("delif" in c or "addif" in c for c in commands)
async def test_ethernet_switch_update_ports_leaves_untouched_ports_alone(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
Only ports whose mode/VLAN changed are touched: re-saving the mapping
no longer re-programs every wired port.
"""
node = compute_project.get_node(ethernet_switch["node_id"])
for port_number in (0, 1):
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number=str(port_number),
)
await compute_client.post(url, json=self._udp_params())
node._ubridge_send.reset_mock()
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
ports[1]["vlan"] = 20
response = await compute_client.put(
app.url_path_for(
"compute:update_ethernet_switch",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
),
json={"ports_mapping": ports},
)
assert response.status_code == status.HTTP_200_OK
br = node._bridge_name
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
assert commands == [
f'brctl vlan_del "{br}" "{br}-1" 1',
f'brctl vlan_add "{br}" "{br}-1" 20 pvid untagged',
], commands
assert not any(f'"{br}-0"' in c for c in commands)
async def test_ethernet_switch_vlan_transition_table(self, compute_project: Project, ethernet_switch: dict) -> None:
"""
Every mode transition is expressed as in-place VLAN edits: the old
mode's entries the new mode does not want, then the new mode's
entries. The dot1q admits-all entry is one range operation in both
directions, and same-to-same emits nothing.
"""
node = compute_project.get_node(ethernet_switch["node_id"])
br = node._bridge_name
iface = "gvdeadbeef0p0"
cases = [
(
{"type": "access", "vlan": 10},
{"type": "dot1q", "vlan": 20},
[
f'brctl vlan_del "{br}" "{iface}" 10',
f'brctl vlan_add "{br}" "{iface}" 1 vid 4094',
f'brctl vlan_add "{br}" "{iface}" 20 pvid untagged',
],
),
(
{"type": "dot1q", "vlan": 20},
{"type": "access", "vlan": 30},
[
f'brctl vlan_del "{br}" "{iface}" 1 vid 4094',
f'brctl vlan_add "{br}" "{iface}" 30 pvid untagged',
],
),
(
{"type": "dot1q", "vlan": 20},
{"type": "dot1q", "vlan": 30},
[
f'brctl vlan_del "{br}" "{iface}" 20',
f'brctl vlan_add "{br}" "{iface}" 30 pvid untagged',
],
),
(
{"type": "qinq", "vlan": 2},
{"type": "dot1q", "vlan": 5},
[
f'brctl vlan_del "{br}" "{iface}" 2',
f'brctl vlan_add "{br}" "{iface}" 1 vid 4094',
f'brctl vlan_add "{br}" "{iface}" 5 pvid untagged',
],
),
(
{"type": "dot1q", "vlan": 5},
{"type": "qinq", "vlan": 2},
[
f'brctl vlan_del "{br}" "{iface}" 1 vid 4094',
f'brctl vlan_add "{br}" "{iface}" 2 pvid untagged',
],
),
(
{"type": "access", "vlan": 10},
{"type": "qinq", "vlan": 2},
[
f'brctl vlan_del "{br}" "{iface}" 10',
f'brctl vlan_add "{br}" "{iface}" 2 pvid untagged',
],
),
(
{"type": "qinq", "vlan": 2},
{"type": "access", "vlan": 10},
[
f'brctl vlan_del "{br}" "{iface}" 2',
f'brctl vlan_add "{br}" "{iface}" 10 pvid untagged',
],
),
(
{"type": "qinq", "vlan": 2},
{"type": "qinq", "vlan": 9},
[
f'brctl vlan_del "{br}" "{iface}" 2',
f'brctl vlan_add "{br}" "{iface}" 9 pvid untagged',
],
),
(
{"type": "access", "vlan": 7},
{"type": "access", "vlan": 7},
[],
),
]
for old_settings, new_settings, expected in cases:
node._ubridge_send.reset_mock()
await node._reconfigure_port_vlan(iface, old_settings, new_settings)
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
assert commands == expected, (old_settings, new_settings, commands)
assert not any("delif" in c or "addif" in c for c in commands)
async def test_ethernet_switch_update_ports_reconfigures_absorbed_anchor_in_place(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
) -> None:
"""
The same in-place reconcile applies to an absorbed kernel anchor (a
peer node's interface): a ports_mapping update issues VLAN edits on
it and never detaches it from the bridge.
"""
anchor = "gv00010203e0p0"
url = app.url_path_for(
"compute:create_ethernet_switch_nio",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
adapter_number="0",
port_number="0",
)
with self._sysfs(anchor_exists=True):
await compute_client.post(url, json=self._anchor_params(anchor))
node = compute_project.get_node(ethernet_switch["node_id"])
node._ubridge_send.reset_mock()
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
ports[0]["vlan"] = 10
response = await compute_client.put(
app.url_path_for(
"compute:update_ethernet_switch",
project_id=ethernet_switch["project_id"],
node_id=ethernet_switch["node_id"],
),
json={"ports_mapping": ports},
)
assert response.status_code == status.HTTP_200_OK
br = node._bridge_name
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
assert commands == [
f'brctl vlan_del "{br}" "{anchor}" 1',
f'brctl vlan_add "{br}" "{anchor}" 10 pvid untagged',
], commands
assert not any("delif" in c or "addif" in c for c in commands)
@@ -612,3 +612,65 @@ async def test_manager_create_nio_builds_the_bridge_nio(router):
assert nio.filters == {"delay": [5]}
assert nio.suspend is True
assert _hypervisor_commands(router) == []
# ---------------------------------------------------------------------------
# Externally bridged anchors (an Ethernet switch absorbed them)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_external_bridge_nio_skips_bridge_membership(router):
"""
A NIOBridge with no bridge means an Ethernet switch owns the anchor's
bridge membership: the router still opens the anchor in the hypervisor
(the TAP needs its fd holder) but never touches any kernel bridge.
"""
router.status = "started"
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router, bridge=None)
await router.slot_add_nio_binding(0, 0, nio)
hypervisor = _hypervisor_commands(router)
assert any(c.startswith("nio create_tap tap-") for c in hypervisor)
assert any(c.startswith('vm slot_add_nio_binding "R1" 0 0 tap-') for c in hypervisor)
assert not any("brctl" in c for c in _ubridge_commands(router))
@pytest.mark.asyncio
async def test_external_bridge_nio_remove_skips_bridge_ops(router):
"""
Detaching an externally bridged anchor releases the hypervisor's fd and
resets the link state, but must not delif or delete any bridge — the
switch owns them, and deleting the switch's bridge would take the whole
switch down.
"""
router.status = "started"
router._kernel_taps[(0, 0)] = TAP00
nio = await _nio(router, bridge=None)
await router.slot_add_nio_binding(0, 0, nio)
router._ubridge_send.reset_mock()
await router.slot_remove_nio_binding(0, 0)
commands = _ubridge_commands(router)
assert not any("brctl" in c for c in commands), commands
assert any(c == f'tc reset "{TAP00}"' for c in commands)
@pytest.mark.asyncio
async def test_remove_kernel_bridges_skips_external_nios(router):
"""
The bridge sweep never deletes a bridge this node does not own.
"""
router._kernel_taps[(0, 0)] = TAP00
external = await _nio(router, bridge=None)
router._slots[0].add_nio(0, external)
await router._remove_kernel_bridges()
assert not any("brctl" in c for c in _ubridge_commands(router))
@@ -802,3 +802,131 @@ async def test_available_filters_unconstrained_without_report(project):
link, _node1, _node2 = await _kernel_link(project)
link._link_data = [{"type": "nio_bridge"}]
assert [f["type"] for f in link.available_filters()] == [f["type"] for f in FILTERS]
# ---------------------------------------------------------------------------
# Ethernet switch fast path: the switch absorbs the peer's anchor
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_switch_links_select_the_kernel_fast_path(project):
"""
A link with an Ethernet switch endpoint rides the kernel fast path when
the *peer* can anchor — the switch side needs no anchoring capability
(its bridge exists by construction). Switch-to-switch links never
qualify (a kernel interface belongs to exactly one bridge), nor do
anchor-less peers.
"""
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "ubridge_iol_tap": True}
switch = _node(project, compute, "sw1", node_type="ethernet_switch")
for peer in (
_node(project, compute, "docker1"),
_node(project, compute, "qemu1", node_type="qemu"),
_node(project, compute, "iou1", node_type="iou"),
_node(project, compute, "r1", node_type="dynamips"),
):
link = await _link(project, switch, peer)
assert link._kernel_datapath_eligible(switch, peer) is True, peer.node_type
# a switch peer: cascades need a veth pair between two bridges — relay
switch2 = _node(project, compute, "sw2", node_type="ethernet_switch")
link = await _link(project, switch, switch2)
assert link._kernel_datapath_eligible(switch, switch2) is False
# an anchor-less peer
vpcs = _node(project, compute, "pc1", node_type="vpcs")
link = await _link(project, switch, vpcs)
assert link._kernel_datapath_eligible(switch, vpcs) is False
# a peer whose compute cannot anchor stays relay too
compute_no_tap = _tap_capable_compute(tap_support=None)
qemu = _node(project, compute_no_tap, "qemu1", node_type="qemu")
switch3 = _node(project, compute_no_tap, "sw3", node_type="ethernet_switch")
link = await _link(project, switch3, qemu)
assert link._kernel_datapath_eligible(switch3, qemu) is False
@pytest.mark.asyncio
async def test_switch_link_prepare_emits_anchor_and_external_bridge(project):
"""
The fast path's wire format: the switch receives ``nio_anchor`` naming
the peer's anchor (the shared naming contract, computable while the
node is stopped), the peer receives ``nio_bridge`` with no bridge —
its anchor is bridged by the switch. The link's tc impairments are
owned by exactly one end (the switch): both would hit the same
interface.
"""
from gns3server.utils.kernel_anchor import kernel_anchor_name
compute = MagicMock()
compute.id = "compute-1"
switch = _node(project, compute, "sw1", node_type="ethernet_switch")
peer = _node(project, compute, "docker1")
link = await _link(project, switch, peer)
link._filters = {"delay": [10]}
entries = await link._prepare()
switch_nio = entries[0][3]
peer_nio = entries[1][3]
assert switch_nio["type"] == "nio_anchor"
assert switch_nio["anchor"] == kernel_anchor_name("docker", peer.id, 0, 0)
assert switch_nio["filters"] == {"delay": [10]}
assert peer_nio["type"] == "nio_bridge"
assert peer_nio["bridge"] is None
assert peer_nio["filters"] == {}
assert link.kernel_datapath is True
# the reverse endpoint order must keep index alignment
link = await _link(project, peer, switch)
link._filters = {"delay": [10]}
entries = await link._prepare()
assert entries[0][3]["type"] == "nio_bridge"
assert entries[0][3]["bridge"] is None
assert entries[0][3]["filters"] == {}
assert entries[1][3]["type"] == "nio_anchor"
assert entries[1][3]["filters"] == {"delay": [10]}
@pytest.mark.asyncio
async def test_switch_link_repushes_the_switch_nio_on_node_start(project):
"""
The switch cannot observe the peer's anchors coming into existence: the
controller re-pushes the switch's NIO after a node of the link starts,
which completes a deferred join (or re-joins an anchor a peer restart
replaced). Relay links do nothing.
"""
from tests.utils import AsyncioMagicMock
compute = MagicMock()
compute.id = "compute-1"
switch = _node(project, compute, "sw1", node_type="ethernet_switch")
peer = _node(project, compute, "docker1")
link = await _link(project, switch, peer)
entries = await link._prepare()
link._created = True
switch.put = AsyncioMagicMock()
await link.node_started(peer)
switch.put.assert_called_once_with(
"/adapters/0/ports/0/nio", data=entries[0][3], timeout=120
)
# a relay link never re-pushes anything
switch.put.reset_mock()
compute2 = MagicMock()
compute2.id = "compute-1"
relay_switch = _node(project, compute2, "sw2", node_type="ethernet_switch")
vpcs = _node(project, compute2, "pc1", node_type="vpcs")
relay_link = await _link(project, relay_switch, vpcs)
relay_link._created = True
relay_switch.put = AsyncioMagicMock()
await relay_link.node_started(vpcs)
assert not relay_switch.put.called
+16
View File
@@ -936,6 +936,22 @@ async def test_start(node, compute):
compute.post.assert_called_with(f"/projects/{node.project.id}/vpcs/nodes/{node.id}/start", timeout=240)
@pytest.mark.asyncio
async def test_start_notifies_links(node, compute):
"""
A successful start tells the node's links: the Ethernet-switch fast
path completes a deferred anchor join only then (UDPLink.node_started).
"""
compute.post = AsyncioMagicMock()
link = AsyncioMagicMock()
link.deferred = False
node.add_link(link)
await node.start()
assert link.node_started.called
@pytest.mark.asyncio
async def test_start_iou(compute, project, controller):
+14 -2
View File
@@ -282,6 +282,12 @@ def live_server(kernel=True):
already be configured for it); everything else starts an isolated
instance. Relay-mode tests always need an isolated instance — the
datapath choice is a server configuration.
Either way this waits for the controller's local compute to finish
connecting: the connection's ``GET /capabilities`` runs the uBridge
probes (several ubridge spawns, seconds), and until it completes the
controller caches a default dict with every capability None — a test
reading capabilities in that window would wrongly skip itself.
"""
url = os.environ.get("GNS3_E2E_URL")
if url and kernel:
@@ -290,8 +296,14 @@ def live_server(kernel=True):
os.environ.get("GNS3_E2E_USER", "admin"),
os.environ.get("GNS3_E2E_PASSWORD", "admin"),
).login()
return Server(compute)
return _start_isolated(kernel)
server = Server(compute)
else:
server = _start_isolated(kernel)
if not wait_until(lambda: (server.compute.capabilities() or {}).get("version"), timeout=45):
raise AssertionError(
f"{server.compute.url}: the controller's local compute did not report capabilities within 45s"
)
return server
def release(server, project_id, failed=False):
@@ -0,0 +1,290 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Live end-to-end test for the Ethernet switch kernel fast path (pytest
marker ``e2e``): a real switch (a Linux kernel bridge) between two real
c7200 routers, driven through the controller REST API and the IOS consoles.
Real ICMP crossing the switch proves the datapath:
* links created while the routers are stopped report ``kernel_datapath``
and defer the join; once the routers boot, the controller's re-push
makes the switch join their anchors — the switch bridge's members become
exactly the two router anchors (no relay TAPs anywhere);
* VLAN programming applies to absorbed anchors: different access VLANs
isolate the routers (ping dies), the same VLAN bridges them again;
* a ``delay 100`` filter lands as netem on the absorbed anchor (single
interface, switch-owned) and the measured RTT grows by ~100 ms;
* suspend admin-downs the anchor and kills the traffic; resume restores;
* capture (hosted by the switch side) writes a pcap of ICMP records;
* deleting a link detaches the anchor (which survives — the router owns
it) and re-creating re-joins it;
* a router stop/start keeps the wiring and the traffic;
* deleting the project removes the switch bridge and the anchors.
``test_ethernet_switch_relay_control`` is the negative control on an
isolated relay-configured instance: the same topology rides the per-port
uBridge relays (the switch bridge's members are its own relay TAPs, never
the routers' anchors) and still pings.
"""
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# c7200 slot 0 is the fixed IO slot; the Ethernet PA goes in slot 1.
SLOTS = {"slot1": "PA-2FE-TX"}
ETH = (1, 0)
def _pick_image(server):
images = server.compute.dynamips_images()
image = next((name for name in images if name.startswith("c7200")), None)
if not image:
pytest.skip(f"no c7200 image available on this server (images: {images[:5]})")
return image
def _switch_bridge_name(switch_id):
# see EthernetSwitch._ensure_bridge
return "gns3" + switch_id.replace("-", "")[:6]
def _create_topology(compute, pid, image, name):
switch = compute.call(
"POST",
f"/projects/{pid}/nodes",
{
"compute_id": "local",
"name": f"{name}-SW",
"node_type": "ethernet_switch",
"properties": {
"ports_mapping": [
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 1},
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 1},
]
},
},
)
r1 = compute.create_dynamips_router(pid, f"{name}-R1", image, SLOTS)
r2 = compute.create_dynamips_router(pid, f"{name}-R2", image, SLOTS)
return switch, r1, r2
def _boot(server, project_id, node_id, timeout=360):
console = harness.dynamips_console(server, project_id, node_id)
console.boot_wait(timeout=timeout)
return console
def test_ethernet_switch_kernel_fast_path():
server = harness.live_server(kernel=True)
compute = server.compute
if compute.capabilities().get("ubridge_tap") is not True:
pytest.skip("this compute's uBridge cannot create persistent TAPs (ubridge_tap != True)")
image = _pick_image(server)
project = compute.create_project("esw-e2e")
pid = project["project_id"]
sw_bridge = None
try:
switch, r1, r2 = _create_topology(compute, pid, image, "E2E")
switch_id = switch["node_id"]
r1_id, r2_id = r1["node_id"], r2["node_id"]
sw_bridge = _switch_bridge_name(switch_id)
a1 = harness.anchor_name(r1_id, *ETH)
a2 = harness.anchor_name(r2_id, *ETH)
# The switch bridge exists from the moment the switch node does; a
# link against a stopped router must not fail — the join defers.
assert harness.bridge_members(sw_bridge) == []
link1 = compute.create_link(pid, (r1_id, *ETH), (switch_id, 0, 0))
link2 = compute.create_link(pid, (r2_id, *ETH), (switch_id, 0, 1))
assert link1["kernel_datapath"] is True, link1
assert link2["kernel_datapath"] is True, link2
assert harness.bridge_members(sw_bridge) == []
print(".. starting routers and waiting for IOS boots")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start")
c1 = _boot(server, pid, r1_id)
c2 = _boot(server, pid, r2_id)
# Deferred joins completed by the node-start re-push: the anchors
# ARE the switch ports now.
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=15), (
harness.bridge_members(sw_bridge)
)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1")
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2")
print(".. both routers configured, pinging through the switch")
baseline = harness.wait_ping(c1, "10.1.1.2")
assert baseline["success"] == 100, baseline["raw"]
# VLAN programming on absorbed anchors: different access VLANs
# isolate, the same VLAN bridges again.
compute.call(
"PUT",
f"/projects/{pid}/nodes/{switch_id}",
{
"properties": {
"ports_mapping": [
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 10},
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 20},
]
}
},
)
assert harness.bridge_members(sw_bridge) == sorted([a1, a2]), harness.bridge_members(sw_bridge)
isolated = harness.ping(c1, "10.1.1.2", repeat=3)
assert isolated["success"] == 0, isolated["raw"]
assert harness.bridge_members(sw_bridge) == sorted([a1, a2]), harness.bridge_members(sw_bridge)
compute.call(
"PUT",
f"/projects/{pid}/nodes/{switch_id}",
{
"properties": {
"ports_mapping": [
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 10},
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 10},
]
}
},
)
rejoined = harness.wait_ping(c1, "10.1.1.2")
assert rejoined["success"] == 100, rejoined["raw"]
# Single-owned impairment: the filter lands on the absorbed anchor
# (one interface — the switch's), RTT grows by ~100 ms one-way.
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"filters": {"delay": [100]}})
assert "netem" in harness.qdiscs(a1), harness.qdiscs(a1)
assert "netem" not in harness.qdiscs(a2), harness.qdiscs(a2)
delayed = harness.wait_ping(c1, "10.1.1.2")
assert delayed["success"] == 100, delayed["raw"]
assert 60 <= delayed["avg"] <= 250, (baseline, delayed)
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"filters": {}})
assert "netem" not in harness.qdiscs(a1)
fast = harness.wait_ping(c1, "10.1.1.2")
assert fast["success"] == 100 and fast["avg"] < 50, fast
# suspend: the anchor admin-downs, the link is dead; resume restores
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"suspend": True})
assert not harness.tap_up(a1)
dead = harness.ping(c1, "10.1.1.2", repeat=3)
assert dead["success"] == 0, dead["raw"]
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"suspend": False})
assert harness.tap_up(a1)
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
# capture (the controller hosts it on the switch — always-on local
# builtin): a pcap of ICMP records written off the absorbed anchor
capture = compute.call(
"POST", f"/projects/{pid}/links/{link1['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}
)
harness.ping(c1, "10.1.1.2")
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{link1['link_id']}/capture/stop")
count, ethertypes = harness.pcap_records(capture["capture_file_path"])
assert count >= 4, capture["capture_file_path"]
assert "0800" in ethertypes, ethertypes
# link delete detaches the anchor (it survives — the router owns
# it); re-creating re-joins it
compute.call("DELETE", f"/projects/{pid}/links/{link2['link_id']}")
assert harness.bridge_members(sw_bridge) == [a1]
assert harness.tap_exists(a2)
link2 = compute.create_link(pid, (r2_id, *ETH), (switch_id, 0, 1))
assert link2["kernel_datapath"] is True, link2
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=10)
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
# router stop/start keeps the wiring: a Dynamips stop only halts the
# emulated router — the hypervisor, the anchor and its bridge
# membership all survive (the same stop-is-not-close semantics as
# the per-link bridge datapath)
print(".. stopping and restarting R1")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/stop")
assert harness.tap_exists(a1)
assert harness.bridge_members(sw_bridge) == sorted([a1, a2])
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
c1.close()
c1 = _boot(server, pid, r1_id)
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=15)
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1")
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# project delete closed everything: switch bridge and anchors gone
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) is None, timeout=15), sw_bridge
def test_ethernet_switch_relay_control():
"""Negative control: the same topology on an isolated relay-configured
instance rides the per-port uBridge relays — the switch bridge's
members are its own relay TAPs, never the routers' anchors — and still
pings."""
server = harness.live_server(kernel=False)
compute = server.compute
image = _pick_image(server)
project = compute.create_project("esw-e2e-relay")
pid = project["project_id"]
sw_bridge = None
try:
switch, r1, r2 = _create_topology(compute, pid, image, "E2E")
sw_bridge = _switch_bridge_name(switch["node_id"])
r1_id, r2_id = r1["node_id"], r2["node_id"]
a1 = harness.anchor_name(r1_id, *ETH)
link1 = compute.create_link(pid, (r1_id, *ETH), (switch["node_id"], 0, 0))
link2 = compute.create_link(pid, (r2_id, *ETH), (switch["node_id"], 0, 1))
assert link1["kernel_datapath"] is False, link1
assert link2["kernel_datapath"] is False, link2
print(".. starting routers and waiting for IOS boots (relay)")
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start")
c1 = _boot(server, pid, r1_id)
c2 = _boot(server, pid, r2_id)
members = harness.bridge_members(sw_bridge)
assert members == sorted([f"{sw_bridge}-0", f"{sw_bridge}-1"]), members
assert a1 not in members
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1")
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2")
relay_ping = harness.wait_ping(c1, "10.1.1.2")
assert relay_ping["success"] == 100, relay_ping["raw"]
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) is None, timeout=15), sw_bridge
+47
View File
@@ -0,0 +1,47 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
The kernel-datapath anchor naming contract (shared by the compute classes
that create anchors and the controller that names a peer's anchor for an
Ethernet-switch link).
"""
from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name
NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f"
def test_every_node_type_has_its_own_prefix():
assert kernel_anchor_name("docker", NODE_ID, 0, 0) == "gv00010203e0p0"
assert kernel_anchor_name("qemu", NODE_ID, 1, 2) == "gq00010203e1p2"
assert kernel_anchor_name("iou", NODE_ID, 0, 3) == "gi00010203e0p3"
assert kernel_anchor_name("dynamips", NODE_ID, 6, 48) == "gd00010203e6p48"
def test_unknown_node_types_have_no_anchor():
assert kernel_anchor_name("ethernet_switch", NODE_ID, 0, 0) is None
assert kernel_anchor_name("vpcs", NODE_ID, 0, 0) is None
def test_names_fit_ifnamsiz():
# worst case: WIC port numbers reach 48 with the longest prefix
assert len(kernel_anchor_name("dynamips", NODE_ID, 6, 48)) <= 15
def test_suffix_is_stable_across_the_node_type():
assert anchor_suffix(NODE_ID, 1, 2) == "00010203e1p2"
assert kernel_anchor_name("docker", NODE_ID, 1, 2) == "gv" + anchor_suffix(NODE_ID, 1, 2)