mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
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:
parent
2bee34031c
commit
08e37a4509
@ -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",
|
||||
]
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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):
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user