feat(iol): anchor IOL runner containers on persistent TAPs

IOL runner containers (IOLDockerVM, GNS3_IOL_RUNNER) had no kernel
datapath: their guest leg is the runner's AF_UNIX socket pairs, so every
link rode the uBridge relay. Their Ethernet bays now own persistent TAP
anchors (gx names, the iol_docker key of the shared naming contract —
born at node start like IOU's, before any link, so the switch fast
path's deferred join can never miss them), and the per-port bridge's
topology leg swaps between UDP (relay) and the anchor (kernel link)
through the frozen stop -> delete -> add -> start contract — the unix
binding never re-binds across a swap.

- iol_docker_vm: KernelDatapathMixin hooks, the anchor lifecycle
  (_prepare_tap_datapath at _start_ubridge: probe ubridge_bridge_tap +
  ubridge_tap, sweep leftover, tap create born down), the kernel attach
  (ensure-then-add — tap create is strictly create-only, IFF_TUN_EXCL,
  so the ensure checks /sys/class/net first: an unconditional create
  answers EBUSY on the device the start loop just made), the release
  (port bridge stop + delete_nio_tap before the shared teardown) and the
  stop order the fd holders dictate (port bridges first, then tap
  delete, then the per-link bridges — all before the hypervisor stops).
- vendor_docker_vm: the port-bridge creation factored into
  _ensure_unix_port_bridge (idempotent on the registered name), keeping
  the NIOBridge rejection for the generic unix-socket containers.
- docker_vm: the three _kernel_veths lookups go through the
  _kernel_host_ifc hook — behavior-identical for DockerVM, and it is
  what lets the subclass route without overriding the binding methods.
- kernel_anchor: the iol_docker key (gx) and kernel_anchor_type, the
  controller-side mapping for naming an IOL runner's anchor on the
  switch fast path (the marker check stays inline: this module sits
  below everything and must not import the controller package).
This commit is contained in:
YueGuobin 2026-10-02 13:30:35 +08:00
parent c20125c180
commit 4b4a0fc35f
No known key found for this signature in database
7 changed files with 773 additions and 31 deletions

View File

@ -1807,16 +1807,17 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode):
async def _connect_nio(self, adapter_number, nio, port_number=0):
host_ifc = self._kernel_veths.get((adapter_number, port_number))
host_ifc = self._kernel_host_ifc(adapter_number, port_number)
if isinstance(nio, NIOBridge):
if host_ifc is None:
# Only reachable for containers started before the
# unified-veth change (or unix-socket vendor containers,
# rejected earlier): the kernel path needs the veth host end.
# rejected earlier): the kernel path needs an anchor — the
# veth host end, or the persistent TAP of an IOL runner.
raise DockerError(
"Adapter {adapter_number} port {port_number} of container '{name}' has no veth "
"interface (it was started before the unified-veth datapath); "
"Adapter {adapter_number} port {port_number} of container '{name}' has no host-side "
"anchor interface (it was started before the unified-veth datapath); "
"restart the node to attach a kernel link to it".format(
adapter_number=adapter_number, port_number=port_number, name=self.name
)
@ -1894,7 +1895,7 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode):
if self.ubridge:
if isinstance(nio, NIOBridge):
if self.status == "started":
host_ifc = self._kernel_veths.get((adapter_number, port_number))
host_ifc = self._kernel_host_ifc(adapter_number, port_number)
if host_ifc is not None:
# Incremental marker + filter reconcile on the anchor
# — the relay-datapath equivalent of the branch below.
@ -2008,9 +2009,11 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode):
nio = self._ethernet_adapters[adapter_number].get_nio(port_number)
if isinstance(nio, NIOBridge):
# Kernel datapath: no relay bridge exists — capture via uBridge's
# AF_PACKET module bound to the veth host end. Single capture per
# uBridge process: a concurrent second one returns EALREADY.
host_ifc = self._kernel_veths.get((adapter_number, port_number))
# AF_PACKET module bound to the anchor (a veth host end for the
# standard container, a persistent TAP for an IOL runner).
# Single capture per uBridge process: a concurrent second one
# returns EALREADY.
host_ifc = self._kernel_host_ifc(adapter_number, port_number)
await self._ubridge_send(f'capture start_kernel {host_ifc} "{output_file}"')
return
# Relay datapath (even though the adapter interface is a veth, the

View File

