feat: allocate IOL Docker application IDs from a pool disjoint from IOU

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.
This commit is contained in:
YueGuobin 2026-09-04 13:05:49 +08:00
parent b17d021bf6
commit 0119373690
No known key found for this signature in database
8 changed files with 177 additions and 24 deletions

View File

@ -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
(5121022, disjoint from IOU's 1511, 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.

View File

@ -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.0<app><iface>0); 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(),

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
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)"
)

View File

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

View File

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