From e272ad915b8f5d3999ffb7f1a3b7a58368a51bd1 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 30 Aug 2026 12:16:12 +0800 Subject: [PATCH] feat: add IOL (iol-runner) Docker node support Run Cisco CML containerized IOL images (e.g. iol-xe/iol-xe:17-18-02, driven by virl.lab/cmd/iol-runner) as GNS3 Docker router nodes: - VendorDockerVM: generic GNS3_UNIX_SOCKET_NIO/GNS3_UNIX_SOCKET_DIR knobs wiring adapters through AF_UNIX datagram socket pairs (add_nio_unix cNN.sock sNN.sock) instead of TAP + move_to_ns; node creation fails if the socket dir is not a persisted volume. - IOLDockerVM (GNS3_IOL_RUNNER=1): forces skip-init + unix-socket NIO + /config,/tmp volumes, writes iol-config.json on every start (num-eth tracks adapters, runner drops to the server uid/gid so the sockets are reachable), pre-creates tmp/run, cleans stale sockets after unclean kills, and makes reload a graceful stop + full start (NVRAM flush + rewiring). Console stays telnet on PID 1 stdio. - Manager selects the node class from console_type or GNS3_* environment markers (create-time, like console_type). - Image-free tests (25) and feature documentation. --- docs/README.md | 3 + docs/features/iol-runner-docker.md | 118 +++++ gns3server/compute/docker/__init__.py | 15 +- gns3server/compute/docker/iol_docker_vm.py | 189 +++++++ gns3server/compute/docker/vendor_docker_vm.py | 106 ++++ tests/compute/docker/test_iol_docker_vm.py | 487 ++++++++++++++++++ 6 files changed, 917 insertions(+), 1 deletion(-) create mode 100644 docs/features/iol-runner-docker.md create mode 100644 gns3server/compute/docker/iol_docker_vm.py create mode 100644 tests/compute/docker/test_iol_docker_vm.py diff --git a/docs/README.md b/docs/README.md index c347479cc..50c432faf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -81,6 +81,9 @@ Console for vendor NOS containers (SR Linux, XRd, …) whose CLI is a TUI off PI ### Cisco XRd Control Plane (`features/vendor-nos-xrd.md`) Cisco XRd as a GNS3 Docker router: vendor path + shm/device injection (`GNS3_SHM_SIZE`/`GNS3_DEVICES`), config-file injection (`extra_configs`), udev masking (`GNS3_MASK_UDEV`) so privileged systemd containers don't disturb the host, and the host-readiness check. +### IOL Images with iol-runner (`features/iol-runner-docker.md`) +Cisco CML containerized IOL (e.g. `iol-xe/iol-xe:17-18-02`) as GNS3 Docker routers: generic unix-socket NIO (`GNS3_UNIX_SOCKET_NIO` — adapters wired via AF_UNIX datagram socket pairs instead of TAP/netns) plus `IOLDockerVM` (`GNS3_IOL_RUNNER=1` — per-start config generation, `/tmp/run` preparation, stale-socket cleanup, console on PID 1 stdio). + --- ## GNS3 AI Copilot (`gns3-copilot/`) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md new file mode 100644 index 000000000..10bca7685 --- /dev/null +++ b/docs/features/iol-runner-docker.md @@ -0,0 +1,118 @@ + + +> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt. + + +# IOL Images with iol-runner (Cisco CML containerized IOL) as Docker Nodes + +## Overview + +IOL images packaged with Cisco CML's container runner — for example +`iol-xe/iol-xe:17-18-02` (IOS-XE 17.18.02 IOL in a scratch image driven by +`iol-runner`, module `virl.lab/cmd/iol-runner`) — run as first-class GNS3 +Docker router nodes with **zero changes to the image**. The integration adds +two generic server mechanisms: + +1. **Unix-socket NIO** (`GNS3_UNIX_SOCKET_NIO=1`, on `VendorDockerVM`): link + adapters through per-interface AF_UNIX datagram sockets instead of a TAP + interface moved into the container's network namespace. +2. **`IOLDockerVM`** (marker `GNS3_IOL_RUNNER=1`): generates the runner's + config file per start, prepares its runtime directory and cleans up stale + sockets — the iol-runner-specific glue on top of the vendor path. + +## How the image works + +```mermaid +graph LR + subgraph Container["scratch container (PID 1)"] + RUNNER["/iol-runner -config /config/iol-config.json -stdio"] + IOL["IOL process
(IOS-XE 17.18.02)"] + NETIOMUX["netiomux"] + SOCKETS["/tmp/s00.sock (recv)
/tmp/c00.sock (send-to path)
… one pair per interface"] + RUNNER -->|"spawn -e/-s/-m + app id"| IOL + IOL -->|"netio bus /tmp/netio<uid>/"| NETIOMUX --> SOCKETS + end + subgraph Host + UBRIDGE["uBridge bridgeN
add_nio_unix c00.sock s00.sock
+ add_nio_udp (topology)"] + VOL["project-files/docker/<node>/tmp"] + end + SOCKETS <-->|"raw Ethernet frames
(bind volume dir)"| VOL <--> UBRIDGE +``` + +* **Console**: the runner muxes the IOS console onto PID 1 stdio (`-stdio` + entrypoint flag). The plain `console_type: "telnet"` attaches to it — no + `docker_exec` needed. The runner requires a TTY, which GNS3 always + allocates; without one the runner exits (`inappropriate ioctl for device`). +* **Networking**: the runner does not touch the container's network + namespace. Per interface N it creates, inside `/tmp`: a receive socket + `s%02d.sock` (frames sent there are injected into guest interface N) and a + send-to path `c%02d.sock` (whoever binds it receives the guest's frames). + Frames are **raw Ethernet**, one datagram per frame. Because `/tmp` is a + persisted volume (bind-mounted from the node directory), uBridge can bind + `cNN.sock` and send to `sNN.sock` on the host. +* **Licensing**: the image ships a self-consistent `/etc/hostid` + `.iourc` + pair, and the runner regenerates the license from the host ID at boot — + nothing to configure. +* **Persistence**: `/tmp/run/` inside the volume holds the NETMAP, the + startup-config (`config`, plain IOS format) and NVRAM (`nvram_00001`), so + the router's configuration survives stop/start and container recreation. + The generated config maps the runner to the server's uid/gid + (`user-id`/`group-id`), which is also what makes the `/tmp` sockets + reachable by uBridge and all volume files owned by the server user (no + permission-fix pass needed). + +## Template + +```json +{ + "name": "IOS-XE 17.18.02 IOL", + "template_type": "docker", + "image": "iol-xe/iol-xe:17-18-02", + "category": "router", + "symbol": ":/symbols/router.svg", + "adapters": 4, + "console_type": "telnet", + "environment": "GNS3_IOL_RUNNER=1", + "extra_volumes": ["/config", "/tmp"], + "memory": 2560 +} +``` + +`/config` and `/tmp` are auto-added even if omitted; listing them keeps the +template self-documenting. + +## Server mechanisms + +| Mechanism | Where | What it does | +|---|---|---| +| `GNS3_UNIX_SOCKET_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix /c{N:02d}.sock /s{N:02d}.sock` instead of TAP + `docker move_to_ns`. No TAP allocation, no `set_mac_addr`, namespace untouched. Fails node creation if the socket dir is not a persisted volume. | +| `GNS3_UNIX_SOCKET_DIR=` | `VendorDockerVM` | Socket directory (default `/tmp`). Any image whose agent exposes the `s%02d`/`c%02d` datagram pairs can use this without the IOL specifics. | +| `GNS3_IOL_RUNNER=1` | `IOLDockerVM` (selected in the manager) | Forces skip-init + unix-socket NIO + the two volumes; on every start writes `/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `/tmp/run/` (the IOL process dies without it) and removes stale `s/c??.sock` + `netio*` left by an unclean kill (`tmp/run` is never touched). | +| `restart()` hardening | `IOLDockerVM` | The base `docker restart` would boot the runner on a stale config and leave uBridge wired to the previous run's sockets; reload becomes graceful stop (SIGTERM → NVRAM flush) + full start. | + +`GNS3_STOP_TIMEOUT` (default 60) controls the SIGTERM grace period on stop. +Extra iol-runner flags can be passed via `start_command`, e.g. `-keep` +(L1 keepalives) or `-debug 9` (verbose `process.log` — very useful when +diagnosing wiring issues). + +## Notes and caveats + +* **Memory sizing**: `memory` caps the whole container; the IOL process gets + `GNS3_IOL_MEMORY` (default 2048 MB). Keep container memory at IOL memory + + ~512 MB headroom or the OOM-killer will shoot the router. +* **MAC addresses**: the `mac_address` template field and per-adapter custom + MACs are ignored — IOL derives its own scheme (`aabb.cc00.0XY0`). +* **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). +* **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`. +* **Class selection is create-time**: toggling `GNS3_IOL_RUNNER` via PUT + takes effect after a project reload (same as `docker_exec`). +* The startup-config lives at `project-files/docker//tmp/run/config`; + `extra_configs` targets under persisted volumes are warned against by the + generic create path — edit the file directly or paste via the console. diff --git a/gns3server/compute/docker/__init__.py b/gns3server/compute/docker/__init__.py index ed37ca626..f4027ea59 100644 --- a/gns3server/compute/docker/__init__.py +++ b/gns3server/compute/docker/__init__.py @@ -33,6 +33,7 @@ from gns3server.utils.asyncio import locking from gns3server.compute.base_manager import BaseManager from gns3server.compute.docker.docker_vm import DockerVM from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM +from gns3server.compute.docker.iol_docker_vm import IOLDockerVM from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error, DockerHttp409Error log = logging.getLogger(__name__) @@ -62,9 +63,21 @@ class Docker(BaseManager): self._host_checked = False def _select_node_class(self, **kwargs): - """Select the node class based on console_type.""" + """ + Select the node class based on console_type and GNS3_* environment + markers. Like console_type, the environment is fixed at node creation + time: toggling a marker via PUT takes effect after a project reload. + """ if kwargs.get("console_type") == "docker_exec": return VendorDockerVM + environment = kwargs.get("environment") or "" + for line in environment.splitlines(): + line = line.strip().rstrip(",") + if line.startswith("GNS3_IOL_RUNNER="): + return IOLDockerVM + if line.startswith("GNS3_UNIX_SOCKET_NIO="): + # Generic capability, usable without the IOL specifics. + return VendorDockerVM return DockerVM async def create_node(self, name, project_id, node_id, *args, **kwargs): diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py new file mode 100644 index 000000000..1c03bc4af --- /dev/null +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -0,0 +1,189 @@ +# +# Copyright (C) 2025 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +IOL (IOS on Linux) Docker container subclass. + +Supports IOL images packaged with Cisco CML's container runner +(``iol-runner``, ``virl.lab/cmd/iol-runner``), e.g. ``iol-xe/iol-xe:17-18-02``: +a scratch image whose ENTRYPOINT is ``iol-runner -config /config/iol-config.json +-stdio``. The runner generates the license, writes the NETMAP, manages NVRAM +and muxes the IOS console onto PID 1 stdio (works with the plain ``telnet`` +console type; requires a TTY, which GNS3 always allocates). + +Networking does not use the container's network namespace at all: the runner's +netiomux exposes per-interface AF_UNIX datagram sockets in ``/tmp`` +(``s%02d.sock`` receive, ``c%02d.sock`` send — raw Ethernet frames), wired by +the generic ``GNS3_UNIX_SOCKET_NIO`` capability of VendorDockerVM. Because the +netio bus directory is private to the node's ``/tmp`` volume, the application +IDs are fixed constants with no cross-node collisions. + +This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker. +""" + +import glob +import json +import logging +import os +import shutil + +from gns3server.compute.docker.docker_error import DockerHttp404Error +from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM + +log = logging.getLogger(__name__) + + +class IOLDockerVM(VendorDockerVM): + """ + VendorDockerVM subclass for iol-runner images. + + Extra opt-in knob (beyond the inherited vendor ones): + + * ``GNS3_IOL_MEMORY=`` — IOL router memory passed via the generated + config (default 2048). The template ``memory`` field caps the whole + container: keep it at IOL memory + ~512 MB headroom or the kernel + OOM-killer will fire. + + The marker itself forces ``GNS3_SKIP_INIT`` and the unix-socket NIO wiring, + and auto-adds the ``/config`` and ``/tmp`` persistent volumes, so a + template containing only ``GNS3_IOL_RUNNER=1`` is fully configured. + """ + + _IOL_CONFIG_DIR = "/config" + _IOL_RUN_DIR = "/tmp/run" + + def _parse_vendor_environment(self): + + super()._parse_vendor_environment() + # The image has no shell (scratch): init.sh could neither run (its + # #!/bin/sh shebang doesn't exist) nor wait for eth interfaces that + # are never created. The console is IOS itself on PID 1 stdio. + self._gns3_init = False + self._unix_socket_nio = True + self._unix_socket_dir = "/tmp" + + self._iol_memory = 2048 + if self._environment: + for _line in self._environment.splitlines(): + _line = _line.strip().rstrip(",") + if _line.startswith("GNS3_IOL_MEMORY="): + try: + memory = int(_line.split("=", 1)[1].strip()) + if memory > 0: + self._iol_memory = memory + except ValueError: + pass + + def _persistent_volume_list(self, image_info, include_network_config=True): + """ + Override: the runner requires ``/config`` (its config file, generated + below) and ``/tmp`` (netiomux sockets, NETMAP, NVRAM) as persisted + volumes — /tmp because uBridge must reach the sockets on the host. + Auto-add both so a minimal template cannot be misconfigured. + """ + + volumes = super()._persistent_volume_list(image_info, include_network_config) + for needed in (self._IOL_CONFIG_DIR, "/tmp"): + if not any(needed == v or needed.startswith(v.rstrip("/") + "/") for v in volumes): + volumes.append(needed) + return volumes + + async def start(self): + + await self._prepare_iol_runtime() + await super().start() + + async def restart(self): + """ + Override: the base restart is a bare ``docker restart`` — the runner + would read a stale config (no adapter-count/memory refresh) and + uBridge would keep wiring to the previous run's sockets. Stop + gracefully (SIGTERM lets the runner flush NVRAM) and start again. + """ + + await self.stop(graceful=True) + await self.start() + + async def _prepare_iol_runtime(self): + """ + Regenerate the node's runtime files before the container starts. + + * ``/tmp/run/`` must exist or the IOL process dies at + boot (the runner writes NETMAP there but does not create it). + * ``/config/iol-config.json`` is rewritten on every + start so adapter-count and memory changes take effect. + * Ephemeral sockets left by a previous (possibly SIGKILLed) run are + removed — the runner rebinds them on boot and would fail on a + stale file. ``tmp/run`` is never touched: startup-config and NVRAM + persist there. + """ + + try: + state = await self._get_container_state() + except DockerHttp404Error: + state = "stopped" + + os.makedirs(os.path.join(self.working_dir, "tmp", "run"), exist_ok=True) + self._write_iol_config() + + if state == "running": + # Idempotent start of a live node: the sockets belong to the + # running runner; base start() will return early. + return + + tmp_dir = os.path.join(self.working_dir, "tmp") + for pattern in ("s??.sock", "c??.sock"): + for stale in glob.glob(os.path.join(tmp_dir, pattern)): + try: + os.unlink(stale) + except OSError: + pass + for netio_dir in glob.glob(os.path.join(tmp_dir, "netio*")): + shutil.rmtree(netio_dir, ignore_errors=True) + + def _write_iol_config(self): + """ + Write the runner's config file on the host side of the /config volume. + The runner drops to user-id/group-id after its setup, so everything it + creates inside the volumes is owned by the server user (which is also + what makes the /tmp sockets reachable by uBridge). + """ + + config = { + "binary": "/binary.iol", + "memory": self._iol_memory, + "num-eth": self.adapters, + "num-serial": 0, # GNS3 docker adapters are ethernet-only + "local-app": 1, + "remote-app": 2, + "user-id": os.getuid(), + "group-id": os.getgid(), + } + config_file = os.path.join(self.working_dir, "config", "iol-config.json") + os.makedirs(os.path.dirname(config_file), exist_ok=True) + with open(config_file, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + f.write("\n") + log.debug("Wrote iol-runner config for '%s': %s", self._name, config) + + async def _fix_permissions(self): + """ + Override: no-op. The generated config maps the runner to the server's + uid/gid, so no root-owned files ever appear in the volumes, and this + image has no shell for the container-side busybox pass anyway. + """ + + self._permissions_fixed = True diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 6089663f2..8b8f5fb52 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -33,6 +33,7 @@ import logging import os import shutil +from gns3server.utils.asyncio import wait_for_file_creation from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer from gns3server.compute.docker.docker_vm import DockerVM from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error @@ -64,6 +65,20 @@ class VendorDockerVM(DockerVM): sessions on the shared exec. * ``GNS3_STOP_TIMEOUT=60`` — SIGTERM grace period in seconds when stopping the container (default 60; Docker SIGKILLs once it expires). + * ``GNS3_UNIX_SOCKET_NIO=1`` — wire adapters through AF_UNIX datagram + socket files instead of a TAP interface moved into the container's + network namespace. For images whose network agent exposes, per adapter + ``N``, a receive socket ``s%02d.sock`` and a send-to path ``c%02d.sock`` + (raw Ethernet frames, one datagram per frame) inside a persisted volume + directory — e.g. Cisco CML's iol-runner (see IOLDockerVM). uBridge + binds ``c{N:02d}.sock`` (its receive side) and sends to + ``s{N:02d}.sock``. No TAP is created, the container's network namespace + is untouched and the ``mac_address`` template field is ignored (the + image's agent owns the MAC scheme). + * ``GNS3_UNIX_SOCKET_DIR=`` — in-container directory holding the + socket files (default ``/tmp``). Must be a persisted volume + (extra_volumes) so uBridge can reach the sockets on the host; node + creation fails with an actionable error otherwise. """ def __init__(self, *args, **kwargs): @@ -86,6 +101,8 @@ class VendorDockerVM(DockerVM): self._console_cmd = None self._console_resize = True self._stop_timeout = 60 + self._unix_socket_nio = False + self._unix_socket_dir = "/tmp" if self._environment: for _line in self._environment.splitlines(): _line = _line.strip().rstrip(",") @@ -99,6 +116,12 @@ class VendorDockerVM(DockerVM): self._console_cmd = _line.split("=", 1)[1].strip() elif _line.startswith("GNS3_CONSOLE_RESIZE="): self._console_resize = _line.split("=", 1)[1].strip().lower() not in ("0", "false", "no") + elif _line.startswith("GNS3_UNIX_SOCKET_NIO="): + self._unix_socket_nio = _line.split("=", 1)[1].strip().lower() in ("1", "true", "yes") + elif _line.startswith("GNS3_UNIX_SOCKET_DIR="): + socket_dir = _line.split("=", 1)[1].strip().rstrip("/") or "/" + if os.path.isabs(socket_dir) and ".." not in socket_dir.split("/"): + self._unix_socket_dir = socket_dir elif _line.startswith("GNS3_STOP_TIMEOUT="): try: timeout = int(_line.split("=", 1)[1].strip()) @@ -142,6 +165,17 @@ class VendorDockerVM(DockerVM): are never shadowed by an empty mount. """ binds = super()._mount_binds(image_info) + if self._unix_socket_nio: + socket_dir = self._unix_socket_dir.rstrip("/") + if not any( + v.rstrip("/") == socket_dir or socket_dir.startswith(v.rstrip("/") + "/") + for v in self._volumes + ): + raise DockerError( + f"GNS3_UNIX_SOCKET_NIO requires socket directory '{self._unix_socket_dir}' of " + f"container '{self._name}' to be a persisted volume (add it to extra_volumes) " + f"so uBridge can reach the sockets on the host" + ) if self._gns3_init: return binds binds = [b for b in binds if b.get("Target") != "/gns3volumes/etc/network"] @@ -284,6 +318,78 @@ class VendorDockerVM(DockerVM): return self._interface_names[adapter_number] return f"eth{adapter_number}" + @property + def _unix_socket_host_dir(self): + """ + Host-side path of the in-container unix-socket directory: the bind + source of the persisted volume it lives in. + """ + return os.path.join(self.working_dir, os.path.relpath(self._unix_socket_dir, "/")) + + async def _add_ubridge_connection(self, nio, adapter_number): + """ + 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. + + Per adapter N the image's network agent is expected to create, inside + GNS3_UNIX_SOCKET_DIR (a persisted volume, enforced by _mount_binds): + + * ``s{N:02d}.sock`` — its receive socket; frames sent there are + injected into guest interface N; + * ``c{N:02d}.sock`` — the path it sends guest-egress frames to. + + uBridge binds the c-socket as its receive side and sends to the + s-socket. No TAP is allocated, the namespace is untouched and guest + MAC addresses are whatever the image's agent uses. + """ + + if not self._unix_socket_nio: + return await super()._add_ubridge_connection(nio, adapter_number) + + try: + adapter = self._ethernet_adapters[adapter_number] + except IndexError: + raise DockerError( + "Adapter {adapter_number} doesn't exist on Docker container '{name}'".format( + name=self.name, adapter_number=adapter_number + ) + ) + + bridge_name = f"bridge{adapter_number}" + await self._ubridge_send(f"bridge create {bridge_name}") + self._bridges.add(bridge_name) + + host_dir = self._unix_socket_host_dir + local_sock = os.path.join(host_dir, f"c{adapter_number:02d}.sock") + remote_sock = os.path.join(host_dir, f"s{adapter_number:02d}.sock") + + # A c-socket left over from a previous ubridge run would fail its bind. + with contextlib.suppress(OSError): + os.unlink(local_sock) + + # The socket appears when the container's agent finishes its interface + # setup; wait instead of silently blackholing the adapter. + try: + await wait_for_file_creation(remote_sock, timeout=30) + except asyncio.TimeoutError: + raise DockerError( + f"Socket '{remote_sock}' for adapter {adapter_number} of container " + f"'{self._name}' did not appear within 30 seconds. Check that the " + f"container's port count covers adapter {adapter_number} and that " + f"'{self._unix_socket_dir}' is bind-mounted from the node directory." + ) + + await self._ubridge_send(f'bridge add_nio_unix {bridge_name} "{local_sock}" "{remote_sock}"') + 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, + ) + + if nio: + await self._connect_nio(adapter_number, nio) + def _cleanup_console_resources(self): """ Override: close the docker-exec pty socket, if any, so the next diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py new file mode 100644 index 000000000..8d12307ef --- /dev/null +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -0,0 +1,487 @@ +# +# Copyright (C) 2025 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Tests for the IOLDockerVM subclass (Cisco CML iol-runner images, e.g. +iol-xe/iol-xe:17-18-02) and its unix-socket NIO wiring. + +Image-free: everything is asserted against generated files, parsed knobs and +the uBridge command stream. +""" + +import asyncio +import glob +import json +import os +import uuid + +import pytest +import pytest_asyncio + +from unittest.mock import patch, MagicMock, call + +from tests.utils import asyncio_patch, AsyncioMagicMock + +from gns3server.compute.docker import Docker +from gns3server.compute.docker.docker_vm import DockerVM +from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM +from gns3server.compute.docker.iol_docker_vm import IOLDockerVM +from gns3server.compute.docker.docker_error import DockerError + + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + +IOL_ENTRYPOINT = ["/iol-runner", "-config", "/config/iol-config.json", "-stdio"] + + +def _create_response(entrypoint=None, volumes=None): + """Build the Docker /containers/create response (with image info merged).""" + return { + "Id": "e90e34656806", + "Warnings": [], + "Config": { + "Entrypoint": entrypoint or IOL_ENTRYPOINT, + "Cmd": [], + "Volumes": volumes or {}, + }, + } + + +@pytest_asyncio.fixture +async def manager(port_manager): + + m = Docker.instance() + m.port_manager = port_manager + return m + + +def _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1", + extra_volumes=None, adapters=4, console_type="telnet"): + """Build an IOLDockerVM with a fake cid (no create() called).""" + vm = IOLDockerVM( + "iol-xe-1", str(uuid.uuid4()), compute_project, manager, "iol-xe/iol-xe:17-18-02", + console_type=console_type, environment=environment, + extra_volumes=extra_volumes or [], adapters=adapters, + ) + vm._cid = "e90e34656842" + return vm + + +def _mock_start(vm, state="stopped"): + """Mock everything DockerVM.start() needs besides the runtime prep.""" + vm._get_container_state = AsyncioMagicMock(return_value=state) + vm._start_ubridge = AsyncioMagicMock() + vm._get_namespace = AsyncioMagicMock(return_value=42) + vm._add_ubridge_connection = AsyncioMagicMock() + vm._start_console_server = AsyncioMagicMock() + + +def _seed_proc(stdout=b"seedcid\n", returncode=0): + proc = MagicMock() + proc.communicate = AsyncioMagicMock(return_value=(stdout, b"")) + proc.returncode = returncode + return proc + + +# --------------------------------------------------------------------------- +# Factory selection +# --------------------------------------------------------------------------- + +def test_factory_selects_iol_for_env_marker(manager): + + assert manager._select_node_class(console_type="telnet", + environment="GNS3_IOL_RUNNER=1") is IOLDockerVM + + +def test_factory_tolerates_whitespace_and_comma(manager): + + assert manager._select_node_class(console_type="telnet", + environment=" GNS3_IOL_RUNNER=1,\nFOO=bar") is IOLDockerVM + + +def test_factory_docker_exec_wins_over_iol_marker(manager): + + assert manager._select_node_class(console_type="docker_exec", + environment="GNS3_IOL_RUNNER=1") is VendorDockerVM + + +def test_factory_plain_environment_is_base(manager): + + assert manager._select_node_class(console_type="telnet", + environment="FOO=bar\nGNS3_BAZ=nope") is DockerVM + + +def test_factory_generic_unix_knob_selects_vendor(manager): + + assert manager._select_node_class(console_type="telnet", + environment="GNS3_UNIX_SOCKET_NIO=1") is VendorDockerVM + + +# --------------------------------------------------------------------------- +# Knob parsing +# --------------------------------------------------------------------------- + +def test_marker_forces_skip_init_and_unix_nio(compute_project, manager): + + vm = _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1") + assert vm._gns3_init is False + assert vm._unix_socket_nio is True + assert vm._unix_socket_dir == "/tmp" + assert vm._iol_memory == 2048 + + +def test_iol_memory_knob(compute_project, manager): + + vm = _make_vm(compute_project, manager, + environment="GNS3_IOL_RUNNER=1\nGNS3_IOL_MEMORY=4096") + assert vm._iol_memory == 4096 + + vm = _make_vm(compute_project, manager, + environment="GNS3_IOL_RUNNER=1\nGNS3_IOL_MEMORY=notanumber") + assert vm._iol_memory == 2048 + + +# --------------------------------------------------------------------------- +# create() +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_create_keeps_image_entrypoint(compute_project, manager): + + with asyncio_patch("gns3server.compute.docker.Docker.list_images", + return_value=[{"image": "iol-xe"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", + return_value=_create_response()) as mock: + with patch("asyncio.subprocess.create_subprocess_exec", + return_value=_seed_proc()): + vm = _make_vm(compute_project, manager) + await vm.create() + sent = mock.call_args.kwargs["data"] + # the iol-runner entrypoint runs as PID 1, untouched + assert sent["Entrypoint"] == IOL_ENTRYPOINT + assert sent["Cmd"] == [] + + +@pytest.mark.asyncio +async def test_create_auto_adds_config_and_tmp_volumes(compute_project, manager): + + with asyncio_patch("gns3server.compute.docker.Docker.list_images", + return_value=[{"image": "iol-xe"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", + return_value=_create_response()) as mock: + with patch("asyncio.subprocess.create_subprocess_exec", + return_value=_seed_proc()): + vm = _make_vm(compute_project, manager, extra_volumes=[]) + await vm.create() + sent = mock.call_args.kwargs["data"] + targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]] + # both volumes are forced and bound at their real in-container + # paths (skip-init retargeting), reachable by uBridge on the host + assert "/config" in targets + assert "/tmp" in targets + assert not any(t.startswith("/gns3volumes/") for t in targets) + vol_env = [v for v in sent["Env"] if v.startswith("GNS3_VOLUMES=")][0] + assert "/config" in vol_env and "/tmp" in vol_env + + +@pytest.mark.asyncio +async def test_create_start_command_becomes_runner_flags(compute_project, manager): + + vm = _make_vm(compute_project, manager) + vm.start_command = "-keep" + with asyncio_patch("gns3server.compute.docker.Docker.list_images", + return_value=[{"image": "iol-xe"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", + return_value=_create_response()) as mock: + with patch("asyncio.subprocess.create_subprocess_exec", + return_value=_seed_proc()): + await vm.create() + sent = mock.call_args.kwargs["data"] + # start_command is the container CMD = extra iol-runner flags + assert sent["Cmd"] == ["-keep"] + + +# --------------------------------------------------------------------------- +# start() — runtime preparation +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_start_writes_iol_config(compute_project, manager): + + vm = _make_vm(compute_project, manager, adapters=4) + _mock_start(vm) + with patch("gns3server.compute.docker.Docker.install_busybox"): + with asyncio_patch("gns3server.compute.docker.Docker.query"): + await vm.start() + + 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-serial"] == 0 + assert config["local-app"] == 1 + assert config["remote-app"] == 2 + assert config["memory"] == 2048 + assert config["user-id"] == os.getuid() + assert config["group-id"] == os.getgid() + assert vm.status == "started" + + +@pytest.mark.asyncio +async def test_start_rewrites_config_on_adapter_change(compute_project, manager): + + vm = _make_vm(compute_project, manager, adapters=4) + _mock_start(vm) + with patch("gns3server.compute.docker.Docker.install_busybox"): + with asyncio_patch("gns3server.compute.docker.Docker.query"): + await vm.start() + + vm.adapters = 8 + _mock_start(vm) + with patch("gns3server.compute.docker.Docker.install_busybox"): + with asyncio_patch("gns3server.compute.docker.Docker.query"): + await vm.start() + + with open(os.path.join(vm.working_dir, "config", "iol-config.json")) as f: + assert json.load(f)["num-eth"] == 8 + + +@pytest.mark.asyncio +async def test_start_creates_run_dir(compute_project, manager): + + vm = _make_vm(compute_project, manager) + _mock_start(vm) + with patch("gns3server.compute.docker.Docker.install_busybox"): + with asyncio_patch("gns3server.compute.docker.Docker.query"): + await vm.start() + + assert os.path.isdir(os.path.join(vm.working_dir, "tmp", "run")) + + +@pytest.mark.asyncio +async def test_start_cleans_stale_sockets_but_keeps_run(compute_project, manager): + + vm = _make_vm(compute_project, manager) + tmp_dir = os.path.join(vm.working_dir, "tmp") + os.makedirs(os.path.join(tmp_dir, "run"), exist_ok=True) + for name in ("s00.sock", "c00.sock", "s01.sock", "c01.sock"): + open(os.path.join(tmp_dir, name), "w").close() + os.makedirs(os.path.join(tmp_dir, "netio1000")) + open(os.path.join(tmp_dir, "run", "nvram_00001"), "w").close() + open(os.path.join(tmp_dir, "run", "config"), "w").close() + + _mock_start(vm, state="stopped") + with patch("gns3server.compute.docker.Docker.install_busybox"): + with asyncio_patch("gns3server.compute.docker.Docker.query"): + await vm.start() + + assert glob.glob(os.path.join(tmp_dir, "s??.sock")) == [] + assert glob.glob(os.path.join(tmp_dir, "c??.sock")) == [] + assert not os.path.exists(os.path.join(tmp_dir, "netio1000")) + # the persistent runtime survives the cleanup + assert os.path.exists(os.path.join(tmp_dir, "run", "nvram_00001")) + assert os.path.exists(os.path.join(tmp_dir, "run", "config")) + + +@pytest.mark.asyncio +async def test_start_skips_cleanup_when_already_running(compute_project, manager): + + vm = _make_vm(compute_project, manager) + tmp_dir = os.path.join(vm.working_dir, "tmp") + os.makedirs(tmp_dir, exist_ok=True) + open(os.path.join(tmp_dir, "s00.sock"), "w").close() + + _mock_start(vm, state="running") + with patch("gns3server.compute.docker.Docker.install_busybox"): + with asyncio_patch("gns3server.compute.docker.Docker.query"): + await vm.start() + + # live runner sockets must not be deleted behind its back + assert os.path.exists(os.path.join(tmp_dir, "s00.sock")) + + +@pytest.mark.asyncio +async def test_fix_permissions_is_noop(compute_project, manager): + + vm = _make_vm(compute_project, manager) + with patch("asyncio.subprocess.create_subprocess_exec") as mock_exec: + await vm._fix_permissions() + mock_exec.assert_not_called() + assert vm._permissions_fixed is True + + +@pytest.mark.asyncio +async def test_restart_is_graceful_stop_then_start(compute_project, manager): + + vm = _make_vm(compute_project, manager) + vm.stop = AsyncioMagicMock() + vm.start = AsyncioMagicMock() + await vm.restart() + vm.stop.assert_called_once_with(graceful=True) + vm.start.assert_called_once() + + +# --------------------------------------------------------------------------- +# Wiring — unix-socket NIO +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_add_ubridge_connection_unix_wiring(compute_project, manager): + + vm = _make_vm(compute_project, manager) + vm._ubridge_hypervisor = MagicMock() + host_dir = os.path.join(vm.working_dir, "tmp") + os.makedirs(host_dir, exist_ok=True) + # the runner's receive socket must exist (created by the container) + open(os.path.join(host_dir, "s00.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, 0) + + sent = [c for c in vm._ubridge_hypervisor.method_calls if "send" in str(c)] + flat = "\n".join(str(c) for c in sent) + assert call.send("bridge create bridge0") in sent + assert call.send(f'bridge add_nio_unix bridge0 "{os.path.join(host_dir, "c00.sock")}" ' + f'"{os.path.join(host_dir, "s00.sock")}"') in sent + assert "add_nio_udp bridge0 4242 127.0.0.1 4343" in flat + assert "bridge start bridge0" in flat + # the TAP/namespace path must not be used at all + assert "add_nio_tap" not in flat + assert "move_to_ns" not in flat + assert "set_mac_addr" not in flat + assert vm._ethernet_adapters[0].host_ifc == os.path.join(host_dir, "c00.sock") + + +@pytest.mark.asyncio +async def test_add_ubridge_connection_stale_local_socket_unlinked(compute_project, manager): + + vm = _make_vm(compute_project, manager) + vm._ubridge_hypervisor = MagicMock() + host_dir = os.path.join(vm.working_dir, "tmp") + os.makedirs(host_dir, exist_ok=True) + open(os.path.join(host_dir, "c00.sock"), "w").close() + open(os.path.join(host_dir, "s00.sock"), "w").close() + + await vm._add_ubridge_connection(None, 0) + assert not os.path.exists(os.path.join(host_dir, "c00.sock")) + + +@pytest.mark.asyncio +async def test_add_ubridge_connection_adapter_out_of_range(compute_project, manager): + + vm = _make_vm(compute_project, manager) + vm._ubridge_hypervisor = MagicMock() + with pytest.raises(DockerError): + await vm._add_ubridge_connection(None, 42) + + +@pytest.mark.asyncio +async def test_add_ubridge_connection_timeout_is_actionable(compute_project, manager): + + vm = _make_vm(compute_project, manager) + vm._ubridge_hypervisor = MagicMock() + + async def raise_timeout(path, timeout=60): + raise asyncio.TimeoutError() + + with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", + side_effect=raise_timeout): + with pytest.raises(DockerError) as excinfo: + await vm._add_ubridge_connection(None, 0) + # the message names the adapter and the socket directory + assert "adapter 0" in str(excinfo.value) + assert "/tmp" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_add_ubridge_connection_without_nio_still_wires(compute_project, manager): + + vm = _make_vm(compute_project, manager) + vm._ubridge_hypervisor = MagicMock() + host_dir = os.path.join(vm.working_dir, "tmp") + os.makedirs(host_dir, exist_ok=True) + open(os.path.join(host_dir, "s00.sock"), "w").close() + + await vm._add_ubridge_connection(None, 0) + flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls) + assert "bridge create bridge0" in flat + assert "add_nio_unix" in flat + # no link yet: no UDP NIO, no bridge start (matches base semantics) + assert "add_nio_udp" not in flat + assert "bridge start" not in flat + + +# --------------------------------------------------------------------------- +# Generic GNS3_UNIX_SOCKET_NIO knob on plain VendorDockerVM +# --------------------------------------------------------------------------- + +def test_env_unix_socket_nio_parsing(compute_project, manager): + + vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks") + assert vm._unix_socket_nio is True + assert vm._unix_socket_dir == "/var/run/socks" + + # invalid dirs are rejected, keeping the default + vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=yes\nGNS3_UNIX_SOCKET_DIR=../../etc") + assert vm._unix_socket_nio is True + assert vm._unix_socket_dir == "/tmp" + + # off by default / explicit off + vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", + console_type="docker_exec", environment="GNS3_SKIP_INIT=1") + assert vm._unix_socket_nio is False + + +@pytest.mark.asyncio +async def test_unix_socket_dir_must_be_a_volume(compute_project, manager): + + vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1", + extra_volumes=[]) + with pytest.raises(DockerError) as excinfo: + vm._mount_binds({"Config": {"Volumes": {}}}) + assert "extra_volumes" in str(excinfo.value) + + vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1", + extra_volumes=["/tmp"]) + binds = vm._mount_binds({"Config": {"Volumes": {}}}) + assert any(b["Target"] == "/tmp" for b in binds) + + +@pytest.mark.asyncio +async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manager): + + vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", + console_type="docker_exec", + environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks") + vm._ubridge_hypervisor = MagicMock() + host_dir = os.path.join(vm.working_dir, "var", "run", "socks") + os.makedirs(host_dir, exist_ok=True) + open(os.path.join(host_dir, "s00.sock"), "w").close() + + await vm._add_ubridge_connection(None, 0) + flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls) + assert f'"{os.path.join(host_dir, "s00.sock")}"' in flat + assert "add_nio_tap" not in flat