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.
This commit is contained in:
YueGuobin committed 2026-09-05 21:54:52 +08:00
1 parent 9147deccaa
commit 8b3aafbbdc
8 files changed
+440 -9

No files matched your search

+44 -2
View File
@@ -20,9 +20,11 @@ import pytest_asyncio
from fastapi import FastAPI, status
from httpx import AsyncClient
from tests.utils import asyncio_patch
from unittest.mock import patch
from tests.utils import asyncio_patch, AsyncioMagicMock
from unittest.mock import patch, MagicMock
from gns3server.compute.docker import Docker
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
from gns3server.compute.project import Project
pytestmark = pytest.mark.asyncio
@@ -98,6 +100,46 @@ class TestDockerNodesRoutes:
assert response.json()["extra_hosts"] == "test:127.0.0.1"
async def test_docker_create_iol_startup_config(
self, app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
base_params: dict
) -> None:
"""
The controller materializes the GNS3_IOL_STARTUP_CONFIG knob into
startup_config_content; the create route must carry it onto the
IOLDockerVM (plain Docker nodes have no such property and skip it).
"""
params = dict(base_params)
params["image"] = "iol-xe/iol-xe:17-18-02"
params["environment"] = "GNS3_IOL_RUNNER=1"
params["startup_config_content"] = "hostname %h\n"
create_response = {
"Id": "8bd8153ea8f5",
"Warnings": [],
"Config": {
"Entrypoint": ["/iol-runner", "-config", "/config/iol-config.json", "-stdio"],
"Cmd": [],
"Volumes": {},
},
}
seed_proc = MagicMock()
seed_proc.communicate = AsyncioMagicMock(return_value=(b"seedcid\n", b""))
seed_proc.returncode = 0
with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "iol-xe/iol-xe"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=create_response):
with patch("asyncio.subprocess.create_subprocess_exec", return_value=seed_proc):
response = await compute_client.post(
app.url_path_for("compute:create_docker_node", project_id=compute_project.id), json=params
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["startup_config_content"] == "hostname %h\n"
vm = Docker.instance().get_node(response.json()["node_id"], project_id=str(compute_project.id))
assert isinstance(vm, IOLDockerVM)
assert vm.startup_config_content == "hostname %h\n"
@pytest.mark.parametrize(
"name, status_code",
(
+133
View File
@@ -40,6 +40,8 @@ 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
# ---------------------------------------------------------------------------
@@ -601,3 +603,134 @@ async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manage
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"
+53
View File
@@ -72,6 +72,59 @@ def test_docker_iol_ports_grouped_in_four_port_units(compute, project):
assert [p.asdict()["name"] for p in node.ports] == ["eth0", "eth1"]
def test_docker_iol_startup_config_materialized_once(compute, project, monkeypatch):
"""
The GNS3_IOL_STARTUP_CONFIG environment knob references a config file in
the controller's configs directory: like the IOU startup_config mapping,
its content is sent to the compute exactly once (the knob is consumed) —
afterwards the node's configuration lives in its NVRAM on the compute.
"""
node = Node(project, compute, "iol",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=my-iol-config.txt"})
monkeypatch.setattr(node, "_base_config_file_content", lambda path: "hostname %h\n")
data = node._node_data()
assert data["startup_config_content"] == "hostname %h\n"
assert data["environment"] == "GNS3_IOL_RUNNER=1"
assert "GNS3_IOL_STARTUP_CONFIG" not in node.properties["environment"]
# sent only once: a later sync (e.g. project reload) carries neither
data = node._node_data()
assert "startup_config_content" not in data
def test_docker_iol_startup_config_missing_file_keeps_knob(compute, project, monkeypatch):
node = Node(project, compute, "iol",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=gone.txt"})
monkeypatch.setattr(node, "_base_config_file_content", lambda path: None)
data = node._node_data()
assert "startup_config_content" not in data
# the knob is kept so the config still applies once the file shows up
assert "GNS3_IOL_STARTUP_CONFIG" in node.properties["environment"]
def test_extract_iol_startup_config_knob_variants():
from gns3server.controller.node import _extract_iol_startup_config_knob
assert _extract_iol_startup_config_knob(None) == (None, None)
assert _extract_iol_startup_config_knob("GNS3_IOL_RUNNER=1") == (None, "GNS3_IOL_RUNNER=1")
# whitespace and trailing commas are tolerated, like the compute env parsing
filename, environment = _extract_iol_startup_config_knob("GNS3_IOL_RUNNER=1,\n GNS3_IOL_STARTUP_CONFIG=cfg.txt ,")
assert filename == "cfg.txt"
assert environment == "GNS3_IOL_RUNNER=1,"
# an empty value is treated as absent
filename, environment = _extract_iol_startup_config_knob("GNS3_IOL_STARTUP_CONFIG=")
assert filename is None
def test_name(compute, project):
"""
If node use a name template generate names