mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 21:45:41 +03:00
feat(esw): wire peer anchors straight into the switch bridge
The Ethernet switch's kernel fast path: instead of a link crossing the
switch's per-port uBridge relay, the switch absorbs the peer's anchor into
its own kernel bridge. The peer's interface (Docker veth host end, QEMU
TAP, IOU port TAP, Dynamips port TAP) becomes the switch port, and frames
cross anchor -> switch bridge -> switch ports entirely in the kernel.
Wire format: the switch end receives nio_anchor ('join this interface to
my own bridge' — the mirror of nio_bridge), the peer end nio_bridge with a
null bridge ('your anchor is bridged elsewhere': carrier, markers and
impairments only — never bridge membership, and never a bridge delete, on
an interface this node does not own). Anchor names come from the shared
naming contract (utils.kernel_anchor), a pure function of node type, id,
adapter and port — the controller computes the peer's anchor while the
node may be stopped, which is what makes links to stopped nodes wireable.
Stopped peers defer the join; after any node of a link starts
(UDPLink.node_started) the controller re-pushes the switch NIO, and the
switch completes — or repairs, when a peer restart replaced its interface
under the same name — the join against the kernel's actual membership
instead of assuming it.
VLAN changes now edit membership in place: only the ports whose mode/VLAN
actually changed are touched, each losing exactly the previous mode's
entries the new mode does not want (the dot1q admits-all entry is one
range operation in both directions), and no port ever leaves the bridge to
change its VLANs. This replaces the old 'reset by re-enslaving' shortcut
(delif+addif on every wired port, added when the ports were the switch's
own TAPs): on absorbed anchors, re-enslaving an interface owned by another
node is where live testing watched links die — anchors occasionally lost
their bridge membership right after a ports_mapping update (later updates
then failed with delif EINVAL), reproducibly with qemu, IOU and Dynamips
peers and never with Docker's veths.
Eligibility: the switch side needs no capability of its own (its bridge
exists by construction); the peer must be able to anchor. Switch-to-switch
cascades stay on the relay — a kernel interface belongs to one bridge, and
joining two bridges needs a veth pair, deferred.
Verified live on a real server: qemu peers through four VLAN cycles with
both anchors enslaved throughout; a mixed switch (2 IOU + 2 Dynamips, four
kernel ports); Docker peers with real ICMP isolating and recovering (with
both anchors still enslaved); VPCS relay ports likewise in place.
This commit is contained in:
parent
3910a6b585
commit
c343ce0b8f
@ -149,7 +149,7 @@ enable_kernel_datapath = True
|
||||
|
||||
## Roadmap
|
||||
|
||||
The Ethernet switch is next (its ports are already uBridge-owned TAPs; the
|
||||
kernel link should attach the peer's anchor straight into the switch's
|
||||
per-node bridge), then the IOL Docker node. Cross-compute kernel links need
|
||||
The Ethernet switch has landed (see `ethernet-switch-kernel-datapath.md` —
|
||||
it absorbs the peer's anchor into its own kernel bridge; switch-to-switch
|
||||
cascades and the IOL Docker node are next). Cross-compute kernel links need
|
||||
VXLAN/GENEVE encapsulation — deferred until every node type is kernelized.
|
||||
|
||||
158
docs/features/ethernet-switch-kernel-datapath.md
Normal file
158
docs/features/ethernet-switch-kernel-datapath.md
Normal file
@ -0,0 +1,158 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
# Ethernet switch kernel datapath
|
||||
|
||||
The builtin Ethernet switch has been a real Linux kernel bridge since its
|
||||
brctl migration: one bridge per switch node, every port a persistent TAP
|
||||
enslaved to it (see `ethernet-switch-ubridge-brctl-migration` design notes
|
||||
in `gns3server/compute/builtin/nodes/ethernet_switch.py`). What remained on
|
||||
the userspace path was the *cable*: each port's TAP doubled as a uBridge
|
||||
relay endpoint, and a link to another node crossed
|
||||
`peer anchor/UDP tunnel → uBridge relay ↔ port TAP → bridge` — two extra
|
||||
copies of every frame.
|
||||
|
||||
The kernel datapath removes the cable's userspace leg: the switch absorbs
|
||||
the **peer's anchor directly into its own bridge**. The peer's veth host
|
||||
end / TAP becomes the switch port; frames cross
|
||||
`peer anchor → switch bridge → other switch ports` entirely in the kernel.
|
||||
|
||||
```
|
||||
relay (before): peer ──UDP── ubridge[nio_udp↔nio_tap] ──tap── kernel bridge
|
||||
kernel (now): peer anchor ──────────────────────────► kernel bridge
|
||||
(the peer's own interface IS the switch port)
|
||||
```
|
||||
|
||||
## Datapath selection
|
||||
|
||||
A link with an `ethernet_switch` endpoint rides the fast path when the
|
||||
**peer** can anchor (same compute; Docker always, QEMU/IOU/Dynamips per
|
||||
their capabilities) — the switch side needs no anchoring capability of its
|
||||
own, its bridge and brctl exist by construction. Exclusions, all staying
|
||||
on the relay:
|
||||
|
||||
* **switch ↔ switch**: a kernel interface can belong to exactly one
|
||||
bridge, so cascading two switch bridges needs a veth pair between them —
|
||||
a separate mechanism, deferred;
|
||||
* anchor-less peers (VPCS, cloud, the Dynamips-hosted hub);
|
||||
* cross-compute links, non-Ethernet ports: the common rules.
|
||||
|
||||
## The wire format: one NIO names the other end's interface
|
||||
|
||||
```
|
||||
controller ──► switch compute: nio_anchor { anchor: "gv00010203e0p0", filters, markers }
|
||||
controller ──► peer compute: nio_bridge { bridge: null, filters: {}, markers }
|
||||
```
|
||||
|
||||
* `nio_anchor` (new, `NIOAnchor`) is the mirror of `nio_bridge`: "enslave
|
||||
*this* interface into *my own* bridge". The switch joins the anchor and
|
||||
applies the port's VLAN mode to it (`brctl addif` + the access/dot1q/
|
||||
qinq composition), exactly as it programs its own port TAPs.
|
||||
* `nio_bridge` with `bridge: null` on the peer end means "externally
|
||||
bridged": the peer keeps everything its node type does around the anchor
|
||||
(Dynamips opens the TAP in the hypervisor, IOU binds the fabric to it,
|
||||
QEMU's netdev, Docker's carrier) and applies markers/impairments, but
|
||||
never touches bridge membership — including on detach, where deleting a
|
||||
bridge it does not own would take the switch down.
|
||||
|
||||
The anchor's **name** is the shared naming contract
|
||||
(`gns3server/utils/kernel_anchor.py`): `g<type>{node_id[:8]}e{adapter}p{port}`,
|
||||
the same function the four node types create their anchors with. It is a
|
||||
pure function of (node type, node id, adapter, port) — valid whether or
|
||||
not the peer is running, which is what makes links to stopped nodes
|
||||
wireable at all.
|
||||
|
||||
**Ownership.** The switch owns everything about the anchor's bridge state
|
||||
(membership, VLANs). The link's *link state* — capture, markers, tc
|
||||
impairments, suspend — is attached to the same interface by exactly one
|
||||
end: filters and capture go to the switch (the controller assigns them
|
||||
single-sided; both ends would hit one interface twice), suspend rides both
|
||||
(same admin state either way), markers follow the usual capture-node
|
||||
routing and work from either end.
|
||||
|
||||
## Stopped peers: deferred join and the re-push
|
||||
|
||||
The peer's anchor exists only while the peer runs. A link created against
|
||||
a stopped peer cannot be joined immediately, so the switch **defers**: it
|
||||
remembers the anchor and touches nothing (the link must not fail — this is
|
||||
the normal case when a project reopens while nodes are still starting).
|
||||
|
||||
The controller completes the wiring: after any node of a link reaches the
|
||||
started state (`Node.start` → `UDPLink.node_started`), a switch link's NIO
|
||||
is re-pushed to the switch, which joins the anchor now. The same re-join
|
||||
runs when a peer restart *replaced* its interface under the same name
|
||||
(QEMU recreates its TAPs at every start): the membership is checked against
|
||||
the kernel (`/sys/class/net/<bridge>/brif/<anchor>`), not assumed.
|
||||
|
||||
Port setting changes (`ports_mapping` update) edit VLAN membership **in
|
||||
place**: only the ports whose mode/VLAN actually changed are touched, and
|
||||
each change deletes the previous mode's entries the new mode does not want
|
||||
and adds the new mode's (the dot1q admits-all entry is one range operation
|
||||
in both directions). A port never leaves the bridge to change its VLANs —
|
||||
no traffic gap, no FDB flush, and nothing that detaches an interface this
|
||||
switch does not own.
|
||||
|
||||
This replaced an earlier "reset by re-enslaving" shortcut (`delif` +
|
||||
`addif`, so the kernel would hand the port a fresh default membership —
|
||||
used because a mode change must drop stale entries, e.g. dot1q's all-VIDs
|
||||
range). On the switch's own relay TAPs it merely cost a brief traffic gap;
|
||||
on **absorbed anchors** re-enslaving an interface owned by another node
|
||||
proved to be where links died: live testing showed an anchor occasionally
|
||||
losing its bridge membership right after a `ports_mapping` update (later
|
||||
updates then failed `delif` with EINVAL), reproducibly with qemu, IOU and
|
||||
Dynamips peers, never with Docker's veths. Replaying the exact command
|
||||
sequence by hand against uBridge (held TAPs, no server involved) did not
|
||||
reproduce it, and the in-place reconcile removes the operation entirely —
|
||||
the failure mode no longer exists by construction.
|
||||
|
||||
## Verified
|
||||
|
||||
Unit level (all under `tests/`): the switch's kernel-port lifecycle (join
|
||||
with VLAN composition, deferred join, re-join, detach with marker/tc/
|
||||
carrier teardown, capture on the anchor, batch acceptance for project
|
||||
open); the in-place VLAN reconcile (every mode transition, untouched ports
|
||||
emit nothing, relay TAPs and absorbed anchors alike); the shared naming
|
||||
contract; the mixin's externally-bridged branch (no bridge membership work
|
||||
in attach/remove, the bridge sweep never deletes a bridge the node does not
|
||||
own); the controller's eligibility table, wire format (anchor name,
|
||||
single-sided filters, endpoint-order alignment), `kernel_datapath`
|
||||
reporting and the node-start re-push.
|
||||
|
||||
Live end-to-end on a real server (`tests/e2e/test_ethernet_switch_kernel_datapath.py`
|
||||
plus scripted reproduction rounds over the REST API):
|
||||
|
||||
* two qemu peers (the previously 2-out-of-2 failing scenario): deferred
|
||||
links → start → both anchors joined; four `ports_mapping` cycles
|
||||
(10/20 ↔ 10/10) with both anchors in the bridge throughout, no failed
|
||||
update, kernel VLAN state correct at every step;
|
||||
* a mixed switch (2 × IOU + 2 × Dynamips on one bridge, four kernel
|
||||
ports): four anchors stable across updates that changed all ports and
|
||||
across updates that changed only two;
|
||||
* Docker peers with real ICMP: isolating VLANs cuts the ping with **both
|
||||
anchors still enslaved** (the filter does the cutting, not a broken
|
||||
link); restoring the VLAN restores the ping;
|
||||
* VPCS peers (the relay path, same switch-side mechanics as cloud): the
|
||||
relay TAPs likewise stay enslaved across VLAN changes, with a real ping
|
||||
proving isolation and recovery.
|
||||
|
||||
The host-side checks throughout are the kernel's own view: bridge
|
||||
membership under `/sys/class/net/<bridge>/brif/`, the `master` link
|
||||
attribute, `bridge vlan show`, and live pings where the peer allows it.
|
||||
|
||||
## Configuration
|
||||
|
||||
Same switch as every node type:
|
||||
|
||||
```ini
|
||||
[Server]
|
||||
enable_kernel_datapath = True
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
Switch-to-switch links (a veth pair joining the two kernel bridges, with
|
||||
each side's port mode applied at its own end) and the IOL Docker node
|
||||
(`bridge add_nio_tap`); cross-compute kernel links need VXLAN/GENEVE
|
||||
encapsulation, deferred until every node type is kernelized.
|
||||
@ -170,9 +170,10 @@ enable_kernel_datapath = True
|
||||
## Roadmap
|
||||
|
||||
Dynamips has landed (see `dynamips-kernel-datapath.md` — the hypervisor
|
||||
opens uBridge-created TAPs, the same shape QEMU uses). Next: the Ethernet
|
||||
switch (its ports are already uBridge-owned TAPs; the kernel link should
|
||||
attach the peer's anchor straight into the switch's per-node bridge), then
|
||||
the IOL Docker node (same `bridge add_nio_tap` shape as this one).
|
||||
Cross-compute kernel links need VXLAN/GENEVE encapsulation — deferred
|
||||
until every node type is kernelized.
|
||||
opens uBridge-created TAPs, the same shape QEMU uses), and so has the
|
||||
Ethernet switch (see `ethernet-switch-kernel-datapath.md` — it absorbs the
|
||||
peer's anchor into its own kernel bridge). What remains: switch-to-switch
|
||||
cascades (a veth pair between the two kernel bridges) and the IOL Docker
|
||||
node (same `bridge add_nio_tap` shape as this one). Cross-compute kernel
|
||||
links need VXLAN/GENEVE encapsulation — deferred until every node type is
|
||||
kernelized.
|
||||
|
||||
@ -23,7 +23,7 @@ through uBridge's ``brctl`` module (see
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from typing import Any, Union
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, status
|
||||
@ -103,16 +103,20 @@ async def update_ethernet_switch(
|
||||
Update an Ethernet switch.
|
||||
"""
|
||||
|
||||
data = jsonable_encoder(node_data, exclude_unset=True)
|
||||
if "name" in data and node.name != data["name"]:
|
||||
node.name = data["name"]
|
||||
if "usage" in data:
|
||||
node.usage = data["usage"]
|
||||
if "ports_mapping" in data:
|
||||
node.ports_mapping = data["ports_mapping"]
|
||||
await node.update_port_settings()
|
||||
if "console_type" in data:
|
||||
node.console_type = data["console_type"]
|
||||
node_data = jsonable_encoder(node_data, exclude_unset=True)
|
||||
if "name" in node_data and node.name != node_data["name"]:
|
||||
node.name = node_data["name"]
|
||||
if "usage" in node_data:
|
||||
node.usage = node_data["usage"]
|
||||
if "ports_mapping" in node_data:
|
||||
# capture the mapping before the setter replaces it: the VLAN
|
||||
# reconcile diffs old against new to touch only what changed, in
|
||||
# place (the setter itself keeps the port-count guard)
|
||||
previous_mapping = [dict(port) for port in node.ports_mapping]
|
||||
node.ports_mapping = node_data["ports_mapping"]
|
||||
await node.update_port_settings(previous_mapping)
|
||||
if "console_type" in node_data:
|
||||
node.console_type = node_data["console_type"]
|
||||
node.updated()
|
||||
return node.asdict()
|
||||
|
||||
@ -174,15 +178,20 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
|
||||
@router.post(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model=schemas.UDPNIO,
|
||||
response_model=Union[schemas.UDPNIO, schemas.AnchorNIO],
|
||||
)
|
||||
async def create_ethernet_switch_nio(
|
||||
*,
|
||||
adapter_number: int = Path(..., ge=0, le=0),
|
||||
port_number: int,
|
||||
nio_data: schemas.UDPNIO,
|
||||
nio_data: Union[schemas.UDPNIO, schemas.AnchorNIO],
|
||||
node: EthernetSwitch = Depends(dep_node),
|
||||
) -> schemas.UDPNIO:
|
||||
) -> Union[schemas.UDPNIO, schemas.AnchorNIO]:
|
||||
"""
|
||||
Add a NIO (Network Input/Output) to the node: a UDP NIO wires the relay
|
||||
datapath, an anchor NIO absorbs the peer's kernel anchor into this
|
||||
switch's kernel bridge. The adapter number on the switch is always 0.
|
||||
"""
|
||||
|
||||
nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
|
||||
await node.add_nio(nio, port_number)
|
||||
@ -192,19 +201,21 @@ async def create_ethernet_switch_nio(
|
||||
@router.put(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model=schemas.UDPNIO,
|
||||
response_model=Union[schemas.UDPNIO, schemas.AnchorNIO],
|
||||
)
|
||||
async def update_ethernet_switch_nio(
|
||||
*,
|
||||
adapter_number: int = Path(..., ge=0, le=0),
|
||||
port_number: int,
|
||||
nio_data: schemas.UDPNIO,
|
||||
nio_data: Union[schemas.UDPNIO, schemas.AnchorNIO],
|
||||
node: EthernetSwitch = Depends(dep_node),
|
||||
) -> schemas.UDPNIO:
|
||||
) -> Union[schemas.UDPNIO, schemas.AnchorNIO]:
|
||||
"""
|
||||
Update a NIO (Network Input/Output) on the node: re-apply the packet
|
||||
filters and traffic-insight markers carried by the NIO onto the port's
|
||||
uBridge relay. The adapter number on the switch is always 0.
|
||||
filters, traffic-insight markers and suspend state carried by the NIO —
|
||||
on the port's uBridge relay for a relay port, on the absorbed anchor
|
||||
(tc + kernel markers + carrier) for a kernel port. The adapter number
|
||||
on the switch is always 0.
|
||||
"""
|
||||
|
||||
nio = node.get_nio(port_number)
|
||||
@ -212,6 +223,10 @@ async def update_ethernet_switch_nio(
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
nio.markers = nio_data.markers or {}
|
||||
# Suspend is what the compute turns into an admin-down anchor on a
|
||||
# kernel link (native carrier), so it must reach the NIO like it does
|
||||
# on the other node routes.
|
||||
nio.suspend = getattr(nio_data, "suspend", None) or False
|
||||
await node.update_nio(port_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
|
||||
@ -41,6 +41,7 @@ from ..utils import force_unix_path
|
||||
from ..utils.images import default_images_directory, images_directories, list_images, md5sum, remove_checksum
|
||||
from .base_node import BaseNode
|
||||
from .error import ImageMissingError, NodeError
|
||||
from .nios.nio_anchor import NIOAnchor
|
||||
from .nios.nio_bridge import NIOBridge
|
||||
from .nios.nio_ethernet import NIOEthernet
|
||||
from .nios.nio_tap import NIOTAP
|
||||
@ -380,6 +381,14 @@ class BaseManager:
|
||||
# uBridge's AF_PACKET marker module (marker add_kernel).
|
||||
nio.markers = nio_settings.get("markers", {})
|
||||
nio.suspend = nio_settings.get("suspend", False)
|
||||
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"])
|
||||
nio.filters = nio_settings.get("filters", {})
|
||||
nio.markers = nio_settings.get("markers", {})
|
||||
nio.suspend = nio_settings.get("suspend", False)
|
||||
elif nio_settings["type"] == "nio_tap":
|
||||
tap_device = nio_settings["tap_device"]
|
||||
# if not is_interface_up(tap_device):
|
||||
|
||||
@ -37,12 +37,16 @@ be the switch). ESW ``access``/``dot1q``/``qinq`` port modes are composed from
|
||||
the ``brctl`` VLAN primitives here -- see ``_apply_port_vlan``.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
from gns3server.compute.ubridge.ubridge_error import UbridgeError
|
||||
|
||||
from ...base_node import BaseNode
|
||||
from ...error import NodeError
|
||||
from ...kernel_datapath import KernelDatapathMixin
|
||||
from ...nios.nio_anchor import NIOAnchor
|
||||
from ...nios.nio_udp import NIOUDP
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@ -55,7 +59,7 @@ _SUPPORTED_VLAN_ETHERTYPE = {"0x8100", "0x88a8"}
|
||||
_QINQ_ETHERTYPE = "0x88a8"
|
||||
|
||||
|
||||
class EthernetSwitch(BaseNode):
|
||||
class EthernetSwitch(KernelDatapathMixin, BaseNode):
|
||||
"""
|
||||
Ethernet switch.
|
||||
|
||||
@ -75,9 +79,11 @@ class EthernetSwitch(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
|
||||
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
|
||||
self._ubridge_tc_caps = None # probed once per uBridge process (mixin)
|
||||
# Idempotency flag for start(). Decoupled from ``status`` so the node can
|
||||
# report "started" (always-on, like the ESW) while ``duplicate_node`` still
|
||||
# sees status "stopped" and refuses only genuinely running stateful nodes.
|
||||
@ -195,11 +201,16 @@ class EthernetSwitch(BaseNode):
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._stop_ubridge()
|
||||
await self._start_ubridge(self._ubridge_require_privileged_access)
|
||||
# A fresh uBridge process: the tc capabilities must be probed again.
|
||||
self._ubridge_tc_caps = None
|
||||
await self._ensure_bridge()
|
||||
for port_number in self._nios:
|
||||
if self._nios[port_number]:
|
||||
try:
|
||||
await self._add_ubridge_connection(self._nios[port_number], port_number)
|
||||
if isinstance(self._nios[port_number], NIOAnchor):
|
||||
await self._add_kernel_port(self._nios[port_number], port_number)
|
||||
else:
|
||||
await self._add_ubridge_connection(self._nios[port_number], port_number)
|
||||
except (UbridgeError, NodeError) as e:
|
||||
self._started = False
|
||||
raise e
|
||||
@ -275,11 +286,24 @@ class EthernetSwitch(BaseNode):
|
||||
if nio and isinstance(nio, NIOUDP):
|
||||
self.manager.port_manager.release_udp_port(nio.lport, self._project)
|
||||
self._nios.clear()
|
||||
self._tap_by_port.clear()
|
||||
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created:
|
||||
# Project close runs every node's close concurrently: a peer's
|
||||
# anchor may still be enslaved at this instant (its node's close
|
||||
# is in flight), which would make the bridge delete fail EBUSY
|
||||
# and leak an empty bridge. Detach every member we know first —
|
||||
# a peer anchor that is already gone answers ENOENT (suppressed;
|
||||
# the kernel removed it with the interface), and our own TAPs go
|
||||
# with the relay teardown — so the delete always sees an empty
|
||||
# bridge. The foreign anchors themselves are not ours to delete.
|
||||
for anchor in self._kernel_ports.values():
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{anchor}"')
|
||||
for tap in self._tap_by_port.values():
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
|
||||
try:
|
||||
# Deleting the bridge releases its enslaved TAPs; uBridge destroys
|
||||
# Deleting the bridge releases any enslaved TAPs; uBridge destroys
|
||||
# them when it stops below.
|
||||
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
|
||||
except UbridgeError as e:
|
||||
@ -287,6 +311,8 @@ class EthernetSwitch(BaseNode):
|
||||
self._bridge_created = False
|
||||
self._bridge_proto_set = False
|
||||
self._bridge_name = None
|
||||
self._tap_by_port.clear()
|
||||
self._kernel_ports.clear()
|
||||
self._started = False
|
||||
|
||||
await self._stop_ubridge()
|
||||
@ -301,19 +327,27 @@ class EthernetSwitch(BaseNode):
|
||||
"""
|
||||
Adds a NIO as a new port on this switch.
|
||||
|
||||
Two kinds are accepted: a UDP NIO (relay datapath — the per-port
|
||||
uBridge relay ends in a bridge TAP) and the kernel datapath's
|
||||
NIOAnchor (the peer's anchor is absorbed straight into this switch's
|
||||
kernel bridge).
|
||||
|
||||
:param nio: NIO instance to add
|
||||
:param port_number: port to allocate for the NIO
|
||||
"""
|
||||
|
||||
if port_number in self._nios:
|
||||
raise NodeError(f"Port {port_number} isn't free")
|
||||
if not isinstance(nio, NIOUDP):
|
||||
raise NodeError("Ethernet switch ports only support UDP NIOs")
|
||||
if not isinstance(nio, (NIOUDP, NIOAnchor)):
|
||||
raise NodeError("Ethernet switch ports only support UDP NIOs and anchor NIOs")
|
||||
|
||||
log.debug(f'Ethernet switch "{self._name}" [{self._id}]: NIO {nio} bound to port {port_number}')
|
||||
try:
|
||||
await self.start()
|
||||
await self._add_ubridge_connection(nio, port_number)
|
||||
if isinstance(nio, NIOAnchor):
|
||||
await self._add_kernel_port(nio, port_number)
|
||||
else:
|
||||
await self._add_ubridge_connection(nio, port_number)
|
||||
self._nios[port_number] = nio
|
||||
except (NodeError, UbridgeError) as e:
|
||||
log.error(f'Cannot add NIO on Ethernet switch "{self._name}": {e}')
|
||||
@ -351,6 +385,95 @@ class EthernetSwitch(BaseNode):
|
||||
await self._ubridge_send(f"bridge start {ubridge_bridge}")
|
||||
self._tap_by_port[port_number] = tap
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# kernel datapath: absorb the peer's anchor (fast path)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _kernel_host_ifc(self, adapter_number, port_number=0):
|
||||
"""
|
||||
The foreign anchor absorbed on a port, or None for a relay port. The
|
||||
switch has no adapters, so *adapter_number* is always 0.
|
||||
"""
|
||||
|
||||
return self._kernel_ports.get(port_number)
|
||||
|
||||
def _kernel_anchors(self):
|
||||
"""
|
||||
Every absorbed anchor — the interfaces this switch bridges and on
|
||||
which kernel markers may attach (the mixin's marker polymorphism).
|
||||
"""
|
||||
|
||||
return set(self._kernel_ports.values())
|
||||
|
||||
def _kernel_error(self, message):
|
||||
return NodeError(message)
|
||||
|
||||
async def _add_kernel_port(self, nio, port_number):
|
||||
"""
|
||||
Absorbs the peer's anchor for one port: joins the anchor straight to
|
||||
this switch's kernel bridge and applies the port's VLAN mode — no
|
||||
relay, no per-port TAP, the peer anchor *is* the port.
|
||||
|
||||
The anchor only exists on the host while the peer is running (its
|
||||
compute creates it at node start), so a link created against a
|
||||
stopped peer defers the join: the port remembers the anchor and
|
||||
``update_nio`` completes the wiring when the controller re-pushes
|
||||
the NIO after the peer starts (UdpLink.node_started). A peer
|
||||
restart can also replace the interface under the same name, so the
|
||||
membership is re-checked rather than assumed.
|
||||
|
||||
The link state the anchored end carries (capture, markers, tc
|
||||
impairments) is applied by the shared mixin flow: NIOAnchor reports
|
||||
no per-link bridge, so only the link state is applied here, while
|
||||
the bridge membership above is this switch's own.
|
||||
"""
|
||||
|
||||
port_settings = self._port_settings(port_number)
|
||||
if port_settings is None:
|
||||
raise NodeError(f"Port {port_number} doesn't exist on Ethernet switch '{self.name}'")
|
||||
|
||||
anchor = nio.anchor
|
||||
# Register before _kernel_attach: the marker reconcile recognises
|
||||
# kernel anchors through _kernel_anchors().
|
||||
self._kernel_ports[port_number] = anchor
|
||||
if not os.path.exists(f"/sys/class/net/{anchor}"):
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: anchor {anchor} for port {port} does not exist yet '
|
||||
"(peer not started); join deferred".format(
|
||||
name=self._name, id=self._id, anchor=anchor, port=port_number
|
||||
)
|
||||
)
|
||||
return
|
||||
try:
|
||||
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{anchor}"')
|
||||
await self._apply_port_vlan(port_settings, anchor)
|
||||
await self._kernel_attach(anchor, nio)
|
||||
await self._set_adapter_carrier(0, not nio.suspend, port_number)
|
||||
except (NodeError, UbridgeError):
|
||||
self._kernel_ports.pop(port_number, None)
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{anchor}"')
|
||||
raise
|
||||
|
||||
async def _remove_kernel_port(self, port_number):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
anchor = self._kernel_ports.pop(port_number, None)
|
||||
if anchor is None:
|
||||
return
|
||||
await self._remove_kernel_markers(anchor)
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'tc reset "{anchor}"')
|
||||
if self._bridge_created:
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{anchor}"')
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'link set "{anchor}" down')
|
||||
|
||||
async def _delete_ubridge_connection(self, port_number):
|
||||
"""
|
||||
Tears down one port's wiring: release the TAP from the bridge and delete
|
||||
@ -388,7 +511,10 @@ class EthernetSwitch(BaseNode):
|
||||
log.debug(f'Ethernet switch "{self._name}" [{self._id}]: NIO {nio} removed from port {port_number}')
|
||||
del self._nios[port_number]
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._delete_ubridge_connection(port_number)
|
||||
if isinstance(nio, NIOAnchor):
|
||||
await self._remove_kernel_port(port_number)
|
||||
else:
|
||||
await self._delete_ubridge_connection(port_number)
|
||||
return nio
|
||||
|
||||
def get_nio(self, port_number):
|
||||
@ -405,32 +531,85 @@ class EthernetSwitch(BaseNode):
|
||||
|
||||
async def update_nio(self, port_number, nio):
|
||||
"""
|
||||
Re-applies uBridge filters/markers for a port (called when a link is updated).
|
||||
Re-applies filters/markers (and carrier) for a port when a link is
|
||||
updated: on the per-port uBridge relay for a relay port, on the
|
||||
absorbed anchor (tc + kernel markers) for a kernel port.
|
||||
"""
|
||||
|
||||
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running()):
|
||||
return
|
||||
if port_number in self._kernel_ports:
|
||||
anchor = self._kernel_ports[port_number]
|
||||
if not os.path.exists(f"/sys/class/net/{self._bridge_name}/brif/{anchor}"):
|
||||
# Not joined (a deferred link whose peer has just started, or
|
||||
# a peer restart that replaced its anchor under the same
|
||||
# name): complete the whole wiring now.
|
||||
await self._add_kernel_port(nio, port_number)
|
||||
return
|
||||
# Kernel port: reconcile on the anchor — markers, tc impairments
|
||||
# and the carrier the suspend flag drives. No re-joining, no
|
||||
# VLAN re-programming (the port mode did not change).
|
||||
await self._kernel_update(anchor, nio)
|
||||
await self._set_adapter_carrier(0, not nio.suspend, port_number)
|
||||
return
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
|
||||
await self._ubridge_apply_markers(ubridge_bridge, nio)
|
||||
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
|
||||
await self._ubridge_apply_markers(ubridge_bridge, nio)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# VLAN mode translation
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _reset_port_vlan(self, tap):
|
||||
async def _reconfigure_port_vlan(self, iface, old_settings, new_settings):
|
||||
"""
|
||||
Resets a port's VLAN membership to the kernel default (PVID 1, untagged)
|
||||
by re-enslaving it. Used before re-applying a changed mode so stale VIDs
|
||||
from the previous mode do not leak.
|
||||
Moves one port's VLAN membership from its previous mode to the new one
|
||||
**in place**: the port never leaves the bridge, so there is no traffic
|
||||
gap, no FDB flush, and nothing that detaches an interface this switch
|
||||
does not own (an absorbed kernel anchor belongs to the peer's node —
|
||||
re-enslaving it from here is not this switch's business).
|
||||
|
||||
Each mode's membership, as installed on a clean port by
|
||||
``_apply_port_vlan``: access(N) = {N, PVID untagged}; dot1q(V) =
|
||||
{1..4094, tagged} plus V as PVID untagged (the all-VIDs entry is a
|
||||
single range entry); qinq(O) = {O, PVID untagged}. The transition
|
||||
deletes exactly the previous mode's entries the new mode does not
|
||||
want, then adds the new mode's entries — the dot1q range is added and
|
||||
deleted as one range operation.
|
||||
"""
|
||||
|
||||
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
|
||||
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
|
||||
old_type, old_vlan = old_settings["type"], int(old_settings["vlan"])
|
||||
new_type, new_vlan = new_settings["type"], int(new_settings["vlan"])
|
||||
if old_type == new_type and old_vlan == new_vlan:
|
||||
return
|
||||
|
||||
br = self._bridge_name
|
||||
commands = []
|
||||
|
||||
if old_type == "dot1q" and new_type != "dot1q":
|
||||
# the admits-all-VIDs range, the old native VLAN included in it
|
||||
commands.append(f'brctl vlan_del "{br}" "{iface}" 1 vid 4094')
|
||||
elif old_type == "dot1q":
|
||||
if old_vlan != new_vlan:
|
||||
commands.append(f'brctl vlan_del "{br}" "{iface}" {old_vlan}')
|
||||
elif new_type == "dot1q" or old_vlan != new_vlan:
|
||||
# access/qinq single-VID entry, replaced by the new mode's
|
||||
commands.append(f'brctl vlan_del "{br}" "{iface}" {old_vlan}')
|
||||
|
||||
if new_type == "dot1q":
|
||||
if old_type != "dot1q":
|
||||
commands.append(f'brctl vlan_add "{br}" "{iface}" 1 vid 4094')
|
||||
commands.append(f'brctl vlan_add "{br}" "{iface}" {new_vlan} pvid untagged')
|
||||
else:
|
||||
commands.append(f'brctl vlan_add "{br}" "{iface}" {new_vlan} pvid untagged')
|
||||
|
||||
for command in commands:
|
||||
await self._ubridge_send(command)
|
||||
|
||||
async def _apply_port_vlan(self, port_settings, tap):
|
||||
"""
|
||||
Translates an ESW port mode into ``brctl`` VLAN primitives. The port must
|
||||
already be enslaved to the bridge and carry the default PVID 1.
|
||||
Translates an ESW port mode into ``brctl`` VLAN primitives, for a
|
||||
**newly wired** port. The port must already be enslaved to the bridge
|
||||
and carry the default PVID 1.
|
||||
|
||||
- access VLAN N: drop default 1, add N as PVID + egress untagged.
|
||||
- dot1q trunk (native N): drop default 1, admit all VIDs tagged, then mark
|
||||
@ -438,6 +617,9 @@ class EthernetSwitch(BaseNode):
|
||||
VLAN per trunk port, so the trunk admits all VIDs, like the emulated ESW.)
|
||||
- qinq (outer N): the bridge-level protocol is set separately; the port
|
||||
gets the service VLAN as PVID + untagged so customer frames are S-tagged.
|
||||
|
||||
Mode *changes* on an already-wired port go through
|
||||
``_reconfigure_port_vlan`` (in place, no re-enslaving) instead.
|
||||
"""
|
||||
|
||||
br = self._bridge_name
|
||||
@ -458,23 +640,41 @@ class EthernetSwitch(BaseNode):
|
||||
else:
|
||||
raise NodeError(f"Unknown port type '{port_type}' on Ethernet switch '{self.name}'")
|
||||
|
||||
async def update_port_settings(self):
|
||||
async def update_port_settings(self, previous_mapping=None):
|
||||
"""
|
||||
Re-applies port settings (called after ``ports_mapping`` is updated). For
|
||||
ports already wired, reset then re-apply so a mode/VLAN change fully
|
||||
replaces the previous VLAN membership.
|
||||
Reconciles port settings after a ``ports_mapping`` update. Only the
|
||||
wired ports whose mode/VLAN actually changed are touched, and each
|
||||
change is applied in place (``_reconfigure_port_vlan``) — no port is
|
||||
ever detached from the bridge just to change its VLANs, on the
|
||||
switch's own relay TAPs and on absorbed kernel anchors alike.
|
||||
|
||||
:param previous_mapping: the ports_mapping as it was before this
|
||||
update (the caller's duty — the setter has already stored the new
|
||||
one); without it nothing can be diffed and no port is touched.
|
||||
"""
|
||||
|
||||
await self._apply_bridge_proto_if_needed()
|
||||
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created):
|
||||
return
|
||||
for port_settings in self._ports_mapping:
|
||||
port_number = port_settings["port_number"]
|
||||
tap = self._tap_by_port.get(port_number)
|
||||
if tap is None:
|
||||
|
||||
previous = {port["port_number"]: port for port in (previous_mapping or [])}
|
||||
wired = dict(self._tap_by_port)
|
||||
for port_number, anchor in self._kernel_ports.items():
|
||||
wired[port_number] = anchor
|
||||
for port_number, iface in wired.items():
|
||||
new_settings = self._port_settings(port_number)
|
||||
if new_settings is None:
|
||||
continue
|
||||
await self._reset_port_vlan(tap)
|
||||
await self._apply_port_vlan(port_settings, tap)
|
||||
old_settings = previous.get(port_number)
|
||||
if old_settings is None:
|
||||
# a wired port always has previous settings; without them a
|
||||
# diff is impossible and re-enslaving would be guessing
|
||||
log.warning(
|
||||
'Ethernet switch "{name}" [{id}]: no previous settings for wired port {port}; '
|
||||
"VLAN reconfiguration skipped".format(name=self._name, id=self._id, port=port_number)
|
||||
)
|
||||
continue
|
||||
await self._reconfigure_port_vlan(iface, old_settings, new_settings)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# capture
|
||||
@ -482,7 +682,9 @@ class EthernetSwitch(BaseNode):
|
||||
|
||||
async def start_capture(self, port_number, output_file, data_link_type="DLT_EN10MB"):
|
||||
"""
|
||||
Starts a packet capture on a port (uBridge captures on the per-port relay).
|
||||
Starts a packet capture on a port — on the per-port relay bridge for
|
||||
a relay port, on the absorbed anchor (AF_PACKET, capture start_kernel)
|
||||
for a kernel port.
|
||||
|
||||
:param port_number: allocated port number
|
||||
:param output_file: PCAP destination file for the capture
|
||||
@ -494,9 +696,17 @@ class EthernetSwitch(BaseNode):
|
||||
raise NodeError(f"Packet capture is already activated on port {port_number}")
|
||||
nio.start_packet_capture(output_file)
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
await self._ubridge_send(f'bridge start_capture {ubridge_bridge} "{output_file}"')
|
||||
log.debug(f'Ethernet switch "{self.name}" [{self.id}]: starting packet capture on port {port_number}')
|
||||
anchor = self._kernel_ports.get(port_number)
|
||||
if anchor is not None:
|
||||
await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"')
|
||||
else:
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
await self._ubridge_send(f'bridge start_capture {ubridge_bridge} "{output_file}"')
|
||||
log.debug(
|
||||
'Ethernet switch "{name}" [{id}]: starting packet capture on port {port}'.format(
|
||||
name=self.name, id=self.id, port=port_number
|
||||
)
|
||||
)
|
||||
|
||||
async def stop_capture(self, port_number):
|
||||
"""
|
||||
@ -510,6 +720,13 @@ class EthernetSwitch(BaseNode):
|
||||
return
|
||||
nio.stop_packet_capture()
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}")
|
||||
log.debug(f'Ethernet switch "{self.name}" [{self.id}]: stopping packet capture on port {port_number}')
|
||||
if port_number in self._kernel_ports:
|
||||
await self._ubridge_send("capture stop_kernel")
|
||||
else:
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}")
|
||||
log.debug(
|
||||
'Ethernet switch "{name}" [{id}]: stopping packet capture on port {port}'.format(
|
||||
name=self.name, id=self.id, port=port_number
|
||||
)
|
||||
)
|
||||
|
||||
@ -29,6 +29,7 @@ from gns3server.compute.kernel_datapath import KernelDatapathMixin
|
||||
from gns3server.compute.ubridge.ubridge_error import UbridgeError, UbridgeNamespaceError
|
||||
|
||||
from .docker_error import DockerError
|
||||
from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name
|
||||
|
||||
import logging
|
||||
|
||||
@ -48,12 +49,15 @@ class DockerKernelDatapathMixin(KernelDatapathMixin):
|
||||
kernel bridge); the guest end is moved into the container namespace
|
||||
and renamed. The ``gv``/``gc`` prefixes keep these out of the ``gns3``
|
||||
bridge/TAP name space and serve as search keys for stale-interface
|
||||
cleanup. 8 hex chars of the node id + adapter/port keep the names
|
||||
unique and within IFNAMSIZ (15).
|
||||
cleanup; the host end's name is the shared anchor naming contract
|
||||
(utils.kernel_anchor), which the controller also uses to name a
|
||||
peer's anchor for an Ethernet-switch link.
|
||||
"""
|
||||
|
||||
suffix = f"{self._id.replace('-', '')[:8]}e{adapter_number}p{port_number}"
|
||||
return f"gv{suffix}", f"gc{suffix}"
|
||||
return (
|
||||
kernel_anchor_name("docker", self._id, adapter_number, port_number),
|
||||
f"gc{anchor_suffix(self._id, adapter_number, port_number)}",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Anchor hooks (see KernelDatapathMixin)
|
||||
|
||||
@ -36,6 +36,7 @@ from gns3server.utils.asyncio import monitor_process, wait_run_in_executor
|
||||
from gns3server.utils.file_watcher import FileWatcher
|
||||
from gns3server.utils.hostname import is_ios_hostname_valid
|
||||
from gns3server.utils.images import md5sum
|
||||
from gns3server.utils.kernel_anchor import kernel_anchor_name
|
||||
|
||||
from ...base_node import BaseNode
|
||||
from ...error import NodeError
|
||||
@ -1547,14 +1548,13 @@ class Router(KernelDatapathMixin, BaseNode):
|
||||
|
||||
def _tap_name(self, slot_number, port_number):
|
||||
"""
|
||||
Deterministic anchor TAP name for a slot/port. The ``gd`` prefix
|
||||
keeps it out of the ``gns3`` bridge/TAP name space (and apart from
|
||||
Docker's ``gv``/``gc``, QEMU's ``gq`` and IOU's ``gi``); 8 hex chars
|
||||
of the node id plus slot/port keep it unique and within IFNAMSIZ
|
||||
(15), even for WIC port numbers (16, 32, 48).
|
||||
Deterministic anchor TAP name for a slot/port (the shared
|
||||
utils.kernel_anchor naming contract — the controller names a peer's
|
||||
anchor with the same function when an Ethernet switch absorbs it;
|
||||
fits IFNAMSIZ even for WIC port numbers 16/32/48).
|
||||
"""
|
||||
|
||||
return f"gd{self._id.replace('-', '')[:8]}e{slot_number}p{port_number}"
|
||||
return kernel_anchor_name("dynamips", self._id, slot_number, port_number)
|
||||
|
||||
def _kernel_host_ifc(self, slot_number, port_number=0):
|
||||
"""
|
||||
|
||||
@ -43,6 +43,7 @@ from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer
|
||||
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
|
||||
from gns3server.utils.file_watcher import FileWatcher
|
||||
from gns3server.utils.hostname import is_ios_hostname_valid
|
||||
from gns3server.utils.kernel_anchor import kernel_anchor_name
|
||||
|
||||
from ..adapters.ethernet_adapter import EthernetAdapter
|
||||
from ..adapters.serial_adapter import SerialAdapter
|
||||
@ -862,13 +863,12 @@ class IOUVM(KernelDatapathMixin, BaseNode):
|
||||
|
||||
def _tap_name(self, adapter_number, port_number):
|
||||
"""
|
||||
Deterministic anchor TAP name for an Ethernet bay/unit. The ``gi``
|
||||
prefix keeps it out of the ``gns3`` bridge/TAP name space (and apart
|
||||
from Docker's ``gv``/``gc`` and QEMU's ``gq``); 8 hex chars of the
|
||||
node id plus bay/unit keep it unique and within IFNAMSIZ (15).
|
||||
Deterministic anchor TAP name for an Ethernet bay/unit (the shared
|
||||
utils.kernel_anchor naming contract — the controller names a peer's
|
||||
anchor with the same function when an Ethernet switch absorbs it).
|
||||
"""
|
||||
|
||||
return f"gi{self._id.replace('-', '')[:8]}e{adapter_number}p{port_number}"
|
||||
return kernel_anchor_name("iou", self._id, adapter_number, port_number)
|
||||
|
||||
def _kernel_host_ifc(self, adapter_number, port_number=0):
|
||||
"""
|
||||
|
||||
@ -146,15 +146,23 @@ class KernelDatapathMixin:
|
||||
Both link endpoints run this concurrently and either may create the
|
||||
bridge first: an existing bridge is success instead of a create race
|
||||
failure.
|
||||
|
||||
A NIO whose ``bridge`` is None means the anchor is bridged
|
||||
elsewhere — an Ethernet switch absorbed it into the switch's own
|
||||
bridge — so the membership work is skipped here and only the link
|
||||
state the anchor carries (capture, markers, impairment filters) is
|
||||
applied. Exactly one end of such a link owns the anchor's bridge
|
||||
state; the other end is a passive carrier.
|
||||
"""
|
||||
|
||||
try:
|
||||
await self._ubridge_send(f'brctl create "{nio.bridge}"')
|
||||
except UbridgeError:
|
||||
# Raises again if the bridge genuinely does not exist.
|
||||
await self._ubridge_send(f'brctl show "{nio.bridge}"')
|
||||
await self._ubridge_send(f'link set "{nio.bridge}" up')
|
||||
await self._ubridge_send(f'brctl addif "{nio.bridge}" "{anchor}"')
|
||||
if nio.bridge is not None:
|
||||
try:
|
||||
await self._ubridge_send(f'brctl create "{nio.bridge}"')
|
||||
except UbridgeError:
|
||||
# Raises again if the bridge genuinely does not exist.
|
||||
await self._ubridge_send(f'brctl show "{nio.bridge}"')
|
||||
await self._ubridge_send(f'link set "{nio.bridge}" up')
|
||||
await self._ubridge_send(f'brctl addif "{nio.bridge}" "{anchor}"')
|
||||
if nio.capturing:
|
||||
# Restore a capture that was active before a node restart
|
||||
# (mirrors the relay path's start_capture).
|
||||
@ -235,6 +243,11 @@ class KernelDatapathMixin:
|
||||
this concurrently: deleting a bridge that still has the peer's port
|
||||
enslaved fails with EBUSY, so the last endpoint to remove its port
|
||||
wins the deletion and the loser's failure is expected and suppressed.
|
||||
|
||||
A NIO whose ``bridge`` is None owns no bridge here: the anchor is
|
||||
bridged by an Ethernet switch, which tears its own membership down
|
||||
on its side; this end only releases the link state it applied
|
||||
(markers, impairments, carrier).
|
||||
"""
|
||||
|
||||
host_ifc = self._kernel_host_ifc(adapter_number, port_number)
|
||||
@ -248,10 +261,12 @@ class KernelDatapathMixin:
|
||||
await self._ubridge_send(f'tc reset "{host_ifc}"')
|
||||
if self.status == "started":
|
||||
await self._set_adapter_carrier(adapter_number, False, port_number)
|
||||
if nio.bridge is not None:
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'brctl delif "{nio.bridge}" "{host_ifc}"')
|
||||
if nio.bridge is not None:
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'brctl delif "{nio.bridge}" "{host_ifc}"')
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'brctl delete "{nio.bridge}"')
|
||||
await self._ubridge_send(f'brctl delete "{nio.bridge}"')
|
||||
|
||||
async def _remove_kernel_bridges(self):
|
||||
"""
|
||||
@ -269,7 +284,9 @@ class KernelDatapathMixin:
|
||||
return
|
||||
for adapter in self._ethernet_adapters:
|
||||
for nio in adapter.ports.values():
|
||||
if isinstance(nio, NIOBridge):
|
||||
# bridge None = externally bridged (an Ethernet switch owns
|
||||
# the bridge); never delete a bridge this node does not own.
|
||||
if isinstance(nio, NIOBridge) and nio.bridge is not None:
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'brctl delete "{nio.bridge}"')
|
||||
|
||||
|
||||
84
gns3server/compute/nios/nio_anchor.py
Normal file
84
gns3server/compute/nios/nio_anchor.py
Normal file
@ -0,0 +1,84 @@
|
||||
#
|
||||
# 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
|
||||
"""
|
||||
|
||||
def __init__(self, anchor):
|
||||
|
||||
super().__init__()
|
||||
self._anchor = anchor
|
||||
|
||||
@property
|
||||
def anchor(self):
|
||||
"""
|
||||
Returns the interface name to absorb.
|
||||
|
||||
:returns: interface name
|
||||
"""
|
||||
|
||||
return self._anchor
|
||||
|
||||
@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):
|
||||
|
||||
return {
|
||||
"type": "nio_anchor",
|
||||
"anchor": self._anchor,
|
||||
"suspend": self._suspended,
|
||||
"filters": self._filters,
|
||||
"markers": self._markers,
|
||||
}
|
||||
@ -25,17 +25,22 @@ filters ride the NIO and run as one tc netem qdisc per veth host end (the
|
||||
types without a netem equivalent — frequency_drop, bpf — stay relay-only);
|
||||
markers and packet capture are served by uBridge's AF_PACKET modules on the
|
||||
veth host end (marker add_kernel / capture start_kernel).
|
||||
|
||||
``bridge`` may be ``None``: the anchor is then bridged elsewhere — an
|
||||
Ethernet switch absorbed it into the switch's own kernel bridge — and this
|
||||
node applies only the link state the anchor carries (carrier, markers,
|
||||
impairments), never bridge membership.
|
||||
"""
|
||||
|
||||
from .nio import NIO
|
||||
|
||||
|
||||
class NIOBridge(NIO):
|
||||
|
||||
"""
|
||||
Kernel-bridge NIO.
|
||||
|
||||
:param bridge: name of the per-link Linux kernel bridge
|
||||
:param bridge: name of the per-link Linux kernel bridge, or None when
|
||||
the anchor is bridged by an Ethernet switch (see module docstring)
|
||||
"""
|
||||
|
||||
def __init__(self, bridge):
|
||||
@ -46,7 +51,8 @@ class NIOBridge(NIO):
|
||||
@property
|
||||
def bridge(self):
|
||||
"""
|
||||
Returns the kernel bridge name.
|
||||
Returns the kernel bridge name, or None for an externally bridged
|
||||
anchor.
|
||||
|
||||
:returns: bridge name
|
||||
"""
|
||||
|
||||
@ -202,13 +202,16 @@ class Link:
|
||||
@property
|
||||
def kernel_datapath(self):
|
||||
"""
|
||||
Whether this link is wired on the kernel datapath (veth pairs
|
||||
enslaved into a per-link Linux bridge — no uBridge relay in the
|
||||
forwarding path). Impairment filters run as tc netem on the veth
|
||||
host end; markers and capture are served by uBridge's AF_PACKET
|
||||
Whether this link is wired on the kernel datapath (no uBridge relay
|
||||
in the forwarding path): either both endpoints enslave their
|
||||
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.
|
||||
"""
|
||||
return any(d.get("type") == "nio_bridge" for d in (getattr(self, "_link_data", None) or []))
|
||||
return any(d.get("type") in ("nio_bridge", "nio_anchor") for d in (getattr(self, "_link_data", None) or []))
|
||||
|
||||
@property
|
||||
def markers(self):
|
||||
@ -335,9 +338,7 @@ class Link:
|
||||
raise ControllerError(
|
||||
"Packet filter(s) {} only run on a kernel-datapath link (tc netem on the "
|
||||
"veth host end); this link is wired on the uBridge relay — delete and "
|
||||
"recreate the link to switch it to the kernel datapath".format(
|
||||
", ".join(sorted(conflicts))
|
||||
)
|
||||
"recreate the link to switch it to the kernel datapath".format(", ".join(sorted(conflicts)))
|
||||
)
|
||||
|
||||
# An unchanged filters dict is normally a no-op skip, but a PUT that
|
||||
@ -685,6 +686,14 @@ class Link:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def node_started(self, node):
|
||||
"""
|
||||
Called after a node member of the link reached the started state.
|
||||
The base link has nothing to re-synchronise; UDPLink uses it for
|
||||
the Ethernet-switch fast path (the switch must re-join a peer anchor
|
||||
that only comes into existence when the peer starts).
|
||||
"""
|
||||
|
||||
def default_capture_file_name(self):
|
||||
"""
|
||||
:returns: File name for a capture on this link
|
||||
|
||||
@ -936,6 +936,11 @@ class Node:
|
||||
await self.post("/start", data=data, timeout=240)
|
||||
except asyncio.TimeoutError:
|
||||
raise ControllerTimeoutError(f"Timeout when starting {self._name}")
|
||||
# A started node may have brought kernel anchors into existence that
|
||||
# a link needs to finish wiring (the Ethernet-switch fast path joins
|
||||
# the peer's anchor on the switch side — see UDPLink.node_started).
|
||||
for link in self._links:
|
||||
await link.node_started(self)
|
||||
|
||||
async def stop(self):
|
||||
"""
|
||||
|
||||
@ -20,12 +20,14 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from gns3server.config import Config
|
||||
from gns3server.utils.kernel_anchor import kernel_anchor_name
|
||||
from gns3server.utils.packet_filter_validation import (
|
||||
KERNEL_UNSUPPORTED_FILTERS,
|
||||
split_kernel_only_features,
|
||||
validate_bpf_syntax,
|
||||
)
|
||||
|
||||
from .compute import ComputeError
|
||||
from .controller_error import ControllerError, ControllerNotFoundError
|
||||
from .link import _UNSET, Link
|
||||
from .node_types import BUILTIN_NODE_TYPES
|
||||
@ -111,6 +113,13 @@ class UDPLink(Link):
|
||||
if kernel is None:
|
||||
kernel = self.kernel_datapath
|
||||
if kernel:
|
||||
# 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).
|
||||
switch = self._switch_endpoint()
|
||||
if switch is not None:
|
||||
return (self._filters, {}) if switch[0] == 0 else ({}, self._filters)
|
||||
return self._filters, self._filters
|
||||
# Relay datapath: the netem-extension filters (rate, reorder, gemodel…)
|
||||
# have no relay equivalent. update_filters rejects them on created
|
||||
@ -200,8 +209,32 @@ class UDPLink(Link):
|
||||
port = side.get("port")
|
||||
if port is not None and port.link_type != "ethernet":
|
||||
return False
|
||||
# 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.
|
||||
switch = self._switch_endpoint()
|
||||
if switch is not None:
|
||||
if node1.node_type == "ethernet_switch" and node2.node_type == "ethernet_switch":
|
||||
return False
|
||||
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)
|
||||
|
||||
def _switch_endpoint(self):
|
||||
"""
|
||||
The Ethernet switch endpoint of this link as ``(index, node)``, or
|
||||
None — the fast path where the switch owns the kernel bridge.
|
||||
"""
|
||||
|
||||
for index, side in enumerate(self._nodes):
|
||||
if side["node"].node_type == "ethernet_switch":
|
||||
return index, side["node"]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _kernel_endpoint_ready(node):
|
||||
"""
|
||||
@ -250,6 +283,49 @@ class UDPLink(Link):
|
||||
port_number2 = self._nodes[1]["port_number"]
|
||||
|
||||
if self._kernel_datapath_eligible(node1, node2):
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2, kernel=True)
|
||||
node1_markers, node2_markers = self._get_node_markers(node1, node2)
|
||||
switch = self._switch_endpoint()
|
||||
if switch is not None:
|
||||
# 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
|
||||
# bridged by the switch; it only keeps its own interface,
|
||||
# markers it hosts and its carrier). The switch end gets the
|
||||
# anchor's name to join: the name is the shared naming
|
||||
# contract (utils.kernel_anchor), a pure function of the
|
||||
# peer's node type/id/adapter/port — valid whether or not
|
||||
# the peer is currently running, which is what makes links
|
||||
# to stopped nodes wireable (the switch joins once the
|
||||
# anchor exists, re-pushed by node_started).
|
||||
index = switch[0]
|
||||
peer_index = 1 - index
|
||||
peer = self._nodes[peer_index]["node"]
|
||||
peer_anchor = kernel_anchor_name(
|
||||
peer.node_type,
|
||||
peer.id,
|
||||
self._nodes[peer_index]["adapter_number"],
|
||||
self._nodes[peer_index]["port_number"],
|
||||
)
|
||||
peer_data = {
|
||||
"type": "nio_bridge",
|
||||
"bridge": None,
|
||||
"filters": node1_filters if peer_index == 0 else node2_filters,
|
||||
"markers": node1_markers if peer_index == 0 else node2_markers,
|
||||
"suspend": self._suspended,
|
||||
}
|
||||
switch_data = {
|
||||
"type": "nio_anchor",
|
||||
"anchor": peer_anchor,
|
||||
"filters": node1_filters if index == 0 else node2_filters,
|
||||
"markers": node1_markers if index == 0 else node2_markers,
|
||||
"suspend": self._suspended,
|
||||
}
|
||||
self._link_data = [switch_data, peer_data] if index == 0 else [peer_data, switch_data]
|
||||
return [
|
||||
(node1, adapter_number1, port_number1, self._link_data[0]),
|
||||
(node2, adapter_number2, port_number2, self._link_data[1]),
|
||||
]
|
||||
# Kernel datapath: each endpoint enslaves its veth host end into
|
||||
# a per-link Linux bridge. The name derives from the link id so
|
||||
# both ends compute it independently (no coordinator) and crash
|
||||
@ -263,8 +339,6 @@ class UDPLink(Link):
|
||||
# the eBPF stateful classifier (frequency_drop, quota) — every
|
||||
# GNS3 filter type has a kernel equivalent now.
|
||||
bridge_name = "gns3" + self._id.replace("-", "")[:11]
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2, kernel=True)
|
||||
node1_markers, node2_markers = self._get_node_markers(node1, node2)
|
||||
self._link_data = [
|
||||
{
|
||||
"type": "nio_bridge",
|
||||
@ -580,6 +654,38 @@ class UDPLink(Link):
|
||||
# explicitly deletes a marker via the REST API, and a marker is torn
|
||||
# down automatically only when its link is deleted.
|
||||
|
||||
async def node_started(self, node):
|
||||
"""
|
||||
Called after a node of this link reached the started state.
|
||||
|
||||
The Ethernet switch fast path wires itself on the switch side, but a
|
||||
peer's anchor only comes into existence when the peer starts (and a
|
||||
peer restart can replace the interface under the same name — e.g.
|
||||
QEMU recreates its TAPs at every start). The switch cannot observe
|
||||
that, so the controller re-pushes its NIO: the switch completes a
|
||||
join it had to defer (or re-joins an anchor that disappeared),
|
||||
then re-applies the link state it owns.
|
||||
"""
|
||||
|
||||
if not self._created:
|
||||
return
|
||||
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)
|
||||
|
||||
async def start_marker(
|
||||
self,
|
||||
name,
|
||||
|
||||
@ -101,7 +101,7 @@ from .compute.iou_nodes import IOU, IOUCreate, IOUStart, IOUUpdate
|
||||
from .compute.nat_nodes import NAT, NATCreate, NATUpdate
|
||||
|
||||
# Compute schemas
|
||||
from .compute.nios import TAPNIO, UDPNIO, BridgeNIO, BatchNIOCreate, BatchNIOEntry, EthernetNIO, MarkerRebuild, MarkerToggle
|
||||
from .compute.nios import AnchorNIO, TAPNIO, UDPNIO, BridgeNIO, BatchNIOCreate, BatchNIOEntry, EthernetNIO, MarkerRebuild, MarkerToggle
|
||||
from .compute.qemu_nodes import Qemu, QemuCreate, QemuUpdate
|
||||
from .compute.virtualbox_nodes import VirtualBox, VirtualBoxCreate, VirtualBoxUpdate
|
||||
from .compute.vmware_nodes import VMware, VMwareCreate, VMwareUpdate
|
||||
@ -164,6 +164,7 @@ __all__ = [
|
||||
"ApplianceVersion",
|
||||
"ApplianceVersionV8",
|
||||
"AutoIdlePC",
|
||||
"AnchorNIO",
|
||||
"BatchNIOCreate",
|
||||
"BatchNIOEntry",
|
||||
"BridgeNIO",
|
||||
|
||||
@ -84,10 +84,18 @@ class BridgeNIO(BaseModel):
|
||||
uBridge reporting cbpf), frequency_drop and quota as the eBPF stateful
|
||||
classifier (needs ebpf). Markers and packet capture are served by
|
||||
uBridge's AF_PACKET modules on the veth host end.
|
||||
|
||||
``bridge`` may be None: the anchor is then bridged by an Ethernet switch
|
||||
(its own kernel bridge absorbed it) and the node applies only carrier,
|
||||
markers and impairments — never bridge membership.
|
||||
"""
|
||||
|
||||
type: BridgeNIOType
|
||||
bridge: str = Field(..., description="Kernel bridge name e.g. gns3a1b2c3d4e5")
|
||||
bridge: Optional[str] = Field(
|
||||
None,
|
||||
description="Kernel bridge name e.g. gns3a1b2c3d4e5; None when an Ethernet switch "
|
||||
"owns the anchor's bridge membership (absorbed link)",
|
||||
)
|
||||
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
|
||||
filters: Optional[dict] = Field(
|
||||
None,
|
||||
@ -97,6 +105,30 @@ class BridgeNIO(BaseModel):
|
||||
markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the veth host end)")
|
||||
|
||||
|
||||
class AnchorNIOType(str, Enum):
|
||||
anchor = "nio_anchor"
|
||||
|
||||
|
||||
class AnchorNIO(BaseModel):
|
||||
"""
|
||||
Anchor-absorbing Network Input/Output properties, for a node that owns a
|
||||
kernel bridge (the Ethernet switch): the named foreign interface — the
|
||||
peer's kernel-datapath anchor — is joined to the node's own bridge with
|
||||
the node's port settings applied to it. The mirror of BridgeNIO; filters,
|
||||
markers and capture ride the NIO and attach to the named anchor.
|
||||
"""
|
||||
|
||||
type: AnchorNIOType
|
||||
anchor: str = Field(..., description="Host interface to absorb e.g. gv00010203e0p0")
|
||||
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
|
||||
filters: Optional[dict] = Field(
|
||||
None,
|
||||
description="Packet filters served on the absorbed anchor (netem surface + extensions via one tc netem "
|
||||
"qdisc, bpf via cls_bpf, frequency_drop/quota via the eBPF classifier)",
|
||||
)
|
||||
markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the absorbed anchor)")
|
||||
|
||||
|
||||
class MarkerToggle(BaseModel):
|
||||
"""
|
||||
Body for the per-marker enable/disable toggle endpoint: flips a running
|
||||
@ -131,7 +163,7 @@ class BatchNIOEntry(BaseModel):
|
||||
node_id: str = Field(..., description="Node the NIO is attached to")
|
||||
adapter_number: int = Field(0, ge=0, description="Adapter number")
|
||||
port_number: int = Field(0, ge=0, description="Port number")
|
||||
nio: Union[UDPNIO, BridgeNIO] = Field(..., description="NIO settings")
|
||||
nio: Union[UDPNIO, BridgeNIO, AnchorNIO] = Field(..., description="NIO settings")
|
||||
|
||||
|
||||
class BatchNIOCreate(BaseModel):
|
||||
|
||||
68
gns3server/utils/kernel_anchor.py
Normal file
68
gns3server/utils/kernel_anchor.py
Normal file
@ -0,0 +1,68 @@
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
The kernel-datapath anchor naming contract, in one place.
|
||||
|
||||
Every node type whose adapters own a host-side interface names that anchor
|
||||
deterministically: ``g<type>{node_id[:8]}e{adapter}p{port}`` — ``gv`` for a
|
||||
Docker veth host end, ``gq`` for a QEMU TAP, ``gi`` for an IOU port TAP,
|
||||
``gd`` for a Dynamips port TAP. The prefixes keep anchors out of the
|
||||
``gns3`` bridge/TAP name space and name-collision-free on a host; the 8
|
||||
hex chars of the node id plus adapter/port keep every name unique and
|
||||
within IFNAMSIZ (15).
|
||||
|
||||
The compute classes create their anchors with these helpers (single source
|
||||
of truth), and so does the controller when a switch link has to name the
|
||||
peer's anchor — the name is a pure function of (node type, node id,
|
||||
adapter, port), valid whether or not the node is currently running, which
|
||||
is what makes links to stopped nodes wireable.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Node type -> the anchor prefix its interface names carry. ``gc`` (the
|
||||
# Docker veth guest end) is container-local and deliberately absent.
|
||||
ANCHOR_PREFIX = {
|
||||
"docker": "gv",
|
||||
"qemu": "gq",
|
||||
"iou": "gi",
|
||||
"dynamips": "gd",
|
||||
}
|
||||
|
||||
|
||||
def anchor_suffix(node_id, adapter_number, port_number=0):
|
||||
"""
|
||||
The shared ``{node_id[:8]}e{adapter}p{port}`` name stem (8 hex chars of
|
||||
the node id, no dashes).
|
||||
"""
|
||||
|
||||
return f"{str(node_id).replace('-', '')[:8]}e{adapter_number}p{port_number}"
|
||||
|
||||
|
||||
def kernel_anchor_name(node_type, node_id, adapter_number, port_number=0):
|
||||
"""
|
||||
The host-side kernel-datapath anchor of one adapter port, or None for a
|
||||
node type without one (the anchor-less types never anchor a kernel
|
||||
link).
|
||||
"""
|
||||
|
||||
prefix = ANCHOR_PREFIX.get(node_type)
|
||||
if prefix is None:
|
||||
return None
|
||||
return prefix + anchor_suffix(node_id, adapter_number, port_number)
|
||||
@ -14,7 +14,8 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from unittest.mock import MagicMock, call
|
||||
import os
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@ -535,3 +536,507 @@ class TestEthernetSwitchNodesRoutes:
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
node._ubridge_send.assert_any_call(f"bridge stop_capture {relay}")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# kernel datapath: absorbed anchors (nio_anchor)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def _anchor_params(anchor: str = "gv00010203e0p0", **extra) -> dict:
|
||||
params = {"type": "nio_anchor", "anchor": anchor}
|
||||
params.update(extra)
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _sysfs(anchor_exists: bool = True, bridge_member: bool = True):
|
||||
"""An ``os.path.exists`` that answers the /sys/class/net probes of the
|
||||
kernel-port wiring, delegating everything else to the real one."""
|
||||
real = os.path.exists
|
||||
|
||||
def fake(path):
|
||||
value = str(path)
|
||||
if "/brif/" in value:
|
||||
return bridge_member
|
||||
if value.startswith("/sys/class/net/"):
|
||||
return anchor_exists
|
||||
return real(path)
|
||||
|
||||
return patch("os.path.exists", new=fake)
|
||||
|
||||
async def test_ethernet_switch_create_anchor_nio(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
A kernel link joins the peer's anchor straight to the switch bridge
|
||||
with the port's VLAN mode applied — no relay, no per-port TAP.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
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._sysfs(anchor_exists=True):
|
||||
response = await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["type"] == "nio_anchor"
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
br = node._bridge_name
|
||||
# access VLAN 1 (default): the anchor replaces the port TAP as the
|
||||
# bridge port, so it carries the port's membership
|
||||
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
|
||||
node._ubridge_send.assert_any_call(f'brctl vlan_del "{br}" "{anchor}" 1')
|
||||
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')
|
||||
# no relay was created for this port
|
||||
assert not any("bridge create" in str(c) for c in node._ubridge_send.call_args_list)
|
||||
assert node._kernel_ports[0] == anchor
|
||||
assert node.get_nio(0).anchor == anchor
|
||||
|
||||
async def test_ethernet_switch_create_anchor_nio_defers_when_anchor_missing(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
The peer's anchor only exists while the peer runs: a link created
|
||||
against a stopped peer must not fail (nor stop the switch's
|
||||
uBridge) — the join is deferred to the re-push after the peer
|
||||
starts.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
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._sysfs(anchor_exists=False):
|
||||
response = await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
assert node._kernel_ports[0] == anchor
|
||||
assert node.get_nio(0).anchor == anchor
|
||||
assert not any("addif" in str(c) for c in node._ubridge_send.call_args_list)
|
||||
assert not any("brctl" in str(c) for c in node._ubridge_send.call_args_list)
|
||||
|
||||
async def test_ethernet_switch_update_anchor_nio_reconciles(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
Updating a joined kernel port reconciles on the anchor: tc
|
||||
impairments, markers and the carrier the suspend flag drives.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
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._sysfs(anchor_exists=True):
|
||||
await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
update_url = app.url_path_for(
|
||||
"compute:update_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._sysfs(anchor_exists=True, bridge_member=True):
|
||||
response = await compute_client.put(
|
||||
update_url, json=self._anchor_params(anchor, filters={"delay": [10]}, suspend=True)
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["suspend"] is True
|
||||
|
||||
node._ubridge_send.assert_any_call(f'tc reset "{anchor}"')
|
||||
node._ubridge_send.assert_any_call(f'tc netem set "{anchor}" delay 10')
|
||||
node._ubridge_send.assert_any_call(f'link set "{anchor}" down')
|
||||
# no re-join on a plain reconcile
|
||||
assert not any("addif" in str(c) for c in node._ubridge_send.call_args_list)
|
||||
|
||||
async def test_ethernet_switch_update_anchor_nio_completes_deferred_join(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
The controller re-pushes the switch NIO once the peer starts: a join
|
||||
deferred at link creation completes (the anchor exists but is not a
|
||||
bridge member), and a peer restart that replaced its anchor under
|
||||
the same name re-joins.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
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._sysfs(anchor_exists=False):
|
||||
await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
update_url = app.url_path_for(
|
||||
"compute:update_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
# the anchor exists now (peer started) but is not a bridge member
|
||||
with self._sysfs(anchor_exists=True, bridge_member=False):
|
||||
response = await compute_client.put(update_url, json=self._anchor_params(anchor))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
br = node._bridge_name
|
||||
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')
|
||||
|
||||
async def test_ethernet_switch_delete_anchor_nio(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
Removing a kernel link resets the anchor's qdisc, takes it out of
|
||||
the switch bridge and drops its carrier — the anchor itself survives
|
||||
(it belongs to the peer).
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
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._sysfs(anchor_exists=True):
|
||||
await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
with self._sysfs(anchor_exists=True):
|
||||
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'tc reset "{anchor}"')
|
||||
node._ubridge_send.assert_any_call(f'brctl delif "{br}" "{anchor}"')
|
||||
node._ubridge_send.assert_any_call(f'link set "{anchor}" down')
|
||||
assert 0 not in node._kernel_ports
|
||||
assert 0 not in node._nios
|
||||
|
||||
async def test_ethernet_switch_capture_anchor_nio(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
Capture on a kernel port runs on the absorbed anchor (AF_PACKET),
|
||||
not on a relay.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
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._sysfs(anchor_exists=True):
|
||||
await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
response = await compute_client.post(
|
||||
app.url_path_for(
|
||||
"compute:start_ethernet_switch_capture",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
),
|
||||
json={"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert any(
|
||||
c.args and str(c.args[0]).startswith(f'capture start_kernel {anchor} "')
|
||||
for c in node._ubridge_send.call_args_list
|
||||
), node._ubridge_send.call_args_list
|
||||
|
||||
response = await compute_client.post(
|
||||
app.url_path_for(
|
||||
"compute:stop_ethernet_switch_capture",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
node._ubridge_send.assert_any_call("capture stop_kernel")
|
||||
|
||||
async def test_ethernet_switch_batch_create_anchor_nio(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
The project-open batch endpoint accepts anchor NIOs (a reopen must
|
||||
not 422 the switch's kernel links).
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p3"
|
||||
params = {
|
||||
"nios": [
|
||||
{
|
||||
"node_id": ethernet_switch["node_id"],
|
||||
"adapter_number": 0,
|
||||
"port_number": 3,
|
||||
"nio": {"type": "nio_anchor", "anchor": anchor},
|
||||
}
|
||||
]
|
||||
}
|
||||
with self._sysfs(anchor_exists=False):
|
||||
response = await compute_client.post(
|
||||
app.url_path_for("compute:create_batch_nios", project_id=compute_project.id), json=params
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["added"] == 1
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
assert node._kernel_ports[3] == anchor
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# in-place VLAN reconfiguration (no port ever leaves the bridge)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def test_ethernet_switch_update_ports_reconfigures_in_place(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
Changing a wired port's VLAN edits its membership in place: delete the
|
||||
old membership, add the new one — the port is never detached from the
|
||||
bridge (a relay TAP here; the TAP is the cloud/relay-side port).
|
||||
"""
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
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",
|
||||
)
|
||||
await compute_client.post(url, json=self._udp_params())
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
|
||||
ports[0]["vlan"] = 10
|
||||
response = await compute_client.put(
|
||||
app.url_path_for(
|
||||
"compute:update_ethernet_switch",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
),
|
||||
json={"ports_mapping": ports},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
br = node._bridge_name
|
||||
tap = f"{br}-0"
|
||||
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
|
||||
assert commands == [
|
||||
f'brctl vlan_del "{br}" "{tap}" 1',
|
||||
f'brctl vlan_add "{br}" "{tap}" 10 pvid untagged',
|
||||
], commands
|
||||
assert not any("delif" in c or "addif" in c for c in commands)
|
||||
|
||||
async def test_ethernet_switch_update_ports_leaves_untouched_ports_alone(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
Only ports whose mode/VLAN changed are touched: re-saving the mapping
|
||||
no longer re-programs every wired port.
|
||||
"""
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
for port_number in (0, 1):
|
||||
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=str(port_number),
|
||||
)
|
||||
await compute_client.post(url, json=self._udp_params())
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
|
||||
ports[1]["vlan"] = 20
|
||||
response = await compute_client.put(
|
||||
app.url_path_for(
|
||||
"compute:update_ethernet_switch",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
),
|
||||
json={"ports_mapping": ports},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
br = node._bridge_name
|
||||
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
|
||||
assert commands == [
|
||||
f'brctl vlan_del "{br}" "{br}-1" 1',
|
||||
f'brctl vlan_add "{br}" "{br}-1" 20 pvid untagged',
|
||||
], commands
|
||||
assert not any(f'"{br}-0"' in c for c in commands)
|
||||
|
||||
async def test_ethernet_switch_vlan_transition_table(self, compute_project: Project, ethernet_switch: dict) -> None:
|
||||
"""
|
||||
Every mode transition is expressed as in-place VLAN edits: the old
|
||||
mode's entries the new mode does not want, then the new mode's
|
||||
entries. The dot1q admits-all entry is one range operation in both
|
||||
directions, and same-to-same emits nothing.
|
||||
"""
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
br = node._bridge_name
|
||||
iface = "gvdeadbeef0p0"
|
||||
|
||||
cases = [
|
||||
(
|
||||
{"type": "access", "vlan": 10},
|
||||
{"type": "dot1q", "vlan": 20},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 10',
|
||||
f'brctl vlan_add "{br}" "{iface}" 1 vid 4094',
|
||||
f'brctl vlan_add "{br}" "{iface}" 20 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "dot1q", "vlan": 20},
|
||||
{"type": "access", "vlan": 30},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 1 vid 4094',
|
||||
f'brctl vlan_add "{br}" "{iface}" 30 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "dot1q", "vlan": 20},
|
||||
{"type": "dot1q", "vlan": 30},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 20',
|
||||
f'brctl vlan_add "{br}" "{iface}" 30 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "qinq", "vlan": 2},
|
||||
{"type": "dot1q", "vlan": 5},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 2',
|
||||
f'brctl vlan_add "{br}" "{iface}" 1 vid 4094',
|
||||
f'brctl vlan_add "{br}" "{iface}" 5 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "dot1q", "vlan": 5},
|
||||
{"type": "qinq", "vlan": 2},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 1 vid 4094',
|
||||
f'brctl vlan_add "{br}" "{iface}" 2 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "access", "vlan": 10},
|
||||
{"type": "qinq", "vlan": 2},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 10',
|
||||
f'brctl vlan_add "{br}" "{iface}" 2 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "qinq", "vlan": 2},
|
||||
{"type": "access", "vlan": 10},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 2',
|
||||
f'brctl vlan_add "{br}" "{iface}" 10 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "qinq", "vlan": 2},
|
||||
{"type": "qinq", "vlan": 9},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 2',
|
||||
f'brctl vlan_add "{br}" "{iface}" 9 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "access", "vlan": 7},
|
||||
{"type": "access", "vlan": 7},
|
||||
[],
|
||||
),
|
||||
]
|
||||
for old_settings, new_settings, expected in cases:
|
||||
node._ubridge_send.reset_mock()
|
||||
await node._reconfigure_port_vlan(iface, old_settings, new_settings)
|
||||
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
|
||||
assert commands == expected, (old_settings, new_settings, commands)
|
||||
assert not any("delif" in c or "addif" in c for c in commands)
|
||||
|
||||
async def test_ethernet_switch_update_ports_reconfigures_absorbed_anchor_in_place(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
The same in-place reconcile applies to an absorbed kernel anchor (a
|
||||
peer node's interface): a ports_mapping update issues VLAN edits on
|
||||
it and never detaches it from the bridge.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
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._sysfs(anchor_exists=True):
|
||||
await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
|
||||
ports[0]["vlan"] = 10
|
||||
response = await compute_client.put(
|
||||
app.url_path_for(
|
||||
"compute:update_ethernet_switch",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
),
|
||||
json={"ports_mapping": ports},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
br = node._bridge_name
|
||||
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
|
||||
assert commands == [
|
||||
f'brctl vlan_del "{br}" "{anchor}" 1',
|
||||
f'brctl vlan_add "{br}" "{anchor}" 10 pvid untagged',
|
||||
], commands
|
||||
assert not any("delif" in c or "addif" in c for c in commands)
|
||||
|
||||
@ -612,3 +612,65 @@ async def test_manager_create_nio_builds_the_bridge_nio(router):
|
||||
assert nio.filters == {"delay": [5]}
|
||||
assert nio.suspend is True
|
||||
assert _hypervisor_commands(router) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Externally bridged anchors (an Ethernet switch absorbed them)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_bridge_nio_skips_bridge_membership(router):
|
||||
"""
|
||||
A NIOBridge with no bridge means an Ethernet switch owns the anchor's
|
||||
bridge membership: the router still opens the anchor in the hypervisor
|
||||
(the TAP needs its fd holder) but never touches any kernel bridge.
|
||||
"""
|
||||
|
||||
router.status = "started"
|
||||
router._kernel_taps[(0, 0)] = TAP00
|
||||
nio = await _nio(router, bridge=None)
|
||||
|
||||
await router.slot_add_nio_binding(0, 0, nio)
|
||||
|
||||
hypervisor = _hypervisor_commands(router)
|
||||
assert any(c.startswith("nio create_tap tap-") for c in hypervisor)
|
||||
assert any(c.startswith('vm slot_add_nio_binding "R1" 0 0 tap-') for c in hypervisor)
|
||||
assert not any("brctl" in c for c in _ubridge_commands(router))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_bridge_nio_remove_skips_bridge_ops(router):
|
||||
"""
|
||||
Detaching an externally bridged anchor releases the hypervisor's fd and
|
||||
resets the link state, but must not delif or delete any bridge — the
|
||||
switch owns them, and deleting the switch's bridge would take the whole
|
||||
switch down.
|
||||
"""
|
||||
|
||||
router.status = "started"
|
||||
router._kernel_taps[(0, 0)] = TAP00
|
||||
nio = await _nio(router, bridge=None)
|
||||
await router.slot_add_nio_binding(0, 0, nio)
|
||||
router._ubridge_send.reset_mock()
|
||||
|
||||
await router.slot_remove_nio_binding(0, 0)
|
||||
|
||||
commands = _ubridge_commands(router)
|
||||
assert not any("brctl" in c for c in commands), commands
|
||||
assert any(c == f'tc reset "{TAP00}"' for c in commands)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_kernel_bridges_skips_external_nios(router):
|
||||
"""
|
||||
The bridge sweep never deletes a bridge this node does not own.
|
||||
"""
|
||||
|
||||
router._kernel_taps[(0, 0)] = TAP00
|
||||
external = await _nio(router, bridge=None)
|
||||
router._slots[0].add_nio(0, external)
|
||||
|
||||
await router._remove_kernel_bridges()
|
||||
|
||||
assert not any("brctl" in c for c in _ubridge_commands(router))
|
||||
|
||||
@ -802,3 +802,131 @@ async def test_available_filters_unconstrained_without_report(project):
|
||||
link, _node1, _node2 = await _kernel_link(project)
|
||||
link._link_data = [{"type": "nio_bridge"}]
|
||||
assert [f["type"] for f in link.available_filters()] == [f["type"] for f in FILTERS]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ethernet switch fast path: the switch absorbs the peer's anchor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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.
|
||||
"""
|
||||
|
||||
compute = MagicMock()
|
||||
compute.id = "compute-1"
|
||||
compute.capabilities = {"ubridge_tap": True, "ubridge_iol_tap": True}
|
||||
switch = _node(project, compute, "sw1", node_type="ethernet_switch")
|
||||
|
||||
for peer in (
|
||||
_node(project, compute, "docker1"),
|
||||
_node(project, compute, "qemu1", node_type="qemu"),
|
||||
_node(project, compute, "iou1", node_type="iou"),
|
||||
_node(project, compute, "r1", node_type="dynamips"),
|
||||
):
|
||||
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
|
||||
switch2 = _node(project, compute, "sw2", node_type="ethernet_switch")
|
||||
link = await _link(project, switch, switch2)
|
||||
assert link._kernel_datapath_eligible(switch, switch2) is False
|
||||
|
||||
# an anchor-less peer
|
||||
vpcs = _node(project, compute, "pc1", node_type="vpcs")
|
||||
link = await _link(project, switch, vpcs)
|
||||
assert link._kernel_datapath_eligible(switch, vpcs) is False
|
||||
|
||||
# a peer whose compute cannot anchor stays relay too
|
||||
compute_no_tap = _tap_capable_compute(tap_support=None)
|
||||
qemu = _node(project, compute_no_tap, "qemu1", node_type="qemu")
|
||||
switch3 = _node(project, compute_no_tap, "sw3", node_type="ethernet_switch")
|
||||
link = await _link(project, switch3, qemu)
|
||||
assert link._kernel_datapath_eligible(switch3, qemu) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_link_prepare_emits_anchor_and_external_bridge(project):
|
||||
"""
|
||||
The fast path's wire format: the switch receives ``nio_anchor`` naming
|
||||
the peer's anchor (the shared naming contract, computable while the
|
||||
node is stopped), the peer receives ``nio_bridge`` with no bridge —
|
||||
its anchor is bridged by the switch. The link's tc impairments are
|
||||
owned by exactly one end (the switch): both would hit the same
|
||||
interface.
|
||||
"""
|
||||
|
||||
from gns3server.utils.kernel_anchor import kernel_anchor_name
|
||||
|
||||
compute = MagicMock()
|
||||
compute.id = "compute-1"
|
||||
switch = _node(project, compute, "sw1", node_type="ethernet_switch")
|
||||
peer = _node(project, compute, "docker1")
|
||||
link = await _link(project, switch, peer)
|
||||
link._filters = {"delay": [10]}
|
||||
|
||||
entries = await link._prepare()
|
||||
switch_nio = entries[0][3]
|
||||
peer_nio = entries[1][3]
|
||||
|
||||
assert switch_nio["type"] == "nio_anchor"
|
||||
assert switch_nio["anchor"] == kernel_anchor_name("docker", peer.id, 0, 0)
|
||||
assert switch_nio["filters"] == {"delay": [10]}
|
||||
assert peer_nio["type"] == "nio_bridge"
|
||||
assert peer_nio["bridge"] is None
|
||||
assert peer_nio["filters"] == {}
|
||||
assert link.kernel_datapath is True
|
||||
|
||||
# the reverse endpoint order must keep index alignment
|
||||
link = await _link(project, peer, switch)
|
||||
link._filters = {"delay": [10]}
|
||||
entries = await link._prepare()
|
||||
assert entries[0][3]["type"] == "nio_bridge"
|
||||
assert entries[0][3]["bridge"] is None
|
||||
assert entries[0][3]["filters"] == {}
|
||||
assert entries[1][3]["type"] == "nio_anchor"
|
||||
assert entries[1][3]["filters"] == {"delay": [10]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_link_repushes_the_switch_nio_on_node_start(project):
|
||||
"""
|
||||
The switch cannot observe the peer's anchors coming into existence: the
|
||||
controller re-pushes the switch's NIO after a node of the link starts,
|
||||
which completes a deferred join (or re-joins an anchor a peer restart
|
||||
replaced). Relay links do nothing.
|
||||
"""
|
||||
|
||||
from tests.utils import AsyncioMagicMock
|
||||
|
||||
compute = MagicMock()
|
||||
compute.id = "compute-1"
|
||||
switch = _node(project, compute, "sw1", node_type="ethernet_switch")
|
||||
peer = _node(project, compute, "docker1")
|
||||
link = await _link(project, switch, peer)
|
||||
entries = await link._prepare()
|
||||
link._created = True
|
||||
switch.put = AsyncioMagicMock()
|
||||
|
||||
await link.node_started(peer)
|
||||
switch.put.assert_called_once_with(
|
||||
"/adapters/0/ports/0/nio", data=entries[0][3], timeout=120
|
||||
)
|
||||
|
||||
# a relay link never re-pushes anything
|
||||
switch.put.reset_mock()
|
||||
compute2 = MagicMock()
|
||||
compute2.id = "compute-1"
|
||||
relay_switch = _node(project, compute2, "sw2", node_type="ethernet_switch")
|
||||
vpcs = _node(project, compute2, "pc1", node_type="vpcs")
|
||||
relay_link = await _link(project, relay_switch, vpcs)
|
||||
relay_link._created = True
|
||||
relay_switch.put = AsyncioMagicMock()
|
||||
await relay_link.node_started(vpcs)
|
||||
assert not relay_switch.put.called
|
||||
|
||||
@ -936,6 +936,22 @@ async def test_start(node, compute):
|
||||
compute.post.assert_called_with(f"/projects/{node.project.id}/vpcs/nodes/{node.id}/start", timeout=240)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_notifies_links(node, compute):
|
||||
"""
|
||||
A successful start tells the node's links: the Ethernet-switch fast
|
||||
path completes a deferred anchor join only then (UDPLink.node_started).
|
||||
"""
|
||||
|
||||
compute.post = AsyncioMagicMock()
|
||||
link = AsyncioMagicMock()
|
||||
link.deferred = False
|
||||
node.add_link(link)
|
||||
|
||||
await node.start()
|
||||
assert link.node_started.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_iou(compute, project, controller):
|
||||
|
||||
|
||||
@ -282,6 +282,12 @@ def live_server(kernel=True):
|
||||
already be configured for it); everything else starts an isolated
|
||||
instance. Relay-mode tests always need an isolated instance — the
|
||||
datapath choice is a server configuration.
|
||||
|
||||
Either way this waits for the controller's local compute to finish
|
||||
connecting: the connection's ``GET /capabilities`` runs the uBridge
|
||||
probes (several ubridge spawns, seconds), and until it completes the
|
||||
controller caches a default dict with every capability None — a test
|
||||
reading capabilities in that window would wrongly skip itself.
|
||||
"""
|
||||
url = os.environ.get("GNS3_E2E_URL")
|
||||
if url and kernel:
|
||||
@ -290,8 +296,14 @@ def live_server(kernel=True):
|
||||
os.environ.get("GNS3_E2E_USER", "admin"),
|
||||
os.environ.get("GNS3_E2E_PASSWORD", "admin"),
|
||||
).login()
|
||||
return Server(compute)
|
||||
return _start_isolated(kernel)
|
||||
server = Server(compute)
|
||||
else:
|
||||
server = _start_isolated(kernel)
|
||||
if not wait_until(lambda: (server.compute.capabilities() or {}).get("version"), timeout=45):
|
||||
raise AssertionError(
|
||||
f"{server.compute.url}: the controller's local compute did not report capabilities within 45s"
|
||||
)
|
||||
return server
|
||||
|
||||
|
||||
def release(server, project_id, failed=False):
|
||||
|
||||
290
tests/e2e/test_ethernet_switch_kernel_datapath.py
Normal file
290
tests/e2e/test_ethernet_switch_kernel_datapath.py
Normal file
@ -0,0 +1,290 @@
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
Live end-to-end test for the Ethernet switch kernel fast path (pytest
|
||||
marker ``e2e``): a real switch (a Linux kernel bridge) between two real
|
||||
c7200 routers, driven through the controller REST API and the IOS consoles.
|
||||
Real ICMP crossing the switch proves the datapath:
|
||||
|
||||
* links created while the routers are stopped report ``kernel_datapath``
|
||||
and defer the join; once the routers boot, the controller's re-push
|
||||
makes the switch join their anchors — the switch bridge's members become
|
||||
exactly the two router anchors (no relay TAPs anywhere);
|
||||
* VLAN programming applies to absorbed anchors: different access VLANs
|
||||
isolate the routers (ping dies), the same VLAN bridges them again;
|
||||
* a ``delay 100`` filter lands as netem on the absorbed anchor (single
|
||||
interface, switch-owned) and the measured RTT grows by ~100 ms;
|
||||
* suspend admin-downs the anchor and kills the traffic; resume restores;
|
||||
* capture (hosted by the switch side) writes a pcap of ICMP records;
|
||||
* deleting a link detaches the anchor (which survives — the router owns
|
||||
it) and re-creating re-joins it;
|
||||
* a router stop/start keeps the wiring and the traffic;
|
||||
* deleting the project removes the switch bridge and the anchors.
|
||||
|
||||
``test_ethernet_switch_relay_control`` is the negative control on an
|
||||
isolated relay-configured instance: the same topology rides the per-port
|
||||
uBridge relays (the switch bridge's members are its own relay TAPs, never
|
||||
the routers' anchors) and still pings.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e import harness
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
# c7200 slot 0 is the fixed IO slot; the Ethernet PA goes in slot 1.
|
||||
SLOTS = {"slot1": "PA-2FE-TX"}
|
||||
ETH = (1, 0)
|
||||
|
||||
|
||||
def _pick_image(server):
|
||||
images = server.compute.dynamips_images()
|
||||
image = next((name for name in images if name.startswith("c7200")), None)
|
||||
if not image:
|
||||
pytest.skip(f"no c7200 image available on this server (images: {images[:5]})")
|
||||
return image
|
||||
|
||||
|
||||
def _switch_bridge_name(switch_id):
|
||||
# see EthernetSwitch._ensure_bridge
|
||||
return "gns3" + switch_id.replace("-", "")[:6]
|
||||
|
||||
|
||||
def _create_topology(compute, pid, image, name):
|
||||
switch = compute.call(
|
||||
"POST",
|
||||
f"/projects/{pid}/nodes",
|
||||
{
|
||||
"compute_id": "local",
|
||||
"name": f"{name}-SW",
|
||||
"node_type": "ethernet_switch",
|
||||
"properties": {
|
||||
"ports_mapping": [
|
||||
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 1},
|
||||
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 1},
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
r1 = compute.create_dynamips_router(pid, f"{name}-R1", image, SLOTS)
|
||||
r2 = compute.create_dynamips_router(pid, f"{name}-R2", image, SLOTS)
|
||||
return switch, r1, r2
|
||||
|
||||
|
||||
def _boot(server, project_id, node_id, timeout=360):
|
||||
console = harness.dynamips_console(server, project_id, node_id)
|
||||
console.boot_wait(timeout=timeout)
|
||||
return console
|
||||
|
||||
|
||||
def test_ethernet_switch_kernel_fast_path():
|
||||
server = harness.live_server(kernel=True)
|
||||
compute = server.compute
|
||||
if compute.capabilities().get("ubridge_tap") is not True:
|
||||
pytest.skip("this compute's uBridge cannot create persistent TAPs (ubridge_tap != True)")
|
||||
image = _pick_image(server)
|
||||
|
||||
project = compute.create_project("esw-e2e")
|
||||
pid = project["project_id"]
|
||||
sw_bridge = None
|
||||
try:
|
||||
switch, r1, r2 = _create_topology(compute, pid, image, "E2E")
|
||||
switch_id = switch["node_id"]
|
||||
r1_id, r2_id = r1["node_id"], r2["node_id"]
|
||||
sw_bridge = _switch_bridge_name(switch_id)
|
||||
a1 = harness.anchor_name(r1_id, *ETH)
|
||||
a2 = harness.anchor_name(r2_id, *ETH)
|
||||
|
||||
# The switch bridge exists from the moment the switch node does; a
|
||||
# link against a stopped router must not fail — the join defers.
|
||||
assert harness.bridge_members(sw_bridge) == []
|
||||
link1 = compute.create_link(pid, (r1_id, *ETH), (switch_id, 0, 0))
|
||||
link2 = compute.create_link(pid, (r2_id, *ETH), (switch_id, 0, 1))
|
||||
assert link1["kernel_datapath"] is True, link1
|
||||
assert link2["kernel_datapath"] is True, link2
|
||||
assert harness.bridge_members(sw_bridge) == []
|
||||
|
||||
print(".. starting routers and waiting for IOS boots")
|
||||
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
|
||||
compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start")
|
||||
c1 = _boot(server, pid, r1_id)
|
||||
c2 = _boot(server, pid, r2_id)
|
||||
|
||||
# Deferred joins completed by the node-start re-push: the anchors
|
||||
# ARE the switch ports now.
|
||||
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=15), (
|
||||
harness.bridge_members(sw_bridge)
|
||||
)
|
||||
|
||||
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1")
|
||||
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2")
|
||||
print(".. both routers configured, pinging through the switch")
|
||||
baseline = harness.wait_ping(c1, "10.1.1.2")
|
||||
assert baseline["success"] == 100, baseline["raw"]
|
||||
|
||||
# VLAN programming on absorbed anchors: different access VLANs
|
||||
# isolate, the same VLAN bridges again.
|
||||
compute.call(
|
||||
"PUT",
|
||||
f"/projects/{pid}/nodes/{switch_id}",
|
||||
{
|
||||
"properties": {
|
||||
"ports_mapping": [
|
||||
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 10},
|
||||
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 20},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
assert harness.bridge_members(sw_bridge) == sorted([a1, a2]), harness.bridge_members(sw_bridge)
|
||||
isolated = harness.ping(c1, "10.1.1.2", repeat=3)
|
||||
assert isolated["success"] == 0, isolated["raw"]
|
||||
assert harness.bridge_members(sw_bridge) == sorted([a1, a2]), harness.bridge_members(sw_bridge)
|
||||
compute.call(
|
||||
"PUT",
|
||||
f"/projects/{pid}/nodes/{switch_id}",
|
||||
{
|
||||
"properties": {
|
||||
"ports_mapping": [
|
||||
{"name": "Ethernet0", "port_number": 0, "type": "access", "vlan": 10},
|
||||
{"name": "Ethernet1", "port_number": 1, "type": "access", "vlan": 10},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
rejoined = harness.wait_ping(c1, "10.1.1.2")
|
||||
assert rejoined["success"] == 100, rejoined["raw"]
|
||||
|
||||
# Single-owned impairment: the filter lands on the absorbed anchor
|
||||
# (one interface — the switch's), RTT grows by ~100 ms one-way.
|
||||
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"filters": {"delay": [100]}})
|
||||
assert "netem" in harness.qdiscs(a1), harness.qdiscs(a1)
|
||||
assert "netem" not in harness.qdiscs(a2), harness.qdiscs(a2)
|
||||
delayed = harness.wait_ping(c1, "10.1.1.2")
|
||||
assert delayed["success"] == 100, delayed["raw"]
|
||||
assert 60 <= delayed["avg"] <= 250, (baseline, delayed)
|
||||
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"filters": {}})
|
||||
assert "netem" not in harness.qdiscs(a1)
|
||||
fast = harness.wait_ping(c1, "10.1.1.2")
|
||||
assert fast["success"] == 100 and fast["avg"] < 50, fast
|
||||
|
||||
# suspend: the anchor admin-downs, the link is dead; resume restores
|
||||
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"suspend": True})
|
||||
assert not harness.tap_up(a1)
|
||||
dead = harness.ping(c1, "10.1.1.2", repeat=3)
|
||||
assert dead["success"] == 0, dead["raw"]
|
||||
compute.call("PUT", f"/projects/{pid}/links/{link1['link_id']}", {"suspend": False})
|
||||
assert harness.tap_up(a1)
|
||||
assert harness.wait_ping(c1, "10.1.1.2")["success"] == 100
|
||||
|
||||
# capture (the controller hosts it on the switch — always-on local
|
||||
# builtin): a pcap of ICMP records written off the absorbed anchor
|
||||
capture = compute.call(
|
||||
"POST", f"/projects/{pid}/links/{link1['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}
|
||||
)
|
||||
harness.ping(c1, "10.1.1.2")
|
||||
time.sleep(1)
|
||||
compute.call("POST", f"/projects/{pid}/links/{link1['link_id']}/capture/stop")
|
||||
count, ethertypes = harness.pcap_records(capture["capture_file_path"])
|
||||
assert count >= 4, capture["capture_file_path"]
|
||||
assert "0800" in ethertypes, ethertypes
|
||||
|
||||
# link delete detaches the anchor (it survives — the router owns
|
||||
# it); re-creating re-joins it
|
||||
compute.call("DELETE", f"/projects/{pid}/links/{link2['link_id']}")
|
||||
assert harness.bridge_members(sw_bridge) == [a1]
|
||||
assert harness.tap_exists(a2)
|
||||
link2 = compute.create_link(pid, (r2_id, *ETH), (switch_id, 0, 1))
|
||||
assert link2["kernel_datapath"] is True, link2
|
||||
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=10)
|
||||
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
|
||||
|
||||
# router stop/start keeps the wiring: a Dynamips stop only halts the
|
||||
# emulated router — the hypervisor, the anchor and its bridge
|
||||
# membership all survive (the same stop-is-not-close semantics as
|
||||
# the per-link bridge datapath)
|
||||
print(".. stopping and restarting R1")
|
||||
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/stop")
|
||||
assert harness.tap_exists(a1)
|
||||
assert harness.bridge_members(sw_bridge) == sorted([a1, a2])
|
||||
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
|
||||
c1.close()
|
||||
c1 = _boot(server, pid, r1_id)
|
||||
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) == sorted([a1, a2]), timeout=15)
|
||||
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1")
|
||||
assert harness.wait_ping(c1, "10.1.1.2", attempts=5)["success"] == 100
|
||||
|
||||
c1.close()
|
||||
c2.close()
|
||||
except BaseException:
|
||||
harness.release(server, pid, failed=True)
|
||||
raise
|
||||
harness.release(server, pid, failed=False)
|
||||
|
||||
# project delete closed everything: switch bridge and anchors gone
|
||||
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) is None, timeout=15), sw_bridge
|
||||
|
||||
|
||||
def test_ethernet_switch_relay_control():
|
||||
"""Negative control: the same topology on an isolated relay-configured
|
||||
instance rides the per-port uBridge relays — the switch bridge's
|
||||
members are its own relay TAPs, never the routers' anchors — and still
|
||||
pings."""
|
||||
server = harness.live_server(kernel=False)
|
||||
compute = server.compute
|
||||
image = _pick_image(server)
|
||||
|
||||
project = compute.create_project("esw-e2e-relay")
|
||||
pid = project["project_id"]
|
||||
sw_bridge = None
|
||||
try:
|
||||
switch, r1, r2 = _create_topology(compute, pid, image, "E2E")
|
||||
sw_bridge = _switch_bridge_name(switch["node_id"])
|
||||
r1_id, r2_id = r1["node_id"], r2["node_id"]
|
||||
a1 = harness.anchor_name(r1_id, *ETH)
|
||||
|
||||
link1 = compute.create_link(pid, (r1_id, *ETH), (switch["node_id"], 0, 0))
|
||||
link2 = compute.create_link(pid, (r2_id, *ETH), (switch["node_id"], 0, 1))
|
||||
assert link1["kernel_datapath"] is False, link1
|
||||
assert link2["kernel_datapath"] is False, link2
|
||||
|
||||
print(".. starting routers and waiting for IOS boots (relay)")
|
||||
compute.call("POST", f"/projects/{pid}/nodes/{r1_id}/start")
|
||||
compute.call("POST", f"/projects/{pid}/nodes/{r2_id}/start")
|
||||
c1 = _boot(server, pid, r1_id)
|
||||
c2 = _boot(server, pid, r2_id)
|
||||
|
||||
members = harness.bridge_members(sw_bridge)
|
||||
assert members == sorted([f"{sw_bridge}-0", f"{sw_bridge}-1"]), members
|
||||
assert a1 not in members
|
||||
|
||||
harness.configure_ios(c1, "R1", eth_ip="10.1.1.1")
|
||||
harness.configure_ios(c2, "R2", eth_ip="10.1.1.2")
|
||||
relay_ping = harness.wait_ping(c1, "10.1.1.2")
|
||||
assert relay_ping["success"] == 100, relay_ping["raw"]
|
||||
|
||||
c1.close()
|
||||
c2.close()
|
||||
except BaseException:
|
||||
harness.release(server, pid, failed=True)
|
||||
raise
|
||||
harness.release(server, pid, failed=False)
|
||||
|
||||
assert harness.wait_until(lambda: harness.bridge_members(sw_bridge) is None, timeout=15), sw_bridge
|
||||
47
tests/utils/test_kernel_anchor.py
Normal file
47
tests/utils/test_kernel_anchor.py
Normal file
@ -0,0 +1,47 @@
|
||||
#
|
||||
# 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/>.
|
||||
|
||||
"""
|
||||
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).
|
||||
"""
|
||||
|
||||
from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name
|
||||
|
||||
NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f"
|
||||
|
||||
|
||||
def test_every_node_type_has_its_own_prefix():
|
||||
assert kernel_anchor_name("docker", NODE_ID, 0, 0) == "gv00010203e0p0"
|
||||
assert kernel_anchor_name("qemu", NODE_ID, 1, 2) == "gq00010203e1p2"
|
||||
assert kernel_anchor_name("iou", NODE_ID, 0, 3) == "gi00010203e0p3"
|
||||
assert kernel_anchor_name("dynamips", NODE_ID, 6, 48) == "gd00010203e6p48"
|
||||
|
||||
|
||||
def test_unknown_node_types_have_no_anchor():
|
||||
assert kernel_anchor_name("ethernet_switch", NODE_ID, 0, 0) is None
|
||||
assert kernel_anchor_name("vpcs", NODE_ID, 0, 0) is None
|
||||
|
||||
|
||||
def test_names_fit_ifnamsiz():
|
||||
# worst case: WIC port numbers reach 48 with the longest prefix
|
||||
assert len(kernel_anchor_name("dynamips", NODE_ID, 6, 48)) <= 15
|
||||
|
||||
|
||||
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)
|
||||
Loading…
x
Reference in New Issue
Block a user