YueGuobin 90bcb32473
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.
2026-10-06 00:21:09 +08:00

109 lines
3.6 KiB
Python

#
# 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/>.
#
"""
Interface for anchor-absorbing NIOs (the Ethernet switch's kernel datapath).
A NIO of this type instructs the node to join a *foreign* host interface —
another node's kernel-datapath anchor — into a bridge the node itself owns,
with the node's port settings applied to it. The Ethernet switch is the
consumer: its per-node kernel bridge absorbs the peer's anchor directly, so
frames cross peer anchor -> switch bridge -> switch ports entirely in the
kernel instead of riding the switch's per-port uBridge relay.
This is the mirror of NIOBridge: NIOBridge says "enslave *my* anchor into
the bridge named here", NIOAnchor says "enslave *this* interface into my
own bridge". Impairment filters, markers and capture ride the NIO and
attach to the named anchor (tc on it, marker add_kernel / capture
start_kernel on it) — exactly one link end owns them.
"""
from .nio import NIO
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, peer=None):
super().__init__()
self._anchor = anchor
self._peer = peer
@property
def anchor(self):
"""
Returns the interface name to absorb.
:returns: interface name
"""
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):
"""
Always None: this NIO owns no per-link bridge — the absorbed anchor
joins the node's *own* bridge. The None makes the shared
KernelDatapathMixin attach/remove flows skip the per-link bridge
membership work (their external-bridge case), which is exactly what
the switch needs on top of its own addif.
"""
return None
def __str__(self):
return "NIO anchor"
def asdict(self):
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