diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index c9b02841c..08d5a215c 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 /proc/PID/root/tmp/cNN.sock …
+ add_nio_udp (topology)"] - VOL["project-files/docker/<node>/config
(+ tmp/run nested bind)"] + UBRIDGE["uBridge bridgeN
add_nio_unix …/gns3/unixio/<node>/cNN.sock …
+ add_nio_udp (topology)"] + RTDIR["/run/user/<uid>/gns3/unixio/<node>
(bind-mounted at /tmp)"] + VOL["project-files/docker/<node>/config
(+ tmp/run, nested bind at /tmp/run)"] end - SOCKETS <-->|"raw Ethernet frames via
the container root in /proc"| UBRIDGE + SOCKETS <-->|"same files, two spellings
(the /tmp bind)"| RTDIR <--> UBRIDGE UBRIDGE -.->|"iol-config.json +
persistent /tmp/run"| VOL ``` @@ -51,25 +52,29 @@ graph LR 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. + the guest's frames). Frames are **raw Ethernet**, one datagram per frame — + the same two-mailbox convention uBridge's `add_nio_unix` natively speaks + (it binds the c-socket, sends to the s-socket). GNS3 bind-mounts a + per-node directory from the runtime directory + (`/run/user//gns3/unixio/`, next to the uBridge control + sockets) at the container's `/tmp`, so uBridge reaches the sockets as + plain host files: the path stays far under AF_UNIX's 107-byte `sun_path` + cap (a projects-tree node path alone exceeds it) and the directory is + owned by the server user, to whom the runner drops its privileges. This + mirrors how CML itself runs the image (`source=…/tmp,target=/tmp` in its + node definition). The directory is ephemeral and removed with the node. * **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` (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). + node directory's `tmp/run/` at `/tmp/run` (nested inside the runtime-dir + bind), so the router's configuration survives stop/start and container + recreation while sockets, netio buses and runner logs stay 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 (no permission-fix pass needed). ## Template @@ -95,9 +100,9 @@ keeps the template self-documenting. | Mechanism | Where | What it does | |---|---|---| -| `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_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. The socket directory is bound from a per-node runtime directory unless a persisted volume already covers it. | | `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). | +| `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), creates `/tmp/run/` (the IOL process dies without it) and removes stale sockets/netio dirs from the socket directory (`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. @@ -112,6 +117,9 @@ diagnosing wiring issues). + ~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`). +* **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). diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index cc3302b81..6243f8a94 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -28,17 +28,22 @@ Networking does not use the container's network namespace at all: the runner's 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. +(uBridge reaches them through a per-node runtime directory bound at /tmp — +see ``VendorDockerVM._unix_socket_host_dir``). Because the netio bus +directory is private to the node, the application IDs are fixed constants +with no cross-node collisions. This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker. """ +import contextlib +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__) @@ -124,22 +129,40 @@ class IOLDockerVM(VendorDockerVM): 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. + * Sockets and netio bus directories left in the wiring directory by a + previous (possibly SIGKILLed) run are removed — the runner rebinds + them on boot and would fail on a stale file. - 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). + ``tmp/run`` (startup-config, NVRAM) is never touched. Neither is + anything while the container is already running (idempotent start of + a live node: the sockets belong to the running runner). """ + 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": + return + + wiring_dir = self._unix_socket_wiring_dir() + for pattern in ("s??.sock", "c??.sock"): + for stale in glob.glob(os.path.join(wiring_dir, pattern)): + with contextlib.suppress(OSError): + os.unlink(stale) + for netio_dir in glob.glob(os.path.join(wiring_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 is owned by the server user — which is also what lets an - unprivileged uBridge traverse /proc//root to reach the - container's sockets. + creates is owned by the server user — which is also what lets the + (unprivileged) uBridge write into the node's socket directory. """ config = { diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 7e4abfd4d..6e788a83d 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -32,6 +32,7 @@ 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,13 +71,16 @@ class VendorDockerVM(DockerVM): 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 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 + 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). + Unless the directory is already covered by a persisted volume, a + per-node directory from the runtime directory is bind-mounted there — + owned by the server user (such agents drop privileges and cannot write + into a root-owned directory) and short enough for an AF_UNIX path, + which a node directory inside the projects tree exceeds. 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``). """ @@ -165,6 +169,22 @@ 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 + ): + # The image's network agent drops privileges before using the + # socket directory, so it must be writable by the server user: + # bind a per-node directory from the runtime directory (see + # _unix_socket_host_dir). + binds.append({ + "Type": "bind", + "Source": self._unix_socket_host_dir(), + "Target": socket_dir, + "BindOptions": {"Propagation": "rprivate"}, + }) if self._gns3_init: return binds binds = [b for b in binds if b.get("Target") != "/gns3volumes/etc/network"] @@ -307,20 +327,55 @@ class VendorDockerVM(DockerVM): return self._interface_names[adapter_number] return f"eth{adapter_number}" - async def _unix_socket_wiring_dir(self): + def _unix_socket_host_dir(self): """ - Host-side directory to reference in the uBridge unix-NIO commands: - the container's socket directory reached through its root in /proc. + Host-side directory bind-mounted at the in-container socket directory + when the latter is not already covered by a persisted volume: a + per-node directory under the runtime directory, next to the uBridge + control sockets. - 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. + AF_UNIX paths are capped at 107 bytes (sun_path minus the NUL) — a + node directory inside the projects tree (projects// + project-files/docker//tmp) alone is ~120, so uBridge would + reject the NIO with "invalid file path size". The runtime directory + path stays short no matter where the projects live, and being owned + by the server user, the image's privilege-dropping agent can write + to it. """ - pid = await self._get_namespace() - return f"/proc/{pid}/root{self._unix_socket_dir}" + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir() + host_dir = os.path.join(runtime_dir, "gns3", "unixio", self.id) + try: + os.makedirs(host_dir, mode=0o700, exist_ok=True) + except OSError as e: + raise DockerError( + f"Could not create unix-socket directory '{host_dir}' for container '{self._name}': {e}" + ) + return host_dir + + def _remove_unix_socket_host_dir(self): + """Best-effort removal of the per-node unix-socket directory.""" + + if self._unix_socket_nio: + shutil.rmtree(self._unix_socket_host_dir(), ignore_errors=True) + + def _unix_socket_wiring_dir(self): + """ + Directory referenced in the uBridge unix-NIO commands: the persisted + volume holding the socket directory, or the per-node runtime + directory bound there by _mount_binds. + """ + + socket_dir = self._unix_socket_dir.rstrip("/") + for volume in self._volumes: + if socket_dir == volume.rstrip("/") or socket_dir.startswith(volume.rstrip("/") + "/"): + return os.path.join(self.working_dir, os.path.relpath(socket_dir, "/")) + return self._unix_socket_host_dir() + + async def delete(self): + # The per-node socket directory is ephemeral; the node is not. + await super().delete() + self._remove_unix_socket_host_dir() async def _add_ubridge_connection(self, nio, adapter_number): """ @@ -336,9 +391,10 @@ class VendorDockerVM(DockerVM): * ``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 (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. + s-socket (both on the host side of the socket directory — 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: @@ -354,31 +410,37 @@ class VendorDockerVM(DockerVM): ) bridge_name = f"bridge{adapter_number}" - await self._ubridge_send(f"bridge create {bridge_name}") - self._bridges.add(bridge_name) - - 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") - - # 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"its network agent creates the per-adapter socket pair in " - f"'{self._unix_socket_dir}'." - ) + await self._ubridge_send(f"bridge create {bridge_name}") + self._bridges.add(bridge_name) - await self._ubridge_send(f'bridge add_nio_unix {bridge_name} "{local_sock}" "{remote_sock}"') + 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") + + # 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"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}"') + except Exception: + # A half-wired bridge would make the next start fail with + # "bridge already exist": uBridge stops with the failed start. + await self._stop_ubridge() + raise adapter.host_ifc = local_sock # bookkeeping / removal logging only log.debug( "Adapter %d of container '%s' wired via unix sockets %s <-> %s", diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 4a82d9a27..672325012 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -23,6 +23,7 @@ the uBridge command stream. """ import asyncio +import glob import json import os import uuid @@ -97,21 +98,25 @@ 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 +@pytest.fixture(autouse=True) +def runtime_dir(tmp_path, monkeypatch): + """ + Point the unix-socket runtime directory at a per-test temporary path so + the wiring/mount tests never create per-node directories in the real one. + """ + rt = tmp_path / "run" + monkeypatch.setenv("XDG_RUNTIME_DIR", str(rt)) + return rt 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) + + +def _wiring_dir(vm): + """The per-node socket directory this VM wires through.""" + return os.path.join(os.environ["XDG_RUNTIME_DIR"], "gns3", "unixio", vm.id) # --------------------------------------------------------------------------- @@ -205,14 +210,17 @@ async def test_create_auto_adds_config_and_tmp_run_volumes(compute_project, mana 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"]] + mounts = sent["HostConfig"]["Mounts"] + targets = [m["Target"] for m in mounts if m.get("Type") == "bind"] # /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 + # (skip-init retargeting); /tmp is the ephemeral runtime-dir + # bind holding the netiomux sockets assert "/config" in targets assert "/tmp/run" in targets - assert "/tmp" not in targets + tmp_mounts = [m for m in mounts if m["Target"] == "/tmp"] + assert len(tmp_mounts) == 1 + assert tmp_mounts[0]["Source"] == _wiring_dir(vm) 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/run" in vol_env @@ -293,27 +301,47 @@ async def test_start_creates_run_dir(compute_project, manager): @pytest.mark.asyncio -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. - """ +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) - open(os.path.join(tmp_dir, "run", "nvram_00001"), "w").close() - open(os.path.join(tmp_dir, "run", "config"), "w").close() + wiring_dir = _wiring_dir(vm) + os.makedirs(wiring_dir, exist_ok=True) + for name in ("s00.sock", "c00.sock", "s01.sock", "c01.sock"): + open(os.path.join(wiring_dir, name), "w").close() + os.makedirs(os.path.join(wiring_dir, "netio1000")) + run_dir = os.path.join(vm.working_dir, "tmp", "run") + os.makedirs(run_dir, exist_ok=True) + open(os.path.join(run_dir, "nvram_00001"), "w").close() + open(os.path.join(run_dir, "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() - # 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")) + assert glob.glob(os.path.join(wiring_dir, "s??.sock")) == [] + assert glob.glob(os.path.join(wiring_dir, "c??.sock")) == [] + assert not os.path.exists(os.path.join(wiring_dir, "netio1000")) + # the persistent runtime survives the cleanup + assert os.path.exists(os.path.join(run_dir, "nvram_00001")) + assert os.path.exists(os.path.join(run_dir, "config")) + + +@pytest.mark.asyncio +async def test_start_skips_cleanup_when_already_running(compute_project, manager): + + vm = _make_vm(compute_project, manager) + wiring_dir = _wiring_dir(vm) + os.makedirs(wiring_dir, exist_ok=True) + open(os.path.join(wiring_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(wiring_dir, "s00.sock")) @pytest.mark.asyncio @@ -346,19 +374,18 @@ async def test_add_ubridge_connection_unix_wiring(compute_project, manager): vm = _make_vm(compute_project, manager) _mock_wiring(vm) + wiring_dir = _wiring_dir(vm) + os.makedirs(wiring_dir, exist_ok=True) + # the runner's receive socket must exist (created by the container) + open(os.path.join(wiring_dir, "s00.sock"), "w").close() nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) - with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation", - side_effect=_no_wait): - await vm._add_ubridge_connection(nio, 0) + 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) - 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 + local_sock = os.path.join(wiring_dir, "c00.sock") + remote_sock = os.path.join(wiring_dir, "s00.sock") assert call.send("bridge create bridge0") 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 @@ -370,6 +397,20 @@ async def test_add_ubridge_connection_unix_wiring(compute_project, manager): assert vm._ethernet_adapters[0].host_ifc == local_sock +@pytest.mark.asyncio +async def test_add_ubridge_connection_stale_local_socket_unlinked(compute_project, manager): + + vm = _make_vm(compute_project, manager) + _mock_wiring(vm) + wiring_dir = _wiring_dir(vm) + os.makedirs(wiring_dir, exist_ok=True) + open(os.path.join(wiring_dir, "c00.sock"), "w").close() + open(os.path.join(wiring_dir, "s00.sock"), "w").close() + + await vm._add_ubridge_connection(None, 0) + assert not os.path.exists(os.path.join(wiring_dir, "c00.sock")) + + @pytest.mark.asyncio async def test_add_ubridge_connection_adapter_out_of_range(compute_project, manager): @@ -384,6 +425,7 @@ async def test_add_ubridge_connection_timeout_is_actionable(compute_project, man vm = _make_vm(compute_project, manager) _mock_wiring(vm) + vm._stop_ubridge = AsyncioMagicMock() async def raise_timeout(path, timeout=60): raise asyncio.TimeoutError() @@ -394,7 +436,10 @@ async def test_add_ubridge_connection_timeout_is_actionable(compute_project, man await vm._add_ubridge_connection(None, 0) # the message names the adapter and the exact wiring path assert "adapter 0" in str(excinfo.value) - assert f"/proc/{_FAKE_PID}/root/tmp/s00.sock" in str(excinfo.value) + assert "s00.sock" in str(excinfo.value) + # uBridge must not survive a half-wired bridge: the retry would fail + # with "bridge already exist" + assert vm._stop_ubridge.called @pytest.mark.asyncio @@ -402,9 +447,11 @@ async def test_add_ubridge_connection_without_nio_still_wires(compute_project, m vm = _make_vm(compute_project, manager) _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) + wiring_dir = _wiring_dir(vm) + os.makedirs(wiring_dir, exist_ok=True) + open(os.path.join(wiring_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 @@ -438,16 +485,29 @@ def test_env_unix_socket_nio_parsing(compute_project, manager): assert vm._unix_socket_nio is False -def test_unix_socket_dir_needs_no_volume(compute_project, manager): +def test_unix_socket_dir_bound_from_runtime_dir(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=[]) - # sockets are reached through /proc, not through a volume bind — creating - # the container without the socket dir in extra_volumes is fine + # the socket directory is an ephemeral per-node directory from the + # runtime dir, not a volume: writable by the (unprivileged) agent and + # short enough for AF_UNIX binds = vm._mount_binds({"Config": {"Volumes": {}}}) - assert not any(b["Target"] == "/tmp" for b in binds) + socket_binds = [b for b in binds if b.get("Target") == "/tmp"] + assert len(socket_binds) == 1 + assert socket_binds[0]["Type"] == "bind" + assert socket_binds[0]["Source"] == _wiring_dir(vm) + + # a socket dir already covered by a persisted volume gets no extra bind + 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 not any(b.get("Source") == _wiring_dir(vm) for b in binds) + assert any(b.get("Target") == "/tmp" for b in binds) @pytest.mark.asyncio @@ -457,9 +517,11 @@ async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manage console_type="docker_exec", environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks") _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) + wiring_dir = _wiring_dir(vm) + os.makedirs(wiring_dir, exist_ok=True) + open(os.path.join(wiring_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'"/proc/{_FAKE_PID}/root/var/run/socks/s00.sock"' in flat + assert f'"{os.path.join(wiring_dir, "s00.sock")}"' in flat assert "add_nio_tap" not in flat