diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 25948a344..9d123478c 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -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 diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index b8e30ec36..212ef1390 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -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) + diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index c1fac493b..3daa935e5 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -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 diff --git a/gns3server/utils/kernel_anchor.py b/gns3server/utils/kernel_anchor.py index 556694097..9cf72b77e 100644 --- a/gns3server/utils/kernel_anchor.py +++ b/gns3server/utils/kernel_anchor.py @@ -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{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) diff --git a/tests/compute/docker/test_iol_docker_kernel_datapath.py b/tests/compute/docker/test_iol_docker_kernel_datapath.py new file mode 100644 index 000000000..8c7a4f7db --- /dev/null +++ b/tests/compute/docker/test_iol_docker_kernel_datapath.py @@ -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 diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 23fcb0e36..49b1952b5 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -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) diff --git a/tests/utils/test_kernel_anchor.py b/tests/utils/test_kernel_anchor.py index 7b88ffd9c..7f5558d1a 100644 --- a/tests/utils/test_kernel_anchor.py +++ b/tests/utils/test_kernel_anchor.py @@ -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"))