From 86d30f34b451a03dc5acd4923e4c6c56b844cf2b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 13 Aug 2026 22:39:48 +0800 Subject: [PATCH] docker: inject /dev/shm size and host devices via HostConfig from env Heavy NOS containers (e.g. Cisco XRd) need /dev/shm larger than Docker's 64 MB default and host device nodes such as /dev/fuse. Add two opt-in environment variables, consumed host-side and applied as native Docker HostConfig keys at create time: GNS3_SHM_SIZE (MB) -> HostConfig.ShmSize (bytes) GNS3_DEVICES -> HostConfig.Devices in `docker run --device` syntax (host[:container[:perm]]; Docker resolves major/minor from the host node itself) Native HostConfig (rather than remount/mknod inside init.sh) is used so this works for vendor NOS nodes that skip init.sh (console_type=docker_exec) -- the path XRd must take, since GNS3's init.sh wrapper crashes XRd's glibc loader. It applies whether or not init.sh runs, needs no schema/API/UI change (reuses the `environment` field), and only takes effect when the vars are set, so ordinary nodes keep default Docker behaviour. GNS3_-prefixed user env vars stay dropped from the container environment (only consumed here host-side), keeping GNS3-injected vars safe. --- gns3server/compute/docker/docker_vm.py | 50 ++++++++++++++++++++++++++ tests/compute/docker/test_docker_vm.py | 36 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 804039216..0f98363ad 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -491,6 +491,25 @@ class DockerVM(BaseNode): "Entrypoint": image_infos.get("Config", {"Entrypoint": []}).get("Entrypoint"), } + # Optional /dev/shm size and host device mappings requested through the + # environment (GNS3_SHM_SIZE in MB, GNS3_DEVICES). These are native Docker + # HostConfig keys applied at create time, so they work whether or not + # init.sh runs -- heavy NOS containers such as Cisco XRd (which skips + # init.sh via the vendor/docker_exec path) rely on them. Only injected + # when set, so ordinary nodes keep the default Docker behaviour. + if self._environment: + for line in self._environment.splitlines(): + line = line.strip() + if line.startswith("GNS3_SHM_SIZE="): + try: + params["HostConfig"]["ShmSize"] = int(line.split("=", 1)[1].strip()) * (1024 * 1024) + except ValueError: + pass + elif line.startswith("GNS3_DEVICES="): + devices = self._format_devices(line.split("=", 1)[1]) + if devices: + params["HostConfig"]["Devices"] = devices + if params["Entrypoint"] is None: params["Entrypoint"] = [] if self._start_command: @@ -625,6 +644,37 @@ class DockerVM(BaseNode): raise DockerError(f"Can't apply `ExtraHosts`, wrong format: {extra_hosts}") return "\n".join([f"{h[1]}\t{h[0]}" for h in hosts]) + def _format_devices(self, devices_value): + """ + Parse a GNS3_DEVICES value into Docker HostConfig Devices entries. + + Mirrors `docker run --device`: items are whitespace/comma-separated and + each is ``host[:container[:permissions]]`` (e.g. /dev/fuse, + /dev/fuse:/dev/fuse:rwm). Docker resolves type/major/minor from the host + node itself, so the device must exist on the host -- the host-readiness + check warns when /dev/fuse is missing (load the fuse module). + """ + + formatted = [] + for raw in devices_value.replace(",", " ").split(): + parts = raw.split(":") + if len(parts) == 1: + on_host = in_container = parts[0] + permissions = "rwm" + elif len(parts) == 2: + on_host, in_container = parts + permissions = "rwm" + elif len(parts) == 3: + on_host, in_container, permissions = parts + else: + continue + formatted.append({ + "PathOnHost": on_host, + "PathInContainer": in_container, + "CgroupPermissions": permissions, + }) + return formatted + async def update(self): """ Destroy and recreate the container with the new settings diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index c9e701306..8938ceaa9 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -271,6 +271,42 @@ async def test_create_with_extra_hosts(compute_project, manager): assert "GNS3_EXTRA_HOSTS=199.199.199.1\ttest\n199.199.199.1\ttest2" in called_kwargs["data"]["Env"] assert vm._extra_hosts == extra_hosts + +@pytest.mark.asyncio +async def test_create_applies_env_host_config(compute_project, manager): + """ + GNS3_SHM_SIZE / GNS3_DEVICES are applied as native Docker HostConfig keys + (ShmSize, Devices) at create time -- not forwarded as container env vars -- + so they work even for vendor nodes that skip init.sh. Other GNS3_-prefixed + vars stay dropped from the container environment. + """ + + environment = ( + "GNS3_SHM_SIZE=1024\n" + "GNS3_DEVICES=/dev/fuse\n" + "GNS3_EVIL=should-be-dropped\n" # GNS3_ -> never forwarded as env + "FOO=bar" # normal var -> forwarded + ) + 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", environment=environment) + await vm.create() + data = mock.call_args[1]["data"] + host_config = data["HostConfig"] + assert host_config["ShmSize"] == 1024 * 1024 * 1024 + assert host_config["Devices"] == [ + {"PathOnHost": "/dev/fuse", "PathInContainer": "/dev/fuse", "CgroupPermissions": "rwm"} + ] + env = data["Env"] + assert "FOO=bar" in env + assert not any( + e.startswith(("GNS3_SHM_SIZE=", "GNS3_DEVICES=", "GNS3_EVIL=")) + for e in env + ), "GNS3_ user vars must not leak into the container environment" + + @pytest.mark.asyncio async def test_create_with_colon_in_project_name(compute_project, manager):