From 0ca9ccc63710affa790281b609a7194e08aefe71 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 30 Aug 2026 21:33:38 +0800 Subject: [PATCH] feat: model IOL adapters as 4-port units like the IOU node type IOL interfaces come in 4-port units (Ethernet0/0-3, Ethernet1/0-3, ...), addressed like IOU as (adapter_number, port_number 0-3): - IOLDockerVM builds EthernetAdapter(interfaces=4) per adapter and asks the runner for adapters x 4 interfaces (num-eth). - DockerVM threads port_number through the NIO/capture API (the compute routes parsed it from the URL but dropped it); single-port adapters keep the historical bridge{N} names and command sequence, multi-port adapters get one bridge per port (bridge{a}_{p}). - The unix-socket NIO wiring addresses sockets flat across adapters: adapter x ports-per-adapter + port, so single-port images keep their exact socket layout. - The controller port list for GNS3_IOL_RUNNER docker nodes is generated by StandardPortFactory with the IOU naming (Ethernet{segment0}/{port0}, segment size 4); plain docker nodes keep eth{N}. --- docs/features/iol-runner-docker.md | 27 ++-- gns3server/api/routes/compute/docker_nodes.py | 14 +- gns3server/compute/docker/docker_vm.py | 153 +++++++++++------- gns3server/compute/docker/iol_docker_vm.py | 26 ++- gns3server/compute/docker/vendor_docker_vm.py | 30 ++-- gns3server/controller/node.py | 43 +++-- tests/compute/docker/test_docker_vm.py | 4 +- tests/compute/docker/test_iol_docker_vm.py | 54 ++++++- tests/controller/test_node.py | 26 +++ 9 files changed, 267 insertions(+), 110 deletions(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 465dd981d..64aeb3a72 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -89,16 +89,10 @@ API docs for the auth flow), or in the Web UI under "image": "iol-xe/iol-xe:17-18-02", "category": "router", "symbol": ":/symbols/router.svg", - "adapters": 4, + "adapters": 2, "console_type": "telnet", "environment": "GNS3_IOL_RUNNER=1", - "extra_volumes": ["/config"], - "custom_adapters": [ - {"adapter_number": 0, "port_name": "Ethernet0/0"}, - {"adapter_number": 1, "port_name": "Ethernet0/1"}, - {"adapter_number": 2, "port_name": "Ethernet0/2"}, - {"adapter_number": 3, "port_name": "Ethernet0/3"} - ] + "extra_volumes": ["/config"] } ``` @@ -106,19 +100,19 @@ API docs for the auth flow), or in the Web UI under |---|---|---| | `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=` (default 2048). | | `extra_volumes` | `["/config"]` | `/tmp/run` is auto-added. **Never add `/tmp`** — it would persist the socket directory into the projects tree and uBridge would reject the too-long AF_UNIX path. | -| `custom_adapters` | one entry per adapter | Names each port after the IOL interface it maps to: adapter `N` → `Ethernet{N/4}/{N%4}` (same mechanism SR Linux uses for `mgmt0`/`e1-1`). Raise the list up to `Ethernet7/3` (32 ports, the IOL maximum) when raising `adapters`; adapters beyond the list fall back to `eth{N}`. | +| `adapters` | number of 4-port units | The IOU convention: one adapter = `Ethernet0/0`–`Ethernet0/3`, two adapters add `Ethernet1/0`–`1/3`, … (8 units / 32 ports max). Ports are addressed as (adapter, port 0–3) and shown grouped in the UI. | | `memory` | optional; `0` (default) = no cap | Unset works — Docker applies no limit. When you do set a cap, keep it at IOL memory + ~512 MB, or the cgroup OOM-killer shoots the router. | | `console_type` | `telnet` | The runner muxes the IOS console onto PID 1 stdio; `docker_exec` is not needed. | -| `adapters` | multiple of 4 | IOL port granularity (one 4-port unit per range). | ### Verify -1. The node's port list shows `Ethernet0/0`… (from the template's - `custom_adapters`). +1. The node's port list shows the grouped IOL interfaces + (`Ethernet0/0`–`Ethernet1/3` for two adapters), addressed + (adapter, port). 2. Drop a node into a project and start it — the console shows the `Linux Unix (i686)` banner within seconds. 3. `$XDG_RUNTIME_DIR/gns3/unixio//` contains `s00.sock`… (one - pair per adapter). + pair per port). 4. The startup-config lives at `project-files/docker//tmp/run/config` (interface names `Ethernet0/0`, not `GigabitEthernet0/0`). @@ -147,9 +141,10 @@ diagnosing wiring issues). * **Interface names are IOL-style `Ethernet0/0`**, not `GigabitEthernet0/0` (4 ports per unit, matching the adapter-count granularity) — startup configs addressing `GigabitEthernet…` are rejected by the parser. -* **Adapters**: change the adapter count while the node is stopped; the - config is regenerated on the next start and the runner creates the - matching socket set (IOL granularity is 4 ports per unit). +* **Adapters**: one adapter is a 4-port unit (the IOU model): change the + count while the node is stopped; the config is regenerated on the next + start (`num-eth` = adapters × 4) and the runner creates the matching + socket set. * **Stop before editing**: NVRAM is only flushed on a graceful stop (SIGTERM, "cleanup done" in `process.log`); a kill loses the running-config changes since the last `write memory`. diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index 63d8bea91..5070cd855 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -284,7 +284,7 @@ async def create_docker_node_nio( """ nio = Docker.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True)) - await node.adapter_add_nio_binding(adapter_number, nio) + await node.adapter_add_nio_binding(adapter_number, nio, port_number) return nio.asdict() @@ -302,13 +302,13 @@ async def update_docker_node_nio( The port number on the Docker node is always 0. """ - nio = node.get_nio(adapter_number) + nio = node.get_nio(adapter_number, port_number) nio.filters.clear() if nio_data.filters: nio.filters = nio_data.filters nio.markers = nio_data.markers or {} nio.suspend = nio_data.suspend - await node.adapter_update_nio_binding(adapter_number, nio) + await node.adapter_update_nio_binding(adapter_number, nio, port_number) return nio.asdict() @@ -327,7 +327,7 @@ async def delete_docker_node_nio( The port number on the Docker node is always 0. """ - await node.adapter_remove_nio_binding(adapter_number) + await node.adapter_remove_nio_binding(adapter_number, port_number) @router.post( @@ -346,7 +346,7 @@ async def start_docker_node_capture( """ pcap_file_path = os.path.join(node.project.capture_working_directory(), node_capture_data.capture_file_name) - await node.start_capture(adapter_number, pcap_file_path) + await node.start_capture(adapter_number, pcap_file_path, port_number) return {"pcap_file_path": str(pcap_file_path)} @@ -365,7 +365,7 @@ async def stop_docker_node_capture( The port number on the Docker node is always 0. """ - await node.stop_capture(adapter_number) + await node.stop_capture(adapter_number, port_number) @router.get( @@ -382,7 +382,7 @@ async def stream_pcap_file( The port number on the Docker node is always 0. """ - nio = node.get_nio(adapter_number) + nio = node.get_nio(adapter_number, port_number) stream = Docker.instance().stream_pcap_file(nio, node.project.id) return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 44a89f968..b226a0f39 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -899,19 +899,24 @@ class DockerVM(BaseNode): await self._start_ubridge(require_privileged_access=True) for adapter_number in range(0, self.adapters): - nio = self._ethernet_adapters[adapter_number].get_nio(0) - async with self.manager.ubridge_lock: - try: - await self._add_ubridge_connection(nio, adapter_number) - except UbridgeNamespaceError: - log.error("Container %s failed to start", self.name) - await self.stop() + adapter = self._ethernet_adapters[adapter_number] + # Single-port adapters (the standard case) loop once, keeping + # the historical command sequence; multi-port adapters + # (e.g. IOL's 4-port units) get one bridge per port. + for port_number in range(0, adapter.interfaces): + nio = adapter.get_nio(port_number) + async with self.manager.ubridge_lock: + try: + await self._add_ubridge_connection(nio, adapter_number, port_number) + except UbridgeNamespaceError: + log.error("Container %s failed to start", self.name) + await self.stop() - # The container can crash soon after the start, this means we can not move the interface to the container namespace - logdata = await self._get_log() - for line in logdata.split("\n"): - log.error(line) - raise DockerError(logdata) + # The container can crash soon after the start, this means we can not move the interface to the container namespace + logdata = await self._get_log() + for line in logdata.split("\n"): + log.error(line) + raise DockerError(logdata) await self._start_console_server() @@ -1406,6 +1411,21 @@ class DockerVM(BaseNode): """ return f"eth{adapter_number}" + def _bridge_name(self, adapter_number, port_number=0): + """ + uBridge bridge name for an adapter port. Adapters with a single + port (every standard Docker node) keep the historical + "bridge{adapter}" name; multi-port adapters (e.g. IOL's 4-port + units) get one bridge per port. + + :param adapter_number: adapter number + :param port_number: port number on the adapter + """ + + if port_number: + return f"bridge{adapter_number}_{port_number}" + return f"bridge{adapter_number}" + async def _start_interface_monitor(self): """Monitor administrative state changes for this container's adapters. @@ -1558,12 +1578,14 @@ class DockerVM(BaseNode): if writer: await self._close_interface_monitor_writer(writer) - async def _add_ubridge_connection(self, nio, adapter_number): + async def _add_ubridge_connection(self, nio, adapter_number, port_number=0): """ Creates a connection in uBridge. :param nio: NIO instance or None if it's a dummy interface (if an interface is missing in ubridge you can't see it via ifconfig in the container) :param adapter_number: adapter number + :param port_number: port number on the adapter (standard Docker + adapters have a single port, so this is always 0 on the TAP path) """ try: @@ -1575,6 +1597,13 @@ class DockerVM(BaseNode): ) ) + if port_number and adapter.interfaces == 1: + raise DockerError( + "Port {port_number} doesn't exist on adapter {adapter_number} of Docker container '{name}'".format( + name=self.name, port_number=port_number, adapter_number=adapter_number + ) + ) + for index in range(4096): if f"tap-gns3-e{index}" not in psutil.net_if_addrs(): adapter.host_ifc = f"tap-gns3-e{str(index)}" @@ -1585,12 +1614,12 @@ class DockerVM(BaseNode): name=self.name, adapter_number=adapter_number ) ) - bridge_name = f"bridge{adapter_number}" + bridge_name = self._bridge_name(adapter_number, port_number) await self._ubridge_send(f"bridge create {bridge_name}") self._bridges.add(bridge_name) await self._ubridge_send( - "bridge add_nio_tap bridge{adapter_number} {hostif} off".format( - adapter_number=adapter_number, hostif=adapter.host_ifc + "bridge add_nio_tap {bridge_name} {hostif} off".format( + bridge_name=bridge_name, hostif=adapter.host_ifc ) ) @@ -1618,23 +1647,24 @@ class DockerVM(BaseNode): log.debug(f"Created adapter {adapter_number} with MAC address {mac_address} in namespace {self._namespace}") if nio: - await self._connect_nio(adapter_number, nio) - await self._set_adapter_carrier(adapter_number, not nio.suspend) + await self._connect_nio(adapter_number, nio, port_number) + await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number) async def _get_namespace(self): result = await self.manager.query("GET", f"containers/{self._cid}/json") return int(result["State"]["Pid"]) - async def _set_adapter_carrier(self, adapter_number, connected): + async def _set_adapter_carrier(self, adapter_number, connected, port_number=0): """Replicate a Docker adapter's connection state on its TAP device.""" + bridge_name = self._bridge_name(adapter_number, port_number) state = "on" if connected else "off" - await self._ubridge_send(f"bridge set_nio_tap_carrier bridge{adapter_number} {state}") + await self._ubridge_send(f"bridge set_nio_tap_carrier {bridge_name} {state}") - async def _connect_nio(self, adapter_number, nio): + async def _connect_nio(self, adapter_number, nio, port_number=0): - bridge_name = f"bridge{adapter_number}" + bridge_name = self._bridge_name(adapter_number, port_number) await self._ubridge_send( "bridge add_nio_udp {bridge_name} {lport} {rhost} {rport}".format( bridge_name=bridge_name, lport=nio.lport, rhost=nio.rhost, rport=nio.rport @@ -1650,12 +1680,13 @@ class DockerVM(BaseNode): await self._ubridge_apply_filters(bridge_name, nio.filters) await self._ubridge_apply_markers(bridge_name, nio) - async def adapter_add_nio_binding(self, adapter_number, nio): + async def adapter_add_nio_binding(self, adapter_number, nio, port_number=0): """ Adds an adapter NIO binding. :param adapter_number: adapter number - :param nio: NIO instance to add to the slot/port + :param nio: NIO instance to add to the adapter/port + :param port_number: port number on the adapter (0 for single-port adapters) """ try: @@ -1667,38 +1698,47 @@ class DockerVM(BaseNode): ) ) - if self.status == "started" and self.ubridge: - await self._connect_nio(adapter_number, nio) - await self._set_adapter_carrier(adapter_number, not nio.suspend) + if not adapter.port_exists(port_number): + raise DockerError( + "Port {port_number} doesn't exist on adapter {adapter_number} of Docker container '{name}'".format( + name=self.name, port_number=port_number, adapter_number=adapter_number + ) + ) - adapter.add_nio(0, nio) + if self.status == "started" and self.ubridge: + await self._connect_nio(adapter_number, nio, port_number) + await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number) + + adapter.add_nio(port_number, nio) log.debug( "Docker container '{name}' [{id}]: {nio} added to adapter {adapter_number}".format( name=self.name, id=self._id, nio=nio, adapter_number=adapter_number ) ) - async def adapter_update_nio_binding(self, adapter_number, nio): + async def adapter_update_nio_binding(self, adapter_number, nio, port_number=0): """ Update an adapter NIO binding. :param adapter_number: adapter number :param nio: NIO instance to update the adapter + :param port_number: port number on the adapter (0 for single-port adapters) """ if self.ubridge: - bridge_name = f"bridge{adapter_number}" + bridge_name = self._bridge_name(adapter_number, port_number) if bridge_name in self._bridges: await self._ubridge_apply_filters(bridge_name, nio.filters) await self._ubridge_apply_markers(bridge_name, nio) if self.status == "started": - await self._set_adapter_carrier(adapter_number, not nio.suspend) + await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number) - async def adapter_remove_nio_binding(self, adapter_number): + async def adapter_remove_nio_binding(self, adapter_number, port_number=0): """ Removes an adapter NIO binding. :param adapter_number: adapter number + :param port_number: port number on the adapter (0 for single-port adapters) :returns: NIO instance """ @@ -1712,20 +1752,20 @@ class DockerVM(BaseNode): ) ) - await self.stop_capture(adapter_number) + await self.stop_capture(adapter_number, port_number) if self.ubridge: - nio = adapter.get_nio(0) - bridge_name = f"bridge{adapter_number}" + nio = adapter.get_nio(port_number) + bridge_name = self._bridge_name(adapter_number, port_number) if self.status == "started": - await self._set_adapter_carrier(adapter_number, False) + await self._set_adapter_carrier(adapter_number, False, port_number) await self._ubridge_send(f"bridge stop {bridge_name}") await self._ubridge_send( - "bridge remove_nio_udp bridge{adapter} {lport} {rhost} {rport}".format( - adapter=adapter_number, lport=nio.lport, rhost=nio.rhost, rport=nio.rport + "bridge remove_nio_udp {bridge_name} {lport} {rhost} {rport}".format( + bridge_name=bridge_name, lport=nio.lport, rhost=nio.rhost, rport=nio.rport ) ) - adapter.remove_nio(0) + adapter.remove_nio(port_number) log.debug( "Docker VM '{name}' [{id}]: {nio} removed from adapter {adapter_number}".format( @@ -1733,11 +1773,12 @@ class DockerVM(BaseNode): ) ) - def get_nio(self, adapter_number): + def get_nio(self, adapter_number, port_number=0): """ Gets an adapter NIO binding. :param adapter_number: adapter number + :param port_number: port number on the adapter (0 for single-port adapters) :returns: NIO instance """ @@ -1751,10 +1792,10 @@ class DockerVM(BaseNode): ) ) - nio = adapter.get_nio(0) + nio = adapter.get_nio(port_number) if not nio: - raise DockerError(f"Adapter {adapter_number} is not connected") + raise DockerError(f"Adapter {adapter_number} port {port_number} is not connected") return nio @@ -1800,46 +1841,49 @@ class DockerVM(BaseNode): await self.manager.pull_image(image, progress_callback=callback) - async def _start_ubridge_capture(self, adapter_number, output_file): + async def _start_ubridge_capture(self, adapter_number, output_file, port_number=0): """ Starts a packet capture in uBridge. :param adapter_number: adapter number :param output_file: PCAP destination file for the capture + :param port_number: port number on the adapter (0 for single-port adapters) """ - adapter = f"bridge{adapter_number}" + bridge_name = self._bridge_name(adapter_number, port_number) if not self.ubridge: raise DockerError("Cannot start the packet capture: uBridge is not running") - await self._ubridge_send(f'bridge start_capture {adapter} "{output_file}"') + await self._ubridge_send(f'bridge start_capture {bridge_name} "{output_file}"') - async def _stop_ubridge_capture(self, adapter_number): + async def _stop_ubridge_capture(self, adapter_number, port_number=0): """ Stops a packet capture in uBridge. :param adapter_number: adapter number + :param port_number: port number on the adapter (0 for single-port adapters) """ - adapter = f"bridge{adapter_number}" + bridge_name = self._bridge_name(adapter_number, port_number) if not self.ubridge: raise DockerError("Cannot stop the packet capture: uBridge is not running") - await self._ubridge_send(f"bridge stop_capture {adapter}") + await self._ubridge_send(f"bridge stop_capture {bridge_name}") - async def start_capture(self, adapter_number, output_file): + async def start_capture(self, adapter_number, output_file, port_number=0): """ Starts a packet capture. :param adapter_number: adapter number :param output_file: PCAP destination file for the capture + :param port_number: port number on the adapter (0 for single-port adapters) """ - nio = self.get_nio(adapter_number) + nio = self.get_nio(adapter_number, port_number) if nio.capturing: raise DockerError(f"Packet capture is already activated on adapter {adapter_number}") nio.start_packet_capture(output_file) if self.status == "started" and self.ubridge: - await self._start_ubridge_capture(adapter_number, output_file) + await self._start_ubridge_capture(adapter_number, output_file, port_number) log.debug( "Docker VM '{name}' [{id}]: starting packet capture on adapter {adapter_number}".format( @@ -1847,19 +1891,20 @@ class DockerVM(BaseNode): ) ) - async def stop_capture(self, adapter_number): + async def stop_capture(self, adapter_number, port_number=0): """ Stops a packet capture. :param adapter_number: adapter number + :param port_number: port number on the adapter (0 for single-port adapters) """ - nio = self.get_nio(adapter_number) + nio = self.get_nio(adapter_number, port_number) if not nio.capturing: return nio.stop_packet_capture() if self.status == "started" and self.ubridge: - await self._stop_ubridge_capture(adapter_number) + await self._stop_ubridge_capture(adapter_number, port_number) log.debug( "Docker VM '{name}' [{id}]: stopping packet capture on adapter {adapter_number}".format( diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index 6243f8a94..df53f2a78 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -43,7 +43,9 @@ import logging import os import shutil +from gns3server.compute.adapters.ethernet_adapter import EthernetAdapter from gns3server.compute.docker.docker_error import DockerHttp404Error +from gns3server.compute.docker.docker_vm import DockerVM from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM log = logging.getLogger(__name__) @@ -90,6 +92,28 @@ class IOLDockerVM(VendorDockerVM): except ValueError: pass + @DockerVM.adapters.setter + def adapters(self, adapters): + """ + Override: one IOL adapter is a 4-port unit — the IOU model. The + template's adapter count is the number of units (2 adapters = + Ethernet0/0-3 + Ethernet1/0-3); the generated config asks the runner + for adapters × 4 interfaces and links address ports as + (adapter_number, port_number 0-3). + """ + + if len(self._ethernet_adapters) == adapters: + return + + self._ethernet_adapters.clear() + for _ in range(0, adapters): + self._ethernet_adapters.append(EthernetAdapter(interfaces=4)) + + log.debug( + "IOL container '%s': number of 4-port Ethernet adapters set to %d", + self._name, adapters, + ) + def _persistent_volume_list(self, image_info, include_network_config=True): """ Override: the runner requires ``/config`` (its config file, generated @@ -168,7 +192,7 @@ class IOLDockerVM(VendorDockerVM): config = { "binary": "/binary.iol", "memory": self._iol_memory, - "num-eth": self.adapters, + "num-eth": self.adapters * 4, # every adapter is a 4-port unit "num-serial": 0, # GNS3 docker adapters are ethernet-only "local-app": 1, "remote-app": 2, diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 6e788a83d..d6157eea4 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -377,14 +377,17 @@ class VendorDockerVM(DockerVM): await super().delete() self._remove_unix_socket_host_dir() - async def _add_ubridge_connection(self, nio, adapter_number): + async def _add_ubridge_connection(self, nio, adapter_number, port_number=0): """ - Override: with GNS3_UNIX_SOCKET_NIO, bridge the adapter through the - image's AF_UNIX datagram socket pair (raw Ethernet frames) instead of - a TAP interface moved into the container's network namespace. + 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. - Per adapter N the image's network agent is expected to create, inside - GNS3_UNIX_SOCKET_DIR: + Ports are addressed flat across adapters: interface index = adapter + number × ports-per-adapter + port number (single-port adapters + reduce to the adapter number). Per interface N the image's network + agent is expected to create, inside GNS3_UNIX_SOCKET_DIR: * ``s{N:02d}.sock`` — its receive socket; frames sent there are injected into guest interface N; @@ -398,7 +401,7 @@ class VendorDockerVM(DockerVM): """ if not self._unix_socket_nio: - return await super()._add_ubridge_connection(nio, adapter_number) + return await super()._add_ubridge_connection(nio, adapter_number, port_number) try: adapter = self._ethernet_adapters[adapter_number] @@ -409,14 +412,15 @@ class VendorDockerVM(DockerVM): ) ) - bridge_name = f"bridge{adapter_number}" + interface_number = adapter_number * adapter.interfaces + port_number + bridge_name = self._bridge_name(adapter_number, port_number) try: await self._ubridge_send(f"bridge create {bridge_name}") self._bridges.add(bridge_name) wiring_dir = self._unix_socket_wiring_dir() - local_sock = os.path.join(wiring_dir, f"c{adapter_number:02d}.sock") - remote_sock = os.path.join(wiring_dir, f"s{adapter_number:02d}.sock") + local_sock = os.path.join(wiring_dir, f"c{interface_number:02d}.sock") + remote_sock = os.path.join(wiring_dir, f"s{interface_number:02d}.sock") # A c-socket left over from a previous ubridge run would fail its bind. with contextlib.suppress(OSError): @@ -443,12 +447,12 @@ class VendorDockerVM(DockerVM): raise adapter.host_ifc = local_sock # bookkeeping / removal logging only log.debug( - "Adapter %d of container '%s' wired via unix sockets %s <-> %s", - adapter_number, self._name, local_sock, remote_sock, + "Adapter %d port %d of container '%s' wired via unix sockets %s <-> %s", + adapter_number, port_number, self._name, local_sock, remote_sock, ) if nio: - await self._connect_nio(adapter_number, nio) + await self._connect_nio(adapter_number, nio, port_number) def _cleanup_console_resources(self): """ diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index 8ea491b3c..d144cfa47 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -804,22 +804,35 @@ class Node: self._ports = DynamipsPortFactory(self._properties) return elif self._node_type == "docker": - for adapter_number in range(0, self._properties["adapters"]): - custom_adapter_settings = {} - if self.custom_adapters: - for custom_adapter in self.custom_adapters: - if custom_adapter["adapter_number"] == adapter_number: - custom_adapter_settings = custom_adapter - break - port_name = f"eth{adapter_number}" - port_name = custom_adapter_settings.get("port_name", port_name) - mac_address = custom_adapter_settings.get("mac_address") - if not mac_address and "mac_address" in self._properties: - mac_address = int_to_macaddress(macaddress_to_int(self._properties["mac_address"]) + adapter_number) + if "GNS3_IOL_RUNNER=" in (self._properties.get("environment") or ""): + # IOL adapters are 4-port units (the IOU model): ports are + # Ethernet0/0-3, Ethernet1/0-3, … addressed as + # (adapter_number, port_number 0-3). + self._ports = StandardPortFactory( + self._properties, + 4, + self._first_port_name, + "Ethernet{segment0}/{port0}", + 4, + self.custom_adapters, + ) + else: + for adapter_number in range(0, self._properties["adapters"]): + custom_adapter_settings = {} + if self.custom_adapters: + for custom_adapter in self.custom_adapters: + if custom_adapter["adapter_number"] == adapter_number: + custom_adapter_settings = custom_adapter + break + port_name = f"eth{adapter_number}" + port_name = custom_adapter_settings.get("port_name", port_name) + mac_address = custom_adapter_settings.get("mac_address") + if not mac_address and "mac_address" in self._properties: + mac_address = int_to_macaddress(macaddress_to_int(self._properties["mac_address"]) + adapter_number) - port = PortFactory(port_name, 0, adapter_number, 0, "ethernet", short_name=port_name) - port.mac_address = mac_address - self._ports.append(port) + port = PortFactory(port_name, 0, adapter_number, 0, "ethernet", short_name=port_name) + port.mac_address = mac_address + self._ports.append(port) elif self._node_type in ("ethernet_switch", "ethernet_hub"): # Basic node we don't want to have adapter number port_number = 0 diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index 10336e2b1..71dbc2c39 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -1198,7 +1198,7 @@ async def test_start(vm, manager, free_console_port, tmpdir): await vm.start() mock_query.assert_called_with("POST", "containers/e90e34656842/start") - vm._add_ubridge_connection.assert_called_once_with(nio, 0) + vm._add_ubridge_connection.assert_called_once_with(nio, 0, 0) assert vm._start_ubridge.called assert vm._start_console.called assert vm._start_aux.called @@ -1253,7 +1253,7 @@ async def test_start_namespace_failed(vm, manager, free_console_port): await vm.start() mock_query.assert_any_call("POST", "containers/e90e34656842/start") - mock_add_ubridge_connection.assert_called_once_with(nio, 0) + mock_add_ubridge_connection.assert_called_once_with(nio, 0, 0) assert mock_start_ubridge.called assert vm.status == "stopped" diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 672325012..4f19cd8c8 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -259,7 +259,7 @@ async def test_start_writes_iol_config(compute_project, manager): with open(os.path.join(vm.working_dir, "config", "iol-config.json")) as f: config = json.load(f) assert config["binary"] == "/binary.iol" - assert config["num-eth"] == 4 + assert config["num-eth"] == 16 # 4 adapters, each a 4-port unit assert config["num-serial"] == 0 assert config["local-app"] == 1 assert config["remote-app"] == 2 @@ -285,7 +285,7 @@ async def test_start_rewrites_config_on_adapter_change(compute_project, manager) await vm.start() with open(os.path.join(vm.working_dir, "config", "iol-config.json")) as f: - assert json.load(f)["num-eth"] == 8 + assert json.load(f)["num-eth"] == 32 # 8 adapters × 4 ports @pytest.mark.asyncio @@ -460,6 +460,56 @@ async def test_add_ubridge_connection_without_nio_still_wires(compute_project, m assert "bridge start" not in flat +# --------------------------------------------------------------------------- +# IOU-style port model — 1 adapter = 4 ethernet ports +# --------------------------------------------------------------------------- + +def test_adapters_are_four_port_units(compute_project, manager): + + vm = _make_vm(compute_project, manager, adapters=2) + assert vm.adapters == 2 + assert len(vm._ethernet_adapters) == 2 + for adapter in vm._ethernet_adapters: + assert adapter.interfaces == 4 + for port_number in range(4): + assert adapter.port_exists(port_number) + assert not adapter.port_exists(4) + + +@pytest.mark.asyncio +async def test_wiring_addresses_ports_within_adapters(compute_project, manager): + + vm = _make_vm(compute_project, manager, adapters=2) + _mock_wiring(vm) + wiring_dir = _wiring_dir(vm) + os.makedirs(wiring_dir, exist_ok=True) + # adapter 1, port 2 -> flat interface index 6 (1 * 4 + 2) + open(os.path.join(wiring_dir, "s06.sock"), "w").close() + + nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) + await vm._add_ubridge_connection(nio, 1, port_number=2) + + flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls) + assert 'bridge add_nio_unix bridge1_2 ' in flat + assert f'"{os.path.join(wiring_dir, "c06.sock")}"' in flat + assert f'"{os.path.join(wiring_dir, "s06.sock")}"' in flat + assert "add_nio_udp bridge1_2 4242 127.0.0.1 4343" in flat + assert vm._ethernet_adapters[1].host_ifc == os.path.join(wiring_dir, "c06.sock") + + +@pytest.mark.asyncio +async def test_nio_binding_rejects_port_out_of_range(compute_project, manager): + + vm = _make_vm(compute_project, manager, adapters=2) + nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) + with pytest.raises(DockerError) as excinfo: + await vm.adapter_add_nio_binding(1, nio, port_number=4) + assert "Port 4" in str(excinfo.value) + + with pytest.raises(DockerError): + await vm.adapter_add_nio_binding(9, nio, port_number=0) + + # --------------------------------------------------------------------------- # Generic GNS3_UNIX_SOCKET_NIO knob on plain VendorDockerVM # --------------------------------------------------------------------------- diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index a20405637..9ba4c0ca6 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -46,6 +46,32 @@ def node(compute, project): return node +def test_docker_iol_ports_grouped_in_four_port_units(compute, project): + """ + IOL docker nodes (GNS3_IOL_RUNNER) model adapters as 4-port units: ports + are Ethernet0/0-3, Ethernet1/0-3, … addressed (adapter, port 0-3), like + the native IOU node type. Plain docker nodes keep the flat eth naming. + """ + + node = Node(project, compute, "iol", + node_id=str(uuid.uuid4()), + node_type="docker", + properties={"adapters": 2, "environment": "GNS3_IOL_RUNNER=1"}) + ports = node.ports + assert [p.asdict()["name"] for p in ports] == [ + "Ethernet0/0", "Ethernet0/1", "Ethernet0/2", "Ethernet0/3", + "Ethernet1/0", "Ethernet1/1", "Ethernet1/2", "Ethernet1/3", + ] + assert (ports[5].adapter_number, ports[5].port_number) == (1, 1) + assert ports[5].short_name == "e1/1" + + node = Node(project, compute, "web", + node_id=str(uuid.uuid4()), + node_type="docker", + properties={"adapters": 2}) + assert [p.asdict()["name"] for p in node.ports] == ["eth0", "eth1"] + + def test_name(compute, project): """ If node use a name template generate names