diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 10bca7685..c9b02841c 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -36,10 +36,11 @@ graph LR 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"] + UBRIDGE["uBridge bridgeN
add_nio_unix /proc/PID/root/tmp/cNN.sock …
+ add_nio_udp (topology)"] + VOL["project-files/docker/<node>/config
(+ tmp/run nested bind)"] end - SOCKETS <-->|"raw Ethernet frames
(bind volume dir)"| VOL <--> UBRIDGE + SOCKETS <-->|"raw Ethernet frames via
the container root in /proc"| UBRIDGE + UBRIDGE -.->|"iol-config.json +
persistent /tmp/run"| VOL ``` * **Console**: the runner muxes the IOS console onto PID 1 stdio (`-stdio` @@ -47,21 +48,27 @@ graph LR `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. + namespace. Per interface N it creates, inside the container's `/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. + uBridge reaches both through the container's root in `/proc` + (`/proc//root/tmp/…`) — a short path (AF_UNIX names are + capped at 107 bytes, which a project directory path alone would exceed) + that requires no volume mount and leaves the sockets ephemeral in the + container, fresh in every container GNS3 creates. * **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 +* **Persistence**: `/tmp/run` (the IOL working directory) holds the NETMAP, + the startup-config (`config`, plain IOS format) and NVRAM (`nvram_00001`). + It is the only `/tmp` path that needs to survive: GNS3 bind-mounts the + node directory's `tmp/run/` at `/tmp/run`, so the router's configuration + survives stop/start and container recreation while everything else in + `/tmp` stays ephemeral. The generated config maps the runner to the + server's uid/gid (`user-id`/`group-id`), so all files it creates are owned + by the server user — which is also what lets an unprivileged uBridge + traverse `/proc//root` to reach the container's sockets (no permission-fix pass needed). ## Template @@ -76,21 +83,21 @@ graph LR "adapters": 4, "console_type": "telnet", "environment": "GNS3_IOL_RUNNER=1", - "extra_volumes": ["/config", "/tmp"], + "extra_volumes": ["/config"], "memory": 2560 } ``` -`/config` and `/tmp` are auto-added even if omitted; listing them keeps the -template self-documenting. +`/config` and `/tmp/run` are auto-added even if omitted; listing `/config` +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). | +| `GNS3_UNIX_SOCKET_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix /proc//root/c{N:02d}.sock /proc//root/s{N:02d}.sock` instead of TAP + `docker move_to_ns`. No TAP allocation, no `set_mac_addr`, namespace untouched, no volume required. | +| `GNS3_UNIX_SOCKET_DIR=` | `VendorDockerVM` | In-container 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 `/config` and `/tmp/run` volumes; on every start writes `/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048) and creates `/tmp/run/` (the IOL process dies without it). | | `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. diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index 1c03bc4af..cc3302b81 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -25,22 +25,20 @@ 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. +netiomux exposes per-interface AF_UNIX datagram sockets in the container's +``/tmp`` (``s%02d.sock`` receive, ``c%02d.sock`` send — raw Ethernet frames), +wired by the generic ``GNS3_UNIX_SOCKET_NIO`` capability of VendorDockerVM +(uBridge reaches them through the container's root in /proc). Because the +netio bus directory is private to the container's /tmp, 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__) @@ -58,7 +56,7 @@ class IOLDockerVM(VendorDockerVM): 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 + and auto-adds the ``/config`` and ``/tmp/run`` persistent volumes, so a template containing only ``GNS3_IOL_RUNNER=1`` is fully configured. """ @@ -90,13 +88,14 @@ class IOLDockerVM(VendorDockerVM): 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. + below) and ``/tmp/run`` (its working directory: startup-config and + NVRAM live there — NETMAP and the netiomux sockets are ephemeral and + stay in the container's own /tmp). 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"): + for needed in (self._IOL_CONFIG_DIR, self._IOL_RUN_DIR): if not any(needed == v or needed.startswith(v.rstrip("/") + "/") for v in volumes): volumes.append(needed) return volumes @@ -119,47 +118,28 @@ class IOLDockerVM(VendorDockerVM): async def _prepare_iol_runtime(self): """ - Regenerate the node's runtime files before the container starts. + 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" + The netiomux sockets and the netio bus directory need no cleanup: + they live in the container's own /tmp, which is fresh in every + container GNS3 creates (containers are recreated on each start). + """ 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). + creates is owned by the server user — which is also what lets an + unprivileged uBridge traverse /proc//root to reach the + container's sockets. """ config = { diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 4eba1a705..7e4abfd4d 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -32,7 +32,6 @@ import json import logging import os import shutil -import tempfile from gns3server.utils.asyncio import wait_for_file_creation from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer @@ -70,16 +69,16 @@ class VendorDockerVM(DockerVM): 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). + (raw Ethernet frames, one datagram per frame) inside the container — + e.g. Cisco CML's iol-runner (see IOLDockerVM). uBridge reaches the + sockets through the container's root in ``/proc`` (see + ``_unix_socket_wiring_dir``), so no volume mount is required; it 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. + socket files (default ``/tmp``). """ def __init__(self, *args, **kwargs): @@ -104,7 +103,6 @@ class VendorDockerVM(DockerVM): self._stop_timeout = 60 self._unix_socket_nio = False self._unix_socket_dir = "/tmp" - self._unix_socket_aliases = set() if self._environment: for _line in self._environment.splitlines(): _line = _line.strip().rstrip(",") @@ -167,17 +165,6 @@ 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"] @@ -320,57 +307,20 @@ class VendorDockerVM(DockerVM): return self._interface_names[adapter_number] return f"eth{adapter_number}" - @property - def _unix_socket_host_dir(self): + async def _unix_socket_wiring_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, "/")) + Host-side directory to reference in the uBridge unix-NIO commands: + the container's socket directory reached through its root in /proc. - def _unix_socket_wiring_dir(self): - """ - Directory to reference in the uBridge unix-NIO commands. - - AF_UNIX paths are capped at 107 bytes (sun_path minus the NUL), and a - node volume directory (projects//project-files/docker//tmp) - alone is ~120 — uBridge would reject the NIO with "invalid file path - size". Long directories are therefore aliased through a symlink in the - runtime directory (same trick as the uBridge control socket). uBridge - resolves the symlink; the socket files themselves always live in the - persisted volume, and the container-side /tmp paths are unaffected. + The sockets live in the container's own filesystem (no volume mount + needed); /proc//root/ resolves to the same files uBridge + sees, from outside any namespace. This also keeps the path well under + the 107-byte sun_path cap — a node directory (projects//…) + alone exceeds it. """ - host_dir = self._unix_socket_host_dir - if len(host_dir) + len("/c00.sock") <= 107: - return host_dir - - runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir() - alias = os.path.join(runtime_dir, "gns3", f"unixio-{self.id}") - try: - os.makedirs(os.path.dirname(alias), mode=0o700, exist_ok=True) - if os.path.lexists(alias): - os.unlink(alias) - os.symlink(host_dir, alias) - except OSError as e: - raise DockerError( - f"Could not create unix-socket alias '{alias}' for container '{self._name}': {e}" - ) - self._unix_socket_aliases.add(alias) - return alias - - async def _stop_ubridge(self): - """ - Override: also drop the short-path aliases created for the unix-socket - NIO (the sockets themselves are removed by the image's agent / the - next start's cleanup). - """ - - await super()._stop_ubridge() - for alias in self._unix_socket_aliases: - with contextlib.suppress(OSError): - os.unlink(alias) - self._unix_socket_aliases.clear() + pid = await self._get_namespace() + return f"/proc/{pid}/root{self._unix_socket_dir}" async def _add_ubridge_connection(self, nio, adapter_number): """ @@ -379,15 +329,16 @@ class VendorDockerVM(DockerVM): 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): + GNS3_UNIX_SOCKET_DIR: * ``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. + s-socket (both reached through /proc — see _unix_socket_wiring_dir). + 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: @@ -406,8 +357,7 @@ class VendorDockerVM(DockerVM): await self._ubridge_send(f"bridge create {bridge_name}") self._bridges.add(bridge_name) - host_dir = self._unix_socket_host_dir - wiring_dir = self._unix_socket_wiring_dir() + wiring_dir = await 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") @@ -424,7 +374,8 @@ class VendorDockerVM(DockerVM): 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." + f"its network agent creates the per-adapter socket pair in " + f"'{self._unix_socket_dir}'." ) await self._ubridge_send(f'bridge add_nio_unix {bridge_name} "{local_sock}" "{remote_sock}"') diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 5986e4dd0..4a82d9a27 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -23,7 +23,6 @@ the uBridge command stream. """ import asyncio -import glob import json import os import uuid @@ -98,6 +97,23 @@ def _seed_proc(stdout=b"seedcid\n", returncode=0): return proc +# A container PID that can never exist on a real host (Linux caps PIDs at +# 2^22): lets the wiring tests run against /proc paths without any risk of +# touching a live process's files. +_FAKE_PID = 4194304 + + +async def _no_wait(path, timeout=None): + """Stand-in for wait_for_file_creation: pretend the socket is there.""" + return None + + +def _mock_wiring(vm): + """Mock everything _add_ubridge_connection's unix-NIO path needs.""" + vm._ubridge_hypervisor = MagicMock() + vm._get_namespace = AsyncioMagicMock(return_value=_FAKE_PID) + + # --------------------------------------------------------------------------- # Factory selection # --------------------------------------------------------------------------- @@ -178,7 +194,7 @@ async def test_create_keeps_image_entrypoint(compute_project, manager): @pytest.mark.asyncio -async def test_create_auto_adds_config_and_tmp_volumes(compute_project, manager): +async def test_create_auto_adds_config_and_tmp_run_volumes(compute_project, manager): with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "iol-xe"}]): @@ -190,13 +206,16 @@ async def test_create_auto_adds_config_and_tmp_volumes(compute_project, manager) 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 + # /config (runner config) and /tmp/run (startup-config + NVRAM) + # are forced and bound at their real in-container paths + # (skip-init retargeting); the sockets stay in the container's + # own /tmp, reached via /proc, so /tmp itself is not a volume assert "/config" in targets - assert "/tmp" in targets + assert "/tmp/run" in targets + assert "/tmp" not 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 + assert "/config" in vol_env and "/tmp/run" in vol_env @pytest.mark.asyncio @@ -274,14 +293,16 @@ async def test_start_creates_run_dir(compute_project, manager): @pytest.mark.asyncio -async def test_start_cleans_stale_sockets_but_keeps_run(compute_project, manager): +async def test_start_does_no_host_side_socket_cleanup(compute_project, manager): + """ + Sockets live in the container's own /tmp (fresh in every container GNS3 + creates), so start() must not touch anything under the node's tmp/ + beyond creating tmp/run. + """ 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() @@ -290,31 +311,11 @@ async def test_start_cleans_stale_sockets_but_keeps_run(compute_project, manager 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 + # the persistent runtime is untouched 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): @@ -344,49 +345,36 @@ async def test_restart_is_graceful_stop_then_start(compute_project, manager): 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() + _mock_wiring(vm) nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) - await vm._add_ubridge_connection(nio, 0) + with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", + side_effect=_no_wait): + 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) - wiring_dir = vm._unix_socket_wiring_dir() # may alias host_dir when long + local_sock = f"/proc/{_FAKE_PID}/root/tmp/c00.sock" + remote_sock = f"/proc/{_FAKE_PID}/root/tmp/s00.sock" + # the wiring path rides the container's root in /proc — well under + # sun_path's 107 bytes no matter how deep the project directory is + assert len(local_sock) <= 107 assert call.send("bridge create bridge0") in sent - assert call.send(f'bridge add_nio_unix bridge0 "{os.path.join(wiring_dir, "c00.sock")}" ' - f'"{os.path.join(wiring_dir, "s00.sock")}"') in sent + assert call.send(f'bridge add_nio_unix bridge0 "{local_sock}" "{remote_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(wiring_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")) + assert vm._ethernet_adapters[0].host_ifc == local_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() + _mock_wiring(vm) with pytest.raises(DockerError): await vm._add_ubridge_connection(None, 42) @@ -395,7 +383,7 @@ async def test_add_ubridge_connection_adapter_out_of_range(compute_project, mana async def test_add_ubridge_connection_timeout_is_actionable(compute_project, manager): vm = _make_vm(compute_project, manager) - vm._ubridge_hypervisor = MagicMock() + _mock_wiring(vm) async def raise_timeout(path, timeout=60): raise asyncio.TimeoutError() @@ -404,21 +392,19 @@ async def test_add_ubridge_connection_timeout_is_actionable(compute_project, man 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 + # the message names the adapter and the exact wiring path assert "adapter 0" in str(excinfo.value) - assert "/tmp" in str(excinfo.value) + assert f"/proc/{_FAKE_PID}/root/tmp/s00.sock" 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) + _mock_wiring(vm) + with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", + side_effect=_no_wait): + 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 @@ -452,23 +438,16 @@ def test_env_unix_socket_nio_parsing(compute_project, manager): assert vm._unix_socket_nio is False -@pytest.mark.asyncio -async def test_unix_socket_dir_must_be_a_volume(compute_project, manager): +def test_unix_socket_dir_needs_no_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"]) + # sockets are reached through /proc, not through a volume bind — creating + # the container without the socket dir in extra_volumes is fine binds = vm._mount_binds({"Config": {"Volumes": {}}}) - assert any(b["Target"] == "/tmp" for b in binds) + assert not any(b["Target"] == "/tmp" for b in binds) @pytest.mark.asyncio @@ -477,44 +456,10 @@ async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manage 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(vm._unix_socket_wiring_dir(), "s00.sock")}"' in flat - assert "add_nio_tap" not in flat - - -@pytest.mark.asyncio -async def test_long_socket_path_aliased_through_runtime_dir(compute_project, manager, monkeypatch, tmp_path): - """ - Node volume paths exceed sun_path's 107 bytes; the wiring must alias them - through a short symlink so uBridge accepts the NIO (and remove the alias - when uBridge stops). - """ - - from unittest.mock import PropertyMock - - long_dir = str(tmp_path / "a-very-long-project-directory-name" / ("x" * 40) / "tmp") - os.makedirs(long_dir, exist_ok=True) - open(os.path.join(long_dir, "s00.sock"), "w").close() - runtime_dir = tmp_path / "run" - monkeypatch.setenv("XDG_RUNTIME_DIR", str(runtime_dir)) - - vm = _make_vm(compute_project, manager) - vm._ubridge_hypervisor = MagicMock() - with patch.object(type(vm), "_unix_socket_host_dir", new_callable=PropertyMock, - return_value=long_dir): + _mock_wiring(vm) + with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", + side_effect=_no_wait): await vm._add_ubridge_connection(None, 0) - alias = os.path.join(str(runtime_dir), "gns3", f"unixio-{vm.id}") - assert os.path.islink(alias) and os.readlink(alias) == long_dir - flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls) - assert f'"{os.path.join(alias, "s00.sock")}"' in flat - assert long_dir not in flat # uBridge only ever sees the short path - - vm._ubridge_hypervisor.stop = AsyncioMagicMock() - await vm._stop_ubridge() - assert not os.path.lexists(alias) + flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls) + assert f'"/proc/{_FAKE_PID}/root/var/run/socks/s00.sock"' in flat + assert "add_nio_tap" not in flat