refactor: reach unix-socket NIOs through the container root in /proc

The unix-socket NIO no longer requires the socket directory to be a
persisted volume: uBridge references the sockets through
/proc/<container-pid>/root<dir>/..., which is always well under the
107-byte sun_path cap (a project directory path alone exceeds it) and
leaves the sockets ephemeral in the container's own filesystem.

This replaces the runtime-dir symlink alias and its cleanup, and the
mount-time volume enforcement. IOLDockerVM now persists only /tmp/run
(the IOL working directory: startup-config + NVRAM) as a nested bind
instead of the whole /tmp; stale socket cleanup is gone with it, as
containers are recreated on every start.
This commit is contained in:
YueGuobin 2026-08-30 13:25:20 +08:00
parent 42e26717e6
commit 165f271bb8
No known key found for this signature in database
4 changed files with 135 additions and 252 deletions

View File

@ -36,10 +36,11 @@ graph LR
IOL -->|"netio bus /tmp/netio&lt;uid&gt;/"| NETIOMUX --> SOCKETS IOL -->|"netio bus /tmp/netio&lt;uid&gt;/"| NETIOMUX --> SOCKETS
end end
subgraph Host subgraph Host
UBRIDGE["uBridge bridgeN<br/>add_nio_unix c00.sock s00.sock<br/>+ add_nio_udp (topology)"] UBRIDGE["uBridge bridgeN<br/>add_nio_unix /proc/PID/root/tmp/cNN.sock …<br/>+ add_nio_udp (topology)"]
VOL["project-files/docker/&lt;node&gt;/tmp"] VOL["project-files/docker/&lt;node&gt;/config<br/>(+ tmp/run nested bind)"]
end end
SOCKETS <-->|"raw Ethernet frames<br/>(bind volume dir)"| VOL <--> UBRIDGE SOCKETS <-->|"raw Ethernet frames via<br/>the container root in /proc"| UBRIDGE
UBRIDGE -.->|"iol-config.json +<br/>persistent /tmp/run"| VOL
``` ```
* **Console**: the runner muxes the IOS console onto PID 1 stdio (`-stdio` * **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 `docker_exec` needed. The runner requires a TTY, which GNS3 always
allocates; without one the runner exits (`inappropriate ioctl for device`). allocates; without one the runner exits (`inappropriate ioctl for device`).
* **Networking**: the runner does not touch the container's network * **Networking**: the runner does not touch the container's network
namespace. Per interface N it creates, inside `/tmp`: a receive socket namespace. Per interface N it creates, inside the container's `/tmp`: a
`s%02d.sock` (frames sent there are injected into guest interface N) and a receive socket `s%02d.sock` (frames sent there are injected into guest
send-to path `c%02d.sock` (whoever binds it receives the guest's frames). interface N) and a send-to path `c%02d.sock` (whoever binds it receives
Frames are **raw Ethernet**, one datagram per frame. Because `/tmp` is a the guest's frames). Frames are **raw Ethernet**, one datagram per frame.
persisted volume (bind-mounted from the node directory), uBridge can bind uBridge reaches both through the container's root in `/proc`
`cNN.sock` and send to `sNN.sock` on the host. (`/proc/<container-pid>/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` * **Licensing**: the image ships a self-consistent `/etc/hostid` + `.iourc`
pair, and the runner regenerates the license from the host ID at boot — pair, and the runner regenerates the license from the host ID at boot —
nothing to configure. nothing to configure.
* **Persistence**: `/tmp/run/` inside the volume holds the NETMAP, the * **Persistence**: `/tmp/run` (the IOL working directory) holds the NETMAP,
startup-config (`config`, plain IOS format) and NVRAM (`nvram_00001`), so the startup-config (`config`, plain IOS format) and NVRAM (`nvram_00001`).
the router's configuration survives stop/start and container recreation. It is the only `/tmp` path that needs to survive: GNS3 bind-mounts the
The generated config maps the runner to the server's uid/gid node directory's `tmp/run/` at `/tmp/run`, so the router's configuration
(`user-id`/`group-id`), which is also what makes the `/tmp` sockets survives stop/start and container recreation while everything else in
reachable by uBridge and all volume files owned by the server user (no `/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/<pid>/root` to reach the container's sockets (no
permission-fix pass needed). permission-fix pass needed).
## Template ## Template
@ -76,21 +83,21 @@ graph LR
"adapters": 4, "adapters": 4,
"console_type": "telnet", "console_type": "telnet",
"environment": "GNS3_IOL_RUNNER=1", "environment": "GNS3_IOL_RUNNER=1",
"extra_volumes": ["/config", "/tmp"], "extra_volumes": ["/config"],
"memory": 2560 "memory": 2560
} }
``` ```
`/config` and `/tmp` are auto-added even if omitted; listing them keeps the `/config` and `/tmp/run` are auto-added even if omitted; listing `/config`
template self-documenting. keeps the template self-documenting.
## Server mechanisms ## Server mechanisms
| Mechanism | Where | What it does | | Mechanism | Where | What it does |
|---|---|---| |---|---|---|
| `GNS3_UNIX_SOCKET_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix <dir>/c{N:02d}.sock <dir>/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_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix /proc/<pid>/root<dir>/c{N:02d}.sock /proc/<pid>/root<dir>/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=<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_UNIX_SOCKET_DIR=<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 two volumes; on every start writes `<node>/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `<node>/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_IOL_RUNNER=1` | `IOLDockerVM` (selected in the manager) | Forces skip-init + unix-socket NIO + the `/config` and `/tmp/run` volumes; on every start writes `<node>/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048) and creates `<node>/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. | | `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. `GNS3_STOP_TIMEOUT` (default 60) controls the SIGTERM grace period on stop.

View File

@ -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). console type; requires a TTY, which GNS3 always allocates).
Networking does not use the container's network namespace at all: the runner's Networking does not use the container's network namespace at all: the runner's
netiomux exposes per-interface AF_UNIX datagram sockets in ``/tmp`` netiomux exposes per-interface AF_UNIX datagram sockets in the container's
(``s%02d.sock`` receive, ``c%02d.sock`` send — raw Ethernet frames), wired by ``/tmp`` (``s%02d.sock`` receive, ``c%02d.sock`` send — raw Ethernet frames),
the generic ``GNS3_UNIX_SOCKET_NIO`` capability of VendorDockerVM. Because the wired by the generic ``GNS3_UNIX_SOCKET_NIO`` capability of VendorDockerVM
netio bus directory is private to the node's ``/tmp`` volume, the application (uBridge reaches them through the container's root in /proc). Because the
IDs are fixed constants with no cross-node collisions. 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. This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker.
""" """
import glob
import json import json
import logging import logging
import os import os
import shutil
from gns3server.compute.docker.docker_error import DockerHttp404Error
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -58,7 +56,7 @@ class IOLDockerVM(VendorDockerVM):
OOM-killer will fire. OOM-killer will fire.
The marker itself forces ``GNS3_SKIP_INIT`` and the unix-socket NIO wiring, 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. 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): def _persistent_volume_list(self, image_info, include_network_config=True):
""" """
Override: the runner requires ``/config`` (its config file, generated Override: the runner requires ``/config`` (its config file, generated
below) and ``/tmp`` (netiomux sockets, NETMAP, NVRAM) as persisted below) and ``/tmp/run`` (its working directory: startup-config and
volumes — /tmp because uBridge must reach the sockets on the host. NVRAM live there — NETMAP and the netiomux sockets are ephemeral and
Auto-add both so a minimal template cannot be misconfigured. 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) 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): if not any(needed == v or needed.startswith(v.rstrip("/") + "/") for v in volumes):
volumes.append(needed) volumes.append(needed)
return volumes return volumes
@ -119,47 +118,28 @@ class IOLDockerVM(VendorDockerVM):
async def _prepare_iol_runtime(self): 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:
* ``<working_dir>/tmp/run/`` must exist or the IOL process dies at * ``<working_dir>/tmp/run/`` must exist or the IOL process dies at
boot (the runner writes NETMAP there but does not create it). boot (the runner writes NETMAP there but does not create it).
* ``<working_dir>/config/iol-config.json`` is rewritten on every * ``<working_dir>/config/iol-config.json`` is rewritten on every
start so adapter-count and memory changes take effect. 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: The netiomux sockets and the netio bus directory need no cleanup:
state = await self._get_container_state() they live in the container's own /tmp, which is fresh in every
except DockerHttp404Error: container GNS3 creates (containers are recreated on each start).
state = "stopped" """
os.makedirs(os.path.join(self.working_dir, "tmp", "run"), exist_ok=True) os.makedirs(os.path.join(self.working_dir, "tmp", "run"), exist_ok=True)
self._write_iol_config() 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): def _write_iol_config(self):
""" """
Write the runner's config file on the host side of the /config volume. 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 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 creates is owned by the server user — which is also what lets an
what makes the /tmp sockets reachable by uBridge). unprivileged uBridge traverse /proc/<pid>/root to reach the
container's sockets.
""" """
config = { config = {

View File

@ -32,7 +32,6 @@ import json
import logging import logging
import os import os
import shutil import shutil
import tempfile
from gns3server.utils.asyncio import wait_for_file_creation from gns3server.utils.asyncio import wait_for_file_creation
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer 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 socket files instead of a TAP interface moved into the container's
network namespace. For images whose network agent exposes, per adapter 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`` ``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 (raw Ethernet frames, one datagram per frame) inside the container —
directory — e.g. Cisco CML's iol-runner (see IOLDockerVM). uBridge e.g. Cisco CML's iol-runner (see IOLDockerVM). uBridge reaches the
binds ``c{N:02d}.sock`` (its receive side) and sends to sockets through the container's root in ``/proc`` (see
``s{N:02d}.sock``. No TAP is created, the container's network namespace ``_unix_socket_wiring_dir``), so no volume mount is required; it binds
is untouched and the ``mac_address`` template field is ignored (the ``c{N:02d}.sock`` (its receive side) and sends to ``s{N:02d}.sock``.
image's agent owns the MAC scheme). 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=<dir>`` — in-container directory holding the * ``GNS3_UNIX_SOCKET_DIR=<dir>`` — in-container directory holding the
socket files (default ``/tmp``). Must be a persisted volume socket files (default ``/tmp``).
(extra_volumes) so uBridge can reach the sockets on the host; node
creation fails with an actionable error otherwise.
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -104,7 +103,6 @@ class VendorDockerVM(DockerVM):
self._stop_timeout = 60 self._stop_timeout = 60
self._unix_socket_nio = False self._unix_socket_nio = False
self._unix_socket_dir = "/tmp" self._unix_socket_dir = "/tmp"
self._unix_socket_aliases = set()
if self._environment: if self._environment:
for _line in self._environment.splitlines(): for _line in self._environment.splitlines():
_line = _line.strip().rstrip(",") _line = _line.strip().rstrip(",")
@ -167,17 +165,6 @@ class VendorDockerVM(DockerVM):
are never shadowed by an empty mount. are never shadowed by an empty mount.
""" """
binds = super()._mount_binds(image_info) 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: if self._gns3_init:
return binds return binds
binds = [b for b in binds if b.get("Target") != "/gns3volumes/etc/network"] 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 self._interface_names[adapter_number]
return f"eth{adapter_number}" return f"eth{adapter_number}"
@property async def _unix_socket_wiring_dir(self):
def _unix_socket_host_dir(self):
""" """
Host-side path of the in-container unix-socket directory: the bind Host-side directory to reference in the uBridge unix-NIO commands:
source of the persisted volume it lives in. the container's socket directory reached through its root in /proc.
"""
return os.path.join(self.working_dir, os.path.relpath(self._unix_socket_dir, "/"))
def _unix_socket_wiring_dir(self): The sockets live in the container's own filesystem (no volume mount
""" needed); /proc/<pid>/root/<dir> resolves to the same files uBridge
Directory to reference in the uBridge unix-NIO commands. sees, from outside any namespace. This also keeps the path well under
the 107-byte sun_path cap — a node directory (projects/<uuid>/…)
AF_UNIX paths are capped at 107 bytes (sun_path minus the NUL), and a alone exceeds it.
node volume directory (projects/<uuid>/project-files/docker/<uuid>/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.
""" """
host_dir = self._unix_socket_host_dir pid = await self._get_namespace()
if len(host_dir) + len("/c00.sock") <= 107: return f"/proc/{pid}/root{self._unix_socket_dir}"
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()
async def _add_ubridge_connection(self, nio, adapter_number): 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. a TAP interface moved into the container's network namespace.
Per adapter N the image's network agent is expected to create, inside 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 * ``s{N:02d}.sock`` — its receive socket; frames sent there are
injected into guest interface N; injected into guest interface N;
* ``c{N:02d}.sock`` — the path it sends guest-egress frames to. * ``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 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 s-socket (both reached through /proc — see _unix_socket_wiring_dir).
MAC addresses are whatever the image's agent uses. 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: if not self._unix_socket_nio:
@ -406,8 +357,7 @@ class VendorDockerVM(DockerVM):
await self._ubridge_send(f"bridge create {bridge_name}") await self._ubridge_send(f"bridge create {bridge_name}")
self._bridges.add(bridge_name) self._bridges.add(bridge_name)
host_dir = self._unix_socket_host_dir wiring_dir = await self._unix_socket_wiring_dir()
wiring_dir = self._unix_socket_wiring_dir()
local_sock = os.path.join(wiring_dir, f"c{adapter_number:02d}.sock") 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") 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"Socket '{remote_sock}' for adapter {adapter_number} of container "
f"'{self._name}' did not appear within 30 seconds. Check that the " f"'{self._name}' did not appear within 30 seconds. Check that the "
f"container's port count covers adapter {adapter_number} and that " 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}"') await self._ubridge_send(f'bridge add_nio_unix {bridge_name} "{local_sock}" "{remote_sock}"')

View File

@ -23,7 +23,6 @@ the uBridge command stream.
""" """
import asyncio import asyncio
import glob
import json import json
import os import os
import uuid import uuid
@ -98,6 +97,23 @@ def _seed_proc(stdout=b"seedcid\n", returncode=0):
return proc 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 # Factory selection
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -178,7 +194,7 @@ async def test_create_keeps_image_entrypoint(compute_project, manager):
@pytest.mark.asyncio @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", with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "iol-xe"}]): 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() await vm.create()
sent = mock.call_args.kwargs["data"] sent = mock.call_args.kwargs["data"]
targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]] targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]]
# both volumes are forced and bound at their real in-container # /config (runner config) and /tmp/run (startup-config + NVRAM)
# paths (skip-init retargeting), reachable by uBridge on the host # 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 "/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) assert not any(t.startswith("/gns3volumes/") for t in targets)
vol_env = [v for v in sent["Env"] if v.startswith("GNS3_VOLUMES=")][0] 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 @pytest.mark.asyncio
@ -274,14 +293,16 @@ async def test_start_creates_run_dir(compute_project, manager):
@pytest.mark.asyncio @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) vm = _make_vm(compute_project, manager)
tmp_dir = os.path.join(vm.working_dir, "tmp") tmp_dir = os.path.join(vm.working_dir, "tmp")
os.makedirs(os.path.join(tmp_dir, "run"), exist_ok=True) 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", "nvram_00001"), "w").close()
open(os.path.join(tmp_dir, "run", "config"), "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"): with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start() await vm.start()
assert glob.glob(os.path.join(tmp_dir, "s??.sock")) == [] # the persistent runtime is untouched
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", "nvram_00001"))
assert os.path.exists(os.path.join(tmp_dir, "run", "config")) 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 @pytest.mark.asyncio
async def test_fix_permissions_is_noop(compute_project, manager): 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): async def test_add_ubridge_connection_unix_wiring(compute_project, manager):
vm = _make_vm(compute_project, manager) vm = _make_vm(compute_project, manager)
vm._ubridge_hypervisor = MagicMock() _mock_wiring(vm)
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"}) 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)] sent = [c for c in vm._ubridge_hypervisor.method_calls if "send" in str(c)]
flat = "\n".join(str(c) for c in sent) 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("bridge create bridge0") in sent
assert call.send(f'bridge add_nio_unix bridge0 "{os.path.join(wiring_dir, "c00.sock")}" ' assert call.send(f'bridge add_nio_unix bridge0 "{local_sock}" "{remote_sock}"') in sent
f'"{os.path.join(wiring_dir, "s00.sock")}"') in sent
assert "add_nio_udp bridge0 4242 127.0.0.1 4343" in flat assert "add_nio_udp bridge0 4242 127.0.0.1 4343" in flat
assert "bridge start bridge0" in flat assert "bridge start bridge0" in flat
# the TAP/namespace path must not be used at all # the TAP/namespace path must not be used at all
assert "add_nio_tap" not in flat assert "add_nio_tap" not in flat
assert "move_to_ns" not in flat assert "move_to_ns" not in flat
assert "set_mac_addr" not in flat assert "set_mac_addr" not in flat
assert vm._ethernet_adapters[0].host_ifc == os.path.join(wiring_dir, "c00.sock") 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)
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 @pytest.mark.asyncio
async def test_add_ubridge_connection_adapter_out_of_range(compute_project, manager): async def test_add_ubridge_connection_adapter_out_of_range(compute_project, manager):
vm = _make_vm(compute_project, manager) vm = _make_vm(compute_project, manager)
vm._ubridge_hypervisor = MagicMock() _mock_wiring(vm)
with pytest.raises(DockerError): with pytest.raises(DockerError):
await vm._add_ubridge_connection(None, 42) 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): async def test_add_ubridge_connection_timeout_is_actionable(compute_project, manager):
vm = _make_vm(compute_project, manager) vm = _make_vm(compute_project, manager)
vm._ubridge_hypervisor = MagicMock() _mock_wiring(vm)
async def raise_timeout(path, timeout=60): async def raise_timeout(path, timeout=60):
raise asyncio.TimeoutError() raise asyncio.TimeoutError()
@ -404,21 +392,19 @@ async def test_add_ubridge_connection_timeout_is_actionable(compute_project, man
side_effect=raise_timeout): side_effect=raise_timeout):
with pytest.raises(DockerError) as excinfo: with pytest.raises(DockerError) as excinfo:
await vm._add_ubridge_connection(None, 0) 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 "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 @pytest.mark.asyncio
async def test_add_ubridge_connection_without_nio_still_wires(compute_project, manager): async def test_add_ubridge_connection_without_nio_still_wires(compute_project, manager):
vm = _make_vm(compute_project, manager) vm = _make_vm(compute_project, manager)
vm._ubridge_hypervisor = MagicMock() _mock_wiring(vm)
host_dir = os.path.join(vm.working_dir, "tmp") with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation",
os.makedirs(host_dir, exist_ok=True) side_effect=_no_wait):
open(os.path.join(host_dir, "s00.sock"), "w").close() await vm._add_ubridge_connection(None, 0)
await vm._add_ubridge_connection(None, 0)
flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls) flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls)
assert "bridge create bridge0" in flat assert "bridge create bridge0" in flat
assert "add_nio_unix" 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 assert vm._unix_socket_nio is False
@pytest.mark.asyncio def test_unix_socket_dir_needs_no_volume(compute_project, manager):
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", vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
console_type="docker_exec", console_type="docker_exec",
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1", environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1",
extra_volumes=[]) extra_volumes=[])
with pytest.raises(DockerError) as excinfo: # sockets are reached through /proc, not through a volume bind — creating
vm._mount_binds({"Config": {"Volumes": {}}}) # the container without the socket dir in extra_volumes is fine
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": {}}}) 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 @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", vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
console_type="docker_exec", console_type="docker_exec",
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks") environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks")
vm._ubridge_hypervisor = MagicMock() _mock_wiring(vm)
host_dir = os.path.join(vm.working_dir, "var", "run", "socks") with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation",
os.makedirs(host_dir, exist_ok=True) side_effect=_no_wait):
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):
await vm._add_ubridge_connection(None, 0) await vm._add_ubridge_connection(None, 0)
alias = os.path.join(str(runtime_dir), "gns3", f"unixio-{vm.id}") flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls)
assert os.path.islink(alias) and os.readlink(alias) == long_dir assert f'"/proc/{_FAKE_PID}/root/var/run/socks/s00.sock"' in flat
flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls) assert "add_nio_tap" not in flat
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)