@ -35,6 +35,17 @@ linked nodes get distinct MACs; starting a node without an allocation is an
error, not a fallback — an uncoordinated id could collide with the pool and
blackhole traffic as a MAC loop.
Kernel datapath: the unix-socket pair is this container's only physical
layer, so one userspace hop on its leg is irreducible — but the *link
segment* need not ride the relay. Every Ethernet bay/unit owns a persistent
TAP anchor (``gx`` names, created at node start like IOU's), and the
per-port uBridge bridge swaps its topology leg between UDP (relay) and the
anchor (kernel link) through ``bridge add_nio_tap`` / ``bridge
delete_nio_tap`` — stop → delete → add → start, the c-socket binding
surviving the swap. Eligibility needs the compute's uBridge to report both
``ubridge_tap`` (the tap module) and ``ubridge_bridge_tap`` (the swappable
leg); anything less keeps the node relay-only, exactly as before.
This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker.
"""
@ -51,6 +62,11 @@ from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Err
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
from gns3server.compute.iou.utils.iou_export import nvram_export
from gns3server.compute.iou.utils.iou_import import nvram_import
from gns3server.compute.kernel_datapath import KernelDatapathMixin
from gns3server.compute.nios.nio_bridge import NIOBridge
from gns3server.compute.ubridge.tc_probe import probe_bridge_tap_support, probe_tap_support
from gns3server.compute.ubridge.ubridge_error import UbridgeError
from gns3server.utils.kernel_anchor import kernel_anchor_name
log = logging.getLogger(__name__)
@ -95,6 +111,15 @@ class IOLDockerVM(VendorDockerVM):
_startup_config_content: str | None = None
_startup_config_dirty = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Kernel datapath (see KernelDatapathMixin): one persistent TAP per
# Ethernet bay/unit, held by uBridge's port bridge while a kernel
# link binds the port (bridge add_nio_tap).
self._kernel_taps = {}
self._tap_datapath = False
def _parse_vendor_environment(self):
super()._parse_vendor_environment()
@ -383,3 +408,268 @@ class IOLDockerVM(VendorDockerVM):
"""
self._permissions_fixed = True
# ------------------------------------------------------------------
# Kernel datapath (see KernelDatapathMixin)
# ------------------------------------------------------------------
def _kernel_host_ifc(self, adapter_number, port_number=0):
"""
The persistent TAP this Ethernet bay/unit owns (one 4-port unit per
adapter, the IOU model), or None before the node started — or on a
uBridge that cannot serve the anchor lifecycle, in which case the
node runs relay-only.
"""
return self._kernel_taps.get((adapter_number, port_number))
def _kernel_anchors(self):
"""
Every anchor TAP this node currently owns.
"""
return set(self._kernel_taps.values())
def _kernel_error(self, message):
return DockerError(message)
def _tap_name(self, adapter_number, port_number):
"""
Deterministic anchor TAP name for an Ethernet bay/unit — the shared
utils.kernel_anchor naming contract under the ``iol_docker`` key, so
the controller names a peer's anchor with the same function when an
Ethernet switch absorbs it.
"""
return kernel_anchor_name("iol_docker", self._id, adapter_number, port_number)
async def _start_ubridge(self, require_privileged_access=False):
"""
Override: with the control channel up, give every Ethernet bay/unit
its persistent TAP anchor — before the first link can attach (the
start loop calls _add_ubridge_connection right after this). The
anchors exist whether or not any link ever uses them: an anchor born
with the link would leave the switch fast path's deferred join
waiting forever (a link to a stopped node must not fail — and one to
a just-started node must not silently miss its anchor either).
"""
await super()._start_ubridge(require_privileged_access=require_privileged_access)
await self._prepare_tap_datapath()
async def _prepare_tap_datapath(self):
"""
Probe uBridge for the commands the anchor lifecycle needs — the tap
module (``tap create``/``tap delete``, the ``ubridge_tap``
capability) and the bridge module's swappable TAP leg
(``bridge delete_nio_tap``, ``ubridge_bridge_tap``) — and when both
answer, create the persistent TAP every Ethernet bay/unit owns.
A uBridge missing either keeps this node on the relay datapath:
every link rides unix ↔ UDP through the port bridges, exactly as
before the kernel datapath existed.
"""
self._tap_datapath = False
self._kernel_taps.clear()
if await probe_bridge_tap_support() is not True:
log.info(
"IOL container '%s': this compute's uBridge cannot swap a bridge's TAP leg "
"(bridge delete_nio_tap); the node runs on the relay datapath and cannot "
"carry kernel links",
self._name,
)
return
if await probe_tap_support() is not True:
log.info(
"IOL container '%s': this compute's uBridge has no persistent-TAP module; "
"the node runs on the relay datapath and cannot carry kernel links",
self._name,
)
return
self._tap_datapath = True
for adapter_number in range(0, len(self._ethernet_adapters)):
for port_number in range(0, self._ethernet_adapters[adapter_number].interfaces):
tap = self._tap_name(adapter_number, port_number)
# A persistent TAP outlives a crash: sweep any leftover of a
# previous run before recreating it (a surviving device would
# also keep its stale tc qdisc).
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'tap delete "{tap}"')
await self._ubridge_send(f'tap create "{tap}"')
# Carrier off until a link attaches (the anchor contract).
await self._ubridge_send(f'link set "{tap}" down')
self._kernel_taps[(adapter_number, port_number)] = tap
log.debug(
"IOL container '%s': anchor TAP %s created for bay %d unit %d",
self._name,
tap,
adapter_number,
port_number,
)
async def _ensure_anchor(self, adapter_number, port_number):
"""
The ensure half of the frozen ensure-then-add contract: ``bridge
add_nio_tap`` deliberately opens a *transient* TAP when the name is
absent (cloud's bridge-interface path depends on it), so a persistent
anchor swept away between the node's start and the link's attach
would silently become a device that dies with the fd. ``tap create``
is strictly create-only (``IFF_TUN_EXCL`` — re-creating an existing
persistent device answers EBUSY), so the existence check is local:
uBridge runs on this host, and /sys/class/net names every device.
The repair create starts DOWN like every anchor at birth, so the
carrier pass in the attach flow is what brings it up.
"""
tap = self._tap_name(adapter_number, port_number)
if os.path.exists(os.path.join("/sys/class/net", tap)):
return
await self._ubridge_send(f'tap create "{tap}"')
async def _add_ubridge_connection(self, nio, adapter_number, port_number=0):
"""
Override: the vendor rejection of kernel-datapath NIOs does not
apply here — IOL runner adapters own persistent TAP anchors, so a
NIOBridge wires the port bridge's topology leg to the anchor instead
of raising (_connect_nio routes it; the port bridge and its unix NIO
are ensured inside).
"""
if isinstance(nio, NIOBridge):
await self._connect_nio(adapter_number, nio, port_number)
return
await super()._add_ubridge_connection(nio, adapter_number, port_number)
async def _connect_nio(self, adapter_number, nio, port_number=0):
"""
Override: route the NIO onto the port bridge's topology leg. A
kernel link (NIOBridge) swaps that leg to the port's anchor TAP and
runs the shared mixin flow on it; a relay NIO keeps the unix ↔ UDP
shape of the base class (the port bridge and its unix NIO ensured
first, so the base path only adds the UDP half).
"""
if isinstance(nio, NIOBridge):
await self._attach_kernel_link(adapter_number, port_number, nio)
return
await self._ensure_unix_port_bridge(adapter_number, port_number)
await super()._connect_nio(adapter_number, nio, port_number)
async def _attach_kernel_link(self, adapter_number, port_number, nio):
"""
Wire a kernel link's NIO (NIOBridge) on this port: the port bridge's
topology leg becomes the anchor TAP (``bridge add_nio_tap`` — uBridge
opens and holds the fd, relaying the unix-socket guest leg onto the
device), then the anchor is enslaved into the per-link kernel bridge
— the shared mixin flow, with the TAP playing the role Docker's veth
and QEMU's TAP play. The relay starts only once both NIOs are in
(uBridge requires two), which is also what carries the guest frames
onto the anchor.
"""
anchor = self._kernel_host_ifc(adapter_number, port_number)
if anchor is None:
raise DockerError(
"Bay {bay}/{unit} of IOL container '{name}' has no TAP anchor to carry a kernel "
"link (this compute's uBridge lacks the tap module or bridge delete_nio_tap, "
"or the node was started before the kernel-datapath support); restart the "
"node after upgrading uBridge".format(bay=adapter_number, unit=port_number, name=self._name)
)
await self._ensure_unix_port_bridge(adapter_number, port_number)
await self._ensure_anchor(adapter_number, port_number)
bridge_name = self._bridge_name(adapter_number, port_number)
await self._ubridge_send(f'bridge add_nio_tap {bridge_name} "{anchor}"')
# Per-link kernel bridge (brctl), capture, markers and impairment
# filters on the anchor — everything keyed on the interface name.
await self._kernel_attach(anchor, nio)
# Two NIOs now: start the [unix ↔ tap] relay.
await self._ubridge_send(f"bridge start {bridge_name}")
# The carrier pass refines the anchor's admin state (a suspended NIO
# sets it back down) — it is also what brings a born-down anchor up.
await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number)
async def _release_port_tap(self, adapter_number, port_number):
"""
The teardown half of the swap contract: stop the port bridge (its
relay threads hold the NIO pointers for their whole life — freeing a
NIO under them is a use-after-free, which is why delete_nio_tap
refuses while running), then release the TAP NIO by name. The anchor
device itself survives (the node owns it, not the link); the c-socket
binding survives too (stop keeps every NIO), so the container's
egress frames queue on the socket until a leg is swapped in again.
"""
bridge_name = self._bridge_name(adapter_number, port_number)
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f"bridge stop {bridge_name}")
tap = self._kernel_taps.get((adapter_number, port_number))
if tap is not None:
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'bridge delete_nio_tap {bridge_name} "{tap}"')
async def adapter_remove_nio_binding(self, adapter_number, port_number=0):
"""
Override: a kernel link leaves through the port-leg swap (stop +
delete_nio_tap) before the shared anchor-side teardown — markers,
tc reset, brctl delif and the per-link bridge deletion — runs in the
base path.
"""
if self.ubridge:
try:
adapter = self._ethernet_adapters[adapter_number]
except IndexError:
adapter = None
if adapter is not None and isinstance(adapter.get_nio(port_number), NIOBridge):
await self._release_port_tap(adapter_number, port_number)
await super().adapter_remove_nio_binding(adapter_number, port_number)
async def _stop_ubridge(self):
"""
Override: release the kernel datapath's host state while the control
channel is still up, in the order the fd holders dictate — the port
bridges hold the anchor TAP fds (``bridge add_nio_tap``), so they go
first (``bridge delete`` stops their relay threads and frees the
NIOs), then the anchors themselves (``tap delete`` answers EBADFD on
a device another fd still holds), then the per-link kernel bridges.
The port bridges die with the process anyway, but the anchors are
persistent: deleting them here is what keeps a stopped node from
littering the host with ``gx`` devices. The next start sweeps and
re-creates everything.
"""
if self.ubridge:
# stop() clears _bridges before this point, so derive the port
# bridge names from the adapters (the historical naming is a
# pure function of adapter/port).
for adapter_number in range(0, len(self._ethernet_adapters)):
adapter = self._ethernet_adapters[adapter_number]
for port_number in range(0, adapter.interfaces):
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f"bridge delete {self._bridge_name(adapter_number, port_number)}")
for tap in self._kernel_taps.values():
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'tap delete "{tap}"')
await self._remove_kernel_bridges()
self._kernel_taps.clear()
self._tap_datapath = False
self._ubridge_tc_caps = None
await super()._stop_ubridge()
async def _set_adapter_carrier(self, adapter_number, connected, port_number=0):
"""
Override: refine the vendor no-op. A port with an anchor (the kernel
datapath) toggles the anchor's admin state — the port bridge's TAP
NIO writes answer EIO and its reads fall silent while it is down,
which is the suspend semantics of every other anchored node type. A
port without an anchor (relay datapath on a uBridge without the
swappable leg) keeps the no-op: the unix-socket pair has no carrier,
and the port bridge carries no TAP NIO for the base class to toggle.
"""
if self._kernel_host_ifc(adapter_number, port_number) is not None:
await KernelDatapathMixin._set_adapter_carrier(self, adapter_number, connected, port_number)

