From 0119373690f5967230dfe9b2c656a2e9353ef7e3 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 4 Sep 2026 13:05:49 +0800 Subject: [PATCH] feat: allocate IOL Docker application IDs from a pool disjoint from IOU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IOL interface MACs derive from the application ID (aabb.cc{app}{iface}), so ids must be unique across opened projects sharing computes — the same reason IOU has its allocator. IOL Docker nodes draw from the upper half (512-1022, netiomux's fixed peer is 1023) so the two node types can neither collide nor starve each other; IOU behavior is unchanged. The controller sniffs the same GNS3_IOL_RUNNER environment marker the compute uses to select IOLDockerVM (both the nested-properties and template/top-level-kwarg shapes), stores the id in node properties like IOU does, and passes it through the Docker create payload. Without an allocation the compute falls back to a stable node-derived id in the same upper range. --- docs/features/iol-runner-docker.md | 10 ++++-- gns3server/compute/docker/iol_docker_vm.py | 38 ++++++++++++++++---- gns3server/controller/node.py | 3 +- gns3server/controller/project.py | 38 ++++++++++++++++++-- gns3server/schemas/compute/docker_nodes.py | 4 ++- gns3server/utils/application_id.py | 42 +++++++++++++++++++--- tests/compute/docker/test_iol_docker_vm.py | 25 ++++++++++--- tests/controller/test_project.py | 41 +++++++++++++++++++++ 8 files changed, 177 insertions(+), 24 deletions(-) diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md index 0c16a1540..1b91301b3 100644 --- a/docs/features/iol-runner-docker.md +++ b/docs/features/iol-runner-docker.md @@ -138,9 +138,13 @@ diagnosing wiring issues). + ~512 MB headroom or the OOM-killer will shoot the router. * **MAC addresses**: the `mac_address` template field and per-adapter custom MACs are ignored — IOL derives its own scheme from the node's application - ID (`aabb.cc{app}{iface}`), e.g. `aabb.cc03.0400`. The ID is derived from - the node UUID so linked routers always get distinct MACs (nodes sharing an - ID would silently drop each other's frames as MAC loops). + ID (`aabb.cc{app}{iface}`), e.g. `aabb.cc03.0400`. The controller + allocates the ID at node creation from the upper half of the id space + (512–1022, disjoint from IOU's 1–511, limit 511 IOL Docker nodes across + opened projects sharing computes); without an allocation (raw compute API, + pre-allocation topologies) a stable node-derived fallback in the same + range is used. Nodes sharing an ID would silently drop each other's + frames as MAC loops. * **Interface names are IOL-style `Ethernet0/0`**, not `GigabitEthernet0/0` (4 ports per unit, matching the adapter-count granularity) — startup configs addressing `GigabitEthernet…` are rejected by the parser. diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py index 3dddf87b7..ce4c03e64 100644 --- a/gns3server/compute/docker/iol_docker_vm.py +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -29,8 +29,10 @@ netiomux exposes per-interface AF_UNIX datagram sockets in the container's ``/tmp`` (``s%02d.sock`` receive, ``c%02d.sock`` send — raw Ethernet frames), wired by the generic ``GNS3_UNIX_SOCKET_NIO`` capability of VendorDockerVM (uBridge reaches them through a per-node runtime directory bound at /tmp — -see ``VendorDockerVM._unix_socket_host_dir``). The local application ID is -derived from the node ID so that linked nodes get distinct MACs. +see ``VendorDockerVM._unix_socket_host_dir``). The controller allocates the +IOL application ID (upper half of the id space, disjoint from IOU's) so that +linked nodes get distinct MACs; without an allocation a stable node-derived +fallback is used. This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker. """ @@ -91,6 +93,27 @@ class IOLDockerVM(VendorDockerVM): except ValueError: pass + # Application ID allocated by the controller (upper half of the id + # space, disjoint from IOU's); None until the create payload carries it. + self._application_id = None + + @property + def application_id(self) -> int: + """ + IOL application ID: drives interface MACs (aabb.cc{app}{iface}) and + the NVRAM file name. Falls back to a stable per-node value in the + IOL Docker half of the id space when no allocation is available + (raw compute API use, topologies created before allocation existed). + """ + + if self._application_id is None: + return 512 + int(self.id.replace("-", ""), 16) % 511 + return self._application_id + + @application_id.setter + def application_id(self, value) -> None: + self._application_id = int(value) + @DockerVM.adapters.setter def adapters(self, adapters): """ @@ -194,11 +217,12 @@ class IOLDockerVM(VendorDockerVM): "num-eth": self.adapters * 4, # every adapter is a 4-port unit "num-serial": 0, # GNS3 docker adapters are ethernet-only # IOL derives interface MACs from the local application ID - # (aabb.cc00.00); every node needs a distinct one or - # linked routers share MACs and drop each other's frames as loops. - # Derived from the node ID: stable across restarts, unique enough - # (CML allocates per-lab sequential IDs for the same reason). - "local-app": int(self.id.replace("-", ""), 16) % 1022 + 1, + # (aabb.cc{app}{iface}); every node needs a distinct one or + # linked routers share MACs and drop each other's frames as + # loops. Allocated by the controller per node (upper half of + # the id space, disjoint from IOU's — CML does the same with + # its per-deployment iol_app_id). + "local-app": self.application_id, "remote-app": 1023, # netiomux's fake peer application ID "user-id": os.getuid(), "group-id": os.getgid(), diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index d144cfa47..e72dfbc9d 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -30,6 +30,7 @@ from .controller_error import ( from .node_types import BUILTIN_NODE_TYPES from .ports.port_factory import PortFactory, StandardPortFactory, DynamipsPortFactory from ..utils.images import images_directories +from ..utils.application_id import is_iol_runner_environment from ..utils import macaddress_to_int, int_to_macaddress from ..config import Config @@ -804,7 +805,7 @@ class Node: self._ports = DynamipsPortFactory(self._properties) return elif self._node_type == "docker": - if "GNS3_IOL_RUNNER=" in (self._properties.get("environment") or ""): + if is_iol_runner_environment(self._properties.get("environment")): # IOL adapters are 4-port units (the IOU model): ports are # Ethernet0/0-3, Ethernet1/0-3, … addressed as # (adapter_number, port_number 0-3). diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 01506a734..d9ebdfae8 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -40,7 +40,7 @@ from .udp_link import UDPLink from .link import _UNSET from ..config import Config from ..utils.path import check_path_allowed, get_default_project_directory -from ..utils.application_id import get_next_application_id +from ..utils.application_id import get_next_application_id, is_iol_runner_environment from ..utils.asyncio.pool import Pool from ..utils.packet_filter_validation import validate_bpf_syntax from ..utils.asyncio import locking @@ -69,6 +69,21 @@ def open_required(func): return wrapper +def _is_iol_docker_kwargs(kwargs) -> bool: + """ + Whether add_node() kwargs describe an iol-runner Docker node. The + environment can arrive nested in a ``properties`` dict or as a top-level + kwarg (the template path spreads it), matching the two shapes IOU + application-id injection handles. + """ + + if "properties" in kwargs.keys(): + environment = (kwargs.get("properties") or {}).get("environment") + else: + environment = kwargs.get("environment") + return is_iol_runner_environment(environment) + + class Project: """ A project inside a controller @@ -159,7 +174,7 @@ class Project: assert self._status != "closed" self.dump() - self._iou_id_lock = asyncio.Lock() + self._application_id_lock = asyncio.Lock() # Serialise the "ensure project exists on this compute" check in # _create_node: without it, concurrent node creations all pass the # `compute not in _project_created_on_compute` check before any has @@ -646,7 +661,7 @@ class Project: self._computes.append(compute.id) if node_type == "iou": - async with self._iou_id_lock: + async with self._application_id_lock: # IOU application IDs must be allocated serially to avoid duplicates. # The lock must also cover _create_node() because get_next_application_id() # checks in-memory nodes (self._nodes), which are only registered @@ -658,6 +673,23 @@ class Project: elif "application_id" not in kwargs.keys() and not kwargs.get("properties"): kwargs["application_id"] = get_next_application_id(self._controller.projects, self._computes) node = await self._create_node(compute, name, node_id, node_type, **kwargs) + elif node_type == "docker" and _is_iol_docker_kwargs(kwargs): + # IOL Docker nodes derive interface MACs from the application ID + # exactly like IOU; they draw from the disjoint upper half of the + # id space so the two node types can neither collide nor starve + # each other. + async with self._application_id_lock: + if "properties" in kwargs.keys(): + properties = kwargs.get("properties") or {} + if "application_id" not in properties: + properties["application_id"] = get_next_application_id( + self._controller.projects, self._computes, iol_docker=True + ) + elif "application_id" not in kwargs.keys(): + kwargs["application_id"] = get_next_application_id( + self._controller.projects, self._computes, iol_docker=True + ) + node = await self._create_node(compute, name, node_id, node_type, **kwargs) else: node = await self._create_node(compute, name, node_id, node_type, **kwargs) self.emit_notification("node.created", node.asdict()) diff --git a/gns3server/schemas/compute/docker_nodes.py b/gns3server/schemas/compute/docker_nodes.py index 7b39921f6..e3523cad8 100644 --- a/gns3server/schemas/compute/docker_nodes.py +++ b/gns3server/schemas/compute/docker_nodes.py @@ -69,7 +69,9 @@ class DockerCreate(DockerBase): Properties to create a Docker node. """ - pass + application_id: Optional[int] = Field( + None, ge=1, le=1022, description="IOL application ID for iol-runner images (allocated by the controller)" + ) class DockerUpdate(DockerBase): diff --git a/gns3server/utils/application_id.py b/gns3server/utils/application_id.py index 9c9d8b347..488fb70b8 100644 --- a/gns3server/utils/application_id.py +++ b/gns3server/utils/application_id.py @@ -13,6 +13,7 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +# from gns3server.controller.controller_error import ControllerError @@ -20,13 +21,32 @@ import logging log = logging.getLogger(__name__) +# IOU draws from the lower half: uBridge's iol_bridge uses application_id + 512 +# for its netio peer endpoint. IOL Docker nodes (iol-runner images) draw from +# the upper half: their netiomux peer is the fixed id 1023. Both node types +# derive interface MACs from the id (aabb.cc{app}{iface}), so the two pools +# must stay disjoint — a shared id silently blackholes traffic between the +# nodes as a MAC loop. +IOU_APPLICATION_ID_POOL = range(1, 512) +IOL_DOCKER_APPLICATION_ID_POOL = range(512, 1023) -def get_next_application_id(projects, computes): + +def is_iol_runner_environment(environment) -> bool: + """ + Whether a Docker node environment carries the GNS3_IOL_RUNNER marker — + the same signal the compute uses to select the IOLDockerVM class. + """ + + return "GNS3_IOL_RUNNER=" in (environment or "") + + +def get_next_application_id(projects, computes, iol_docker=False): """ Calculates free application_id from given nodes :param projects: all projects managed by controller :param computes: all computes used by the project + :param iol_docker: allocate for an IOL Docker (iol-runner) node instead of IOU :raises HTTPConflict when exceeds number :return: integer first free id """ @@ -38,12 +58,26 @@ def get_next_application_id(projects, computes): if project.status == "opened": nodes.extend(list(project.nodes.values())) - used = {n.properties["application_id"] for n in nodes if n.node_type == "iou" and n.compute.id in computes} - pool = set(range(1, 512)) + if iol_docker: + used = { + n.properties["application_id"] + for n in nodes + if n.node_type == "docker" + and n.compute.id in computes + and "application_id" in n.properties + and is_iol_runner_environment(n.properties.get("environment")) + } + pool = set(IOL_DOCKER_APPLICATION_ID_POOL) + limit = "511 IOL Docker nodes" + else: + used = {n.properties["application_id"] for n in nodes if n.node_type == "iou" and n.compute.id in computes} + pool = set(IOU_APPLICATION_ID_POOL) + limit = "512 nodes" try: application_id = (pool - used).pop() return application_id except KeyError: raise ControllerError( - "Cannot create a new IOU node (limit of 512 nodes across all opened projects using the same computes)" + f"Cannot create a new {'IOL Docker' if iol_docker else 'IOU'} node " + f"(limit of {limit} across all opened projects using the same computes)" ) diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py index 28ea48afa..b3aa8cbab 100644 --- a/tests/compute/docker/test_iol_docker_vm.py +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -261,7 +261,7 @@ async def test_start_writes_iol_config(compute_project, manager): assert config["binary"] == "/binary.iol" assert config["num-eth"] == 16 # 4 adapters, each a 4-port unit assert config["num-serial"] == 0 - assert config["local-app"] == int(vm.id.replace("-", ""), 16) % 1022 + 1 + assert config["local-app"] == 512 + int(vm.id.replace("-", ""), 16) % 511 assert config["remote-app"] == 1023 assert config["memory"] == 2048 assert config["user-id"] == os.getuid() @@ -274,10 +274,25 @@ def test_local_app_is_distinct_per_node(compute_project, manager): # drop each other's frames as MAC loops, so IDs must differ per node. vm1 = _make_vm(compute_project, manager) vm2 = _make_vm(compute_project, manager) - id1 = int(vm1.id.replace("-", ""), 16) % 1022 + 1 - id2 = int(vm2.id.replace("-", ""), 16) % 1022 + 1 - assert id1 != id2 - assert 1 <= id1 <= 1022 and 1 <= id2 <= 1022 + assert vm1.application_id != vm2.application_id + for vm in (vm1, vm2): + assert 512 <= vm.application_id <= 1022 + + +@pytest.mark.asyncio +async def test_allocated_application_id_overrides_fallback(compute_project, manager): + # The controller allocates the application ID (upper half of the id + # space); the node-derived hash is only a fallback without one. + vm = _make_vm(compute_project, manager) + vm.application_id = 700 + assert vm.application_id == 700 + _mock_start(vm) + with patch("gns3server.compute.docker.Docker.install_busybox"): + with asyncio_patch("gns3server.compute.docker.Docker.query"): + await vm.start() + with open(os.path.join(vm.working_dir, "config", "iol-config.json")) as f: + config = json.load(f) + assert config["local-app"] == 700 @pytest.mark.asyncio diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index 31a4a2e7a..373d05e10 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -296,6 +296,47 @@ async def test_add_node_iou(controller): assert node3.properties["application_id"] == 3 +@pytest.mark.asyncio +async def test_add_node_iol_docker(controller): + """ + IOL Docker nodes (GNS3_IOL_RUNNER marker) get an application ID from the + upper half of the id space, disjoint from IOU's lower half + """ + + compute = MagicMock() + compute.id = "local" + project = await controller.add_project(project_id=str(uuid.uuid4()), name="test1") + project.emit_notification = MagicMock() + + response = MagicMock() + compute.post = AsyncioMagicMock(return_value=response) + + # template shape: environment as a top-level kwarg + node1 = await project.add_node( + compute, "iol1", None, node_type="docker", image="iol-xe/iol-xe:17-18-02", environment="GNS3_IOL_RUNNER=1" + ) + # raw API shape: environment nested in properties + node2 = await project.add_node( + compute, + "iol2", + None, + node_type="docker", + properties={"image": "iol-xe/iol-xe:17-18-02", "environment": "GNS3_IOL_RUNNER=1"}, + ) + # plain docker nodes are left alone + node3 = await project.add_node( + compute, "web", None, node_type="docker", image="nginx", environment="FOO=1", adapters=1 + ) + + assert node1.properties["application_id"] == 512 + assert node2.properties["application_id"] == 513 + assert "application_id" not in node3.properties + + # IOU keeps its own pool: a subsequent IOU node still gets the lower half + node4 = await project.add_node(compute, "iou1", None, node_type="iou") + assert node4.properties["application_id"] == 1 + + @pytest.mark.asyncio async def test_add_node_iou_with_multiple_projects(controller): """