gns3-server/tests/compute/docker/test_iol_docker_vm.py
YueGuobin 165f271bb8
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.
2026-09-05 21:54:12 +08:00

466 lines
18 KiB
Python

#
# Copyright (C) 2025 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Tests for the IOLDockerVM subclass (Cisco CML iol-runner images, e.g.
iol-xe/iol-xe:17-18-02) and its unix-socket NIO wiring.
Image-free: everything is asserted against generated files, parsed knobs and
the uBridge command stream.
"""
import asyncio
import json
import os
import uuid
import pytest
import pytest_asyncio
from unittest.mock import patch, MagicMock, call
from tests.utils import asyncio_patch, AsyncioMagicMock
from gns3server.compute.docker import Docker
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
from gns3server.compute.docker.docker_error import DockerError
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
IOL_ENTRYPOINT = ["/iol-runner", "-config", "/config/iol-config.json", "-stdio"]
def _create_response(entrypoint=None, volumes=None):
"""Build the Docker /containers/create response (with image info merged)."""
return {
"Id": "e90e34656806",
"Warnings": [],
"Config": {
"Entrypoint": entrypoint or IOL_ENTRYPOINT,
"Cmd": [],
"Volumes": volumes or {},
},
}
@pytest_asyncio.fixture
async def manager(port_manager):
m = Docker.instance()
m.port_manager = port_manager
return m
def _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1",
extra_volumes=None, adapters=4, console_type="telnet"):
"""Build an IOLDockerVM with a fake cid (no create() called)."""
vm = IOLDockerVM(
"iol-xe-1", str(uuid.uuid4()), compute_project, manager, "iol-xe/iol-xe:17-18-02",
console_type=console_type, environment=environment,
extra_volumes=extra_volumes or [], adapters=adapters,
)
vm._cid = "e90e34656842"
return vm
def _mock_start(vm, state="stopped"):
"""Mock everything DockerVM.start() needs besides the runtime prep."""
vm._get_container_state = AsyncioMagicMock(return_value=state)
vm._start_ubridge = AsyncioMagicMock()
vm._get_namespace = AsyncioMagicMock(return_value=42)
vm._add_ubridge_connection = AsyncioMagicMock()
vm._start_console_server = AsyncioMagicMock()
def _seed_proc(stdout=b"seedcid\n", returncode=0):
proc = MagicMock()
proc.communicate = AsyncioMagicMock(return_value=(stdout, b""))
proc.returncode = returncode
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
# ---------------------------------------------------------------------------
def test_factory_selects_iol_for_env_marker(manager):
assert manager._select_node_class(console_type="telnet",
environment="GNS3_IOL_RUNNER=1") is IOLDockerVM
def test_factory_tolerates_whitespace_and_comma(manager):
assert manager._select_node_class(console_type="telnet",
environment=" GNS3_IOL_RUNNER=1,\nFOO=bar") is IOLDockerVM
def test_factory_docker_exec_wins_over_iol_marker(manager):
assert manager._select_node_class(console_type="docker_exec",
environment="GNS3_IOL_RUNNER=1") is VendorDockerVM
def test_factory_plain_environment_is_base(manager):
assert manager._select_node_class(console_type="telnet",
environment="FOO=bar\nGNS3_BAZ=nope") is DockerVM
def test_factory_generic_unix_knob_selects_vendor(manager):
assert manager._select_node_class(console_type="telnet",
environment="GNS3_UNIX_SOCKET_NIO=1") is VendorDockerVM
# ---------------------------------------------------------------------------
# Knob parsing
# ---------------------------------------------------------------------------
def test_marker_forces_skip_init_and_unix_nio(compute_project, manager):
vm = _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1")
assert vm._gns3_init is False
assert vm._unix_socket_nio is True
assert vm._unix_socket_dir == "/tmp"
assert vm._iol_memory == 2048
def test_iol_memory_knob(compute_project, manager):
vm = _make_vm(compute_project, manager,
environment="GNS3_IOL_RUNNER=1\nGNS3_IOL_MEMORY=4096")
assert vm._iol_memory == 4096
vm = _make_vm(compute_project, manager,
environment="GNS3_IOL_RUNNER=1\nGNS3_IOL_MEMORY=notanumber")
assert vm._iol_memory == 2048
# ---------------------------------------------------------------------------
# create()
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_keeps_image_entrypoint(compute_project, manager):
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "iol-xe"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query",
return_value=_create_response()) as mock:
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=_seed_proc()):
vm = _make_vm(compute_project, manager)
await vm.create()
sent = mock.call_args.kwargs["data"]
# the iol-runner entrypoint runs as PID 1, untouched
assert sent["Entrypoint"] == IOL_ENTRYPOINT
assert sent["Cmd"] == []
@pytest.mark.asyncio
async def test_create_auto_adds_config_and_tmp_run_volumes(compute_project, manager):
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "iol-xe"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query",
return_value=_create_response()) as mock:
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=_seed_proc()):
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"]]
# /config (runner config) and /tmp/run (startup-config + NVRAM)
# are forced and bound at their real in-container paths
# (skip-init retargeting); the sockets stay in the container's
# own /tmp, reached via /proc, so /tmp itself is not a volume
assert "/config" in targets
assert "/tmp/run" in targets
assert "/tmp" not in targets
assert not any(t.startswith("/gns3volumes/") for t in targets)
vol_env = [v for v in sent["Env"] if v.startswith("GNS3_VOLUMES=")][0]
assert "/config" in vol_env and "/tmp/run" in vol_env
@pytest.mark.asyncio
async def test_create_start_command_becomes_runner_flags(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.start_command = "-keep"
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "iol-xe"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query",
return_value=_create_response()) as mock:
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=_seed_proc()):
await vm.create()
sent = mock.call_args.kwargs["data"]
# start_command is the container CMD = extra iol-runner flags
assert sent["Cmd"] == ["-keep"]
# ---------------------------------------------------------------------------
# start() — runtime preparation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_writes_iol_config(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=4)
_mock_start(vm)
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
with open(os.path.join(vm.working_dir, "config", "iol-config.json")) as f:
config = json.load(f)
assert config["binary"] == "/binary.iol"
assert config["num-eth"] == 4
assert config["num-serial"] == 0
assert config["local-app"] == 1
assert config["remote-app"] == 2
assert config["memory"] == 2048
assert config["user-id"] == os.getuid()
assert config["group-id"] == os.getgid()
assert vm.status == "started"
@pytest.mark.asyncio
async def test_start_rewrites_config_on_adapter_change(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=4)
_mock_start(vm)
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
vm.adapters = 8
_mock_start(vm)
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
with open(os.path.join(vm.working_dir, "config", "iol-config.json")) as f:
assert json.load(f)["num-eth"] == 8
@pytest.mark.asyncio
async def test_start_creates_run_dir(compute_project, manager):
vm = _make_vm(compute_project, manager)
_mock_start(vm)
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
assert os.path.isdir(os.path.join(vm.working_dir, "tmp", "run"))
@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.
"""
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()
_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"))
@pytest.mark.asyncio
async def test_fix_permissions_is_noop(compute_project, manager):
vm = _make_vm(compute_project, manager)
with patch("asyncio.subprocess.create_subprocess_exec") as mock_exec:
await vm._fix_permissions()
mock_exec.assert_not_called()
assert vm._permissions_fixed is True
@pytest.mark.asyncio
async def test_restart_is_graceful_stop_then_start(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.stop = AsyncioMagicMock()
vm.start = AsyncioMagicMock()
await vm.restart()
vm.stop.assert_called_once_with(graceful=True)
vm.start.assert_called_once()
# ---------------------------------------------------------------------------
# Wiring — unix-socket NIO
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_add_ubridge_connection_unix_wiring(compute_project, manager):
vm = _make_vm(compute_project, manager)
_mock_wiring(vm)
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)
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
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
assert "bridge start bridge0" in flat
# the TAP/namespace path must not be used at all
assert "add_nio_tap" not in flat
assert "move_to_ns" not in flat
assert "set_mac_addr" not in flat
assert vm._ethernet_adapters[0].host_ifc == local_sock
@pytest.mark.asyncio
async def test_add_ubridge_connection_adapter_out_of_range(compute_project, manager):
vm = _make_vm(compute_project, manager)
_mock_wiring(vm)
with pytest.raises(DockerError):
await vm._add_ubridge_connection(None, 42)
@pytest.mark.asyncio
async def test_add_ubridge_connection_timeout_is_actionable(compute_project, manager):
vm = _make_vm(compute_project, manager)
_mock_wiring(vm)
async def raise_timeout(path, timeout=60):
raise asyncio.TimeoutError()
with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation",
side_effect=raise_timeout):
with pytest.raises(DockerError) as excinfo:
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)
@pytest.mark.asyncio
async def test_add_ubridge_connection_without_nio_still_wires(compute_project, manager):
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)
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
# no link yet: no UDP NIO, no bridge start (matches base semantics)
assert "add_nio_udp" not in flat
assert "bridge start" not in flat
# ---------------------------------------------------------------------------
# Generic GNS3_UNIX_SOCKET_NIO knob on plain VendorDockerVM
# ---------------------------------------------------------------------------
def test_env_unix_socket_nio_parsing(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\nGNS3_UNIX_SOCKET_DIR=/var/run/socks")
assert vm._unix_socket_nio is True
assert vm._unix_socket_dir == "/var/run/socks"
# invalid dirs are rejected, keeping the default
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=yes\nGNS3_UNIX_SOCKET_DIR=../../etc")
assert vm._unix_socket_nio is True
assert vm._unix_socket_dir == "/tmp"
# off by default / explicit off
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
console_type="docker_exec", environment="GNS3_SKIP_INIT=1")
assert vm._unix_socket_nio is False
def test_unix_socket_dir_needs_no_volume(compute_project, manager):
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
console_type="docker_exec",
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1",
extra_volumes=[])
# sockets are reached through /proc, not through a volume bind — creating
# the container without the socket dir in extra_volumes is fine
binds = vm._mount_binds({"Config": {"Volumes": {}}})
assert not any(b["Target"] == "/tmp" for b in binds)
@pytest.mark.asyncio
async def test_generic_unix_socket_dir_honored_in_wiring(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\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)
flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls)
assert f'"/proc/{_FAKE_PID}/root/var/run/socks/s00.sock"' in flat
assert "add_nio_tap" not in flat