feat(controller): offer IOU the kernel datapath

Eligibility gains an IOU branch gated on the compute's ubridge_iol_tap
(strict True: an unreported capability keeps the link on the relay), and
a port-level rule: a kernel link is an Ethernet segment, so any endpoint
whose port is not Ethernet (IOU serial) disqualifies the link before it
is dispatched. Without that, a serial link between two capable IOU nodes
would be sent as nio_bridge and fail at the compute.

The IOU NIO routes accept nio_bridge alongside the UDP/TAP/Ethernet NIOs
— without the widening the controller's POST is 422'd before the node
ever sees it, the same trap the QEMU routes hit.
This commit is contained in:
YueGuobin 2026-10-01 00:59:46 +08:00
parent 200c946eef
commit 806afec0d8
No known key found for this signature in database
3 changed files with 92 additions and 15 deletions

View File

@ -189,15 +189,15 @@ async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None:
@router.post( @router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)], dependencies=[Depends(compute_authentication)],
) )
async def create_iou_node_nio( async def create_iou_node_nio(
adapter_number: int, adapter_number: int,
port_number: int, port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO],
node: IOUVM = Depends(dep_node), node: IOUVM = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]: ) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO]:
""" """
Add a NIO (Network Input/Output) to the node. Add a NIO (Network Input/Output) to the node.
""" """
@ -210,15 +210,15 @@ async def create_iou_node_nio(
@router.put( @router.put(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)], dependencies=[Depends(compute_authentication)],
) )
async def update_iou_node_nio( async def update_iou_node_nio(
adapter_number: int, adapter_number: int,
port_number: int, port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO], nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO],
node: IOUVM = Depends(dep_node), node: IOUVM = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]: ) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO]:
""" """
Update a NIO (Network Input/Output) on the node. Update a NIO (Network Input/Output) on the node.
""" """
@ -227,7 +227,8 @@ async def update_iou_node_nio(
nio.filters.clear() nio.filters.clear()
if isinstance(nio_data, schemas.UDPNIO) and nio_data.filters: if isinstance(nio_data, schemas.UDPNIO) and nio_data.filters:
nio.filters = nio_data.filters nio.filters = nio_data.filters
# NIO type is a Union (Ethernet/TAP/UDP); only UDPNIO carries markers. # NIO type is a Union (Ethernet/TAP/UDP/bridge); only UDPNIO and
# BridgeNIO carry markers.
nio.markers = getattr(nio_data, "markers", None) or {} nio.markers = getattr(nio_data, "markers", None) or {}
await node.adapter_update_nio_binding(adapter_number, port_number, nio) await node.adapter_update_nio_binding(adapter_number, port_number, nio)
return nio.asdict() return nio.asdict()

View File

@ -179,7 +179,12 @@ class UDPLink(Link):
must be able to anchor: Docker adapters are born as veth pairs, must be able to anchor: Docker adapters are born as veth pairs,
QEMU adapters as persistent TAPs, which need uBridge's tap module — QEMU adapters as persistent TAPs, which need uBridge's tap module —
asked of that compute, since an old uBridge leaves QEMU on the relay asked of that compute, since an old uBridge leaves QEMU on the relay
datapath and cannot carry a kernel link at all. datapath and cannot carry a kernel link at all. IOU's Ethernet
bays anchor on persistent TAPs too, bound to its IOL fabric
(``iol_bridge add_nio_tap``) — a separate capability, since it can
be present or absent independently of the tap module. A kernel link
is an Ethernet segment, so non-Ethernet ports (IOU serial) stay on
the relay whatever the node's capabilities.
""" """
if not Config.instance().settings.Server.enable_kernel_datapath: if not Config.instance().settings.Server.enable_kernel_datapath:
@ -188,6 +193,10 @@ class UDPLink(Link):
return False return False
if _is_unix_socket_docker(node1) or _is_unix_socket_docker(node2): if _is_unix_socket_docker(node1) or _is_unix_socket_docker(node2):
return False return False
for side in self._nodes:
port = side.get("port")
if port is not None and port.link_type != "ethernet":
return False
return self._kernel_endpoint_ready(node1) and self._kernel_endpoint_ready(node2) return self._kernel_endpoint_ready(node1) and self._kernel_endpoint_ready(node2)
@staticmethod @staticmethod
@ -198,12 +207,14 @@ class UDPLink(Link):
if node.node_type == "docker": if node.node_type == "docker":
return True return True
if node.node_type != "qemu":
return False
capabilities = node.compute.capabilities or {} capabilities = node.compute.capabilities or {}
# Strict True: an unreported capability (old uBridge, failed probe) if node.node_type == "qemu":
# keeps the link on the relay, where it always works. # Strict True: an unreported capability (old uBridge, failed
return capabilities.get("ubridge_tap") is True # probe) keeps the link on the relay, where it always works.
return capabilities.get("ubridge_tap") is True
if node.node_type == "iou":
return capabilities.get("ubridge_iol_tap") is True
return False
async def _prepare(self): async def _prepare(self):
""" """

View File

@ -107,8 +107,8 @@ async def _link(project, node1, node2):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_for_other_node_types(project): async def test_kernel_datapath_not_eligible_for_other_node_types(project):
""" """
A node type with no anchor of its own (IOU, VPCS, cloud, ...) keeps the A node type with no anchor of its own (VPCS, cloud, ...) keeps the relay
relay however capable the compute is — and a QEMU node on a compute whose however capable the compute is — and a QEMU node on a compute whose
uBridge cannot create persistent TAPs does too, because it runs on the uBridge cannot create persistent TAPs does too, because it runs on the
legacy relay datapath there. legacy relay datapath there.
""" """
@ -120,11 +120,76 @@ async def test_kernel_datapath_not_eligible_for_other_node_types(project):
assert link._kernel_datapath_eligible(node1, node2) is False assert link._kernel_datapath_eligible(node1, node2) is False
compute = _tap_capable_compute() compute = _tap_capable_compute()
node1 = _node(project, compute, "vpcs1", node_type="vpcs")
node2 = _node(project, compute, "docker1")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_for_iou_with_iol_tap_support(project):
"""
IOU's Ethernet bays anchor on persistent TAPs bound to the IOL fabric
(iol_bridge add_nio_tap) — its own capability, asked per compute like
QEMU's, and mixable with docker/qemu endpoints on the same compute.
"""
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "ubridge_iol_tap": True}
node1 = _node(project, compute, "iou1", node_type="iou")
node2 = _node(project, compute, "iou2", node_type="iou")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is True
for peer in (_node(project, compute, "docker1"), _node(project, compute, "qemu1", node_type="qemu")):
link = await _link(project, node1, peer)
assert link._kernel_datapath_eligible(node1, peer) is True
@pytest.mark.asyncio
async def test_iou_eligibility_is_its_own_capability(project):
"""
The tap module and the IOL-port TAP command land independently: a
compute reporting only one of them keeps IOU on the relay (an old
uBridge without add_nio_tap), and an unreported/failed probe does too.
"""
compute = _tap_capable_compute() # ubridge_tap only, no ubridge_iol_tap
node1 = _node(project, compute, "iou1", node_type="iou") node1 = _node(project, compute, "iou1", node_type="iou")
node2 = _node(project, compute, "docker1") node2 = _node(project, compute, "docker1")
link = await _link(project, node1, node2) link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False assert link._kernel_datapath_eligible(node1, node2) is False
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "ubridge_iol_tap": None}
node1 = _node(project, compute, "iou1", node_type="iou")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_for_serial_ports(project):
"""
A kernel link is an Ethernet segment: an IOU serial port stays on the
relay whatever the compute's capabilities — dispatching it to the
kernel path would fail at the compute (no anchor on a serial bay).
"""
from gns3server.controller.ports.serial_port import SerialPort
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "ubridge_iol_tap": True}
node1 = _node(project, compute, "iou1", node_type="iou")
node1._ports = [SerialPort("Serial0/0", 0, 0, 0)]
node2 = _node(project, compute, "iou2", node_type="iou")
node2._ports = [SerialPort("Serial0/0", 0, 0, 0)]
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_kernel_datapath_eligible_for_qemu_with_tap_support(project): async def test_kernel_datapath_eligible_for_qemu_with_tap_support(project):