From 08e37a4509dd9afc716c9d3bc15b60a6c046932e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 13 Aug 2026 23:33:34 +0800 Subject: [PATCH] docker: inject config files into containers via extra_configs Add an `extra_configs` field (list of {target, content}) to the docker node/template/appliance schemas. For each entry GNS3 writes `content` to a file in the node working directory and bind-mounts it read-only at `target` inside the container. This lets a NOS appliance seed its startup config without rebuilding the image: XRd points XR_FIRST_BOOT_CONFIG at an injected /firstboot.cfg, FRR at /etc/frr/frr.conf, etc. The bind is a single-file mount applied at create time, so it works for both the generic init.sh path and vendor nodes that skip init.sh (console_type=docker_exec). Entries are only injected when present, so ordinary nodes are unaffected. The content can't go through `environment` (it is line-delimited, one var per line), hence a dedicated field -- the same plumbing shape as extra_volumes. --- gns3server/api/routes/compute/docker_nodes.py | 5 ++- gns3server/compute/docker/docker_vm.py | 33 ++++++++++++++++ gns3server/schemas/common.py | 13 +++++++ gns3server/schemas/compute/docker_nodes.py | 3 +- gns3server/schemas/controller/appliances.py | 5 +++ .../controller/templates/docker_templates.py | 3 +- tests/compute/docker/test_docker_vm.py | 39 +++++++++++++++++++ 7 files changed, 98 insertions(+), 3 deletions(-) diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index e64ec3d60..528925ff9 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -78,6 +78,7 @@ async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate) aux_type=node_data.pop("aux_type", "none"), extra_hosts=node_data.get("extra_hosts"), extra_volumes=node_data.get("extra_volumes"), + extra_configs=node_data.get("extra_configs"), memory=node_data.get("memory", 0), cpus=node_data.get("cpus", 0), ) @@ -87,7 +88,8 @@ async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate) for key in ( "console", "console_type", "console_resolution", "console_http_port", "console_http_path", "aux", "aux_type", "start_command", "environment", - "adapters", "mac_address", "extra_hosts", "extra_volumes", "memory", "cpus", + "adapters", "mac_address", "extra_hosts", "extra_volumes", "extra_configs", + "memory", "cpus", ): node_data.pop(key, None) for name, value in node_data.items(): @@ -137,6 +139,7 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D "custom_adapters", "extra_hosts", "extra_volumes", + "extra_configs", "memory", "cpus", ] diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 0f98363ad..a2980f359 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -89,6 +89,7 @@ class DockerVM(BaseNode): console_http_path="/", extra_hosts=None, extra_volumes=[], + extra_configs=None, memory=0, cpus=0, ): @@ -118,6 +119,7 @@ class DockerVM(BaseNode): self._console_websocket = None self._extra_hosts = extra_hosts self._extra_volumes = extra_volumes or [] + self._extra_configs = extra_configs or [] self._memory = memory self._cpus = cpus self._permissions_fixed = True @@ -164,6 +166,7 @@ class DockerVM(BaseNode): "node_directory": self.working_path, "extra_hosts": self.extra_hosts, "extra_volumes": self.extra_volumes, + "extra_configs": self.extra_configs, "memory": self.memory, "cpus": self.cpus, } @@ -295,6 +298,14 @@ class DockerVM(BaseNode): def extra_volumes(self, extra_volumes): self._extra_volumes = extra_volumes + @property + def extra_configs(self): + return self._extra_configs + + @extra_configs.setter + def extra_configs(self, extra_configs): + self._extra_configs = extra_configs or [] + @property def memory(self): return self._memory @@ -391,6 +402,28 @@ class DockerVM(BaseNode): "Target": "/gns3volumes{}".format(volume) }) + # Inject extra config files: write each to the node working directory and + # bind-mount it read-only at its target path. Single-file binds are applied + # at create time, so this works for the generic init.sh path AND for vendor + # nodes that skip init.sh (the NOS reads its startup config from the mount). + for cfg in self._extra_configs: + target = cfg["target"] if isinstance(cfg, dict) else cfg.target + content = cfg["content"] if isinstance(cfg, dict) else cfg.content + if not target.startswith("/") or ".." in target.split("/"): + raise DockerError( + f"Extra config target '{target}' must be an absolute path and not contain '..'." + ) + host_path = os.path.join(self.working_dir, "configs", target.lstrip("/")) + os.makedirs(os.path.dirname(host_path), exist_ok=True) + with open(host_path, "w") as f: + f.write(content) + binds.append({ + "Type": "bind", + "Source": host_path, + "Target": target, + "ReadOnly": True, + }) + return binds def _create_network_config(self): diff --git a/gns3server/schemas/common.py b/gns3server/schemas/common.py index b15bc5201..fbf8ddf11 100644 --- a/gns3server/schemas/common.py +++ b/gns3server/schemas/common.py @@ -48,6 +48,19 @@ class CustomAdapter(BaseModel): mac_address: Optional[str] = Field(None, pattern="^([0-9a-fA-F]{2}[:]){5}([0-9a-fA-F]{2})$") +class ExtraConfig(BaseModel): + """ + A configuration file injected into a Docker container. + + GNS3 writes ``content`` to a host file and bind-mounts it read-only at + ``target`` inside the container. Used to seed NOS startup configs (e.g. + XRd first-boot config, FRR frr.conf) without rebuilding the image. + """ + + target: str = Field(..., description="Absolute path inside the container where the file is mounted") + content: str = Field("", description="File content written by GNS3 and bind-mounted read-only into the container") + + class ConsoleType(str, Enum): """ Supported console types. diff --git a/gns3server/schemas/compute/docker_nodes.py b/gns3server/schemas/compute/docker_nodes.py index 2328baabc..1461c18c1 100644 --- a/gns3server/schemas/compute/docker_nodes.py +++ b/gns3server/schemas/compute/docker_nodes.py @@ -18,7 +18,7 @@ from pydantic import BaseModel, Field from typing import Optional, List from uuid import UUID -from ..common import NodeStatus, CustomAdapter, ConsoleType, AuxType +from ..common import NodeStatus, CustomAdapter, ConsoleType, AuxType, ExtraConfig class DockerBase(BaseModel): @@ -43,6 +43,7 @@ class DockerBase(BaseModel): environment: Optional[str] = Field(None, description="Docker environment variables") 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)") 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/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py index 23e7b32c0..44906df18 100644 --- a/gns3server/schemas/controller/appliances.py +++ b/gns3server/schemas/controller/appliances.py @@ -20,6 +20,7 @@ from enum import Enum from typing import Annotated, List, Literal, Optional, Union from uuid import UUID from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag +from ..common import ExtraConfig # ============================================================================ @@ -329,6 +330,7 @@ class Docker(BaseModel): console_http_path: Optional[str] = Field(None, description='Path of the web interface') extra_hosts: Optional[str] = Field(None, description='Hosts which will be written to /etc/hosts into container') extra_volumes: Optional[List[str]] = Field(None, description='Additional directories to make persistent that are not included in the images VOLUME directive') + extra_configs: Optional[List[ExtraConfig]] = Field(None, description='Configuration files injected into the container (bind-mounted read-only)') class Iou(BaseModel): @@ -479,6 +481,9 @@ class DockerPropertiesV8(BaseModel): extra_volumes: Optional[List[str]] = Field( None, title='Additional directories to make persistent' ) + extra_configs: Optional[List[ExtraConfig]] = Field( + None, title='Configuration files injected into the container (bind-mounted read-only)' + ) class IouPropertiesV8(BaseModel): diff --git a/gns3server/schemas/controller/templates/docker_templates.py b/gns3server/schemas/controller/templates/docker_templates.py index 5eb10d7f5..3d7688cbb 100644 --- a/gns3server/schemas/controller/templates/docker_templates.py +++ b/gns3server/schemas/controller/templates/docker_templates.py @@ -16,7 +16,7 @@ from . import Category, TemplateBase -from ...common import ConsoleType, AuxType, CustomAdapter +from ...common import ConsoleType, AuxType, CustomAdapter, ExtraConfig from pydantic import Field from typing import Optional, List @@ -49,6 +49,7 @@ class DockerTemplate(TemplateBase): ) extra_hosts: Optional[str] = Field("", description="Docker extra hosts (added to /etc/hosts)") extra_volumes: Optional[List] = Field([], description="Additional directories to make persistent") + extra_configs: Optional[List[ExtraConfig]] = Field(default_factory=list, description="Configuration files injected into the container (bind-mounted read-only)") memory: Optional[int] = Field(0, ge=0, description="Maximum amount of memory the container can use in MB") cpus: Optional[float] = Field(0, ge=0, description="Maximum amount of CPU resources the container can use") custom_adapters: Optional[List[CustomAdapter]] = Field(default_factory=list, description="Custom adapters") diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index 8938ceaa9..8041b623a 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -69,6 +69,7 @@ def test_json(vm, compute_project): 'console_http_path': '/', 'extra_hosts': None, 'extra_volumes': [], + 'extra_configs': [], 'memory': 0, 'cpus': 0, 'aux': vm.aux, @@ -307,6 +308,44 @@ async def test_create_applies_env_host_config(compute_project, manager): ), "GNS3_ user vars must not leak into the container environment" +@pytest.mark.asyncio +async def test_create_with_extra_configs(compute_project, manager): + """ + extra_configs entries are written to the node working directory and + bind-mounted read-only at their target path inside the container. + """ + + extra_configs = [{"target": "/firstboot.cfg", "content": "username clab\n!\nend"}] + response = {"Id": "e90e34656806", "Warnings": []} + + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock: + vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu", extra_configs=extra_configs) + await vm.create() + mounts = mock.call_args[1]["data"]["HostConfig"]["Mounts"] + injected = [m for m in mounts if m.get("Target") == "/firstboot.cfg"] + assert len(injected) == 1 + assert injected[0]["ReadOnly"] is True + with open(injected[0]["Source"]) as f: + assert f.read() == "username clab\n!\nend" + + +@pytest.mark.asyncio +async def test_create_with_extra_configs_invalid_target(compute_project, manager): + """ + An extra_configs target that is not absolute (or contains '..') is rejected. + """ + + extra_configs = [{"target": "relative/path", "content": "x"}] + response = {"Id": "e90e34656806", "Warnings": []} + + with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "ubuntu"}]): + with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response): + vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu", extra_configs=extra_configs) + with pytest.raises(DockerError): + await vm.create() + + @pytest.mark.asyncio async def test_create_with_colon_in_project_name(compute_project, manager):