Merge branch 'feat/iol-docker-startup-config' into local-test

This commit is contained in:
YueGuobin 2026-09-05 21:55:51 +08:00
commit 1adc1b47b7
No known key found for this signature in database
19 changed files with 2139 additions and 91 deletions

View File

@ -39,6 +39,8 @@ docs/
│ ├── statistics-api.md # Aggregated statistics API for monitoring
│ ├── vnc-websocket-console.md # Browser-based VNC console via WebSocket
│ └── web-wireshark-business-process.md # Web Wireshark (Docker + xpra packet capture)
├── design/ # Design proposals & roadmaps (not yet implemented)
│ └── docker-image-type.md # Docker image types: vendor profile discriminator + registry
├── gns3-copilot/ # AI Copilot feature documentation
│ ├── netmiko_devices.md # Netmiko supported devices (366 types)
│ ├── template-based-configuration-roadmap.md # Future: template-based config with HITL
@ -84,6 +86,16 @@ 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).
---
## Design & Roadmaps (`design/`)
### Docker Image Types (`design/docker-image-type.md`)
Proposed `image_type` discriminator on Docker templates plus a compute-side profile registry: vendor parameters graduate from environment markers into schema-gated fields, generic-feature applicability becomes declared capability data, and the existing markers remain as a compatibility fallback. Not a new node type — vendor images are content, not mechanism.
---
## GNS3 AI Copilot (`gns3-copilot/`)

View File

@ -0,0 +1,144 @@
# Docker Image Types (Vendor Profiles) — Roadmap
Status: **proposal, not implemented**. This is the agreed direction for the
next iteration of vendor Docker support. Four vendor profiles already
exist across branches and prototypes — iol-runner (this branch), XRd and
the SR Linux prototype (vendor/skip-init family), and the FRR registry
appliance — enough evidence to justify the registry. Implement it as a
follow-up to the current PR series, shaped by all four rather than by
iol-runner alone.
## Problem
All Docker nodes share one template type (`template_type: "docker"`) and
one template schema. Vendor images (iol-runner, SR Linux, XRd, …) are
selected and configured today through **environment markers** — free-form
strings parsed by the compute (`GNS3_IOL_RUNNER`, `GNS3_IOL_MEMORY`,
`GNS3_IOL_STARTUP_CONFIG`, `GNS3_SKIP_INIT`, `GNS3_UNIX_SOCKET_NIO`, …).
Three pains have already surfaced:
1. **Invisible and unvalidated.** The markers do not exist in the API
schema: no OpenAPI documentation, no validation, typo or bad value
silently falls back to a default (`GNS3_IOL_MEMORY=notanumber` → 2048).
2. **The shared-schema dilemma.** Adding a vendor-specific field to the
Docker template schema pollutes every Docker template with a field only
one vendor reads; not adding it pushes everything into environment
strings. The `startup_config` discussion (ended in the
`GNS3_IOL_STARTUP_CONFIG` knob) is the canonical example — the tension
exists because there is no legitimate discriminator dimension.
3. **Generic-feature applicability is implicit.** Which generic Docker
features apply to which image is only documented in code comments:
`mac_address` is meaningless under unix-socket NIO, `/etc/network`
seeding is dead weight for skip-init images, `extra_configs` targets
under persisted volumes get a (correct-but-noisy) shadow warning.
## Non-goal: new node types
A `template_type`/`node_type` per vendor (`"iol"`, `"srlinux"`, …) is
explicitly rejected. `node_type` is a top-level concept: it drives compute
module routing (`/projects/{id}/{node_type}/nodes`), capability
reporting, GUI node types and link handling. N vendor types that are all
Docker underneath would multiply routing and schema surface for zero
mechanism — vendor images are *content*, not mechanism, and GNS3's
appliance/template system is where content belongs.
## Design
### One discriminator field on Docker templates
```json
{
"template_type": "docker",
"image_type": "iol-runner",
"image": "iol-xe/iol-xe:17-18-02"
}
```
`image_type` is optional; absent (or `"generic"`) keeps today's plain
`DockerVM` behavior and marker sniffing. The profiles that exist today,
mapped to their current mechanisms and archetypes:
| Profile | Current mechanism | Archetype |
|---|---|---|
| FRR appliance | plain `DockerVM`: init.sh `/etc/network` + `start_command` (frrinit.sh), console on PID 1 | generic |
| XRd | `VendorDockerVM` skip-init + `GNS3_SHM_SIZE`/`GNS3_DEVICES`, `extra_configs`, udev masking | vendor skip-init |
| SR Linux (prototype) | `VendorDockerVM` skip-init + `docker_exec` console + `GNS3_INTERFACE_NAMES` | vendor skip-init |
| iol-runner | `IOLDockerVM`: unix-socket NIO, per-start config generation, NVRAM startup-config | iol-runner |
Possible first values: `generic`, `iol-runner`, and a value for the
skip-init vendor NOS family once its common shape settles (XRd and SR
Linux may end up sharing it or splitting — that is exactly what the
registry should decide with all four in front of us).
### A compute-side registry
```python
IMAGE_PROFILES = {
"iol-runner": {
"class": IOLDockerVM,
"fields": ("iol_memory", "startup_config"), # schema-gated
"capabilities": {...}, # see below
},
...
}
```
* **Class selection** moves from environment sniffing
(`Docker._select_node_class`) to the field, with the existing markers
kept as a fallback for templates created before the field existed
(zero-migration compatibility).
* **Vendor parameters graduate into schema fields**, gated by pydantic
conditional validation (accepted — and validated — only when
`image_type` matches). This resolves the shared-schema dilemma properly:
the field exists, but only means something for its profile. The
environment knobs remain as the wire-level compatibility entry.
* **Capability declaration** makes generic-feature applicability data
instead of comments:
| Capability | plain Docker | vendor skip-init | iol-runner |
|---|---|---|---|
| `mac_address` honored | yes | yes | **no** (IOL derives MACs from the app id) |
| `/etc/network` seeding | yes | no | no |
| `extra_configs` targets under volumes | shadowed | works (real-path binds) | works |
| console | telnet/http/… | `docker_exec` | PID 1 stdio (telnet) |
| startup config | `extra_configs` injection | image-specific | nvram build (`nvram_import`) |
Consumers: error/warning quality (don't warn about inapplicable
features), the WebUI (vendor-aware template forms — a bonus, not a
driver), and documentation generation.
The existing class hierarchy (`DockerVM` → `VendorDockerVM` →
`IOLDockerVM`) is unchanged — the registry only externalizes selection
and declaration.
## Migration & compatibility
* Old templates (markers only) keep working via the fallback; a one-time
optional converter can rewrite markers → `image_type` + fields.
* The controller-side materialization conventions
(`GNS3_IOL_STARTUP_CONFIG` file → `startup_config_content`, sent once)
carry over unchanged — the field version references the same content
pipeline.
## Roadmap steps
1. Add `image_type` to the Docker template schema (+ DB column +
Alembic migration — see the three-place rule for template fields) and
wire class selection through the registry, markers as fallback.
2. Move iol-runner knobs to gated fields (`iol_memory`,
`startup_config`), deprecating-but-supporting the env forms.
3. Introduce the capability table and use it to silence inapplicable
warnings (`mac_address`, `/etc/network`, extra-config shadowing).
4. Revisit per-vendor *schemas* (a sub-model per profile) only if a
profile grows more than a handful of fields — not before.
## Open questions
* Field name: `image_type` vs `vendor` vs `runner` — decide when the
second profile lands; the value vocabulary should name the *runtime
contract*, not the vendor.
* Should appliances (`.gns3a`) carry `image_type` explicitly, or should
installation keep deriving it from the appliance's environment block?
* Where the gated vendor fields live long-term: flat on the Docker
template (simple, gated) vs nested `{"image_type": ..., "settings":
{...}}` (cleaner, more schema churn).

View File

