mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-06 07:11:13 +02:00
feat(esw): cascade two switches through a veth pair
Switch-to-switch links were the last shape left on the relay: the switch fast path works by absorbing the *peer's* anchor, and a switch has none — its ports are its own bridge, and a kernel interface belongs to exactly one bridge, so two bridges cannot be joined by enslaving anything. Join them with a veth pair instead: one end per bridge, each side enslaving its own end and applying its own port mode to it — an access port on one side and a trunk on the other behaves like the real cable it models. uBridge's docker create_veth/delete_veth (the primitive every Docker adapter is already born from) creates and destroys the pair; the names derive from the link id (utils.kernel_anchor.kernel_cascade_names), so each side computes its own end and the names survive a restart. The pair belongs to the link, and the two ends are wired by two independent uBridge processes — the project-open batch drives different nodes concurrently — so the protocol is order-free: both sides create the pair when their own end is missing (one create wins; the loser's failure is re-checked against the kernel and "the pair is there" is not an error), and either side's teardown destroys it (deleting one veth end takes the other with it, so both converge on the same gone pair and the second delif answers ENOENT). A pre-existing end — this switch restarted, or a leftover from a previous life of the link id — is reused rather than recreated, because the other end may still be a live port of the peer switch; a stale tc qdisc is all a reused end can carry. No new wire format was needed: a cascade is an anchor NIO on both ends, the absorbed-anchor case plus the peer end's name. That also corrects the filter routing — the single-sided rule exists because an absorbed anchor is one interface, while a cascade has one per switch, so impairments go to both ends like any other kernel link. Tests: the wire format, the eligibility flip and the both-ends re-push on node start (controller); the naming contract (utils); the switch-side lifecycle — create, reuse, lost create race, delete destroys the pair (compute routes). Drive-by: the relay filter test's compute mock carried no awaitable `put`, so tests/controller/test_kernel_datapath_link.py failed when run alone while an argument-less full run passed; callers now get one.
This commit is contained in:
10 files changed
+472
-41
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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 <anchor>
|
||||
<peer>``, 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
|
||||
Reference in new issue
Block a user