mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +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
@@ -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)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
Reference in new issue
Block a user