mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-15 06:20:42 +03:00
feat: add IOL (iol-runner) Docker node support
Run Cisco CML containerized IOL images (e.g. iol-xe/iol-xe:17-18-02, driven by virl.lab/cmd/iol-runner) as GNS3 Docker router nodes: - VendorDockerVM: generic GNS3_UNIX_SOCKET_NIO/GNS3_UNIX_SOCKET_DIR knobs wiring adapters through AF_UNIX datagram socket pairs (add_nio_unix cNN.sock sNN.sock) instead of TAP + move_to_ns; node creation fails if the socket dir is not a persisted volume. - IOLDockerVM (GNS3_IOL_RUNNER=1): forces skip-init + unix-socket NIO + /config,/tmp volumes, writes iol-config.json on every start (num-eth tracks adapters, runner drops to the server uid/gid so the sockets are reachable), pre-creates tmp/run, cleans stale sockets after unclean kills, and makes reload a graceful stop + full start (NVRAM flush + rewiring). Console stays telnet on PID 1 stdio. - Manager selects the node class from console_type or GNS3_* environment markers (create-time, like console_type). - Image-free tests (25) and feature documentation.
This commit is contained in:
parent
b72b8b44b4
commit
e272ad915b
@ -81,6 +81,9 @@ Console for vendor NOS containers (SR Linux, XRd, …) whose CLI is a TUI off PI
|
||||
### Cisco XRd Control Plane (`features/vendor-nos-xrd.md`)
|
||||
Cisco XRd as a GNS3 Docker router: vendor path + shm/device injection (`GNS3_SHM_SIZE`/`GNS3_DEVICES`), config-file injection (`extra_configs`), udev masking (`GNS3_MASK_UDEV`) so privileged systemd containers don't disturb the host, and the host-readiness check.
|
||||
|
||||
### IOL Images with iol-runner (`features/iol-runner-docker.md`)
|
||||
Cisco CML containerized IOL (e.g. `iol-xe/iol-xe:17-18-02`) as GNS3 Docker routers: generic unix-socket NIO (`GNS3_UNIX_SOCKET_NIO` — adapters wired via AF_UNIX datagram socket pairs instead of TAP/netns) plus `IOLDockerVM` (`GNS3_IOL_RUNNER=1` — per-start config generation, `/tmp/run` preparation, stale-socket cleanup, console on PID 1 stdio).
|
||||
|
||||
---
|
||||
|
||||
## GNS3 AI Copilot (`gns3-copilot/`)
|
||||
|
||||
118
docs/features/iol-runner-docker.md
Normal file
118
docs/features/iol-runner-docker.md
Normal file
@ -0,0 +1,118 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt.
|
||||
|
||||
|
||||
# IOL Images with iol-runner (Cisco CML containerized IOL) as Docker Nodes
|
||||
|
||||
## Overview
|
||||
|
||||
IOL images packaged with Cisco CML's container runner — for example
|
||||
`iol-xe/iol-xe:17-18-02` (IOS-XE 17.18.02 IOL in a scratch image driven by
|
||||
`iol-runner`, module `virl.lab/cmd/iol-runner`) — run as first-class GNS3
|
||||
Docker router nodes with **zero changes to the image**. The integration adds
|
||||
two generic server mechanisms:
|
||||
|
||||
1. **Unix-socket NIO** (`GNS3_UNIX_SOCKET_NIO=1`, on `VendorDockerVM`): link
|
||||
adapters through per-interface AF_UNIX datagram sockets instead of a TAP
|
||||
interface moved into the container's network namespace.
|
||||
2. **`IOLDockerVM`** (marker `GNS3_IOL_RUNNER=1`): generates the runner's
|
||||
config file per start, prepares its runtime directory and cleans up stale
|
||||
sockets — the iol-runner-specific glue on top of the vendor path.
|
||||
|
||||
## How the image works
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Container["scratch container (PID 1)"]
|
||||
RUNNER["/iol-runner -config /config/iol-config.json -stdio"]
|
||||
IOL["IOL process<br/>(IOS-XE 17.18.02)"]
|
||||
NETIOMUX["netiomux"]
|
||||
SOCKETS["/tmp/s00.sock (recv)<br/>/tmp/c00.sock (send-to path)<br/>… one pair per interface"]
|
||||
RUNNER -->|"spawn -e/-s/-m + app id"| IOL
|
||||
IOL -->|"netio bus /tmp/netio<uid>/"| NETIOMUX --> SOCKETS
|
||||
end
|
||||
subgraph Host
|
||||
UBRIDGE["uBridge bridgeN<br/>add_nio_unix c00.sock s00.sock<br/>+ add_nio_udp (topology)"]
|
||||
VOL["project-files/docker/<node>/tmp"]
|
||||
end
|
||||
SOCKETS <-->|"raw Ethernet frames<br/>(bind volume dir)"| VOL <--> UBRIDGE
|
||||
```
|
||||
|
||||
* **Console**: the runner muxes the IOS console onto PID 1 stdio (`-stdio`
|
||||
entrypoint flag). The plain `console_type: "telnet"` attaches to it — no
|
||||
`docker_exec` needed. The runner requires a TTY, which GNS3 always
|
||||
allocates; without one the runner exits (`inappropriate ioctl for device`).
|
||||
* **Networking**: the runner does not touch the container's network
|
||||
namespace. Per interface N it creates, inside `/tmp`: a receive socket
|
||||
`s%02d.sock` (frames sent there are injected into guest interface N) and a
|
||||
send-to path `c%02d.sock` (whoever binds it receives the guest's frames).
|
||||
Frames are **raw Ethernet**, one datagram per frame. Because `/tmp` is a
|
||||
persisted volume (bind-mounted from the node directory), uBridge can bind
|
||||
`cNN.sock` and send to `sNN.sock` on the host.
|
||||
* **Licensing**: the image ships a self-consistent `/etc/hostid` + `.iourc`
|
||||
pair, and the runner regenerates the license from the host ID at boot —
|
||||
nothing to configure.
|
||||
* **Persistence**: `/tmp/run/` inside the volume holds the NETMAP, the
|
||||
startup-config (`config`, plain IOS format) and NVRAM (`nvram_00001`), so
|
||||
the router's configuration survives stop/start and container recreation.
|
||||
The generated config maps the runner to the server's uid/gid
|
||||
(`user-id`/`group-id`), which is also what makes the `/tmp` sockets
|
||||
reachable by uBridge and all volume files owned by the server user (no
|
||||
permission-fix pass needed).
|
||||
|
||||
## Template
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "IOS-XE 17.18.02 IOL",
|
||||
"template_type": "docker",
|
||||
"image": "iol-xe/iol-xe:17-18-02",
|
||||
"category": "router",
|
||||
"symbol": ":/symbols/router.svg",
|
||||
"adapters": 4,
|
||||
"console_type": "telnet",
|
||||
"environment": "GNS3_IOL_RUNNER=1",
|
||||
"extra_volumes": ["/config", "/tmp"],
|
||||
"memory": 2560
|
||||
}
|
||||
```
|
||||
|
||||
`/config` and `/tmp` are auto-added even if omitted; listing them keeps the
|
||||
template self-documenting.
|
||||
|
||||
## Server mechanisms
|
||||
|
||||
| Mechanism | Where | What it does |
|
||||
|---|---|---|
|
||||
| `GNS3_UNIX_SOCKET_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix <dir>/c{N:02d}.sock <dir>/s{N:02d}.sock` instead of TAP + `docker move_to_ns`. No TAP allocation, no `set_mac_addr`, namespace untouched. Fails node creation if the socket dir is not a persisted volume. |
|
||||
| `GNS3_UNIX_SOCKET_DIR=<dir>` | `VendorDockerVM` | Socket directory (default `/tmp`). Any image whose agent exposes the `s%02d`/`c%02d` datagram pairs can use this without the IOL specifics. |
|
||||
| `GNS3_IOL_RUNNER=1` | `IOLDockerVM` (selected in the manager) | Forces skip-init + unix-socket NIO + the two volumes; on every start writes `<node>/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `<node>/tmp/run/` (the IOL process dies without it) and removes stale `s/c??.sock` + `netio*` left by an unclean kill (`tmp/run` is never touched). |
|
||||
| `restart()` hardening | `IOLDockerVM` | The base `docker restart` would boot the runner on a stale config and leave uBridge wired to the previous run's sockets; reload becomes graceful stop (SIGTERM → NVRAM flush) + full start. |
|
||||
|
||||
`GNS3_STOP_TIMEOUT` (default 60) controls the SIGTERM grace period on stop.
|
||||
Extra iol-runner flags can be passed via `start_command`, e.g. `-keep`
|
||||
(L1 keepalives) or `-debug 9` (verbose `process.log` — very useful when
|
||||
diagnosing wiring issues).
|
||||
|
||||
## Notes and caveats
|
||||
|
||||
* **Memory sizing**: `memory` caps the whole container; the IOL process gets
|
||||
`GNS3_IOL_MEMORY` (default 2048 MB). Keep container memory at IOL memory
|
||||
+ ~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 (`aabb.cc00.0XY0`).
|
||||
* **Adapters**: change the adapter count while the node is stopped; the
|
||||
config is regenerated on the next start and the runner creates the
|
||||
matching socket set (IOL granularity is 4 ports per unit).
|
||||
* **Stop before editing**: NVRAM is only flushed on a graceful stop (SIGTERM,
|
||||
"cleanup done" in `process.log`); a kill loses the running-config changes
|
||||
since the last `write memory`.
|
||||
* **Class selection is create-time**: toggling `GNS3_IOL_RUNNER` via PUT
|
||||
takes effect after a project reload (same as `docker_exec`).
|
||||
* The startup-config lives at `project-files/docker/<node>/tmp/run/config`;
|
||||
`extra_configs` targets under persisted volumes are warned against by the
|
||||
generic create path — edit the file directly or paste via the console.
|
||||
@ -33,6 +33,7 @@ from gns3server.utils.asyncio import locking
|
||||
from gns3server.compute.base_manager import BaseManager
|
||||
from gns3server.compute.docker.docker_vm import DockerVM
|
||||
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
|
||||
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
|
||||
from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error, DockerHttp409Error
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@ -62,9 +63,21 @@ class Docker(BaseManager):
|
||||
self._host_checked = False
|
||||
|
||||
def _select_node_class(self, **kwargs):
|
||||
"""Select the node class based on console_type."""
|
||||
"""
|
||||
Select the node class based on console_type and GNS3_* environment
|
||||
markers. Like console_type, the environment is fixed at node creation
|
||||
time: toggling a marker via PUT takes effect after a project reload.
|
||||
"""
|
||||
if kwargs.get("console_type") == "docker_exec":
|
||||
return VendorDockerVM
|
||||
environment = kwargs.get("environment") or ""
|
||||
for line in environment.splitlines():
|
||||
line = line.strip().rstrip(",")
|
||||
if line.startswith("GNS3_IOL_RUNNER="):
|
||||
return IOLDockerVM
|
||||
if line.startswith("GNS3_UNIX_SOCKET_NIO="):
|
||||
# Generic capability, usable without the IOL specifics.
|
||||
return VendorDockerVM
|
||||
return DockerVM
|
||||
|
||||
async def create_node(self, name, project_id, node_id, *args, **kwargs):
|
||||
|
||||
189
gns3server/compute/docker/iol_docker_vm.py
Normal file
189
gns3server/compute/docker/iol_docker_vm.py
Normal file
@ -0,0 +1,189 @@
|
||||
#
|
||||
# Copyright (C) 2025 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
IOL (IOS on Linux) Docker container subclass.
|
||||
|
||||
Supports IOL images packaged with Cisco CML's container runner
|
||||
(``iol-runner``, ``virl.lab/cmd/iol-runner``), e.g. ``iol-xe/iol-xe:17-18-02``:
|
||||
a scratch image whose ENTRYPOINT is ``iol-runner -config /config/iol-config.json
|
||||
-stdio``. The runner generates the license, writes the NETMAP, manages NVRAM
|
||||
and muxes the IOS console onto PID 1 stdio (works with the plain ``telnet``
|
||||
console type; requires a TTY, which GNS3 always allocates).
|
||||
|
||||
Networking does not use the container's network namespace at all: the runner's
|
||||
netiomux exposes per-interface AF_UNIX datagram sockets in ``/tmp``
|
||||
(``s%02d.sock`` receive, ``c%02d.sock`` send — raw Ethernet frames), wired by
|
||||
the generic ``GNS3_UNIX_SOCKET_NIO`` capability of VendorDockerVM. Because the
|
||||
netio bus directory is private to the node's ``/tmp`` volume, the application
|
||||
IDs are fixed constants with no cross-node collisions.
|
||||
|
||||
This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from gns3server.compute.docker.docker_error import DockerHttp404Error
|
||||
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IOLDockerVM(VendorDockerVM):
|
||||
"""
|
||||
VendorDockerVM subclass for iol-runner images.
|
||||
|
||||
Extra opt-in knob (beyond the inherited vendor ones):
|
||||
|
||||
* ``GNS3_IOL_MEMORY=<MB>`` — IOL router memory passed via the generated
|
||||
config (default 2048). The template ``memory`` field caps the whole
|
||||
container: keep it at IOL memory + ~512 MB headroom or the kernel
|
||||
OOM-killer will fire.
|
||||
|
||||
The marker itself forces ``GNS3_SKIP_INIT`` and the unix-socket NIO wiring,
|
||||
and auto-adds the ``/config`` and ``/tmp`` persistent volumes, so a
|
||||
template containing only ``GNS3_IOL_RUNNER=1`` is fully configured.
|
||||
"""
|
||||
|
||||
_IOL_CONFIG_DIR = "/config"
|
||||
_IOL_RUN_DIR = "/tmp/run"
|
||||
|
||||
def _parse_vendor_environment(self):
|
||||
|
||||
super()._parse_vendor_environment()
|
||||
# The image has no shell (scratch): init.sh could neither run (its
|
||||
# #!/bin/sh shebang doesn't exist) nor wait for eth interfaces that
|
||||
# are never created. The console is IOS itself on PID 1 stdio.
|
||||
self._gns3_init = False
|
||||
self._unix_socket_nio = True
|
||||
self._unix_socket_dir = "/tmp"
|
||||
|
||||
self._iol_memory = 2048
|
||||
if self._environment:
|
||||
for _line in self._environment.splitlines():
|
||||
_line = _line.strip().rstrip(",")
|
||||
if _line.startswith("GNS3_IOL_MEMORY="):
|
||||
try:
|
||||
memory = int(_line.split("=", 1)[1].strip())
|
||||
if memory > 0:
|
||||
self._iol_memory = memory
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _persistent_volume_list(self, image_info, include_network_config=True):
|
||||
"""
|
||||
Override: the runner requires ``/config`` (its config file, generated
|
||||
below) and ``/tmp`` (netiomux sockets, NETMAP, NVRAM) as persisted
|
||||
volumes — /tmp because uBridge must reach the sockets on the host.
|
||||
Auto-add both so a minimal template cannot be misconfigured.
|
||||
"""
|
||||
|
||||
volumes = super()._persistent_volume_list(image_info, include_network_config)
|
||||
for needed in (self._IOL_CONFIG_DIR, "/tmp"):
|
||||
if not any(needed == v or needed.startswith(v.rstrip("/") + "/") for v in volumes):
|
||||
volumes.append(needed)
|
||||
return volumes
|
||||
|
||||
async def start(self):
|
||||
|
||||
await self._prepare_iol_runtime()
|
||||
await super().start()
|
||||
|
||||
async def restart(self):
|
||||
"""
|
||||
Override: the base restart is a bare ``docker restart`` — the runner
|
||||
would read a stale config (no adapter-count/memory refresh) and
|
||||
uBridge would keep wiring to the previous run's sockets. Stop
|
||||
gracefully (SIGTERM lets the runner flush NVRAM) and start again.
|
||||
"""
|
||||
|
||||
await self.stop(graceful=True)
|
||||
await self.start()
|
||||
|
||||
async def _prepare_iol_runtime(self):
|
||||
"""
|
||||
Regenerate the node's runtime files before the container starts.
|
||||
|
||||
* ``<working_dir>/tmp/run/`` must exist or the IOL process dies at
|
||||
boot (the runner writes NETMAP there but does not create it).
|
||||
* ``<working_dir>/config/iol-config.json`` is rewritten on every
|
||||
start so adapter-count and memory changes take effect.
|
||||
* Ephemeral sockets left by a previous (possibly SIGKILLed) run are
|
||||
removed — the runner rebinds them on boot and would fail on a
|
||||
stale file. ``tmp/run`` is never touched: startup-config and NVRAM
|
||||
persist there.
|
||||
"""
|
||||
|
||||
try:
|
||||
state = await self._get_container_state()
|
||||
except DockerHttp404Error:
|
||||
state = "stopped"
|
||||
|
||||
os.makedirs(os.path.join(self.working_dir, "tmp", "run"), exist_ok=True)
|
||||
self._write_iol_config()
|
||||
|
||||
if state == "running":
|
||||
# Idempotent start of a live node: the sockets belong to the
|
||||
# running runner; base start() will return early.
|
||||
return
|
||||
|
||||
tmp_dir = os.path.join(self.working_dir, "tmp")
|
||||
for pattern in ("s??.sock", "c??.sock"):
|
||||
for stale in glob.glob(os.path.join(tmp_dir, pattern)):
|
||||
try:
|
||||
os.unlink(stale)
|
||||
except OSError:
|
||||
pass
|
||||
for netio_dir in glob.glob(os.path.join(tmp_dir, "netio*")):
|
||||
shutil.rmtree(netio_dir, ignore_errors=True)
|
||||
|
||||
def _write_iol_config(self):
|
||||
"""
|
||||
Write the runner's config file on the host side of the /config volume.
|
||||
The runner drops to user-id/group-id after its setup, so everything it
|
||||
creates inside the volumes is owned by the server user (which is also
|
||||
what makes the /tmp sockets reachable by uBridge).
|
||||
"""
|
||||
|
||||
config = {
|
||||
"binary": "/binary.iol",
|
||||
"memory": self._iol_memory,
|
||||
"num-eth": self.adapters,
|
||||
"num-serial": 0, # GNS3 docker adapters are ethernet-only
|
||||
"local-app": 1,
|
||||
"remote-app": 2,
|
||||
"user-id": os.getuid(),
|
||||
"group-id": os.getgid(),
|
||||
}
|
||||
config_file = os.path.join(self.working_dir, "config", "iol-config.json")
|
||||
os.makedirs(os.path.dirname(config_file), exist_ok=True)
|
||||
with open(config_file, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
f.write("\n")
|
||||
log.debug("Wrote iol-runner config for '%s': %s", self._name, config)
|
||||
|
||||
async def _fix_permissions(self):
|
||||
"""
|
||||
Override: no-op. The generated config maps the runner to the server's
|
||||
uid/gid, so no root-owned files ever appear in the volumes, and this
|
||||
image has no shell for the container-side busybox pass anyway.
|
||||
"""
|
||||
|
||||
self._permissions_fixed = True
|
||||
@ -33,6 +33,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from gns3server.utils.asyncio import wait_for_file_creation
|
||||
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
|
||||
from gns3server.compute.docker.docker_vm import DockerVM
|
||||
from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error
|
||||
@ -64,6 +65,20 @@ class VendorDockerVM(DockerVM):
|
||||
sessions on the shared exec.
|
||||
* ``GNS3_STOP_TIMEOUT=60`` — SIGTERM grace period in seconds when stopping
|
||||
the container (default 60; Docker SIGKILLs once it expires).
|
||||
* ``GNS3_UNIX_SOCKET_NIO=1`` — wire adapters through AF_UNIX datagram
|
||||
socket files instead of a TAP interface moved into the container's
|
||||
network namespace. For images whose network agent exposes, per adapter
|
||||
``N``, a receive socket ``s%02d.sock`` and a send-to path ``c%02d.sock``
|
||||
(raw Ethernet frames, one datagram per frame) inside a persisted volume
|
||||
directory — e.g. Cisco CML's iol-runner (see IOLDockerVM). uBridge
|
||||
binds ``c{N:02d}.sock`` (its receive side) and sends to
|
||||
``s{N:02d}.sock``. No TAP is created, the container's network namespace
|
||||
is untouched and the ``mac_address`` template field is ignored (the
|
||||
image's agent owns the MAC scheme).
|
||||
* ``GNS3_UNIX_SOCKET_DIR=<dir>`` — in-container directory holding the
|
||||
socket files (default ``/tmp``). Must be a persisted volume
|
||||
(extra_volumes) so uBridge can reach the sockets on the host; node
|
||||
creation fails with an actionable error otherwise.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -86,6 +101,8 @@ class VendorDockerVM(DockerVM):
|
||||
self._console_cmd = None
|
||||
self._console_resize = True
|
||||
self._stop_timeout = 60
|
||||
self._unix_socket_nio = False
|
||||
self._unix_socket_dir = "/tmp"
|
||||
if self._environment:
|
||||
for _line in self._environment.splitlines():
|
||||
_line = _line.strip().rstrip(",")
|
||||
@ -99,6 +116,12 @@ class VendorDockerVM(DockerVM):
|
||||
self._console_cmd = _line.split("=", 1)[1].strip()
|
||||
elif _line.startswith("GNS3_CONSOLE_RESIZE="):
|
||||
self._console_resize = _line.split("=", 1)[1].strip().lower() not in ("0", "false", "no")
|
||||
elif _line.startswith("GNS3_UNIX_SOCKET_NIO="):
|
||||
self._unix_socket_nio = _line.split("=", 1)[1].strip().lower() in ("1", "true", "yes")
|
||||
elif _line.startswith("GNS3_UNIX_SOCKET_DIR="):
|
||||
socket_dir = _line.split("=", 1)[1].strip().rstrip("/") or "/"
|
||||
if os.path.isabs(socket_dir) and ".." not in socket_dir.split("/"):
|
||||
self._unix_socket_dir = socket_dir
|
||||
elif _line.startswith("GNS3_STOP_TIMEOUT="):
|
||||
try:
|
||||
timeout = int(_line.split("=", 1)[1].strip())
|
||||
@ -142,6 +165,17 @@ class VendorDockerVM(DockerVM):
|
||||
are never shadowed by an empty mount.
|
||||
"""
|
||||
binds = super()._mount_binds(image_info)
|
||||
if self._unix_socket_nio:
|
||||
socket_dir = self._unix_socket_dir.rstrip("/")
|
||||
if not any(
|
||||
v.rstrip("/") == socket_dir or socket_dir.startswith(v.rstrip("/") + "/")
|
||||
for v in self._volumes
|
||||
):
|
||||
raise DockerError(
|
||||
f"GNS3_UNIX_SOCKET_NIO requires socket directory '{self._unix_socket_dir}' of "
|
||||
f"container '{self._name}' to be a persisted volume (add it to extra_volumes) "
|
||||
f"so uBridge can reach the sockets on the host"
|
||||
)
|
||||
if self._gns3_init:
|
||||
return binds
|
||||
binds = [b for b in binds if b.get("Target") != "/gns3volumes/etc/network"]
|
||||
@ -284,6 +318,78 @@ class VendorDockerVM(DockerVM):
|
||||
return self._interface_names[adapter_number]
|
||||
return f"eth{adapter_number}"
|
||||
|
||||
@property
|
||||
def _unix_socket_host_dir(self):
|
||||
"""
|
||||
Host-side path of the in-container unix-socket directory: the bind
|
||||
source of the persisted volume it lives in.
|
||||
"""
|
||||
return os.path.join(self.working_dir, os.path.relpath(self._unix_socket_dir, "/"))
|
||||
|
||||
async def _add_ubridge_connection(self, nio, adapter_number):
|
||||
"""
|
||||
Override: with GNS3_UNIX_SOCKET_NIO, bridge the adapter through the
|
||||
image's AF_UNIX datagram socket pair (raw Ethernet frames) instead of
|
||||
a TAP interface moved into the container's network namespace.
|
||||
|
||||
Per adapter N the image's network agent is expected to create, inside
|
||||
GNS3_UNIX_SOCKET_DIR (a persisted volume, enforced by _mount_binds):
|
||||
|
||||
* ``s{N:02d}.sock`` — its receive socket; frames sent there are
|
||||
injected into guest interface N;
|
||||
* ``c{N:02d}.sock`` — the path it sends guest-egress frames to.
|
||||
|
||||
uBridge binds the c-socket as its receive side and sends to the
|
||||
s-socket. No TAP is allocated, the namespace is untouched and guest
|
||||
MAC addresses are whatever the image's agent uses.
|
||||
"""
|
||||
|
||||
if not self._unix_socket_nio:
|
||||
return await super()._add_ubridge_connection(nio, adapter_number)
|
||||
|
||||
try:
|
||||
adapter = self._ethernet_adapters[adapter_number]
|
||||
except IndexError:
|
||||
raise DockerError(
|
||||
"Adapter {adapter_number} doesn't exist on Docker container '{name}'".format(
|
||||
name=self.name, adapter_number=adapter_number
|
||||
)
|
||||
)
|
||||
|
||||
bridge_name = f"bridge{adapter_number}"
|
||||
await self._ubridge_send(f"bridge create {bridge_name}")
|
||||
self._bridges.add(bridge_name)
|
||||
|
||||
host_dir = self._unix_socket_host_dir
|
||||
local_sock = os.path.join(host_dir, f"c{adapter_number:02d}.sock")
|
||||
remote_sock = os.path.join(host_dir, f"s{adapter_number:02d}.sock")
|
||||
|
||||
# A c-socket left over from a previous ubridge run would fail its bind.
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(local_sock)
|
||||
|
||||
# The socket appears when the container's agent finishes its interface
|
||||
# setup; wait instead of silently blackholing the adapter.
|
||||
try:
|
||||
await wait_for_file_creation(remote_sock, timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
raise DockerError(
|
||||
f"Socket '{remote_sock}' for adapter {adapter_number} of container "
|
||||
f"'{self._name}' did not appear within 30 seconds. Check that the "
|
||||
f"container's port count covers adapter {adapter_number} and that "
|
||||
f"'{self._unix_socket_dir}' is bind-mounted from the node directory."
|
||||
)
|
||||
|
||||
await self._ubridge_send(f'bridge add_nio_unix {bridge_name} "{local_sock}" "{remote_sock}"')
|
||||
adapter.host_ifc = local_sock # bookkeeping / removal logging only
|
||||
log.debug(
|
||||
"Adapter %d of container '%s' wired via unix sockets %s <-> %s",
|
||||
adapter_number, self._name, local_sock, remote_sock,
|
||||
)
|
||||
|
||||
if nio:
|
||||
await self._connect_nio(adapter_number, nio)
|
||||
|
||||
def _cleanup_console_resources(self):
|
||||
"""
|
||||
Override: close the docker-exec pty socket, if any, so the next
|
||||
|
||||
487
tests/compute/docker/test_iol_docker_vm.py
Normal file
487
tests/compute/docker/test_iol_docker_vm.py
Normal file
@ -0,0 +1,487 @@
|
||||
#
|
||||
# Copyright (C) 2025 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Tests for the IOLDockerVM subclass (Cisco CML iol-runner images, e.g.
|
||||
iol-xe/iol-xe:17-18-02) and its unix-socket NIO wiring.
|
||||
|
||||
Image-free: everything is asserted against generated files, parsed knobs and
|
||||
the uBridge command stream.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from unittest.mock import patch, MagicMock, call
|
||||
|
||||
from tests.utils import asyncio_patch, AsyncioMagicMock
|
||||
|
||||
from gns3server.compute.docker import Docker
|
||||
from gns3server.compute.docker.docker_vm import DockerVM
|
||||
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
|
||||
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
|
||||
from gns3server.compute.docker.docker_error import DockerError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
IOL_ENTRYPOINT = ["/iol-runner", "-config", "/config/iol-config.json", "-stdio"]
|
||||
|
||||
|
||||
def _create_response(entrypoint=None, volumes=None):
|
||||
"""Build the Docker /containers/create response (with image info merged)."""
|
||||
return {
|
||||
"Id": "e90e34656806",
|
||||
"Warnings": [],
|
||||
"Config": {
|
||||
"Entrypoint": entrypoint or IOL_ENTRYPOINT,
|
||||
"Cmd": [],
|
||||
"Volumes": volumes or {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def manager(port_manager):
|
||||
|
||||
m = Docker.instance()
|
||||
m.port_manager = port_manager
|
||||
return m
|
||||
|
||||
|
||||
def _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1",
|
||||
extra_volumes=None, adapters=4, console_type="telnet"):
|
||||
"""Build an IOLDockerVM with a fake cid (no create() called)."""
|
||||
vm = IOLDockerVM(
|
||||
"iol-xe-1", str(uuid.uuid4()), compute_project, manager, "iol-xe/iol-xe:17-18-02",
|
||||
console_type=console_type, environment=environment,
|
||||
extra_volumes=extra_volumes or [], adapters=adapters,
|
||||
)
|
||||
vm._cid = "e90e34656842"
|
||||
return vm
|
||||
|
||||
|
||||
def _mock_start(vm, state="stopped"):
|
||||
"""Mock everything DockerVM.start() needs besides the runtime prep."""
|
||||
vm._get_container_state = AsyncioMagicMock(return_value=state)
|
||||
vm._start_ubridge = AsyncioMagicMock()
|
||||
vm._get_namespace = AsyncioMagicMock(return_value=42)
|
||||
vm._add_ubridge_connection = AsyncioMagicMock()
|
||||
vm._start_console_server = AsyncioMagicMock()
|
||||
|
||||
|
||||
def _seed_proc(stdout=b"seedcid\n", returncode=0):
|
||||
proc = MagicMock()
|
||||
proc.communicate = AsyncioMagicMock(return_value=(stdout, b""))
|
||||
proc.returncode = returncode
|
||||
return proc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_factory_selects_iol_for_env_marker(manager):
|
||||
|
||||
assert manager._select_node_class(console_type="telnet",
|
||||
environment="GNS3_IOL_RUNNER=1") is IOLDockerVM
|
||||
|
||||
|
||||
def test_factory_tolerates_whitespace_and_comma(manager):
|
||||
|
||||
assert manager._select_node_class(console_type="telnet",
|
||||
environment=" GNS3_IOL_RUNNER=1,\nFOO=bar") is IOLDockerVM
|
||||
|
||||
|
||||
def test_factory_docker_exec_wins_over_iol_marker(manager):
|
||||
|
||||
assert manager._select_node_class(console_type="docker_exec",
|
||||
environment="GNS3_IOL_RUNNER=1") is VendorDockerVM
|
||||
|
||||
|
||||
def test_factory_plain_environment_is_base(manager):
|
||||
|
||||
assert manager._select_node_class(console_type="telnet",
|
||||
environment="FOO=bar\nGNS3_BAZ=nope") is DockerVM
|
||||
|
||||
|
||||
def test_factory_generic_unix_knob_selects_vendor(manager):
|
||||
|
||||
assert manager._select_node_class(console_type="telnet",
|
||||
environment="GNS3_UNIX_SOCKET_NIO=1") is VendorDockerVM
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Knob parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_marker_forces_skip_init_and_unix_nio(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager, environment="GNS3_IOL_RUNNER=1")
|
||||
assert vm._gns3_init is False
|
||||
assert vm._unix_socket_nio is True
|
||||
assert vm._unix_socket_dir == "/tmp"
|
||||
assert vm._iol_memory == 2048
|
||||
|
||||
|
||||
def test_iol_memory_knob(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager,
|
||||
environment="GNS3_IOL_RUNNER=1\nGNS3_IOL_MEMORY=4096")
|
||||
assert vm._iol_memory == 4096
|
||||
|
||||
vm = _make_vm(compute_project, manager,
|
||||
environment="GNS3_IOL_RUNNER=1\nGNS3_IOL_MEMORY=notanumber")
|
||||
assert vm._iol_memory == 2048
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_keeps_image_entrypoint(compute_project, manager):
|
||||
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
|
||||
return_value=[{"image": "iol-xe"}]):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query",
|
||||
return_value=_create_response()) as mock:
|
||||
with patch("asyncio.subprocess.create_subprocess_exec",
|
||||
return_value=_seed_proc()):
|
||||
vm = _make_vm(compute_project, manager)
|
||||
await vm.create()
|
||||
sent = mock.call_args.kwargs["data"]
|
||||
# the iol-runner entrypoint runs as PID 1, untouched
|
||||
assert sent["Entrypoint"] == IOL_ENTRYPOINT
|
||||
assert sent["Cmd"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_auto_adds_config_and_tmp_volumes(compute_project, manager):
|
||||
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
|
||||
return_value=[{"image": "iol-xe"}]):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query",
|
||||
return_value=_create_response()) as mock:
|
||||
with patch("asyncio.subprocess.create_subprocess_exec",
|
||||
return_value=_seed_proc()):
|
||||
vm = _make_vm(compute_project, manager, extra_volumes=[])
|
||||
await vm.create()
|
||||
sent = mock.call_args.kwargs["data"]
|
||||
targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]]
|
||||
# both volumes are forced and bound at their real in-container
|
||||
# paths (skip-init retargeting), reachable by uBridge on the host
|
||||
assert "/config" in targets
|
||||
assert "/tmp" in targets
|
||||
assert not any(t.startswith("/gns3volumes/") for t in targets)
|
||||
vol_env = [v for v in sent["Env"] if v.startswith("GNS3_VOLUMES=")][0]
|
||||
assert "/config" in vol_env and "/tmp" in vol_env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_start_command_becomes_runner_flags(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager)
|
||||
vm.start_command = "-keep"
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
|
||||
return_value=[{"image": "iol-xe"}]):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query",
|
||||
return_value=_create_response()) as mock:
|
||||
with patch("asyncio.subprocess.create_subprocess_exec",
|
||||
return_value=_seed_proc()):
|
||||
await vm.create()
|
||||
sent = mock.call_args.kwargs["data"]
|
||||
# start_command is the container CMD = extra iol-runner flags
|
||||
assert sent["Cmd"] == ["-keep"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start() — runtime preparation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_writes_iol_config(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager, adapters=4)
|
||||
_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["binary"] == "/binary.iol"
|
||||
assert config["num-eth"] == 4
|
||||
assert config["num-serial"] == 0
|
||||
assert config["local-app"] == 1
|
||||
assert config["remote-app"] == 2
|
||||
assert config["memory"] == 2048
|
||||
assert config["user-id"] == os.getuid()
|
||||
assert config["group-id"] == os.getgid()
|
||||
assert vm.status == "started"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_rewrites_config_on_adapter_change(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager, adapters=4)
|
||||
_mock_start(vm)
|
||||
with patch("gns3server.compute.docker.Docker.install_busybox"):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query"):
|
||||
await vm.start()
|
||||
|
||||
vm.adapters = 8
|
||||
_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:
|
||||
assert json.load(f)["num-eth"] == 8
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_creates_run_dir(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager)
|
||||
_mock_start(vm)
|
||||
with patch("gns3server.compute.docker.Docker.install_busybox"):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query"):
|
||||
await vm.start()
|
||||
|
||||
assert os.path.isdir(os.path.join(vm.working_dir, "tmp", "run"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_cleans_stale_sockets_but_keeps_run(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager)
|
||||
tmp_dir = os.path.join(vm.working_dir, "tmp")
|
||||
os.makedirs(os.path.join(tmp_dir, "run"), exist_ok=True)
|
||||
for name in ("s00.sock", "c00.sock", "s01.sock", "c01.sock"):
|
||||
open(os.path.join(tmp_dir, name), "w").close()
|
||||
os.makedirs(os.path.join(tmp_dir, "netio1000"))
|
||||
open(os.path.join(tmp_dir, "run", "nvram_00001"), "w").close()
|
||||
open(os.path.join(tmp_dir, "run", "config"), "w").close()
|
||||
|
||||
_mock_start(vm, state="stopped")
|
||||
with patch("gns3server.compute.docker.Docker.install_busybox"):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query"):
|
||||
await vm.start()
|
||||
|
||||
assert glob.glob(os.path.join(tmp_dir, "s??.sock")) == []
|
||||
assert glob.glob(os.path.join(tmp_dir, "c??.sock")) == []
|
||||
assert not os.path.exists(os.path.join(tmp_dir, "netio1000"))
|
||||
# the persistent runtime survives the cleanup
|
||||
assert os.path.exists(os.path.join(tmp_dir, "run", "nvram_00001"))
|
||||
assert os.path.exists(os.path.join(tmp_dir, "run", "config"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_skips_cleanup_when_already_running(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager)
|
||||
tmp_dir = os.path.join(vm.working_dir, "tmp")
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
open(os.path.join(tmp_dir, "s00.sock"), "w").close()
|
||||
|
||||
_mock_start(vm, state="running")
|
||||
with patch("gns3server.compute.docker.Docker.install_busybox"):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query"):
|
||||
await vm.start()
|
||||
|
||||
# live runner sockets must not be deleted behind its back
|
||||
assert os.path.exists(os.path.join(tmp_dir, "s00.sock"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fix_permissions_is_noop(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager)
|
||||
with patch("asyncio.subprocess.create_subprocess_exec") as mock_exec:
|
||||
await vm._fix_permissions()
|
||||
mock_exec.assert_not_called()
|
||||
assert vm._permissions_fixed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_is_graceful_stop_then_start(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager)
|
||||
vm.stop = AsyncioMagicMock()
|
||||
vm.start = AsyncioMagicMock()
|
||||
await vm.restart()
|
||||
vm.stop.assert_called_once_with(graceful=True)
|
||||
vm.start.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wiring — unix-socket NIO
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_ubridge_connection_unix_wiring(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager)
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
host_dir = os.path.join(vm.working_dir, "tmp")
|
||||
os.makedirs(host_dir, exist_ok=True)
|
||||
# the runner's receive socket must exist (created by the container)
|
||||
open(os.path.join(host_dir, "s00.sock"), "w").close()
|
||||
|
||||
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
|
||||
await vm._add_ubridge_connection(nio, 0)
|
||||
|
||||
sent = [c for c in vm._ubridge_hypervisor.method_calls if "send" in str(c)]
|
||||
flat = "\n".join(str(c) for c in sent)
|
||||
assert call.send("bridge create bridge0") in sent
|
||||
assert call.send(f'bridge add_nio_unix bridge0 "{os.path.join(host_dir, "c00.sock")}" '
|
||||
f'"{os.path.join(host_dir, "s00.sock")}"') in sent
|
||||
assert "add_nio_udp bridge0 4242 127.0.0.1 4343" in flat
|
||||
assert "bridge start bridge0" in flat
|
||||
# the TAP/namespace path must not be used at all
|
||||
assert "add_nio_tap" not in flat
|
||||
assert "move_to_ns" not in flat
|
||||
assert "set_mac_addr" not in flat
|
||||
assert vm._ethernet_adapters[0].host_ifc == os.path.join(host_dir, "c00.sock")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_ubridge_connection_stale_local_socket_unlinked(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager)
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
host_dir = os.path.join(vm.working_dir, "tmp")
|
||||
os.makedirs(host_dir, exist_ok=True)
|
||||
open(os.path.join(host_dir, "c00.sock"), "w").close()
|
||||
open(os.path.join(host_dir, "s00.sock"), "w").close()
|
||||
|
||||
await vm._add_ubridge_connection(None, 0)
|
||||
assert not os.path.exists(os.path.join(host_dir, "c00.sock"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_ubridge_connection_adapter_out_of_range(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager)
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
with pytest.raises(DockerError):
|
||||
await vm._add_ubridge_connection(None, 42)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_ubridge_connection_timeout_is_actionable(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager)
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
|
||||
async def raise_timeout(path, timeout=60):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
with patch("gns3server.compute.docker.vendor_docker_vm.wait_for_file_creation",
|
||||
side_effect=raise_timeout):
|
||||
with pytest.raises(DockerError) as excinfo:
|
||||
await vm._add_ubridge_connection(None, 0)
|
||||
# the message names the adapter and the socket directory
|
||||
assert "adapter 0" in str(excinfo.value)
|
||||
assert "/tmp" in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_ubridge_connection_without_nio_still_wires(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager)
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
host_dir = os.path.join(vm.working_dir, "tmp")
|
||||
os.makedirs(host_dir, exist_ok=True)
|
||||
open(os.path.join(host_dir, "s00.sock"), "w").close()
|
||||
|
||||
await vm._add_ubridge_connection(None, 0)
|
||||
flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls)
|
||||
assert "bridge create bridge0" in flat
|
||||
assert "add_nio_unix" in flat
|
||||
# no link yet: no UDP NIO, no bridge start (matches base semantics)
|
||||
assert "add_nio_udp" not in flat
|
||||
assert "bridge start" not in flat
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic GNS3_UNIX_SOCKET_NIO knob on plain VendorDockerVM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_env_unix_socket_nio_parsing(compute_project, manager):
|
||||
|
||||
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
|
||||
console_type="docker_exec",
|
||||
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks")
|
||||
assert vm._unix_socket_nio is True
|
||||
assert vm._unix_socket_dir == "/var/run/socks"
|
||||
|
||||
# invalid dirs are rejected, keeping the default
|
||||
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
|
||||
console_type="docker_exec",
|
||||
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=yes\nGNS3_UNIX_SOCKET_DIR=../../etc")
|
||||
assert vm._unix_socket_nio is True
|
||||
assert vm._unix_socket_dir == "/tmp"
|
||||
|
||||
# off by default / explicit off
|
||||
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
|
||||
console_type="docker_exec", environment="GNS3_SKIP_INIT=1")
|
||||
assert vm._unix_socket_nio is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unix_socket_dir_must_be_a_volume(compute_project, manager):
|
||||
|
||||
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
|
||||
console_type="docker_exec",
|
||||
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1",
|
||||
extra_volumes=[])
|
||||
with pytest.raises(DockerError) as excinfo:
|
||||
vm._mount_binds({"Config": {"Volumes": {}}})
|
||||
assert "extra_volumes" in str(excinfo.value)
|
||||
|
||||
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
|
||||
console_type="docker_exec",
|
||||
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1",
|
||||
extra_volumes=["/tmp"])
|
||||
binds = vm._mount_binds({"Config": {"Volumes": {}}})
|
||||
assert any(b["Target"] == "/tmp" for b in binds)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manager):
|
||||
|
||||
vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest",
|
||||
console_type="docker_exec",
|
||||
environment="GNS3_SKIP_INIT=1\nGNS3_UNIX_SOCKET_NIO=1\nGNS3_UNIX_SOCKET_DIR=/var/run/socks")
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
host_dir = os.path.join(vm.working_dir, "var", "run", "socks")
|
||||
os.makedirs(host_dir, exist_ok=True)
|
||||
open(os.path.join(host_dir, "s00.sock"), "w").close()
|
||||
|
||||
await vm._add_ubridge_connection(None, 0)
|
||||
flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls)
|
||||
assert f'"{os.path.join(host_dir, "s00.sock")}"' in flat
|
||||
assert "add_nio_tap" not in flat
|
||||
Loading…
x
Reference in New Issue
Block a user