mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 13:35:31 +03: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:
parent
caa054986f
commit
90bcb32473
@ -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
|
||||
|
||||
@ -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 []))
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user