mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-07 02:25:21 +03:00
refactor: wire unix-socket NIOs through a per-node runtime directory
uBridge cannot reach sockets bind-mounted from the node's projects directory: the path exceeds AF_UNIX's 107-byte sun_path cap. The /proc detour from the previous commit is a dead end too - the runner drops from root to the server uid, which makes the process non-dumpable and its /proc/<pid>/root unreadable for the unprivileged server. Instead, mirror how CML itself runs the image (source=<scratch>/tmp, target=/tmp in its node definition): bind a per-node directory from the runtime directory (<XDG_RUNTIME_DIR>/gns3/unixio/<node-id>, next to the uBridge control sockets) at the image's socket directory. The path is short, the directory is owned by the server user (whom the agent drops its privileges to), and it is removed with the node. A socket directory covered by a persisted volume keeps working as before. IOLDockerVM persists only /tmp/run (nested bind at /tmp/run) and cleans stale sockets/netio dirs from the socket directory on start, skipping the cleanup when the container is already running. A failed wiring now stops uBridge so the next start does not hit 'bridge already exist'.
This commit is contained in:
parent
165f271bb8
commit
d04a99f934
@ -36,10 +36,11 @@ graph LR
|
||||
IOL -->|"netio bus /tmp/netio<uid>/"| NETIOMUX --> SOCKETS
|
||||
end
|
||||
subgraph Host
|
||||
UBRIDGE["uBridge bridgeN<br/>add_nio_unix /proc/PID/root/tmp/cNN.sock …<br/>+ add_nio_udp (topology)"]
|
||||
VOL["project-files/docker/<node>/config<br/>(+ tmp/run nested bind)"]
|
||||
UBRIDGE["uBridge bridgeN<br/>add_nio_unix …/gns3/unixio/<node>/cNN.sock …<br/>+ add_nio_udp (topology)"]
|
||||
RTDIR["/run/user/<uid>/gns3/unixio/<node><br/>(bind-mounted at /tmp)"]
|
||||
VOL["project-files/docker/<node>/config<br/>(+ tmp/run, nested bind at /tmp/run)"]
|
||||
end
|
||||
SOCKETS <-->|"raw Ethernet frames via<br/>the container root in /proc"| UBRIDGE
|
||||
SOCKETS <-->|"same files, two spellings<br/>(the /tmp bind)"| RTDIR <--> UBRIDGE
|
||||
UBRIDGE -.->|"iol-config.json +<br/>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/<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.
|
||||
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/<uid>/gns3/unixio/<node-id>`, 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/<pid>/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/<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_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. The socket directory is bound from a per-node runtime directory unless a persisted volume already covers it. |
|
||||
| `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 `/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). |
|
||||
| `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), creates `<node>/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).
|
||||
|
||||
@ -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).
|
||||
* ``<working_dir>/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/<pid>/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 = {
|
||||
|
||||
@ -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=<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/<pid>/root/<dir> 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/<uuid>/…)
|
||||
alone exceeds it.
|
||||
AF_UNIX paths are capped at 107 bytes (sun_path minus the NUL) — a
|
||||
node directory inside the projects tree (projects/<uuid>/
|
||||
project-files/docker/<uuid>/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",
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user