@ -0,0 +1,218 @@
<!--
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&lt;uid&gt;/"| NETIOMUX --> SOCKETS
end
subgraph Host
UBRIDGE["uBridge bridgeN<br/>add_nio_unix …/gns3/unixio/&lt;node&gt;/cNN.sock …<br/>+ add_nio_udp (topology)"]
RTDIR["/run/user/&lt;uid&gt;/gns3/unixio/&lt;node&gt;<br/>(bind-mounted at /tmp)"]
VOL["project-files/docker/&lt;node&gt;/config<br/>(+ tmp/run, nested bind at /tmp/run)"]
end
SOCKETS <-->|"same files, two spellings<br/>(the /tmp bind)"| RTDIR <--> UBRIDGE
UBRIDGE -.->|"iol-config.json +<br/>persistent /tmp/run"| VOL
```
* **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 the container's `/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 —
the same two-mailbox convention uBridge's `add_nio_unix` natively speaks
(it binds the c-socket, sends to the s-socket). GNS3 bind-mounts a
per-node directory from the runtime directory
(`/run/user/<uid>/gns3/unixio/<node-id>`, next to the uBridge control
sockets) at the container's `/tmp`, so uBridge reaches the sockets as
plain host files: the path stays far under AF_UNIX's 107-byte `sun_path`
cap (a projects-tree node path alone exceeds it) and the directory is
owned by the server user, to whom the runner drops its privileges. This
mirrors how CML itself runs the image (`source=…/tmp,target=/tmp` in its
node definition). The directory is ephemeral and removed with the node.
* **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` (the IOL working directory) holds the NETMAP,
the startup-config (`config`, plain IOS format) and NVRAM (`nvram_00001`).
It is the only `/tmp` path that needs to survive: GNS3 bind-mounts the
node directory's `tmp/run/` at `/tmp/run` (nested inside the runtime-dir
bind), so the router's configuration survives stop/start and container
recreation while sockets, netio buses and runner logs stay ephemeral.
The generated config maps the runner to the server's uid/gid
(`user-id`/`group-id`), so all files it creates are owned by the server
user (no permission-fix pass needed).
## Template
Create the template once via `POST /v3/templates` (authenticated — see the
API docs for the auth flow), or in the Web UI under
*Edit → Preferences → Docker templates → New* with the same fields:
```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": 2,
"console_type": "telnet",
"environment": "GNS3_IOL_RUNNER=1",
"extra_volumes": ["/config"]
}
```
| Field | Value | Why |
|---|---|---|
| `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=<MB>` (default 2048), `GNS3_IOL_STARTUP_CONFIG=<file>` (initial configuration, see below). |
| `extra_volumes` | `["/config"]` | `/tmp/run` is auto-added. **Never add `/tmp`** — it would persist the socket directory into the projects tree and uBridge would reject the too-long AF_UNIX path. |
| `adapters` | number of 4-port units | The IOU convention: one adapter = `Ethernet0/0`–`Ethernet0/3`, two adapters add `Ethernet1/0`–`1/3`, … (8 units / 32 ports max). Ports are addressed as (adapter, port 0–3) and shown grouped in the UI. |
| `memory` | optional; `0` (default) = no cap | Unset works — Docker applies no limit. When you do set a cap, keep it at IOL memory + ~512 MB, or the cgroup OOM-killer shoots the router. |
| `console_type` | `telnet` | The runner muxes the IOS console onto PID 1 stdio; `docker_exec` is not needed. |
### Verify
1. The node's port list shows the grouped IOL interfaces
(`Ethernet0/0`–`Ethernet1/3` for two adapters), addressed
(adapter, port).
2. Drop a node into a project and start it — the console shows the
`Linux Unix (i686)` banner within seconds.
3. `$XDG_RUNTIME_DIR/gns3/unixio/<node-id>/` contains `s00.sock`… (one
pair per port).
4. A node created with a startup-config boots straight to the configured
hostname — no initial configuration dialog (interface names in the
config are `Ethernet0/0`, not `GigabitEthernet0/0`).
## Startup configuration
IOL Docker nodes load an initial configuration exactly like native IOU
nodes: the **template references a config file**, and every node created
from it boots with that configuration as its personal starting point.
```json
"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=iol-xe-base.txt"
```
* The file lives in the controller's configs directory (the `configs_path`
server setting, by default `~/GNS3/configs`) — the same place IOU and
VPCS base configs live. A minimal `iol-xe-base.txt` ships with the
server and is installed there on startup (never overwriting a
user-modified copy); an absolute path in the knob bypasses the directory
entirely. `%h` in the content is replaced with the node name at start.
* **Creation materializes it once**: the controller reads the file and
sends its content with the node; afterwards the knob is consumed and the
template file is never referenced again — editing it does not affect
existing nodes. The knob also disappears from the node's `environment`
(`GNS3_IOL_RUNNER=1` remains — it re-selects the node class on every
recreation), so a node whose environment shows only `GNS3_IOL_RUNNER=1`
is the normal sign that the file was found and its content delivered;
when the file is missing the knob stays in the environment (with a
server-side warning) and the lookup is retried at the next creation.
* **NVRAM is the source of truth at boot** (verified on the runner): the
content is built into the node's `tmp/run/nvram_<app id>` — IOL and IOU
share the nvram container format, so the server-side `nvram_import`
utility produces a file IOL boots from directly. Consequences:
* `write memory` survives stop/start and container recreation (a plain
restart never re-applies the startup-config over it). The first
`write memory` after a server-built NVRAM asks for a one-time
`[confirm]` (the builder stamps an IOS 15.4 version marker into the
nvram header; press return — IOS then writes its own).
* Editing a node's `startup_config_content` property (PUT) re-applies it
on the next start, overwriting what `write memory` had saved — the
explicit edit wins, as with IOU.
* Renaming a node rewrites the `hostname` line in its NVRAM, so a
duplicated/renamed node boots under its new name.
* **Resetting a node to its template config**: stop the node, delete
`project-files/docker/<node>/tmp/run/nvram_*`, then re-apply the content
(or recreate the node).
## 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. The socket directory is bound from a per-node runtime directory unless a persisted volume already covers it. |
| `GNS3_UNIX_SOCKET_DIR=<dir>` | `VendorDockerVM` | In-container 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 `/config` and `/tmp/run` 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 sockets/netio dirs from the socket directory (`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_IOL_STARTUP_CONFIG=<file>` | controller `Node._node_data` + `IOLDockerVM` | Startup-config plumbing (see above): the controller translates the file into `startup_config_content` on node creation (sent once); the compute builds it into `nvram_<app id>` at the next start via the IOU `nvram_import` utility and rewrites the hostname line on rename. |
`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
* **Console typing latency**: single keystrokes are echoed on a ~50 ms
cadence — the image services console input on an internal poll.
Measured directly on the container console (bypassing the GNS3 server
entirely, through the same Docker attach endpoint it uses): keystroke
echo is bimodal — 1–11 ms when the keystroke lands just before a poll
tick, ~50–100 ms when it just misses one — while a pasted line is
picked up as one batch (~1.4 ms) and command output streams
back-to-back (inter-frame gaps ~0.01 ms). A control container on the
same attach endpoint echoes in ~1 ms, so the GNS3 telnet path is not a
factor; this matches IOS's low-priority console-input polling and there
is nothing to fix server-side. Paste long commands instead of typing
them.
* **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 from the node's application
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). Starting a node without an allocation
(raw compute API, pre-allocation topologies) is an error, not a fallback —
an uncoordinated ID could collide with the pool and make nodes 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.
* **Adapters**: one adapter is a 4-port unit (the IOU model): change the
count while the node is stopped; the config is regenerated on the next
start (`num-eth` = adapters × 4) and the runner creates the matching
socket set.
* **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.

View File

