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.
This commit is contained in:
YueGuobin 2026-08-13 23:33:34 +08:00
parent 2bee34031c
commit 08e37a4509
No known key found for this signature in database
7 changed files with 98 additions and 3 deletions

View File

@ -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",
]

View File

@ -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):

View File

@ -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.

View File

@ -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")

View File

@ -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):

View File

@ -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")

View File

@ -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):