gns3-server/tests/compute/docker/test_iol_docker_vm.py
YueGuobin 8b3aafbbdc
feat: IOU-style startup-config for IOL Docker nodes
Templates reference a config file with the GNS3_IOL_STARTUP_CONFIG
environment knob; the controller materializes the file content into
startup_config_content on node creation (sent once, knob consumed —
the same pattern as the IOU startup_config mapping). The compute builds
the content into the node's nvram_<app id> at the next start using the
IOU nvram_import utility (IOL and IOU share the nvram container format,
verified against iol-xe 17.18.02): valid config at boot, no setup
dialog, %h hostname substitution, hostname rewrite on rename.

Semantics verified against the runner: IOL boots from NVRAM whenever it
holds a config, so a plain stop/start never re-applies the startup
config and 'write memory' survives restarts; an explicit content edit
(PUT) is re-applied on the next start and wins over the saved config,
like IOU.
2026-09-05 21:54:52 +08:00

737 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#
# 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 glob
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
from gns3server.compute.iou.utils.iou_export import nvram_export
from gns3server.compute.iou.utils.iou_import import nvram_import
# ---------------------------------------------------------------------------
# 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
@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()
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)
# ---------------------------------------------------------------------------
# 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"]
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); /tmp is the ephemeral runtime-dir
# bind holding the netiomux sockets
assert "/config" in targets
assert "/tmp/run" 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
@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"] == 16 # 4 adapters, each a 4-port unit
assert config["num-serial"] == 0
assert config["local-app"] == 512 + int(vm.id.replace("-", ""), 16) % 511
assert config["remote-app"] == 1023
assert config["memory"] == 2048
assert config["user-id"] == os.getuid()
assert config["group-id"] == os.getgid()
assert vm.status == "started"
def test_local_app_is_distinct_per_node(compute_project, manager):
# IOL derives interface MACs from the app ID: two nodes sharing one would
# drop each other's frames as MAC loops, so IDs must differ per node.
vm1 = _make_vm(compute_project, manager)
vm2 = _make_vm(compute_project, manager)
assert vm1.application_id != vm2.application_id
for vm in (vm1, vm2):
assert 512 <= vm.application_id <= 1022
@pytest.mark.asyncio
async def test_allocated_application_id_overrides_fallback(compute_project, manager):
# The controller allocates the application ID (upper half of the id
# space); the node-derived hash is only a fallback without one.
vm = _make_vm(compute_project, manager)
vm.application_id = 700
assert vm.application_id == 700
_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["local-app"] == 700
@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"] == 32 # 8 adapters × 4 ports
@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_cleans_stale_sockets_but_keeps_run(compute_project, manager):
vm = _make_vm(compute_project, manager)
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()
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
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)
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"})
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 = 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
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_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):
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)
vm._stop_ubridge = AsyncioMagicMock()
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 "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
async def test_add_ubridge_connection_without_nio_still_wires(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, "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
# 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
# ---------------------------------------------------------------------------
# IOU-style port model — 1 adapter = 4 ethernet ports
# ---------------------------------------------------------------------------
def test_adapters_are_four_port_units(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=2)
assert vm.adapters == 2
assert len(vm._ethernet_adapters) == 2
for adapter in vm._ethernet_adapters:
assert adapter.interfaces == 4
for port_number in range(4):
assert adapter.port_exists(port_number)
assert not adapter.port_exists(4)
@pytest.mark.asyncio
async def test_wiring_addresses_ports_within_adapters(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=2)
_mock_wiring(vm)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
# adapter 1, port 2 -> flat interface index 6 (1 * 4 + 2)
open(os.path.join(wiring_dir, "s06.sock"), "w").close()
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
await vm._add_ubridge_connection(nio, 1, port_number=2)
flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls)
assert 'bridge add_nio_unix bridge1_2 ' in flat
assert f'"{os.path.join(wiring_dir, "c06.sock")}"' in flat
assert f'"{os.path.join(wiring_dir, "s06.sock")}"' in flat
assert "add_nio_udp bridge1_2 4242 127.0.0.1 4343" in flat
assert vm._ethernet_adapters[1].host_ifc == os.path.join(wiring_dir, "c06.sock")
@pytest.mark.asyncio
async def test_nio_binding_rejects_port_out_of_range(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=2)
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
with pytest.raises(DockerError) as excinfo:
await vm.adapter_add_nio_binding(1, nio, port_number=4)
assert "Port 4" in str(excinfo.value)
with pytest.raises(DockerError):
await vm.adapter_add_nio_binding(9, nio, port_number=0)
# ---------------------------------------------------------------------------
# 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_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=[])
# 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": {}}})
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
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)
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'"{os.path.join(wiring_dir, "s00.sock")}"' in flat
assert "add_nio_tap" not in flat
# ---------------------------------------------------------------------------
# Startup configuration
# ---------------------------------------------------------------------------
def _nvram_path(vm):
return os.path.join(vm.working_dir, "tmp", "run", f"nvram_{vm.application_id:05d}")
def _read_nvram(vm):
with open(_nvram_path(vm), "rb") as f:
return f.read()
async def _start(vm, state="stopped"):
_mock_start(vm, state=state)
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
@pytest.mark.asyncio
async def test_startup_config_built_into_nvram_at_start(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname %h\ninterface Ethernet0/0\n ip address dhcp\n no shutdown"
await _start(vm)
startup, _ = nvram_export(_read_nvram(vm))
content = startup.decode("utf-8")
# %h is substituted with the node name, like the IOU startup-config
assert f"hostname {vm.name}" in content
assert "ip address dhcp" in content
assert vm._startup_config_dirty is False # consumed
@pytest.mark.asyncio
async def test_plain_restart_never_reapplies_startup_config(compute_project, manager):
# IOL boots from NVRAM whenever it holds a config: a plain stop/start must
# not rebuild it or `write memory` done in the router would be lost.
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname RouterOne"
await _start(vm)
# simulate `write memory`: IOS rewrites the NVRAM behind our back
with open(_nvram_path(vm), "wb") as f:
f.write(nvram_import(None, b"hostname RouterSaved\n", None, 256))
await _start(vm) # plain restart, no new content delivered
startup, _ = nvram_export(_read_nvram(vm))
assert b"hostname RouterSaved" in startup
@pytest.mark.asyncio
async def test_changed_content_reapplied_at_next_start(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname RouterOne"
await _start(vm)
vm.startup_config_content = "hostname RouterTwo"
assert vm._startup_config_dirty is True
await _start(vm)
startup, _ = nvram_export(_read_nvram(vm))
assert b"hostname RouterTwo" in startup
@pytest.mark.asyncio
async def test_empty_content_never_builds_nvram(compute_project, manager):
# Web clients PUT "" for unset fields: must be ignored, not treated as a
# config change (mirrors the IOU setter's erase protection).
vm = _make_vm(compute_project, manager)
vm.startup_config_content = ""
vm.startup_config_content = None
await _start(vm)
assert not os.path.exists(_nvram_path(vm))
@pytest.mark.asyncio
async def test_reput_unchanged_content_keeps_written_nvram(compute_project, manager):
# A full PUT that carries the same content again (WebUI round-trips what
# the GET returned) must not re-apply it over `write memory`.
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname RouterOne"
await _start(vm)
with open(_nvram_path(vm), "wb") as f: # simulate `write memory`
f.write(nvram_import(None, b"hostname RouterSaved\n", None, 256))
vm.startup_config_content = "hostname RouterOne" # unchanged re-PUT
await _start(vm)
startup, _ = nvram_export(_read_nvram(vm))
assert b"hostname RouterSaved" in startup
@pytest.mark.asyncio
async def test_running_container_keeps_pending_config(compute_project, manager):
# The NVRAM is only (re)built on a genuine start of a stopped container:
# swapping it mid-flight would be lost to the next `write memory`.
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname RouterOne"
await _start(vm, state="running")
assert not os.path.exists(_nvram_path(vm))
assert vm._startup_config_dirty is True
@pytest.mark.asyncio
async def test_rename_rewrites_hostname_in_nvram(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname %h"
await _start(vm)
vm.name = "renamed-1" # IOU parity: the node boots under its new name
startup, _ = nvram_export(_read_nvram(vm))
assert b"hostname renamed-1" in startup
def test_asdict_exposes_startup_config_content(compute_project, manager):
vm = _make_vm(compute_project, manager)
assert vm.asdict()["startup_config_content"] is None
vm.startup_config_content = "hostname RouterOne"
assert vm.asdict()["startup_config_content"] == "hostname RouterOne"