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