mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-27 18:52:23 +02: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"),
|
aux_type=node_data.pop("aux_type", "none"),
|
||||||
extra_hosts=node_data.get("extra_hosts"),
|
extra_hosts=node_data.get("extra_hosts"),
|
||||||
extra_volumes=node_data.get("extra_volumes"),
|
extra_volumes=node_data.get("extra_volumes"),
|
||||||
|
extra_configs=node_data.get("extra_configs"),
|
||||||
memory=node_data.get("memory", 0),
|
memory=node_data.get("memory", 0),
|
||||||
cpus=node_data.get("cpus", 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 (
|
for key in (
|
||||||
"console", "console_type", "console_resolution", "console_http_port",
|
"console", "console_type", "console_resolution", "console_http_port",
|
||||||
"console_http_path", "aux", "aux_type", "start_command", "environment",
|
"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)
|
node_data.pop(key, None)
|
||||||
for name, value in node_data.items():
|
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",
|
"custom_adapters",
|
||||||
"extra_hosts",
|
"extra_hosts",
|
||||||
"extra_volumes",
|
"extra_volumes",
|
||||||
|
"extra_configs",
|
||||||
"memory",
|
"memory",
|
||||||
"cpus",
|
"cpus",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -89,6 +89,7 @@ class DockerVM(BaseNode):
|
|||||||
console_http_path="/",
|
console_http_path="/",
|
||||||
extra_hosts=None,
|
extra_hosts=None,
|
||||||
extra_volumes=[],
|
extra_volumes=[],
|
||||||
|
extra_configs=None,
|
||||||
memory=0,
|
memory=0,
|
||||||
cpus=0,
|
cpus=0,
|
||||||
):
|
):
|
||||||
@ -118,6 +119,7 @@ class DockerVM(BaseNode):
|
|||||||
self._console_websocket = None
|
self._console_websocket = None
|
||||||
self._extra_hosts = extra_hosts
|
self._extra_hosts = extra_hosts
|
||||||
self._extra_volumes = extra_volumes or []
|
self._extra_volumes = extra_volumes or []
|
||||||
|
self._extra_configs = extra_configs or []
|
||||||
self._memory = memory
|
self._memory = memory
|
||||||
self._cpus = cpus
|
self._cpus = cpus
|
||||||
self._permissions_fixed = True
|
self._permissions_fixed = True
|
||||||
@ -164,6 +166,7 @@ class DockerVM(BaseNode):
|
|||||||
"node_directory": self.working_path,
|
"node_directory": self.working_path,
|
||||||
"extra_hosts": self.extra_hosts,
|
"extra_hosts": self.extra_hosts,
|
||||||
"extra_volumes": self.extra_volumes,
|
"extra_volumes": self.extra_volumes,
|
||||||
|
"extra_configs": self.extra_configs,
|
||||||
"memory": self.memory,
|
"memory": self.memory,
|
||||||
"cpus": self.cpus,
|
"cpus": self.cpus,
|
||||||
}
|
}
|
||||||
@ -295,6 +298,14 @@ class DockerVM(BaseNode):
|
|||||||
def extra_volumes(self, extra_volumes):
|
def extra_volumes(self, extra_volumes):
|
||||||
self._extra_volumes = 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
|
@property
|
||||||
def memory(self):
|
def memory(self):
|
||||||
return self._memory
|
return self._memory
|
||||||
@ -391,6 +402,28 @@ class DockerVM(BaseNode):
|
|||||||
"Target": "/gns3volumes{}".format(volume)
|
"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
|
return binds
|
||||||
|
|
||||||
def _create_network_config(self):
|
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})$")
|
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):
|
class ConsoleType(str, Enum):
|
||||||
"""
|
"""
|
||||||
Supported console types.
|
Supported console types.
|
||||||
|
|||||||
@ -18,7 +18,7 @@ from pydantic import BaseModel, Field
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from ..common import NodeStatus, CustomAdapter, ConsoleType, AuxType
|
from ..common import NodeStatus, CustomAdapter, ConsoleType, AuxType, ExtraConfig
|
||||||
|
|
||||||
|
|
||||||
class DockerBase(BaseModel):
|
class DockerBase(BaseModel):
|
||||||
@ -43,6 +43,7 @@ class DockerBase(BaseModel):
|
|||||||
environment: Optional[str] = Field(None, description="Docker environment variables")
|
environment: Optional[str] = Field(None, description="Docker environment variables")
|
||||||
extra_hosts: Optional[str] = Field(None, description="Docker extra hosts (added to /etc/hosts)")
|
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_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")
|
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")
|
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")
|
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 typing import Annotated, List, Literal, Optional, Union
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag
|
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')
|
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_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_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):
|
class Iou(BaseModel):
|
||||||
@ -479,6 +481,9 @@ class DockerPropertiesV8(BaseModel):
|
|||||||
extra_volumes: Optional[List[str]] = Field(
|
extra_volumes: Optional[List[str]] = Field(
|
||||||
None, title='Additional directories to make persistent'
|
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):
|
class IouPropertiesV8(BaseModel):
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
|
|
||||||
from . import Category, TemplateBase
|
from . import Category, TemplateBase
|
||||||
from ...common import ConsoleType, AuxType, CustomAdapter
|
from ...common import ConsoleType, AuxType, CustomAdapter, ExtraConfig
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from typing import Optional, List
|
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_hosts: Optional[str] = Field("", description="Docker extra hosts (added to /etc/hosts)")
|
||||||
extra_volumes: Optional[List] = Field([], description="Additional directories to make persistent")
|
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")
|
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")
|
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")
|
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': '/',
|
'console_http_path': '/',
|
||||||
'extra_hosts': None,
|
'extra_hosts': None,
|
||||||
'extra_volumes': [],
|
'extra_volumes': [],
|
||||||
|
'extra_configs': [],
|
||||||
'memory': 0,
|
'memory': 0,
|
||||||
'cpus': 0,
|
'cpus': 0,
|
||||||
'aux': vm.aux,
|
'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"
|
), "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
|
@pytest.mark.asyncio
|
||||||
async def test_create_with_colon_in_project_name(compute_project, manager):
|
async def test_create_with_colon_in_project_name(compute_project, manager):
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user