@ -140,6 +140,7 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D
"extra_hosts",
"extra_volumes",
"extra_configs",
"startup_config_content",
"memory",
"cpus",
]
@ -147,7 +148,8 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D
changed = False
node_data = jsonable_encoder(node_data, exclude_unset=True)
for prop in props:
if prop in node_data and node_data[prop] != getattr(node, prop):
# hasattr: startup_config_content only exists on IOLDockerVM
if prop in node_data and hasattr(node, prop) and node_data[prop] != getattr(node, prop):
setattr(node, prop, node_data[prop])
changed = True
# We don't call container.update for nothing because it will restart the container
@ -284,7 +286,7 @@ async def create_docker_node_nio(
"""
nio = Docker.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
await node.adapter_add_nio_binding(adapter_number, nio)
await node.adapter_add_nio_binding(adapter_number, nio, port_number)
return nio.asdict()
@ -302,13 +304,13 @@ async def update_docker_node_nio(
The port number on the Docker node is always 0.
"""
nio = node.get_nio(adapter_number)
nio = node.get_nio(adapter_number, port_number)
nio.filters.clear()
if nio_data.filters:
nio.filters = nio_data.filters
nio.markers = nio_data.markers or {}
nio.suspend = nio_data.suspend
await node.adapter_update_nio_binding(adapter_number, nio)
await node.adapter_update_nio_binding(adapter_number, nio, port_number)
return nio.asdict()
@ -327,7 +329,7 @@ async def delete_docker_node_nio(
The port number on the Docker node is always 0.
"""
await node.adapter_remove_nio_binding(adapter_number)
await node.adapter_remove_nio_binding(adapter_number, port_number)
@router.post(
@ -346,7 +348,7 @@ async def start_docker_node_capture(
"""
pcap_file_path = os.path.join(node.project.capture_working_directory(), node_capture_data.capture_file_name)
await node.start_capture(adapter_number, pcap_file_path)
await node.start_capture(adapter_number, pcap_file_path, port_number)
return {"pcap_file_path": str(pcap_file_path)}
@ -365,7 +367,7 @@ async def stop_docker_node_capture(
The port number on the Docker node is always 0.
"""
await node.stop_capture(adapter_number)
await node.stop_capture(adapter_number, port_number)
@router.get(
@ -382,7 +384,7 @@ async def stream_pcap_file(
The port number on the Docker node is always 0.
"""
nio = node.get_nio(adapter_number)
nio = node.get_nio(adapter_number, port_number)
stream = Docker.instance().stream_pcap_file(nio, node.project.id)
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")

View File

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

View File

@ -899,19 +899,24 @@ class DockerVM(BaseNode):
await self._start_ubridge(require_privileged_access=True)
for adapter_number in range(0, self.adapters):
nio = self._ethernet_adapters[adapter_number].get_nio(0)
async with self.manager.ubridge_lock:
try:
await self._add_ubridge_connection(nio, adapter_number)
except UbridgeNamespaceError:
log.error("Container %s failed to start", self.name)
await self.stop()
adapter = self._ethernet_adapters[adapter_number]
# Single-port adapters (the standard case) loop once, keeping
# the historical command sequence; multi-port adapters
# (e.g. IOL's 4-port units) get one bridge per port.
for port_number in range(0, adapter.interfaces):
nio = adapter.get_nio(port_number)
async with self.manager.ubridge_lock:
try:
await self._add_ubridge_connection(nio, adapter_number, port_number)
except UbridgeNamespaceError:
log.error("Container %s failed to start", self.name)
await self.stop()
# The container can crash soon after the start, this means we can not move the interface to the container namespace
logdata = await self._get_log()
for line in logdata.split("\n"):
log.error(line)
raise DockerError(logdata)
# The container can crash soon after the start, this means we can not move the interface to the container namespace
logdata = await self._get_log()
for line in logdata.split("\n"):
log.error(line)
raise DockerError(logdata)
await self._start_console_server()
@ -1406,6 +1411,21 @@ class DockerVM(BaseNode):
"""
return f"eth{adapter_number}"
def _bridge_name(self, adapter_number, port_number=0):
"""
uBridge bridge name for an adapter port. Adapters with a single
port (every standard Docker node) keep the historical
"bridge{adapter}" name; multi-port adapters (e.g. IOL's 4-port
units) get one bridge per port.
:param adapter_number: adapter number
:param port_number: port number on the adapter
"""
if port_number:
return f"bridge{adapter_number}_{port_number}"
return f"bridge{adapter_number}"
async def _start_interface_monitor(self):
"""Monitor administrative state changes for this container's adapters.
@ -1558,12 +1578,14 @@ class DockerVM(BaseNode):
if writer:
await self._close_interface_monitor_writer(writer)
async def _add_ubridge_connection(self, nio, adapter_number):
async def _add_ubridge_connection(self, nio, adapter_number, port_number=0):
"""
Creates a connection in uBridge.
:param nio: NIO instance or None if it's a dummy interface (if an interface is missing in ubridge you can't see it via ifconfig in the container)
:param adapter_number: adapter number
:param port_number: port number on the adapter (standard Docker
adapters have a single port, so this is always 0 on the TAP path)
"""
try:
@ -1575,6 +1597,13 @@ class DockerVM(BaseNode):
)
)
if port_number and adapter.interfaces == 1:
raise DockerError(
"Port {port_number} doesn't exist on adapter {adapter_number} of Docker container '{name}'".format(
name=self.name, port_number=port_number, adapter_number=adapter_number
)
)
for index in range(4096):
if f"tap-gns3-e{index}" not in psutil.net_if_addrs():
adapter.host_ifc = f"tap-gns3-e{str(index)}"
@ -1585,12 +1614,12 @@ class DockerVM(BaseNode):
name=self.name, adapter_number=adapter_number
)
)
bridge_name = f"bridge{adapter_number}"
bridge_name = self._bridge_name(adapter_number, port_number)
await self._ubridge_send(f"bridge create {bridge_name}")
self._bridges.add(bridge_name)
await self._ubridge_send(
"bridge add_nio_tap bridge{adapter_number} {hostif} off".format(
adapter_number=adapter_number, hostif=adapter.host_ifc
"bridge add_nio_tap {bridge_name} {hostif} off".format(
bridge_name=bridge_name, hostif=adapter.host_ifc
)
)
@ -1618,23 +1647,24 @@ class DockerVM(BaseNode):
log.debug(f"Created adapter {adapter_number} with MAC address {mac_address} in namespace {self._namespace}")
if nio:
await self._connect_nio(adapter_number, nio)
await self._set_adapter_carrier(adapter_number, not nio.suspend)
await self._connect_nio(adapter_number, nio, port_number)
await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number)
async def _get_namespace(self):
result = await self.manager.query("GET", f"containers/{self._cid}/json")
return int(result["State"]["Pid"])
async def _set_adapter_carrier(self, adapter_number, connected):
async def _set_adapter_carrier(self, adapter_number, connected, port_number=0):
"""Replicate a Docker adapter's connection state on its TAP device."""
bridge_name = self._bridge_name(adapter_number, port_number)
state = "on" if connected else "off"
await self._ubridge_send(f"bridge set_nio_tap_carrier bridge{adapter_number} {state}")
await self._ubridge_send(f"bridge set_nio_tap_carrier {bridge_name} {state}")
async def _connect_nio(self, adapter_number, nio):
async def _connect_nio(self, adapter_number, nio, port_number=0):
bridge_name = f"bridge{adapter_number}"
bridge_name = self._bridge_name(adapter_number, port_number)
await self._ubridge_send(
"bridge add_nio_udp {bridge_name} {lport} {rhost} {rport}".format(
bridge_name=bridge_name, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
@ -1650,12 +1680,13 @@ class DockerVM(BaseNode):
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
async def adapter_add_nio_binding(self, adapter_number, nio):
async def adapter_add_nio_binding(self, adapter_number, nio, port_number=0):
"""
Adds an adapter NIO binding.
:param adapter_number: adapter number
:param nio: NIO instance to add to the slot/port
:param nio: NIO instance to add to the adapter/port
:param port_number: port number on the adapter (0 for single-port adapters)
"""
try:
@ -1667,38 +1698,47 @@ class DockerVM(BaseNode):
)
)
if self.status == "started" and self.ubridge:
await self._connect_nio(adapter_number, nio)
await self._set_adapter_carrier(adapter_number, not nio.suspend)
if not adapter.port_exists(port_number):
raise DockerError(
"Port {port_number} doesn't exist on adapter {adapter_number} of Docker container '{name}'".format(
name=self.name, port_number=port_number, adapter_number=adapter_number
)
)
adapter.add_nio(0, nio)
if self.status == "started" and self.ubridge:
await self._connect_nio(adapter_number, nio, port_number)
await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number)
adapter.add_nio(port_number, nio)
log.debug(
"Docker container '{name}' [{id}]: {nio} added to adapter {adapter_number}".format(
name=self.name, id=self._id, nio=nio, adapter_number=adapter_number
)
)
async def adapter_update_nio_binding(self, adapter_number, nio):
async def adapter_update_nio_binding(self, adapter_number, nio, port_number=0):
"""
Update an adapter NIO binding.
:param adapter_number: adapter number
:param nio: NIO instance to update the adapter
:param port_number: port number on the adapter (0 for single-port adapters)
"""
if self.ubridge:
bridge_name = f"bridge{adapter_number}"
bridge_name = self._bridge_name(adapter_number, port_number)
if bridge_name in self._bridges:
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
if self.status == "started":
await self._set_adapter_carrier(adapter_number, not nio.suspend)
await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number)
async def adapter_remove_nio_binding(self, adapter_number):
async def adapter_remove_nio_binding(self, adapter_number, port_number=0):
"""
Removes an adapter NIO binding.
:param adapter_number: adapter number
:param port_number: port number on the adapter (0 for single-port adapters)
:returns: NIO instance
"""
@ -1712,20 +1752,20 @@ class DockerVM(BaseNode):
)
)
await self.stop_capture(adapter_number)
await self.stop_capture(adapter_number, port_number)
if self.ubridge:
nio = adapter.get_nio(0)
bridge_name = f"bridge{adapter_number}"
nio = adapter.get_nio(port_number)
bridge_name = self._bridge_name(adapter_number, port_number)
if self.status == "started":
await self._set_adapter_carrier(adapter_number, False)
await self._set_adapter_carrier(adapter_number, False, port_number)
await self._ubridge_send(f"bridge stop {bridge_name}")
await self._ubridge_send(
"bridge remove_nio_udp bridge{adapter} {lport} {rhost} {rport}".format(
adapter=adapter_number, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
"bridge remove_nio_udp {bridge_name} {lport} {rhost} {rport}".format(
bridge_name=bridge_name, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
)
)
adapter.remove_nio(0)
adapter.remove_nio(port_number)
log.debug(
"Docker VM '{name}' [{id}]: {nio} removed from adapter {adapter_number}".format(
@ -1733,11 +1773,12 @@ class DockerVM(BaseNode):
)
)
def get_nio(self, adapter_number):
def get_nio(self, adapter_number, port_number=0):
"""
Gets an adapter NIO binding.
:param adapter_number: adapter number
:param port_number: port number on the adapter (0 for single-port adapters)
:returns: NIO instance
"""
@ -1751,10 +1792,10 @@ class DockerVM(BaseNode):
)
)
nio = adapter.get_nio(0)
nio = adapter.get_nio(port_number)
if not nio:
raise DockerError(f"Adapter {adapter_number} is not connected")
raise DockerError(f"Adapter {adapter_number} port {port_number} is not connected")
return nio
@ -1800,46 +1841,49 @@ class DockerVM(BaseNode):
await self.manager.pull_image(image, progress_callback=callback)
async def _start_ubridge_capture(self, adapter_number, output_file):
async def _start_ubridge_capture(self, adapter_number, output_file, port_number=0):
"""
Starts a packet capture in uBridge.
:param adapter_number: adapter number
:param output_file: PCAP destination file for the capture
:param port_number: port number on the adapter (0 for single-port adapters)
"""
adapter = f"bridge{adapter_number}"
bridge_name = self._bridge_name(adapter_number, port_number)
if not self.ubridge:
raise DockerError("Cannot start the packet capture: uBridge is not running")
await self._ubridge_send(f'bridge start_capture {adapter} "{output_file}"')
await self._ubridge_send(f'bridge start_capture {bridge_name} "{output_file}"')
async def _stop_ubridge_capture(self, adapter_number):
async def _stop_ubridge_capture(self, adapter_number, port_number=0):
"""
Stops a packet capture in uBridge.
:param adapter_number: adapter number
:param port_number: port number on the adapter (0 for single-port adapters)
"""
adapter = f"bridge{adapter_number}"
bridge_name = self._bridge_name(adapter_number, port_number)
if not self.ubridge:
raise DockerError("Cannot stop the packet capture: uBridge is not running")
await self._ubridge_send(f"bridge stop_capture {adapter}")
await self._ubridge_send(f"bridge stop_capture {bridge_name}")
async def start_capture(self, adapter_number, output_file):
async def start_capture(self, adapter_number, output_file, port_number=0):
"""
Starts a packet capture.
:param adapter_number: adapter number
:param output_file: PCAP destination file for the capture
:param port_number: port number on the adapter (0 for single-port adapters)
"""
nio = self.get_nio(adapter_number)
nio = self.get_nio(adapter_number, port_number)
if nio.capturing:
raise DockerError(f"Packet capture is already activated on adapter {adapter_number}")
nio.start_packet_capture(output_file)
if self.status == "started" and self.ubridge:
await self._start_ubridge_capture(adapter_number, output_file)
await self._start_ubridge_capture(adapter_number, output_file, port_number)
log.debug(
"Docker VM '{name}' [{id}]: starting packet capture on adapter {adapter_number}".format(
@ -1847,19 +1891,20 @@ class DockerVM(BaseNode):
)
)
async def stop_capture(self, adapter_number):
async def stop_capture(self, adapter_number, port_number=0):
"""
Stops a packet capture.
:param adapter_number: adapter number
:param port_number: port number on the adapter (0 for single-port adapters)
"""
nio = self.get_nio(adapter_number)
nio = self.get_nio(adapter_number, port_number)
if not nio.capturing:
return
nio.stop_packet_capture()
if self.status == "started" and self.ubridge:
await self._stop_ubridge_capture(adapter_number)
await self._stop_ubridge_capture(adapter_number, port_number)
log.debug(
"Docker VM '{name}' [{id}]: stopping packet capture on adapter {adapter_number}".format(

View File

@ -0,0 +1,375 @@
#
# 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 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 controller allocates the
IOL application ID (upper half of the id space, disjoint from IOU's) so that
linked nodes get distinct MACs; starting a node without an allocation is an
error, not a fallback — an uncoordinated id could collide with the pool and
blackhole traffic as a MAC loop.
This class is selected by the ``GNS3_IOL_RUNNER=1`` environment marker.
"""
import contextlib
import glob
import json
import logging
import os
import re
import shutil
from gns3server.compute.adapters.ethernet_adapter import EthernetAdapter
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
from gns3server.compute.iou.utils.iou_export import nvram_export
from gns3server.compute.iou.utils.iou_import import nvram_import
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/run`` persistent volumes, so a
template containing only ``GNS3_IOL_RUNNER=1`` is fully configured.
Startup configuration follows the IOU model: a template may reference a
config file with the ``GNS3_IOL_STARTUP_CONFIG`` environment knob; the
controller materializes the file content into ``startup_config_content``
when the node is created. The content is built into the node's NVRAM
(``tmp/run/nvram_<app id>``) on the next start — IOL boots straight from
NVRAM, so a plain stop/start never re-applies it and ``write memory``
survives restarts.
"""
_IOL_CONFIG_DIR = "/config"
_IOL_RUN_DIR = "/tmp/run"
# The runner launches IOL with a fixed 256KB nvram (-n 256)
_IOL_NVRAM_SIZE_KB = 256
# Payload-delivered state. Deliberately NOT initialized in
# _parse_vendor_environment(): create() re-runs that parser on every
# (re)create (a stop removes the container, so every start recreates it)
# to pick up environment changes — resetting these there would lose the
# controller-allocated application id (MACs would flip to the fallback
# hash, colliding with the allocation pool) and any pending
# startup-config delivered by a PUT.
_application_id = None
_startup_config_content = None
_startup_config_dirty = False
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
@property
def application_id(self) -> int:
"""
IOL application ID: drives interface MACs (aabb.cc{app}{iface}) and
the NVRAM file name. Allocated by the controller from the IOL Docker
half of the id space (disjoint from IOU's) — there is deliberately
no fallback: an id derived any other way could silently collide with
an allocation and blackhole traffic as a MAC loop. Starting a node
without one raises (see _prepare_iol_runtime).
"""
return self._application_id
@application_id.setter
def application_id(self, value) -> None:
self._application_id = int(value)
@property
def startup_config_content(self):
"""
Startup-config content, delivered by the controller when the node is
created from a template carrying GNS3_IOL_STARTUP_CONFIG (or updated
with new content).
"""
return self._startup_config_content
@startup_config_content.setter
def startup_config_content(self, content):
"""
Record new startup-config content; it is built into the node's NVRAM
on the next start. IOL boots from NVRAM whenever it holds a config, so
the content is only pushed when it actually changes — a plain
stop/start never re-applies it and `write memory` survives restarts.
"""
if not content or content == self._startup_config_content:
# An empty value is ignored: erasing the config is not supported
# (mirrors the IOU setter) and Web clients PUT "" for unset fields.
return
self._startup_config_content = content
self._startup_config_dirty = True
def _iol_nvram_file(self) -> str:
"""
The IOL NVRAM file inside the persistent /tmp/run volume. The name
embeds the application id (nvram_00772-style, like CML).
"""
return os.path.join(self.working_dir, "tmp", "run", f"nvram_{self.application_id:05d}")
def _apply_pending_startup_config(self) -> None:
"""
Build the node's NVRAM from the recorded startup-config content. IOL
and IOU share the same NVRAM container format (startup-config stored
as text inside the nvram file system), so the IOU nvram_import
utility produces a file IOL boots from directly — valid config, no
initial configuration dialog.
"""
content = self._startup_config_content.replace("%h", self._name)
nvram_file = self._iol_nvram_file()
os.makedirs(os.path.dirname(nvram_file), exist_ok=True)
try:
nvram = nvram_import(None, content.encode("utf-8"), None, self._IOL_NVRAM_SIZE_KB)
with open(nvram_file, "wb") as f:
f.write(nvram)
except (OSError, ValueError) as e:
raise DockerError(f"Could not write IOL startup-config to NVRAM of container '{self._name}': {e}")
log.debug("IOL container '%s': startup-config written to %s", self._name, nvram_file)
@DockerVM.name.setter
def name(self, new_name):
"""
Override: keep the hostname line inside the NVRAM in sync with the
node name (IOU parity), so a renamed or duplicated node boots under
its new name. Skipped while a content change is pending — the next
start pushes the new content with the already-updated name.
"""
if not self._startup_config_dirty and self._application_id is not None:
nvram_file = self._iol_nvram_file()
if os.path.exists(nvram_file):
try:
with open(nvram_file, "rb") as f:
startup_config, _ = nvram_export(f.read())
if startup_config:
content = re.sub(
r"hostname .+$",
"hostname " + new_name,
startup_config.decode("utf-8", errors="replace"),
flags=re.MULTILINE,
)
nvram = nvram_import(None, content.encode("utf-8"), None, self._IOL_NVRAM_SIZE_KB)
with open(nvram_file, "wb") as f:
f.write(nvram)
except (OSError, ValueError) as e:
log.warning(f"Could not update hostname in NVRAM of IOL container '{self._name}': {e}")
super(IOLDockerVM, IOLDockerVM).name.__set__(self, new_name)
def asdict(self):
"""
Override: expose the recorded startup-config content (empty for nodes
created before the knob existed or reloaded from a topology).
"""
result = super().asdict()
result["startup_config_content"] = self._startup_config_content
return result
@DockerVM.adapters.setter
def adapters(self, adapters):
"""
Override: one IOL adapter is a 4-port unit — the IOU model. The
template's adapter count is the number of units (2 adapters =
Ethernet0/0-3 + Ethernet1/0-3); the generated config asks the runner
for adapters × 4 interfaces and links address ports as
(adapter_number, port_number 0-3).
"""
if len(self._ethernet_adapters) == adapters:
return
self._ethernet_adapters.clear()
for _ in range(0, adapters):
self._ethernet_adapters.append(EthernetAdapter(interfaces=4))
log.debug(
"IOL container '%s': number of 4-port Ethernet adapters set to %d",
self._name, adapters,
)
def _persistent_volume_list(self, image_info, include_network_config=True):
"""
Override: the runner requires ``/config`` (its config file, generated
below) and ``/tmp/run`` (its working directory: startup-config and
NVRAM live there — NETMAP and the netiomux sockets are ephemeral and
stay in the container's own /tmp). 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, self._IOL_RUN_DIR):
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.
* Sockets and netio bus directories left in the wiring directory by a
previous (possibly SIGKILLed) run are removed — the runner rebinds
them on boot and would fail on a stale file.
``tmp/run`` (startup-config, NVRAM) is never touched. Neither is
anything while the container is already running (idempotent start of
a live node: the sockets belong to the running runner).
"""
try:
state = await self._get_container_state()
except DockerHttp404Error:
state = "stopped"
if self._application_id is None:
raise DockerError(
f"IOL container '{self._name}' has no application ID: nodes must be "
"created through the controller (which allocates one from the pool "
"shared with IOU), or created with an explicit application_id "
"(512-1022) on the compute API. Without a coordinated ID two nodes "
"would share MACs and drop each other's frames as loops."
)
os.makedirs(os.path.join(self.working_dir, "tmp", "run"), exist_ok=True)
self._write_iol_config()
if state == "running":
return
# Pending startup-config is materialized here rather than in the
# property setter: at create-payload time the application id may not
# be final yet (the create route applies fields in schema order) and
# a running container must not have its NVRAM swapped mid-flight.
if self._startup_config_dirty:
self._apply_pending_startup_config()
self._startup_config_dirty = False
wiring_dir = self._unix_socket_wiring_dir()
for pattern in ("s??.sock", "c??.sock"):
for stale in glob.glob(os.path.join(wiring_dir, pattern)):
with contextlib.suppress(OSError):
os.unlink(stale)
for netio_dir in glob.glob(os.path.join(wiring_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 is owned by the server user — which is also what lets the
(unprivileged) uBridge write into the node's socket directory.
"""
config = {
"binary": "/binary.iol",
"memory": self._iol_memory,
"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.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(),
}
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

View File

@ -32,7 +32,9 @@ import json
import logging
import os
import shutil
import tempfile
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 +66,23 @@ 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 the container —
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``.
Unless the directory is already covered by a persisted volume, a
per-node directory from the runtime directory is bind-mounted there —
owned by the server user (such agents drop privileges and cannot write
into a root-owned directory) and short enough for an AF_UNIX path,
which a node directory inside the projects tree exceeds. 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``).
"""
def __init__(self, *args, **kwargs):
@ -86,6 +105,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 +120,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 +169,22 @@ 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
):
# The image's network agent drops privileges before using the
# socket directory, so it must be writable by the server user:
# bind a per-node directory from the runtime directory (see
# _unix_socket_host_dir).
binds.append({
"Type": "bind",
"Source": self._unix_socket_host_dir(),
"Target": socket_dir,
"BindOptions": {"Propagation": "rprivate"},
})
if self._gns3_init:
return binds
binds = [b for b in binds if b.get("Target") != "/gns3volumes/etc/network"]
@ -284,6 +327,133 @@ class VendorDockerVM(DockerVM):
return self._interface_names[adapter_number]
return f"eth{adapter_number}"
def _unix_socket_host_dir(self):
"""
Host-side directory bind-mounted at the in-container socket directory
when the latter is not already covered by a persisted volume: a
per-node directory under the runtime directory, next to the uBridge
control sockets.
AF_UNIX paths are capped at 107 bytes (sun_path minus the NUL) — a
node directory inside the projects tree (projects/<uuid>/
project-files/docker/<uuid>/tmp) alone is ~120, so uBridge would
reject the NIO with "invalid file path size". The runtime directory
path stays short no matter where the projects live, and being owned
by the server user, the image's privilege-dropping agent can write
to it.
"""
runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir()
host_dir = os.path.join(runtime_dir, "gns3", "unixio", self.id)
try:
os.makedirs(host_dir, mode=0o700, exist_ok=True)
except OSError as e:
raise DockerError(
f"Could not create unix-socket directory '{host_dir}' for container '{self._name}': {e}"
)
return host_dir
def _remove_unix_socket_host_dir(self):
"""Best-effort removal of the per-node unix-socket directory."""
if self._unix_socket_nio:
shutil.rmtree(self._unix_socket_host_dir(), ignore_errors=True)
def _unix_socket_wiring_dir(self):
"""
Directory referenced in the uBridge unix-NIO commands: the persisted
volume holding the socket directory, or the per-node runtime
directory bound there by _mount_binds.
"""
socket_dir = self._unix_socket_dir.rstrip("/")
for volume in self._volumes:
if socket_dir == volume.rstrip("/") or socket_dir.startswith(volume.rstrip("/") + "/"):
return os.path.join(self.working_dir, os.path.relpath(socket_dir, "/"))
return self._unix_socket_host_dir()
async def delete(self):
# The per-node socket directory is ephemeral; the node is not.
await super().delete()
self._remove_unix_socket_host_dir()
async def _add_ubridge_connection(self, nio, adapter_number, port_number=0):
"""
Override: with GNS3_UNIX_SOCKET_NIO, bridge the adapter port through
the image's AF_UNIX datagram socket pair (raw Ethernet frames)
instead of a TAP interface moved into the container's network
namespace.
Ports are addressed flat across adapters: interface index = adapter
number × ports-per-adapter + port number (single-port adapters
reduce to the adapter number). Per interface N the image's network
agent is expected to create, inside GNS3_UNIX_SOCKET_DIR:
* ``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 (both on the host side of the socket directory — see
_unix_socket_wiring_dir). 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, port_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
)
)
interface_number = adapter_number * adapter.interfaces + port_number
bridge_name = self._bridge_name(adapter_number, port_number)
try:
await self._ubridge_send(f"bridge create {bridge_name}")
self._bridges.add(bridge_name)
wiring_dir = self._unix_socket_wiring_dir()
local_sock = os.path.join(wiring_dir, f"c{interface_number:02d}.sock")
remote_sock = os.path.join(wiring_dir, f"s{interface_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"its network agent creates the per-adapter socket pair in "
f"'{self._unix_socket_dir}'."
)
await self._ubridge_send(f'bridge add_nio_unix {bridge_name} "{local_sock}" "{remote_sock}"')
except Exception:
# A half-wired bridge would make the next start fail with
# "bridge already exist": uBridge stops with the failed start.
await self._stop_ubridge()
raise
adapter.host_ifc = local_sock # bookkeeping / removal logging only
log.debug(
"Adapter %d port %d of container '%s' wired via unix sockets %s <-> %s",
adapter_number, port_number, self._name, local_sock, remote_sock,
)
if nio:
await self._connect_nio(adapter_number, nio, port_number)
def _cleanup_console_resources(self):
"""
Override: close the docker-exec pty socket, if any, so the next

View File

@ -0,0 +1,16 @@
!
service timestamps debug datetime msec
service timestamps log datetime msec
!
hostname %h
!
no ip domain lookup
!
ip tcp synwait-time 5
!
line con 0
exec-timeout 0 0
privilege level 15
logging synchronous
!
end

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
@ -39,6 +40,30 @@ import logging
log = logging.getLogger(__name__)
def _extract_iol_startup_config_knob(environment):
"""
Split the GNS3_IOL_STARTUP_CONFIG knob out of a Docker environment
(iol-runner images): the value is a config file name, resolved by the
controller in its configs directory.
:returns: (filename, environment without the knob line); filename is None
when the knob is absent or empty.
"""
if not environment or "GNS3_IOL_STARTUP_CONFIG=" not in environment:
return None, environment
filename = None
kept = []
for line in environment.splitlines():
stripped = line.strip().rstrip(",")
if stripped.startswith("GNS3_IOL_STARTUP_CONFIG="):
if filename is None:
filename = stripped.split("=", 1)[1].strip()
else:
kept.append(line)
return filename or None, "\n".join(kept)
class Node:
# These properties are used only on controller and are not forwarded to the compute
CONTROLLER_ONLY_PROPERTIES = [
@ -572,6 +597,25 @@ class Node:
data[v] = self._base_config_file_content(self._properties[k])
del data[k]
del self._properties[k] # We send the file only one time
# IOL runner (Docker) startup-config: translate the config file
# referenced by the GNS3_IOL_STARTUP_CONFIG environment knob into
# content, like the mappings above. Sent only once — afterwards
# the node's configuration lives in its NVRAM on the compute (a
# reloaded node is recreated without the knob and boots from the
# persistent NVRAM, preserving `write memory`).
if self._node_type == "docker":
filename, environment = _extract_iol_startup_config_knob(self._properties.get("environment"))
if filename:
content = self._base_config_file_content(filename)
if content:
data["startup_config_content"] = content
data["environment"] = environment
self._properties["environment"] = environment
else:
log.warning(
f"Cannot load IOL startup-config file '{filename}' for node '{self._name}': file not found"
)
data["name"] = self._name
# For remote computes, convert absolute image paths to relative paths
@ -804,22 +848,35 @@ class Node:
self._ports = DynamipsPortFactory(self._properties)
return
elif self._node_type == "docker":
for adapter_number in range(0, self._properties["adapters"]):
custom_adapter_settings = {}
if self.custom_adapters:
for custom_adapter in self.custom_adapters:
if custom_adapter["adapter_number"] == adapter_number:
custom_adapter_settings = custom_adapter
break
port_name = f"eth{adapter_number}"
port_name = custom_adapter_settings.get("port_name", port_name)
mac_address = custom_adapter_settings.get("mac_address")
if not mac_address and "mac_address" in self._properties:
mac_address = int_to_macaddress(macaddress_to_int(self._properties["mac_address"]) + adapter_number)
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).
self._ports = StandardPortFactory(
self._properties,
4,
self._first_port_name,
"Ethernet{segment0}/{port0}",
4,
self.custom_adapters,
)
else:
for adapter_number in range(0, self._properties["adapters"]):
custom_adapter_settings = {}
if self.custom_adapters:
for custom_adapter in self.custom_adapters:
if custom_adapter["adapter_number"] == adapter_number:
custom_adapter_settings = custom_adapter
break
port_name = f"eth{adapter_number}"
port_name = custom_adapter_settings.get("port_name", port_name)
mac_address = custom_adapter_settings.get("mac_address")
if not mac_address and "mac_address" in self._properties:
mac_address = int_to_macaddress(macaddress_to_int(self._properties["mac_address"]) + adapter_number)
port = PortFactory(port_name, 0, adapter_number, 0, "ethernet", short_name=port_name)
port.mac_address = mac_address
self._ports.append(port)
port = PortFactory(port_name, 0, adapter_number, 0, "ethernet", short_name=port_name)
port.mac_address = mac_address
self._ports.append(port)
elif self._node_type in ("ethernet_switch", "ethernet_hub"):
# Basic node we don't want to have adapter number
port_number = 0

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
@ -657,7 +672,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
@ -669,6 +684,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

@ -26,7 +26,7 @@ class DockerBase(BaseModel):
Common Docker node properties.
"""
@field_validator("start_command", "environment", "extra_hosts", mode="before")
@field_validator("start_command", "environment", "extra_hosts", "startup_config_content", mode="before")
@classmethod
def _empty_string_to_none(cls, value):
# Web clients serialize empty form fields as "" while unset values are
@ -59,6 +59,9 @@ class DockerBase(BaseModel):
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_configs: Optional[List[ExtraConfig]] = Field(None, description="Configuration files injected into the container (bind-mounted read-only)")
startup_config_content: Optional[str] = Field(
None, description="Startup-config content (IOL runner images: materialized into the node's NVRAM at start)"
)
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")
custom_adapters: Optional[List[CustomAdapter]] = Field(None, description="Custom adapters")
@ -69,7 +72,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

@ -20,9 +20,11 @@ import pytest_asyncio
from fastapi import FastAPI, status
from httpx import AsyncClient
from tests.utils import asyncio_patch
from unittest.mock import patch
from tests.utils import asyncio_patch, AsyncioMagicMock
from unittest.mock import patch, MagicMock
from gns3server.compute.docker import Docker
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
from gns3server.compute.project import Project
pytestmark = pytest.mark.asyncio
@ -98,6 +100,46 @@ class TestDockerNodesRoutes:
assert response.json()["extra_hosts"] == "test:127.0.0.1"
async def test_docker_create_iol_startup_config(
self, app: FastAPI,
compute_client: AsyncClient,
compute_project: Project,
base_params: dict
) -> None:
"""
The controller materializes the GNS3_IOL_STARTUP_CONFIG knob into
startup_config_content; the create route must carry it onto the
IOLDockerVM (plain Docker nodes have no such property and skip it).
"""
params = dict(base_params)
params["image"] = "iol-xe/iol-xe:17-18-02"
params["environment"] = "GNS3_IOL_RUNNER=1"
params["startup_config_content"] = "hostname %h\n"
create_response = {
"Id": "8bd8153ea8f5",
"Warnings": [],
"Config": {
"Entrypoint": ["/iol-runner", "-config", "/config/iol-config.json", "-stdio"],
"Cmd": [],
"Volumes": {},
},
}
seed_proc = MagicMock()
seed_proc.communicate = AsyncioMagicMock(return_value=(b"seedcid\n", b""))
seed_proc.returncode = 0
with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "iol-xe/iol-xe"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=create_response):
with patch("asyncio.subprocess.create_subprocess_exec", return_value=seed_proc):
response = await compute_client.post(
app.url_path_for("compute:create_docker_node", project_id=compute_project.id), json=params
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["startup_config_content"] == "hostname %h\n"
vm = Docker.instance().get_node(response.json()["node_id"], project_id=str(compute_project.id))
assert isinstance(vm, IOLDockerVM)
assert vm.startup_config_content == "hostname %h\n"
@pytest.mark.parametrize(
"name, status_code",
(

View File

@ -1198,7 +1198,7 @@ async def test_start(vm, manager, free_console_port, tmpdir):
await vm.start()
mock_query.assert_called_with("POST", "containers/e90e34656842/start")
vm._add_ubridge_connection.assert_called_once_with(nio, 0)
vm._add_ubridge_connection.assert_called_once_with(nio, 0, 0)
assert vm._start_ubridge.called
assert vm._start_console.called
assert vm._start_aux.called
@ -1253,7 +1253,7 @@ async def test_start_namespace_failed(vm, manager, free_console_port):
await vm.start()
mock_query.assert_any_call("POST", "containers/e90e34656842/start")
mock_add_ubridge_connection.assert_called_once_with(nio, 0)
mock_add_ubridge_connection.assert_called_once_with(nio, 0, 0)
assert mock_start_ubridge.called
assert vm.status == "stopped"

View File

@ -0,0 +1,760 @@
#
# 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
from gns3server.compute.iou.utils.iou_export import nvram_export
from gns3server.compute.iou.utils.iou_import import nvram_import
# ---------------------------------------------------------------------------
# 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"
# mirrors the controller flow, which always delivers an allocated id
vm.application_id = 700
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
@pytest.fixture(autouse=True)
def runtime_dir(tmp_path, monkeypatch):
"""
Point the unix-socket runtime directory at a per-test temporary path so
the wiring/mount tests never create per-node directories in the real one.
"""
rt = tmp_path / "run"
monkeypatch.setenv("XDG_RUNTIME_DIR", str(rt))
return rt
def _mock_wiring(vm):
"""Mock everything _add_ubridge_connection's unix-NIO path needs."""
vm._ubridge_hypervisor = MagicMock()
def _wiring_dir(vm):
"""The per-node socket directory this VM wires through."""
return os.path.join(os.environ["XDG_RUNTIME_DIR"], "gns3", "unixio", vm.id)
# ---------------------------------------------------------------------------
# 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_run_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"]
mounts = sent["HostConfig"]["Mounts"]
targets = [m["Target"] for m in mounts if m.get("Type") == "bind"]
# /config (runner config) and /tmp/run (startup-config + NVRAM)
# are forced and bound at their real in-container paths
# (skip-init retargeting); /tmp is the ephemeral runtime-dir
# bind holding the netiomux sockets
assert "/config" in targets
assert "/tmp/run" in targets
tmp_mounts = [m for m in mounts if m["Target"] == "/tmp"]
assert len(tmp_mounts) == 1
assert tmp_mounts[0]["Source"] == _wiring_dir(vm)
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/run" 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"] == 16 # 4 adapters, each a 4-port unit
assert config["num-serial"] == 0
assert config["local-app"] == 700
assert config["remote-app"] == 1023
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_missing_application_id_is_an_actionable_error(compute_project, manager):
# There is no fallback id on purpose: an uncoordinated one could collide
# with a pool allocation and blackhole traffic as a MAC loop. Nodes come
# through the controller, which always allocates.
vm = _make_vm(compute_project, manager)
vm._application_id = None
vm._get_container_state = AsyncioMagicMock(return_value="stopped")
with pytest.raises(DockerError, match="no application ID"):
await vm._prepare_iol_runtime()
@pytest.mark.asyncio
async def test_allocated_application_id_used(compute_project, manager):
# The controller allocates the application ID (upper half of the id
# space, disjoint from IOU's); the node uses it verbatim.
vm = _make_vm(compute_project, manager)
vm.application_id = 701
assert vm.application_id == 701
_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"] == 701
@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"] == 32 # 8 adapters × 4 ports
@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)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
for name in ("s00.sock", "c00.sock", "s01.sock", "c01.sock"):
open(os.path.join(wiring_dir, name), "w").close()
os.makedirs(os.path.join(wiring_dir, "netio1000"))
run_dir = os.path.join(vm.working_dir, "tmp", "run")
os.makedirs(run_dir, exist_ok=True)
open(os.path.join(run_dir, "nvram_00001"), "w").close()
open(os.path.join(run_dir, "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(wiring_dir, "s??.sock")) == []
assert glob.glob(os.path.join(wiring_dir, "c??.sock")) == []
assert not os.path.exists(os.path.join(wiring_dir, "netio1000"))
# the persistent runtime survives the cleanup
assert os.path.exists(os.path.join(run_dir, "nvram_00001"))
assert os.path.exists(os.path.join(run_dir, "config"))
@pytest.mark.asyncio
async def test_start_skips_cleanup_when_already_running(compute_project, manager):
vm = _make_vm(compute_project, manager)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
open(os.path.join(wiring_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(wiring_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)
_mock_wiring(vm)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
# the runner's receive socket must exist (created by the container)
open(os.path.join(wiring_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)
local_sock = os.path.join(wiring_dir, "c00.sock")
remote_sock = os.path.join(wiring_dir, "s00.sock")
assert call.send("bridge create bridge0") in sent
assert call.send(f'bridge add_nio_unix bridge0 "{local_sock}" "{remote_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 == local_sock
@pytest.mark.asyncio
async def test_add_ubridge_connection_stale_local_socket_unlinked(compute_project, manager):
vm = _make_vm(compute_project, manager)
_mock_wiring(vm)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
open(os.path.join(wiring_dir, "c00.sock"), "w").close()
open(os.path.join(wiring_dir, "s00.sock"), "w").close()
await vm._add_ubridge_connection(None, 0)
assert not os.path.exists(os.path.join(wiring_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)
_mock_wiring(vm)
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)
_mock_wiring(vm)
vm._stop_ubridge = AsyncioMagicMock()
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 exact wiring path
assert "adapter 0" in str(excinfo.value)
assert "s00.sock" in str(excinfo.value)
# uBridge must not survive a half-wired bridge: the retry would fail
# with "bridge already exist"
assert vm._stop_ubridge.called
@pytest.mark.asyncio
async def test_add_ubridge_connection_without_nio_still_wires(compute_project, manager):
vm = _make_vm(compute_project, manager)
_mock_wiring(vm)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
open(os.path.join(wiring_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
# ---------------------------------------------------------------------------
# IOU-style port model — 1 adapter = 4 ethernet ports
# ---------------------------------------------------------------------------
def test_adapters_are_four_port_units(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=2)
assert vm.adapters == 2
assert len(vm._ethernet_adapters) == 2
for adapter in vm._ethernet_adapters:
assert adapter.interfaces == 4
for port_number in range(4):
assert adapter.port_exists(port_number)
assert not adapter.port_exists(4)
@pytest.mark.asyncio
async def test_wiring_addresses_ports_within_adapters(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=2)
_mock_wiring(vm)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
# adapter 1, port 2 -> flat interface index 6 (1 * 4 + 2)
open(os.path.join(wiring_dir, "s06.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, 1, port_number=2)
flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls)
assert 'bridge add_nio_unix bridge1_2 ' in flat
assert f'"{os.path.join(wiring_dir, "c06.sock")}"' in flat
assert f'"{os.path.join(wiring_dir, "s06.sock")}"' in flat
assert "add_nio_udp bridge1_2 4242 127.0.0.1 4343" in flat
assert vm._ethernet_adapters[1].host_ifc == os.path.join(wiring_dir, "c06.sock")
@pytest.mark.asyncio
async def test_nio_binding_rejects_port_out_of_range(compute_project, manager):
vm = _make_vm(compute_project, manager, adapters=2)
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
with pytest.raises(DockerError) as excinfo:
await vm.adapter_add_nio_binding(1, nio, port_number=4)
assert "Port 4" in str(excinfo.value)
with pytest.raises(DockerError):
await vm.adapter_add_nio_binding(9, nio, port_number=0)
# ---------------------------------------------------------------------------
# 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
def test_unix_socket_dir_bound_from_runtime_dir(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=[])
# the socket directory is an ephemeral per-node directory from the
# runtime dir, not a volume: writable by the (unprivileged) agent and
# short enough for AF_UNIX
binds = vm._mount_binds({"Config": {"Volumes": {}}})
socket_binds = [b for b in binds if b.get("Target") == "/tmp"]
assert len(socket_binds) == 1
assert socket_binds[0]["Type"] == "bind"
assert socket_binds[0]["Source"] == _wiring_dir(vm)
# a socket dir already covered by a persisted volume gets no extra bind
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 not any(b.get("Source") == _wiring_dir(vm) for b in binds)
assert any(b.get("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")
_mock_wiring(vm)
wiring_dir = _wiring_dir(vm)
os.makedirs(wiring_dir, exist_ok=True)
open(os.path.join(wiring_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(wiring_dir, "s00.sock")}"' in flat
assert "add_nio_tap" not in flat
# ---------------------------------------------------------------------------
# Startup configuration
# ---------------------------------------------------------------------------
def _nvram_path(vm):
return os.path.join(vm.working_dir, "tmp", "run", f"nvram_{vm.application_id:05d}")
def _read_nvram(vm):
with open(_nvram_path(vm), "rb") as f:
return f.read()
async def _start(vm, state="stopped"):
_mock_start(vm, state=state)
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
@pytest.mark.asyncio
async def test_startup_config_built_into_nvram_at_start(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname %h\ninterface Ethernet0/0\n ip address dhcp\n no shutdown"
await _start(vm)
startup, _ = nvram_export(_read_nvram(vm))
content = startup.decode("utf-8")
# %h is substituted with the node name, like the IOU startup-config
assert f"hostname {vm.name}" in content
assert "ip address dhcp" in content
assert vm._startup_config_dirty is False # consumed
@pytest.mark.asyncio
async def test_plain_restart_never_reapplies_startup_config(compute_project, manager):
# IOL boots from NVRAM whenever it holds a config: a plain stop/start must
# not rebuild it or `write memory` done in the router would be lost.
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname RouterOne"
await _start(vm)
# simulate `write memory`: IOS rewrites the NVRAM behind our back
with open(_nvram_path(vm), "wb") as f:
f.write(nvram_import(None, b"hostname RouterSaved\n", None, 256))
await _start(vm) # plain restart, no new content delivered
startup, _ = nvram_export(_read_nvram(vm))
assert b"hostname RouterSaved" in startup
@pytest.mark.asyncio
async def test_changed_content_reapplied_at_next_start(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname RouterOne"
await _start(vm)
vm.startup_config_content = "hostname RouterTwo"
assert vm._startup_config_dirty is True
await _start(vm)
startup, _ = nvram_export(_read_nvram(vm))
assert b"hostname RouterTwo" in startup
@pytest.mark.asyncio
async def test_empty_content_never_builds_nvram(compute_project, manager):
# Web clients PUT "" for unset fields: must be ignored, not treated as a
# config change (mirrors the IOU setter's erase protection).
vm = _make_vm(compute_project, manager)
vm.startup_config_content = ""
vm.startup_config_content = None
await _start(vm)
assert not os.path.exists(_nvram_path(vm))
@pytest.mark.asyncio
async def test_reput_unchanged_content_keeps_written_nvram(compute_project, manager):
# A full PUT that carries the same content again (WebUI round-trips what
# the GET returned) must not re-apply it over `write memory`.
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname RouterOne"
await _start(vm)
with open(_nvram_path(vm), "wb") as f: # simulate `write memory`
f.write(nvram_import(None, b"hostname RouterSaved\n", None, 256))
vm.startup_config_content = "hostname RouterOne" # unchanged re-PUT
await _start(vm)
startup, _ = nvram_export(_read_nvram(vm))
assert b"hostname RouterSaved" in startup
@pytest.mark.asyncio
async def test_running_container_keeps_pending_config(compute_project, manager):
# The NVRAM is only (re)built on a genuine start of a stopped container:
# swapping it mid-flight would be lost to the next `write memory`.
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname RouterOne"
await _start(vm, state="running")
assert not os.path.exists(_nvram_path(vm))
assert vm._startup_config_dirty is True
@pytest.mark.asyncio
async def test_rename_rewrites_hostname_in_nvram(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm.startup_config_content = "hostname %h"
await _start(vm)
vm.name = "renamed-1" # IOU parity: the node boots under its new name
startup, _ = nvram_export(_read_nvram(vm))
assert b"hostname renamed-1" in startup
def test_asdict_exposes_startup_config_content(compute_project, manager):
vm = _make_vm(compute_project, manager)
assert vm.asdict()["startup_config_content"] is None
vm.startup_config_content = "hostname RouterOne"
assert vm.asdict()["startup_config_content"] == "hostname RouterOne"
def test_reparse_on_recreate_keeps_payload_state(compute_project, manager):
# create() re-runs _parse_vendor_environment on every (re)create (a stop
# removes the container, so every start recreates it): payload-delivered
# state must survive the re-parse. Losing the allocated application id
# would flip MACs to the fallback hash (colliding with the allocation
# pool); losing the startup-config would drop pending PUT edits.
vm = _make_vm(compute_project, manager)
vm.application_id = 700
vm.startup_config_content = "hostname RouterOne"
vm._parse_vendor_environment()
assert vm.application_id == 700
assert vm.startup_config_content == "hostname RouterOne"
assert vm._startup_config_dirty is True
# environment-derived state still re-derives
assert vm._gns3_init is False and vm._unix_socket_nio is True

View File

@ -462,6 +462,9 @@ async def test_install_base_configs(controller, config, tmpdir):
await controller._install_base_configs()
assert os.path.exists(str(tmpdir / 'iou_l3_base_startup-config.txt'))
# the IOL docker base config ships with the server too (referenced by the
# GNS3_IOL_STARTUP_CONFIG knob in the documented template)
assert os.path.exists(str(tmpdir / 'iol-xe-base.txt'))
# Check is the file has not been overwritten
with open(str(tmpdir / 'iou_l2_base_startup-config.txt')) as f:

View File

@ -46,6 +46,85 @@ def node(compute, project):
return node
def test_docker_iol_ports_grouped_in_four_port_units(compute, project):
"""
IOL docker nodes (GNS3_IOL_RUNNER) model adapters as 4-port units: ports
are Ethernet0/0-3, Ethernet1/0-3, … addressed (adapter, port 0-3), like
the native IOU node type. Plain docker nodes keep the flat eth naming.
"""
node = Node(project, compute, "iol",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"adapters": 2, "environment": "GNS3_IOL_RUNNER=1"})
ports = node.ports
assert [p.asdict()["name"] for p in ports] == [
"Ethernet0/0", "Ethernet0/1", "Ethernet0/2", "Ethernet0/3",
"Ethernet1/0", "Ethernet1/1", "Ethernet1/2", "Ethernet1/3",
]
assert (ports[5].adapter_number, ports[5].port_number) == (1, 1)
assert ports[5].short_name == "e1/1"
node = Node(project, compute, "web",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"adapters": 2})
assert [p.asdict()["name"] for p in node.ports] == ["eth0", "eth1"]
def test_docker_iol_startup_config_materialized_once(compute, project, monkeypatch):
"""
The GNS3_IOL_STARTUP_CONFIG environment knob references a config file in
the controller's configs directory: like the IOU startup_config mapping,
its content is sent to the compute exactly once (the knob is consumed) —
afterwards the node's configuration lives in its NVRAM on the compute.
"""
node = Node(project, compute, "iol",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=my-iol-config.txt"})
monkeypatch.setattr(node, "_base_config_file_content", lambda path: "hostname %h\n")
data = node._node_data()
assert data["startup_config_content"] == "hostname %h\n"
assert data["environment"] == "GNS3_IOL_RUNNER=1"
assert "GNS3_IOL_STARTUP_CONFIG" not in node.properties["environment"]
# sent only once: a later sync (e.g. project reload) carries neither
data = node._node_data()
assert "startup_config_content" not in data
def test_docker_iol_startup_config_missing_file_keeps_knob(compute, project, monkeypatch):
node = Node(project, compute, "iol",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=gone.txt"})
monkeypatch.setattr(node, "_base_config_file_content", lambda path: None)
data = node._node_data()
assert "startup_config_content" not in data
# the knob is kept so the config still applies once the file shows up
assert "GNS3_IOL_STARTUP_CONFIG" in node.properties["environment"]
def test_extract_iol_startup_config_knob_variants():
from gns3server.controller.node import _extract_iol_startup_config_knob
assert _extract_iol_startup_config_knob(None) == (None, None)
assert _extract_iol_startup_config_knob("GNS3_IOL_RUNNER=1") == (None, "GNS3_IOL_RUNNER=1")
# whitespace and trailing commas are tolerated, like the compute env parsing
filename, environment = _extract_iol_startup_config_knob("GNS3_IOL_RUNNER=1,\n GNS3_IOL_STARTUP_CONFIG=cfg.txt ,")
assert filename == "cfg.txt"
assert environment == "GNS3_IOL_RUNNER=1,"
# an empty value is treated as absent
filename, environment = _extract_iol_startup_config_knob("GNS3_IOL_STARTUP_CONFIG=")
assert filename is None
def test_name(compute, project):
"""
If node use a name template generate names

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