mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-28 23:00:12 +03:00
Merge branch 'feat/iol-docker-startup-config' into local-test
This commit is contained in:
commit
1adc1b47b7
@ -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/`)
|
||||
|
||||
144
docs/design/docker-image-type.md
Normal file
144
docs/design/docker-image-type.md
Normal 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).
|
||||
218
docs/features/iol-runner-docker.md
Normal file
218
docs/features/iol-runner-docker.md
Normal 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<uid>/"| NETIOMUX --> SOCKETS
|
||||
end
|
||||
subgraph Host
|
||||
UBRIDGE["uBridge bridgeN<br/>add_nio_unix …/gns3/unixio/<node>/cNN.sock …<br/>+ add_nio_udp (topology)"]
|
||||
RTDIR["/run/user/<uid>/gns3/unixio/<node><br/>(bind-mounted at /tmp)"]
|
||||
VOL["project-files/docker/<node>/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.
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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(
|
||||
|
||||
375
gns3server/compute/docker/iol_docker_vm.py
Normal file
375
gns3server/compute/docker/iol_docker_vm.py
Normal 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
|
||||
@ -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
|
||||
|
||||
16
gns3server/configs/iol-xe-base.txt
Normal file
16
gns3server/configs/iol-xe-base.txt
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)"
|
||||
)
|
||||
|
||||
@ -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",
|
||||
(
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
760
tests/compute/docker/test_iol_docker_vm.py
Normal file
760
tests/compute/docker/test_iol_docker_vm.py
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user