View File

@ -395,7 +395,28 @@ class VendorDockerVM(DockerVM):
Override: with GNS3_UNIX_SOCKET_NIO, bridge the adapter port through
the image's AF_UNIX datagram socket pair (raw Ethernet frames)
instead of a TAP interface moved into the container's network
namespace.
namespace — see ``_ensure_unix_port_bridge`` for the wiring.
"""
if not self._unix_socket_nio:
return await super()._add_ubridge_connection(nio, adapter_number, port_number)
if isinstance(nio, NIOBridge):
raise DockerError(
f"Container '{self._name}' bridges its adapters through AF_UNIX sockets; "
"kernel-datapath links are not supported for this container"
)
await self._ensure_unix_port_bridge(adapter_number, port_number)
if nio:
await self._connect_nio(adapter_number, nio, port_number)
async def _ensure_unix_port_bridge(self, adapter_number, port_number):
"""
The adapter port's uBridge bridge with its unix-socket NIO — the
guest leg every link topology rides on. Idempotent: an already
created bridge (this start wired it) is left alone.
Ports are addressed flat across adapters: interface index = adapter
number × ports-per-adapter + port number (single-port adapters
@ -413,15 +434,6 @@ class VendorDockerVM(DockerVM):
uses.
"""
if not self._unix_socket_nio:
return await super()._add_ubridge_connection(nio, adapter_number, port_number)
if isinstance(nio, NIOBridge):
raise DockerError(
f"Container '{self._name}' bridges its adapters through AF_UNIX sockets; "
"kernel-datapath links are not supported for this container"
)
try:
adapter = self._ethernet_adapters[adapter_number]
except IndexError:
@ -429,6 +441,8 @@ class VendorDockerVM(DockerVM):
interface_number = adapter_number * adapter.interfaces + port_number
bridge_name = self._bridge_name(adapter_number, port_number)
if bridge_name in self._bridges:
return
try:
await self._ubridge_send(f"bridge create {bridge_name}")
self._bridges.add(bridge_name)
@ -470,9 +484,6 @@ class VendorDockerVM(DockerVM):
remote_sock,
)
if nio:
await self._connect_nio(adapter_number, nio, port_number)
async def _set_adapter_carrier(self, adapter_number, connected, port_number=0):
"""
Override: with GNS3_UNIX_SOCKET_NIO the bridges carry only unix and

View File

@ -20,10 +20,12 @@ 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).
``gd`` for a Dynamips port TAP, ``gx`` for an IOL runner container's port
TAP (a Docker node whose guest leg is unix sockets, so its anchor is a TAP
like IOU's, not a veth). 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
@ -40,12 +42,32 @@ log = logging.getLogger(__name__)
# Docker veth guest end) is container-local and deliberately absent.
ANCHOR_PREFIX = {
"docker": "gv",
"iol_docker": "gx",
"qemu": "gq",
"iou": "gi",
"dynamips": "gd",
}
def kernel_anchor_type(node_type, environment=None):
"""
The anchor-type key a node of this type and environment names its
anchors under. IOL runner containers are Docker nodes (node_type
"docker") whose anchors are persistent TAPs, not veth host ends — their
own key keeps the ``gv`` veth namespace free of TAPs (stale-interface
sweeps and human eyes both key on the prefix). Every other node type is
its own key unchanged.
"""
# The marker check mirrors utils.application_id.is_iol_runner_environment
# inline: this module sits below every node module (compute and
# controller alike import it during their own import), so it must not
# depend on anything that pulls the controller package in.
if node_type == "docker" and "GNS3_IOL_RUNNER=" in (environment or ""):
return "iol_docker"
return node_type
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
@ -59,7 +81,8 @@ 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).
link). Accepts either a raw node type or an anchor-type key from
:func:`kernel_anchor_type`.
"""
prefix = ANCHOR_PREFIX.get(node_type)

View File

@ -0,0 +1,395 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software and is distributed under the same terms as gns3-server.
# See the LICENSE file for licensing information.
#
"""
Kernel-datapath tests for IOLDockerVM: the persistent TAP anchors (one per
Ethernet bay/unit), the per-port bridge's swappable topology leg (unix ↔ UDP
relay vs unix ↔ TAP anchor) and the teardown order the fd holders dictate.
Image-free: everything is asserted against the uBridge command stream and
the anchor naming contract.
"""
import uuid
import pytest
import pytest_asyncio
from unittest.mock import MagicMock, patch
from tests.utils import AsyncioMagicMock
from gns3server.compute.docker import Docker
from gns3server.compute.docker.docker_error import DockerError
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f"
TAP00 = "gx00010203e0p0" # the vm fixture's node id, bay 0 unit 0
TAP01 = "gx00010203e0p1"
TAP10 = "gx00010203e1p0"
PORT_BRIDGE = "bridge0" # adapter 0 port 0 (single-port naming)
BRIDGE = "gns3abc123def45" # the per-link kernel bridge carried by the NIO
@pytest_asyncio.fixture
async def manager(port_manager):
m = Docker.instance()
m.port_manager = port_manager
return m
@pytest_asyncio.fixture(scope="function")
async def vm(compute_project, manager):
vm = IOLDockerVM(
"iol-xe-1",
NODE_ID,
compute_project,
manager,
"iol-xe/iol-xe:17-18-02",
environment="GNS3_IOL_RUNNER=1",
adapters=2,
)
vm._cid = "e90e34656842"
vm.application_id = 700
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_hypervisor.is_running.return_value = True
vm._ubridge_send = AsyncioMagicMock()
return vm
def _capable(vm, monkeypatch):
"""Make both anchor-lifecycle probes answer True."""
async def probe(timeout=15.0):
return True
monkeypatch.setattr("gns3server.compute.docker.iol_docker_vm.probe_bridge_tap_support", probe)
monkeypatch.setattr("gns3server.compute.docker.iol_docker_vm.probe_tap_support", probe)
return vm
def _commands(vm):
return [str(c.args[0]) for c in vm._ubridge_send.call_args_list]
# ---------------------------------------------------------------------------
# Anchor lifecycle
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prepare_tap_datapath_creates_one_tap_per_bay_unit(vm, monkeypatch):
"""
On a capable uBridge every Ethernet bay/unit gets its persistent TAP —
four units per adapter (the IOU model), swept of leftovers first and
born down. No set_owner: uBridge itself holds these fds.
"""
_capable(vm, monkeypatch)
await vm._prepare_tap_datapath()
assert vm._tap_datapath is True
assert set(vm._kernel_taps) == {(bay, unit) for bay in range(2) for unit in range(4)}
assert vm._kernel_taps[(0, 0)] == TAP00
commands = _commands(vm)
assert f'tap delete "{TAP00}"' in commands # the sweep precedes every create
assert commands.index(f'tap delete "{TAP00}"') < commands.index(f'tap create "{TAP00}"')
assert f'link set "{TAP00}" down' in commands
assert not any("set_owner" in c for c in commands)
@pytest.mark.asyncio
async def test_prepare_tap_datapath_falls_back_without_either_probe(vm, monkeypatch):
"""
A uBridge without the swappable TAP leg (or without the tap module at
all) keeps the node on the relay datapath — it still starts, it just
cannot carry kernel links.
"""
async def probe(timeout=15.0):
return None
monkeypatch.setattr("gns3server.compute.docker.iol_docker_vm.probe_bridge_tap_support", probe)
monkeypatch.setattr("gns3server.compute.docker.iol_docker_vm.probe_tap_support", probe)
await vm._prepare_tap_datapath()
assert vm._tap_datapath is False
assert vm._kernel_taps == {}
assert not any("tap create" in c for c in _commands(vm))
def test_tap_name_fits_ifnamsiz(vm):
"""
The anchor name has to fit IFNAMSIZ (15) even for a late bay/unit.
"""
assert len(vm._tap_name(7, 3)) <= 15
# ---------------------------------------------------------------------------
# Link attach / detach (the port bridge's swappable topology leg)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_attach_kernel_link_swaps_the_port_leg_to_the_anchor(vm):
"""
A kernel link ensures the anchor (the frozen ensure-then-add contract —
add_nio_tap on an absent name would open a transient TAP), swaps the
port bridge's topology leg to it, runs the shared mixin flow (per-link
kernel bridge, capture/markers/filters) and starts the relay — the unix
binding untouched throughout.
"""
vm._kernel_taps[(0, 0)] = TAP00
vm._bridges.add(PORT_BRIDGE) # the start flow registered the port bridge
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
await vm._attach_kernel_link(0, 0, nio)
commands = _commands(vm)
assert f'tap create "{TAP00}"' in commands # ensure-then-add
assert commands.index(f'tap create "{TAP00}"') < commands.index(f'bridge add_nio_tap {PORT_BRIDGE} "{TAP00}"')
assert f'brctl create "{BRIDGE}"' in commands
assert f'brctl addif "{BRIDGE}" "{TAP00}"' in commands
assert commands.index(f'brctl addif "{BRIDGE}" "{TAP00}"') < commands.index(f"bridge start {PORT_BRIDGE}")
assert f'link set "{TAP00}" up' in commands # the carrier pass brings the anchor up
assert not any("add_nio_udp" in c for c in commands)
@pytest.mark.asyncio
async def test_ensure_anchor_creates_only_when_missing(vm, monkeypatch):
"""
tap create is strictly create-only (IFF_TUN_EXCL — an existing device
answers EBUSY), so the ensure checks existence first: a present anchor
costs no command, a swept one is recreated (the repair half of the
ensure-then-add contract).
"""
vm._kernel_taps[(0, 0)] = TAP00
with monkeypatch.context() as missing:
missing.setattr("os.path.exists", lambda p: False)
await vm._ensure_anchor(0, 0)
assert f'tap create "{TAP00}"' in _commands(vm)
vm._ubridge_send.reset_mock()
with monkeypatch.context() as present:
present.setattr("os.path.exists", lambda p: True)
await vm._ensure_anchor(0, 0)
vm._ubridge_send.assert_not_called()
@pytest.mark.asyncio
async def test_attach_kernel_link_without_anchor_raises(vm):
"""
A kernel link on a node that never anchored (relay-only uBridge, or a
start that predates the kernel datapath) is an error naming the relay
datapath, not a silent fallback.
"""
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
with pytest.raises(DockerError, match="no TAP anchor"):
await vm._attach_kernel_link(0, 0, nio)
@pytest.mark.asyncio
async def test_add_ubridge_connection_routes_a_kernel_nio_to_the_anchor(vm):
"""
The start-flow entry (also the restart-with-attached-link path) accepts
a kernel NIO where the vendor class rejects it: the port bridge is
ensured, then the same attach flow runs.
"""
vm._kernel_taps[(0, 0)] = TAP00
vm._bridges.add(PORT_BRIDGE)
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
await vm._add_ubridge_connection(nio, 0, 0)
assert f'bridge add_nio_tap {PORT_BRIDGE} "{TAP00}"' in _commands(vm)
@pytest.mark.asyncio
async def test_add_nio_binding_relay_keeps_the_unix_udp_shape(vm):
"""
A relay link on a running node wires only the UDP half — the port bridge
and its unix NIO already exist from the start flow.
"""
vm.status = "started"
vm._bridges.add(PORT_BRIDGE)
nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001})
await vm.adapter_add_nio_binding(0, nio, 0)
commands = _commands(vm)
assert "bridge add_nio_udp bridge0 20000 127.0.0.1 20001" in commands
assert "bridge start bridge0" in commands
assert not any("add_nio_tap" in c for c in commands)
@pytest.mark.asyncio
async def test_update_nio_binding_kernel_reapplies_on_the_anchor(vm):
"""
A filter update on an attached kernel link reconciles on the anchor (tc)
without re-swapping the port leg or re-enslaving the TAP.
"""
vm.status = "started"
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
nio.filters = {"delay": [10]}
await vm.adapter_update_nio_binding(0, nio, 0)
commands = _commands(vm)
assert f'tc reset "{TAP00}"' in commands
assert f'tc netem set "{TAP00}" delay 10' in commands
assert not any("add_nio_tap" in c for c in commands)
assert not any("brctl addif" in c for c in commands)
@pytest.mark.asyncio
async def test_update_nio_binding_kernel_suspend_flaps_the_anchor(vm):
"""
Suspending a kernel link admin-downs the anchor: the port bridge's TAP
writes fail EIO (dropped) and nothing comes back — a genuinely dead
link, where the relay datapath used the synthetic filter instead.
"""
vm.status = "started"
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
nio.suspend = True
await vm.adapter_update_nio_binding(0, nio, 0)
assert f'link set "{TAP00}" down' in _commands(vm)
@pytest.mark.asyncio
async def test_remove_nio_binding_kernel_releases_the_port_leg_first(vm):
"""
Removing a kernel link stops the port bridge (its relay threads hold the
NIO pointers for their whole life — delete_nio_tap refuses while
running), releases the TAP NIO by name, then the shared anchor-side
teardown runs: qdisc reset, brctl delif, per-link bridge deletion.
"""
vm._kernel_taps[(0, 0)] = TAP00
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
await vm.adapter_remove_nio_binding(0, 0)
commands = _commands(vm)
assert f"bridge stop {PORT_BRIDGE}" in commands
assert f'bridge delete_nio_tap {PORT_BRIDGE} "{TAP00}"' in commands
assert commands.index(f"bridge stop {PORT_BRIDGE}") < commands.index(f'bridge delete_nio_tap {PORT_BRIDGE} "{TAP00}"')
assert f'tc reset "{TAP00}"' in commands
assert f'brctl delif "{BRIDGE}" "{TAP00}"' in commands
assert f'brctl delete "{BRIDGE}"' in commands
assert commands.index(f'bridge delete_nio_tap {PORT_BRIDGE} "{TAP00}"') < commands.index(f'tc reset "{TAP00}"')
@pytest.mark.asyncio
async def test_remove_nio_binding_relay_keeps_the_udp_teardown(vm):
"""
Removing a relay link keeps the historical teardown: stop + UDP NIO
removal, the unix NIO and its binding untouched.
"""
vm.status = "started"
vm._bridges.add(PORT_BRIDGE)
nio = vm.manager.create_nio({"type": "nio_udp", "lport": 20000, "rhost": "127.0.0.1", "rport": 20001})
vm._ethernet_adapters[0].add_nio(0, nio)
await vm.adapter_remove_nio_binding(0, 0)
commands = _commands(vm)
assert f"bridge stop {PORT_BRIDGE}" in commands
assert "bridge remove_nio_udp bridge0 20000 127.0.0.1 20001" in commands
assert not any("delete_nio_tap" in c for c in commands)
# ---------------------------------------------------------------------------
# Stop order (the port bridges hold the anchor fds)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stop_ubridge_releases_fds_before_deleting_taps(vm):
"""
The port bridges hold the anchor TAP fds (bridge add_nio_tap), so they
are deleted first — closing the fds — before the taps are unpersisted
(tap delete answers EBADFD on a held device), and the per-link kernel
bridges still need the live control channel. All before the hypervisor
itself stops.
"""
vm._kernel_taps[(0, 0)] = TAP00
vm._kernel_taps[(1, 0)] = TAP10
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio)
with patch("gns3server.compute.docker.docker_vm.DockerVM._stop_ubridge", new=AsyncioMagicMock()) as stop:
await vm._stop_ubridge()
stop.assert_called_once()
commands = _commands(vm)
assert f"bridge delete {PORT_BRIDGE}" in commands
assert "bridge delete bridge1" in commands # bay 1 unit 0 (port 0 keeps the historical name)
assert commands.index(f"bridge delete {PORT_BRIDGE}") < commands.index(f'tap delete "{TAP00}"')
assert commands.index("bridge delete bridge1") < commands.index(f'tap delete "{TAP10}"')
assert commands.index(f'tap delete "{TAP00}"') < commands.index(f'brctl delete "{BRIDGE}"')
assert vm._kernel_taps == {}
assert vm._tap_datapath is False
@pytest.mark.asyncio
async def test_start_ubridge_prepares_the_datapath_before_any_link(vm):
"""
The anchors are born with the node (uBridge control channel up, before
the start loop attaches links): an anchor that appears only with its
link would leave the switch fast path's deferred join waiting forever.
"""
with (
patch("gns3server.compute.base_node.BaseNode._start_ubridge", new=AsyncioMagicMock()) as start,
patch.object(IOLDockerVM, "_prepare_tap_datapath", new=AsyncioMagicMock()) as prepare,
):
await vm._start_ubridge(require_privileged_access=True)
start.assert_called_once()
prepare.assert_called_once()
# ---------------------------------------------------------------------------
# Anchor naming (the shared contract)
# ---------------------------------------------------------------------------
def test_anchor_names_follow_the_iol_docker_key():
"""
The controller names an IOL runner's anchor for switch absorption with
the same function the compute creates it under — the iol_docker key,
never the docker veth namespace.
"""
from gns3server.utils.kernel_anchor import kernel_anchor_name, kernel_anchor_type
environment = "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=cfg.txt"
assert kernel_anchor_type("docker", environment) == "iol_docker"
assert kernel_anchor_type("docker", "GNS3_UNIX_SOCKET_NIO=1") == "docker"
assert kernel_anchor_name(kernel_anchor_type("docker", environment), NODE_ID, 0, 1) == TAP01

View File

@ -650,8 +650,8 @@ async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manage
async def test_link_operations_never_send_tap_carrier(compute_project, manager):
"""
uBridge rejects set_nio_tap_carrier on a bridge without a TAP NIO
("bridge has no TAP NIO"), and unix-socket NIO bridges never have one:
every link create/update/delete on a running node would fail. The
("bridge has no TAP NIO"), and unix-socket NIO relay bridges never have
one: every link create/update/delete on a running node would fail. The
vendor override must keep the carrier command out of all three paths.
"""
@ -659,6 +659,10 @@ async def test_link_operations_never_send_tap_carrier(compute_project, manager):
vm._ubridge_hypervisor = MagicMock() # truthy ubridge, like a started node
vm._ubridge_send = AsyncioMagicMock()
vm.status = "started"
# A started node's port bridge exists and is registered by the start
# flow — record it, or the relay attach would run the ensure path
# (bridge create + socket wait) this fake never set up.
vm._bridges.add(vm._bridge_name(0, 0))
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
await vm.adapter_add_nio_binding(0, nio, 0)

View File

@ -21,7 +21,7 @@ Ethernet-switch link), plus the cascade veth naming for switch-to-switch
links.
"""
from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name, kernel_cascade_names
from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name, kernel_anchor_type, kernel_cascade_names
NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f"
LINK_ID = "1a2b3c4d-5e6f-4a5b-8c9d-0e1f2a3b4c5d"
@ -32,6 +32,22 @@ def test_every_node_type_has_its_own_prefix():
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"
assert kernel_anchor_name("iol_docker", NODE_ID, 0, 0) == "gx00010203e0p0"
def test_anchor_type_keys_iol_runner_containers_apart():
"""
IOL runner containers are Docker nodes whose anchors are persistent TAPs,
not veth host ends — their own key keeps the gv namespace free of TAPs.
The marker check must ignore a mere mention of the knob and survive a
multi-line environment.
"""
assert kernel_anchor_type("docker", "GNS3_IOL_RUNNER=1") == "iol_docker"
assert kernel_anchor_type("docker", "GNS3_IOL_STARTUP_CONFIG=cfg.txt\nGNS3_IOL_RUNNER=1") == "iol_docker"
assert kernel_anchor_type("docker") == "docker"
assert kernel_anchor_type("docker", "GNS3_UNIX_SOCKET_NIO=1") == "docker"
assert kernel_anchor_type("qemu") == "qemu"
def test_unknown_node_types_have_no_anchor():
@ -62,7 +78,7 @@ def test_cascade_names_fit_ifnamsiz():
def test_cascade_names_stay_out_of_the_node_anchor_namespace():
# "gs" cannot collide with any node anchor prefix (gv/gq/gi/gd), so a
# "gs" cannot collide with any node anchor prefix (gv/gq/gi/gd/gx), so a
# cascade end is never mistaken for another node's anchor
for end in kernel_cascade_names(LINK_ID):
assert not any(end.startswith(prefix) for prefix in ("gv", "gq", "gi", "gd"))
assert not any(end.startswith(prefix) for prefix in ("gv", "gq", "gi", "gd", "gx"))