diff --git a/gns3server/compute/base_manager.py b/gns3server/compute/base_manager.py index 35c8be784..8df8703b7 100644 --- a/gns3server/compute/base_manager.py +++ b/gns3server/compute/base_manager.py @@ -384,8 +384,9 @@ class BaseManager: elif nio_settings["type"] == "nio_anchor": # The node-owned-bridge mirror of nio_bridge: the Ethernet # switch joins the named foreign anchor to its own kernel - # bridge (AnchorNIO/NIOAnchor). - nio = NIOAnchor(nio_settings["anchor"]) + # bridge (AnchorNIO/NIOAnchor). A cascade link carries the + # other veth end's name in "peer" on the owning side. + nio = NIOAnchor(nio_settings["anchor"], nio_settings.get("peer")) nio.filters = nio_settings.get("filters", {}) nio.markers = nio_settings.get("markers", {}) nio.suspend = nio_settings.get("suspend", False) diff --git a/gns3server/compute/builtin/nodes/ethernet_switch.py b/gns3server/compute/builtin/nodes/ethernet_switch.py index 63f26153f..48188bde1 100644 --- a/gns3server/compute/builtin/nodes/ethernet_switch.py +++ b/gns3server/compute/builtin/nodes/ethernet_switch.py @@ -80,6 +80,11 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): self._nios = {} self._tap_by_port = {} # port_number -> kernel TAP enslaved to the bridge self._kernel_ports = {} # port_number -> foreign anchor absorbed into the bridge + # port_number -> the other end's name, for the ports that are one end + # of a switch-to-switch cascade veth pair (created when missing, + # destroyed on teardown — symmetrically, from either side). Absent + # for absorbed anchors and relay ports. + self._cascade_peers = {} self._bridge_name = None # kernel bridge interface name (allocated on start) self._bridge_created = False self._bridge_proto_set = False # whether ``brctl setvlanproto`` has been applied @@ -299,6 +304,17 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): for anchor in self._kernel_ports.values(): with contextlib.suppress(UbridgeError): await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{anchor}"') + # Cascade pairs die with the switch: the delif above released + # this end, and deleting it takes the peer end (still enslaved + # on the other side, if that close is still in flight) out of + # its bridge as well — which is where the pair is headed anyway, + # the link is going away with the project. The other switch's + # own delete answers ENOENT, suppressed. + for port_number in self._cascade_peers: + anchor = self._kernel_ports.get(port_number) + if anchor is not None: + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'docker delete_veth "{anchor}"') for tap in self._tap_by_port.values(): with contextlib.suppress(UbridgeError): await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"') @@ -313,6 +329,7 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): self._bridge_name = None self._tap_by_port.clear() self._kernel_ports.clear() + self._cascade_peers.clear() self._started = False await self._stop_ubridge() @@ -433,6 +450,29 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): raise NodeError(f"Port {port_number} doesn't exist on Ethernet switch '{self.name}'") anchor = nio.anchor + if nio.peer is not None: + # Cascade: this port is one end of a link-owned veth pair. Both + # sides create the pair when their end is missing — the two + # uBridge processes may race (the project-open batch even wires + # different nodes concurrently), one create wins and the other's + # failure is re-checked against the kernel: an existing pair + # means the race was lost, anything else is a real failure the + # link must not paper over. A pre-existing end (this switch + # restarted, or a leftover from a previous life of this link id + # — the names are a function of it) is reused as-is: the other + # end may still be a live port of the peer switch, and a stale + # tc qdisc is all a reused end can carry (VLAN entries die with + # the bridge that held them). + self._cascade_peers[port_number] = nio.peer + if not os.path.exists(f"/sys/class/net/{anchor}"): + try: + await self._ubridge_send(f'docker create_veth "{anchor}" "{nio.peer}"') + except UbridgeError: + if not os.path.exists(f"/sys/class/net/{anchor}"): + raise + else: + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'tc reset "{anchor}"') # Register before _kernel_attach: the marker reconcile recognises # kernel anchors through _kernel_anchors(). self._kernel_ports[port_number] = anchor @@ -459,10 +499,15 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): """ Releases an absorbed anchor: detaches the link state (markers, impairments, carrier) and takes the anchor out of the switch bridge. - The anchor itself belongs to the peer and survives. + The anchor itself belongs to the peer and survives. A cascade end + dies with the link instead: deleting one veth end destroys the pair + (and the peer end with it), so either side's teardown converges on + the same gone pair — the other side's delif then answers ENOENT, + suppressed. """ anchor = self._kernel_ports.pop(port_number, None) + peer = self._cascade_peers.pop(port_number, None) if anchor is None: return await self._remove_kernel_markers(anchor) @@ -473,6 +518,9 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode): await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{anchor}"') with contextlib.suppress(UbridgeError): await self._ubridge_send(f'link set "{anchor}" down') + if peer is not None: + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'docker delete_veth "{anchor}"') async def _delete_ubridge_connection(self, port_number): """ diff --git a/gns3server/compute/nios/nio_anchor.py b/gns3server/compute/nios/nio_anchor.py index cecfcc6f8..35174dfe9 100644 --- a/gns3server/compute/nios/nio_anchor.py +++ b/gns3server/compute/nios/nio_anchor.py @@ -40,12 +40,22 @@ class NIOAnchor(NIO): Anchor-absorbing NIO. :param anchor: host interface to join to the node's own bridge + :param peer: for a switch-to-switch cascade link, the *other* end of the + veth pair this port is one end of. Both sides carry it: each creates + the pair when its end is missing (``docker create_veth + ``, the race between the two uBridge processes lost benignly + by whoever's create answers "exists"), and either side's teardown + may destroy it — deleting one veth end takes the whole pair, so the + deletions converge. Absent on absorbed-anchor links, where the + interface belongs to the peer's node and is never this switch's to + create or destroy. """ - def __init__(self, anchor): + def __init__(self, anchor, peer=None): super().__init__() self._anchor = anchor + self._peer = peer @property def anchor(self): @@ -57,6 +67,17 @@ class NIOAnchor(NIO): return self._anchor + @property + def peer(self): + """ + Returns the other end of the cascade veth pair, or None when the + anchor is a foreign interface the switch merely absorbs. + + :returns: interface name or None + """ + + return self._peer + @property def bridge(self): """ @@ -75,10 +96,13 @@ class NIOAnchor(NIO): def asdict(self): - return { + data = { "type": "nio_anchor", "anchor": self._anchor, "suspend": self._suspended, "filters": self._filters, "markers": self._markers, } + if self._peer is not None: + data["peer"] = self._peer + return data diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index bcca97f89..013b7e509 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -207,9 +207,10 @@ class Link: anchors into a per-link Linux bridge (``nio_bridge``), or an Ethernet switch absorbs the peer's anchor into its own kernel bridge (``nio_anchor`` on the switch end, ``nio_bridge`` with no - bridge on the peer end). Impairment filters run as tc netem on the - anchors; markers and capture are served by uBridge's AF_PACKET - module. + bridge on the peer end), or two switches are cascaded through a + veth pair (``nio_anchor`` on both ends). Impairment filters run as + tc netem on the anchors; markers and capture are served by + uBridge's AF_PACKET module. """ return any(d.get("type") in ("nio_bridge", "nio_anchor") for d in (getattr(self, "_link_data", None) or [])) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 4ed74b3e8..cc8e3b356 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -21,7 +21,7 @@ import logging from gns3server.config import Config from gns3server.utils.application_id import is_iol_runner_environment -from gns3server.utils.kernel_anchor import kernel_anchor_name +from gns3server.utils.kernel_anchor import kernel_anchor_name, kernel_cascade_names from gns3server.utils.packet_filter_validation import ( KERNEL_UNSUPPORTED_FILTERS, split_kernel_only_features, @@ -121,9 +121,15 @@ class UDPLink(Link): # Ethernet switch fast path: the link's tc impairments apply on # the absorbed anchor, which is a single interface — exactly one # end may own them, and that end is the switch (it owns the - # anchor's bridge membership and port VLANs). + # anchor's bridge membership and port VLANs). A cascade link is + # two interfaces (one veth end per switch), so it takes the + # normal two-sided treatment like any other kernel link. switch = self._switch_endpoint() - if switch is not None: + both_switches = ( + self._nodes[0]["node"].node_type == "ethernet_switch" + and self._nodes[1]["node"].node_type == "ethernet_switch" + ) + if switch is not None and not both_switches: return (self._filters, {}) if switch[0] == 0 else ({}, self._filters) return self._filters, self._filters # Relay datapath: the netem-extension filters (rate, reorder, gemodel…) @@ -217,14 +223,14 @@ class UDPLink(Link): # Ethernet switch fast path: the switch absorbs the peer's anchor # into its own kernel bridge, so the switch side needs no anchoring # capability of its own (its bridge and brctl already exist) — only - # the peer must be able to anchor. Two switches never qualify: a - # kernel interface belongs to exactly one bridge, so a cascaded - # link would need a veth pair between the two bridges and stays on - # the relay for now. + # the peer must be able to anchor. Two switches cascade instead: a + # veth pair joins the two kernel bridges, each side enslaving its + # own end (a kernel interface belongs to exactly one bridge, so the + # pair is the only shape a bridge-to-bridge cable can take). switch = self._switch_endpoint() if switch is not None: if node1.node_type == "ethernet_switch" and node2.node_type == "ethernet_switch": - return False + return True peer = node2 if switch[0] == 0 else node1 return self._kernel_endpoint_ready(peer) return self._kernel_endpoint_ready(node1) and self._kernel_endpoint_ready(node2) @@ -292,6 +298,47 @@ class UDPLink(Link): node1_markers, node2_markers = self._get_node_markers(node1, node2) switch = self._switch_endpoint() if switch is not None: + if node1.node_type == "ethernet_switch" and node2.node_type == "ethernet_switch": + # Switch-to-switch cascade: one veth pair joins the two + # kernel bridges, each side enslaving its own end into + # its own bridge and applying its own port mode to it — + # the kernel-native shape of a cable between two + # bridges (an interface belongs to exactly one bridge, + # so the pair is the only way to express it). The names + # derive from the link id (utils.kernel_anchor) and both + # sides carry the other end's name: each creates the + # pair when its end is missing (the two uBridge + # processes may race — one create wins, the other's + # failure is re-checked against the kernel), and either + # side's teardown may destroy it (deleting one veth end + # takes the whole pair), so no ordering between the two + # NIO posts is needed. Filters are two-sided (each end + # is its own interface, so each direction is impaired + # exactly once, like any other kernel link — not the + # single-sided absorbed-anchor case). + end0, end1 = kernel_cascade_names(self._id) + self._link_data = [ + { + "type": "nio_anchor", + "anchor": end0, + "peer": end1, + "filters": node1_filters, + "markers": node1_markers, + "suspend": self._suspended, + }, + { + "type": "nio_anchor", + "anchor": end1, + "peer": end0, + "filters": node2_filters, + "markers": node2_markers, + "suspend": self._suspended, + }, + ] + return [ + (node1, adapter_number1, port_number1, self._link_data[0]), + (node2, adapter_number2, port_number2, self._link_data[1]), + ] # Ethernet switch fast path: the switch absorbs the peer's # anchor into its own kernel bridge — no per-link bridge at # all, so the peer end carries no bridge name (its anchor is @@ -430,8 +477,12 @@ class UDPLink(Link): # The two ends are independent once the ports and peer addresses are # known — each node talks to its own compute/uBridge with no shared - # lock between them — so the two POSTs overlap. If either fails, roll - # back whichever side succeeded before re-raising the first error. + # lock between them — so the two POSTs overlap. That includes a + # cascade link: both sides create the veth pair when their end is + # missing (a race one side is designated to lose benignly), so + # neither post depends on the other's completion. If either fails, + # roll back whichever side succeeded before re-raising the first + # error. results = await asyncio.gather( node1.post(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=nio_data1, timeout=120), node2.post(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=nio_data2, timeout=120), @@ -677,19 +728,28 @@ class UDPLink(Link): switch = self._switch_endpoint() if switch is None or not self.kernel_datapath: return - index, switch_node = switch - adapter_number = self._nodes[index]["adapter_number"] - port_number = self._nodes[index]["port_number"] - try: - await switch_node.put( - f"/adapters/{adapter_number}/ports/{port_number}/nio", - data=self._link_data[index], - timeout=120, - ) - except (ComputeError, ControllerError): - # The link keeps its stored NIO; the next node start or link - # update retries. A failure here must not fail the node start. - log.warning("Could not re-push switch NIO for link %s after node start", self.id) + both_switches = ( + self._nodes[0]["node"].node_type == "ethernet_switch" + and self._nodes[1]["node"].node_type == "ethernet_switch" + ) + # A cascade link has a switch on both ends and each end is its own + # veth half: either switch starting (recreating its bridge) can + # leave its own half unjoined, so both ends are re-pushed. + indices = [0, 1] if both_switches else [switch[0]] + for index in indices: + switch_node = self._nodes[index]["node"] + adapter_number = self._nodes[index]["adapter_number"] + port_number = self._nodes[index]["port_number"] + try: + await switch_node.put( + f"/adapters/{adapter_number}/ports/{port_number}/nio", + data=self._link_data[index], + timeout=120, + ) + except (ComputeError, ControllerError): + # The link keeps its stored NIO; the next node start or link + # update retries. A failure here must not fail the node start. + log.warning("Could not re-push switch NIO for link %s after node start", self.id) async def start_marker( self, diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index 842682e7c..d3be033e1 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -120,6 +120,11 @@ class AnchorNIO(BaseModel): type: AnchorNIOType anchor: str = Field(..., description="Host interface to absorb e.g. gv00010203e0p0") + peer: Optional[str] = Field( + None, + description="Switch-to-switch cascade: the other end of the veth pair this port is one end of. Present on " + "exactly one side (the owner, which creates and destroys the pair); absent for an absorbed anchor", + ) suspend: Optional[bool] = Field(None, description="Suspend the NIO") filters: Optional[dict] = Field( None, diff --git a/gns3server/utils/kernel_anchor.py b/gns3server/utils/kernel_anchor.py index 02e380c74..556694097 100644 --- a/gns3server/utils/kernel_anchor.py +++ b/gns3server/utils/kernel_anchor.py @@ -66,3 +66,19 @@ def kernel_anchor_name(node_type, node_id, adapter_number, port_number=0): if prefix is None: return None return prefix + anchor_suffix(node_id, adapter_number, port_number) + + +def kernel_cascade_names(link_id): + """ + The two veth ends of a switch-to-switch cascade link, as + ``(owner end, peer end)``. The pair belongs to the *link* (not to either + switch): both names are a pure function of the link id, so each side + computes its own end and the controller can hand each switch exactly + the name to enslave — the same deterministic-name trick per-link + bridges use. ``gs`` keeps the cascade ends out of the node-anchor and + ``gns3`` name spaces; 10 hex chars of the link id plus the side index + stay within IFNAMSIZ (15). + """ + + stem = str(link_id).replace("-", "")[:10] + return f"gs{stem}0", f"gs{stem}1" diff --git a/tests/api/routes/compute/test_ethernet_switch_nodes.py b/tests/api/routes/compute/test_ethernet_switch_nodes.py index ad5521488..215c1d9ad 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -821,6 +821,174 @@ class TestEthernetSwitchNodesRoutes: node = compute_project.get_node(ethernet_switch["node_id"]) assert node._kernel_ports[3] == anchor + # ------------------------------------------------------------------ # + # switch-to-switch cascade: one veth pair joins the two bridges + # ------------------------------------------------------------------ # + + @staticmethod + def _cascade_params(anchor: str, peer: str) -> dict: + return {"type": "nio_anchor", "anchor": anchor, "peer": peer} + + def _cascade_sysfs(self, anchor: str, exists: dict): + """An ``os.path.exists`` whose answer for *one* interface flips with + the ``exists`` flag — for the create-race test, where the pair comes + into being between the pre-check and the failed create.""" + real = os.path.exists + + def fake(path): + value = str(path) + if value == f"/sys/class/net/{anchor}": + return exists["value"] + if "/brif/" in value: + return True + if value.startswith("/sys/class/net/"): + return True + return real(value) + + return patch("os.path.exists", new=fake) + + async def test_ethernet_switch_create_cascade_nio_creates_the_pair( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + A cascade port is one end of a link-owned veth pair: the switch + creates the pair (uBridge's docker create_veth — the same primitive + every Docker adapter is born from) and enslaves its own end into its + bridge with the port's VLAN mode, exactly like an absorbed anchor. + """ + + anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1" + node = compute_project.get_node(ethernet_switch["node_id"]) + exists = {"value": False} + + async def create_the_pair(command): + if "create_veth" in str(command): + exists["value"] = True + + node._ubridge_send.side_effect = create_the_pair + + 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._cascade_sysfs(anchor, exists): + response = await compute_client.post(url, json=self._cascade_params(anchor, peer)) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["peer"] == peer + + node = compute_project.get_node(ethernet_switch["node_id"]) + br = node._bridge_name + node._ubridge_send.assert_any_call(f'docker create_veth "{anchor}" "{peer}"') + 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') + assert node._kernel_ports[0] == anchor + assert node._cascade_peers[0] == peer + + async def test_ethernet_switch_create_cascade_nio_reuses_an_existing_pair( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + An existing end (this switch restarted, or a leftover from a previous + life of this link id) is reused as-is: the other end may still be a + live port of the peer switch, so the pair is never destroyed here — + a stale tc qdisc is all a reused end can carry, and the reset clears + it before the join. + """ + + anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1" + 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._cascade_sysfs(anchor, {"value": True}): + response = await compute_client.post(url, json=self._cascade_params(anchor, peer)) + assert response.status_code == status.HTTP_201_CREATED + + node = compute_project.get_node(ethernet_switch["node_id"]) + br = node._bridge_name + node._ubridge_send.assert_any_call(f'tc reset "{anchor}"') + node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"') + assert not any("create_veth" in str(c) for c in node._ubridge_send.call_args_list) + + async def test_ethernet_switch_create_cascade_nio_lost_create_race( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + Both cascade sides create the pair when their end is missing, so the + two uBridge processes can race: the loser's create fails with + "exists", the kernel re-check shows the pair is there, and the join + proceeds — a lost race is not an error. + """ + + from gns3server.compute.ubridge.ubridge_error import UbridgeError + + anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1" + node = compute_project.get_node(ethernet_switch["node_id"]) + exists = {"value": False} + + async def lose_the_race(command): + if "create_veth" in str(command): + # the peer switch's create won between our pre-check and now + exists["value"] = True + raise UbridgeError("202-interface already exists") + + node._ubridge_send.side_effect = lose_the_race + + 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._cascade_sysfs(anchor, exists): + response = await compute_client.post(url, json=self._cascade_params(anchor, peer)) + assert response.status_code == status.HTTP_201_CREATED + + br = node._bridge_name + node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"') + assert node._kernel_ports[0] == anchor + + async def test_ethernet_switch_delete_cascade_nio_destroys_the_pair( + self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict + ) -> None: + """ + The cascade pair dies with the link: the teardown detaches the link + state, delifs the end, and destroys the pair (deleting one veth end + takes the peer end with it — either side's teardown converges on the + same gone pair). + """ + + anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1" + 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._cascade_sysfs(anchor, {"value": True}): + await compute_client.post(url, json=self._cascade_params(anchor, peer)) + + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + + 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'brctl delif "{br}" "{anchor}"') + node._ubridge_send.assert_any_call(f'docker delete_veth "{anchor}"') + assert node._kernel_ports == {} + assert node._cascade_peers == {} + # ------------------------------------------------------------------ # # in-place VLAN reconfiguration (no port ever leaves the bridge) # ------------------------------------------------------------------ # diff --git a/tests/controller/test_kernel_datapath_link.py b/tests/controller/test_kernel_datapath_link.py index 9087fe720..834172dde 100644 --- a/tests/controller/test_kernel_datapath_link.py +++ b/tests/controller/test_kernel_datapath_link.py @@ -53,19 +53,30 @@ async def _kernel_link(project, **kwargs): def _mock_compute_http(compute): - """Wire the async compute HTTP callbacks the relay _prepare path needs.""" + """Wire the async compute HTTP callbacks the relay paths need: POST + (port reservation, NIO creation) and PUT (filter updates — without an + awaitable put, a relay filter update blows up on the sync MagicMock, + which is what made this file fail when run alone: some other module's + fixtures happened to mask it in a full run).""" async def subnet_callback(other): return ("192.168.1.1", "192.168.1.2") compute.get_ip_on_same_subnet.side_effect = subnet_callback - async def port_callback(path, data=None, **kwargs): + async def post_callback(path, data=None, **kwargs): response = MagicMock() response.json = {"udp_port": 1024} return response - compute.post.side_effect = port_callback + compute.post.side_effect = post_callback + + async def put_callback(path, data=None, **kwargs): + response = MagicMock() + response.json = {} + return response + + compute.put.side_effect = put_callback async def _relay_link(project): @@ -855,9 +866,10 @@ 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. + (its bridge exists by construction). A switch peer cascades through a + veth pair (one end per bridge — a kernel interface belongs to exactly + one bridge, so the pair is the only cable shape); anchor-less peers + stay on the relay. """ compute = MagicMock() @@ -874,10 +886,10 @@ async def test_switch_links_select_the_kernel_fast_path(project): 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 + # a switch peer: cascade — the veth pair joins the two kernel bridges switch2 = _node(project, compute, "sw2", node_type="ethernet_switch") link = await _link(project, switch, switch2) - assert link._kernel_datapath_eligible(switch, switch2) is False + assert link._kernel_datapath_eligible(switch, switch2) is True # an anchor-less peer vpcs = _node(project, compute, "pc1", node_type="vpcs") @@ -969,3 +981,78 @@ async def test_switch_link_repushes_the_switch_nio_on_node_start(project): relay_switch.put = AsyncioMagicMock() await relay_link.node_started(vpcs) assert not relay_switch.put.called + + +# --------------------------------------------------------------------------- +# Switch-to-switch cascade: one veth pair joins the two kernel bridges +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_prepare_cascade_link_emits_paired_anchor_nios(project): + """ + A link between two Ethernet switches rides the kernel datapath through + one veth pair: each side gets an anchor NIO naming its own end and + carrying the other end's name (both sides create the pair when their + end is missing, so no ordering between the two NIO posts is needed). + Unlike the absorbed-anchor fast path, filters are two-sided — each end + is its own interface, so each direction of the link is impaired exactly + once like any other kernel link. + """ + + from gns3server.utils.kernel_anchor import kernel_cascade_names + + compute = MagicMock() + compute.id = "compute-1" + sw1 = _node(project, compute, "sw1", node_type="ethernet_switch") + sw2 = _node(project, compute, "sw2", node_type="ethernet_switch") + link = await _link(project, sw1, sw2) + link._filters = {"delay": [10]} + + entries = await link._prepare() + end0, end1 = kernel_cascade_names(link.id) + + assert entries[0][3] == { + "type": "nio_anchor", + "anchor": end0, + "peer": end1, + "filters": {"delay": [10]}, + "markers": {}, + "suspend": False, + } + assert entries[1][3] == { + "type": "nio_anchor", + "anchor": end1, + "peer": end0, + "filters": {"delay": [10]}, + "markers": {}, + "suspend": False, + } + assert link.kernel_datapath is True + + +@pytest.mark.asyncio +async def test_node_started_repushes_both_cascade_ends(project): + """ + A cascade link has a switch on both ends and each end is its own veth + half: whichever switch starts (recreating its bridge and losing its + half's membership), both ends are re-pushed — each switch's update + re-checks and re-joins its own half. + """ + + from tests.utils import AsyncioMagicMock + + compute = MagicMock() + compute.id = "compute-1" + sw1 = _node(project, compute, "sw1", node_type="ethernet_switch") + sw2 = _node(project, compute, "sw2", node_type="ethernet_switch") + link = await _link(project, sw1, sw2) + entries = await link._prepare() + link._created = True + sw1.put = AsyncioMagicMock() + sw2.put = AsyncioMagicMock() + + await link.node_started(sw1) + + sw1.put.assert_called_once_with("/adapters/0/ports/0/nio", data=entries[0][3], timeout=120) + sw2.put.assert_called_once_with("/adapters/0/ports/0/nio", data=entries[1][3], timeout=120) diff --git a/tests/utils/test_kernel_anchor.py b/tests/utils/test_kernel_anchor.py index e5d091ec2..7b88ffd9c 100644 --- a/tests/utils/test_kernel_anchor.py +++ b/tests/utils/test_kernel_anchor.py @@ -17,12 +17,14 @@ """ 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). +Ethernet-switch link), plus the cascade veth naming for switch-to-switch +links. """ -from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name +from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name, kernel_cascade_names NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f" +LINK_ID = "1a2b3c4d-5e6f-4a5b-8c9d-0e1f2a3b4c5d" def test_every_node_type_has_its_own_prefix(): @@ -45,3 +47,22 @@ def test_names_fit_ifnamsiz(): 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) + + +def test_cascade_names_are_a_pair_of_distinct_sides(): + end0, end1 = kernel_cascade_names(LINK_ID) + assert end0 == "gs1a2b3c4d5e0" + assert end1 == "gs1a2b3c4d5e1" + assert end0 != end1 + + +def test_cascade_names_fit_ifnamsiz(): + for end in kernel_cascade_names(LINK_ID): + assert len(end) <= 15 + + +def test_cascade_names_stay_out_of_the_node_anchor_namespace(): + # "gs" cannot collide with any node anchor prefix (gv/gq/gi/gd), so a + # cascade end is never mistaken for another node's anchor + for end in kernel_cascade_names(LINK_ID): + assert not any(end.startswith(prefix) for prefix in ("gv", "gq", "gi", "gd"))