diff --git a/.claude/skills/gns3-api-test-writing/SKILL.md b/.claude/skills/gns3-api-test-writing/SKILL.md index 6149bf21f..991df03f2 100644 --- a/.claude/skills/gns3-api-test-writing/SKILL.md +++ b/.claude/skills/gns3-api-test-writing/SKILL.md @@ -56,3 +56,18 @@ Always pass `secret_key=DEFAULT_JWT_SECRET_KEY`: the autouse `run_around_tests` ## Failure-Diagnosis Heuristic **Passes in isolation, fails in a class run → suspect shared fixture state first** (`base_client.headers`, the `Config` singleton, class-scoped DB rows) — never the product code. Reproduce with `-k "test_a or test_b"` pairs to find the polluting test. Do not add debug prints to product code to chase test-order issues; make the test order-independent with explicit per-request headers instead. + +## Order Independence + +The suite runs in collection order by default, and a full run is green — but that hides order dependencies. Two real incident classes so far: + +1. **The frozen from-import** (fixed 2026-09): `run_around_tests` monkeypatches `gns3server.utils.path.get_default_project_directory` with a lambda. A product module first-imported *while that patch is active* (e.g. `from gns3server.api.server import app` written inside a test body) freezes the patched lambda into its namespace forever — later tests then get a deleted tmpdir path (`FileNotFoundError` from `psutil.disk_usage`). The patched lambda now resolves `Config.instance()` at call time, so freezing is benign — keep it that way. +2. **Sequential-scenario DB tests**: `tests/api/routes/controller/test_users.py`, `test_roles.py`, `test_pools.py`, `test_templates.py`, `test_images.py`, `test_groups.py`, `test_appliances.py`, `test_acl.py` and `tests/controller/test_rbac.py` build shared rows across tests within a file (a test asserts on users/roles created by earlier tests). They are known-red under any reordering — do not copy this pattern into new files. + +### Rules for new tests + +- **Import product modules at test-module top level**, never first-import inside a test body (an autouse fixture's monkeypatches are live there, and module import executes product `from`-imports). +- **Autouse patch replacements must resolve state at call time** (`Config.instance().settings...`), never close over test-local values (tmppaths, fixture objects) — a closed-over value survives into other tests if the replacement object gets frozen anywhere. +- **Verify a new test file is order-independent**: `venv/bin/python -m pytest tests/.py --random-order --random-order-seed=1 -q` (and a second seed). It must pass shuffled. `pytest-random-order` is pinned in `dev-requirements.txt`; it is inert unless `--random-order` is passed. +- A test that needs specific rows creates them itself (or via a fixture) — never relies on rows another test in the file created. +- Diagnosing a suspected order bug: rerun the exact failing pair with the seed printed by `--random-order` (`--random-order-seed=` reproduces it), then bisect to the polluting test. diff --git a/dev-requirements.txt b/dev-requirements.txt index 55aac6eaa..80d7da3b4 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -3,4 +3,5 @@ flake8==7.3.0 pytest-timeout==2.4.0 pytest-asyncio==1.4.0 httpx==0.28.1 -httpx_ws==0.7.2 # upgrading leads to failures in tests \ No newline at end of file +httpx_ws==0.7.2 # upgrading leads to failures in tests +pytest-random-order==1.2.0 # opt-in: --random-order --random-order-bucket=global (legacy suites are not shuffle-clean yet, see .claude/skills/gns3-api-test-writing) \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index c347479cc..ea1581292 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 @@ -75,12 +77,25 @@ Web-based packet capture analysis using Docker + xpra HTML5 client. Zero-install ### Marker (Traffic Insight) (`features/marker-traffic-insight.md`) Real-time traffic insight via per-link BPF markers and project-level inherited definitions. A marker taps a link in uBridge, emitting match notifications and pcap capture on BPF hit; definitions fan out to every capable link automatically. +### Marker Tag Replay (`features/marker-tag-replay.md`) +Aggregate playback across links keyed by `tag`: once every marker under a tag is paused, their pcaps merge into one timestamp-ordered timeline; frames are decoded on demand via tshark into an isomorphic JSON protocol tree. The cross-link delta of the same packet measures the intermediate node's forwarding latency. + ### Docker exec Console (Vendor NOS) (`features/docker-exec-console.md`) Console for vendor NOS containers (SR Linux, XRd, …) whose CLI is a TUI off PID 1: runs the vendor CLI via the Docker exec API, plus `GNS3_SKIP_INIT`/`GNS3_INTERFACE_NAMES` boot knobs and SKIP_INIT volume persistence. ### 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/`) @@ -127,4 +142,4 @@ Quick-start guide for Ubuntu 24.04: install via PPA, set up dependencies, and ru --- -_Last updated: 2026-08-14_ +_Last updated: 2026-09-01_ diff --git a/docs/design/docker-image-type.md b/docs/design/docker-image-type.md new file mode 100644 index 000000000..db00cc345 --- /dev/null +++ b/docs/design/docker-image-type.md @@ -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). diff --git a/docs/features/iol-runner-docker.md b/docs/features/iol-runner-docker.md new file mode 100644 index 000000000..893d77493 --- /dev/null +++ b/docs/features/iol-runner-docker.md @@ -0,0 +1,218 @@ + + +> 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
(IOS-XE 17.18.02)"] + NETIOMUX["netiomux"] + SOCKETS["/tmp/s00.sock (recv)
/tmp/c00.sock (send-to path)
… 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
add_nio_unix …/gns3/unixio/<node>/cNN.sock …
+ add_nio_udp (topology)"] + RTDIR["/run/user/<uid>/gns3/unixio/<node>
(bind-mounted at /tmp)"] + VOL["project-files/docker/<node>/config
(+ tmp/run, nested bind at /tmp/run)"] + end + SOCKETS <-->|"same files, two spellings
(the /tmp bind)"| RTDIR <--> UBRIDGE + UBRIDGE -.->|"iol-config.json +
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//gns3/unixio/`, 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=` (default 2048), `GNS3_IOL_STARTUP_CONFIG=` (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//` 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_` — 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//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 /c{N:02d}.sock /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=` | `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 `/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `/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=` | 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_` 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//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. diff --git a/docs/features/marker-tag-replay.md b/docs/features/marker-tag-replay.md new file mode 100644 index 000000000..4d148a5c8 --- /dev/null +++ b/docs/features/marker-tag-replay.md @@ -0,0 +1,257 @@ + + +> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt. + +# Marker Tag Replay (Aggregate Playback) + +## Overview + +Replays traffic captured by [markers](marker-traffic-insight.md) **across links**, keyed by +`tag`. Markers on different links that share a tag form one *distributed capture session*; +once every marker under the tag is paused, their per-marker pcaps are merged into a single +timestamp-ordered timeline. The Web UI browses that timeline and fetches individual frames +on demand — each fetch decodes exactly one frame via `tshark` into a self-describing JSON +protocol tree. + +The unique observable: the delta between the same packet hitting two consecutive links +measures the **intermediate node's forwarding latency** (host view) — something a +single-link capture can never show. + +## Architecture + +```mermaid +graph TB + UI["Web UI"] + + subgraph Controller["Controller (replay endpoints)"] + GATE["Tag gate
(all markers under tag paused?)"] + SCAN["Timeline scan
(pcap record headers)"] + MAP["PDML → JSON
isomorphic mapper"] + end + + FS[("markers dir
{node}_{link}_{marker}.pcap")] + TS["tshark -T pdml
(one frame at a time)"] + TMP["/tmp scratch copy
(hardened-profile workaround)"] + + UI -->|"GET range / frames"| GATE + GATE --> SCAN + SCAN --> FS + UI -->|"GET frame detail (lazy)"| MAP + MAP --> TMP + TMP --> TS + MAP -->|"hex: raw bytes"| FS +``` + +Two deliberately separated performance regimes: + +| Path | Work | tshark | +|------|------|--------| +| Timeline | 16-byte record-header scan per frame, cross-file merge sort | **never invoked** — browsing works without tshark | +| Frame detail | locate frame → `tshark -T pdml -Y "frame.number == N"` → map to JSON | forked once per frame the user opens | + +tshark call count equals user clicks — no caching or rate limiting needed, and a tshark +failure (501/502) affects only that one frame, never timeline browsing. pcap files are +compute-side (`/project-files/markers/`); the initial scope is single-server +deployments (controller and compute in one process, direct file access) — remote computes +will reuse the existing capture-file proxy pattern. + +## Business Process + +```mermaid +sequenceDiagram + participant UI as Web UI + participant C as Controller + participant PC as markers dir (pcaps) + participant TS as tshark + + Note over UI,PC: ① configure — same tag on every link's marker + UI->>C: POST markers (bpf, tag=666) on each link + + Note over PC: ② capture — uBridge appends matches (flushed per packet), replay forbidden + UI->>C: GET range + C--xC: 409 (a marker under tag 666 is still enabled) + + Note over UI: ③ pause — every marker under the tag + UI->>C: PUT markers {"enabled": false} × each + + Note over UI,TS: ④ replay + UI->>C: GET /markers/tags/666/replay/range + C->>PC: scan record headers, merge sort + C-->>UI: {start, end, sources, frames} + UI->>C: GET frames?ts=T&window_ms=W + C-->>UI: frames in [T, T+W] — or {"frames": []} + UI->>C: GET frame/detail?ts=…&node_id=…&link_id=…&marker=… + C->>PC: locate frame, read raw bytes (hex) + C->>TS: -T pdml (reads the /tmp copy) + TS-->>C: PDML + C-->>UI: protocol tree + hex +``` + +## The tag gate + +Replay reads append-only pcaps, so it is only available while the data is at rest. Every +replay endpoint evaluates the same gate: walk every marker in the project carrying the +requested tag; if any has `enabled: true` → 409 (the response names them); a tag with no +markers at all → 404. + +| Marker state under the tag | pcap file | Replay | +|---------------------------|-----------|--------| +| any `enabled: true` (capturing) | growing | denied — 409 | +| all `enabled: false` (paused) | retained, frozen | **allowed** | +| deleted | file unlinked | no data | +| `bpf`/`tag`/`direction` changed (rebuild) | pcap reopened (truncated) — new session | prior history gone | +| capture node (re)started | pcap reopened (truncated) — new session | prior history gone | + +- **Pause, not delete.** Deleting a marker (or its definition) deletes its pcap — replay + before deleting or the data is gone. +- **Pause → resume → pause is fine.** The pcap accumulates the full history; replay covers + everything up to the current pause point. +- **The replay window ends when nodes restart.** A pcap's lifetime equals its uBridge's + lifetime: a fresh uBridge reinstalls every desired marker — paused ones too (install + first, then turn the filter off) — and uBridge opens the pcap with truncate semantics + (`pcap_dump_open`, not `_append`). Server restart + project reopen **without starting + nodes** is safe: nothing touches the files until a uBridge comes up (verified live). + Docker nodes effectively restart on server restart as well (stale-container cleanup), + so their window is shorter still. +- uBridge flushes every matched packet to the pcap immediately (`pcap_dump_flush` per + packet under a mutex — verified in the uBridge source), so a pause boundary never loses + tail frames. + +## API Endpoints + +All read-only; JWT bearer token, privilege `Project.Audit`. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/v3/projects/{pid}/markers/tags/{tag}/replay/range` | Timeline metadata + full frame list for the tag | +| GET | `/v3/projects/{pid}/markers/tags/{tag}/replay/frames?ts=&window_ms=&limit=` | Frames with ts in `[T, T+window]`, merged across sources | +| GET | `/v3/projects/{pid}/markers/tags/{tag}/replay/frame/detail?ts=&node_id=&link_id=&marker=` | Single frame: tshark protocol tree + raw hex | + +### `range` — the timeline + +```json +{ + "tag": 666, + "start": "1788196663.226372", + "end": "1788196713.706634", + "frame_count": 20, + "truncated": false, + "sources": [ + { "node_id": "b764c434…", "link_id": "316ef8fd…", "marker": "global-def-…", + "data_link_type": "DLT_EN10MB", "count": 10 } + ], + "frames": [ + { "ts": "1788196663.226372", "len": 98, "node_id": "b764c434…", + "link_id": "316ef8fd…", "marker": "global-def-…", "frame_number": 1 } + ] +} +``` + +- `frames` is the **full, merged, time-ordered list** (cap 5000) — the Web UI lays out the + whole timeline from one request. Over the cap, `frames` is omitted and per-second + `buckets` are returned instead with `truncated: true`. +- Each frame entry carries `(node_id, link_id, marker, frame_number)` — the locating + tuple for the detail request. + +### `frames` — point / window query + +A time with no frames is a normal, successful answer — an empty array, no sentinel strings: + +```json +GET …/replay/frames?ts=1788196700.000&window_ms=500 +→ { "frames": [] } +``` + +### `frame/detail` — lazy single-frame decode + +Invoked only when the user opens a frame. The `ts` must be the **exact string received in +the timeline/frame list** (round-tripped verbatim — never re-serialized through a float); +`node_id + link_id + marker` identify the pcap. The server re-resolves the ts against the +file, guarding against a capture rebuilt between the timeline view and this click. + +```json +{ + "ts": "1788196663.226372", + "source": { "node_id": "b764c434…", "link_id": "316ef8fd…", + "marker": "global-def-…", "frame_number": 1 }, + "tshark_version": "TShark (Wireshark) 4.6.7 …", + "field_count": 85, + "hex": "45000062…", + "tree": [ + { "element": "proto", "name": "ip", + "showname": "Internet Protocol Version 4, Src: 10.1.10.101, Dst: 203.0.113.1", + "children": [ + { "element": "field", "name": "ip.ttl", "show": "64", + "showname": "Time to Live: 64", "value": "40", "size": "1", + "pos": "22", "children": [] } + ] } + ] +} +``` + +- `tree` mirrors PDML **isomorphically**: every ``/`` becomes a node, every + XML attribute (`name`, `show`, `showname`, `value`, `size`, `pos`, `hide`, `mask`, + `unmaskedvalue`) becomes a JSON key, plus one structural key `element` (proto/field). + Nothing is selected out, nothing interpreted, and **all values stay strings** — numeric + conversion is the client's business. +- `hex` is the raw frame bytes read straight from the pcap (not via tshark); keeping + `pos`/`size` on every field enables Wireshark-style *click field → highlight bytes*. +- `field_count` is the mapped node count — a client-side sanity check. + +## Ordering and timestamps + +- `ts` is the pcap record timestamp (µs) written by uBridge at match time — a userspace + `gettimeofday()` instant measured after the packet has crossed the kernel twice. The + last digit or two are scheduling noise; microseconds are sufficient in a simulated + environment. +- The sort key is `(ts, source file, frame_number)` — ts alone is **not** unique (two + links can hit the same microsecond); the tiebreaker yields a stable, determined order + instead of a fictional one. Index structures must never use ts as a dict key, or + same-microsecond frames silently overwrite each other. +- The cross-link delta is the intermediate node's end-to-end forwarding latency + (veth/TAP → guest protocol stack → back to host), typically hundreds of microseconds to + milliseconds. UI labels should read *node forwarding latency (host view)*, not link + propagation delay. A live capture pair confirmed it end-to-end: same `ip.id`, + TTL 64→63, 509 µs between two links. + +## Fidelity guarantee (PDML → JSON) + +The conversion is an isomorphic structure map, not a semantic transform, with two rules: +**map every attribute** and **keep values as strings**. A round-trip test enforces both: +PDML element count equals JSON node count, and every XML attribute survives with an +identical JSON value (`tests/controller/test_marker_replay.py`). The raw frame bytes — +the one thing PDML genuinely does not contain — are covered by `hex` read directly from +the pcap. + +## Error Responses + +| Status | Description | +|--------|-------------| +| 401 | Not authenticated | +| 404 | Tag has no markers in the project; detail source unknown, or ts does not match the file (the capture may have been rebuilt) | +| 409 | Tag gate: a marker under the tag is still `enabled: true` (the response lists them) | +| 501 | tshark not installed / unavailable — affects detail only; the timeline never needs tshark | +| 502 | tshark failed or timed out (10 s); truncated output never reaches the mapper | + +## Notes + +- **Heterogeneous link types coexist.** Frames are never merged into a single pcap + (mergecap is deliberately not used) — each frame carries its source and is decoded + individually, so Ethernet and serial (cHDLC/PPP) markers can share one timeline. + Malformed packets are tshark's problem: it emits `[Malformed Packet]` as regular PDML + and carries on. +- **Hardened tshark profiles.** openSUSE-style profiles (AppArmor &c.) can deny tshark + access to the project directory and the user's home even though the server process can + read both. The detail path therefore copies the pcap to a real file under `/tmp` + (not a symlink — the profile resolves real paths) and gives tshark a scratch `HOME`; + the copy is unlinked afterwards. The hex view still reads the original file. +- **Tag type.** REST and the `marker.match` WS event both carry `tag` as `int` (the + listener normalizes); replay keys on that int value. +- **Follow-ups.** Remote-compute support via the existing capture-file proxy pattern; + convenience APIs (`GET …/markers/tags` to list tags, `POST …/markers/tags/{tag}/pause` + to batch-pause — a one-call path to the replayable state); uBridge-side + `pcap_dump_open_append` (with a linktype-header check on the existing file) so capture + history survives node restarts instead of being truncated on every reinstall. diff --git a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py index b5173668c..7478301f8 100644 --- a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py +++ b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py @@ -94,6 +94,7 @@ from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool from gns3server.agent.gns3_copilot.tools_v2 import GNS3TemplateTool from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool +from gns3server.agent.gns3_copilot.tools_v2 import GNS3WaitTool from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands from gns3server.agent.gns3_copilot.tools_v2 import PacketAnalysisTool from gns3server.agent.gns3_copilot.skills import DeviceSkillsTool @@ -113,7 +114,8 @@ TEACHING_ASSISTANT_MODE_TOOLS = [ GNS3TemplateTool(), # Get GNS3 node templates GNS3CreateNodeTool(), # Create new nodes in GNS3 GNS3LinkTool(), # Create links between nodes - GNS3StartNodeTool(), # Start GNS3 nodes + GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately) + GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node) GNS3UpdateNodeNameTool(), # Update node name ExecuteMultipleDeviceCommands(), # Execute show/display/debug commands # (READ-ONLY) @@ -127,7 +129,8 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [ GNS3TemplateTool(), # Get GNS3 node templates GNS3CreateNodeTool(), # Create new nodes in GNS3 GNS3LinkTool(), # Create links between nodes - GNS3StartNodeTool(), # Start GNS3 nodes + GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately) + GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node) GNS3StopNodeTool(), # Stop GNS3 nodes GNS3SuspendNodeTool(), # Suspend GNS3 nodes (preserve state) GNS3UpdateNodeNameTool(), # Update node name diff --git a/gns3server/agent/gns3_copilot/tools_v2/__init__.py b/gns3server/agent/gns3_copilot/tools_v2/__init__.py index 49957fc79..18f122727 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/__init__.py +++ b/gns3server/agent/gns3_copilot/tools_v2/__init__.py @@ -40,7 +40,8 @@ Main modules: - vpcs_tools_netmiko: VPCS device configuration tool using Netmiko - gns3_create_node: GNS3 node creation tool - gns3_create_link: GNS3 link creation tool -- gns3_start_node: GNS3 node startup tool +- gns3_start_node: GNS3 node startup tool (immediate return, no boot wait) +- gns3_wait: Wait timer tool (pair with node start to let devices boot) - gns3_get_node_temp: GNS3 template retrieval tool - gns3_update_node_name: GNS3 node name update tool - gns3_packet_filter: GNS3 packet filter management tool @@ -62,6 +63,7 @@ from .gns3_start_node import GNS3StartNodeTool from .gns3_stop_node import GNS3StopNodeTool from .gns3_suspend_node import GNS3SuspendNodeTool from .gns3_update_node_name import GNS3UpdateNodeNameTool +from .gns3_wait import GNS3WaitTool from .packet_analysis_tool import PacketAnalysisTool # Dynamic version management @@ -89,6 +91,7 @@ __all__ = [ "GNS3SuspendNodeTool", "GNS3UpdateNodeNameTool", "GNS3TemplateTool", + "GNS3WaitTool", "PacketAnalysisTool", ] diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py index 8e749d35a..e7ae2dd82 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py @@ -26,13 +26,15 @@ GNS3 node startup tool for network device activation. -Provides functionality to start one or multiple nodes in GNS3 projects -with progress tracking and status monitoring. +Sends start commands and returns immediately — it never blocks on a fixed +boot timer. A failed start command (e.g. a 409 from the compute) is +reported in the same round-trip instead of after a two-minute progress +bar. Use the wait_seconds tool between this and any status check to give +nodes time to boot. """ import json import logging -import time from pprint import pprint from typing import Any @@ -48,290 +50,15 @@ from gns3server.agent.gns3_copilot.gns3_client.api_handlers import ( # Configure logging logger = logging.getLogger(__name__) -# Node startup time configuration by device type -# Based on typical boot times for different emulators -# Conservative timing to account for slower hardware environments -NODE_STARTUP_TIME = { - "vpcs": {"base": 15, "extra_per_node": 2}, # VPCS: Very fast startup - "iou": {"base": 25, "extra_per_node": 3}, # IOU: Fast startup - "default": {"base": 120, "extra_per_node": 10}, # Other devices: Conservative time -} - - -def calculate_startup_time(nodes: list) -> int: - """ - Calculate startup wait time based on node types. - - Strategy: - - If all nodes are fast devices (VPCS/IOU): use fast startup time - - If any node is a slow device: use conservative startup time - - Args: - nodes: List of node dicts with a "node_type" key - - Returns: - Calculated wait time in seconds - """ - if not nodes: - return 60 # Default: 60 seconds for empty list - - # Get all node types - node_types = [node.get("node_type") or "default" for node in nodes] - - # Check if all nodes are fast startup devices (VPCS or IOU) - fast_types = {"vpcs", "iou"} - all_fast = all(node_type in fast_types for node_type in node_types) - - if all_fast: - # Use fast startup time: base + (count - 1) * extra_per_node - # Use the largest base time among the fast devices - max_fast_base = max( - NODE_STARTUP_TIME[nt]["base"] - for nt in node_types if nt in fast_types - ) - # Use the smallest extra_per_node among the fast devices - min_fast_extra = min( - NODE_STARTUP_TIME[nt]["extra_per_node"] - for nt in node_types if nt in fast_types - ) - total_time = max_fast_base + (len(nodes) - 1) * min_fast_extra - logger.info( - "All fast devices detected (%s), using fast startup time: %ds", - node_types, - total_time - ) - return total_time - else: - # Use conservative startup time for mixed or slow devices - config = NODE_STARTUP_TIME["default"] - total_time = config["base"] + (len(nodes) - 1) * config["extra_per_node"] - logger.info( - "Mixed or slow devices detected (%s), using conservative startup time: %ds", - node_types, - total_time - ) - return total_time - - -def show_progress_bar( - duration: int = 120, interval: int = 1, node_count: int = 1 -) -> None: - """ - Display a simple text progress bar for node startup. - - Args: - duration: Total duration of the progress bar in seconds - interval: Update interval in seconds - node_count: Number of nodes being started - """ - print(f"Starting {node_count} node(s), please wait...") - for elapsed in range(duration): - # Calculate progress percentage - progress = (elapsed + 1) / duration * 100 - - # Create progress bar display - bar_length = 30 - filled_length = int(bar_length * elapsed // duration) - progress_string = ( - "=" * filled_length + ">" + " " * (bar_length - filled_length - 1) - ) - - # Print progress bar with node count - print(f"\r[{progress_string}] {progress:.1f}%", end="", flush=True) - time.sleep(interval) - - print(f"\n{node_count} node(s) startup completed!") - class GNS3StartNodeTool(BaseTool): """ A LangChain tool to start one or multiple nodes in a GNS3 project. - **Input**: - A JSON object with project_id and node_ids (list of node IDs). - Example: - { - "project_id": "uuid-of-project", - "node_ids": ["uuid-of-node-1", "uuid-of-node-2"] - } - - **Output**: - A dictionary with all nodes' details: - { - "project_id": "...", - "total_nodes": 2, - "successful": 2, - "failed": 0, - "nodes": [ - {"node_id": "...", "name": "...", "status": "..."}, - {"node_id": "...", "name": "...", "status": "..."} - ] - } - """ - - name: str = "start_gns3_node" - description: str = """ - Starts one or multiple nodes in a GNS3 project. - Input: JSON with project_id and node_ids (list of node IDs). - Returns: A dict with all nodes' details (success/failure status). - """ - - def _run( - self, - tool_input: str, - run_manager: CallbackManagerForToolRun | None = None, - ) -> dict[str, Any]: - try: - # Parse input JSON - input_data = json.loads(tool_input) - project_id = input_data.get("project_id") - node_ids = input_data.get("node_ids") - - # Validate input - if not project_id or not node_ids: - logger.error( - "Missing required fields: project_id or node_ids." - ) - return { - "error": "Missing required fields: " - "project_id and node_ids." - } - - if not isinstance(node_ids, list): - logger.error("node_ids must be a list.") - return {"error": "node_ids must be a list."} - - # Build handler context (JWT + server URL from request context) - logger.info("Connecting to GNS3 server...") - gns3_ctx = build_gns3_ctx() - - if gns3_ctx is None: - logger.error("Failed to create GNS3 connector") - return { - "error": "Failed to connect to GNS3 server. " - "Please check your configuration." - } - - # Phase 1: fetch node info (including node_type) in one call - logger.info( - "Retrieving node info for %d nodes in project %s...", - len(node_ids), - project_id, - ) - listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) - if "error" in listing: - return {"error": listing["error"]} - nodes_by_id = {n["node_id"]: n for n in listing["nodes"]} - nodes = [nodes_by_id[nid] for nid in node_ids if nid in nodes_by_id] - for node in nodes: - logger.info( - "Node %s (%s) type: %s", - node["node_id"], - node.get("name"), - node.get("node_type"), - ) - for nid in node_ids: - if nid not in nodes_by_id: - logger.error( - "Node %s not found in project %s", nid, project_id - ) - - # Calculate startup time based on node types - wait_time = calculate_startup_time(nodes) - - # Phase 2: send start commands for all nodes (parallel batch) - logger.info( - "Sending start commands for %d nodes in project %s...", - len(nodes), - project_id, - ) - start_results = start_node_handler( - {"project_id": project_id, "node_ids": [n["node_id"] for n in nodes]}, - gns3_ctx, - ) - for r in start_results: - if r.get("status") == "error": - logger.error( - "Failed to send start command for node %s: %s", - r.get("node_id"), - r.get("error"), - ) - else: - logger.info("Start command sent for node %s", r.get("node_id")) - - # Show progress bar with calculated wait time - show_progress_bar( - duration=wait_time, interval=1, node_count=len(nodes) - ) - - # Phase 3: get final status for all nodes (one call) - results = [] - logger.info("Retrieving status for %d nodes...", len(nodes)) - listing = get_nodes_handler({"project_id": project_id}, gns3_ctx) - if "error" in listing: - return {"error": listing["error"]} - final_by_id = {n["node_id"]: n for n in listing["nodes"]} - for node in nodes: - node_info = final_by_id.get(node["node_id"], node) - results.append( - { - "node_id": node["node_id"], - "name": node_info.get("name") or "N/A", - "status": node_info.get("status") or "unknown", - } - ) - - # Handle nodes that failed to be retrieved initially - retrieved_node_ids = {node["node_id"] for node in nodes} - for node_id in node_ids: - if node_id not in retrieved_node_ids: - results.append( - { - "node_id": node_id, - "name": "N/A", - "status": "error", - "error": "Node not found during info retrieval", - } - ) - - # Analyze results - successful_nodes = [ - r for r in results if r.get("status") != "error" - ] - failed_nodes = [r for r in results if r.get("status") == "error"] - - # Construct final response - response = { - "project_id": project_id, - "total_nodes": len(node_ids), - "successful": len(successful_nodes), - "failed": len(failed_nodes), - "nodes": results, - } - - logger.info( - "Start operation completed: %d successful, %d failed", - len(successful_nodes), - len(failed_nodes), - ) - - return response - - except json.JSONDecodeError as e: - logger.error("Invalid JSON input: %s", e) - return {"error": f"Invalid JSON input: {e}"} - except Exception as e: - logger.error("Failed to start nodes: %s", e) - return {"error": f"Failed to start nodes: {str(e)}"} - - -class GNS3StartNodeQuickTool(BaseTool): - """ - A LangChain tool to start nodes in a GNS3 project WITHOUT waiting. - - This tool sends start commands to all nodes and immediately returns status, - without blocking for startup completion. Suitable for automated deployment - workflows where long waits would cause HTTP timeouts. + Sends the start commands in a parallel batch and returns each node's + status immediately — nodes keep booting in the background. It does NOT + wait for boot completion: follow up with the wait_seconds tool and a + status/topology check to confirm nodes actually came up. **Input**: A JSON object with project_id and node_ids (list of node IDs). @@ -350,19 +77,20 @@ class GNS3StartNodeQuickTool(BaseTool): "failed": 0, "nodes": [ {"node_id": "...", "name": "...", "status": "started"}, - {"node_id": "...", "name": "...", "status": "started"} + {"node_id": "...", "name": "...", "status": "error", "error": "..."} ], "note": "Start commands sent. Nodes are booting in background." } """ - name: str = "start_gns3_node_quick" + name: str = "start_gns3_node" description: str = """ - Starts nodes in a GNS3 project WITHOUT waiting for startup completion. - Use this for automated deployments to avoid HTTP timeouts. + Starts one or multiple nodes in a GNS3 project and returns immediately + (nodes boot in the background; start failures are reported right away). + After calling this, use wait_seconds (VPCS/IOU ~15-30s, IOS routers + ~60-120s, heavy NOS images 2-5min) before checking node status. Input: JSON with project_id and node_ids (list of node IDs). - Returns: Dict with nodes' details after start commands are sent. - NOTE: Nodes will continue booting in background after this tool returns. + Returns: Dict with per-node start command results. """ def _run( @@ -490,7 +218,7 @@ class GNS3StartNodeQuickTool(BaseTool): "nodes": results, "note": ( "Start commands sent. Nodes are booting in background. " - "Check node status later." + "Use wait_seconds, then check node status." ), } @@ -510,6 +238,11 @@ class GNS3StartNodeQuickTool(BaseTool): return {"error": f"Failed to start nodes: {str(e)}"} +# Backward-compat alias: the waiting variant was removed; both names now +# point at the immediate-return tool. +GNS3StartNodeQuickTool = GNS3StartNodeTool + + if __name__ == "__main__": # Test with single node print("=== Testing single node startup ===") @@ -524,19 +257,3 @@ if __name__ == "__main__": tool = GNS3StartNodeTool() result_single = tool._run(test_input_single) pprint(result_single) - - # Test with multiple nodes - print("\n=== Testing multiple nodes startup ===") - test_input_multiple = json.dumps( - { - "project_id": "", # Replace with actual project UUID - "node_ids": [ - "fbeda109-9a74-4d8c-a749-cc3847911a90", - # Replace with actual node UUIDs - "another-node-uuid-here", - "third-node-uuid-here", - ], - } - ) - result_multiple = tool._run(test_input_multiple) - pprint(result_multiple) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py new file mode 100644 index 000000000..d31e9e6d2 --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py @@ -0,0 +1,109 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot 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. +# +# GNS3-Copilot 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 GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# +""" + +GNS3-Copilot wait tool. + +start_gns3_node returns as soon as the start commands are accepted — nodes +keep booting in the background. This tool gives the agent a deliberate +pause it controls itself (instead of a hard-coded progress bar inside the +start tool), so the usual flow is: start_gns3_node → wait_seconds → check +node status / run show commands. +""" + +import json +import logging +import time +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun + +# Configure logging +logger = logging.getLogger(__name__) + +# Hard ceiling so a hallucinated "wait 999999" cannot wedge the agent loop. +MAX_WAIT_SECONDS = 600 +# Log a liveness line every few seconds so long waits are visible in the +# server log. +LOGBOOK_TICK = 5 + + +class GNS3WaitTool(BaseTool): + """ + A LangChain tool that sleeps for a given number of seconds. + + **Input**: + A JSON object with seconds (integer, 1-600). + Example: + {"seconds": 30} + + **Output**: + {"waited": 30} + """ + + name: str = "wait_seconds" + description: str = """ + Pause execution for a given number of seconds (1-600), then continue. + Use after start_gns3_node (which returns immediately) to let nodes + boot before checking status: VPCS/IOU ~15-30s, IOS/IOL routers + ~60-120s, heavy NOS images (XRd, SR Linux) 2-5min. Prefer several + short waits with a status check in between over one long blind wait. + Input: JSON with seconds (integer). + Returns: {"waited": }. + """ + + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> dict[str, Any]: + try: + input_data = json.loads(tool_input) + seconds = input_data.get("seconds") + if isinstance(seconds, str) and seconds.strip().isdigit(): + seconds = int(seconds.strip()) + if not isinstance(seconds, int) or isinstance(seconds, bool): + return {"error": "seconds must be an integer (1-600)."} + if not 1 <= seconds <= MAX_WAIT_SECONDS: + return { + "error": f"seconds must be between 1 and {MAX_WAIT_SECONDS}." + } + + logger.info("Waiting %d seconds...", seconds) + waited = 0 + while waited < seconds: + tick = min(LOGBOOK_TICK, seconds - waited) + time.sleep(tick) + waited += tick + logger.info("Waited %d/%d seconds", waited, seconds) + return {"waited": waited} + + except json.JSONDecodeError as e: + logger.error("Invalid JSON input: %s", e) + return {"error": f"Invalid JSON input: {e}"} + except Exception as e: + logger.error("Wait tool failed: %s", e) + return {"error": f"Wait tool failed: {str(e)}"} diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index 63d8bea91..4c7f319c3 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -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") diff --git a/gns3server/api/routes/compute/projects.py b/gns3server/api/routes/compute/projects.py index 7da980a08..54d7e460e 100644 --- a/gns3server/api/routes/compute/projects.py +++ b/gns3server/api/routes/compute/projects.py @@ -144,8 +144,14 @@ async def _add_nio_binding(node, adapter_number, port_number, nio): manager_name = type(node.manager).__name__ # Adapter-based nodes: docker / qemu / vmware / virtualbox take - # (adapter_number, nio); iou additionally takes port_number. - if manager_name in ("Docker", "Qemu", "VMware", "VirtualBox"): + # (adapter_number, nio); iou additionally takes port_number. Docker + # adapters can be multi-port (e.g. iol-runner nodes model 4 ports per + # adapter): dropping port_number would bind every NIO to port 0, where + # add_nio() silently overwrites — the last entry per node wins and links + # end up cross-wired (observed as dead direct links after reopen). + if manager_name == "Docker": + await node.adapter_add_nio_binding(adapter_number, nio, port_number) + elif manager_name in ("Qemu", "VMware", "VirtualBox"): await node.adapter_add_nio_binding(adapter_number, nio) elif manager_name == "IOU": await node.adapter_add_nio_binding(adapter_number, port_number, nio) @@ -176,7 +182,9 @@ def _get_existing_nio(node, adapter_number, port_number): """ manager_name = type(node.manager).__name__ - if manager_name in ("Docker", "Qemu", "VMware", "VirtualBox"): + if manager_name == "Docker": + return node.get_nio(adapter_number, port_number) + elif manager_name in ("Qemu", "VMware", "VirtualBox"): return node.get_nio(adapter_number) elif manager_name == "IOU": return node.get_nio(adapter_number, port_number) @@ -207,7 +215,9 @@ async def _update_nio_binding(node, adapter_number, port_number, nio): """ manager_name = type(node.manager).__name__ - if manager_name in ("Docker", "Qemu", "VMware", "VirtualBox"): + if manager_name == "Docker": + await node.adapter_update_nio_binding(adapter_number, nio, port_number) + elif manager_name in ("Qemu", "VMware", "VirtualBox"): await node.adapter_update_nio_binding(adapter_number, nio) elif manager_name == "IOU": await node.adapter_update_nio_binding(adapter_number, port_number, nio) diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index f9012a8ef..0595bde7b 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -44,6 +44,8 @@ from gns3server.controller.link import _UNSET from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError from gns3server.controller.import_project import import_project as import_controller_project from gns3server.controller.export_project import export_project as export_controller_project +from gns3server.controller import marker_replay +from gns3server.controller.marker_replay import TsharkError, TsharkMissingError from gns3server.utils.asyncio import aiozipstream from gns3server.utils.path import is_safe_path from gns3server.db.repositories.templates import TemplatesRepository @@ -219,6 +221,87 @@ def get_project_markers(project: Project = Depends(dep_project)) -> dict: return project.markers +@router.get( + "/{project_id}/markers/tags/{tag}/replay/range", + dependencies=[Depends(has_privilege("Project.Audit"))], +) +def replay_tag_range(tag: int, project: Project = Depends(dep_project)) -> dict: + """ + Aggregate replay timeline for a tag: merges the pcap of every marker + carrying ``tag`` into one timestamp-ordered view (design reference: + ``marker_replay`` module docstring). + + The tag gate applies: every marker under the tag must be paused + (``enabled: false``) — 409 otherwise. The response carries the timeline + bounds, per-source stats, and the full merged frame list while under the + frame cap (5000); above it the list is replaced by per-second buckets. + + Required privilege: Project.Audit + """ + + return marker_replay.build_timeline(project, tag) + + +@router.get( + "/{project_id}/markers/tags/{tag}/replay/frames", + dependencies=[Depends(has_privilege("Project.Audit"))], +) +def replay_tag_frames( + tag: int, + ts: str, + window_ms: int = 100, + limit: int = 1000, + project: Project = Depends(dep_project), +) -> dict: + """ + Frames with ts in ``[ts, ts + window_ms]`` merged across every source of + the tag. A time with no frames is a normal successful answer: + ``{"frames": []}``. The tag gate applies (409 while any marker captures). + + ``ts`` must be the exact string returned by the range response — never + re-serialize it through a float. + + Required privilege: Project.Audit + """ + + return marker_replay.query_frames(project, tag, ts, window_ms=window_ms, limit=limit) + + +@router.get( + "/{project_id}/markers/tags/{tag}/replay/frame/detail", + dependencies=[Depends(has_privilege("Project.Audit"))], +) +async def replay_tag_frame_detail( + tag: int, + ts: str, + node_id: str, + link_id: str, + marker: str, + project: Project = Depends(dep_project), +) -> dict: + """ + Decode exactly one frame (lazy — invoked when the user opens a frame, + never by the timeline itself): raw bytes for the hex view read straight + from the pcap, protocol tree from ``tshark -T pdml`` mapped isomorphically + to JSON (every PDML attribute survives, values stay strings). + + ``ts`` must be the exact string from the frame list; ``node_id`` + + ``link_id`` + ``marker`` identify the source pcap. The tag gate applies. + + Required privilege: Project.Audit + """ + + try: + return await marker_replay.decode_frame(project, tag, ts, node_id, link_id, marker) + except TsharkMissingError: + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="tshark is not installed on this server — frame detail is unavailable", + ) + except TsharkError as e: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) + + # --------------------------------------------------------------------------- # Project-level marker definitions (global rules inherited by every link) # --------------------------------------------------------------------------- diff --git a/gns3server/compute/docker/__init__.py b/gns3server/compute/docker/__init__.py index ed37ca626..f4027ea59 100644 --- a/gns3server/compute/docker/__init__.py +++ b/gns3server/compute/docker/__init__.py @@ -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): diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 44a89f968..b226a0f39 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -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( diff --git a/gns3server/compute/docker/iol_docker_vm.py b/gns3server/compute/docker/iol_docker_vm.py new file mode 100644 index 000000000..38042de6d --- /dev/null +++ b/gns3server/compute/docker/iol_docker_vm.py @@ -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 . + +""" +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=`` — 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_``) 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: + + * ``/tmp/run/`` must exist or the IOL process dies at + boot (the runner writes NETMAP there but does not create it). + * ``/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 diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 6089663f2..b156209ae 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -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=`` — 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,165 @@ 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// + project-files/docker//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) + + async def _set_adapter_carrier(self, adapter_number, connected, port_number=0): + """ + Override: with GNS3_UNIX_SOCKET_NIO the bridges carry only unix and + UDP NIOs — there is no TAP, and uBridge rejects the carrier command + ("bridge has no TAP NIO"), which would fail every link create, + update or delete on a running node. The link state is the socket + pair itself. + """ + + if not self._unix_socket_nio: + await super()._set_adapter_carrier(adapter_number, connected, port_number) + + async def _start_interface_monitor(self): + """ + Override: with GNS3_UNIX_SOCKET_NIO no GNS3-managed eth interface + exists in the container's network namespace — the busybox poll would + idle forever (or misreport the Docker default eth0 as adapter 0 + status). Vendor images with TAP wiring keep the base monitor. + """ + + if not self._unix_socket_nio: + await super()._start_interface_monitor() + + async def _stop_interface_monitor(self): + """ + Override: mirror _start_interface_monitor — there is nothing to stop + when the monitor never started under unix-socket NIO. + """ + + if not self._unix_socket_nio: + await super()._stop_interface_monitor() + def _cleanup_console_resources(self): """ Override: close the docker-exec pty socket, if any, so the next diff --git a/gns3server/compute/marker/marker_listener.py b/gns3server/compute/marker/marker_listener.py index 40cdd97b6..a30769c1e 100644 --- a/gns3server/compute/marker/marker_listener.py +++ b/gns3server/compute/marker/marker_listener.py @@ -114,13 +114,24 @@ class MarkerListener(asyncio.DatagramProtocol): # signals that carry no `link=`. signal_link = link if link and link != "-" else None + # Normalize the tag to int so the event matches the REST schema + # (MarkerCreate.tag is Optional[int]): the signal merely echoes the + # decimal we installed via `mark tag `, so parsing cannot + # fail for well-formed signals; a malformed value keeps the registered + # int, and the tag is None only when neither side carries one. + event_tag = registered_tag + if tag and tag != "-": + try: + event_tag = int(tag) + except ValueError: + pass # malformed signal tag: keep the registered value + event = { "project_id": project_id, "node_id": node_id, "link_id": signal_link or link_id, "filter": filter_name, - # Prefer the value carried in the signal; fall back to the one we registered. - "tag": tag if tag and tag != "-" else registered_tag, + "tag": event_tag, "ts": ts, "len": int(length) if length and length.isdigit() else 0, # Travel direction relative to the capture node (node_id above); diff --git a/gns3server/configs/iol-xe-base.txt b/gns3server/configs/iol-xe-base.txt new file mode 100644 index 000000000..fb275da85 --- /dev/null +++ b/gns3server/configs/iol-xe-base.txt @@ -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 diff --git a/gns3server/controller/marker_replay.py b/gns3server/controller/marker_replay.py new file mode 100644 index 000000000..993bb31f8 --- /dev/null +++ b/gns3server/controller/marker_replay.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python +# +# Copyright (C) 2024 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 . + +""" +Tag-keyed aggregate replay over paused markers' pcap files. + +Markers on different links sharing a ``tag`` form one distributed capture +session. This module merges their per-marker pcaps +(``/project-files/markers/{node_id}_{link_id}_{name}.pcap``) into a +single timestamp-ordered timeline and decodes individual frames on demand. + +Two deliberately separated performance regimes: + +* the timeline path reads only the 16-byte pcap record headers — tshark is + never invoked, so browsing works even where tshark is not installed; +* the detail path runs one ``tshark -T pdml`` per frame the caller asks about + (call count = user clicks) and maps the XML to JSON isomorphically — every + PDML attribute survives as a JSON key, values stay strings, nothing is + selected out or interpreted. + +Timestamps are uBridge's userspace ``gettimeofday`` at match time (µs, a +value measured after the packet has crossed the kernel twice — the last +digit or two are scheduling noise). A timestamp is NOT a unique key: the +merge sorts by ``(ts, source file, frame number)`` and index structures must +never use ts alone as a dict key, or same-microsecond frames silently +overwrite each other. +""" + +import asyncio +import logging +import os +import shutil +import struct +import tempfile +import xml.etree.ElementTree as ET + +from .controller_error import ControllerError, ControllerNotFoundError, ControllerBadRequestError + +log = logging.getLogger(__name__) + +# Full frame list is embedded in the range response while under this cap; +# above it the response degrades to start/end + per-second buckets so the +# client is never flooded by a high-traffic BPF. +FRAME_LIST_CAP = 5000 + +# One tshark decode per user click: a generous ceiling, not a rate limiter. +TSHARK_TIMEOUT_SECONDS = 10.0 + + +class TsharkMissingError(ControllerError): + """tshark is not installed (or not on PATH) — frame detail unavailable.""" + + +class TsharkError(ControllerError): + """tshark exited non-zero / timed out / produced unusable output.""" + + +# --------------------------------------------------------------------------- +# pcap record-header scanning (timeline path — no tshark) +# --------------------------------------------------------------------------- + +# magic → (byte order, timestamp unit). Both pcap families uBridge can write +# (libpcap default µs; ns variant accepted defensively) and both endiannesses. +_PCAP_MAGICS = { + 0xA1B2C3D4: ("<", 1), # little-endian, microseconds + 0xD4C3B2A1: (">", 1), # big-endian, microseconds + 0xA1B23C4D: ("<", 1000), # little-endian, nanoseconds + 0x4D3CB2A1: (">", 1000), # big-endian, nanoseconds +} + + +def _format_ts(sec: int, usec: int) -> str: + """Canonical ts string — the exact form clients must round-trip back.""" + + return f"{sec}.{usec:06d}" + + +def _parse_ts(ts: str) -> int: + """Parse a round-tripped ts string to integer microseconds (exact, no floats).""" + + try: + sec, _, frac = ts.partition(".") + usec = int(frac.ljust(6, "0")[:6]) if frac else 0 + return int(sec) * 1_000_000 + usec + except ValueError: + raise ControllerBadRequestError(f"Invalid timestamp: {ts!r}") + + +def scan_pcap_frames(path): + """ + Walk a pcap file reading only record headers. + + :returns: list of ``(ts_sec, ts_usec, incl_len)`` per frame, 1-based order. + Tolerates a truncated tail (stops when a record header claims more bytes + than the file holds) so a snapshot mid-write never raises. + """ + + frames = [] + try: + with open(path, "rb") as f: + data = f.read() + except OSError as e: + raise ControllerError(f"Cannot read marker pcap {path}: {e}") + + if len(data) < 24: + return frames # not even a global header — zero frames + magic = struct.unpack(" 0xFFFF or pos + 16 + incl_len > len(data): + break # truncated tail (snapshot mid-write / torn final record) + # Normalize ns pcaps to µs by truncation — uBridge writes µs anyway. + frames.append((ts_sec, ts_frac // unit if unit > 1 else ts_frac, incl_len)) + pos += 16 + incl_len + return frames + + +def read_frame_bytes(path, frame_number): + """ + Read one frame's raw bytes (hex view) straight from the pcap — never via + tshark. ``frame_number`` is 1-based (the same number tshark's + ``frame.number`` filter uses). + """ + + frames = scan_pcap_frames(path) + if not 1 <= frame_number <= len(frames): + return None + # Offset arithmetic mirrors the header scan: global header + every full + # record before the target + the target's own record header. + offset = 24 + sum(16 + incl for _s, _u, incl in frames[:frame_number - 1]) + 16 + incl_len = frames[frame_number - 1][2] + with open(path, "rb") as f: + f.seek(offset) + return f.read(incl_len).hex() + + +# --------------------------------------------------------------------------- +# Tag gate + timeline assembly +# --------------------------------------------------------------------------- + +def _tag_markers(project, tag): + """ + Every marker entry in the project carrying ``tag`` (flat + ``project.markers`` values: link_id / node_id / name keys included). + """ + + entries = [] + for key, info in project.markers.items(): + if info.get("tag") == tag: + link_id, _, name = key.partition("/") + entries.append({ + "node_id": info["node_id"], + "link_id": link_id, + "marker": name, + "enabled": info.get("enabled", True), + "data_link_type": info.get("data_link_type", "DLT_EN10MB"), + }) + return entries + + +def gate_tag(project, tag): + """ + Replay reads append-only pcaps, so it is only available while the data is + at rest: every marker under the tag must be paused. Raises 409 listing + the still-running markers, 404 when the tag has no markers at all. + """ + + entries = _tag_markers(project, tag) + if not entries: + raise ControllerNotFoundError(f"No markers with tag {tag} in project") + running = [f"{e['marker']} on link {e['link_id']}" for e in entries if e["enabled"]] + if running: + raise ControllerError( + f"Cannot replay tag {tag} while markers are capturing: {', '.join(running)}. " + "Pause every marker under the tag first." + ) + return entries + + +def _merged_frames(project, entries): + """ + Scan every source pcap and merge into one list sorted by + ``(ts, source file, frame number)`` — ts alone is not unique (two links + can hit the same microsecond); the tiebreaker yields a stable, determined + order instead of a fictional one. + """ + + markers_dir = project.markers_directory + merged = [] + sources = [] + for entry in entries: + pcap = os.path.join( + markers_dir, f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}.pcap" + ) + frames = scan_pcap_frames(pcap) if os.path.exists(pcap) else [] + sources.append({**{k: entry[k] for k in ("node_id", "link_id", "marker", "data_link_type")}, + "count": len(frames)}) + for frame_number, (sec, usec, incl_len) in enumerate(frames, start=1): + merged.append({ + "ts": _format_ts(sec, usec), + "ts_us": sec * 1_000_000 + usec, + "len": incl_len, + "node_id": entry["node_id"], + "link_id": entry["link_id"], + "marker": entry["marker"], + "frame_number": frame_number, + "_source": f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}", + }) + merged.sort(key=lambda f: (f["ts_us"], f["_source"], f["frame_number"])) + for frame in merged: + del frame["ts_us"] + del frame["_source"] + return merged, sources + + +def build_timeline(project, tag, frame_cap=FRAME_LIST_CAP): + """ + The ``range`` response: timeline bounds, per-source stats, and (under + ``frame_cap``) the full merged frame list for one-request timeline + layout. Over the cap the list is replaced by per-second buckets. + """ + + entries = gate_tag(project, tag) + frames, sources = _merged_frames(project, entries) + + response = { + "tag": tag, + "start": frames[0]["ts"] if frames else None, + "end": frames[-1]["ts"] if frames else None, + "frame_count": len(frames), + "truncated": len(frames) > frame_cap, + "sources": sources, + } + if len(frames) <= frame_cap: + response["frames"] = frames + else: + buckets = {} + for frame in frames: + second = _parse_ts(frame["ts"]) // 1_000_000 + buckets[second] = buckets.get(second, 0) + 1 + response["buckets"] = [ + {"ts": _format_ts(second, 0), "count": count} + for second, count in sorted(buckets.items()) + ] + return response + + +def query_frames(project, tag, ts, window_ms=100, limit=1000): + """ + Frames with ts in ``[T, T+window_ms]`` merged across sources. A time with + no frames is a normal, successful answer — ``{"frames": []}``. + """ + + entries = gate_tag(project, tag) + frames, _sources = _merged_frames(project, entries) + + start_us = _parse_ts(ts) + end_us = start_us + max(window_ms, 0) * 1000 + hits = [f for f in frames if start_us <= _parse_ts(f["ts"]) <= end_us] + return {"frames": hits[:max(limit, 0)]} + + +# --------------------------------------------------------------------------- +# Frame detail (tshark path — lazy, one frame per call) +# --------------------------------------------------------------------------- + +async def _tshark_version(): + try: + proc = await asyncio.create_subprocess_exec( + "tshark", "--version", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=TSHARK_TIMEOUT_SECONDS) + return stdout.decode(errors="replace").splitlines()[0].strip() + except (OSError, asyncio.TimeoutError, IndexError): + raise TsharkMissingError("tshark is not available on this server") + + +def _tshark_scratch_copy(pcap): + """ + Copy the pcap to a scratch file under the system temp dir for tshark to + read. Hardened tshark profiles (AppArmor &c.) can deny it access to the + project directory / the user's home while still allowing /tmp — a real + copy, deliberately not a symlink, since the profile resolves real paths. + Caller must unlink the returned path. + """ + + fd, scratch = tempfile.mkstemp(suffix=".pcap", prefix="gns3-replay-") + os.close(fd) + shutil.copyfile(pcap, scratch) + return scratch + + +def _tshark_env(): + """Scratch HOME so tshark never even tries to read the user's home.""" + + env = dict(os.environ) + env["HOME"] = tempfile.gettempdir() + return env + + +def _pdml_to_nodes(element): + """ + Isomorphic PDML → JSON mapping: every XML attribute becomes a JSON key + verbatim (values stay strings), children nest under ``children``. The + element tag ("proto"/"field") is carried as ``element`` — the one + structural key beyond the attributes, so a renderer can tell a protocol + group from a leaf field (geninfo's tagless names make names unreliable). + """ + + return { + "element": element.tag, + **element.attrib, + "children": [_pdml_to_nodes(child) for child in element], + } + + +def _count_nodes(nodes): + return 1 + sum(_count_nodes(child) for child in nodes.get("children", [])) + + +async def decode_frame(project, tag, ts, node_id, link_id, marker): + """ + Decode exactly one frame: locate its pcap by source identity, verify the + round-tripped ts still matches the file (guards a rebuild between the + timeline view and this click), read the raw bytes for the hex view, and + map tshark's PDML of that single frame to JSON. + """ + + entries = gate_tag(project, tag) + entry = next( + (e for e in entries + if e["node_id"] == node_id and e["link_id"] == link_id and e["marker"] == marker), + None, + ) + if entry is None: + raise ControllerNotFoundError( + f"No marker '{marker}' with tag {tag} on link {link_id} captured by {node_id}" + ) + + pcap = os.path.join(project.markers_directory, f"{node_id}_{link_id}_{marker}.pcap") + if not os.path.exists(pcap): + raise ControllerNotFoundError(f"No capture file for marker '{marker}' (nothing ever matched)") + + frames = scan_pcap_frames(pcap) + # The ts must be the exact string the timeline returned; find the frame + # it identifies rather than trusting any position hint from the client. + frame_number = next( + (i for i, (sec, usec, _len) in enumerate(frames, start=1) + if _format_ts(sec, usec) == ts), + None, + ) + if frame_number is None: + raise ControllerNotFoundError( + f"No frame at ts {ts} in marker '{marker}' (the capture may have been rebuilt)" + ) + + raw_hex = read_frame_bytes(pcap, frame_number) + + if shutil.which("tshark") is None: + raise TsharkMissingError("tshark is not installed — frame detail is unavailable") + version = await _tshark_version() + + # Hand tshark a scratch copy under the temp dir: hardened profiles may + # deny it the project directory even though this process can read it + # (the hex view above reads the original directly). + scratch = _tshark_scratch_copy(pcap) + try: + try: + proc = await asyncio.create_subprocess_exec( + "tshark", "-r", scratch, "-T", "pdml", "-Y", f"frame.number == {frame_number}", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=_tshark_env(), + ) + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=TSHARK_TIMEOUT_SECONDS + ) + except OSError as e: + raise TsharkError(f"Could not run tshark: {e}") + except asyncio.TimeoutError: + raise TsharkError(f"tshark timed out after {TSHARK_TIMEOUT_SECONDS:.0f}s") + if proc.returncode != 0 or not stdout.strip(): + # Never feed truncated/failed output to the mapper. + raise TsharkError(f"tshark failed: {stderr.decode(errors='replace').strip()[:500]}") + finally: + try: + os.unlink(scratch) + except OSError: + pass + + try: + root = ET.fromstring(stdout) + except ET.ParseError as e: + raise TsharkError(f"Malformed PDML from tshark: {e}") + + packet = root.find("./packet") + tree = [_pdml_to_nodes(child) for child in packet] if packet is not None else [] + return { + "ts": ts, + "source": {"node_id": node_id, "link_id": link_id, "marker": marker, + "frame_number": frame_number}, + "tshark_version": version, + "field_count": sum(_count_nodes(node) for node in tree), + "hex": raw_hex, + "tree": tree, + } diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index 8ea491b3c..c9189f8d6 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -30,6 +30,7 @@ from .controller_error import ( from .node_types import BUILTIN_NODE_TYPES from .ports.port_factory import PortFactory, StandardPortFactory, DynamipsPortFactory from ..utils.images import images_directories +from ..utils.application_id import is_iol_runner_environment from ..utils import macaddress_to_int, int_to_macaddress from ..config import Config @@ -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 diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 01506a734..b69a587b6 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -40,7 +40,7 @@ from .udp_link import UDPLink from .link import _UNSET from ..config import Config from ..utils.path import check_path_allowed, get_default_project_directory -from ..utils.application_id import get_next_application_id +from ..utils.application_id import get_next_application_id, is_iol_runner_environment from ..utils.asyncio.pool import Pool from ..utils.packet_filter_validation import validate_bpf_syntax from ..utils.asyncio import locking @@ -69,6 +69,21 @@ def open_required(func): return wrapper +def _is_iol_docker_kwargs(kwargs) -> bool: + """ + Whether add_node() kwargs describe an iol-runner Docker node. The + environment can arrive nested in a ``properties`` dict or as a top-level + kwarg (the template path spreads it), matching the two shapes IOU + application-id injection handles. + """ + + if "properties" in kwargs.keys(): + environment = (kwargs.get("properties") or {}).get("environment") + else: + environment = kwargs.get("environment") + return is_iol_runner_environment(environment) + + class Project: """ A project inside a controller @@ -159,7 +174,7 @@ class Project: assert self._status != "closed" self.dump() - self._iou_id_lock = asyncio.Lock() + self._application_id_lock = asyncio.Lock() # Serialise the "ensure project exists on this compute" check in # _create_node: without it, concurrent node creations all pass the # `compute not in _project_created_on_compute` check before any has @@ -504,6 +519,17 @@ class Project: os.makedirs(path, exist_ok=True) return path + @property + def markers_directory(self): + """ + Location of the marker pcap files (same layout the compute side writes + via ``markers_working_directory`` — single-server deployments share the + project directory, which is what tag replay reads). + """ + path = os.path.join(self._path, "project-files", "markers") + os.makedirs(path, exist_ok=True) + return path + @property def pictures_directory(self): """ @@ -646,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 @@ -658,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()) diff --git a/gns3server/schemas/compute/docker_nodes.py b/gns3server/schemas/compute/docker_nodes.py index 7b39921f6..e2023d407 100644 --- a/gns3server/schemas/compute/docker_nodes.py +++ b/gns3server/schemas/compute/docker_nodes.py @@ -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): diff --git a/gns3server/utils/application_id.py b/gns3server/utils/application_id.py index 9c9d8b347..488fb70b8 100644 --- a/gns3server/utils/application_id.py +++ b/gns3server/utils/application_id.py @@ -13,6 +13,7 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . +# from gns3server.controller.controller_error import ControllerError @@ -20,13 +21,32 @@ import logging log = logging.getLogger(__name__) +# IOU draws from the lower half: uBridge's iol_bridge uses application_id + 512 +# for its netio peer endpoint. IOL Docker nodes (iol-runner images) draw from +# the upper half: their netiomux peer is the fixed id 1023. Both node types +# derive interface MACs from the id (aabb.cc{app}{iface}), so the two pools +# must stay disjoint — a shared id silently blackholes traffic between the +# nodes as a MAC loop. +IOU_APPLICATION_ID_POOL = range(1, 512) +IOL_DOCKER_APPLICATION_ID_POOL = range(512, 1023) -def get_next_application_id(projects, computes): + +def is_iol_runner_environment(environment) -> bool: + """ + Whether a Docker node environment carries the GNS3_IOL_RUNNER marker — + the same signal the compute uses to select the IOLDockerVM class. + """ + + return "GNS3_IOL_RUNNER=" in (environment or "") + + +def get_next_application_id(projects, computes, iol_docker=False): """ Calculates free application_id from given nodes :param projects: all projects managed by controller :param computes: all computes used by the project + :param iol_docker: allocate for an IOL Docker (iol-runner) node instead of IOU :raises HTTPConflict when exceeds number :return: integer first free id """ @@ -38,12 +58,26 @@ def get_next_application_id(projects, computes): if project.status == "opened": nodes.extend(list(project.nodes.values())) - used = {n.properties["application_id"] for n in nodes if n.node_type == "iou" and n.compute.id in computes} - pool = set(range(1, 512)) + if iol_docker: + used = { + n.properties["application_id"] + for n in nodes + if n.node_type == "docker" + and n.compute.id in computes + and "application_id" in n.properties + and is_iol_runner_environment(n.properties.get("environment")) + } + pool = set(IOL_DOCKER_APPLICATION_ID_POOL) + limit = "511 IOL Docker nodes" + else: + used = {n.properties["application_id"] for n in nodes if n.node_type == "iou" and n.compute.id in computes} + pool = set(IOU_APPLICATION_ID_POOL) + limit = "512 nodes" try: application_id = (pool - used).pop() return application_id except KeyError: raise ControllerError( - "Cannot create a new IOU node (limit of 512 nodes across all opened projects using the same computes)" + f"Cannot create a new {'IOL Docker' if iol_docker else 'IOU'} node " + f"(limit of {limit} across all opened projects using the same computes)" ) diff --git a/tests/api/routes/compute/test_docker_nodes.py b/tests/api/routes/compute/test_docker_nodes.py index efadd604b..b9fa79fd8 100644 --- a/tests/api/routes/compute/test_docker_nodes.py +++ b/tests/api/routes/compute/test_docker_nodes.py @@ -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", ( diff --git a/tests/api/routes/compute/test_projects.py b/tests/api/routes/compute/test_projects.py index 949c0d891..6131db742 100644 --- a/tests/api/routes/compute/test_projects.py +++ b/tests/api/routes/compute/test_projects.py @@ -315,6 +315,37 @@ class TestBatchNIOEdgeCases: await _add_nio_binding(node, 1, 2, nio) node.adapter_add_nio_binding.assert_called_once_with(1, 2, nio) + @pytest.mark.asyncio + async def test_docker_dispatch_keeps_port_number(self): + """ + Docker adapters can be multi-port (iol-runner nodes model 4 ports per + adapter). Dropping port_number binds every NIO to port 0 where + add_nio() silently overwrites — reopened projects then end up with + cross-wired links (the last entry per node wins). + """ + from unittest.mock import AsyncMock, MagicMock + from gns3server.api.routes.compute.projects import ( + _add_nio_binding, + _get_existing_nio, + _update_nio_binding, + ) + + node = MagicMock() + type(node.manager).__name__ = "Docker" + node.adapter_add_nio_binding = AsyncMock() + node.adapter_update_nio_binding = AsyncMock() + nio = MagicMock() + node.get_nio = MagicMock(return_value=nio) + + await _add_nio_binding(node, 0, 1, nio) + node.adapter_add_nio_binding.assert_called_once_with(0, nio, 1) + + assert _get_existing_nio(node, 0, 1) is nio + node.get_nio.assert_called_once_with(0, 1) + + await _update_nio_binding(node, 0, 1, nio) + node.adapter_update_nio_binding.assert_called_once_with(0, nio, 1) + @pytest.mark.asyncio async def test_vpcs_dispatch_to_port_add_nio_binding(self): """_add_nio_binding dispatches VPCS to port_add_nio_binding.""" diff --git a/tests/api/routes/controller/test_marker_replay.py b/tests/api/routes/controller/test_marker_replay.py new file mode 100644 index 000000000..9a19e291d --- /dev/null +++ b/tests/api/routes/controller/test_marker_replay.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python +# +# 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-route tests for the tag replay endpoints: the tag gate (409 while any +marker captures, 404 unknown tag), the merged timeline, window queries +(empty window = success), and the lazy frame detail (ts guard, isomorphic +JSON, 501 when tshark is unavailable). +""" + +import shutil +from unittest.mock import patch + +import pytest +from fastapi import FastAPI, status +from httpx import AsyncClient + +from gns3server.controller.project import Project +from gns3server.controller.udp_link import UDPLink + +from tests.controller.test_marker_replay import _write_pcap, _icmp_frame + +pytestmark = pytest.mark.asyncio + +tshark_present = pytest.mark.skipif(shutil.which("tshark") is None, reason="tshark not installed") + + +def _add_marker(project, tag, enabled, node_id, frames=None): + """Create a paused/capturing link+marker and optionally its pcap.""" + + link = UDPLink(project) + link._markers["icmp"] = {"bpf": "icmp", "tag": tag, "enabled": enabled, "color": None, + "highlight_duration": None, "capture_node_id": node_id, + "direction": None, "data_link_type": "DLT_EN10MB"} + project._links[link.id] = link + if frames is not None: + _write_pcap( + f"{project.markers_directory}/{node_id}_{link.id}_icmp.pcap", frames + ) + return link + + +class TestReplayRoutes: + + async def test_range_409_while_capturing(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + _add_marker(project, tag=7, enabled=False, node_id="n1") + running = _add_marker(project, tag=7, enabled=True, node_id="n2") + + response = await client.get( + app.url_path_for("replay_tag_range", project_id=project.id, tag=7) + ) + assert response.status_code == status.HTTP_409_CONFLICT + assert "icmp" in response.json()["message"] + assert running.id in response.json()["message"] + + async def test_range_404_unknown_tag(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + _add_marker(project, tag=7, enabled=False, node_id="n1") + + response = await client.get( + app.url_path_for("replay_tag_range", project_id=project.id, tag=99) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + async def test_range_merges_sources_in_ts_order(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + # r1→r2 captures at t1 and t3; r2→r3 captures at t2 and t3 (same µs + # as source A's t3 — the tiebreak must keep both frames). + _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ + (1693472000, 500000, b"a" * 60), + (1693472002, 0, b"a" * 60), + ]) + _add_marker(project, tag=7, enabled=False, node_id="n2", frames=[ + (1693472001, 0, b"b" * 60), + (1693472002, 0, b"b" * 60), + ]) + + response = await client.get( + app.url_path_for("replay_tag_range", project_id=project.id, tag=7) + ) + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["tag"] == 7 + assert body["frame_count"] == 4 + assert body["start"] == "1693472000.500000" + assert body["end"] == "1693472002.000000" + assert body["truncated"] is False + assert [f["node_id"] for f in body["frames"]] == ["n1", "n2", "n1", "n2"] + assert [f["ts"] for f in body["frames"]] == [ + "1693472000.500000", "1693472001.000000", + "1693472002.000000", "1693472002.000000", + ] + assert len(body["sources"]) == 2 + + async def test_frames_window_miss_is_empty_success( + self, app: FastAPI, client: AsyncClient, project: Project + ) -> None: + + _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ + (1693472000, 0, b"a" * 60), + ]) + + response = await client.get( + app.url_path_for("replay_tag_frames", project_id=project.id, tag=7), + params={"ts": "1693472001.000000", "window_ms": 100}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"frames": []} + + async def test_frames_window_hit(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ + (1693472000, 0, b"a" * 60), + (1693472000, 150000, b"a" * 60), + ]) + + response = await client.get( + app.url_path_for("replay_tag_frames", project_id=project.id, tag=7), + params={"ts": "1693472000.000000", "window_ms": 150}, + ) + assert response.status_code == status.HTTP_200_OK + assert [f["ts"] for f in response.json()["frames"]] == [ + "1693472000.000000", "1693472000.150000" + ] + + async def test_detail_404_on_ts_mismatch(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ + (1693472000, 123456, _icmp_frame()), + ]) + + response = await client.get( + app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7), + params={"ts": "1.000000", "node_id": "n1", "link_id": link.id, "marker": "icmp"}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "rebuilt" in response.json()["message"] + + async def test_detail_501_without_tshark(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ + (1693472000, 123456, _icmp_frame()), + ]) + + with patch("gns3server.controller.marker_replay.shutil.which", return_value=None): + response = await client.get( + app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7), + params={"ts": "1693472000.123456", "node_id": "n1", + "link_id": link.id, "marker": "icmp"}, + ) + assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED + + @tshark_present + async def test_detail_decodes_single_frame(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = _add_marker(project, tag=7, enabled=False, node_id="n1", frames=[ + (1693472000, 123456, _icmp_frame()), + ]) + + response = await client.get( + app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7), + params={"ts": "1693472000.123456", "node_id": "n1", + "link_id": link.id, "marker": "icmp"}, + ) + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["source"]["frame_number"] == 1 + assert body["hex"] == _icmp_frame().hex() + assert body["field_count"] > 10 + assert "tshark" in body["tshark_version"].lower() + + ip = next(p for p in body["tree"] if p.get("name") == "ip") + ttl = next(f for f in ip["children"] if f.get("name") == "ip.ttl") + # Values arrive as strings, exactly as tshark emitted them. + assert ttl["show"] == "64" and ttl["showname"] == "Time to Live: 64" diff --git a/tests/compute/docker/test_docker_vm.py b/tests/compute/docker/test_docker_vm.py index 10336e2b1..6c6e109a1 100644 --- a/tests/compute/docker/test_docker_vm.py +++ b/tests/compute/docker/test_docker_vm.py @@ -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" @@ -1798,7 +1798,7 @@ async def test_adapter_add_nio_binding_sets_carrier(vm): await vm.adapter_add_nio_binding(0, nio) - vm._set_adapter_carrier.assert_called_once_with(0, True) + vm._set_adapter_carrier.assert_called_once_with(0, True, 0) @pytest.mark.asyncio @@ -1831,7 +1831,7 @@ async def test_adapter_update_nio_binding_sets_suspended_carrier(vm): await vm.adapter_update_nio_binding(0, nio) - vm._set_adapter_carrier.assert_called_once_with(0, False) + vm._set_adapter_carrier.assert_called_once_with(0, False, 0) @pytest.mark.asyncio diff --git a/tests/compute/docker/test_iol_docker_vm.py b/tests/compute/docker/test_iol_docker_vm.py new file mode 100644 index 000000000..4826b00a7 --- /dev/null +++ b/tests/compute/docker/test_iol_docker_vm.py @@ -0,0 +1,821 @@ +# +# 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 . + +""" +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 + + +# --------------------------------------------------------------------------- +# Upstream link-carrier / interface-monitor vs unix-socket NIO +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_link_operations_never_send_tap_carrier(compute_project, manager): + """ + uBridge rejects set_nio_tap_carrier on a bridge without a TAP NIO + ("bridge has no TAP NIO"), and unix-socket NIO bridges never have one: + every link create/update/delete on a running node would fail. The + vendor override must keep the carrier command out of all three paths. + """ + + vm = _make_vm(compute_project, manager) + vm._ubridge_hypervisor = MagicMock() # truthy ubridge, like a started node + vm._ubridge_send = AsyncioMagicMock() + vm.status = "started" + nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) + + await vm.adapter_add_nio_binding(0, nio, 0) + await vm.adapter_update_nio_binding(0, nio, 0) + await vm.adapter_remove_nio_binding(0, 0) + + commands = [str(c.args[0]) for c in vm._ubridge_send.call_args_list] + assert any(c.startswith("bridge add_nio_udp") for c in commands) + assert not any("set_nio_tap_carrier" in c for c in commands) + + +@pytest.mark.asyncio +async def test_interface_monitor_disabled_under_unix_nio(compute_project, manager): + + vm = _make_vm(compute_project, manager) + vm.manager.query = AsyncioMagicMock() + + await vm._start_interface_monitor() # must be a no-op: no eth NICs to poll + await vm._stop_interface_monitor() + + vm.manager.query.assert_not_called() + assert vm._interface_monitor_task is None + + +@pytest.mark.asyncio +async def test_tap_wired_vendor_keeps_carrier_and_monitor(compute_project, manager): + """ + Vendor nodes with TAP wiring (XRd, SR Linux, ...) keep the base + behavior: the guards only apply to unix-socket NIO. + """ + + vm = VendorDockerVM("vendor-1", str(uuid.uuid4()), compute_project, manager, "vendor:latest", + console_type="docker_exec", environment="GNS3_SKIP_INIT=1") + with patch.object(DockerVM, "_set_adapter_carrier", new=AsyncioMagicMock()) as carrier, \ + patch.object(DockerVM, "_start_interface_monitor", new=AsyncioMagicMock()) as monitor: + await vm._set_adapter_carrier(0, True) + await vm._start_interface_monitor() + # the base-class attribute is replaced by a plain mock (no descriptor + # binding), so super() calls arrive without self + carrier.assert_called_once_with(0, True, 0) + monitor.assert_called_once_with() + + +# --------------------------------------------------------------------------- +# 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 diff --git a/tests/compute/marker/test_marker_manager.py b/tests/compute/marker/test_marker_manager.py index da495ae92..5917870d7 100644 --- a/tests/compute/marker/test_marker_manager.py +++ b/tests/compute/marker/test_marker_manager.py @@ -121,7 +121,11 @@ class TestMarkerListener: assert ev["node_id"] == "n1" assert ev["link_id"] == "l1" assert ev["filter"] == "f1" - assert ev["tag"] == "7" + # The event tag is normalized to int to match the REST schema — the + # signal echoes the decimal we installed, so str and int variants of + # the same tag must never reach consumers as different keys. + assert ev["tag"] == 7 + assert isinstance(ev["tag"], int) assert ev["ts"] == pytest.approx(1700000000.123456) assert ev["len"] == 98 # No dir= in the signal (legacy uBridge) → undirected. @@ -163,6 +167,24 @@ class TestMarkerListener: lis.datagram_received(b"MARK 2.0 node=n filter=f tag=- len=20\n", None) assert fmgr.events[0][1]["tag"] == 42 + def test_non_numeric_signal_tag_falls_back_to_registered(self): + # A corrupt/unknown signal value must not leak a str tag into the + # event stream — the registry's int wins. + fmgr = FakeMarkerManager() + fmgr.register("p", "n", "f", "l", tag=42) + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK 2.0 node=n filter=f tag=oops len=20\n", None) + assert fmgr.events[0][1]["tag"] == 42 + + def test_no_tag_anywhere_is_none(self): + fmgr = FakeMarkerManager() + fmgr.register("p", "n", "f", "l", tag=None) + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK 2.0 node=n filter=f tag=- len=20\n", None) + assert fmgr.events[0][1]["tag"] is None + def test_link_in_signal_overrides_registry_link(self): # Per-link attribution (contract §3.2/§3.3): the signal's `link=` is # authoritative and must disambiguate links sharing a node+filter — diff --git a/tests/conftest.py b/tests/conftest.py index f67b04839..b0994ebb8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -418,7 +418,13 @@ def run_around_tests(monkeypatch, config, port_manager): # avoid monitoring for new images while testing config.settings.Server.auto_discover_images = False - monkeypatch.setattr("gns3server.utils.path.get_default_project_directory", lambda *args: os.path.join(tmppath, 'projects')) + # Resolve the projects directory from the Config singleton at call time instead of + # closing over this test's tmppath: a from-import executed anywhere while this + # patch is active (e.g. the first import of gns3server.api.server from inside a + # test body) freezes the patched object into the importing module's namespace + # forever, and a closed-over path would then point at a deleted directory in + # every later test (order-dependent FileNotFoundError in psutil.disk_usage). + monkeypatch.setattr("gns3server.utils.path.get_default_project_directory", lambda *args: Config.instance().settings.Server.projects_path) # Force sys.platform to the original value. Because it seems not be restored correctly after each test sys.platform = sys.original_platform diff --git a/tests/controller/test_compute.py b/tests/controller/test_compute.py index 58ba606c4..671b8e7a0 100644 --- a/tests/controller/test_compute.py +++ b/tests/controller/test_compute.py @@ -25,6 +25,7 @@ from unittest.mock import patch, MagicMock from gns3server.controller.project import Project from gns3server.controller.compute import Compute +from gns3server.api.server import app as gns3_app from gns3server.controller.controller_error import ( ControllerError, ControllerNotFoundError, @@ -581,8 +582,10 @@ async def test_connect_notification_poison_frame_autoreconnects(compute, monkeyp compute._http_session = session # allow the reconnection to be scheduled during the test + # (import gns3_app at module top: a first import from inside a test body + # would execute module-level from-imports while the autouse fixture's + # monkeypatches are active, freezing patched objects into namespaces) monkeypatch.delattr(sys, "_called_from_test", raising=False) - from gns3server.api.server import app as gns3_app monkeypatch.setattr(gns3_app.state, "exiting", False) async def fake_connect(): diff --git a/tests/controller/test_controller.py b/tests/controller/test_controller.py index dad07581e..412a9b019 100644 --- a/tests/controller/test_controller.py +++ b/tests/controller/test_controller.py @@ -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: diff --git a/tests/controller/test_marker_replay.py b/tests/controller/test_marker_replay.py new file mode 100644 index 000000000..e33fb9a8c --- /dev/null +++ b/tests/controller/test_marker_replay.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python +# +# 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 . + +""" +Unit tests for the tag-keyed aggregate replay module (controller layer): + +* pcap record-header scanning (ts extraction, truncated-tail tolerance, + nanosecond-magic normalization) and raw-bytes reads for the hex view +* the tag gate (404 unknown tag, 409 while any marker captures) +* timeline assembly: cross-source merge ordered by (ts, source, frame number), + same-microsecond tiebreak, missing-pcap sources, frame-cap degradation to + per-second buckets +* window queries (inclusive bounds, empty-window success) +* the lazy frame detail: round-tripped-ts guard, and — where tshark is + installed — the isomorphic PDML → JSON mapping with a round-trip check + (element count and attribute coverage). +""" + +import os +import shutil +import struct + +import pytest +from types import SimpleNamespace + +from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError +from gns3server.controller.marker_replay import ( + build_timeline, + decode_frame, + query_frames, + read_frame_bytes, + scan_pcap_frames, + _format_ts, + _parse_ts, +) + +pytestmark = pytest.mark.asyncio + +PCAP_MAGIC_US = 0xA1B2C3D4 +PCAP_MAGIC_NS = 0xA1B23C4D + + +def _write_pcap(path, frames, magic=PCAP_MAGIC_US, snaplen=65535): + """frames: list of (sec, frac, payload bytes); frac is µs (or ns for the ns magic).""" + + with open(path, "wb") as f: + f.write(struct.pack("> 16: + s = (s & 0xFFFF) + (s >> 16) + return (~s) & 0xFFFF + + icmp = bytes([8, 0, 0, 0]) + struct.pack(">HHH", 1, 1, 0) + b"payload12" + icmp = icmp[:2] + struct.pack(">H", cksum(icmp)) + icmp[4:] + ip0 = struct.pack(">BBHHHBBH4s4s", 0x45, 0, 20 + len(icmp), 1, 0, 64, 1, 0, + bytes([10, 0, 0, 1]), bytes([10, 0, 0, 3])) + ip = ip0[:10] + struct.pack(">H", cksum(ip0)) + ip0[12:] + return bytes.fromhex("0200000000020200000000010800") + ip + icmp + + +def _fake_project(tmp_path, markers, markers_dir=None): + """markers: the flat project.markers shape ({'link/name': {..., node_id}}).""" + + return SimpleNamespace(markers=markers, markers_directory=str(markers_dir or tmp_path)) + + +def _marker_entry(tag, enabled=True, node_id="node-1"): + return {"bpf": "icmp", "tag": tag, "enabled": enabled, "color": None, + "highlight_duration": None, "capture_node_id": node_id, + "direction": None, "data_link_type": "DLT_EN10MB", + "node_id": node_id} + + +# --------------------------------------------------------------------------- +# pcap scanning +# --------------------------------------------------------------------------- + +class TestScanPcap: + + async def test_scans_frames_and_truncated_tail(self, tmp_path): + pcap = tmp_path / "a.pcap" + _write_pcap(pcap, [ + (1693472000, 123456, b"x" * 60), + (1693472001, 654321, b"y" * 40), + ]) + # Tear the final record in half: a snapshot mid-write must not raise. + data = bytearray(pcap.read_bytes()) + pcap.write_bytes(data[:len(data) - 20]) + + frames = scan_pcap_frames(str(pcap)) + assert frames == [(1693472000, 123456, 60)] + + async def test_ns_magic_normalized_to_us(self, tmp_path): + pcap = tmp_path / "ns.pcap" + _write_pcap(pcap, [(1693472000, 1500000, b"z" * 10)], magic=PCAP_MAGIC_NS) + assert scan_pcap_frames(str(pcap)) == [(1693472000, 1500, 10)] # 1.5 ms in µs + + async def test_read_frame_bytes_offsets(self, tmp_path): + pcap = tmp_path / "b.pcap" + _write_pcap(pcap, [ + (100, 0, b"first" + b"0" * 55), # 60 bytes + (200, 0, b"second"), # 6 bytes + ]) + assert read_frame_bytes(str(pcap), 2) == b"second".hex() + assert read_frame_bytes(str(pcap), 1) == (b"first" + b"0" * 55).hex() + assert read_frame_bytes(str(pcap), 3) is None + + async def test_ts_string_round_trip_is_exact(self): + ts = _format_ts(1693472000, 5) + assert ts == "1693472000.000005" + assert _parse_ts(ts) == 1693472000000005 + assert _parse_ts(_format_ts(1693472000, 123456)) == 1693472000123456 + + +# --------------------------------------------------------------------------- +# Tag gate + timeline +# --------------------------------------------------------------------------- + +class TestGateAndTimeline: + + async def test_unknown_tag_404(self, tmp_path): + project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=1)}) + with pytest.raises(ControllerNotFoundError): + build_timeline(project, tag=7) + + async def test_gate_409_while_capturing(self, tmp_path): + project = _fake_project(tmp_path, { + "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), + "linkB/icmp": _marker_entry(tag=7, enabled=True, node_id="n2"), + }) + with pytest.raises(ControllerError, match="linkB"): + build_timeline(project, tag=7) + + async def test_merge_orders_by_ts_with_stable_tiebreak(self, tmp_path): + # Two sources, deliberately interleaved in time, colliding on one µs. + _write_pcap(tmp_path / "n1_linkA_icmp.pcap", [ + (1693472000, 500000, b"a" * 60), # t1 sourceA + (1693472002, 000000, b"a" * 60), # t3 sourceA + ]) + _write_pcap(tmp_path / "n2_linkB_icmp.pcap", [ + (1693472001, 000000, b"b" * 60), # t2 sourceB + (1693472002, 000000, b"b" * 60), # t3 sourceB — same µs as t3 sourceA + ]) + project = _fake_project(tmp_path, { + "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), + "linkB/icmp": _marker_entry(tag=7, enabled=False, node_id="n2"), + }) + + timeline = build_timeline(project, tag=7) + assert timeline["frame_count"] == 4 + assert timeline["start"] == "1693472000.500000" + assert timeline["end"] == "1693472002.000000" + assert [f["node_id"] for f in timeline["frames"]] == ["n1", "n2", "n1", "n2"] + # Same-microsecond pair keeps both frames (a ts dict key would drop one). + assert [f["ts"] for f in timeline["frames"]][2:] == ["1693472002.000000"] * 2 + assert [f["frame_number"] for f in timeline["frames"]] == [1, 1, 2, 2] + assert {s["count"] for s in timeline["sources"]} == {2} + + async def test_missing_pcap_is_zero_count_source(self, tmp_path): + project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False)}) + timeline = build_timeline(project, tag=7) + assert timeline["frame_count"] == 0 + assert timeline["start"] is None and timeline["end"] is None + assert timeline["frames"] == [] + assert timeline["sources"][0]["count"] == 0 + + async def test_over_cap_degrades_to_buckets(self, tmp_path): + _write_pcap(tmp_path / "n1_linkA_icmp.pcap", [ + (1693472000, 0, b"a" * 60), (1693472000, 500000, b"a" * 60), + (1693472001, 0, b"a" * 60), + ]) + project = _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")}) + + timeline = build_timeline(project, tag=7, frame_cap=2) + assert timeline["truncated"] is True + assert "frames" not in timeline + assert timeline["buckets"] == [ + {"ts": "1693472000.000000", "count": 2}, + {"ts": "1693472001.000000", "count": 1}, + ] + + +# --------------------------------------------------------------------------- +# Window query +# --------------------------------------------------------------------------- + +class TestQueryFrames: + + def _project(self, tmp_path): + _write_pcap(tmp_path / "n1_linkA_icmp.pcap", [ + (1693472000, 0, b"a" * 60), + (1693472000, 150000, b"a" * 60), + (1693472005, 0, b"a" * 60), + ]) + return _fake_project(tmp_path, {"linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1")}) + + async def test_window_inclusive_bounds(self, tmp_path): + result = query_frames(self._project(tmp_path), tag=7, ts="1693472000.000000", window_ms=150) + assert [f["ts"] for f in result["frames"]] == ["1693472000.000000", "1693472000.150000"] + + async def test_window_miss_is_empty_success(self, tmp_path): + result = query_frames(self._project(tmp_path), tag=7, ts="1693472001.000000", window_ms=100) + assert result == {"frames": []} + + async def test_limit_applies(self, tmp_path): + result = query_frames(self._project(tmp_path), tag=7, ts="1693472000.000000", + window_ms=150, limit=1) + assert len(result["frames"]) == 1 + + +# --------------------------------------------------------------------------- +# Frame detail (tshark path) +# --------------------------------------------------------------------------- + +tshark_present = pytest.mark.skipif(shutil.which("tshark") is None, reason="tshark not installed") + + +class TestDecodeFrame: + + def _project(self, tmp_path): + _write_pcap(tmp_path / "n1_linkA_icmp.pcap", [ + (1693472000, 123456, _icmp_frame()), + ]) + return _fake_project(tmp_path, { + "linkA/icmp": _marker_entry(tag=7, enabled=False, node_id="n1"), + }) + + async def test_ts_mismatch_guard_404(self, tmp_path): + project = self._project(tmp_path) + with pytest.raises(ControllerNotFoundError, match="rebuilt"): + await decode_frame(project, tag=7, ts="1.000000", + node_id="n1", link_id="linkA", marker="icmp") + + async def test_unknown_source_404(self, tmp_path): + project = self._project(tmp_path) + with pytest.raises(ControllerNotFoundError): + await decode_frame(project, tag=7, ts="1693472000.123456", + node_id="nobody", link_id="linkA", marker="icmp") + + async def test_decode_feeds_tshark_a_scratch_copy(self, tmp_path): + """Hardened tshark profiles deny the project dir — tshark must read a + /tmp copy (a real copy, not a symlink) that is unlinked afterwards.""" + + import tempfile + from unittest.mock import patch, AsyncMock + + observed = [] + PDML = (b'' + b'' + b'') + + class FakeProc: + returncode = 0 + + async def communicate(self): + return PDML, b"" + + async def fake_exec(*args, **kwargs): + r_index = args.index("-r") + observed.append((args[r_index + 1], kwargs.get("env"))) + return FakeProc() + + project = self._project(tmp_path) + with patch("gns3server.controller.marker_replay.shutil.which", return_value="tshark"), \ + patch("gns3server.controller.marker_replay._tshark_version", AsyncMock(return_value="tshark 4.6.7")), \ + patch("gns3server.controller.marker_replay.asyncio.create_subprocess_exec", side_effect=fake_exec): + detail = await decode_frame(project, tag=7, ts="1693472000.123456", + node_id="n1", link_id="linkA", marker="icmp") + + assert detail["field_count"] == 2 # proto + field from the canned PDML + (scratch, env), = observed + original = str(tmp_path / "n1_linkA_icmp.pcap") + assert scratch != original + assert scratch.startswith(tempfile.gettempdir()) and scratch.endswith(".pcap") + assert env["HOME"] == tempfile.gettempdir() + assert not os.path.exists(scratch) # cleaned up after the decode + + @tshark_present + async def test_decode_isomorphic_mapping(self, tmp_path): + import asyncio + import xml.etree.ElementTree as ET + + project = self._project(tmp_path) + detail = await decode_frame(project, tag=7, ts="1693472000.123456", + node_id="n1", link_id="linkA", marker="icmp") + + assert detail["source"]["frame_number"] == 1 + assert detail["hex"] == _icmp_frame().hex() + assert detail["field_count"] > 0 + assert "tshark" in detail["tshark_version"].lower() + + # Round-trip fidelity: node count equals the PDML element count + # (protos + fields, excluding the container itself)… + proc = await asyncio.create_subprocess_exec( + "tshark", "-r", str(tmp_path / "n1_linkA_icmp.pcap"), "-T", "pdml", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + ) + stdout, _ = await proc.communicate() + packet = ET.fromstring(stdout).find("./packet") + xml_elements = [e for e in packet.iter() if e is not packet] + assert detail["field_count"] == len(xml_elements) + + # …and every XML attribute survives verbatim as a JSON string key. + def walk(element, node): + for key, value in element.attrib.items(): + assert node.get(key) == value + assert all(isinstance(v, str) for k, v in node.items() if k != "children") + for child, child_node in zip(element, node["children"]): + walk(child, child_node) + + for element, node in zip(packet, detail["tree"]): + walk(element, node) + + # Values stay strings (no numeric re-typing). + ttl = next( + f for p in detail["tree"] if p.get("name") == "ip" + for f in p["children"] if f.get("name") == "ip.ttl" + ) + assert ttl["show"] == "64" and ttl["showname"] == "Time to Live: 64" diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index a20405637..e4ed15bd6 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -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 diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index 31a4a2e7a..373d05e10 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -296,6 +296,47 @@ async def test_add_node_iou(controller): assert node3.properties["application_id"] == 3 +@pytest.mark.asyncio +async def test_add_node_iol_docker(controller): + """ + IOL Docker nodes (GNS3_IOL_RUNNER marker) get an application ID from the + upper half of the id space, disjoint from IOU's lower half + """ + + compute = MagicMock() + compute.id = "local" + project = await controller.add_project(project_id=str(uuid.uuid4()), name="test1") + project.emit_notification = MagicMock() + + response = MagicMock() + compute.post = AsyncioMagicMock(return_value=response) + + # template shape: environment as a top-level kwarg + node1 = await project.add_node( + compute, "iol1", None, node_type="docker", image="iol-xe/iol-xe:17-18-02", environment="GNS3_IOL_RUNNER=1" + ) + # raw API shape: environment nested in properties + node2 = await project.add_node( + compute, + "iol2", + None, + node_type="docker", + properties={"image": "iol-xe/iol-xe:17-18-02", "environment": "GNS3_IOL_RUNNER=1"}, + ) + # plain docker nodes are left alone + node3 = await project.add_node( + compute, "web", None, node_type="docker", image="nginx", environment="FOO=1", adapters=1 + ) + + assert node1.properties["application_id"] == 512 + assert node2.properties["application_id"] == 513 + assert "application_id" not in node3.properties + + # IOU keeps its own pool: a subsequent IOU node still gets the lower half + node4 = await project.add_node(compute, "iou1", None, node_type="iou") + assert node4.properties["application_id"] == 1 + + @pytest.mark.asyncio async def test_add_node_iou_with_multiple_projects(controller): """