From 8b3aafbbdc3701c50e5a8317c49b33bb9bbfa4b4 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 5 Sep 2026 00:01:18 +0800 Subject: [PATCH] feat: IOU-style startup-config for IOL Docker nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_ 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. --- docs/features/iol-runner-docker.md | 42 +++++- gns3server/api/routes/compute/docker_nodes.py | 4 +- gns3server/compute/docker/iol_docker_vm.py | 123 +++++++++++++++- gns3server/controller/node.py | 43 ++++++ gns3server/schemas/compute/docker_nodes.py | 5 +- tests/api/routes/compute/test_docker_nodes.py | 46 +++++- tests/compute/docker/test_iol_docker_vm.py | 133 ++++++++++++++++++ tests/controller/test_node.py | 53 +++++++ 8 files changed, 440 insertions(+), 9 deletions(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 1b91301b3..03b263282 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -98,7 +98,7 @@ API docs for the auth flow), or in the Web UI under | Field | Value | Why | |---|---|---| -| `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=` (default 2048). | +| `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=` (default 2048), `GNS3_IOL_STARTUP_CONFIG=` (initial configuration, see below). | | `extra_volumes` | `["/config"]` | `/tmp/run` is auto-added. **Never add `/tmp`** — it would persist the socket directory into the projects tree and uBridge would reject the too-long AF_UNIX path. | | `adapters` | number of 4-port units | The IOU convention: one adapter = `Ethernet0/0`–`Ethernet0/3`, two adapters add `Ethernet1/0`–`1/3`, … (8 units / 32 ports max). Ports are addressed as (adapter, port 0–3) and shown grouped in the UI. | | `memory` | optional; `0` (default) = no cap | Unset works — Docker applies no limit. When you do set a cap, keep it at IOL memory + ~512 MB, or the cgroup OOM-killer shoots the router. | @@ -113,9 +113,42 @@ API docs for the auth flow), or in the Web UI under `Linux Unix (i686)` banner within seconds. 3. `$XDG_RUNTIME_DIR/gns3/unixio//` contains `s00.sock`… (one pair per port). -4. The startup-config lives at - `project-files/docker//tmp/run/config` (interface names - `Ethernet0/0`, not `GigabitEthernet0/0`). +4. A node created with a startup-config boots straight to the configured + hostname — no initial configuration dialog (interface names in the + config are `Ethernet0/0`, not `GigabitEthernet0/0`). + +## Startup configuration + +IOL Docker nodes load an initial configuration exactly like native IOU +nodes: the **template references a config file**, and every node created +from it boots with that configuration as its personal starting point. + +```json +"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=iol-xe-base.txt" +``` + +* The file lives in the controller's configs directory (the `configs_path` + server setting, e.g. `~/.config/GNS3/3.1/configs`) — the same place IOU + and VPCS base configs live. `%h` in the content is replaced with the node + name at start. +* **Creation materializes it once**: the controller reads the file and + sends its content with the node; afterwards the knob is consumed and the + template file is never referenced again — editing it does not affect + existing nodes. +* **NVRAM is the source of truth at boot** (verified on the runner): the + content is built into the node's `tmp/run/nvram_` — IOL and IOU + share the nvram container format, so the server-side `nvram_import` + utility produces a file IOL boots from directly. Consequences: + * `write memory` survives stop/start and container recreation (a plain + restart never re-applies the startup-config over it). + * Editing a node's `startup_config_content` property (PUT) re-applies it + on the next start, overwriting what `write memory` had saved — the + explicit edit wins, as with IOU. + * Renaming a node rewrites the `hostname` line in its NVRAM, so a + duplicated/renamed node boots under its new name. +* **Resetting a node to its template config**: stop the node, delete + `project-files/docker//tmp/run/nvram_*`, then re-apply the content + (or recreate the node). ## Server mechanisms @@ -125,6 +158,7 @@ API docs for the auth flow), or in the Web UI under | `GNS3_UNIX_SOCKET_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 `/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `/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_IOL_STARTUP_CONFIG=` | controller `Node._node_data` + `IOLDockerVM` | Startup-config plumbing (see above): the controller translates the file into `startup_config_content` on node creation (sent once); the compute builds it into `nvram_` at the next start via the IOU `nvram_import` utility and rewrites the hostname line on rename. | `GNS3_STOP_TIMEOUT` (default 60) controls the SIGTERM grace period on stop. Extra iol-runner flags can be passed via `start_command`, e.g. `-keep` diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index 5070cd855..4c7f319c3 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -140,6 +140,7 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D "extra_hosts", "extra_volumes", "extra_configs", + "startup_config_content", "memory", "cpus", ] @@ -147,7 +148,8 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D changed = False node_data = jsonable_encoder(node_data, exclude_unset=True) for prop in props: - if prop in node_data and node_data[prop] != getattr(node, prop): + # hasattr: startup_config_content only exists on IOLDockerVM + if prop in node_data and hasattr(node, prop) and node_data[prop] != getattr(node, prop): setattr(node, prop, node_data[prop]) changed = True # We don't call container.update for nothing because it will restart the container diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index ce4c03e64..096f3efad 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -42,12 +42,15 @@ import glob import json import logging import os +import re import shutil from gns3server.compute.adapters.ethernet_adapter import EthernetAdapter -from gns3server.compute.docker.docker_error import DockerHttp404Error +from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error from gns3server.compute.docker.docker_vm import DockerVM from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM +from gns3server.compute.iou.utils.iou_export import nvram_export +from gns3server.compute.iou.utils.iou_import import nvram_import log = logging.getLogger(__name__) @@ -66,10 +69,20 @@ class IOLDockerVM(VendorDockerVM): The marker itself forces ``GNS3_SKIP_INIT`` and the unix-socket NIO wiring, and auto-adds the ``/config`` and ``/tmp/run`` persistent volumes, so a template containing only ``GNS3_IOL_RUNNER=1`` is fully configured. + + Startup configuration follows the IOU model: a template may reference a + config file with the ``GNS3_IOL_STARTUP_CONFIG`` environment knob; the + controller materializes the file content into ``startup_config_content`` + when the node is created. The content is built into the node's NVRAM + (``tmp/run/nvram_``) on the next start — IOL boots straight from + NVRAM, so a plain stop/start never re-applies it and ``write memory`` + survives restarts. """ _IOL_CONFIG_DIR = "/config" _IOL_RUN_DIR = "/tmp/run" + # The runner launches IOL with a fixed 256KB nvram (-n 256) + _IOL_NVRAM_SIZE_KB = 256 def _parse_vendor_environment(self): @@ -97,6 +110,13 @@ class IOLDockerVM(VendorDockerVM): # space, disjoint from IOU's); None until the create payload carries it. self._application_id = None + # Startup-config content materialized by the controller from the file + # referenced by GNS3_IOL_STARTUP_CONFIG. Applied to the NVRAM at start + # time (not in the setter: the application id may not be final yet + # when the create payload is applied field by field). + self._startup_config_content = None + self._startup_config_dirty = False + @property def application_id(self) -> int: """ @@ -114,6 +134,99 @@ class IOLDockerVM(VendorDockerVM): def application_id(self, value) -> None: self._application_id = int(value) + @property + def startup_config_content(self): + """ + Startup-config content, delivered by the controller when the node is + created from a template carrying GNS3_IOL_STARTUP_CONFIG (or updated + with new content). + """ + + return self._startup_config_content + + @startup_config_content.setter + def startup_config_content(self, content): + """ + Record new startup-config content; it is built into the node's NVRAM + on the next start. IOL boots from NVRAM whenever it holds a config, so + the content is only pushed when it actually changes — a plain + stop/start never re-applies it and `write memory` survives restarts. + """ + + if not content or content == self._startup_config_content: + # An empty value is ignored: erasing the config is not supported + # (mirrors the IOU setter) and Web clients PUT "" for unset fields. + return + self._startup_config_content = content + self._startup_config_dirty = True + + def _iol_nvram_file(self) -> str: + """ + The IOL NVRAM file inside the persistent /tmp/run volume. The name + embeds the application id (nvram_00772-style, like CML). + """ + + return os.path.join(self.working_dir, "tmp", "run", f"nvram_{self.application_id:05d}") + + def _apply_pending_startup_config(self) -> None: + """ + Build the node's NVRAM from the recorded startup-config content. IOL + and IOU share the same NVRAM container format (startup-config stored + as text inside the nvram file system), so the IOU nvram_import + utility produces a file IOL boots from directly — valid config, no + initial configuration dialog. + """ + + content = self._startup_config_content.replace("%h", self._name) + nvram_file = self._iol_nvram_file() + os.makedirs(os.path.dirname(nvram_file), exist_ok=True) + try: + nvram = nvram_import(None, content.encode("utf-8"), None, self._IOL_NVRAM_SIZE_KB) + with open(nvram_file, "wb") as f: + f.write(nvram) + except (OSError, ValueError) as e: + raise DockerError(f"Could not write IOL startup-config to NVRAM of container '{self._name}': {e}") + log.debug("IOL container '%s': startup-config written to %s", self._name, nvram_file) + + @DockerVM.name.setter + def name(self, new_name): + """ + Override: keep the hostname line inside the NVRAM in sync with the + node name (IOU parity), so a renamed or duplicated node boots under + its new name. Skipped while a content change is pending — the next + start pushes the new content with the already-updated name. + """ + + if not self._startup_config_dirty: + nvram_file = self._iol_nvram_file() + if os.path.exists(nvram_file): + try: + with open(nvram_file, "rb") as f: + startup_config, _ = nvram_export(f.read()) + if startup_config: + content = re.sub( + r"hostname .+$", + "hostname " + new_name, + startup_config.decode("utf-8", errors="replace"), + flags=re.MULTILINE, + ) + nvram = nvram_import(None, content.encode("utf-8"), None, self._IOL_NVRAM_SIZE_KB) + with open(nvram_file, "wb") as f: + f.write(nvram) + except (OSError, ValueError) as e: + log.warning(f"Could not update hostname in NVRAM of IOL container '{self._name}': {e}") + super(IOLDockerVM, IOLDockerVM).name.__set__(self, new_name) + + def asdict(self): + """ + Override: expose the recorded startup-config content (empty for nodes + created before the knob existed or reloaded from a topology). + """ + + result = super().asdict() + result["startup_config_content"] = self._startup_config_content + return result + @DockerVM.adapters.setter def adapters(self, adapters): """ @@ -195,6 +308,14 @@ class IOLDockerVM(VendorDockerVM): if state == "running": return + # Pending startup-config is materialized here rather than in the + # property setter: at create-payload time the application id may not + # be final yet (the create route applies fields in schema order) and + # a running container must not have its NVRAM swapped mid-flight. + if self._startup_config_dirty: + self._apply_pending_startup_config() + self._startup_config_dirty = False + 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)): diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index e72dfbc9d..c9189f8d6 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -40,6 +40,30 @@ import logging log = logging.getLogger(__name__) +def _extract_iol_startup_config_knob(environment): + """ + Split the GNS3_IOL_STARTUP_CONFIG knob out of a Docker environment + (iol-runner images): the value is a config file name, resolved by the + controller in its configs directory. + + :returns: (filename, environment without the knob line); filename is None + when the knob is absent or empty. + """ + + if not environment or "GNS3_IOL_STARTUP_CONFIG=" not in environment: + return None, environment + filename = None + kept = [] + for line in environment.splitlines(): + stripped = line.strip().rstrip(",") + if stripped.startswith("GNS3_IOL_STARTUP_CONFIG="): + if filename is None: + filename = stripped.split("=", 1)[1].strip() + else: + kept.append(line) + return filename or None, "\n".join(kept) + + class Node: # These properties are used only on controller and are not forwarded to the compute CONTROLLER_ONLY_PROPERTIES = [ @@ -573,6 +597,25 @@ class Node: data[v] = self._base_config_file_content(self._properties[k]) del data[k] del self._properties[k] # We send the file only one time + + # IOL runner (Docker) startup-config: translate the config file + # referenced by the GNS3_IOL_STARTUP_CONFIG environment knob into + # content, like the mappings above. Sent only once — afterwards + # the node's configuration lives in its NVRAM on the compute (a + # reloaded node is recreated without the knob and boots from the + # persistent NVRAM, preserving `write memory`). + if self._node_type == "docker": + filename, environment = _extract_iol_startup_config_knob(self._properties.get("environment")) + if filename: + content = self._base_config_file_content(filename) + if content: + data["startup_config_content"] = content + data["environment"] = environment + self._properties["environment"] = environment + else: + log.warning( + f"Cannot load IOL startup-config file '{filename}' for node '{self._name}': file not found" + ) data["name"] = self._name # For remote computes, convert absolute image paths to relative paths diff --git a/gns3server/schemas/compute/docker_nodes.py b/gns3server/schemas/compute/docker_nodes.py index e3523cad8..e2023d407 100644 --- a/gns3server/schemas/compute/docker_nodes.py +++ b/gns3server/schemas/compute/docker_nodes.py @@ -26,7 +26,7 @@ class DockerBase(BaseModel): Common Docker node properties. """ - @field_validator("start_command", "environment", "extra_hosts", mode="before") + @field_validator("start_command", "environment", "extra_hosts", "startup_config_content", mode="before") @classmethod def _empty_string_to_none(cls, value): # Web clients serialize empty form fields as "" while unset values are @@ -59,6 +59,9 @@ class DockerBase(BaseModel): extra_hosts: Optional[str] = Field(None, description="Docker extra hosts (added to /etc/hosts)") extra_volumes: Optional[List[str]] = Field(None, description="Additional directories to make persistent") extra_configs: Optional[List[ExtraConfig]] = Field(None, description="Configuration files injected into the container (bind-mounted read-only)") + startup_config_content: Optional[str] = Field( + None, description="Startup-config content (IOL runner images: materialized into the node's NVRAM at start)" + ) memory: Optional[int] = Field(None, ge=0, description="Maximum amount of memory the container can use in MB") cpus: Optional[float] = Field(None, ge=0, description="Maximum amount of CPU resources the container can use") custom_adapters: Optional[List[CustomAdapter]] = Field(None, description="Custom adapters") diff --git a/tests/api/routes/compute/test_docker_nodes.py b/tests/api/routes/compute/test_docker_nodes.py index efadd604b..b9fa79fd8 100644 --- a/tests/api/routes/compute/test_docker_nodes.py +++ b/tests/api/routes/compute/test_docker_nodes.py @@ -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", ( diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index b3aa8cbab..a5022eea2 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -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" diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index 9ba4c0ca6..e4ed15bd6 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -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