Merge remote-tracking branch 'origin/3.1' into gh-pages

This commit is contained in:
github-actions 2026-09-27 17:18:56 +00:00
commit 6dc3708237
501 changed files with 30013 additions and 23851 deletions

View File

@ -33,7 +33,7 @@ from gns3server.services import auth_service
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) # specific user
response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) # specific user
response = await client.get(url, headers={"Authorization": "Bearer invalid_token"}) # 401
```
@ -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/<new_file>.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=<n>` reproduces it), then bisect to the polluting test.

View File

@ -1,2 +1,9 @@
[run]
source = gns3server
branch = True
[report]
fail_under = 55
[paths]
source = gns3server

View File

@ -21,22 +21,23 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install .[ai-features,dev]
- name: Lint with flake8
- name: Install sharkd (marker replay engine)
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
sudo apt-get update
sudo apt-get install -y wireshark-common
- name: Run all checks
run: |
python -m pytest -vv
just test

4
.gitignore vendored
View File

@ -37,6 +37,10 @@ pip-log.txt
.tox
.cache
nosetests.xml
coverage.xml
htmlcov/
.mypy_cache/
.ruff_cache/
# Translations
*.mo

View File

@ -1,5 +1,92 @@
# Change Log
## 3.1.0a6 27/09/2026
* Bundle web-ui v3.1.0a6
* ci: no flake8 checks for now, ruff covers them
* ci: run just test
* fix(qemu): Fixed missing image replacement for linked-clone nodes
* Allow projects containing missing images to open in a degraded state instead of failing to load
* fix: make node working directory deletion robust
* feat: keep Docker images on computes consistent with the controller host
* feat: sync Docker images from the controller to remote computes
* fix: run the reclaim helper as root regardless of the image's default USER
* fix: reclaim root-owned container files so docker nodes and projects can be deleted
* ci: install sharkd in the test workflow and make manager tests hermetic
* fix: harden sharkd replay sessions and serve the uncapped frame list
* feat: accept link=<link_id> on the replay range and frames endpoints
* feat: drive marker replay with resident sharkd sessions
* fix: stop leaking a global os.kill mock from the shutdown route test
* fix: keep port_number in Docker NIO dispatch of the batch endpoints
* feat(copilot): make start_gns3_node immediate-return, add wait_seconds tool
* fix: keep upstream link-carrier commands off unix-socket NIO bridges
* feat: ship an iol-xe-base.txt base config for IOL Docker nodes
* fix: keep payload-delivered state across container (re)creation
* feat: IOU-style startup-config for IOL Docker nodes
* fix: normalize marker.match event tag to int to match the REST schema
* feat: allocate IOL Docker application IDs from a pool disjoint from IOU
* fix: derive per-node IOL app id so linked routers get distinct MACs
* feat: model IOL adapters as 4-port units like the IOU node type
* revert IOL port-name override; document custom_adapters instead
* feat: show IOL-style interface names on iol-runner ports
* refactor: wire unix-socket NIOs through a per-node runtime directory
* refactor: reach unix-socket NIOs through the container root in /proc
* fix: alias long unix-socket NIO paths through the runtime dir
* feat: add IOL (iol-runner) Docker node support
* fix: normalize marker.match event tag to int to match the REST schema
* fix: Fix failing tests and adress the MCP 2.x incompatibility failures
* feat: Add Docker link carrier and interface status support
* fix: list every project in template/image in-use refusal, not just the first
* feat: forbid deleting templates and images still used by projects
* fix: publish-api-documentation.yml
* fix: handle client disconnect in compute console WebSocket forwarding
* fix: update link_capture_download and symbol_get tool descriptions for tickets
* fix: replace Bearer JWTs with path-bound access tickets in MCP download tools
* fix: issue short-lived console tickets instead of JWTs in node_console MCP tool
* fix: handle client disconnect in console WebSocket forwarding
* fix: accept the 'local' compute id in compute tools
* fix: unify the device tool error contract
* fix: reject non-VPCS nodes in the VPCS config tool
* fix: dedupe and report automatic template creation from images
* fix: return the created template from appliance install
* fix: keep default node naming aligned with batch submission order
* fix: keep node file content byte-faithful in node_file_get
* fix: keep submission order and unify status in MCP batch handlers
* fix: propagate 405 when suspending a node without suspend support
* fix: report empty projects as not locked
* feat: expose data_link_type on the link_marker MCP tool (create-only)
* fix: never allow the projects directory to become a project directory
* refactor: sink shared REST handlers into gns3_client, drop gns3fy wrappers
* api: add endpoint serving the project .gns3 topology file
* fix: type optional configuration fields as Optional
* feat: add GET/PUT /v3/settings server settings API
* feat: add config read-modify-write update and harden file watcher
* refactor: move MCP service from api/routes to agent package
* fix: notification ping starved under sustained event load
* fix: compute notification stream silently died on uncaught exceptions
* docker: replace vendor SKIP_INIT exec volume bridge with create-time direct binds
* feat(copilot): device skills per-topic split layout and topic retrieval
* docker: don't lose a client size that races the exec creation
* docker: add GNS3_CONSOLE_RESIZE knob for paging CLIs
* console: forward client terminal size over the console WebSocket
* controller: allow markers and packet filters on Ethernet switch links
* docker: don't recreate containers on empty-string property PUTs
* mcp: don't run API keys through JWT validation
* mcp: fingerprint short-lived console tokens for copy-corruption checks
* auth: split JWT validation errors by failure cause
* docker: give the docker_exec console a tall default PTY geometry
* copilot: log into devices using the node default credentials
* api: expose installed netmiko device types for the web UI
* nodes: per-node default credentials seeded from the template
* templates: persist appliance metadata on install
* fix: address appliance v8 install review findings
* copilot: prefer netmiko_device_type over the device_type:<type> tag
* appliance: seed netmiko_device_type from the appliance file
* nodes: per-node netmiko_device_type (controller-only)
* templates: add netmiko_device_type for automation tools
* appliance: close the v8 gaps for Docker vendor appliances
* appliance: implement install support for registry version 8
## 3.1.0a5 19/08/2026
* Bundle web-ui v3.1.0a5

View File

@ -126,34 +126,37 @@ flake8 gns3server/utils/
import os # Never used
# Good: Delete this line
# F841: Mark as intentionally unused
# Bad
def foo():
x = 1 # Never used
# Good: Use or mark as intentionally unused
def foo():
x = 1
_ = x # Mark as intentionally unused
# F824: Remove unnecessary global declaration
# Bad
def foo():
global _fernet # Never assigned in this scope
# Good: Remove global statement (if only reading)
def foo():
# Just read the global variable, no global declaration needed
pass
# E501: Break long lines
# Bad
raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
# Good
raise RuntimeError(
"Encryption not initialized. Call init_encryption() first."
)
raise RuntimeError("Encryption not initialized. Call init_encryption() first.")
```
#### Ruff (Alternative)
@ -177,6 +180,7 @@ ruff format gns3server/ # Format code
def process_data(input_data: dict[str, Any]) -> list[dict[str, Any]]:
pass
# 2. Error handling
try:
result = tool.invoke(args)

View File

@ -1,6 +1,10 @@
pytest==9.1.1
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
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)
pytest-cov==7.1.0
ruff==0.16.9
mypy==2.3.1
rust-just==1.58.0 # provides the `just` command runner, see justfile

View File

@ -39,6 +39,8 @@ docs/
│ ├── statistics-api.md # Aggregated statistics API for monitoring
│ ├── vnc-websocket-console.md # Browser-based VNC console via WebSocket
│ └── web-wireshark-business-process.md # Web Wireshark (Docker + xpra packet capture)
├── design/ # Design proposals & roadmaps (not yet implemented)
│ └── docker-image-type.md # Docker image types: vendor profile discriminator + registry
├── gns3-copilot/ # AI Copilot feature documentation
│ ├── netmiko_devices.md # Netmiko supported devices (366 types)
│ ├── template-based-configuration-roadmap.md # Future: template-based config with HITL
@ -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_

View File

@ -279,7 +279,7 @@ Fix the WebSocket error handler:
```python
@app.exception_handler(ControllerNotFoundError)
async def controller_not_found_error_handler(request: Request, exc: ControllerNotFoundError):
method = getattr(request, 'method', 'WebSocket')
method = getattr(request, "method", "WebSocket")
log.error(f"Controller not found error in {request.url.path} ({method}): {exc}")
return JSONResponse(
status_code=status.HTTP_404_NOT_FOUND,

View File

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

View File

@ -0,0 +1,218 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt.
# IOL Images with iol-runner (Cisco CML containerized IOL) as Docker Nodes
## Overview
IOL images packaged with Cisco CML's container runner — for example
`iol-xe/iol-xe:17-18-02` (IOS-XE 17.18.02 IOL in a scratch image driven by
`iol-runner`, module `virl.lab/cmd/iol-runner`) — run as first-class GNS3
Docker router nodes with **zero changes to the image**. The integration adds
two generic server mechanisms:
1. **Unix-socket NIO** (`GNS3_UNIX_SOCKET_NIO=1`, on `VendorDockerVM`): link
adapters through per-interface AF_UNIX datagram sockets instead of a TAP
interface moved into the container's network namespace.
2. **`IOLDockerVM`** (marker `GNS3_IOL_RUNNER=1`): generates the runner's
config file per start, prepares its runtime directory and cleans up stale
sockets — the iol-runner-specific glue on top of the vendor path.
## How the image works
```mermaid
graph LR
subgraph Container["scratch container (PID 1)"]
RUNNER["/iol-runner -config /config/iol-config.json -stdio"]
IOL["IOL process<br/>(IOS-XE 17.18.02)"]
NETIOMUX["netiomux"]
SOCKETS["/tmp/s00.sock (recv)<br/>/tmp/c00.sock (send-to path)<br/>… one pair per interface"]
RUNNER -->|"spawn -e/-s/-m + app id"| IOL
IOL -->|"netio bus /tmp/netio&lt;uid&gt;/"| NETIOMUX --> SOCKETS
end
subgraph Host
UBRIDGE["uBridge bridgeN<br/>add_nio_unix …/gns3/unixio/&lt;node&gt;/cNN.sock …<br/>+ add_nio_udp (topology)"]
RTDIR["/run/user/&lt;uid&gt;/gns3/unixio/&lt;node&gt;<br/>(bind-mounted at /tmp)"]
VOL["project-files/docker/&lt;node&gt;/config<br/>(+ tmp/run, nested bind at /tmp/run)"]
end
SOCKETS <-->|"same files, two spellings<br/>(the /tmp bind)"| RTDIR <--> UBRIDGE
UBRIDGE -.->|"iol-config.json +<br/>persistent /tmp/run"| VOL
```
* **Console**: the runner muxes the IOS console onto PID 1 stdio (`-stdio`
entrypoint flag). The plain `console_type: "telnet"` attaches to it — no
`docker_exec` needed. The runner requires a TTY, which GNS3 always
allocates; without one the runner exits (`inappropriate ioctl for device`).
* **Networking**: the runner does not touch the container's network
namespace. Per interface N it creates, inside the container's `/tmp`: a
receive socket `s%02d.sock` (frames sent there are injected into guest
interface N) and a send-to path `c%02d.sock` (whoever binds it receives
the guest's frames). Frames are **raw Ethernet**, one datagram per frame —
the same two-mailbox convention uBridge's `add_nio_unix` natively speaks
(it binds the c-socket, sends to the s-socket). GNS3 bind-mounts a
per-node directory from the runtime directory
(`/run/user/<uid>/gns3/unixio/<node-id>`, next to the uBridge control
sockets) at the container's `/tmp`, so uBridge reaches the sockets as
plain host files: the path stays far under AF_UNIX's 107-byte `sun_path`
cap (a projects-tree node path alone exceeds it) and the directory is
owned by the server user, to whom the runner drops its privileges. This
mirrors how CML itself runs the image (`source=…/tmp,target=/tmp` in its
node definition). The directory is ephemeral and removed with the node.
* **Licensing**: the image ships a self-consistent `/etc/hostid` + `.iourc`
pair, and the runner regenerates the license from the host ID at boot —
nothing to configure.
* **Persistence**: `/tmp/run` (the IOL working directory) holds the NETMAP,
the startup-config (`config`, plain IOS format) and NVRAM (`nvram_00001`).
It is the only `/tmp` path that needs to survive: GNS3 bind-mounts the
node directory's `tmp/run/` at `/tmp/run` (nested inside the runtime-dir
bind), so the router's configuration survives stop/start and container
recreation while sockets, netio buses and runner logs stay ephemeral.
The generated config maps the runner to the server's uid/gid
(`user-id`/`group-id`), so all files it creates are owned by the server
user (no permission-fix pass needed).
## Template
Create the template once via `POST /v3/templates` (authenticated — see the
API docs for the auth flow), or in the Web UI under
*Edit → Preferences → Docker templates → New* with the same fields:
```json
{
"name": "IOS-XE 17.18.02 IOL",
"template_type": "docker",
"image": "iol-xe/iol-xe:17-18-02",
"category": "router",
"symbol": ":/symbols/router.svg",
"adapters": 2,
"console_type": "telnet",
"environment": "GNS3_IOL_RUNNER=1",
"extra_volumes": ["/config"]
}
```
| Field | Value | Why |
|---|---|---|
| `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=<MB>` (default 2048), `GNS3_IOL_STARTUP_CONFIG=<file>` (initial configuration, see below). |
| `extra_volumes` | `["/config"]` | `/tmp/run` is auto-added. **Never add `/tmp`** — it would persist the socket directory into the projects tree and uBridge would reject the too-long AF_UNIX path. |
| `adapters` | number of 4-port units | The IOU convention: one adapter = `Ethernet0/0`–`Ethernet0/3`, two adapters add `Ethernet1/0`–`1/3`, … (8 units / 32 ports max). Ports are addressed as (adapter, port 0–3) and shown grouped in the UI. |
| `memory` | optional; `0` (default) = no cap | Unset works — Docker applies no limit. When you do set a cap, keep it at IOL memory + ~512 MB, or the cgroup OOM-killer shoots the router. |
| `console_type` | `telnet` | The runner muxes the IOS console onto PID 1 stdio; `docker_exec` is not needed. |
### Verify
1. The node's port list shows the grouped IOL interfaces
(`Ethernet0/0`–`Ethernet1/3` for two adapters), addressed
(adapter, port).
2. Drop a node into a project and start it — the console shows the
`Linux Unix (i686)` banner within seconds.
3. `$XDG_RUNTIME_DIR/gns3/unixio/<node-id>/` contains `s00.sock`… (one
pair per port).
4. A node created with a startup-config boots straight to the configured
hostname — no initial configuration dialog (interface names in the
config are `Ethernet0/0`, not `GigabitEthernet0/0`).
## Startup configuration
IOL Docker nodes load an initial configuration exactly like native IOU
nodes: the **template references a config file**, and every node created
from it boots with that configuration as its personal starting point.
```json
"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=iol-xe-base.txt"
```
* The file lives in the controller's configs directory (the `configs_path`
server setting, by default `~/GNS3/configs`) — the same place IOU and
VPCS base configs live. A minimal `iol-xe-base.txt` ships with the
server and is installed there on startup (never overwriting a
user-modified copy); an absolute path in the knob bypasses the directory
entirely. `%h` in the content is replaced with the node name at start.
* **Creation materializes it once**: the controller reads the file and
sends its content with the node; afterwards the knob is consumed and the
template file is never referenced again — editing it does not affect
existing nodes. The knob also disappears from the node's `environment`
(`GNS3_IOL_RUNNER=1` remains — it re-selects the node class on every
recreation), so a node whose environment shows only `GNS3_IOL_RUNNER=1`
is the normal sign that the file was found and its content delivered;
when the file is missing the knob stays in the environment (with a
server-side warning) and the lookup is retried at the next creation.
* **NVRAM is the source of truth at boot** (verified on the runner): the
content is built into the node's `tmp/run/nvram_<app id>` — IOL and IOU
share the nvram container format, so the server-side `nvram_import`
utility produces a file IOL boots from directly. Consequences:
* `write memory` survives stop/start and container recreation (a plain
restart never re-applies the startup-config over it). The first
`write memory` after a server-built NVRAM asks for a one-time
`[confirm]` (the builder stamps an IOS 15.4 version marker into the
nvram header; press return — IOS then writes its own).
* Editing a node's `startup_config_content` property (PUT) re-applies it
on the next start, overwriting what `write memory` had saved — the
explicit edit wins, as with IOU.
* Renaming a node rewrites the `hostname` line in its NVRAM, so a
duplicated/renamed node boots under its new name.
* **Resetting a node to its template config**: stop the node, delete
`project-files/docker/<node>/tmp/run/nvram_*`, then re-apply the content
(or recreate the node).
## Server mechanisms
| Mechanism | Where | What it does |
|---|---|---|
| `GNS3_UNIX_SOCKET_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix <dir>/c{N:02d}.sock <dir>/s{N:02d}.sock` instead of TAP + `docker move_to_ns`. No TAP allocation, no `set_mac_addr`, namespace untouched. The socket directory is bound from a per-node runtime directory unless a persisted volume already covers it. |
| `GNS3_UNIX_SOCKET_DIR=<dir>` | `VendorDockerVM` | In-container socket directory (default `/tmp`). Any image whose agent exposes the `s%02d`/`c%02d` datagram pairs can use this without the IOL specifics. |
| `GNS3_IOL_RUNNER=1` | `IOLDockerVM` (selected in the manager) | Forces skip-init + unix-socket NIO + the `/config` and `/tmp/run` volumes; on every start writes `<node>/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `<node>/tmp/run/` (the IOL process dies without it) and removes stale sockets/netio dirs from the socket directory (`tmp/run` is never touched). |
| `restart()` hardening | `IOLDockerVM` | The base `docker restart` would boot the runner on a stale config and leave uBridge wired to the previous run's sockets; reload becomes graceful stop (SIGTERM → NVRAM flush) + full start. |
| `GNS3_IOL_STARTUP_CONFIG=<file>` | controller `Node._node_data` + `IOLDockerVM` | Startup-config plumbing (see above): the controller translates the file into `startup_config_content` on node creation (sent once); the compute builds it into `nvram_<app id>` at the next start via the IOU `nvram_import` utility and rewrites the hostname line on rename. |
`GNS3_STOP_TIMEOUT` (default 60) controls the SIGTERM grace period on stop.
Extra iol-runner flags can be passed via `start_command`, e.g. `-keep`
(L1 keepalives) or `-debug 9` (verbose `process.log` — very useful when
diagnosing wiring issues).
## Notes and caveats
* **Console typing latency**: single keystrokes are echoed on a ~50 ms
cadence — the image services console input on an internal poll.
Measured directly on the container console (bypassing the GNS3 server
entirely, through the same Docker attach endpoint it uses): keystroke
echo is bimodal — 1–11 ms when the keystroke lands just before a poll
tick, ~50–100 ms when it just misses one — while a pasted line is
picked up as one batch (~1.4 ms) and command output streams
back-to-back (inter-frame gaps ~0.01 ms). A control container on the
same attach endpoint echoes in ~1 ms, so the GNS3 telnet path is not a
factor; this matches IOS's low-priority console-input polling and there
is nothing to fix server-side. Paste long commands instead of typing
them.
* **Memory sizing**: `memory` caps the whole container; the IOL process gets
`GNS3_IOL_MEMORY` (default 2048 MB). Keep container memory at IOL memory
+ ~512 MB headroom or the OOM-killer will shoot the router.
* **MAC addresses**: the `mac_address` template field and per-adapter custom
MACs are ignored — IOL derives its own scheme from the node's application
ID (`aabb.cc{app}{iface}`), e.g. `aabb.cc03.0400`. The controller
allocates the ID at node creation from the upper half of the id space
(512–1022, disjoint from IOU's 1–511, limit 511 IOL Docker nodes across
opened projects sharing computes). Starting a node without an allocation
(raw compute API, pre-allocation topologies) is an error, not a fallback —
an uncoordinated ID could collide with the pool and make nodes silently
drop each other's frames as MAC loops.
* **Interface names are IOL-style `Ethernet0/0`**, not `GigabitEthernet0/0`
(4 ports per unit, matching the adapter-count granularity) — startup
configs addressing `GigabitEthernet…` are rejected by the parser.
* **Adapters**: one adapter is a 4-port unit (the IOU model): change the
count while the node is stopped; the config is regenerated on the next
start (`num-eth` = adapters × 4) and the runner creates the matching
socket set.
* **Stop before editing**: NVRAM is only flushed on a graceful stop (SIGTERM,
"cleanup done" in `process.log`); a kill loses the running-config changes
since the last `write memory`.
* **Class selection is create-time**: toggling `GNS3_IOL_RUNNER` via PUT
takes effect after a project reload (same as `docker_exec`).
* The startup-config lives at `project-files/docker/<node>/tmp/run/config`;
`extra_configs` targets under persisted volumes are warned against by the
generic create path — edit the file directly or paste via the console.

View File

@ -0,0 +1,349 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt.
# 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 (with Wireshark-style packet
list columns) and fetches individual frames on demand — each fetch decodes exactly one
frame via the resident **sharkd** daemon 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.
**sharkd is a hard requirement** (part of the Wireshark package). Without it every replay
endpoint that needs the engine returns 501 — there is deliberately no degraded mode; one
engine, one rendering shape for the Web UI. (A tag whose sources captured nothing returns
an empty timeline without consulting the engine — an empty answer, not a degraded one.)
## Architecture
```mermaid
graph TB
UI["Web UI"]
subgraph Controller["Controller (replay endpoints)"]
GATE["Tag gate<br/>(all markers under tag paused?)"]
SCAN["Timeline backbone<br/>(pcap record-header scan,<br/>merge ordering, hex reads)"]
SESS["sharkd sessions<br/>(one per source pcap)"]
end
FS[("markers dir<br/>{node}_{link}_{marker}.pcap")]
TMP["/tmp scratch copies<br/>(hardened-profile workaround)"]
SK["sharkd -<br/>(resident JSON-RPC on stdio)"]
UI -->|"GET range / frames [?filter=&link=]"| GATE
GATE --> SCAN
SCAN -->|"columns / filter matches"| SESS
SESS --> TMP --> SK
SCAN -->|"hex: raw bytes"| FS
UI -->|"GET frame detail (lazy)"| SESS
```
Backbone vs engine, deliberately separated:
- **Timeline backbone** (plain Python): the tag gate, 16-byte-per-frame pcap record-header
scan, cross-source merge ordering, canonical ts strings, and raw-bytes reads for the hex
view. Identity and ordering never depend on the engine.
- **Engine layer (sharkd)**: packet-list columns, display filters, and per-frame protocol
trees. One resident `sharkd -` process per source pcap (sharkd loads one file at a
time), spawned lazily on first use, addressed with one-line JSON-RPC on stdio.
Session lifecycle: each session is validated per request against the source pcap's
`(mtime, size)` — a mismatch (e.g. the capture node restarted and uBridge truncated the
pcap while paused) kills and respawns it. Sessions are refcounted while a request holds
them and the LRU bound (16) only ever evicts **idle** sessions, so neither a request's
own walk over a tag with more sources than the bound nor a concurrent request can have
its session killed mid-RPC. A single manager lock makes check-spawn atomic (concurrent
requests share one spawn instead of double-spawning a process nobody reaps). Each RPC
is serialized by a per-session lock (sharkd serves one request at a time), carries a
timeout and an **id check** — any transport failure (timeout, dead pipe, malformed or
stale reply, oversized line) kills the session for good, because a desynchronized
session would serve shifted results. sharkd reads a `/tmp` scratch **directory**
containing a copy of the pcap plus pinned Wireshark preferences (the column layout the
frames RPC is parsed against is a contract the server owns, immune to system or user
column customization) — hardened profiles (AppArmor &c.) deny it the project directory
and the user's home even though the server process can read both. The pcap copy runs
off the event loop, and server shutdown kills every session so no scratch copy leaks
into `/tmp` across restarts.
### Resource bounds
Three independent bounds, easily confused:
| Bound | Value | Governs |
|-------|-------|---------|
| `_STREAM_LIMIT_BYTES` | 16 MB | Max **single JSON-RPC reply line** read from sharkd. A full 1000-row page measures ~190 KB against asyncio's 64 KB default — the trigger is the frame count in one pcap (≥ 1000), never the number of pcaps |
| `_FRAMES_PAGE` | 1000 | Rows fetched per `frames` RPC while draining one source's columns |
| `SESSION_MAX` | 16 | Resident **idle** sharkd sessions (LRU eviction) |
The `range` frame list itself is uncapped by decision — the client owns the
rendering cost of a huge list.
Process count follows usage, not the pcap inventory. sharkd loads one file per
process, so a session exists per source pcap **actually consulted** — a replay
request only touches its own tag's sources, and a session is either in use
(refcounted, held for a whole pagination drain rather than per RPC) or idle
(no holder — the only state the LRU cap ever evicts). At any moment the pool
holds at most `SESSION_MAX` idle sessions plus one per source being drained by
an in-flight request: a project with a thousand marker pcaps still runs at
most `16 + Σ(in-flight sources)` sharkd processes. Each process holds its pcap
in memory (~file size), which is the quantity the session cap primarily bounds.
## Business Process
```mermaid
sequenceDiagram
participant UI as Web UI
participant C as Controller
participant SK as sharkd session
Note over UI: ① configure — same tag on every link's marker
Note over C: ② capture — uBridge appends matches, replay forbidden (409)
Note over UI: ③ pause every marker under the tag
Note over UI,SK: ④ replay
UI->>C: GET /markers/tags/666/replay/range[?filter=…&link=…]
C->>C: gate → scan record headers → merge order
C->>SK: frames {filter, skip, limit} → columns + matches
C-->>UI: {start, end, sources, frames[] with src/dst/proto/info/bg/fg}
UI->>C: GET frames?ts=T&window_ms=W (paging — {"frames": []} on a miss)
UI->>C: GET frame/detail?ts=…&node_id=…&link_id=…&marker=…[&frame_number=…]
C->>C: hex straight from the pcap
C->>SK: frame {frame: N, proto: true}
SK-->>C: tree (keys renamed to the REST contract)
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 — 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. Docker nodes effectively restart on server restart as well
(stale-container cleanup), so their window is shorter still.
## API Endpoints
All read-only; JWT bearer token, privilege `Project.Audit`. All require sharkd — 501
without it.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/v3/projects/{pid}/markers/tags/{tag}/replay/range[?filter=&link=]` | Timeline metadata + full merged frame list with packet-list columns |
| GET | `/v3/projects/{pid}/markers/tags/{tag}/replay/frames?ts=&window_ms=&limit=[&filter=&link=]` | 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=[&frame_number=]` | Single frame: protocol tree + raw hex (lazy — one call per frame the user opens) |
### `range` — the timeline
```json
{
"tag": 102,
"start": "1788369209.406812",
"end": "1788369219.249085",
"frame_count": 29,
"sources": [
{ "node_id": "47703cad…", "link_id": "2697a7c6…", "marker": "global-ospf",
"data_link_type": "DLT_EN10MB", "count": 4 }
],
"frames": [
{ "ts": "1788369209.406812", "len": 114,
"node_id": "47703cad…", "link_id": "2697a7c6…",
"marker": "global-ospf", "frame_number": 1,
"src": "10.0.12.1", "dst": "224.0.0.5",
"proto": "OSPF", "info": "Hello Packet",
"bg": "fff3d6", "fg": "12272e" }
]
}
```
- `frames` is the **full, merged, time-ordered list** — one request lays out the whole
timeline, **deliberately uncapped**: rendering a huge list is the client's concern,
and the window endpoint exists for incremental views. There is no `truncated` flag
and no bucket histogram; `frame_count` always equals `len(frames)`.
- Every frame entry carries the Wireshark packet-list columns — `src` / `dst` / `proto`
/ `info` — plus the **coloring-rule hints `bg` / `fg`** (Wireshark's own palette
decisions, so the UI can color rows exactly like Wireshark without shipping the
colorization engine). Columns are `null` only for a frame the engine could not describe.
- Each frame entry carries `(node_id, link_id, marker, frame_number)` — the locating
tuple for the detail request, and the link association for timeline/topology rendering
(`link_id` joins the Web UI's own link objects).
### Display filter
`?filter=<expression>` on both `range` and `frames` is a Wireshark display filter,
applied **before** counting and slicing — `start` / `end` / `frame_count` / `frames`
are all computed on the matching frames only. Filtered frames keep
their original pcap frame numbers. The filter travels as one argv-style element (never
through a shell) and is capped at 2000 characters. An invalid expression is a **400**
whose message carries sharkd's original error text — suitable for inline display in the
filter bar, and distinct from the 409 gate / 404 unknown-tag semantics. Only sharkd's
filter-rejection error maps to 400; any other engine failure while a filter is set is a
502 (the filter was fine — the engine was not).
### Capture-source selection
`?link=<link_id>` on both `range` and `frames` narrows the frame stream to one
capture source — a pure identity filter applied **before** any engine work (only the
selected link's pcap gets a sharkd pass), AND-composing with `filter`. Windows and the
histogram therefore always agree with the link-filtered view. Two boundaries by
design:
- **`sources` is the stable inventory of the tag**: every capture source is always
listed, with engine-free **total** counts, unaffected by `link` / `filter` — a
source dropdown must not shrink when the view narrows.
- **An unknown `link_id` matches nothing**: `frame_count: 0`, `start: null`, empty
`frames` — the same shape as a zero-match display filter, deliberately not a 404.
### `frames` — point / window query (paging)
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": [] }
```
Paging is deliberately **ts + window_ms only** (no offset/limit over the filtered set):
the merge spans multiple pcaps, so slicing happens server-side on the merged stream
either way, windows align with timeline semantics, and the gate freezes the data (the
window answer is deterministic).
### `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. Since ts is **not unique within one pcap** (same-microsecond frames are kept
deliberately), the optional `frame_number` — carried by every frame list entry —
disambiguates them: it must still land on the exact ts, and without it the first ts
match decodes.
```json
{
"ts": "1788369209.406812",
"source": { "node_id": "47703cad…", "link_id": "2697a7c6…",
"marker": "global-ospf", "frame_number": 1 },
"field_count": 89,
"hex": "01005e000005…",
"tree": [
{ "element": "proto", "label": "Internet Protocol Version 4, …", "children": [
{ "element": "field", "name": "ip.ttl", "label": "Time to Live: 1",
"filter_expr": "ip.ttl == 1", "pos": 22, "size": 1, "children": [] }
] }
]
}
```
The tree is sharkd's protocol tree with **keys renamed into the REST contract** — a
closed, protocol-independent key set (census-verified across ICMP / TCP / VLAN+OSPF
trees and pinned by a test), with values untouched:
| sharkd | Contract key | Meaning |
|--------|--------------|---------|
| `t` | `element` | node type (`proto`, …) |
| `l` | `label` | display text |
| `fn` | `name` | field name (`ip.ttl`) |
| `f` | `filter_expr` | **ready-made display filter with the value baked in** — click-to-filter |
| `h` | `pos` + `size` | byte range — click field → highlight hex bytes |
| `s` | `expert` | expert severity name (`Chat` / `Warn` / …) |
| `g` | `generated` | generated-by-Wireshark flag |
| `n` | `children` | nested fields |
| `e` | *(dropped)* | Wireshark-internal hf id, unstable across versions |
Unknown keys from a newer Wireshark pass through verbatim (never silently dropped); a
census test flags new keys for naming. `hex` is the raw frame bytes read straight from
the pcap; `field_count` is the mapped node count (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.
- 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.
## Error Responses
All error bodies are `{"message": "…"}` (the app's unified format).
| Status | Description |
|--------|-------------|
| 400 | Invalid display filter (sharkd's filter rejection only; message carries its original text) or filter longer than 2000 chars |
| 401 | Not authenticated |
| 404 | Tag has no markers in the project; detail source unknown; `ts`/`frame_number` do not match the file (the capture may have been rebuilt); or the pcap vanished mid-request |
| 409 | Tag gate: a marker under the tag is still `enabled: true` (the response lists them) |
| 501 | sharkd not installed / unavailable — replay is unavailable, no degraded mode |
| 502 | sharkd failed, timed out (10 s per RPC), or answered out of sync — the session is killed and re-spawned on the next request |
## 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 dissected like any other; sharkd marks them in the tree.
- **Live validation (2026-09, 9-link OSPF project, 29 frames over 9 sources).** Cold
`range` (spawning all sharkd sessions) answered in 0.84 s with full columns and
Wireshark coloring; filters verified in all four regimes (match / zero-match with
`start: null`, invalid expression → 400 with sharkd's text, oversized → 400); window
hit and miss behaved per contract; a frame detail returned 89 nodes with
`filter_expr: "ip.ttl == 1"` (OSPF multicast TTL) and byte ranges for hex
highlighting.
- **Columns are re-fetched per request** (~90 ms per source against a loaded session).
The data is frozen while the gate passes, so a cache keyed on `(mtime, size)` is a
natural follow-up if list latency ever matters at many-source scale.
- **Session invalidation is cheap and total.** Every request stats the source pcap; a
rewritten file (mtime/size change) respawns the session — a paused-but-restarted
capture can never serve stale dissect state. Transport failures are equally total: a
timeout, dead pipe, malformed or stale reply (id mismatch) kills the session instead
of leaving a poisoned one resident, and concurrent requests for the same pcap share a
single spawn.
- **The packet-list layout is pinned, not assumed.** sharkd runs with a scratch `HOME`
whose Wireshark preferences fix `gui.column.format` to exactly the four columns the
frame entries carry (personal config overrides any `/etc/wireshark` customization), so
the column indexes the server parses are a contract it owns.
- **Server shutdown kills every resident session** and drops its `/tmp` scratch
directory — nothing accumulates across restarts.
- **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.

View File

@ -238,25 +238,32 @@ Both `device_config_send` and `device_show_run` support an optional `template` p
```python
# Direct commands (single/batch)
device_config_send(project_id, device_configs=[
{"device_name": "R1", "config_commands": ["int lo0", "ip add 1.1.1.1 255.255.255.255"]},
])
device_config_send(
project_id,
device_configs=[
{"device_name": "R1", "config_commands": ["int lo0", "ip add 1.1.1.1 255.255.255.255"]},
],
)
# Jinja2 template (reduces token usage for batch)
device_config_send(project_id,
device_config_send(
project_id,
template="interface lo{{ n }}\nip address {{ ip }} 255.255.255.255",
device_configs=[
{"device_name": "R1", "vars": {"n": 0, "ip": "1.1.1.1"}},
{"device_name": "R2", "vars": {"n": 0, "ip": "2.2.2.2"}},
])
],
)
# Show commands with template
device_show_run(project_id,
device_show_run(
project_id,
template="show ip route {{ protocol }}",
device_configs=[
{"device_name": "R1", "vars": {"protocol": "ospf"}},
{"device_name": "R2", "vars": {"protocol": "bgp"}},
])
],
)
```
### Best Practices
@ -273,9 +280,12 @@ device_show_run(project_id,
```python
# Save config on device
device_show_run(project_id, device_configs=[
{"device_name": "R1", "commands": ["write memory"]},
])
device_show_run(
project_id,
device_configs=[
{"device_name": "R1", "commands": ["write memory"]},
],
)
# Backup
config = node_file_get(project_id, node_id, "startup-config.cfg")
# Restore if config breaks

View File

@ -51,8 +51,8 @@ Each IOU VM creation spawned `ld-linux --verify` and `iou-image -h` subprocesses
```python
# Class-level caches shared across all instances
IOUVM._loader_cache = {} # image path → loader command
IOUVM._default_values_cache = {} # image path → (ram, nvram)
IOUVM._loader_cache = {} # image path → loader command
IOUVM._default_values_cache = {} # image path → (ram, nvram)
```
Only the first node with a given image runs the subprocesses; subsequent nodes reuse cached values.

View File

@ -798,7 +798,7 @@ OpenAI-compatible message model.
content=observation, # Always JSON string format
tool_call_id=tool_call["id"],
name=tool_call["name"],
metadata={"created_at": datetime.utcnow().isoformat()}
metadata={"created_at": datetime.utcnow().isoformat()},
)
```
@ -924,11 +924,7 @@ ToolMessage(content=JSON_string)
**Route Registration**:
```python
router.include_router(
chat.router,
prefix="/{project_id}/chat",
tags=["Chat"]
)
router.include_router(chat.router, prefix="/{project_id}/chat", tags=["Chat"])
```
**Main Endpoint Implementation**:

View File

@ -79,9 +79,11 @@ PC1> ping 10.10.0.254
def check_config_mode(self) -> bool:
return False # VPCS has no config mode
def config_mode(self) -> str:
return "" # No config mode to enter
def exit_config_mode(self) -> str:
return "" # No config mode to exit
```
@ -354,6 +356,7 @@ The custom driver must be registered with Netmiko's global mappings:
```python
def register_custom_device_type() -> None:
import importlib
sd = importlib.import_module("netmiko.ssh_dispatcher")
# Register in CLASS_MAPPER (for ConnectHandler)
@ -430,12 +433,12 @@ gns3server/agent/gns3_copilot/utils/custom_netmiko/ruijie_telnet.py
**1. Preprocessing - Known Interactive Commands**
```python
INTERACTIVE_PATTERNS = [
re.compile(r'^router-id\s+', re.IGNORECASE), # OSPF/EIGRP/BGP router-id
re.compile(r'^erase\s+', re.IGNORECASE), # erase startup-config
re.compile(r'^delete\s+', re.IGNORECASE), # delete files
re.compile(r'^format\s+', re.IGNORECASE), # format filesystem
re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot
re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image
re.compile(r"^router-id\s+", re.IGNORECASE), # OSPF/EIGRP/BGP router-id
re.compile(r"^erase\s+", re.IGNORECASE), # erase startup-config
re.compile(r"^delete\s+", re.IGNORECASE), # delete files
re.compile(r"^format\s+", re.IGNORECASE), # format filesystem
re.compile(r"^reload\b", re.IGNORECASE), # reload/reboot
re.compile(r"^boot\s+system\s+", re.IGNORECASE), # change boot image
]
```

View File

@ -520,9 +520,14 @@ The tools call the shared REST handler layer (`gns3_copilot.gns3_client.api_hand
```python
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx, create_node_handler, create_link_handler,
get_nodes_handler, start_node_handler, stop_node_handler,
suspend_node_handler, update_node_handler,
build_gns3_ctx,
create_node_handler,
create_link_handler,
get_nodes_handler,
start_node_handler,
stop_node_handler,
suspend_node_handler,
update_node_handler,
)
gns3_ctx = build_gns3_ctx() # JWT + server URL from the request context
@ -650,10 +655,7 @@ Note: These special node types are filtered out by GNS3TemplateTool and won't ap
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StartNodeTool
tool = GNS3StartNodeTool()
result = tool._run(json.dumps({
"project_id": "abc-123-def",
"node_ids": ["node-1", "node-2", "node-3"]
}))
result = tool._run(json.dumps({"project_id": "abc-123-def", "node_ids": ["node-1", "node-2", "node-3"]}))
# Output includes progress bar and final status
```
@ -664,10 +666,7 @@ result = tool._run(json.dumps({
from gns3server.agent.gns3_copilot.tools_v2 import GNS3StopNodeTool
tool = GNS3StopNodeTool()
result = tool._run(json.dumps({
"project_id": "abc-123-def",
"node_ids": ["node-1", "node-2"]
}))
result = tool._run(json.dumps({"project_id": "abc-123-def", "node_ids": ["node-1", "node-2"]}))
# Immediate return with stop status
```
@ -677,19 +676,13 @@ result = tool._run(json.dumps({
```python
# Lab deployment
start_tool = GNS3StartNodeTool()
start_result = start_tool._run(json.dumps({
"project_id": project_id,
"node_ids": all_node_ids
}))
start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
# ... Run tests ...
# Lab shutdown
stop_tool = GNS3StopNodeTool()
stop_result = stop_tool._run(json.dumps({
"project_id": project_id,
"node_ids": all_node_ids
}))
stop_result = stop_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
```
### Example 4: Lab Pause and Resume
@ -699,56 +692,45 @@ from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool
# Start lab
start_tool = GNS3StartNodeTool()
start_result = start_tool._run(json.dumps({
"project_id": project_id,
"node_ids": all_node_ids
}))
start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
# ... Configure devices ...
# ... Run some tests ...
# Suspend lab (preserves all state)
suspend_tool = GNS3SuspendNodeTool()
suspend_result = suspend_tool._run(json.dumps({
"project_id": project_id,
"node_ids": all_node_ids
}))
suspend_result = suspend_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
# Nodes suspended - state preserved in memory
# ... Take a break, work on something else ...
# Resume lab (quick recovery)
start_result = start_tool._run(json.dumps({
"project_id": project_id,
"node_ids": all_node_ids
}))
start_result = start_tool._run(json.dumps({"project_id": project_id, "node_ids": all_node_ids}))
# Back to previous state in seconds!
```
### Example 5: Suspend While Renaming Nodes
```python
from gns3server.agent.gns3_copilot.tools_v2 import (
GNS3SuspendNodeTool,
GNS3UpdateNodeNameTool
)
from gns3server.agent.gns3_copilot.tools_v2 import GNS3SuspendNodeTool, GNS3UpdateNodeNameTool
# Suspend nodes first (allows renaming)
suspend_tool = GNS3SuspendNodeTool()
suspend_result = suspend_tool._run(json.dumps({
"project_id": project_id,
"node_ids": node_ids
}))
suspend_result = suspend_tool._run(json.dumps({"project_id": project_id, "node_ids": node_ids}))
# Now rename nodes (possible while suspended!)
rename_tool = GNS3UpdateNodeNameTool()
rename_result = rename_tool._run(json.dumps({
"project_id": project_id,
"nodes": [
{"node_id": "node-1", "new_name": "Router-Primary"},
{"node_id": "node-2", "new_name": "Router-Backup"}
]
}))
rename_result = rename_tool._run(
json.dumps(
{
"project_id": project_id,
"nodes": [
{"node_id": "node-1", "new_name": "Router-Primary"},
{"node_id": "node-2", "new_name": "Router-Backup"},
],
}
)
)
# Resume when ready
# Note: Cannot rename while started, but CAN rename while suspended!
@ -777,21 +759,17 @@ result = tool._run("")
from gns3server.agent.gns3_copilot.tools_v2 import GNS3CreateNodeTool
tool = GNS3CreateNodeTool()
result = tool._run(json.dumps({
"project_id": "abc-123-def",
"nodes": [
result = tool._run(
json.dumps(
{
"template_id": "uuid-of-router-template",
"x": 100,
"y": -200
},
{
"template_id": "uuid-of-switch-template",
"x": -200,
"y": 300
"project_id": "abc-123-def",
"nodes": [
{"template_id": "uuid-of-router-template", "x": 100, "y": -200},
{"template_id": "uuid-of-switch-template", "x": -200, "y": 300},
],
}
]
}))
)
)
# Creates two nodes with specified templates and positions
```
@ -802,23 +780,27 @@ result = tool._run(json.dumps({
from gns3server.agent.gns3_copilot.tools_v2 import GNS3LinkTool
tool = GNS3LinkTool()
result = tool._run(json.dumps({
"project_id": "abc-123-def",
"links": [
result = tool._run(
json.dumps(
{
"node_id1": "uuid-of-node1",
"port1": "Ethernet0/0",
"node_id2": "uuid-of-node2",
"port2": "Ethernet0/0"
},
{
"node_id1": "uuid-of-node1",
"port1": "Ethernet0/1",
"node_id2": "uuid-of-node3",
"port2": "Ethernet0/0"
"project_id": "abc-123-def",
"links": [
{
"node_id1": "uuid-of-node1",
"port1": "Ethernet0/0",
"node_id2": "uuid-of-node2",
"port2": "Ethernet0/0",
},
{
"node_id1": "uuid-of-node1",
"port1": "Ethernet0/1",
"node_id2": "uuid-of-node3",
"port2": "Ethernet0/0",
},
],
}
]
}))
)
)
# Creates two links connecting the nodes
```
@ -829,15 +811,19 @@ result = tool._run(json.dumps({
from gns3server.agent.gns3_copilot.tools_v2 import GNS3UpdateNodeNameTool
tool = GNS3UpdateNodeNameTool()
result = tool._run(json.dumps({
"project_id": "abc-123-def",
"nodes": [
{"node_id": "node-1", "new_name": "R1-Core"},
{"node_id": "node-2", "new_name": "R2-Core"},
{"node_id": "node-3", "new_name": "S1-Access"},
{"node_id": "node-4", "new_name": "S2-Access"}
]
}))
result = tool._run(
json.dumps(
{
"project_id": "abc-123-def",
"nodes": [
{"node_id": "node-1", "new_name": "R1-Core"},
{"node_id": "node-2", "new_name": "R2-Core"},
{"node_id": "node-3", "new_name": "S1-Access"},
{"node_id": "node-4", "new_name": "S2-Access"},
],
}
)
)
# Applies consistent naming to all nodes
```
@ -850,7 +836,7 @@ from gns3server.agent.gns3_copilot.tools_v2 import (
GNS3CreateNodeTool,
GNS3LinkTool,
GNS3UpdateNodeNameTool,
GNS3StartNodeTool
GNS3StartNodeTool,
)
# Step 1: Get available templates
@ -860,44 +846,63 @@ templates = template_tool._run("")
# Step 2: Create nodes
create_tool = GNS3CreateNodeTool()
nodes = create_tool._run(json.dumps({
"project_id": project_id,
"nodes": [
{"template_id": router_template_id, "x": 0, "y": -200},
{"template_id": router_template_id, "x": 200, "y": -200},
{"template_id": switch_template_id, "x": 100, "y": 0}
]
}))
nodes = create_tool._run(
json.dumps(
{
"project_id": project_id,
"nodes": [
{"template_id": router_template_id, "x": 0, "y": -200},
{"template_id": router_template_id, "x": 200, "y": -200},
{"template_id": switch_template_id, "x": 100, "y": 0},
],
}
)
)
# Step 3: Connect nodes
link_tool = GNS3LinkTool()
links = link_tool._run(json.dumps({
"project_id": project_id,
"links": [
{"node_id1": nodes["created_nodes"][0]["node_id"], "port1": "Ethernet0/0",
"node_id2": nodes["created_nodes"][2]["node_id"], "port2": "Ethernet0/0"},
{"node_id1": nodes["created_nodes"][1]["node_id"], "port1": "Ethernet0/0",
"node_id2": nodes["created_nodes"][2]["node_id"], "port2": "Ethernet0/1"}
]
}))
links = link_tool._run(
json.dumps(
{
"project_id": project_id,
"links": [
{
"node_id1": nodes["created_nodes"][0]["node_id"],
"port1": "Ethernet0/0",
"node_id2": nodes["created_nodes"][2]["node_id"],
"port2": "Ethernet0/0",
},
{
"node_id1": nodes["created_nodes"][1]["node_id"],
"port1": "Ethernet0/0",
"node_id2": nodes["created_nodes"][2]["node_id"],
"port2": "Ethernet0/1",
},
],
}
)
)
# Step 4: Apply naming
name_tool = GNS3UpdateNodeNameTool()
names = name_tool._run(json.dumps({
"project_id": project_id,
"nodes": [
{"node_id": nodes["created_nodes"][0]["node_id"], "new_name": "R1"},
{"node_id": nodes["created_nodes"][1]["node_id"], "new_name": "R2"},
{"node_id": nodes["created_nodes"][2]["node_id"], "new_name": "SW1"}
]
}))
names = name_tool._run(
json.dumps(
{
"project_id": project_id,
"nodes": [
{"node_id": nodes["created_nodes"][0]["node_id"], "new_name": "R1"},
{"node_id": nodes["created_nodes"][1]["node_id"], "new_name": "R2"},
{"node_id": nodes["created_nodes"][2]["node_id"], "new_name": "SW1"},
],
}
)
)
# Step 5: Start nodes
start_tool = GNS3StartNodeTool()
start_result = start_tool._run(json.dumps({
"project_id": project_id,
"node_ids": [n["node_id"] for n in nodes["created_nodes"]]
}))
start_result = start_tool._run(
json.dumps({"project_id": project_id, "node_ids": [n["node_id"] for n in nodes["created_nodes"]]})
)
```
## Error Handling

View File

@ -150,9 +150,9 @@ Git operations use per-command environment variables to prevent hanging:
```python
_GIT_TIMEOUT_ENV = {
'GIT_HTTP_TIMEOUT': '10', # Connection timeout (default: 120s)
'GIT_HTTP_LOW_SPEED_TIME': '5', # Slow speed threshold window
'GIT_HTTP_LOW_SPEED_LIMIT': '1000', # < 1 KB/s = slow → abort
"GIT_HTTP_TIMEOUT": "10", # Connection timeout (default: 120s)
"GIT_HTTP_LOW_SPEED_TIME": "5", # Slow speed threshold window
"GIT_HTTP_LOW_SPEED_LIMIT": "1000", # < 1 KB/s = slow → abort
}
```

View File

@ -389,18 +389,13 @@ router ospf {{ process_id }}
@interrupt
def template_review_checkpoint(state):
"""Pause and wait for user confirmation."""
return {
"type": "template_review",
"data": state["generated_template"]
}
return {"type": "template_review", "data": state["generated_template"]}
@interrupt
def params_review_checkpoint(state):
"""Pause and wait for user confirmation."""
return {
"type": "params_review",
"data": state["generated_params"]
}
return {"type": "params_review", "data": state["generated_params"]}
```
**State Management:**
@ -939,24 +934,9 @@ For 1000+ devices, some failures are inevitable. The system provides:
```python
{
"total_devices": 1000,
"summary": {
"success": 987,
"failed": 13,
"skipped": 0
},
"failed_devices": [
{
"device_name": "R456",
"error": "Connection timeout",
"retry_available": true
},
...
],
"retry_suggestions": {
"auto_retry": True,
"retry_batch_size": 10,
"exponential_backoff": True
}
"summary": {"success": 987, "failed": 13, "skipped": 0},
"failed_devices": [{"device_name": "R456", "error": "Connection timeout", "retry_available": true}, ...],
"retry_suggestions": {"auto_retry": True, "retry_batch_size": 10, "exponential_backoff": True},
}
```
@ -1277,54 +1257,38 @@ All servers connect to access switches in pairs.
"node_type": "cisco_iosv",
"count": 4,
"name_pattern": "Spine-R{{ id }}",
"properties": {
"ram": 4096,
"cpus": 2,
"adapters": 8
},
"position": {
"strategy": "hierarchical",
"y": 100,
"x_spacing": 600
}
"properties": {"ram": 4096, "cpus": 2, "adapters": 8},
"position": {"strategy": "hierarchical", "y": 100, "x_spacing": 600},
},
{
"name": "leaf",
"node_type": "cisco_iosv_l2",
"count": 20,
"name_pattern": "Leaf-SW{{ id }}",
"properties": {
"ram": 2048,
"cpus": 1,
"adapters": 16
},
"properties": {"ram": 2048, "cpus": 1, "adapters": 16},
"position": {
"strategy": "grid",
"grid_rows": 4,
"grid_cols": 5,
"y": 400,
"x_spacing": 300,
"y_spacing": 200
}
"y_spacing": 200,
},
},
{
"name": "access",
"node_type": "cisco_iosv_l2",
"count": 200,
"name_pattern": "Acc-SW{{ id }}",
"properties": {
"ram": 1024,
"cpus": 1,
"adapters": 4
},
"properties": {"ram": 1024, "cpus": 1, "adapters": 4},
"position": {
"strategy": "grid",
"grid_rows": 10,
"grid_cols": 20,
"y": 800,
"x_spacing": 120,
"y_spacing": 100
}
"y_spacing": 100,
},
},
{
"name": "server",
@ -1338,36 +1302,19 @@ All servers connect to access switches in pairs.
"grid_cols": 25,
"y": 1200,
"x_spacing": 60,
"y_spacing": 60
}
}
"y_spacing": 60,
},
},
],
"auto_link": {
"links": [
{
"from_group": "spine",
"to_group": "leaf",
"strategy": "mesh"
},
{
"from_group": "leaf",
"to_group": "access",
"strategy": "paired",
"count": 10
},
{
"from_group": "access",
"to_group": "server",
"strategy": "paired",
"count": 2
}
{"from_group": "spine", "to_group": "leaf", "strategy": "mesh"},
{"from_group": "leaf", "to_group": "access", "strategy": "paired", "count": 10},
{"from_group": "access", "to_group": "server", "strategy": "paired", "count": 2},
]
},
"layout": "auto_spine_leaf",
"resource_limits": {
"max_ram_mb": 750000,
"max_vcpus": 724
}
"resource_limits": {"max_ram_mb": 750000, "max_vcpus": 724},
}
# Execution Result
@ -1381,8 +1328,8 @@ All servers connect to access switches in pairs.
{"name": "spine", "created": 4, "failed": 0},
{"name": "leaf", "created": 20, "failed": 0},
{"name": "access", "created": 200, "failed": 0},
{"name": "server", "created": 500, "failed": 0}
]
{"name": "server", "created": 500, "failed": 0},
],
}
```
@ -1522,14 +1469,14 @@ class LinkPattern(BaseModel):
"""Pattern for creating links between node groups."""
from_nodes: NodeSelector # Source nodes
to_nodes: NodeSelector # Destination nodes
to_nodes: NodeSelector # Destination nodes
strategy: Literal[
"one_to_one", # 1:1 pairing
"one_to_many", # Star topology
"many_to_many", # Full mesh
"sequential", # Linear chain
"ring" # Ring topology
"one_to_one", # 1:1 pairing
"one_to_many", # Star topology
"many_to_many", # Full mesh
"sequential", # Linear chain
"ring", # Ring topology
]
port_allocation: PortAllocationStrategy
@ -1548,16 +1495,16 @@ class PortAllocationStrategy(BaseModel):
"""How to allocate ports for links."""
strategy: Literal[
"round_robin", # Distribute evenly
"sequential", # Use in order
"optimized", # Smart allocation
"auto" # Automatic selection
"round_robin", # Distribute evenly
"sequential", # Use in order
"optimized", # Smart allocation
"auto", # Automatic selection
]
on_conflict: Literal[
"skip", # Skip if port unavailable
"use_next", # Use next available port
"fail" # Fail on conflict
"skip", # Skip if port unavailable
"use_next", # Use next available port
"fail", # Fail on conflict
] = "use_next"
```

View File

@ -35,7 +35,7 @@ Add `max_nodes` field to both `User` and `UserGroup` models:
# In User model
max_nodes = Column(Integer, nullable=True) # NULL = no limit
# In UserGroup model
# In UserGroup model
max_nodes = Column(Integer, nullable=True) # NULL = no limit
```
@ -49,9 +49,7 @@ Add node limit configuration to `ControllerSettings`:
class NodeLimitSettings(BaseModel):
enabled: bool = False # Feature toggle (default disabled)
default_max_nodes: int = 5 # Default limit when enabled
excluded_node_types: List[str] = Field(default_factory=lambda: [
"ethernet_switch", "ethernet_hub", "cloud", "nat"
])
excluded_node_types: List[str] = Field(default_factory=lambda: ["ethernet_switch", "ethernet_hub", "cloud", "nat"])
```
#### 3. Core Service Implementation
@ -83,15 +81,13 @@ Add limit checking to node startup endpoint:
async def start_node(
node: Node = Depends(dep_node),
current_user: User = Depends(get_current_active_user),
node_limit_service: NodeLimitService = Depends(get_node_limit_service)
node_limit_service: NodeLimitService = Depends(get_node_limit_service),
):
# Node limit check
can_start, error_msg = await node_limit_service.check_user_node_limit(
current_user, node.project
)
can_start, error_msg = await node_limit_service.check_user_node_limit(current_user, node.project)
if not can_start:
raise HTTPException(status_code=403, detail=error_msg)
# Original startup logic
await node.start()
```

View File

@ -36,6 +36,7 @@ AI_COPILOT_AVAILABLE = False
try:
from .gns3_copilot.project_agent_manager import get_project_agent_manager
from .gns3_copilot.project_agent_manager import ProjectAgentManager
AI_COPILOT_AVAILABLE = True
# Start skills repository initialization in background.
@ -64,8 +65,7 @@ except ImportError as e:
RuntimeError: If AI Copilot dependencies are not installed
"""
raise RuntimeError(
"AI Copilot is not available. "
"Install AI dependencies with: pip install gns3-server[ai-features]"
"AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
)
class ProjectAgentManager:
@ -75,8 +75,7 @@ except ImportError as e:
def __init__(self):
raise RuntimeError(
"AI Copilot is not available. "
"Install AI dependencies with: pip install gns3-server[ai-features]"
"AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
)
@ -88,6 +87,7 @@ try:
# Use importlib so the top-level SDK name "mcp" is not bound in this
# namespace — it would shadow the gns3server.agent.mcp subpackage.
import importlib
importlib.import_module("mcp.server.fastmcp")
MCP_AVAILABLE = True
except ImportError:

View File

@ -67,9 +67,12 @@ logger = logging.getLogger(__name__)
# Initialize tiktoken encoding (required dependency)
import time
logger.debug("Initializing tiktoken encoding (cl100k_base)...")
logger.debug(f"Cache directory: {_cache_dir}")
logger.debug("This may take a moment on first run (downloading ~1.6MB encoding file from openaipublic.blob.core.windows.net)")
logger.debug(
"This may take a moment on first run (downloading ~1.6MB encoding file from openaipublic.blob.core.windows.net)"
)
start_time = time.time()
_tiktoken_encoding = tiktoken.get_encoding("cl100k_base")
elapsed = time.time() - start_time
@ -149,21 +152,16 @@ def estimate_tool_tokens(tools: list[Any]) -> int:
if hasattr(tool, "args_schema") and tool.args_schema:
try:
# Try Pydantic v2 method (model_json_schema)
tool_schema["function"]["parameters"] = (
tool.args_schema.model_json_schema()
)
tool_schema["function"]["parameters"] = tool.args_schema.model_json_schema()
except AttributeError:
# Fallback to Pydantic v1 method (schema)
try:
tool_schema["function"]["parameters"] = (
tool.args_schema.schema()
)
tool_schema["function"]["parameters"] = tool.args_schema.schema()
except Exception:
# Both methods failed, use empty schema
tool_name = getattr(tool, "name", "unknown")
logger.debug(
"Failed to get schema for tool %s, using empty "
"parameters",
"Failed to get schema for tool %s, using empty parameters",
tool_name,
)
tool_schema["function"]["parameters"] = {}
@ -171,15 +169,12 @@ def estimate_tool_tokens(tools: list[Any]) -> int:
# model_json_schema() raised an exception
tool_name = getattr(tool, "name", "unknown")
logger.debug(
"model_json_schema() failed for tool %s: %s, "
"trying v1 fallback",
"model_json_schema() failed for tool %s: %s, trying v1 fallback",
tool_name,
e,
)
try:
tool_schema["function"]["parameters"] = (
tool.args_schema.schema()
)
tool_schema["function"]["parameters"] = tool.args_schema.schema()
except Exception:
tool_schema["function"]["parameters"] = {}
@ -331,14 +326,11 @@ def create_pre_model_hook(
if not llm_config:
logger.error("LLM config not found. context_limit is required.")
raise ValueError(
"LLM config not found. context_limit is required."
)
raise ValueError("LLM config not found. context_limit is required.")
if "context_limit" not in llm_config:
logger.error(
"context_limit not found in LLM config. "
"This is a required field. Please configure context_limit."
"context_limit not found in LLM config. This is a required field. Please configure context_limit."
)
raise ValueError("context_limit is required in LLM config")
@ -385,9 +377,7 @@ def create_pre_model_hook(
system_tokens = _count_tokens_for_message(system_message)
# Calculate tokens for messages_with_system (including system)
messages_with_system_tokens = sum(
_count_tokens_for_message(m) for m in messages_with_system
)
messages_with_system_tokens = sum(_count_tokens_for_message(m) for m in messages_with_system)
# Calculate available budget
model_limit_tokens = context_limit_k * TOKENS_PER_K
@ -449,14 +439,11 @@ def create_pre_model_hook(
# Calculate final token counts
final_total = sum(_count_tokens_for_message(m) for m in trimmed)
usage_percent = (
(final_total + tool_tokens) / model_limit_tokens * 100
)
usage_percent = (final_total + tool_tokens) / model_limit_tokens * 100
if len(trimmed) < len(messages_with_system):
logger.info(
"Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d "
"tools = %d / %dK (%.1f%%), strategy=%s",
"Messages trimmed: %d → %d msgs. Total: ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s",
len(messages_with_system),
len(trimmed),
final_total,
@ -468,8 +455,7 @@ def create_pre_model_hook(
)
else:
logger.info(
"Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK "
"(%.1f%%), strategy=%s",
"Context ready: %d msgs, ~%d tokens + %d tools = %d / %dK (%.1f%%), strategy=%s",
len(trimmed),
final_total,
tool_tokens,
@ -520,33 +506,23 @@ def _inject_topology_into_system(
if topology_data:
topology_str = str(topology_data)
formatted_prompt = system_prompt.replace(
"{{topology_info}}", f"\n\n## Current Topology\n{topology_str}"
)
formatted_prompt = system_prompt.replace("{{topology_info}}", f"\n\n## Current Topology\n{topology_str}")
logger.info(
"✓ Topology injected: %d chars, nodes: %s",
len(topology_str),
list(topology_data.get("nodes", {}).keys())[:5],
) # Show first 5 node names
logger.debug(
"Full topology data: %s", topology_str[:500]
) # First 500 chars
logger.debug("Full topology data: %s", topology_str[:500]) # First 500 chars
else:
formatted_prompt = system_prompt.replace(
"{{topology_info}}", "(No topology information available)"
)
formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)")
logger.warning("✗ Topology data is None, injecting placeholder")
# Filter out existing SystemMessage instances
non_system_messages = [
m for m in messages if not isinstance(m, SystemMessage)
]
non_system_messages = [m for m in messages if not isinstance(m, SystemMessage)]
filtered_count = len(messages) - len(non_system_messages)
if filtered_count > 0:
logger.debug(
"Filtered out %d existing SystemMessage(s)", filtered_count
)
logger.debug("Filtered out %d existing SystemMessage(s)", filtered_count)
return [SystemMessage(content=formatted_prompt)] + non_system_messages
@ -568,8 +544,7 @@ def prepare_context_messages(
**DEPRECATED**: Use create_pre_model_hook() instead.
"""
warnings.warn(
"prepare_context_messages() is deprecated. Use "
"create_pre_model_hook() instead.",
"prepare_context_messages() is deprecated. Use create_pre_model_hook() instead.",
DeprecationWarning,
stacklevel=2,
)
@ -577,13 +552,9 @@ def prepare_context_messages(
if "{{topology_info}}" not in system_prompt:
formatted_prompt = system_prompt
elif topology_context:
formatted_prompt = system_prompt.replace(
"{{topology_info}}", f"\n\n## Current Topology\n{topology_context}"
)
formatted_prompt = system_prompt.replace("{{topology_info}}", f"\n\n## Current Topology\n{topology_context}")
else:
formatted_prompt = system_prompt.replace(
"{{topology_info}}", "(No topology information available)"
)
formatted_prompt = system_prompt.replace("{{topology_info}}", "(No topology information available)")
return [SystemMessage(content=formatted_prompt)] + state_messages
@ -629,9 +600,7 @@ if __name__ == "__main__":
# Test invocation
print("\nTest 3: Invoke pre_model_hook")
test_state = {
"messages": [
HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5)
],
"messages": [HumanMessage(f"Message {i}: {'x' * 50}") for i in range(5)],
"topology_info": {"project_id": "test123", "nodes": 3},
}

View File

@ -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
@ -308,8 +311,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
if topology and "error" not in topology:
topology_info = topology
logger.info(
"Successfully retrieved topology for project_id: %s, "
"name: %s",
"Successfully retrieved topology for project_id: %s, name: %s",
project_id,
topology.get("name"),
)
@ -317,9 +319,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
logger.warning(
"Failed to retrieve topology for project_id %s: %s",
project_id,
topology.get("error", "Unknown error")
if topology
else "No result",
topology.get("error", "Unknown error") if topology else "No result",
)
except Exception as e:
logger.warning(
@ -335,20 +335,13 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
copilot_mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
if copilot_mode == "lab_automation_assistant":
mode_tools = LAB_AUTOMATION_ASSISTANT_MODE_TOOLS
logger.info(
"Using LAB_AUTOMATION_ASSISTANT mode tools (includes "
"configuration tools)"
)
logger.info("Using LAB_AUTOMATION_ASSISTANT mode tools (includes configuration tools)")
elif copilot_mode == "troubleshooting_injection":
mode_tools = TROUBLESHOOTING_INJECTION_MODE_TOOLS
logger.info(
"Using TROUBLESHOOTING_INJECTION mode tools (fault injection)"
)
logger.info("Using TROUBLESHOOTING_INJECTION mode tools (fault injection)")
else: # teaching_assistant mode (default)
mode_tools = TEACHING_ASSISTANT_MODE_TOOLS
logger.info(
"Using TEACHING_ASSISTANT mode tools (diagnostic tools only)"
)
logger.info("Using TEACHING_ASSISTANT mode tools (diagnostic tools only)")
# Create pre_model_hook for automatic topology injection and trimming
# Load system prompt based on copilot_mode configuration
@ -369,22 +362,16 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
copilot_mode,
len(mode_tools),
)
model_with_tools = create_base_model_with_tools(
mode_tools, llm_config=llm_config
)
model_with_tools = create_base_model_with_tools(mode_tools, llm_config=llm_config)
# Call pre_hook directly to prepare messages (topology injection +
# trimming)
# Note: LangGraph's pre_model_hook only works with prebuilt agents, not
# custom StateGraph
logger.debug("Calling pre_hook to prepare %d messages", len(messages))
prepared_state = pre_hook(
{"messages": messages, "topology_info": topology_info}
)
prepared_state = pre_hook({"messages": messages, "topology_info": topology_info})
prepared_messages = prepared_state["messages"]
logger.info(
"Messages prepared: %d → %d", len(messages), len(prepared_messages)
)
logger.info("Messages prepared: %d → %d", len(messages), len(prepared_messages))
# Invoke model with prepared messages
response = model_with_tools.invoke(prepared_messages)
@ -418,9 +405,7 @@ def llm_call(state: dict, config: RunnableConfig | None = None):
# Define generate title node
def generate_title(
state: MessagesState, config: RunnableConfig | None = None
) -> dict:
def generate_title(state: MessagesState, config: RunnableConfig | None = None) -> dict:
"""
Generate a conversation title using a lightweight assistant LLM
(title_model). This node is only executed when no title has been set yet
@ -466,27 +451,21 @@ def generate_title(
# Validate the generated title
if not new_title or len(new_title) < 3:
raise ValueError(
f"Generated title too short or empty: '{new_title}'"
)
raise ValueError(f"Generated title too short or empty: '{new_title}'")
if new_title in [
"New Conversation",
"Untitled Session",
"GNS3 Session",
]:
raise ValueError(
f"Generated title is a default value: '{new_title}'"
)
raise ValueError(f"Generated title is a default value: '{new_title}'")
# Safety: truncate long titles and avoid line breaks
if len(new_title) > TITLE_MAX_LENGTH:
new_title = new_title[: TITLE_MAX_LENGTH - 2] + "..."
# Remove unwanted characters
new_title = (
new_title.replace("\n", " ").replace('"', "").replace("'", "")
)
new_title = new_title.replace("\n", " ").replace('"', "").replace("'", "")
logger.debug("Generated new title: %s", new_title)
return {"conversation_title": new_title, "session_id": state.get("session_id")}
@ -537,9 +516,7 @@ def tool_node(state: dict, config: RunnableConfig | None = None):
result = []
for tool_call in tool_calls:
tool_name = tool_call["name"]
logger.debug(
"Executing tool: %s with args: %s", tool_name, tool_call["args"]
)
logger.debug("Executing tool: %s with args: %s", tool_name, tool_call["args"])
tool = tools_by_name[tool_name]
try:
observation = tool.invoke(tool_call["args"])
@ -587,11 +564,13 @@ def abort_handler_node(state: dict) -> dict:
result = []
for tool_call in last_message.tool_calls:
tool_msg = ToolMessage(
content=json.dumps({
"status": "aborted",
"message": "Tool execution was aborted by user",
"tool_call_id": tool_call["id"],
}),
content=json.dumps(
{
"status": "aborted",
"message": "Tool execution was aborted by user",
"tool_call_id": tool_call["id"],
}
),
tool_call_id=tool_call["id"],
name=tool_call["name"],
metadata={"created_at": datetime.utcnow().isoformat(), "aborted": True},

View File

@ -94,8 +94,7 @@ def create_base_model(
# Log the loaded configuration (mask sensitive data)
logger.info(
"Creating base model: name=%s, provider=%s, base_url=%s, "
"temperature=%s",
"Creating base model: name=%s, provider=%s, base_url=%s, temperature=%s",
config_vars["model_name"],
config_vars["model_provider"],
config_vars["base_url"] if config_vars["base_url"] else "default",
@ -163,8 +162,7 @@ def create_title_model(
config_vars = _load_llm_config(llm_config)
logger.info(
"Creating title model: name=%s, provider=%s, base_url=%s, "
"temperature=1.0",
"Creating title model: name=%s, provider=%s, base_url=%s, temperature=1.0",
config_vars["model_name"],
config_vars["model_provider"],
config_vars["base_url"] if config_vars["base_url"] else "default",

View File

@ -123,9 +123,7 @@ class AgentService:
return self._checkpointer
checkpoint_dir = self._get_checkpoint_dir()
checkpointer_path = os.path.join(
checkpoint_dir, "copilot_checkpoints.db"
)
checkpointer_path = os.path.join(checkpoint_dir, "copilot_checkpoints.db")
log.debug("Creating checkpointer at: %s", checkpointer_path)
@ -135,17 +133,13 @@ class AgentService:
await self._checkpointer_conn.close()
log.debug("Closed previous checkpointer connection")
except Exception as e:
log.warning(
"Error closing old checkpointer connection: %s", e
)
log.warning("Error closing old checkpointer connection: %s", e)
# Create new connection
conn = await aiosqlite.connect(checkpointer_path)
# Enable WAL mode for better concurrent performance
await conn.execute("PRAGMA journal_mode=WAL;")
self._checkpointer_conn = (
conn # Save connection reference to prevent GC
)
self._checkpointer_conn = conn # Save connection reference to prevent GC
self._checkpointer = AsyncSqliteSaver(conn)
# CRITICAL: Initialize database schema
@ -197,14 +191,8 @@ class AgentService:
""")
# Create indexes
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_thread_id ON "
"chat_sessions(thread_id)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_project ON "
"chat_sessions(user_id, project_id)"
)
await conn.execute("CREATE INDEX IF NOT EXISTS idx_thread_id ON chat_sessions(thread_id)")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_user_project ON chat_sessions(user_id, project_id)")
# Check if pinned column exists, add it if not (migration for existing
# databases)
@ -214,16 +202,12 @@ class AgentService:
if "pinned" not in column_names:
log.debug("Adding pinned column to existing chat_sessions table")
await conn.execute(
"ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT "
"FALSE"
)
await conn.execute("ALTER TABLE chat_sessions ADD COLUMN pinned BOOLEAN DEFAULT FALSE")
await conn.commit()
# Create pinned index (after column is guaranteed to exist)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pinned_updated ON "
"chat_sessions(pinned DESC, updated_at DESC)"
"CREATE INDEX IF NOT EXISTS idx_pinned_updated ON chat_sessions(pinned DESC, updated_at DESC)"
)
await conn.commit()
@ -234,9 +218,7 @@ class AgentService:
if self._graph is None:
checkpointer = await self._get_checkpointer()
self._graph = agent_builder.compile(checkpointer=checkpointer)
log.info(
"LangGraph agent compiled for project: %s", self.project_path
)
log.info("LangGraph agent compiled for project: %s", self.project_path)
return self._graph
async def stream_chat(
@ -266,8 +248,7 @@ class AgentService:
Dict containing SSE-compatible response chunks
"""
log.info(
"Stream chat started: project_id=%s, user_id=%s, session_id=%s, "
"mode=%s",
"Stream chat started: project_id=%s, user_id=%s, session_id=%s, mode=%s",
project_id,
user_id,
session_id,
@ -293,11 +274,7 @@ class AgentService:
title="New Conversation",
copilot_mode=copilot_mode,
)
log.debug(
"Created new chat session: thread_id=%s, copilot_mode=%s",
session_id,
copilot_mode
)
log.debug("Created new chat session: thread_id=%s, copilot_mode=%s", session_id, copilot_mode)
# Set request-scoped context variables (memory-only, not persisted)
if jwt_token:
@ -387,39 +364,25 @@ class AgentService:
# Stream events
try:
async for event in graph.astream_events(
inputs, config=config, version="v2"
):
async for event in graph.astream_events(inputs, config=config, version="v2"):
event_type = event.get("event", "")
data = event.get("data", {})
# Track LLM calls and tokens
if event_type == "on_chat_model_start":
# Filter out title_generator_node from statistics
langgraph_node = event.get("metadata", {}).get(
"langgraph_node", ""
)
langgraph_node = event.get("metadata", {}).get("langgraph_node", "")
if langgraph_node != "title_generator_node":
llm_calls_count += 1
log.debug(
"LLM call started, count=%d", llm_calls_count
)
log.debug("LLM call started, count=%d", llm_calls_count)
else:
log.debug(
"Skipping LLM call count for internal node: "
"title_generator_node"
)
log.debug("Skipping LLM call count for internal node: title_generator_node")
elif event_type == "on_chat_model_end":
# Filter out title_generator_node from token counting
langgraph_node = event.get("metadata", {}).get(
"langgraph_node", ""
)
langgraph_node = event.get("metadata", {}).get("langgraph_node", "")
if langgraph_node == "title_generator_node":
log.debug(
"Skipping token counting for internal node: "
"title_generator_node"
)
log.debug("Skipping token counting for internal node: title_generator_node")
else:
# Extract token usage from response metadata
# Try multiple possible locations where token usage
@ -441,12 +404,8 @@ class AgentService:
if hasattr(output_msg, "usage_metadata"):
usage = output_msg.usage_metadata
if usage:
input_tokens += usage.get(
"input_tokens", 0
)
output_tokens += usage.get(
"output_tokens", 0
)
input_tokens += usage.get("input_tokens", 0)
output_tokens += usage.get("output_tokens", 0)
token_info_found = True
# Method 3: Check data directly for token usage fields
@ -455,10 +414,7 @@ class AgentService:
input_tokens += data.get("input_tokens", 0)
if "output_tokens" in data:
output_tokens += data.get("output_tokens", 0)
if (
"input_tokens" in data
or "output_tokens" in data
):
if "input_tokens" in data or "output_tokens" in data:
token_info_found = True
# Count AI response as one message (only once per turn)
@ -470,9 +426,7 @@ class AgentService:
elif event_type == "on_tool_end":
message_count += 1 # Tool result message
tool_messages_counted += 1
log.debug(
"Tool message counted, message_count=%d", message_count
)
log.debug("Tool message counted, message_count=%d", message_count)
# Convert event to chunk for SSE streaming
# Use accumulator for on_chat_model_stream events to handle
@ -480,16 +434,11 @@ class AgentService:
# Filter out internal nodes (title_generator_node) from
# streaming to frontend
langgraph_node = event.get("metadata", {}).get(
"langgraph_node", ""
)
langgraph_node = event.get("metadata", {}).get("langgraph_node", "")
if langgraph_node == "title_generator_node":
# Skip all events from the title_generator_node (internal
# use only)
log.debug(
"Skipping event from internal node: "
"title_generator_node"
)
log.debug("Skipping event from internal node: title_generator_node")
continue
if event_type == "on_chat_model_stream":
@ -540,8 +489,7 @@ class AgentService:
last_message_at=last_message_at,
)
log.info(
"Session statistics updated: thread_id=%s, messages=%d, "
"llm_calls=%d, tokens=%d+%d=%d",
"Session statistics updated: thread_id=%s, messages=%d, llm_calls=%d, tokens=%d+%d=%d",
session_id,
message_count,
llm_calls_count,
@ -555,13 +503,8 @@ class AgentService:
if final_state and "conversation_title" in final_state.values:
generated_title = final_state.values["conversation_title"]
current_session = await repo.get_session_by_thread(session_id)
if (
current_session
and current_session.title != generated_title
):
await repo.update_session(
thread_id=session_id, title=generated_title
)
if current_session and current_session.title != generated_title:
await repo.update_session(thread_id=session_id, title=generated_title)
log.info(
"Auto-generated title synced: thread_id=%s, title=%s",
session_id,
@ -576,9 +519,7 @@ class AgentService:
"session_id": session_id,
}
def _convert_event_to_chunk(
self, event: Dict[str, Any], session_id: str
) -> Optional[Dict[str, Any]]:
def _convert_event_to_chunk(self, event: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
"""
Convert LangGraph event to API response chunk.
@ -627,9 +568,7 @@ class AgentService:
return None
async def get_history(
self, session_id: str, limit: int = 100
) -> Dict[str, Any]:
async def get_history(self, session_id: str, limit: int = 100) -> Dict[str, Any]:
"""
Get conversation history for a session.
@ -651,9 +590,7 @@ class AgentService:
for msg in state.values["messages"][-limit:]:
messages.append(self._convert_message_to_dict(msg))
title = state.values.get(
"conversation_title", "New Conversation"
)
title = state.values.get("conversation_title", "New Conversation")
return {
"thread_id": session_id,
@ -710,9 +647,7 @@ class AgentService:
repo = ChatSessionsRepository(self._checkpointer_conn)
return await repo.delete_session(session_id)
async def rename_session(
self, session_id: str, new_title: str
) -> Optional[Dict[str, Any]]:
async def rename_session(self, session_id: str, new_title: str) -> Optional[Dict[str, Any]]:
"""
Rename a chat session.
@ -727,14 +662,10 @@ class AgentService:
await self._get_checkpointer()
repo = ChatSessionsRepository(self._checkpointer_conn)
session = await repo.update_session(
thread_id=session_id, title=new_title
)
session = await repo.update_session(thread_id=session_id, title=new_title)
return session.to_dict() if session else None
async def pin_session(
self, session_id: str, pinned: bool = True
) -> Optional[Dict[str, Any]]:
async def pin_session(self, session_id: str, pinned: bool = True) -> Optional[Dict[str, Any]]:
"""
Pin or unpin a chat session.
@ -760,10 +691,7 @@ class AgentService:
if self._checkpointer_conn:
try:
# Add timeout to prevent blocking on database close
await asyncio.wait_for(
self._checkpointer_conn.close(),
timeout=5.0
)
await asyncio.wait_for(self._checkpointer_conn.close(), timeout=5.0)
log.debug(
"Checkpointer connection closed for: %s",
self.project_path,

View File

@ -156,16 +156,11 @@ class ChatSessionsRepository:
await self.conn.commit()
session_id = cursor.lastrowid
log.info(
"Created chat session: id=%s, thread_id=%s, copilot_mode=%s",
session_id, thread_id, copilot_mode
)
log.info("Created chat session: id=%s, thread_id=%s, copilot_mode=%s", session_id, thread_id, copilot_mode)
return await self.get_session_by_id(session_id)
async def get_session_by_id(
self, session_id: int
) -> Optional[ChatSession]:
async def get_session_by_id(self, session_id: int) -> Optional[ChatSession]:
"""
Get a session by its database ID.
@ -175,18 +170,14 @@ class ChatSessionsRepository:
Returns:
ChatSession or None
"""
cursor = await self.conn.execute(
"SELECT * FROM chat_sessions WHERE id = ?", (session_id,)
)
cursor = await self.conn.execute("SELECT * FROM chat_sessions WHERE id = ?", (session_id,))
row = await cursor.fetchone()
if row:
return self._row_to_session(row)
return None
async def get_session_by_thread(
self, thread_id: str
) -> Optional[ChatSession]:
async def get_session_by_thread(self, thread_id: str) -> Optional[ChatSession]:
"""
Get a session by thread_id.
@ -196,9 +187,7 @@ class ChatSessionsRepository:
Returns:
ChatSession or None
"""
cursor = await self.conn.execute(
"SELECT * FROM chat_sessions WHERE thread_id = ?", (thread_id,)
)
cursor = await self.conn.execute("SELECT * FROM chat_sessions WHERE thread_id = ?", (thread_id,))
row = await cursor.fetchone()
if row:
@ -318,10 +307,7 @@ class ChatSessionsRepository:
params.append(now)
params.append(thread_id)
query = (
f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id "
f"= ?"
)
query = f"UPDATE chat_sessions SET {', '.join(updates)} WHERE thread_id = ?"
await self.conn.execute(query, params)
await self.conn.commit()
@ -340,21 +326,15 @@ class ChatSessionsRepository:
True if deleted, False if not found
"""
# First, delete the checkpoint data
await self.conn.execute(
"DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)
)
await self.conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,))
# Then delete the session
cursor = await self.conn.execute(
"DELETE FROM chat_sessions WHERE thread_id = ?", (thread_id,)
)
cursor = await self.conn.execute("DELETE FROM chat_sessions WHERE thread_id = ?", (thread_id,))
await self.conn.commit()
deleted = cursor.rowcount > 0
if deleted:
log.info(
"Deleted chat session and checkpoints: thread_id=%s", thread_id
)
log.info("Deleted chat session and checkpoints: thread_id=%s", thread_id)
return deleted
@ -378,13 +358,9 @@ class ChatSessionsRepository:
# Delete checkpoints and sessions
for thread_id in thread_ids:
await self.conn.execute(
"DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)
)
await self.conn.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,))
cursor = await self.conn.execute(
"DELETE FROM chat_sessions WHERE project_id = ?", (project_id,)
)
cursor = await self.conn.execute("DELETE FROM chat_sessions WHERE project_id = ?", (project_id,))
await self.conn.commit()
deleted_count = cursor.rowcount
@ -397,9 +373,7 @@ class ChatSessionsRepository:
return deleted_count
async def pin_session(
self, thread_id: str, pinned: bool = True
) -> Optional[ChatSession]:
async def pin_session(self, thread_id: str, pinned: bool = True) -> Optional[ChatSession]:
"""
Pin or unpin a session.
@ -412,8 +386,7 @@ class ChatSessionsRepository:
"""
now = datetime.utcnow().isoformat()
await self.conn.execute(
"UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE "
"thread_id = ?",
"UPDATE chat_sessions SET pinned = ?, updated_at = ? WHERE thread_id = ?",
(1 if pinned else 0, now, thread_id),
)
await self.conn.commit()

View File

@ -35,13 +35,10 @@ from gns3server.config import Config
SKILLS_CONFIG = {
# Git repository URL for skills
"repo_url": "https://github.com/gns3/gns3-skills.git",
# Git branch to use
"branch": "main",
# Automatically pull updates on reload
"auto_update": True,
# Enable external skills loading
# If False, use the built-in hardcoded skills
"enabled": True,

View File

@ -70,22 +70,53 @@ MAX_NODE_FILE_BYTES = 50 * 1024 # 50 KiB
VALID_NODE_FIELDS = {
# NodeBase
"compute_id", "name", "node_type", "node_id",
"console", "console_type", "console_auto_start",
"aux", "aux_type", "properties", "label", "symbol",
"x", "y", "z", "locked",
"port_name_format", "port_segment_size", "first_port_name",
"custom_adapters", "tags",
"compute_id",
"name",
"node_type",
"node_id",
"console",
"console_type",
"console_auto_start",
"aux",
"aux_type",
"properties",
"label",
"symbol",
"x",
"y",
"z",
"locked",
"port_name_format",
"port_segment_size",
"first_port_name",
"custom_adapters",
"tags",
# Node
"template_id", "project_id", "node_directory", "status",
"command_line", "width", "height", "ports", "console_host",
"template_id",
"project_id",
"node_directory",
"status",
"command_line",
"width",
"height",
"ports",
"console_host",
}
VALID_LINK_FIELDS = {
"link_id", "project_id", "link_type", "nodes", "suspend",
"link_style", "filters", "show_filters_icon",
"capturing", "capture_file_name", "capture_file_path",
"capture_compute_id", "wireshark",
"link_id",
"project_id",
"link_type",
"nodes",
"suspend",
"link_style",
"filters",
"show_filters_icon",
"capturing",
"capture_file_name",
"capture_file_path",
"capture_compute_id",
"wireshark",
}
LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
@ -93,6 +124,7 @@ LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
# ── Helpers ────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
return Gns3Connector(
url=gns3_ctx["server_url"],
@ -102,9 +134,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
)
def build_gns3_ctx(
jwt_token: str | None = None, url: str | None = None
) -> dict[str, Any] | None:
def build_gns3_ctx(jwt_token: str | None = None, url: str | None = None) -> dict[str, Any] | None:
"""
Build a handler ``gns3_ctx`` for in-process copilot callers.
@ -183,13 +213,14 @@ def _normalize_link_nodes(nodes) -> list[dict[str, Any]]:
]
raise ValueError(
f"Unrecognized link nodes format. "
f"Use standard [{{\"node_id\":\"..\",\"adapter_number\":0,\"port_number\":0}},...] "
f"or compact [\"id\",0,0,\"id\",0,0], got: {nodes}"
f'Use standard [{{"node_id":"..","adapter_number":0,"port_number":0}},...] '
f'or compact ["id",0,0,"id",0,0], got: {nodes}'
)
# ── Node handlers ──────────────────────────────────────────────────────────
def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
@ -199,7 +230,7 @@ def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
return {"error": 'fields must be a list of field names, e.g. ["name", "status"]'}
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
if invalid:
return {
@ -221,7 +252,7 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
return {"error": 'fields must be a list of field names, e.g. ["name", "status"]'}
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
if invalid:
return {
@ -235,12 +266,14 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
def _batch_lifecycle(project_id, node_ids, action, conn, action_label):
"""Helper to run a lifecycle action on multiple nodes in parallel."""
def _act(nid):
try:
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{nid}/{action}")
return {"node_id": nid, "status": "success", "message": f"Node {nid} {action_label}"}
except Exception as e:
return {"node_id": nid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_act, node_ids))
@ -307,7 +340,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
fields = params.get("fields")
if fields is not None and not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"node_id\", \"name\"]"}
return {"error": 'fields must be a list, e.g. ["node_id", "name"]'}
nodes = params.get("nodes")
# Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}]
@ -317,6 +350,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return {"error": "nodes must be a non-empty array"}
default_tid = params.get("template_id")
conn = _get_connector(gns3_ctx)
def _create_one(node_data):
tid = node_data.get("template_id", default_tid)
if not tid:
@ -335,6 +369,7 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)}
except Exception as e:
return {"template_id": tid, "status": "error", "error": str(e)}
if any(not node.get("name") for node in nodes):
# The controller assigns default names (R-1, R-2, ...) and console
# ports in request arrival order, and a parallel fan-out makes the
@ -375,12 +410,14 @@ def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if not isinstance(node_ids, list):
return {"error": "node_ids must be a list"}
conn = _get_connector(gns3_ctx)
def _del(nid):
try:
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{nid}")
return {"node_id": nid, "status": "success", "message": f"Node {nid} deleted"}
except Exception as e:
return {"node_id": nid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_del, node_ids))
node_id = params.get("node_id")
@ -423,12 +460,16 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
# the ~200-char JWT previously embedded here (dropped header segment →
# "Missing 'alg' value in header" on the server).
username = gns3_ctx.get("jwt_username")
ticket = access_ticket_service.mint(
username,
token_version=gns3_ctx.get("jwt_token_version", 0),
project_id=project_id,
node_id=node_id,
) if username else None
ticket = (
access_ticket_service.mint(
username,
token_version=gns3_ctx.get("jwt_token_version", 0),
project_id=project_id,
node_id=node_id,
)
if username
else None
)
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
if ticket:
raw_url += f"?token={ticket}"
@ -499,7 +540,7 @@ def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
total_lines = len(lines)
# Apply offset/limit
selected = lines[offset: offset + limit] if offset < total_lines else []
selected = lines[offset : offset + limit] if offset < total_lines else []
has_more = (offset + limit) < total_lines or truncated
content = "".join(selected)
@ -581,7 +622,9 @@ def duplicate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
return {"error": "project_id and node_id are required"}
conn = _get_connector(gns3_ctx)
data = {k: v for k, v in params.items() if k not in ("project_id", "node_id") and v is not None}
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data).json()
result = conn.http_call(
"post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data
).json()
return {"message": f"Node {node_id} duplicated", "node": result}
@ -617,6 +660,7 @@ def get_node_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
# ── Link handlers ──────────────────────────────────────────────────────────
def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
@ -626,7 +670,7 @@ def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
return {"error": 'fields must be a list, e.g. ["link_id", "nodes"]'}
invalid = [f for f in fields if f not in VALID_LINK_FIELDS]
if invalid:
return {
@ -669,7 +713,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
fields = params.get("fields")
if fields is not None and not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
return {"error": 'fields must be a list, e.g. ["link_id", "nodes"]'}
links = params.get("links")
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}]
@ -677,6 +721,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if not isinstance(links, list) or not links:
return {"error": "links must be a non-empty array"}
conn = _get_connector(gns3_ctx)
def _create_one(link_data):
raw_nodes = link_data.get("nodes")
if not raw_nodes:
@ -694,6 +739,7 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
return {"status": "success", "link": _filter_link_response(resp, fields)}
except Exception as e:
return {"status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool:
# pool.map keeps the submission order, so callers can correlate
# results with the links they sent regardless of completion order
@ -725,12 +771,14 @@ def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
conn = _get_connector(gns3_ctx)
def _del(lid):
try:
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{lid}")
return {"link_id": lid, "status": "success", "message": f"Link {lid} deleted"}
except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_del, link_ids))
link_id = params.get("link_id")
@ -770,6 +818,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
conn = _get_connector(gns3_ctx)
def _rst(lid):
try:
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/reset"
@ -777,6 +826,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
return {"link_id": lid, "status": "reset", "link": r}
except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_rst, link_ids))
link_id = params.get("link_id")
@ -790,6 +840,7 @@ def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
def _batch_capture(project_id, link_ids, action, data_builder, conn):
"""Helper for batch capture start/stop."""
def _act(lid):
try:
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/capture/{action}"
@ -798,6 +849,7 @@ def _batch_capture(project_id, link_ids, action, data_builder, conn):
return {"link_id": lid, "status": "success"}
except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_act, link_ids))
@ -814,11 +866,13 @@ def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
dlt = params.get("data_link_type", "DLT_EN10MB")
ws = params.get("wireshark", False)
fname = params.get("capture_file_name")
def _build(lid):
data = {"data_link_type": dlt, "wireshark": ws}
if fname:
data["capture_file_name"] = fname
return {"json_data": data}
return _batch_capture(project_id, link_ids, "start", _build, conn)
link_id = params.get("link_id")
if not link_id:
@ -879,7 +933,11 @@ def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, An
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
results = [_download(lid) for lid in link_ids]
return {"downloads": results, "count": len(results), "note": "Files are in pcap format. URLs include a 10-minute ticket."}
return {
"downloads": results,
"count": len(results),
"note": "Files are in pcap format. URLs include a 10-minute ticket.",
}
link_id = params.get("link_id")
if not link_id:
@ -946,12 +1004,18 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
elif direction in ("tx", "rx"):
body["direction"] = direction
if not body:
return {"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"}
return {
"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"
}
return conn.http_call("put", url, json_data=body).json()
# action == "delete"
conn.http_call("delete", url)
return {"message": f"Marker '{marker_name}' deleted from link {link_id}", "link_id": link_id, "marker_name": marker_name}
return {
"message": f"Marker '{marker_name}' deleted from link {link_id}",
"link_id": link_id,
"marker_name": marker_name,
}
def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
@ -1002,7 +1066,9 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
if params.get(opt) is not None:
body[opt] = params[opt]
if not body:
return {"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"}
return {
"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"
}
return conn.http_call("put", url, json_data=body).json()
# action == "delete"

View File

@ -139,17 +139,11 @@ class Gns3Connector:
self.session.headers["Accept"] = "application/json" # pragma: no cover
# Set authentication based on API version
if (
self.auth_type == "basic"
and self.user is not None
and self.cred is not None
):
if self.auth_type == "basic" and self.user is not None and self.cred is not None:
self.session.auth = (self.user, self.cred) # pragma: no cover
elif self.auth_type == "jwt" and self.access_token:
self.session.headers["Authorization"] = (
f"Bearer {self.access_token}"
)
self.session.headers["Authorization"] = f"Bearer {self.access_token}"
def _authenticate_v3(self) -> None:
"""
@ -161,15 +155,10 @@ class Gns3Connector:
return
if not self.user or not self.cred:
raise ValueError(
"Username and password are required for v3 authentication "
"when no JWT token is provided"
)
raise ValueError("Username and password are required for v3 authentication when no JWT token is provided")
# Construct authentication URL (v3 API uses different base URL)
auth_url = (
f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
)
auth_url = f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
auth_data = {"username": self.user, "password": self.cred}
# Use temporary session for authentication
@ -177,21 +166,14 @@ class Gns3Connector:
temp_session.headers["Content-Type"] = "application/json"
try:
response = temp_session.post(
auth_url, json=auth_data, verify=self.verify, timeout=10.0
)
response = temp_session.post(auth_url, json=auth_data, verify=self.verify, timeout=10.0)
if response.status_code == 200:
auth_result = response.json()
self.access_token = auth_result["access_token"]
# Update session with new token
self.session.headers["Authorization"] = (
f"Bearer {self.access_token}"
)
self.session.headers["Authorization"] = f"Bearer {self.access_token}"
else:
raise HTTPError(
f"v3 API authentication failed: {response.status_code} - "
f"{response.text}"
)
raise HTTPError(f"v3 API authentication failed: {response.status_code} - {response.text}")
except Exception as e:
raise HTTPError(f"v3 API authentication error: {str(e)}") from e
@ -205,9 +187,7 @@ class Gns3Connector:
try:
# Decode token without verification to check expiry
decoded: dict[str, Any] = jwt.decode(
token, options={"verify_signature": False}
)
decoded: dict[str, Any] = jwt.decode(token, options={"verify_signature": False})
exp = decoded.get("exp")
if exp is not None:
return time.time() > float(exp)
@ -237,12 +217,7 @@ class Gns3Connector:
"""
# Handle JWT authentication
if (
self.auth_type == "jwt"
and not self.access_token
and self.user
and self.cred
):
if self.auth_type == "jwt" and not self.access_token and self.user and self.cred:
self._authenticate_v3()
# Get request function (e.g., session.get, session.post)
@ -285,15 +260,10 @@ class Gns3Connector:
try:
# Only attempt parsing when Content-Type is JSON
if (
"application/json"
in response.headers.get("Content-Type", "").lower()
):
if "application/json" in response.headers.get("Content-Type", "").lower():
error_json = response.json()
status = error_json.get("status", "Unknown Status")
message = error_json.get(
"message", "No message provided in JSON."
)
message = error_json.get("message", "No message provided in JSON.")
# Construct a more descriptive new error
new_err = HTTPError(
f"{status}: {message} (Original {response.status_code} Error)",

View File

@ -76,10 +76,7 @@ def _get_url_from_controller() -> Optional[str]:
controller = Controller.instance()
local_compute = controller.get_compute("local")
url = (
f"{local_compute.protocol}://{local_compute.host}:"
f"{local_compute.port}"
)
url = f"{local_compute.protocol}://{local_compute.host}:{local_compute.port}"
logger.debug(
"Got GNS3 URL from Controller: %s (protocol=%s, host=%s, port=%s)",
url,
@ -98,9 +95,7 @@ def _get_url_from_controller() -> Optional[str]:
logger.debug("Local compute not found in Controller: %s", str(e))
return None
except Exception as e:
logger.warning(
"Unexpected error getting URL from Controller: %s", str(e)
)
logger.warning("Unexpected error getting URL from Controller: %s", str(e))
return None
@ -114,10 +109,7 @@ def _get_url_from_config() -> Optional[str]:
from gns3server.config import Config
server_config = Config.instance().settings.Server
url = (
f"{server_config.protocol.value}://{server_config.host}:"
f"{server_config.port}"
)
url = f"{server_config.protocol.value}://{server_config.host}:{server_config.port}"
logger.debug(
"Got GNS3 URL from Config: %s (protocol=%s, host=%s, port=%s)",
url,
@ -137,9 +129,7 @@ def _get_url_from_config() -> Optional[str]:
return None
def get_gns3_connector(
jwt_token: Optional[str] = None, url: Optional[str] = None
) -> Optional[Gns3Connector]:
def get_gns3_connector(jwt_token: Optional[str] = None, url: Optional[str] = None) -> Optional[Gns3Connector]:
"""Create and return a Gns3Connector instance with JWT authentication.
URL Resolution Strategy (in order):
@ -223,9 +213,7 @@ def get_gns3_connector(
return connector
except Exception as e:
logger.error(
"Failed to create Gns3Connector: %s", str(e), exc_info=True
)
logger.error("Failed to create Gns3Connector: %s", str(e), exc_info=True)
return None
@ -284,9 +272,7 @@ async def get_gns3_connector_with_llm_config(
url = _detect_url_for_api()
# Step 3: Get LLM config
llm_config = get_llm_config(
user_id=user_id, jwt_token=jwt_token, app=app
)
llm_config = get_llm_config(user_id=user_id, jwt_token=jwt_token, app=app)
if not llm_config:
logger.warning(f"No LLM config found for user {user_id}")
@ -303,9 +289,7 @@ async def get_gns3_connector_with_llm_config(
return {"connector": connector, "llm_config": llm_config}
except Exception as e:
logger.error(
f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True
)
logger.error(f"Failed to get GNS3 connector with LLM config: {e}", exc_info=True)
return None
@ -375,9 +359,7 @@ def get_gns3_server_host() -> str:
logger.debug("Extracted GNS3 server host: %s from URL: %s", host, url)
return host
except Exception as e:
logger.warning(
"Failed to extract host from URL %s: %s, using fallback", url, e
)
logger.warning("Failed to extract host from URL %s: %s, using fallback", url, e)
return DEFAULT_GNS3_URL.split("://")[1].split(":")[0]
@ -428,9 +410,7 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]:
# We're in an async context with a running loop
# This shouldn't happen since this is a sync function
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(
asyncio.run, get_user_llm_config_with_app(user_id, app)
)
future = executor.submit(asyncio.run, get_user_llm_config_with_app(user_id, app))
return future.result(timeout=10)
except RuntimeError:
# No running event loop - we're in a sync context
@ -449,26 +429,19 @@ def get_llm_config(user_id, jwt_token: str, app=None) -> Optional[dict]:
return future.result(timeout=10)
else:
# Loop exists but not running - use it
return loop.run_until_complete(
get_user_llm_config_with_app(user_id, app)
)
return loop.run_until_complete(get_user_llm_config_with_app(user_id, app))
except RuntimeError:
# No event loop exists - create a new one
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(
get_user_llm_config_with_app(user_id, app)
)
return loop.run_until_complete(get_user_llm_config_with_app(user_id, app))
finally:
loop.close()
# Fallback: No app provided, try API call (will mask group config API
# keys)
logger.warning(
"No app provided for get_llm_config, group config API keys may be "
"masked"
)
logger.warning("No app provided for get_llm_config, group config API keys may be masked")
return None
except Exception as e:

View File

@ -62,12 +62,8 @@ logger = logging.getLogger(__name__)
# Context variables for request-scoped data
# Automatically cleaned up when request context ends
_jwt_token_context: ContextVar[Optional[str]] = ContextVar(
"_jwt_token_context", default=None
)
_llm_config_context: ContextVar[Optional[dict]] = ContextVar(
"_llm_config_context", default=None
)
_jwt_token_context: ContextVar[Optional[str]] = ContextVar("_jwt_token_context", default=None)
_llm_config_context: ContextVar[Optional[dict]] = ContextVar("_llm_config_context", default=None)
def set_current_jwt_token(token: str) -> None:

View File

@ -90,20 +90,13 @@ class GNS3ProjectInfoTool(BaseTool):
"""
# Log received input
logger.info(
"Received tool_input: %s, project_id: %s", tool_input, project_id
)
logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id)
try:
# Validate project_id parameter
if not project_id:
logger.error("project_id parameter is required.")
return {
"error": (
"project_id parameter is required. Please provide a valid "
"project UUID."
)
}
return {"error": ("project_id parameter is required. Please provide a valid project UUID.")}
# Build handler context (JWT + server URL from request context)
logger.debug("Connecting to GNS3 server...")
@ -111,17 +104,10 @@ class GNS3ProjectInfoTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": (
"Failed to connect to GNS3 server. Please check your "
"configuration."
)
}
return {"error": ("Failed to connect to GNS3 server. Please check your configuration.")}
# Use the provided project_id directly
logger.info(
f"Retrieving project info for project_id: {project_id}"
)
logger.info(f"Retrieving project info for project_id: {project_id}")
inventory = fetch_project_inventory(gns3_ctx, project_id)
# Get node and link counts

View File

@ -100,18 +100,13 @@ class GNS3TopologyTool(BaseTool):
"""
# Log received input
logger.info(
"Received tool_input: %s, project_id: %s", tool_input, project_id
)
logger.info("Received tool_input: %s, project_id: %s", tool_input, project_id)
try:
# Validate project_id parameter
if not project_id:
logger.error("project_id parameter is required.")
return {
"error": "project_id parameter is required. "
"Please provide a valid project UUID."
}
return {"error": "project_id parameter is required. Please provide a valid project UUID."}
# Build handler context (JWT + server URL)
# jwt_token/url can be passed explicitly (e.g. from MCP handlers)
@ -121,10 +116,7 @@ class GNS3TopologyTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. Please check "
"your configuration."
}
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
# Use the provided project_id directly
logger.info(f"Retrieving topology for project_id: {project_id}")
@ -135,16 +127,13 @@ class GNS3TopologyTool(BaseTool):
"project_id": inventory["project_id"],
"name": inventory["name"],
"status": inventory["status"],
"nodes": self._clean_nodes_ports(
copy.deepcopy(inventory["nodes_inventory"])
),
"nodes": self._clean_nodes_ports(copy.deepcopy(inventory["nodes_inventory"])),
"links": inventory["links_summary"],
}
# Log topology result
logger.info(
"Topology retrieved: project_id=%s, name=%s, nodes=%d, "
"links=%d",
"Topology retrieved: project_id=%s, name=%s, nodes=%d, links=%d",
topology.get("project_id"),
topology.get("name"),
len(topology.get("nodes", {})),
@ -167,10 +156,7 @@ class GNS3TopologyTool(BaseTool):
"""
for node in data.values(): # Iterate through R-1, R-2, R-3, R-4
if "ports" in node and isinstance(node["ports"], list):
node["ports"] = [
{"name": port["name"], "short_name": port["short_name"]}
for port in node["ports"]
]
node["ports"] = [{"name": port["name"], "short_name": port["short_name"]} for port in node["ports"]]
return data

View File

@ -38,9 +38,7 @@ from urllib.parse import urlparse
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import _get_connector
def build_nodes_inventory(
nodes: list[dict[str, Any]], server_host: str | None
) -> dict[str, Any]:
def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None) -> dict[str, Any]:
"""
Build an inventory-style dict keyed by node name.
@ -70,9 +68,7 @@ def build_nodes_inventory(
return inventory
def build_links_summary(
nodes: list[dict[str, Any]], links: list[dict[str, Any]]
) -> list[dict[str, str]]:
def build_links_summary(nodes: list[dict[str, Any]], links: list[dict[str, Any]]) -> list[dict[str, str]]:
"""
Build a human/LLM-friendly link list resolving node and port names.
@ -87,46 +83,40 @@ def build_links_summary(
side_a = link["nodes"][0]
side_b = link["nodes"][1]
try:
node_a = next(
x for x in nodes if x.get("node_id") == side_a["node_id"]
)
node_a = next(x for x in nodes if x.get("node_id") == side_a["node_id"])
port_a = str(
next(
p["name"]
for p in (node_a.get("ports") or [])
if p["port_number"] == side_a["port_number"]
and p["adapter_number"] == side_a["adapter_number"]
if p["port_number"] == side_a["port_number"] and p["adapter_number"] == side_a["adapter_number"]
)
)
node_b = next(
x for x in nodes if x.get("node_id") == side_b["node_id"]
)
node_b = next(x for x in nodes if x.get("node_id") == side_b["node_id"])
port_b = str(
next(
p["name"]
for p in (node_b.get("ports") or [])
if p["port_number"] == side_b["port_number"]
and p["adapter_number"] == side_b["adapter_number"]
if p["port_number"] == side_b["port_number"] and p["adapter_number"] == side_b["adapter_number"]
)
)
name_a = str(node_a["name"]) if node_a.get("name") else "Unknown"
name_b = str(node_b["name"]) if node_b.get("name") else "Unknown"
summary.append({
"link_id": link.get("link_id"),
"node_a": name_a,
"port_a": port_a,
"node_b": name_b,
"port_b": port_b,
})
summary.append(
{
"link_id": link.get("link_id"),
"node_a": name_a,
"port_a": port_a,
"node_b": name_b,
"port_b": port_b,
}
)
except (StopIteration, KeyError, AttributeError):
# Prevent errors when lookups can't match data
continue
return summary
def fetch_project_inventory(
gns3_ctx: dict[str, Any], project_id: str
) -> dict[str, Any]:
def fetch_project_inventory(gns3_ctx: dict[str, Any], project_id: str) -> dict[str, Any]:
"""
Fetch a project's metadata, nodes and links and return the aggregated
inventory — the equivalent of the old ``Project.get()`` +

View File

@ -61,9 +61,7 @@ class ProjectAgentManager:
cls._instance._lock = asyncio.Lock()
return cls._instance
async def get_agent(
self, project_id: str, project_path: str
) -> AgentService:
async def get_agent(self, project_id: str, project_path: str) -> AgentService:
"""
Get or create an AgentService for a project.

View File

@ -70,10 +70,7 @@ def load_system_prompt(llm_config: dict | None = None) -> str:
str: The system prompt string, or empty string if not found.
"""
if not llm_config:
logger.debug(
"No LLM config provided, using default TEACHING_ASSISTANT "
"prompt mode"
)
logger.debug("No LLM config provided, using default TEACHING_ASSISTANT prompt mode")
return _load_prompt("teaching_assistant")
# llm_config is a flattened dict with copilot_mode at the top level
@ -81,15 +78,10 @@ def load_system_prompt(llm_config: dict | None = None) -> str:
mode = llm_config.get("copilot_mode", "teaching_assistant").lower()
if mode == "lab_automation_assistant":
logger.debug(
"Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + "
"configuration)"
)
logger.debug("Using LAB_AUTOMATION_ASSISTANT prompt mode (diagnostics + configuration)")
return _load_prompt("lab_automation_assistant")
elif mode == "troubleshooting_injection":
logger.debug(
"Using TROUBLESHOOTING_INJECTION prompt mode (fault injection)"
)
logger.debug("Using TROUBLESHOOTING_INJECTION prompt mode (fault injection)")
return _load_prompt("troubleshooting_injection")
else:
logger.debug("Using TEACHING_ASSISTANT prompt mode (diagnostics only)")

View File

@ -201,7 +201,9 @@ class SkillsLoader:
topic_key = yaml_file.stem
logger.warning(f"No 'topic' field in {yaml_file}, using filename '{topic_key}'")
if topic_key in topics:
logger.warning(f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting")
logger.warning(
f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting"
)
# category/topics belong to the base skill only
topic_data.pop("category", None)

View File

@ -49,9 +49,9 @@ logger = logging.getLogger(__name__)
# Applied per-command via the `env` parameter to avoid polluting
# the global process environment.
_GIT_TIMEOUT_ENV = {
'GIT_HTTP_TIMEOUT': '10', # Connection timeout (default: 120s)
'GIT_HTTP_LOW_SPEED_TIME': '5', # Slow speed threshold window
'GIT_HTTP_LOW_SPEED_LIMIT': '10240', # < 10 KB/s = slow → abort
"GIT_HTTP_TIMEOUT": "10", # Connection timeout (default: 120s)
"GIT_HTTP_LOW_SPEED_TIME": "5", # Slow speed threshold window
"GIT_HTTP_LOW_SPEED_LIMIT": "10240", # < 10 KB/s = slow → abort
}
@ -66,12 +66,7 @@ class SkillsManager:
- Version tracking
"""
def __init__(
self,
repo_url: str = None,
branch: str = "main",
auto_update: bool = False
):
def __init__(self, repo_url: str = None, branch: str = "main", auto_update: bool = False):
"""
Initialize the skills manager.
@ -147,7 +142,7 @@ class SkillsManager:
logger.warning(
"Skills repository has uncommitted changes, skipping pull. "
"Commit or stash changes in %s to enable automatic updates.",
self.local_path
self.local_path,
)
return
@ -161,14 +156,9 @@ class SkillsManager:
# Check if behind and pull
try:
behind_commits = list(self._repo.iter_commits(
f'{self.branch}..origin/{self.branch}'
))
behind_commits = list(self._repo.iter_commits(f"{self.branch}..origin/{self.branch}"))
if behind_commits:
logger.info(
"Skills repository is behind by %d commit(s), pulling...",
len(behind_commits)
)
logger.info("Skills repository is behind by %d commit(s), pulling...", len(behind_commits))
origin.pull(self.branch, env=_GIT_TIMEOUT_ENV)
logger.info(f"Updated to commit {self.get_current_version()}")
else:
@ -188,12 +178,7 @@ class SkillsManager:
return False
try:
self._repo = git.Repo.clone_from(
self.repo_url,
self.local_path,
branch=self.branch,
env=_GIT_TIMEOUT_ENV
)
self._repo = git.Repo.clone_from(self.repo_url, self.local_path, branch=self.branch, env=_GIT_TIMEOUT_ENV)
logger.info(f"Successfully cloned skills repository to {self.local_path}")
return True
except git.GitCommandError as e:
@ -256,7 +241,9 @@ class SkillsManager:
del SKILLS_REGISTRY[k]
SKILLS_REGISTRY.update(all_skills)
logger.info(f"Loaded {len(new_injection_skills)} injection skills, {len(new_device_skills)} device skills, and {len(new_feature_skills)} feature skills")
logger.info(
f"Loaded {len(new_injection_skills)} injection skills, {len(new_device_skills)} device skills, and {len(new_feature_skills)} feature skills"
)
return True
except Exception as e:
logger.error(f"Failed to reload skills: {e}")
@ -302,12 +289,7 @@ class SkillsManager:
"""
try:
# Available prompt names
prompt_names = [
"lab_automation_assistant",
"teaching_assistant",
"troubleshooting_injection",
"title"
]
prompt_names = ["lab_automation_assistant", "teaching_assistant", "troubleshooting_injection", "title"]
# Load all prompts
loaded_count = 0
@ -385,6 +367,7 @@ class SkillsManager:
"""
try:
from .registry import INJECTION_SKILLS_REGISTRY
return len(INJECTION_SKILLS_REGISTRY)
except Exception:
return 0
@ -413,7 +396,7 @@ class SkillsManager:
"skill_count": self.get_skill_count(),
"prompt_count": self.get_prompt_count(),
"auto_update": self.auto_update,
"is_initialized": (self.local_path / ".git").exists()
"is_initialized": (self.local_path / ".git").exists(),
}
def rollback(self, commit_hash: str) -> bool:
@ -462,12 +445,14 @@ class SkillsManager:
commits = []
for commit in self._repo.iter_commits(max_count=limit):
commits.append({
"hash": commit.hexsha,
"message": commit.message.strip(),
"author": str(commit.author),
"date": commit.committed_datetime.isoformat()
})
commits.append(
{
"hash": commit.hexsha,
"message": commit.message.strip(),
"author": str(commit.author),
"date": commit.committed_datetime.isoformat(),
}
)
return commits
except Exception as e:
logger.error(f"Failed to get commit history: {e}")

View File

@ -115,7 +115,7 @@ def _ensure_skills_manager():
manager = SkillsManager(
repo_url=config.get("repo_url"),
branch=config.get("branch", "main"),
auto_update=config.get("auto_update", False)
auto_update=config.get("auto_update", False),
)
if not manager.initialize():
@ -224,7 +224,7 @@ def reload_injection_skills() -> dict[str, Any]:
"success": False,
"message": "Skills manager not initialized",
"skill_count": len(INJECTION_SKILLS_REGISTRY),
"version": ""
"version": "",
}
try:
@ -233,7 +233,7 @@ def reload_injection_skills() -> dict[str, Any]:
"success": success,
"message": "Skills reloaded successfully" if success else "Failed to reload skills",
"skill_count": manager.get_skill_count(),
"version": manager.get_current_version()
"version": manager.get_current_version(),
}
except Exception as e:
logger.error(f"Error during skills reload: {e}")
@ -241,7 +241,7 @@ def reload_injection_skills() -> dict[str, Any]:
"success": False,
"message": f"Error: {str(e)}",
"skill_count": len(INJECTION_SKILLS_REGISTRY),
"version": ""
"version": "",
}
@ -263,12 +263,7 @@ def reload_prompts() -> dict[str, Any]:
"""
manager = get_skills_manager()
if manager is None:
return {
"success": False,
"message": "Skills manager not initialized",
"prompt_count": 0,
"version": ""
}
return {"success": False, "message": "Skills manager not initialized", "prompt_count": 0, "version": ""}
try:
success = manager.reload_prompts()
@ -276,7 +271,7 @@ def reload_prompts() -> dict[str, Any]:
"success": success,
"message": "Prompts reloaded successfully" if success else "Failed to reload prompts",
"prompt_count": manager.get_prompt_count(),
"version": manager.get_current_version()
"version": manager.get_current_version(),
}
except Exception as e:
logger.error(f"Error during prompts reload: {e}")
@ -285,7 +280,7 @@ def reload_prompts() -> dict[str, Any]:
"success": False,
"message": f"Error: {str(e)}",
"prompt_count": manager.get_prompt_count() if manager else 0,
"version": ""
"version": "",
}
@ -315,16 +310,11 @@ def reload_forbidden_commands() -> dict[str, Any]:
"success": True,
"message": "Forbidden commands reloaded",
"command_count": len(commands),
"version": manager.get_current_version() if manager else ""
"version": manager.get_current_version() if manager else "",
}
except Exception as e:
logger.error(f"Error during forbidden commands reload: {e}")
return {
"success": False,
"message": f"Error: {str(e)}",
"command_count": 0,
"version": ""
}
return {"success": False, "message": f"Error: {str(e)}", "command_count": 0, "version": ""}
def get_prompt(prompt_name: str) -> str:
@ -360,10 +350,7 @@ def get_skills_repository_info() -> dict[str, Any]:
"""
manager = get_skills_manager()
if manager is None:
return {
"initialized": False,
"message": "Skills manager not initialized"
}
return {"initialized": False, "message": "Skills manager not initialized"}
return manager.get_repository_info()
@ -411,8 +398,7 @@ def get_skill(
"error": f"device_type '{device_type}' is not in category '{category}'",
"device_category": skill.get("category"),
"available_in_category": [
did for did, s in SKILLS_REGISTRY.items()
if s.get("category", "").lower() == category.lower()
did for did, s in SKILLS_REGISTRY.items() if s.get("category", "").lower() == category.lower()
],
}
@ -484,8 +470,7 @@ def get_skill(
}
if topics:
result["topics"] = {
k: {"name": v.get("name", k), "description": v.get("description", "")}
for k, v in topics.items()
k: {"name": v.get("name", k), "description": v.get("description", "")} for k, v in topics.items()
}
return result
@ -503,19 +488,23 @@ def list_available_skills(category: str | None = None) -> list[dict[str, Any]]:
for did, skill in SKILLS_REGISTRY.items():
if category:
if skill.get("category", "").lower() == category.lower():
skills.append({
skills.append(
{
"device_type": did,
"name": skill.get("name", did),
"category": skill.get("category"),
"topic_count": len(skill.get("topics", {})),
}
)
else:
skills.append(
{
"device_type": did,
"name": skill.get("name", did),
"category": skill.get("category"),
"topic_count": len(skill.get("topics", {})),
})
else:
skills.append({
"device_type": did,
"name": skill.get("name", did),
"category": skill.get("category"),
"topic_count": len(skill.get("topics", {})),
})
}
)
return skills
@ -541,7 +530,7 @@ def get_injection_skill(
return {
"error": f"Unknown injection fault type: {device_type}",
"available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()),
"hint": "Use {'action': 'list'} to see all available fault types"
"hint": "Use {'action': 'list'} to see all available fault types",
}
issues = skill.get("issues", {})
@ -611,11 +600,13 @@ def list_available_injection_skills(context: list[str] | None = None) -> list[di
category_lower = category.lower()
if not any(kw.lower() in category_lower or kw.lower() in did.lower() for kw in context):
continue
skills.append({
"device_type": did,
"name": skill.get("name", did),
"category": category,
})
skills.append(
{
"device_type": did,
"name": skill.get("name", did),
"category": category,
}
)
return skills
@ -658,11 +649,13 @@ def list_available_packet_analysis_protocols() -> list[dict[str, str]]:
"""
protocols = []
for key, data in PACKET_ANALYSIS_REGISTRY.items():
protocols.append({
"protocol": key,
"name": data.get("name", key),
"description": data.get("description", ""),
})
protocols.append(
{
"protocol": key,
"name": data.get("name", key),
"description": data.get("description", ""),
}
)
return protocols
@ -711,10 +704,14 @@ class DeviceSkillsTool(BaseTool):
try:
params = json.loads(tool_input)
except json.JSONDecodeError as e:
return json.dumps({
"error": f"Invalid JSON input: {e}",
"hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}'
}, ensure_ascii=False, indent=2)
return json.dumps(
{
"error": f"Invalid JSON input: {e}",
"hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}',
},
ensure_ascii=False,
indent=2,
)
else:
params = tool_input
@ -722,18 +719,19 @@ class DeviceSkillsTool(BaseTool):
if action == "list":
skills = list_available_skills()
return json.dumps({
"count": len(skills),
"skills": skills
}, ensure_ascii=False, indent=2)
return json.dumps({"count": len(skills), "skills": skills}, ensure_ascii=False, indent=2)
device_type = params.get("device_type")
if not device_type:
return json.dumps({
"error": "Missing required field: device_type",
"available_device_types": list(SKILLS_REGISTRY.keys()),
"hint": 'Use {"action": "list"} to see all available device types'
}, ensure_ascii=False, indent=2)
return json.dumps(
{
"error": "Missing required field: device_type",
"available_device_types": list(SKILLS_REGISTRY.keys()),
"hint": 'Use {"action": "list"} to see all available device types',
},
ensure_ascii=False,
indent=2,
)
category = params.get("category")
detail = params.get("detail", "full")
@ -788,10 +786,14 @@ class InjectionSkillsTool(BaseTool):
try:
params = json.loads(tool_input)
except json.JSONDecodeError as e:
return json.dumps({
"error": f"Invalid JSON input: {e}",
"hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}'
}, ensure_ascii=False, indent=2)
return json.dumps(
{
"error": f"Invalid JSON input: {e}",
"hint": 'Expected format: {"device_type": "xxx"} or {"action": "list"}',
},
ensure_ascii=False,
indent=2,
)
else:
params = tool_input
@ -800,33 +802,44 @@ class InjectionSkillsTool(BaseTool):
if action == "list":
context = params.get("context")
if not context or not isinstance(context, list) or len(context) == 0:
return json.dumps({
"error": "context parameter is required when action='list'",
"hint": "Analyze the topology and device configurations first, "
"then pass the protocols/services you found as context. "
'Example: {"action": "list", "context": ["ospf", "bgp", "vlan"]}',
"available_categories": sorted(set(
skill.get("category", "")
for skill in INJECTION_SKILLS_REGISTRY.values()
))
}, ensure_ascii=False, indent=2)
return json.dumps(
{
"error": "context parameter is required when action='list'",
"hint": "Analyze the topology and device configurations first, "
"then pass the protocols/services you found as context. "
'Example: {"action": "list", "context": ["ospf", "bgp", "vlan"]}',
"available_categories": sorted(
set(skill.get("category", "") for skill in INJECTION_SKILLS_REGISTRY.values())
),
},
ensure_ascii=False,
indent=2,
)
skills = list_available_injection_skills(context=context)
logger.debug(f"Injection skills filtered by context={context}: {len(skills)} matching")
return json.dumps({
"count": len(skills),
"total_available": len(INJECTION_SKILLS_REGISTRY),
"context": context,
"fault_types": skills
}, ensure_ascii=False, indent=2)
return json.dumps(
{
"count": len(skills),
"total_available": len(INJECTION_SKILLS_REGISTRY),
"context": context,
"fault_types": skills,
},
ensure_ascii=False,
indent=2,
)
device_type = params.get("device_type")
if not device_type:
return json.dumps({
"error": "Missing required field: device_type",
"available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()),
"hint": 'Use {"action": "list"} to see all available fault types'
}, ensure_ascii=False, indent=2)
return json.dumps(
{
"error": "Missing required field: device_type",
"available_fault_types": list(INJECTION_SKILLS_REGISTRY.keys()),
"hint": 'Use {"action": "list"} to see all available fault types',
},
ensure_ascii=False,
indent=2,
)
detail = params.get("detail", "full")
issue = params.get("issue")
@ -876,10 +889,14 @@ class PacketAnalysisSkillsTool(BaseTool):
try:
params = json.loads(tool_input)
except json.JSONDecodeError as e:
return json.dumps({
"error": f"Invalid JSON input: {e}",
"hint": 'Expected format: {"action": "get", "protocol": "ospf"}'
}, ensure_ascii=False, indent=2)
return json.dumps(
{
"error": f"Invalid JSON input: {e}",
"hint": 'Expected format: {"action": "get", "protocol": "ospf"}',
},
ensure_ascii=False,
indent=2,
)
else:
params = tool_input
@ -887,17 +904,18 @@ class PacketAnalysisSkillsTool(BaseTool):
if action == "list":
protocols = list_available_packet_analysis_protocols()
return json.dumps({
"count": len(protocols),
"protocols": protocols
}, ensure_ascii=False, indent=2)
return json.dumps({"count": len(protocols), "protocols": protocols}, ensure_ascii=False, indent=2)
protocol = params.get("protocol")
if not protocol:
return json.dumps({
"error": "Missing required field: protocol",
"available_protocols": list(PACKET_ANALYSIS_REGISTRY.keys()),
}, ensure_ascii=False, indent=2)
return json.dumps(
{
"error": "Missing required field: protocol",
"available_protocols": list(PACKET_ANALYSIS_REGISTRY.keys()),
},
ensure_ascii=False,
indent=2,
)
result = get_packet_analysis_protocol(protocol)
return json.dumps(result, ensure_ascii=False, indent=2)

View File

@ -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",
]

View File

@ -196,56 +196,35 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input)
if (
isinstance(device_configs_list, list)
and len(device_configs_list) > 0
and "error" in device_configs_list[0]
):
if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
return device_configs_list
# Filter forbidden commands and store blocked commands info
device_configs_list, blocked_commands_map = (
self._filter_forbidden_commands_from_device_configs(
device_configs_list
)
device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs(
device_configs_list
)
# Expand multiline commands (e.g., banner commands with embedded newlines)
# This converts commands like "banner motd #\nline1\nline2\n#" into
# separate commands: ["banner motd #", "line1", "line2", "#"]
device_configs_list = self._expand_multiline_commands(
device_configs_list
)
device_configs_list = self._expand_multiline_commands(device_configs_list)
# Create a mapping of device names to their configuration commands
device_configs_map = self._configs_map(device_configs_list)
# Prepare device hosts data
try:
hosts_data = self._prepare_device_hosts_data(
device_configs_list, project_id, jwt_token=jwt_token, url=url
)
hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url)
except ValueError as e:
logger.error("Failed to prepare device hosts data: %s", e)
return [{"status": "failed", "error": str(e)}]
# Check if any devices have errors (e.g., missing device_type tag)
error_devices = {
name: data
for name, data in hosts_data.items()
if "error" in data
}
error_devices = {name: data for name, data in hosts_data.items() if "error" in data}
if error_devices:
logger.error(
"Devices with configuration errors: %s",
list(error_devices.keys())
)
logger.error("Devices with configuration errors: %s", list(error_devices.keys()))
return [
{
"device_name": name,
"status": "failed",
"error": data["error"]
}
{"device_name": name, "status": "failed", "error": data["error"]}
for name, data in error_devices.items()
]
@ -275,9 +254,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
except Exception as e:
# Overall execution failed
logger.error(
"Error executing configurations on all devices: %s", e
)
logger.error("Error executing configurations on all devices: %s", e)
return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
logger.info(
@ -287,22 +264,16 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return results
def _run_all_device_configs_with_single_retry(
self, task: Task, device_configs_map: dict[str, list[str]]
) -> Result:
def _run_all_device_configs_with_single_retry(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result:
"""Execute configuration commands with single retry mechanism."""
device_name = task.host.name
config_commands = device_configs_map.get(device_name, [])
if not config_commands:
return Result(
host=task.host, result="No configuration commands to execute"
)
return Result(host=task.host, result="No configuration commands to execute")
try:
_result = task.run(
task=netmiko_send_config, config_commands=config_commands
)
_result = task.run(task=netmiko_send_config, config_commands=config_commands)
return Result(host=task.host, result=_result.result)
except ReadTimeout as e:
@ -322,9 +293,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Handle Cisco IOSv L2 where '#' prompt char may be delayed,
# causing Netmiko failures. Implements retry logic.
if "netmiko_send_config (failed)" in str(e):
_result = task.run(
task=netmiko_send_config, config_commands=config_commands
)
_result = task.run(task=netmiko_send_config, config_commands=config_commands)
return Result(host=task.host, result=_result.result)
# Log any other exceptions with full details
@ -364,9 +333,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
parsed_input = json.loads(tool_input)
logger.debug("Successfully parsed tool input from JSON string.")
except json.JSONDecodeError as e:
logger.error(
"Invalid JSON string received as tool input: %s", e
)
logger.error("Invalid JSON string received as tool input: %s", e)
return (
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
None,
@ -392,10 +359,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return ([{"status": "failed", "error": error_msg}], None)
if not self._validate_project_id(project_id):
error_msg = (
f"Invalid project_id format: {project_id}. "
"Expected UUID format."
)
error_msg = f"Invalid project_id format: {project_id}. Expected UUID format."
logger.error(error_msg)
return ([{"status": "failed", "error": error_msg}], None)
@ -413,10 +377,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Handle legacy format: [...]
elif isinstance(parsed_input, list):
logger.warning(
"Using legacy format without project_id. "
"Please use new format with project_id."
)
logger.warning("Using legacy format without project_id. Please use new format with project_id.")
return parsed_input, None
else:
@ -438,9 +399,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
Returns:
True if valid UUID format, False otherwise
"""
uuid_pattern = (
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
def _filter_forbidden_commands_from_device_configs(
@ -465,9 +424,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
commands = device_config["config_commands"]
# Filter commands
allowed_commands, blocked_info = filter_forbidden_commands(
commands
)
allowed_commands, blocked_info = filter_forbidden_commands(commands)
# Update device config with allowed commands only
filtered_config = device_config.copy()
@ -486,9 +443,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return filtered_list, blocked_commands_map
def _expand_multiline_commands(
self, device_configs_list: list[dict[str, Any]]
) -> list[dict[str, Any]]:
def _expand_multiline_commands(self, device_configs_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Expand commands that contain embedded newline characters.
@ -540,9 +495,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return expanded_list
def _configs_map(
self, device_config_list: list[dict[str, Any]]
) -> dict[str, list[str]]:
def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]:
"""Create a mapping of device names to their configuration commands."""
device_configs_map = {}
for device_config in device_config_list:
@ -561,15 +514,10 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
) -> dict[str, dict[str, Any]]:
"""Prepare device hosts data from topology information."""
# Extract device names list
device_names = [
device_config["device_name"]
for device_config in device_config_list
]
device_names = [device_config["device_name"] for device_config in device_config_list]
# Get device port information with project_id
hosts_data = get_device_ports_from_topology(
device_names, project_id, jwt_token=jwt_token, url=url
)
hosts_data = get_device_ports_from_topology(device_names, project_id, jwt_token=jwt_token, url=url)
if not hosts_data:
error_msg = (
@ -589,9 +537,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return hosts_data
def _initialize_nornir(
self, hosts_data: dict[str, dict[str, Any]]
) -> Nornir:
def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> Nornir:
"""
Initialize Nornir with the provided hosts data.
@ -616,8 +562,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Log device types being configured
device_types = [
host["connection_options"]["netmiko"]["extras"]["device_type"]
for host in hosts_data.values()
host["connection_options"]["netmiko"]["extras"]["device_type"] for host in hosts_data.values()
]
logger.info(
"Initializing Nornir: host=%s, device_types=%s, hosts=%d",
@ -665,16 +610,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
device_result = {
"device_name": device_name,
"status": "failed",
"error": (
f"Device '{device_name}' not found in topology or "
"missing console_port"
),
"error": (f"Device '{device_name}' not found in topology or missing console_port"),
}
# Add blocked commands info if any
if blocked_commands_info:
device_result["blocked_commands"] = list(
blocked_commands_info.keys()
)
device_result["blocked_commands"] = list(blocked_commands_info.keys())
device_result["blocked_info"] = blocked_commands_info
results.append(device_result)
continue
@ -684,15 +624,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
device_result = {
"device_name": device_name,
"status": "failed",
"error": (
f"Device '{device_name}' not found in task results"
),
"error": (f"Device '{device_name}' not found in task results"),
}
# Add blocked commands info if any
if blocked_commands_info:
device_result["blocked_commands"] = list(
blocked_commands_info.keys()
)
device_result["blocked_commands"] = list(blocked_commands_info.keys())
device_result["blocked_info"] = blocked_commands_info
results.append(device_result)
continue
@ -704,9 +640,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
if multi_result[0].failed:
# Execution failed
device_result["status"] = "failed"
device_result["error"] = (
f"Configuration execution failed: {multi_result[0].result}"
)
device_result["error"] = f"Configuration execution failed: {multi_result[0].result}"
device_result["output"] = multi_result[0].result
else:
# Execution successful
@ -716,9 +650,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
# Add blocked commands info if any
if blocked_commands_info:
device_result["blocked_commands"] = list(
blocked_commands_info.keys()
)
device_result["blocked_commands"] = list(blocked_commands_info.keys())
device_result["blocked_info"] = blocked_commands_info
# Update status if some commands were blocked but succeeded
if device_result["status"] == "success":

View File

@ -199,18 +199,12 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input)
if (
isinstance(device_configs_list, list)
and len(device_configs_list) > 0
and "error" in device_configs_list[0]
):
if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
return device_configs_list
# Filter forbidden commands and store blocked commands info
device_configs_list, blocked_commands_map = (
self._filter_forbidden_commands_from_device_configs(
device_configs_list
)
device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs(
device_configs_list
)
# Create a mapping of device names to their display commands
@ -218,30 +212,17 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Prepare device hosts data
try:
hosts_data = self._prepare_device_hosts_data(
device_configs_list, project_id, jwt_token=jwt_token, url=url
)
hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url)
except ValueError as e:
logger.error("Failed to prepare device hosts data: %s", e)
return [{"status": "failed", "error": str(e)}]
# Check if any devices have errors (e.g., missing device_type tag)
error_devices = {
name: data
for name, data in hosts_data.items()
if "error" in data
}
error_devices = {name: data for name, data in hosts_data.items() if "error" in data}
if error_devices:
logger.error(
"Devices with configuration errors: %s",
list(error_devices.keys())
)
logger.error("Devices with configuration errors: %s", list(error_devices.keys()))
return [
{
"device_name": name,
"status": "failed",
"error": data["error"]
}
{"device_name": name, "status": "failed", "error": data["error"]}
for name, data in error_devices.items()
]
@ -281,17 +262,13 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return results
def _run_all_device_configs_with_single_retry(
self, task: Task, device_configs_map: dict[str, list[str]]
) -> Result:
def _run_all_device_configs_with_single_retry(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result:
"""Execute READ-ONLY diagnostic commands with single retry."""
device_name = task.host.name
diagnostic_commands = device_configs_map.get(device_name, [])
if not diagnostic_commands:
return Result(
host=task.host, result="No diagnostic commands to execute"
)
return Result(host=task.host, result="No diagnostic commands to execute")
try:
_result = task.run(
@ -366,9 +343,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
parsed_input = json.loads(tool_input)
logger.info("Successfully parsed tool input from JSON string.")
except json.JSONDecodeError as e:
logger.error(
"Invalid JSON string received as tool input: %s", e
)
logger.error("Invalid JSON string received as tool input: %s", e)
return (
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
None,
@ -412,9 +387,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Handle legacy format: [...]
elif isinstance(parsed_input, list):
logger.warning(
"Legacy input format without project_id. Use new format."
)
logger.warning("Legacy input format without project_id. Use new format.")
return parsed_input, None
else:
@ -435,9 +408,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
Returns:
True if valid UUID format, False otherwise
"""
uuid_pattern = (
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
def _filter_forbidden_commands_from_device_configs(
@ -462,9 +433,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
commands = device_config["commands"]
# Filter commands
allowed_commands, blocked_info = filter_forbidden_commands(
commands
)
allowed_commands, blocked_info = filter_forbidden_commands(commands)
# Update device config with allowed commands only
filtered_config = device_config.copy()
@ -483,9 +452,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return filtered_list, blocked_commands_map
def _configs_map(
self, device_config_list: list[dict[str, Any]]
) -> dict[str, list[str]]:
def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]:
"""Create a mapping of device names to their diagnostic commands."""
device_diagnostic_map = {}
for device_config in device_config_list:
@ -504,21 +471,13 @@ class ExecuteMultipleDeviceCommands(BaseTool):
) -> dict[str, dict[str, Any]]:
"""Prepare device hosts data from topology information."""
# Extract device names list
device_names = [
device_config["device_name"]
for device_config in device_config_list
]
device_names = [device_config["device_name"] for device_config in device_config_list]
# Get device port information with project_id
hosts_data = get_device_ports_from_topology(
device_names, project_id, jwt_token=jwt_token, url=url
)
hosts_data = get_device_ports_from_topology(device_names, project_id, jwt_token=jwt_token, url=url)
if not hosts_data:
error_msg = (
f"Failed to get device info from topology. "
f"Project: {project_id}, Devices: {device_names}"
)
error_msg = f"Failed to get device info from topology. Project: {project_id}, Devices: {device_names}"
raise ValueError(error_msg)
# Check for missing devices
@ -532,9 +491,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return hosts_data
def _initialize_nornir(
self, hosts_data: dict[str, dict[str, Any]]
) -> Nornir:
def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> Nornir:
"""
Initialize Nornir with the provided hosts data.
@ -559,8 +516,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Log device types being configured
device_types = [
host["connection_options"]["netmiko"]["extras"]["device_type"]
for host in hosts_data.values()
host["connection_options"]["netmiko"]["extras"]["device_type"] for host in hosts_data.values()
]
logger.info(
"Initializing Nornir: host=%s, device_types=%s, hosts=%d",
@ -608,16 +564,11 @@ class ExecuteMultipleDeviceCommands(BaseTool):
device_result = {
"device_name": device_name,
"status": "failed",
"error": (
f"Device '{device_name}' not found in topology "
"or missing console_port"
),
"error": (f"Device '{device_name}' not found in topology or missing console_port"),
}
# Add blocked commands info if any
if blocked_commands_info:
device_result["blocked_commands"] = list(
blocked_commands_info.keys()
)
device_result["blocked_commands"] = list(blocked_commands_info.keys())
device_result["blocked_info"] = blocked_commands_info
results.append(device_result)
continue
@ -627,15 +578,11 @@ class ExecuteMultipleDeviceCommands(BaseTool):
device_result = {
"device_name": device_name,
"status": "failed",
"error": (
f"Device '{device_name}' not found in task results"
),
"error": (f"Device '{device_name}' not found in task results"),
}
# Add blocked commands info if any
if blocked_commands_info:
device_result["blocked_commands"] = list(
blocked_commands_info.keys()
)
device_result["blocked_commands"] = list(blocked_commands_info.keys())
device_result["blocked_info"] = blocked_commands_info
results.append(device_result)
continue
@ -647,9 +594,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
if multi_result[0].failed:
# Execution failed
device_result["status"] = "failed"
device_result["error"] = (
f"Diagnostic command failed: {multi_result[0].result}"
)
device_result["error"] = f"Diagnostic command failed: {multi_result[0].result}"
device_result["output"] = multi_result[0].result
else:
# Execution successful
@ -659,9 +604,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
# Add blocked commands info if any
if blocked_commands_info:
device_result["blocked_commands"] = list(
blocked_commands_info.keys()
)
device_result["blocked_commands"] = list(blocked_commands_info.keys())
device_result["blocked_info"] = blocked_commands_info
# Update status if some commands were blocked but succeeded
if device_result["status"] == "success":

View File

@ -132,9 +132,7 @@ class GNS3LinkTool(BaseTool):
if not isinstance(links_data, list) or len(links_data) == 0:
logger.error("Invalid links data: must be a non-empty array")
return [
{"error": "Invalid links data: must be a non-empty array"}
]
return [{"error": "Invalid links data: must be a non-empty array"}]
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
@ -142,14 +140,7 @@ class GNS3LinkTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return [
{
"error": (
"Failed to connect to GNS3 server. "
"Please check your configuration."
)
}
]
return [{"error": ("Failed to connect to GNS3 server. Please check your configuration.")}]
# Fetch all nodes once for port resolution
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -172,9 +163,7 @@ class GNS3LinkTool(BaseTool):
# Validate link parameters
if not all([node_id1, port1, node_id2, port2]):
error_msg = (
f"Missing required fields in link definition {i}"
)
error_msg = f"Missing required fields in link definition {i}"
logger.error(error_msg)
created_links.append({"error": error_msg})
continue
@ -218,29 +207,15 @@ class GNS3LinkTool(BaseTool):
"nodes": [
{
"node_id": node_id1,
"adapter_number": port1_info.get(
"adapter_number", 0
),
"port_number": port1_info.get(
"port_number", 0
),
"label": {
"text": port1_info.get("short_name")
or port1
},
"adapter_number": port1_info.get("adapter_number", 0),
"port_number": port1_info.get("port_number", 0),
"label": {"text": port1_info.get("short_name") or port1},
},
{
"node_id": node_id2,
"adapter_number": port2_info.get(
"adapter_number", 0
),
"port_number": port2_info.get(
"port_number", 0
),
"label": {
"text": port2_info.get("short_name")
or port2
},
"adapter_number": port2_info.get("adapter_number", 0),
"port_number": port2_info.get("port_number", 0),
"label": {"text": port2_info.get("short_name") or port2},
},
],
"fields": ["link_id"],
@ -266,9 +241,7 @@ class GNS3LinkTool(BaseTool):
created_links.append({"error": error_msg})
# Log final results
success_count = len(
[link for link in created_links if "error" not in link]
)
success_count = len([link for link in created_links if "error" not in link])
logger.info(
"Link creation completed: %d successful, %d failed",
success_count,

View File

@ -169,9 +169,7 @@ class GNS3CreateNodeTool(BaseTool):
# Validate each node in the array
for i, node_data in enumerate(nodes):
if not isinstance(node_data, dict):
logger.error(
"Invalid input: Node %d must be a dictionary.", i + 1
)
logger.error("Invalid input: Node %d must be a dictionary.", i + 1)
return {"error": f"Node {i + 1} must be a dictionary."}
template_id = node_data.get("template_id")
@ -187,14 +185,10 @@ class GNS3CreateNodeTool(BaseTool):
]
):
logger.error(
"Invalid input: Node %d missing or invalid "
"template_id, x, or y.",
"Invalid input: Node %d missing or invalid template_id, x, or y.",
i + 1,
)
return {
"error": f"Node {i + 1} missing or invalid "
f"template_id, x, or y."
}
return {"error": f"Node {i + 1} missing or invalid template_id, x, or y."}
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
@ -202,15 +196,10 @@ class GNS3CreateNodeTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
# Create nodes
logger.info(
"Creating %d nodes in project %s...", len(nodes), project_id
)
logger.info("Creating %d nodes in project %s...", len(nodes), project_id)
results: list[dict[str, Any]] = []
for i, node_data in enumerate(nodes):
@ -261,12 +250,8 @@ class GNS3CreateNodeTool(BaseTool):
# Continue with next node even if one fails
# Calculate summary statistics
successful_nodes = len(
[r for r in results if r.get("status") == "success"]
)
failed_nodes = len(
[r for r in results if r.get("status") == "failed"]
)
successful_nodes = len([r for r in results if r.get("status") == "success"])
failed_nodes = len([r for r in results if r.get("status") == "failed"])
# Prepare final result
final_result = {
@ -293,9 +278,7 @@ class GNS3CreateNodeTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"}
except Exception as e:
logger.error("Failed to process node creation request: %s", e)
return {
"error": f"Failed to process node creation request: {str(e)}"
}
return {"error": f"Failed to process node creation request: {str(e)}"}
if __name__ == "__main__":

View File

@ -46,12 +46,12 @@ logger = logging.getLogger(__name__)
# Built-in templates to filter out (utility templates, not actual network devices)
FILTERED_TEMPLATES = {
"atm_switch", # ATM switch
"cloud", # Cloud
"ethernet_hub", # Ethernet hub
"ethernet_switch", # Ethernet switch (built-in)
"atm_switch", # ATM switch
"cloud", # Cloud
"ethernet_hub", # Ethernet hub
"ethernet_switch", # Ethernet switch (built-in)
"frame_relay_switch", # Frame Relay switch
"nat", # NAT device
"nat", # NAT device
}
@ -131,17 +131,10 @@ class GNS3TemplateTool(BaseTool):
if gns3_server is None:
logger.error("Failed to create GNS3 connector")
return {
"error": (
"Failed to connect to GNS3 server. "
"Please check your configuration."
)
}
return {"error": ("Failed to connect to GNS3 server. Please check your configuration.")}
# Retrieve all available templates
templates = gns3_server.http_call(
"get", f"{gns3_server.base_url}/templates"
).json()
templates = gns3_server.http_call("get", f"{gns3_server.base_url}/templates").json()
# Filter out utility templates and extract relevant info
template_info = []
@ -154,11 +147,13 @@ class GNS3TemplateTool(BaseTool):
continue
# Extract name, template_id, and template_type
template_info.append({
"name": template.get("name", "N/A"),
"template_id": template.get("template_id", "N/A"),
"template_type": template.get("template_type", "N/A"),
})
template_info.append(
{
"name": template.get("name", "N/A"),
"template_id": template.get("template_id", "N/A"),
"template_type": template.get("template_type", "N/A"),
}
)
# Return JSON-formatted result with full logging
result = {"templates": template_info}
@ -172,9 +167,7 @@ class GNS3TemplateTool(BaseTool):
return result
except Exception as e:
logger.error(
"Failed to connect to GNS3 server or retrieve templates: %s", e
)
logger.error("Failed to connect to GNS3 server or retrieve templates: %s", e)
return {"error": f"Failed to retrieve templates: {str(e)}"}

View File

@ -198,18 +198,14 @@ class GNS3PacketFilterTool(BaseTool):
valid_actions = ["get_available", "set", "get", "clear"]
if action not in valid_actions:
logger.error("Invalid action: %s. Must be one of %s", action, valid_actions)
return {
"error": f"Invalid action: {action}. Must be one of {valid_actions}"
}
return {"error": f"Invalid action: {action}. Must be one of {valid_actions}"}
# Validate filters for "set" action
if action == "set":
filters = input_data.get("filters")
if not filters or not isinstance(filters, dict):
logger.error("Invalid input: 'set' action requires 'filters' dict.")
return {
"error": "'set' action requires 'filters' dict with filter configuration."
}
return {"error": "'set' action requires 'filters' dict with filter configuration."}
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
@ -217,28 +213,19 @@ class GNS3PacketFilterTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
# Execute action
logger.info(
"Processing packet filter action '%s' for link %s...", action, link_id
)
logger.info("Processing packet filter action '%s' for link %s...", action, link_id)
if action == "get_available":
result = self._get_available_filters(gns3_ctx, project_id, link_id)
elif action == "set":
filters = input_data.get("filters", {})
result = self._set_filters(
gns3_ctx, project_id, link_id, filters, show_filters_icon
)
result = self._set_filters(gns3_ctx, project_id, link_id, filters, show_filters_icon)
elif action == "get":
result = self._get_filters(gns3_ctx, project_id, link_id)
elif action == "clear":
result = self._clear_filters(
gns3_ctx, project_id, link_id, show_filters_icon
)
result = self._clear_filters(gns3_ctx, project_id, link_id, show_filters_icon)
else:
result = {"error": f"Unknown action: {action}"}
@ -252,18 +239,12 @@ class GNS3PacketFilterTool(BaseTool):
return {"error": f"Invalid JSON input: {e}"}
except Exception as e:
logger.error("Failed to process packet filter request: %s", e)
return {
"error": f"Failed to process packet filter request: {str(e)}"
}
return {"error": f"Failed to process packet filter request: {str(e)}"}
def _get_available_filters(
self, gns3_ctx: dict, project_id: str, link_id: str
) -> dict[str, Any]:
def _get_available_filters(self, gns3_ctx: dict, project_id: str, link_id: str) -> dict[str, Any]:
"""Get available filter types for the link."""
try:
filters = available_filters_handler(
{"project_id": project_id, "link_id": link_id}, gns3_ctx
)
filters = available_filters_handler({"project_id": project_id, "link_id": link_id}, gns3_ctx)
if "error" in filters:
raise RuntimeError(filters["error"])
logger.info("Retrieved %d available filter types.", len(filters))
@ -307,13 +288,9 @@ class GNS3PacketFilterTool(BaseTool):
if "Invalid" in result.stdout or "Invalid" in result.stderr:
error_lines = []
if "Invalid" in result.stderr:
error_lines.extend(
line for line in result.stderr.split("\n") if "Invalid" in line
)
error_lines.extend(line for line in result.stderr.split("\n") if "Invalid" in line)
if "Invalid" in result.stdout:
error_lines.extend(
line for line in result.stdout.split("\n") if "Invalid" in line
)
error_lines.extend(line for line in result.stdout.split("\n") if "Invalid" in line)
# Strip interface suffix (e.g., "for interface 'lo'") for cleaner error
error_msg_parts = []
@ -336,10 +313,7 @@ class GNS3PacketFilterTool(BaseTool):
except FileNotFoundError:
# tshark not installed - skip validation
logger.warning(
"tshark not found, skipping BPF syntax validation. "
"Install tshark to enable BPF validation."
)
logger.warning("tshark not found, skipping BPF syntax validation. Install tshark to enable BPF validation.")
return {"valid": True, "error": None}
except Exception as e:
@ -414,14 +388,10 @@ class GNS3PacketFilterTool(BaseTool):
"status": "failed",
}
def _get_filters(
self, gns3_ctx: dict, project_id: str, link_id: str
) -> dict[str, Any]:
def _get_filters(self, gns3_ctx: dict, project_id: str, link_id: str) -> dict[str, Any]:
"""Get current filters configured on the link."""
try:
link = get_link_handler(
{"project_id": project_id, "link_id": link_id}, gns3_ctx
)
link = get_link_handler({"project_id": project_id, "link_id": link_id}, gns3_ctx)
if "error" in link:
raise RuntimeError(link["error"])

View File

@ -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(
@ -378,13 +106,8 @@ class GNS3StartNodeQuickTool(BaseTool):
# 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."
}
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.")
@ -396,10 +119,7 @@ class GNS3StartNodeQuickTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
# Verify nodes exist and capture pre-start info (one call)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -415,14 +135,8 @@ class GNS3StartNodeQuickTool(BaseTool):
)
results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
start_results = start_node_handler(
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx
)
start_errors = {
r["node_id"]: r.get("error")
for r in start_results
if r.get("status") == "error"
}
start_results = start_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
start_errors = {r["node_id"]: r.get("error") for r in start_results if r.get("status") == "error"}
# Get immediate status (likely 'starting' or 'stopped') — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -432,9 +146,7 @@ class GNS3StartNodeQuickTool(BaseTool):
for node_id in node_ids:
if node_id not in nodes_by_id:
logger.error(
"Node %s not found in project %s", node_id, project_id
)
logger.error("Node %s not found in project %s", node_id, project_id)
results.append(
{
"node_id": node_id,
@ -476,9 +188,7 @@ class GNS3StartNodeQuickTool(BaseTool):
)
# Analyze results (count based on successful command sending)
successful_nodes = [
r for r in results if r.get("status") != "error"
]
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
@ -489,8 +199,7 @@ class GNS3StartNodeQuickTool(BaseTool):
"failed": len(failed_nodes),
"nodes": results,
"note": (
"Start commands sent. Nodes are booting in background. "
"Check node status later."
"Start commands sent. Nodes are booting in background. Use wait_seconds, then check node status."
),
}
@ -510,33 +219,20 @@ 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 ===")
test_input_single = json.dumps(
{
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
"node_ids": [
"fbeda109-9a74-4d8c-a749-cc3847911a90"
], # Replace with actual node UUID
"node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID
}
)
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": "<PROJECT_UUID>", # 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)

View File

@ -94,13 +94,8 @@ class GNS3StopNodeTool(BaseTool):
# 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."
}
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.")
@ -112,10 +107,7 @@ class GNS3StopNodeTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
# Verify nodes exist and capture names (one call)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -131,14 +123,8 @@ class GNS3StopNodeTool(BaseTool):
)
results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
stop_results = stop_node_handler(
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx
)
stop_errors = {
r["node_id"]: r.get("error")
for r in stop_results
if r.get("status") == "error"
}
stop_results = stop_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
stop_errors = {r["node_id"]: r.get("error") for r in stop_results if r.get("status") == "error"}
# Get updated status — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -148,9 +134,7 @@ class GNS3StopNodeTool(BaseTool):
for node_id in node_ids:
if node_id not in nodes_by_id:
logger.error(
"Node %s not found in project %s", node_id, project_id
)
logger.error("Node %s not found in project %s", node_id, project_id)
results.append(
{
"node_id": node_id,
@ -160,9 +144,7 @@ class GNS3StopNodeTool(BaseTool):
}
)
elif node_id in stop_errors:
logger.error(
"Failed to stop node %s: %s", node_id, stop_errors[node_id]
)
logger.error("Failed to stop node %s: %s", node_id, stop_errors[node_id])
results.append(
{
"node_id": node_id,
@ -187,9 +169,7 @@ class GNS3StopNodeTool(BaseTool):
)
# Analyze results
successful_nodes = [
r for r in results if r.get("status") != "error"
]
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
@ -223,9 +203,7 @@ if __name__ == "__main__":
test_input_single = json.dumps(
{
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
"node_ids": [
"fbeda109-9a74-4d8c-a749-cc3847911a90"
], # Replace with actual node UUID
"node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID
}
)
tool = GNS3StopNodeTool()

View File

@ -100,13 +100,8 @@ class GNS3SuspendNodeTool(BaseTool):
# 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."
}
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.")
@ -118,10 +113,7 @@ class GNS3SuspendNodeTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
# Verify nodes exist and capture names (one call)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -137,14 +129,8 @@ class GNS3SuspendNodeTool(BaseTool):
)
results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
suspend_results = suspend_node_handler(
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx
)
suspend_errors = {
r["node_id"]: r.get("error")
for r in suspend_results
if r.get("status") == "error"
}
suspend_results = suspend_node_handler({"project_id": project_id, "node_ids": known_ids}, gns3_ctx)
suspend_errors = {r["node_id"]: r.get("error") for r in suspend_results if r.get("status") == "error"}
# Get updated status — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -154,9 +140,7 @@ class GNS3SuspendNodeTool(BaseTool):
for node_id in node_ids:
if node_id not in nodes_by_id:
logger.error(
"Node %s not found in project %s", node_id, project_id
)
logger.error("Node %s not found in project %s", node_id, project_id)
results.append(
{
"node_id": node_id,
@ -195,9 +179,7 @@ class GNS3SuspendNodeTool(BaseTool):
)
# Analyze results
successful_nodes = [
r for r in results if r.get("status") != "error"
]
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
@ -207,10 +189,7 @@ class GNS3SuspendNodeTool(BaseTool):
"successful": len(successful_nodes),
"failed": len(failed_nodes),
"nodes": results,
"note": (
"Suspended nodes preserve their state in memory. "
"Use resume to continue where you left off."
),
"note": ("Suspended nodes preserve their state in memory. Use resume to continue where you left off."),
}
logger.info(
@ -235,9 +214,7 @@ if __name__ == "__main__":
test_input_single = json.dumps(
{
"project_id": "<PROJECT_UUID>", # Replace with actual project UUID
"node_ids": [
"fbeda109-9a74-4d8c-a749-cc3847911a90"
], # Replace with actual node UUID
"node_ids": ["fbeda109-9a74-4d8c-a749-cc3847911a90"], # Replace with actual node UUID
}
)
tool = GNS3SuspendNodeTool()

View File

@ -125,9 +125,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
return {"error": f"Node {i + 1} must be a dictionary."}
if "node_id" not in node_data or "new_name" not in node_data:
logger.error("Node %d missing node_id or new_name.", i + 1)
return {
"error": f"Node {i + 1} missing node_id or new_name."
}
return {"error": f"Node {i + 1} missing node_id or new_name."}
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
@ -135,10 +133,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
# Fetch current node names in one call (old names + existence)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
@ -208,9 +203,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
"error": "Name verification failed",
}
results.append(error_info)
logger.error(
"Failed to update node name for %s", node_id
)
logger.error("Failed to update node name for %s", node_id)
except Exception as e:
error_info = {
@ -223,9 +216,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
logger.error("Failed to update node %d: %s", i + 1, e)
# Analyze results
successful_nodes = [
r for r in results if r.get("status") == "success"
]
successful_nodes = [r for r in results if r.get("status") == "success"]
failed_nodes = [r for r in results if r.get("status") == "failed"]
# Construct final response

View File

@ -0,0 +1,107 @@
# 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 <https://www.gnu.org/licenses/>.
#
# 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": <seconds>}.
"""
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)}"}

View File

@ -123,19 +123,25 @@ class PacketAnalysisTool(BaseTool):
query = query.strip()
if len(query.split()) > 1:
return json.dumps({
"error": "Only one keyword allowed",
"hint": 'Use a single keyword like "ospf.lsa" or "bgp", not multiple words',
})
return json.dumps(
{
"error": "Only one keyword allowed",
"hint": 'Use a single keyword like "ospf.lsa" or "bgp", not multiple words',
}
)
tshark = subprocess.Popen(
["tshark", "-G", "fields"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
grep = subprocess.Popen(
["grep", "-iF", query, "-"],
stdin=tshark.stdout, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True,
stdin=tshark.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
tshark.stdout.close()
stdout, _ = grep.communicate(timeout=30)
@ -151,21 +157,27 @@ class PacketAnalysisTool(BaseTool):
name = parts[2]
if name not in seen:
seen.add(name)
results.append({
"field": name,
"type": parts[3] if len(parts) > 3 else "",
"protocol": parts[4] if len(parts) > 4 else "",
"description": parts[7] if len(parts) > 7 else "",
})
results.append(
{
"field": name,
"type": parts[3] if len(parts) > 3 else "",
"protocol": parts[4] if len(parts) > 4 else "",
"description": parts[7] if len(parts) > 7 else "",
}
)
if len(results) >= 40:
break
return json.dumps({
"query": query,
"count": len(results),
"fields": results,
"hint": "Use the 'field' values in tshark_args with -e",
}, ensure_ascii=False, indent=2)
return json.dumps(
{
"query": query,
"count": len(results),
"fields": results,
"hint": "Use the 'field' values in tshark_args with -e",
},
ensure_ascii=False,
indent=2,
)
@classmethod
def _load_valid_tshark_fields(cls) -> set:
@ -235,11 +247,13 @@ class PacketAnalysisTool(BaseTool):
i += 1
if invalid_fields:
return json.dumps({
"error": f"Invalid tshark field names: {', '.join(invalid_fields)}",
"hint": 'Use {"action": "search_fields", "query": "<protocol>"} to look up valid field names',
"invalid_fields": invalid_fields,
})
return json.dumps(
{
"error": f"Invalid tshark field names: {', '.join(invalid_fields)}",
"hint": 'Use {"action": "search_fields", "query": "<protocol>"} to look up valid field names',
"invalid_fields": invalid_fields,
}
)
return None
@ -271,8 +285,7 @@ class PacketAnalysisTool(BaseTool):
return self._search_fields(query or "")
logger.info(
f"PacketAnalysisTool invoked: project_id={project_id}, "
f"link_id={link_id}, tshark_args={tshark_args}"
f"PacketAnalysisTool invoked: project_id={project_id}, link_id={link_id}, tshark_args={tshark_args}"
)
# Validate inputs
@ -429,6 +442,7 @@ class PacketAnalysisTool(BaseTool):
"""
# Build command: tshark -r <file> <user_args>
import shlex
cmd = ["tshark", "-r", pcap_file] + shlex.split(tshark_args)
logger.info(f"Running tshark: {' '.join(cmd)}")
@ -451,10 +465,7 @@ class PacketAnalysisTool(BaseTool):
f"tshark reported: {stderr_clean}",
]
if "is not a valid protocol" in stderr.lower() or "aren't valid" in stderr.lower():
hints.append(
'Use {"action": "search_fields", "query": "<protocol>"} '
"to find correct field names."
)
hints.append('Use {"action": "search_fields", "query": "<protocol>"} to find correct field names.')
logger.warning(f"tshark stderr: {stderr}")
return json.dumps({"error": "tshark argument error", "hints": hints})

View File

@ -179,11 +179,7 @@ class VPCSCommands(BaseTool):
# Validate input
device_configs_list, project_id = self._validate_tool_input(tool_input)
if (
isinstance(device_configs_list, list)
and len(device_configs_list) > 0
and "error" in device_configs_list[0]
):
if isinstance(device_configs_list, list) and len(device_configs_list) > 0 and "error" in device_configs_list[0]:
return device_configs_list
# Create a mapping of device names to their commands
@ -191,30 +187,17 @@ class VPCSCommands(BaseTool):
# Prepare device hosts data
try:
hosts_data = self._prepare_device_hosts_data(
device_configs_list, project_id, jwt_token=jwt_token, url=url
)
hosts_data = self._prepare_device_hosts_data(device_configs_list, project_id, jwt_token=jwt_token, url=url)
except ValueError as e:
logger.error("Failed to prepare device hosts data: %s", e)
return [{"status": "failed", "error": str(e)}]
# Check if any devices have errors (e.g., missing device)
error_devices = {
name: data
for name, data in hosts_data.items()
if "error" in data
}
error_devices = {name: data for name, data in hosts_data.items() if "error" in data}
if error_devices:
logger.error(
"Devices with configuration errors: %s",
list(error_devices.keys())
)
logger.error("Devices with configuration errors: %s", list(error_devices.keys()))
return [
{
"device_name": name,
"status": "failed",
"error": data["error"]
}
{"device_name": name, "status": "failed", "error": data["error"]}
for name, data in error_devices.items()
]
@ -253,17 +236,13 @@ class VPCSCommands(BaseTool):
return results
def _run_vpcs_commands(
self, task: Task, device_configs_map: dict[str, list[str]]
) -> Result:
def _run_vpcs_commands(self, task: Task, device_configs_map: dict[str, list[str]]) -> Result:
"""Execute VPCS commands with single retry."""
device_name = task.host.name
commands = device_configs_map.get(device_name, [])
if not commands:
return Result(
host=task.host, result="No commands to execute"
)
return Result(host=task.host, result="No commands to execute")
try:
# Use netmiko_multiline for VPCS commands
@ -334,9 +313,7 @@ class VPCSCommands(BaseTool):
parsed_input = json.loads(tool_input)
logger.info("Successfully parsed tool input from JSON string.")
except json.JSONDecodeError as e:
logger.error(
"Invalid JSON string received as tool input: %s", e
)
logger.error("Invalid JSON string received as tool input: %s", e)
return (
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
None,
@ -377,10 +354,7 @@ class VPCSCommands(BaseTool):
return device_configs, project_id
else:
error_msg = (
"Tool input must be JSON with project_id and device_configs, "
f"got {type(parsed_input).__name__}"
)
error_msg = f"Tool input must be JSON with project_id and device_configs, got {type(parsed_input).__name__}"
logger.error(error_msg)
return ([{"status": "failed", "error": error_msg}], None)
@ -394,14 +368,10 @@ class VPCSCommands(BaseTool):
Returns:
True if valid UUID format, False otherwise
"""
uuid_pattern = (
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
return bool(re.match(uuid_pattern, project_id, re.IGNORECASE))
def _configs_map(
self, device_config_list: list[dict[str, Any]]
) -> dict[str, list[str]]:
def _configs_map(self, device_config_list: list[dict[str, Any]]) -> dict[str, list[str]]:
"""
Create a mapping of device names to their command lists.
@ -411,10 +381,7 @@ class VPCSCommands(BaseTool):
Returns:
Dictionary mapping device names to command lists
"""
return {
config["device_name"]: config["commands"]
for config in device_config_list
}
return {config["device_name"]: config["commands"] for config in device_config_list}
def _prepare_device_hosts_data(
self,
@ -445,18 +412,14 @@ class VPCSCommands(BaseTool):
device_names = [config["device_name"] for config in device_configs_list]
# Get device port mappings from topology
device_ports = get_device_ports_from_topology(
device_names, project_id=project_id, jwt_token=jwt_token, url=url
)
device_ports = get_device_ports_from_topology(device_names, project_id=project_id, jwt_token=jwt_token, url=url)
# Build Nornir inventory hosts data
hosts_data = {}
for device_name in device_names:
if device_name not in device_ports:
logger.error("Device '%s' not found in topology", device_name)
hosts_data[device_name] = {
"error": f"Device '{device_name}' not found in topology"
}
hosts_data[device_name] = {"error": f"Device '{device_name}' not found in topology"}
continue
port = device_ports[device_name]["port"]
@ -498,9 +461,7 @@ class VPCSCommands(BaseTool):
return hosts_data
def _initialize_nornir(
self, hosts_data: dict[str, dict[str, Any]]
) -> "Nornir":
def _initialize_nornir(self, hosts_data: dict[str, dict[str, Any]]) -> "Nornir":
"""
Initialize Nornir with VPCS device inventory.
@ -576,12 +537,14 @@ class VPCSCommands(BaseTool):
# Check if device had an error during preparation
if device_name in hosts_data and "error" in hosts_data[device_name]:
results.append({
"device_name": device_name,
"status": "failed",
"error": hosts_data[device_name]["error"],
"commands": device_config["commands"],
})
results.append(
{
"device_name": device_name,
"status": "failed",
"error": hosts_data[device_name]["error"],
"commands": device_config["commands"],
}
)
continue
# Get result from Nornir task
@ -591,28 +554,34 @@ class VPCSCommands(BaseTool):
if host_result.failed:
# Task failed
error_msg = str(host_result.result) if host_result.result else "Unknown error"
results.append({
"device_name": device_name,
"status": "failed",
"error": error_msg,
"commands": device_config["commands"],
})
results.append(
{
"device_name": device_name,
"status": "failed",
"error": error_msg,
"commands": device_config["commands"],
}
)
else:
# Task succeeded
results.append({
"device_name": device_name,
"status": "success",
"output": host_result.result,
"commands": device_config["commands"],
})
results.append(
{
"device_name": device_name,
"status": "success",
"output": host_result.result,
"commands": device_config["commands"],
}
)
else:
# Device not in task result (shouldn't happen)
results.append({
"device_name": device_name,
"status": "failed",
"error": f"Device '{device_name}' not in task results",
"commands": device_config["commands"],
})
results.append(
{
"device_name": device_name,
"status": "failed",
"error": f"Device '{device_name}' not in task results",
"commands": device_config["commands"],
}
)
return results

View File

@ -73,14 +73,14 @@ def _load_forbidden_commands() -> list[str]:
# Try to load from skills repository
manager = get_skills_manager()
if manager is not None:
commands = manager.load_forbidden_commands()
if commands:
logger.info(
"Loaded %d forbidden command patterns from skills repository",
len(commands),
)
_forbidden_commands_cache = commands
return _forbidden_commands_cache
commands = manager.load_forbidden_commands()
if commands:
logger.info(
"Loaded %d forbidden command patterns from skills repository",
len(commands),
)
_forbidden_commands_cache = commands
return _forbidden_commands_cache
# Fallback to defaults
logger.warning("Using default forbidden commands list")

View File

@ -69,11 +69,9 @@ The drivers are auto-registered when the tools are imported:
from gns3server.agent.gns3_copilot.tools_v2 import DisplayToolNornir
tool = DisplayToolNornir()
result = tool._run(json.dumps({
"device_names": ["huawei-sw1"],
"commands": ["display version"],
"project_id": "project-uuid"
}))
result = tool._run(
json.dumps({"device_names": ["huawei-sw1"], "commands": ["display version"], "project_id": "project-uuid"})
)
```
## Running Tests
@ -145,11 +143,14 @@ Create `tests/test_cisco.py`:
import unittest
from gns3server.agent.gns3_copilot.utils.custom_netmiko import cisco
class TestCustomCiscoDriver(unittest.TestCase):
def test_device_type_registered(self):
from netmiko.ssh_dispatcher import CLASS_MAPPER
self.assertIn("cisco_custom", CLASS_MAPPER)
if __name__ == "__main__":
unittest.main()
```

View File

@ -209,7 +209,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
# Ensure we're in a clean state
try:
if hasattr(self, 'base_prompt') and self.base_prompt:
if hasattr(self, "base_prompt") and self.base_prompt:
self._test_channel_read(pattern=self.base_prompt)
else:
# If base_prompt is not set yet, just read to clear buffer
@ -288,9 +288,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
# This method keeps reading until there is no new data for
# 'last_read' seconds. This is the proper Netmiko way to handle
# command output
output += self.read_channel_timing(
read_timeout=read_timeout, last_read=2.0
)
output += self.read_channel_timing(read_timeout=read_timeout, last_read=2.0)
# Exit config mode if requested
if exit_config_mode:
@ -318,9 +316,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
return output
def exit_config_mode(
self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$"
) -> str:
def exit_config_mode(self, exit_config: str = "return", pattern: str = r"<\S+>|>\s*$") -> str:
r"""
Exit configuration mode for Huawei devices.
@ -377,6 +373,7 @@ class GNS3HuaweiTelnetCE(HuaweiBase):
# Register the custom device type with Netmiko
_registered = False # Flag to prevent duplicate registration
def register_custom_device_type() -> None:
"""
Register the custom GNS3HuaweiTelnetCE device type with Netmiko.
@ -451,7 +448,4 @@ try:
except Exception as e:
# Log but don't fail on import
logger = logging.getLogger(__name__)
logger.warning(
f"Failed to register custom device type: {e}",
exc_info=True
)
logger.warning(f"Failed to register custom device type: {e}", exc_info=True)

View File

@ -60,12 +60,12 @@ class RuijieTelnetEnhanced(RuijieOSBase):
# Interactive command patterns that trigger [yes/no] prompts
# These are commands that commonly require confirmation
INTERACTIVE_PATTERNS = [
re.compile(r'^router-id\s+', re.IGNORECASE), # OSPF router-id
re.compile(r'^erase\s+', re.IGNORECASE), # erase startup-config
re.compile(r'^delete\s+', re.IGNORECASE), # delete files
re.compile(r'^format\s+', re.IGNORECASE), # format filesystem
re.compile(r'^reload\b', re.IGNORECASE), # reload/reboot
re.compile(r'^boot\s+system\s+', re.IGNORECASE), # change boot image
re.compile(r"^router-id\s+", re.IGNORECASE), # OSPF router-id
re.compile(r"^erase\s+", re.IGNORECASE), # erase startup-config
re.compile(r"^delete\s+", re.IGNORECASE), # delete files
re.compile(r"^format\s+", re.IGNORECASE), # format filesystem
re.compile(r"^reload\b", re.IGNORECASE), # reload/reboot
re.compile(r"^boot\s+system\s+", re.IGNORECASE), # change boot image
]
def __init__(
@ -80,9 +80,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
kwargs["default_enter"] = "\r\n"
super().__init__(*args, **kwargs)
def _preprocess_interactive_commands(
self, config_commands: list[str]
) -> list[str]:
def _preprocess_interactive_commands(self, config_commands: list[str]) -> list[str]:
"""
Preprocess commands to insert 'yes' after interactive commands.
@ -105,8 +103,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
for pattern in self.INTERACTIVE_PATTERNS:
if pattern.match(cmd.strip()):
logging.info(
"Ruijie device: Detected interactive command '%s', "
"inserting 'yes'",
"Ruijie device: Detected interactive command '%s', inserting 'yes'",
cmd.strip(),
)
# Insert 'yes' after this command
@ -153,25 +150,18 @@ class RuijieTelnetEnhanced(RuijieOSBase):
output += self.config_mode()
# Preprocess: Insert 'yes' after known interactive commands
processed_commands = self._preprocess_interactive_commands(
config_commands
)
processed_commands = self._preprocess_interactive_commands(config_commands)
# Try batch send first (fast path)
try:
output += self._send_config_batch(
processed_commands, read_timeout, delay_factor
)
output += self._send_config_batch(processed_commands, read_timeout, delay_factor)
except Exception as batch_error:
logging.warning(
"Ruijie device: Batch send failed, "
"falling back to one-by-one: %s",
"Ruijie device: Batch send failed, falling back to one-by-one: %s",
batch_error,
)
# Fallback to one-by-one send with real-time detection
output += self._send_config_one_by_one(
config_commands, read_timeout, delay_factor
)
output += self._send_config_one_by_one(config_commands, read_timeout, delay_factor)
# Exit config mode if requested
if exit_config_mode:
@ -179,9 +169,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
return output
def _send_config_batch(
self, commands: list[str], read_timeout: int, delay_factor: float
) -> str:
def _send_config_batch(self, commands: list[str], read_timeout: int, delay_factor: float) -> str:
"""
Send configuration commands in batch (fast).
@ -197,15 +185,11 @@ class RuijieTelnetEnhanced(RuijieOSBase):
# Read all output at once
# Use same default as Netmiko (2.0 seconds) to ensure complete output
output += self.read_channel_timing(
read_timeout=read_timeout, last_read=2.0
)
output += self.read_channel_timing(read_timeout=read_timeout, last_read=2.0)
return output
def _send_config_one_by_one(
self, commands: list[str], read_timeout: int, delay_factor: float
) -> str:
def _send_config_one_by_one(self, commands: list[str], read_timeout: int, delay_factor: float) -> str:
"""
Send commands one-by-one with real-time prompt detection.
@ -225,26 +209,21 @@ class RuijieTelnetEnhanced(RuijieOSBase):
time.sleep(0.05)
# Read output after this command
new_output = self.read_channel_timing(
read_timeout=10, last_read=0.5
)
new_output = self.read_channel_timing(read_timeout=10, last_read=0.5)
output += new_output
# Check if interactive prompt appeared after this command
for pattern in interactive_patterns:
if re.search(pattern, new_output, re.IGNORECASE):
logging.info(
"Ruijie device: Detected interactive prompt "
"after '%s', sending 'yes'",
"Ruijie device: Detected interactive prompt after '%s', sending 'yes'",
cmd,
)
# Send 'yes' to confirm
self.write_channel(f"yes{self.RETURN}")
time.sleep(0.3)
# Read the confirmation response
output += self.read_channel_timing(
read_timeout=30, last_read=0.5
)
output += self.read_channel_timing(read_timeout=30, last_read=0.5)
break
return output
@ -253,6 +232,7 @@ class RuijieTelnetEnhanced(RuijieOSBase):
# Register the custom device type with Netmiko
_registered = False # Flag to prevent duplicate registration
def register_custom_device_type() -> None:
"""
Register the custom RuijieTelnetEnhanced device type with Netmiko.
@ -295,7 +275,4 @@ try:
register_custom_device_type()
except Exception as e:
logger = logging.getLogger(__name__)
logger.warning(
f"Failed to register Ruijie device type: {e}",
exc_info=True
)
logger.warning(f"Failed to register Ruijie device type: {e}", exc_info=True)

View File

@ -39,6 +39,7 @@ def get_netmiko_version() -> str:
"""Get Netmiko library version."""
try:
import netmiko
return netmiko.__version__
except Exception:
return "Unknown"
@ -68,47 +69,39 @@ def register_custom_drivers() -> set:
try:
# Import and register Huawei CE driver
from gns3server.agent.gns3_copilot.utils.custom_netmiko \
import huawei_ce
from gns3server.agent.gns3_copilot.utils.custom_netmiko import huawei_ce
# Store custom device types before registration
custom_devices.add("gns3_huawei_telnet_ce")
# The driver auto-registers on import, but we can call it
# explicitly
if hasattr(huawei_ce, 'register_custom_device_type'):
if hasattr(huawei_ce, "register_custom_device_type"):
huawei_ce.register_custom_device_type()
except ImportError:
# Silently skip if custom drivers are not available
pass
except Exception as e:
# Log but don't fail
print(
f"Warning: Failed to register Huawei CE driver: {e}",
file=sys.stderr
)
print(f"Warning: Failed to register Huawei CE driver: {e}", file=sys.stderr)
try:
# Import and register Ruijie Telnet driver
from gns3server.agent.gns3_copilot.utils.custom_netmiko \
import ruijie_telnet
from gns3server.agent.gns3_copilot.utils.custom_netmiko import ruijie_telnet
# Store custom device types before registration
custom_devices.add("gns3_ruijie_telnet")
# The driver auto-registers on import, but we can call it
# explicitly
if hasattr(ruijie_telnet, 'register_custom_device_type'):
if hasattr(ruijie_telnet, "register_custom_device_type"):
ruijie_telnet.register_custom_device_type()
except ImportError:
# Silently skip if custom drivers are not available
pass
except Exception as e:
# Log but don't fail
print(
f"Warning: Failed to register Ruijie driver: {e}",
file=sys.stderr
)
print(f"Warning: Failed to register Ruijie driver: {e}", file=sys.stderr)
return custom_devices
@ -124,32 +117,32 @@ def extract_brand_name(device_type: str) -> str:
Brand name in a standardized format
"""
# Remove common suffixes
name = device_type.replace('_telnet', '').replace('_ssh', '')
name = name.replace('_serial', '')
name = device_type.replace("_telnet", "").replace("_ssh", "")
name = name.replace("_serial", "")
# Map to standard brand names
brand_mapping = {
'cisco': 'Cisco',
'huawei': 'Huawei',
'juniper': 'Juniper',
'arista': 'Arista',
'hp': 'HP',
'aruba': 'Aruba',
'dell': 'Dell',
'brocade': 'Brocade',
'extreme': 'Extreme',
'ruckus': 'Ruckus',
'ruijie': 'Ruijie (锐捷)',
'zte': 'ZTE (中兴)',
'maipu': 'Maipu (迈普)',
'h3c': 'H3C (华三)',
'nokia': 'Nokia',
'paloalto': 'Palo Alto',
'f5': 'F5',
'checkpoint': 'Check Point',
'generic': 'Generic',
'huaweiyt': 'HuaweiYT',
'vsz': 'Ruckus',
"cisco": "Cisco",
"huawei": "Huawei",
"juniper": "Juniper",
"arista": "Arista",
"hp": "HP",
"aruba": "Aruba",
"dell": "Dell",
"brocade": "Brocade",
"extreme": "Extreme",
"ruckus": "Ruckus",
"ruijie": "Ruijie (锐捷)",
"zte": "ZTE (中兴)",
"maipu": "Maipu (迈普)",
"h3c": "H3C (华三)",
"nokia": "Nokia",
"paloalto": "Palo Alto",
"f5": "F5",
"checkpoint": "Check Point",
"generic": "Generic",
"huaweiyt": "HuaweiYT",
"vsz": "Ruckus",
}
# Check for exact matches first
@ -163,7 +156,7 @@ def extract_brand_name(device_type: str) -> str:
return value
# Return capitalized version of the first part
return name.split('_')[0].capitalize()
return name.split("_")[0].capitalize()
def group_devices_by_brand(device_types: List[str]) -> Dict[str, List[str]]:
@ -205,8 +198,8 @@ def get_devices_by_protocol() -> Tuple[List[str], List[str]]:
for device_type in CLASS_MAPPER.keys():
device_lower = device_type.lower()
has_telnet = 'telnet' in device_lower
has_ssh = 'ssh' in device_lower
has_telnet = "telnet" in device_lower
has_ssh = "ssh" in device_lower
if has_telnet and has_ssh:
both_protocols.append(device_type)
@ -230,17 +223,14 @@ def get_total_device_count() -> int:
"""Get total number of device types in Netmiko."""
try:
from netmiko.ssh_dispatcher import CLASS_MAPPER
return len(CLASS_MAPPER)
except ImportError:
return 0
def generate_markdown(
version: str,
ssh_devices: List[str],
telnet_devices: List[str],
total_count: int,
custom_devices: set
version: str, ssh_devices: List[str], telnet_devices: List[str], total_count: int, custom_devices: set
) -> str:
"""
Generate Markdown documentation with table format.
@ -261,7 +251,7 @@ def generate_markdown(
md_lines.append("# Netmiko Supported Devices")
md_lines.append("")
md_lines.append(f"**Netmiko Version:** {version}")
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
md_lines.append(f"**Generated:** {timestamp}")
md_lines.append("")
md_lines.append("---")
@ -313,8 +303,7 @@ def generate_markdown(
# Footer
md_lines.append("---")
md_lines.append("")
md_lines.append("*This document was generated automatically by the "
"Netmiko device list script.*")
md_lines.append("*This document was generated automatically by the Netmiko device list script.*")
return "\n".join(md_lines)
@ -332,10 +321,8 @@ def save_markdown(content: str, filename: str = "netmiko_devices.md") -> None:
# Go up from: .../gns3server/agent/gns3_copilot/utils/
# custom_netmiko/scripts to project root
# That's 6 levels up
project_root = os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(script_dir))))
)
project_root = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(script_dir)))))
)
# Target directory: docs/gns3-copilot
@ -348,17 +335,15 @@ def save_markdown(content: str, filename: str = "netmiko_devices.md") -> None:
output_path = os.path.join(target_dir, filename)
try:
with open(output_path, 'w', encoding='utf-8') as f:
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"Markdown document saved to: {output_path}")
except IOError as e:
print(f"Error: Failed to save Markdown file: {e}",
file=sys.stderr)
print(f"Error: Failed to save Markdown file: {e}", file=sys.stderr)
sys.exit(1)
def print_summary(version: str, ssh_count: int, telnet_count: int,
total_count: int, output_file: str) -> None:
def print_summary(version: str, ssh_count: int, telnet_count: int, total_count: int, output_file: str) -> None:
"""Print summary to console."""
print("=" * 80)
print("Netmiko Device List Generator")
@ -402,7 +387,7 @@ def main() -> int:
ssh_devices=ssh_devices,
telnet_devices=telnet_devices,
total_count=total_count,
custom_devices=custom_devices
custom_devices=custom_devices,
)
# Save to file
@ -415,7 +400,7 @@ def main() -> int:
ssh_count=len(ssh_devices),
telnet_count=len(telnet_devices),
total_count=total_count,
output_file=output_file
output_file=output_file,
)
return 0

View File

@ -25,9 +25,7 @@ import os
# Add project root to path using relative path
test_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(
os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(test_dir)))))
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir)))))
sys.path.insert(0, project_root)
@ -103,13 +101,8 @@ class TestHuaweiTelnetCEDriver(unittest.TestCase):
def test_initialization_parameters(self):
"""Test that initialization sets correct parameters."""
# Create a mock instance (without actual connection)
with patch.object(
self.HuaweiTelnetCE, "__init__",
lambda self, *args, **kwargs: None
):
instance = self.HuaweiTelnetCE.__new__(
self.HuaweiTelnetCE
)
with patch.object(self.HuaweiTelnetCE, "__init__", lambda self, *args, **kwargs: None):
instance = self.HuaweiTelnetCE.__new__(self.HuaweiTelnetCE)
# Mock the necessary attributes
instance.protocol = "telnet"
@ -169,9 +162,7 @@ class TestHuaweiTelnetCEIntegration(unittest.TestCase):
)
# Create a mock instance
instance = GNS3HuaweiTelnetCE.__new__(
GNS3HuaweiTelnetCE
)
instance = GNS3HuaweiTelnetCE.__new__(GNS3HuaweiTelnetCE)
# Mock the necessary attributes and methods
instance.host = "127.0.0.1"
@ -203,12 +194,8 @@ def run_tests():
suite = unittest.TestSuite()
# Add test cases
suite.addTests(
loader.loadTestsFromTestCase(TestHuaweiTelnetCEDriver)
)
suite.addTests(
loader.loadTestsFromTestCase(TestHuaweiTelnetCEIntegration)
)
suite.addTests(loader.loadTestsFromTestCase(TestHuaweiTelnetCEDriver))
suite.addTests(loader.loadTestsFromTestCase(TestHuaweiTelnetCEIntegration))
# Run tests
runner = unittest.TextTestRunner(verbosity=2)
@ -218,9 +205,7 @@ def run_tests():
print("\n" + "=" * 100)
print("Test Summary:")
print(f" Run: {result.testsRun}")
success_count = (
result.testsRun - len(result.failures) - len(result.errors)
)
success_count = result.testsRun - len(result.failures) - len(result.errors)
print(f" Success: {success_count}")
print(f" Failed: {len(result.failures)}")
print(f" Errors: {len(result.errors)}")

View File

@ -25,9 +25,7 @@ import os
# Add project root to path using relative path
test_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(
os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(test_dir)))))
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir)))))
sys.path.insert(0, project_root)
@ -92,9 +90,7 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
import re
# Should have INTERACTIVE_PATTERNS as a class attribute
self.assertTrue(
hasattr(self.RuijieTelnetEnhanced, "INTERACTIVE_PATTERNS")
)
self.assertTrue(hasattr(self.RuijieTelnetEnhanced, "INTERACTIVE_PATTERNS"))
# Check it's a list
patterns = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
@ -108,18 +104,11 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
def test_preprocess_interactive_commands(self):
"""Test that interactive commands are preprocessed correctly."""
# Create a mock instance (without actual connection)
with patch.object(
self.RuijieTelnetEnhanced, "__init__",
lambda self, *args, **kwargs: None
):
instance = self.RuijieTelnetEnhanced.__new__(
self.RuijieTelnetEnhanced
)
with patch.object(self.RuijieTelnetEnhanced, "__init__", lambda self, *args, **kwargs: None):
instance = self.RuijieTelnetEnhanced.__new__(self.RuijieTelnetEnhanced)
# Set INTERACTIVE_PATTERNS from class
instance.INTERACTIVE_PATTERNS = (
self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
)
instance.INTERACTIVE_PATTERNS = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
# Test commands with router-id (should trigger 'yes')
commands = [
@ -128,34 +117,23 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
"network 10.0.0.0 0.0.0.255 area 0",
]
processed = instance._preprocess_interactive_commands(
commands
)
processed = instance._preprocess_interactive_commands(commands)
# Should have inserted 'yes' after router-id command
self.assertEqual(len(processed), 4)
self.assertEqual(processed[0], "router ospf 1")
self.assertEqual(processed[1], "router-id 1.1.1.1")
self.assertEqual(processed[2], "yes")
self.assertEqual(
processed[3], "network 10.0.0.0 0.0.0.255 area 0"
)
self.assertEqual(processed[3], "network 10.0.0.0 0.0.0.255 area 0")
def test_preprocess_non_interactive_commands(self):
"""Test that non-interactive commands are not modified."""
# Create a mock instance
with patch.object(
self.RuijieTelnetEnhanced, "__init__",
lambda self, *args, **kwargs: None
):
instance = self.RuijieTelnetEnhanced.__new__(
self.RuijieTelnetEnhanced
)
with patch.object(self.RuijieTelnetEnhanced, "__init__", lambda self, *args, **kwargs: None):
instance = self.RuijieTelnetEnhanced.__new__(self.RuijieTelnetEnhanced)
# Set INTERACTIVE_PATTERNS from class
instance.INTERACTIVE_PATTERNS = (
self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
)
instance.INTERACTIVE_PATTERNS = self.RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
# Test commands without interactive prompts
commands = [
@ -190,9 +168,7 @@ class TestRuijieTelnetEnhancedDriver(unittest.TestCase):
# Check that default_enter is handled in __init__
# (The actual logic is in the __init__ method body)
init_source = inspect.getsource(
self.RuijieTelnetEnhanced.__init__
)
init_source = inspect.getsource(self.RuijieTelnetEnhanced.__init__)
self.assertIn("default_enter", init_source)
self.assertIn("\\r\\n", init_source)
@ -207,9 +183,7 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase):
)
# Create a mock instance
instance = RuijieTelnetEnhanced.__new__(
RuijieTelnetEnhanced
)
instance = RuijieTelnetEnhanced.__new__(RuijieTelnetEnhanced)
# Mock the necessary attributes and methods
instance.RETURN = "\r\n"
@ -224,9 +198,7 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase):
commands = ["interface GigabitEthernet 0/1", "description Test"]
# Call send_config_set
instance.send_config_set(
commands, enter_config_mode=False, exit_config_mode=False
)
instance.send_config_set(commands, enter_config_mode=False, exit_config_mode=False)
# Verify _send_config_batch was called
instance._send_config_batch.assert_called_once()
@ -238,17 +210,13 @@ class TestRuijieTelnetEnhancedIntegration(unittest.TestCase):
)
# Create a mock instance
instance = RuijieTelnetEnhanced.__new__(
RuijieTelnetEnhanced
)
instance.INTERACTIVE_PATTERNS = (
RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
)
instance = RuijieTelnetEnhanced.__new__(RuijieTelnetEnhanced)
instance.INTERACTIVE_PATTERNS = RuijieTelnetEnhanced.INTERACTIVE_PATTERNS
# Test commands with multiple interactive prompts
commands = [
"router-id 1.1.1.1", # Should trigger 'yes'
"erase startup-config", # Should trigger 'yes'
"router-id 1.1.1.1", # Should trigger 'yes'
"erase startup-config", # Should trigger 'yes'
"interface GigabitEthernet 0/1", # Should NOT trigger
]
@ -273,12 +241,8 @@ def run_tests():
suite = unittest.TestSuite()
# Add test cases
suite.addTests(
loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedDriver)
)
suite.addTests(
loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedIntegration)
)
suite.addTests(loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedDriver))
suite.addTests(loader.loadTestsFromTestCase(TestRuijieTelnetEnhancedIntegration))
# Run tests
runner = unittest.TextTestRunner(verbosity=2)
@ -288,9 +252,7 @@ def run_tests():
print("\n" + "=" * 100)
print("Test Summary:")
print(f" Run: {result.testsRun}")
success_count = (
result.testsRun - len(result.failures) - len(result.errors)
)
success_count = result.testsRun - len(result.failures) - len(result.errors)
print(f" Success: {success_count}")
print(f" Failed: {len(result.failures)}")
print(f" Errors: {len(result.errors)}")

View File

@ -27,9 +27,7 @@ import os
# Add project root to path using relative path
test_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(
os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(test_dir)))))
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(test_dir)))))
sys.path.insert(0, project_root)
@ -121,6 +119,7 @@ class TestVPCSTelnetInit(unittest.TestCase):
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
VPCSTelnet,
)
cls.VPCSTelnet = VPCSTelnet
def test_default_enter_parameter(self):
@ -134,6 +133,7 @@ class TestVPCSTelnetInit(unittest.TestCase):
def test_default_parameters_when_none(self):
"""Test default parameters when not provided."""
# Mock the BaseConnection.__init__ to avoid actual connection
# But also set device_type since VPCSTelnet.__init__ sets it
def mock_init(self, *args, **kwargs):
@ -141,23 +141,20 @@ class TestVPCSTelnetInit(unittest.TestCase):
kwargs.setdefault("device_type", "gns3_vpcs_telnet")
self.device_type = kwargs.get("device_type")
with patch.object(
self.VPCSTelnet, "__init__", mock_init
):
with patch.object(self.VPCSTelnet, "__init__", mock_init):
instance = self.VPCSTelnet(host="127.0.0.1")
self.assertEqual(instance.device_type, "gns3_vpcs_telnet")
def test_device_type_set(self):
"""Test that device_type is set to gns3_vpcs_telnet."""
# Mock the __init__ to set device_type
def mock_init(self, *args, **kwargs):
# Simulate what VPCSTelnet.__init__ does
kwargs.setdefault("device_type", "gns3_vpcs_telnet")
self.device_type = kwargs.get("device_type")
with patch.object(
self.VPCSTelnet, "__init__", mock_init
):
with patch.object(self.VPCSTelnet, "__init__", mock_init):
instance = self.VPCSTelnet(host="127.0.0.1")
self.assertEqual(instance.device_type, "gns3_vpcs_telnet")
@ -171,6 +168,7 @@ class TestVPCSTelnetMethods(unittest.TestCase):
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
VPCSTelnet,
)
cls.VPCSTelnet = VPCSTelnet
def test_check_config_mode_always_false(self):
@ -213,6 +211,7 @@ class TestVPCSTelnetSendCommand(unittest.TestCase):
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
VPCSTelnet,
)
cls.VPCSTelnet = VPCSTelnet
def test_send_command_writes_bytes(self):
@ -243,7 +242,7 @@ class TestVPCSTelnetSendCommand(unittest.TestCase):
# Check that expect_string defaults to r"PC\d+>"
source = inspect.getsource(self.VPCSTelnet.send_command)
self.assertIn("expect_string = r\"PC\\d+>\"", source)
self.assertIn('expect_string = r"PC\\d+>"', source)
def test_send_command_timing_calls_send_command(self):
"""Test that send_command_timing delegates to send_command."""
@ -277,6 +276,7 @@ class TestVPCSTelnetTelnetLogin(unittest.TestCase):
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
VPCSTelnet,
)
cls.VPCSTelnet = VPCSTelnet
def test_telnet_login_default_prompt_pattern(self):
@ -335,19 +335,16 @@ class TestVPCSTelnetRegistration(unittest.TestCase):
from gns3server.agent.gns3_copilot.utils.custom_netmiko import (
vpcs_telnet,
)
cls.vpcs_telnet = vpcs_telnet
def test_register_function_exists(self):
"""Test that register_custom_device_type function exists."""
self.assertTrue(
hasattr(self.vpcs_telnet, "register_custom_device_type")
)
self.assertTrue(hasattr(self.vpcs_telnet, "register_custom_device_type"))
def test_register_function_is_callable(self):
"""Test that register_custom_device_type is callable."""
self.assertTrue(
callable(self.vpcs_telnet.register_custom_device_type)
)
self.assertTrue(callable(self.vpcs_telnet.register_custom_device_type))
class TestVPCSTelnetAnsiStripping(unittest.TestCase):
@ -359,6 +356,7 @@ class TestVPCSTelnetAnsiStripping(unittest.TestCase):
from gns3server.agent.gns3_copilot.utils.custom_netmiko.vpcs_telnet import ( # noqa: E501
VPCSTelnet,
)
cls.VPCSTelnet = VPCSTelnet
def test_strip_ansi_codes_method_exists(self):
@ -435,14 +433,10 @@ IP/MASK : 192.168.1.20/24"""
# Mock necessary attributes
instance.remote_conn = MagicMock()
instance.remote_conn.write = MagicMock()
instance.read_until_pattern = MagicMock(
return_value="\x1b[1mNAME\x1b[0m : PC2\nPC2>"
)
instance.read_until_pattern = MagicMock(return_value="\x1b[1mNAME\x1b[0m : PC2\nPC2>")
# Mock _strip_ansi_codes to verify it's called
instance._strip_ansi_codes = Mock(
return_value="NAME : PC2\nPC2>"
)
instance._strip_ansi_codes = Mock(return_value="NAME : PC2\nPC2>")
# Call send_command
instance.send_command("show ip")
@ -476,9 +470,7 @@ def run_tests():
print("\n" + "=" * 100)
print("Test Summary:")
print(f" Run: {result.testsRun}")
success_count = (
result.testsRun - len(result.failures) - len(result.errors)
)
success_count = result.testsRun - len(result.failures) - len(result.errors)
print(f" Success: {success_count}")
print(f" Failed: {len(result.failures)}")
print(f" Errors: {len(result.errors)}")

View File

@ -149,10 +149,7 @@ class VPCSTelnet(BaseConnection):
# Step 3: Wait for VPCS prompt pattern
try:
# Read until we see the prompt
output = self.read_until_pattern(
pattern=pri_prompt_terminator,
read_timeout=10
)
output = self.read_until_pattern(pattern=pri_prompt_terminator, read_timeout=10)
return_msg += output
if re.search(pri_prompt_terminator, return_msg, flags=re.M):
@ -291,9 +288,7 @@ class VPCSTelnet(BaseConnection):
strip_command=strip_command,
)
def check_config_mode(
self, check_string: str = "", pattern: str = "", force_regex: bool = False
) -> bool:
def check_config_mode(self, check_string: str = "", pattern: str = "", force_regex: bool = False) -> bool:
"""
VPCS has no configuration mode.
@ -326,9 +321,7 @@ class VPCSTelnet(BaseConnection):
"""
return ANSI_ESCAPE_PATTERN.sub("", text)
def config_mode(
self, config_command: str = "", pattern: str = "", re_flags: int = 0
) -> str:
def config_mode(self, config_command: str = "", pattern: str = "", re_flags: int = 0) -> str:
"""
VPCS has no configuration mode.
@ -370,6 +363,7 @@ class VPCSTelnet(BaseConnection):
# Register the custom device type with Netmiko
_registered = False # Flag to prevent duplicate registration
def register_custom_device_type() -> None:
"""
Register the custom VPCS Telnet device type with Netmiko.
@ -431,8 +425,4 @@ try:
register_custom_device_type()
except Exception as e:
# Log but don't fail on import
logger.warning(
"Failed to register VPCS device type: %s",
e,
exc_info=True
)
logger.warning("Failed to register VPCS device type: %s", e, exc_info=True)

View File

@ -61,10 +61,7 @@ def format_error_message(error: Exception) -> str:
# Check if error message contains HTML
if _contains_html(error_str):
logger.warning(
"Detected HTML in error message, likely due to incorrect API "
"configuration or base URL"
)
logger.warning("Detected HTML in error message, likely due to incorrect API configuration or base URL")
return (
"API request failed. The error response indicates an issue with "
"your API configuration. Please check:\n"

View File

@ -71,9 +71,7 @@ def get_device_ports_from_topology(
Devices that don't exist or missing console_port will not be included
"""
# Log received parameters
logger.info(
"Called with device_names=%s, project_id=%s", device_names, project_id
)
logger.info("Called with device_names=%s, project_id=%s", device_names, project_id)
try:
# Lazy import to avoid circular dependency
@ -93,9 +91,7 @@ def get_device_ports_from_topology(
for device_name in device_names:
# Check if device exists in topology
if device_name not in topology.get("nodes", {}):
logger.warning(
"Device '%s' not found in topology", device_name
)
logger.warning("Device '%s' not found in topology", device_name)
continue
node_info = topology["nodes"][device_name]
@ -133,9 +129,7 @@ def get_device_ports_from_topology(
f"Current tags: {tags}"
)
logger.error(error_msg)
hosts_data[device_name] = {
"error": error_msg
}
hosts_data[device_name] = {"error": error_msg}
continue
logger.debug(
@ -170,11 +164,7 @@ def get_device_ports_from_topology(
"platform": platform,
"node_type": node_info.get("type"),
"groups": ["network_devices"], # For inheriting hostname, timeout, etc.
"connection_options": {
"netmiko": {
"extras": {"device_type": device_type}
}
},
"connection_options": {"netmiko": {"extras": {"device_type": device_type}}},
}
# Per-node default credentials (seeded from the template appliance

View File

@ -135,14 +135,9 @@ def calculate_two_node_shape(
node2_center_x = node2["x"] + (node2_width / 2)
node2_center_y = node2["y"] + (node2_height / 2)
distance = math.sqrt(
(node2_center_x - node1_center_x) ** 2
+ (node2_center_y - node1_center_y) ** 2
)
distance = math.sqrt((node2_center_x - node1_center_x) ** 2 + (node2_center_y - node1_center_y) ** 2)
angle_rad = math.atan2(
node2_center_y - node1_center_y, node2_center_x - node1_center_x
)
angle_rad = math.atan2(node2_center_y - node1_center_y, node2_center_x - node1_center_x)
angle_deg = round(math.degrees(angle_rad))
angle_rad = math.radians(angle_deg)
@ -162,16 +157,10 @@ def calculate_two_node_shape(
shape_width = rx * 2
shape_height = ry * 2
svg_x = center_x - (
rx * math.cos(angle_rad) - ry * math.sin(angle_rad)
)
svg_y = center_y - (
rx * math.sin(angle_rad) + ry * math.cos(angle_rad)
)
svg_x = center_x - (rx * math.cos(angle_rad) - ry * math.sin(angle_rad))
svg_y = center_y - (rx * math.sin(angle_rad) + ry * math.cos(angle_rad))
shape_svg = generate_ellipse_svg(
int(rx), int(ry), color_scheme, int(shape_width), int(shape_height)
)
shape_svg = generate_ellipse_svg(int(rx), int(ry), color_scheme, int(shape_width), int(shape_height))
offset_distance = ry * text_offset_ratio
@ -188,22 +177,12 @@ def calculate_two_node_shape(
else: # rectangle
shape_width = distance
shape_height = max(
node1_width, node1_height, node2_width, node2_height
)
shape_height = max(node1_width, node1_height, node2_width, node2_height)
svg_x = center_x - (
(shape_width / 2) * math.cos(angle_rad)
- (shape_height / 2) * math.sin(angle_rad)
)
svg_y = center_y - (
(shape_width / 2) * math.sin(angle_rad)
+ (shape_height / 2) * math.cos(angle_rad)
)
svg_x = center_x - ((shape_width / 2) * math.cos(angle_rad) - (shape_height / 2) * math.sin(angle_rad))
svg_y = center_y - ((shape_width / 2) * math.sin(angle_rad) + (shape_height / 2) * math.cos(angle_rad))
shape_svg = generate_rectangle_svg(
int(shape_width), int(shape_height), color_scheme
)
shape_svg = generate_rectangle_svg(int(shape_width), int(shape_height), color_scheme)
offset_distance = (shape_height / 2) * text_offset_ratio
@ -354,33 +333,15 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]:
return COLOR_SCHEMES["NORMAL_AREA"]
# 3. Logical Isolation
if (
"VRF" in label
or "VLAN" in label
or "MSTP" in label
or "VXLAN" in label
or "MPLS" in label
):
if "VRF" in label or "VLAN" in label or "MSTP" in label or "VXLAN" in label or "MPLS" in label:
return COLOR_SCHEMES["ISOLATION"]
# 4. High Availability
if (
"VRRP" in label
or "HSRP" in label
or "HA" in label
or "STACK" in label
or "M-LAG" in label
):
if "VRRP" in label or "HSRP" in label or "HA" in label or "STACK" in label or "M-LAG" in label:
return COLOR_SCHEMES["HIGH_AVAILABILITY"]
# 5. External/Internet
if (
"INET" in label
or "OUT" in label
or "EXTERNAL" in label
or "INTERNET" in label
or "DMZ" in label
):
if "INET" in label or "OUT" in label or "EXTERNAL" in label or "INTERNET" in label or "DMZ" in label:
return COLOR_SCHEMES["EXTERNAL"]
# 6. Management
@ -392,12 +353,7 @@ def _get_color_scheme(area_name: str) -> dict[str, Any]:
return COLOR_SCHEMES["SECURITY_TRUSTED"]
# 8. Cloud/Tunnel
if (
"TUNNEL" in label
or "CLOUD" in label
or "GRE" in label
or "IPSEC" in label
):
if "TUNNEL" in label or "CLOUD" in label or "GRE" in label or "IPSEC" in label:
return COLOR_SCHEMES["CLOUD_TUNNEL"]
# Legacy keyword matching for backward compatibility
@ -436,9 +392,7 @@ def calculate_two_node_ellipse(
Wrapper around calculate_two_node_shape with shape_type="ellipse".
"""
result = calculate_two_node_shape(
node1, node2, area_name, "ellipse", text_offset_ratio
)
result = calculate_two_node_shape(node1, node2, area_name, "ellipse", text_offset_ratio)
return {
"ellipse": result["shape"],
"text": result["text"],
@ -457,9 +411,7 @@ def calculate_two_node_rectangle(
Wrapper around calculate_two_node_shape with shape_type="rectangle".
"""
result = calculate_two_node_shape(
node1, node2, area_name, "rectangle", text_offset_ratio
)
result = calculate_two_node_shape(node1, node2, area_name, "rectangle", text_offset_ratio)
metadata = result["metadata"]
return {
"rectangle": result["shape"],

View File

@ -53,9 +53,7 @@ from fastapi import FastAPI
logger = logging.getLogger(__name__)
async def get_user_llm_config_with_app(
user_id: UUID, app: FastAPI
) -> Optional[Dict[str, Any]]:
async def get_user_llm_config_with_app(user_id: UUID, app: FastAPI) -> Optional[Dict[str, Any]]:
"""
Get user's default LLM model configuration with decrypted API key.

View File

@ -160,9 +160,7 @@ def parse_tool_content(
# Empty string handling
if not content.strip():
result = {}
logger.info(
"Content is empty or whitespace, returning: %s", result
)
logger.info("Content is empty or whitespace, returning: %s", result)
return result
s = content.strip()
@ -170,9 +168,7 @@ def parse_tool_content(
# Handle empty dictionary case
if s == "{}":
result = {}
logger.info(
"Content is empty dictionary, returning: %s", result
)
logger.info("Content is empty dictionary, returning: %s", result)
return result
# Try to parse as Python literal
@ -190,9 +186,7 @@ def parse_tool_content(
# Try to parse as JSON
try:
result = json.loads(s)
logger.info(
"Successfully parsed as JSON, returning: %s", result
)
logger.info("Successfully parsed as JSON, returning: %s", result)
return result
except json.JSONDecodeError:
pass
@ -221,8 +215,7 @@ def parse_tool_content(
# Handle unsupported types
error_msg = ( # type: ignore[unreachable]
"Content must be str, dict, list, int, float, bool, or None, got "
f"{type(content).__name__}"
f"Content must be str, dict, list, int, float, bool, or None, got {type(content).__name__}"
)
logger.error(error_msg)
@ -238,9 +231,7 @@ def parse_tool_content(
return result
def format_tool_response(
content: str | dict | list | int | float | bool | None, indent: int = 2
) -> str:
def format_tool_response(content: str | dict | list | int | float | bool | None, indent: int = 2) -> str:
"""
Format tool response as a beautiful JSON string for UI display.
@ -259,9 +250,7 @@ def format_tool_response(
logger.info("format_tool_response parameter indent: %s", indent)
try:
parsed = parse_tool_content(
content, fallback_to_raw=True, strict_mode=False
)
parsed = parse_tool_content(content, fallback_to_raw=True, strict_mode=False)
# Ensure the result can be serialized to JSON
result = json.dumps(parsed, ensure_ascii=False, indent=indent)
logger.info("format_tool_response returning: %s", result)
@ -270,9 +259,7 @@ def format_tool_response(
# If the parsed result cannot be serialized, convert to string and wrap
logger.error("Cannot serialize parsed result to JSON: %s", e)
try:
result = json.dumps(
{"raw": str(content)}, ensure_ascii=False, indent=indent
)
result = json.dumps({"raw": str(content)}, ensure_ascii=False, indent=indent)
logger.info("format_tool_response returning fallback: %s", result)
return result
except Exception:
@ -286,16 +273,12 @@ def format_tool_response(
return result
except Exception as e:
logger.error("Error formatting tool response: %s", e)
result = json.dumps(
{"error": str(e)}, ensure_ascii=False, indent=indent
)
result = json.dumps({"error": str(e)}, ensure_ascii=False, indent=indent)
logger.info("format_tool_response returning error: %s", result)
return result
def normalize_tool_response(
response: dict | list | str, tool_name: str = "unknown"
) -> dict:
def normalize_tool_response(response: dict | list | str, tool_name: str = "unknown") -> dict:
"""
Normalize tool response to standard format for consistent frontend display.
@ -334,11 +317,7 @@ def normalize_tool_response(
}
# Handle error responses
if (
isinstance(response, dict)
and "error" in response
and len(response) == 1
):
if isinstance(response, dict) and "error" in response and len(response) == 1:
return {
"success": False,
"total": 0,
@ -362,34 +341,21 @@ def normalize_tool_response(
# Handle list responses (most tools return list of device results)
if isinstance(response, list):
successful = sum(
1
for item in response
if isinstance(item, dict) and item.get("status") == "success"
)
successful = sum(1 for item in response if isinstance(item, dict) and item.get("status") == "success")
failed = len(response) - successful
normalized_data = []
for item in response:
if isinstance(item, dict):
normalized_item = {
"id": item.get("device_id")
or item.get("node_id")
or item.get("id")
or "",
"id": item.get("device_id") or item.get("node_id") or item.get("id") or "",
"name": item.get("device_name") or item.get("name") or "",
"status": item.get("status", "unknown"),
}
if normalized_item["status"] == "success":
normalized_item["result"] = (
item.get("output") or item.get("result") or ""
)
normalized_item["result"] = item.get("output") or item.get("result") or ""
else:
normalized_item["error"] = (
item.get("error")
or item.get("output")
or "Unknown error"
)
normalized_item["error"] = item.get("error") or item.get("output") or "Unknown error"
normalized_data.append(normalized_item)
else:
# Non-dict items in list
@ -426,14 +392,8 @@ def normalize_tool_response(
}
# Legacy format: extract common fields
total = (
response.get("total_nodes")
or response.get("total")
or response.get("count", 1)
)
successful = (
response.get("successful_nodes") or response.get("successful") or 0
)
total = response.get("total_nodes") or response.get("total") or response.get("count", 1)
successful = response.get("successful_nodes") or response.get("successful") or 0
failed = response.get("failed_nodes") or response.get("failed") or 0
# Extract data from various possible locations
@ -458,13 +418,9 @@ def normalize_tool_response(
if not data and "status" in response:
data = [
{
"name": response.get("device_name")
or response.get("name")
or "",
"name": response.get("device_name") or response.get("name") or "",
"status": response["status"],
"result": response.get("output")
or response.get("result")
or "",
"result": response.get("output") or response.get("result") or "",
"error": response.get("error") or "",
}
]
@ -564,9 +520,7 @@ def _test_parse_tool_content() -> None:
valid = "✓"
except Exception:
valid = "✗"
print(
f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}"
)
print(f"Format Test {i + 1}: {valid} Input: {repr(input_data)} -> {result}")
if __name__ == "__main__":

View File

@ -98,9 +98,7 @@ class ToolCallStreamAccumulator:
"id": tc_id,
"type": "function",
"function": {
"name": self._current_tool_call[
"name"
],
"name": self._current_tool_call["name"],
"arguments": "",
},
},
@ -122,9 +120,7 @@ class ToolCallStreamAccumulator:
# Core: string concatenation
if isinstance(args_chunk, str):
tool_data[
"args_string"
] += args_chunk
tool_data["args_string"] += args_chunk
# Send updated tool_call event with accumulated args
chunks.append(
@ -135,9 +131,7 @@ class ToolCallStreamAccumulator:
"type": "function",
"function": {
"name": tool_data["name"],
"arguments": tool_data[
"args_string"
],
"arguments": tool_data["args_string"],
},
},
}
@ -147,15 +141,9 @@ class ToolCallStreamAccumulator:
# complete ==========
# Check finish_reason == "tool_calls" or "STOP"
response_metadata = getattr(chunk, "response_metadata", {})
finish_reason = (
response_metadata.get("finish_reason")
if isinstance(response_metadata, dict)
else None
)
finish_reason = response_metadata.get("finish_reason") if isinstance(response_metadata, dict) else None
if (finish_reason == "tool_calls") or (
finish_reason == "stop" and self._current_tool_call is not None
):
if (finish_reason == "tool_calls") or (finish_reason == "stop" and self._current_tool_call is not None):
if self._current_tool_call:
tool_data = self._current_tool_call

File diff suppressed because it is too large Load Diff

View File

@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -41,12 +43,32 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
VALID_APPLIANCE_FIELDS = {
"appliance_id", "name", "category", "description", "vendor_name",
"vendor_url", "product_name", "product_url", "documentation_url",
"status", "availability", "maintainer", "usage", "symbol",
"images", "versions", "tags", "builtin",
"first_port_name", "port_name_format", "port_segment_size",
"linked_clone", "docker", "iou", "dynamips", "qemu",
"appliance_id",
"name",
"category",
"description",
"vendor_name",
"vendor_url",
"product_name",
"product_url",
"documentation_url",
"status",
"availability",
"maintainer",
"usage",
"symbol",
"images",
"versions",
"tags",
"builtin",
"first_port_name",
"port_name_format",
"port_segment_size",
"linked_clone",
"docker",
"iou",
"dynamips",
"qemu",
}
@ -56,7 +78,7 @@ def get_appliances_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"name\", \"category\"]"}
return {"error": 'fields must be a list, e.g. ["name", "category"]'}
invalid = [f for f in fields if f not in VALID_APPLIANCE_FIELDS]
if invalid:
return {

View File

@ -27,6 +27,7 @@ log = logging.getLogger(__name__)
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],

View File

@ -81,6 +81,7 @@ def _render_template(template: str, device_configs: list[dict], commands_field:
# ── Tool handlers ──────────────────────────────────────────────────────────
def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]:
"""Send configuration commands to network devices via console."""
project_id = params.get("project_id")
@ -97,10 +98,12 @@ def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ExecuteMultipleDeviceConfigCommands
tool = ExecuteMultipleDeviceConfigCommands()
input_data = json.dumps({
"project_id": project_id,
"device_configs": device_configs,
})
input_data = json.dumps(
{
"project_id": project_id,
"device_configs": device_configs,
}
)
return tool._run(
input_data,
jwt_token=gns3_ctx["jwt_token"],
@ -114,7 +117,12 @@ def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
device_configs = params.get("device_configs")
template = params.get("template")
if not project_id or not device_configs:
return [{"status": "failed", "error": "project_id and device_configs (list of {device_name, commands}) are required"}]
return [
{
"status": "failed",
"error": "project_id and device_configs (list of {device_name, commands}) are required",
}
]
if template:
device_configs = _render_template(template, device_configs, commands_field="commands")
@ -124,10 +132,12 @@ def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands
tool = ExecuteMultipleDeviceCommands()
input_data = json.dumps({
"project_id": project_id,
"device_configs": device_configs,
})
input_data = json.dumps(
{
"project_id": project_id,
"device_configs": device_configs,
}
)
return tool._run(
input_data,
jwt_token=gns3_ctx["jwt_token"],
@ -145,10 +155,12 @@ def vpcs_config_set_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
tool = VPCSCommands()
input_data = json.dumps({
"project_id": project_id,
"device_configs": device_configs,
})
input_data = json.dumps(
{
"project_id": project_id,
"device_configs": device_configs,
}
)
return tool._run(
input_data,
jwt_token=gns3_ctx["jwt_token"],

View File

@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
def get_drawings_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:

View File

@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
def get_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx)
images = conn.http_call("get", f"{conn.base_url}/images").json()

View File

@ -31,9 +31,11 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
"""Create a Gns3Connector from the GNS3 context dict."""
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -44,6 +46,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
def list_projects_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx)
projects = conn.http_call("get", f"{conn.base_url}/projects").json()
@ -141,7 +144,12 @@ def get_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
return {"project_id": project_id, "file": "README.txt", "content": content}
except Exception as e:
if "404" in str(e):
return {"project_id": project_id, "file": "README.txt", "content": None, "message": "README.txt does not exist yet"}
return {
"project_id": project_id,
"file": "README.txt",
"content": None,
"message": "README.txt does not exist yet",
}
raise
@ -286,7 +294,10 @@ PROJECT_TOOLS = [
"show_grid": {"type": "boolean", "description": "Show the grid on the drawing area"},
"grid_size": {"type": "integer", "description": "Grid size for the drawing area for nodes"},
"drawing_grid_size": {"type": "integer", "description": "Grid size for the drawing area for drawings"},
"show_interface_labels": {"type": "boolean", "description": "Show interface labels on the drawing area"},
"show_interface_labels": {
"type": "boolean",
"description": "Show interface labels on the drawing area",
},
},
"required": ["project_id"],
},

View File

@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
def get_version_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx)
return conn.http_call("get", f"{conn.base_url}/version").json()

View File

@ -28,8 +28,10 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -40,6 +42,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
def get_snapshots_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:

View File

@ -30,8 +30,10 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -42,6 +44,7 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
def get_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx)
symbols = conn.http_call("get", f"{conn.base_url}/symbols").json()
@ -57,9 +60,11 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
username = gns3_ctx.get("jwt_username")
# short-lived ticket bound to this exact path — LLM clients retyping curl
# commands corrupted the long Bearer JWT this used to embed
ticket = access_ticket_service.mint(
username, token_version=gns3_ctx.get("jwt_token_version", 0), path=path
) if username else None
ticket = (
access_ticket_service.mint(username, token_version=gns3_ctx.get("jwt_token_version", 0), path=path)
if username
else None
)
if ticket:
download_url += f"?token={ticket}"
result = {
@ -68,7 +73,7 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
"note": "Symbol files are SVG images.",
}
if ticket:
safe_name = symbol_id.replace(':', '').replace('/', '_')
safe_name = symbol_id.replace(":", "").replace("/", "_")
result["curl_command"] = f"curl -L -o '{safe_name}.svg' '{download_url}'"
result["note"] += " The download URL includes a 10-minute ticket."
return result

View File

@ -31,8 +31,10 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -44,9 +46,19 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
VALID_TEMPLATE_FIELDS = {
"template_id", "name", "version", "category", "default_name_format",
"symbol", "template_type", "compute_id", "usage", "tags", "builtin",
"created_at", "updated_at",
"template_id",
"name",
"version",
"category",
"default_name_format",
"symbol",
"template_type",
"compute_id",
"usage",
"tags",
"builtin",
"created_at",
"updated_at",
}
TEMPLATE_DEFAULT_FIELDS = ["template_id", "name", "template_type", "category", "default_name_format"]
@ -67,7 +79,7 @@ def list_templates_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"template_id\", \"name\"]"}
return {"error": 'fields must be a list, e.g. ["template_id", "name"]'}
invalid = [f for f in fields if f not in VALID_TEMPLATE_FIELDS]
if invalid:
return {

View File

@ -83,7 +83,7 @@ class DockerHTTPClient:
self._connected = True
# Parse API version
api_version = parse_version(docker_info['ApiVersion'])
api_version = parse_version(docker_info["ApiVersion"])
docker_version = docker_info["Version"]
logger.info(f"Connected to Docker {docker_version}, API {api_version}")
@ -102,7 +102,7 @@ class DockerHTTPClient:
logger.info(f"Using Docker API version {self._api_version}")
else:
# Use Docker daemon's actual API version
self._api_version = docker_info['ApiVersion']
self._api_version = docker_info["ApiVersion"]
logger.info(f"Using Docker API version {self._api_version} (daemon native)")
except (aiohttp.ClientError, FileNotFoundError) as e:
@ -124,7 +124,7 @@ class DockerHTTPClient:
Response JSON data
"""
# Check connection and version on first request
check_connection = kwargs.pop('check_connection', True)
check_connection = kwargs.pop("check_connection", True)
if check_connection and not self._connected:
await self._check_connection()
@ -147,22 +147,21 @@ class DockerHTTPClient:
except RuntimeError as e:
# Retry with lower API version if Docker daemon doesn't support current version
error_msg = str(e)
if ("400" in error_msg or "not found" in error_msg.lower()) and self._api_version == DOCKER_PREFERRED_API_VERSION:
logger.warning(f"Docker daemon doesn't support API version {self._api_version}, falling back to {DOCKER_MINIMUM_API_VERSION}")
if (
"400" in error_msg or "not found" in error_msg.lower()
) and self._api_version == DOCKER_PREFERRED_API_VERSION:
logger.warning(
f"Docker daemon doesn't support API version {self._api_version}, falling back to {DOCKER_MINIMUM_API_VERSION}"
)
self._api_version = DOCKER_MINIMUM_API_VERSION
return await self._request(method, endpoint, check_connection=False, **kwargs)
raise
async def create_network(self, name: str, driver: str = "bridge", subnet: str = None):
"""Create Docker network."""
data = {
"Name": name,
"Driver": driver
}
data = {"Name": name, "Driver": driver}
if subnet:
data["IPAM"] = {
"Config": [{"Subnet": subnet}]
}
data["IPAM"] = {"Config": [{"Subnet": subnet}]}
await self._request("POST", "networks/create", json=data)
async def get_network(self, name: str):
@ -176,19 +175,12 @@ class DockerHTTPClient:
async def create_container(self, name: str, image: str, **kwargs):
"""Create container."""
data = {
"Image": image,
"name": name,
"HostConfig": {},
"NetworkingConfig": {}
}
data = {"Image": image, "name": name, "HostConfig": {}, "NetworkingConfig": {}}
# Handle network config
if "network" in kwargs:
network_name = kwargs.pop("network")
data["NetworkingConfig"]["EndpointsConfig"] = {
network_name: {}
}
data["NetworkingConfig"]["EndpointsConfig"] = {network_name: {}}
# Handle environment variables
if "environment" in kwargs:

View File

@ -92,10 +92,7 @@ logger = logging.getLogger(__name__)
def setup_logging(verbose: bool = False):
"""Setup logging configuration."""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logging.basicConfig(level=level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
async def cmd_start(args) -> int:
@ -119,7 +116,7 @@ async def cmd_start(args) -> int:
image=args.image,
memory=args.memory,
cpus=args.cpus,
pids_limit=args.pids_limit
pids_limit=args.pids_limit,
)
print(json.dumps(result, indent=2))
@ -143,10 +140,7 @@ async def cmd_stop(args) -> int:
"""
manager = WebWiresharkManager()
try:
await manager.stop_wireshark_session(
project_id=args.project_id,
link_id=args.link_id
)
await manager.stop_wireshark_session(project_id=args.project_id, link_id=args.link_id)
print(json.dumps({"status": "stopped"}))
return 0
except Exception as e:
@ -172,7 +166,7 @@ async def cmd_restart(args) -> int:
project_id=args.project_id,
link_id=args.link_id,
jwt_token=args.jwt_token,
capture_stream_url=args.capture_url
capture_stream_url=args.capture_url,
)
print(json.dumps(result, indent=2))
return 0
@ -274,14 +268,8 @@ async def cmd_delete(args) -> int:
def create_parser() -> argparse.ArgumentParser:
"""Create command line argument parser."""
parser = argparse.ArgumentParser(
description="Web Wireshark container management"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose logging"
)
parser = argparse.ArgumentParser(description="Web Wireshark container management")
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
subparsers = parser.add_subparsers(dest="command", help="Commands")
@ -290,32 +278,13 @@ def create_parser() -> argparse.ArgumentParser:
start_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
start_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID")
start_parser.add_argument("--jwt-token", required=True, help="JWT token")
start_parser.add_argument("--capture-url", help="Capture stream URL (auto-detected if not provided)")
start_parser.add_argument(
"--capture-url",
help="Capture stream URL (auto-detected if not provided)"
)
start_parser.add_argument(
"--image",
default="gns3/web-wireshark:latest",
help="Docker image (default: gns3/web-wireshark:latest)"
)
start_parser.add_argument(
"--memory",
default="2g",
help="Memory limit (default: 2g)"
)
start_parser.add_argument(
"--cpus",
type=float,
default=1.0,
help="CPU cores (default: 1.0)"
)
start_parser.add_argument(
"--pids-limit",
type=int,
default=1000,
help="Process limit (default: 1000)"
"--image", default="gns3/web-wireshark:latest", help="Docker image (default: gns3/web-wireshark:latest)"
)
start_parser.add_argument("--memory", default="2g", help="Memory limit (default: 2g)")
start_parser.add_argument("--cpus", type=float, default=1.0, help="CPU cores (default: 1.0)")
start_parser.add_argument("--pids-limit", type=int, default=1000, help="Process limit (default: 1000)")
# Stop command
stop_parser = subparsers.add_parser("stop", help="Stop Web Wireshark session")
@ -327,10 +296,7 @@ def create_parser() -> argparse.ArgumentParser:
restart_parser.add_argument("--project-id", required=True, type=validate_uuid, help="Project ID")
restart_parser.add_argument("--link-id", required=True, type=validate_uuid, help="Link ID")
restart_parser.add_argument("--jwt-token", required=True, help="JWT token")
restart_parser.add_argument(
"--capture-url",
help="Capture stream URL (auto-detected if not provided)"
)
restart_parser.add_argument("--capture-url", help="Capture stream URL (auto-detected if not provided)")
# Stop all command
stop_all_parser = subparsers.add_parser("stop-all", help="Stop all sessions")

View File

@ -63,15 +63,16 @@ class WebWiresharkManager:
"""
try:
proc = await asyncio.create_subprocess_exec(
"docker", "exec", container_id,
"bash", "-c", "echo 'ping'",
"docker",
"exec",
container_id,
"bash",
"-c",
"echo 'ping'",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, _ = await asyncio.wait_for(
proc.communicate(),
timeout=self.CONTAINER_EXEC_TIMEOUT
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=self.CONTAINER_EXEC_TIMEOUT)
return proc.returncode == 0 and b"ping" in stdout
except asyncio.TimeoutError:
logger.warning(f"Container {container_id[:12]} health check timeout")
@ -99,16 +100,17 @@ class WebWiresharkManager:
try:
proc = await asyncio.create_subprocess_exec(
"docker", "exec", container_id,
"bash", "-c", command,
"docker",
"exec",
container_id,
"bash",
"-c",
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(),
timeout=timeout
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return (proc.returncode, stdout.decode().strip(), stderr.decode().strip())
except asyncio.TimeoutError:
logger.error(f"Command timeout after {timeout}s: {command}")
@ -142,7 +144,7 @@ class WebWiresharkManager:
logger.debug(f"No processes found matching pattern '{pattern}'")
return
pids = pids_str.replace('\n', ' ')
pids = pids_str.replace("\n", " ")
logger.info(f"Found matching processes for '{pattern}': PIDs={pids}")
# Kill the processes using their container-local PIDs
@ -170,10 +172,13 @@ class WebWiresharkManager:
try:
# Get container init PID from host perspective
proc = await asyncio.create_subprocess_exec(
"docker", "inspect", container_id,
"--format", "{{.State.Pid}}",
"docker",
"inspect",
container_id,
"--format",
"{{.State.Pid}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
container_init_pid = stdout.decode().strip()
@ -187,15 +192,13 @@ class WebWiresharkManager:
# Check for residual processes from host perspective
proc = await asyncio.create_subprocess_exec(
"ps", "-eo", "pid,ppid,args",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
"ps", "-eo", "pid,ppid,args", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, _ = await proc.communicate()
# Build parent->children mapping
children_map = {}
for line in stdout.decode().strip().split('\n'):
for line in stdout.decode().strip().split("\n"):
parts = line.split(None, 2)
if len(parts) < 3:
continue
@ -218,14 +221,15 @@ class WebWiresharkManager:
# Check if any descendant processes match display patterns
import re
patterns = [
f'xpra.*:{display}',
f'Xvfb.*:{display}',
f'wireshark.*:{display}',
f'pulseaudio.*display=:{display}'
f"xpra.*:{display}",
f"Xvfb.*:{display}",
f"wireshark.*:{display}",
f"pulseaudio.*display=:{display}",
]
for line in stdout.decode().strip().split('\n'):
for line in stdout.decode().strip().split("\n"):
parts = line.split(None, 2)
if len(parts) < 3:
continue
@ -248,13 +252,11 @@ class WebWiresharkManager:
# /proc/<pid>/root points to container filesystem
lock_paths = [
f"/proc/{container_init_pid}/root/tmp/.X{display}-lock",
f"/proc/{container_init_pid}/root/tmp/.X11-unix/X{display}"
f"/proc/{container_init_pid}/root/tmp/.X11-unix/X{display}",
]
for lock_path in lock_paths:
proc = await asyncio.create_subprocess_exec(
"test", "-e", lock_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
"test", "-e", lock_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
if proc.returncode == 0:
@ -264,9 +266,7 @@ class WebWiresharkManager:
# Check for xpra socket files
socket_path = f"/proc/{container_init_pid}/root/run/user/1000/xpra/{display}/socket"
proc = await asyncio.create_subprocess_exec(
"test", "-e", socket_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
"test", "-e", socket_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
if proc.returncode == 0:
@ -298,10 +298,13 @@ class WebWiresharkManager:
try:
# Get container init PID from host perspective
proc = await asyncio.create_subprocess_exec(
"docker", "inspect", container_id,
"--format", "{{.State.Pid}}",
"docker",
"inspect",
container_id,
"--format",
"{{.State.Pid}}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
container_init_pid = stdout.decode().strip()
@ -314,9 +317,7 @@ class WebWiresharkManager:
# List all processes from host perspective with PID, PPID, and command
# This is faster than docker exec and allows us to walk the process tree
proc = await asyncio.create_subprocess_exec(
"ps", "-eo", "pid,ppid,args",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
"ps", "-eo", "pid,ppid,args", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
stdout, _ = await proc.communicate()
@ -328,7 +329,7 @@ class WebWiresharkManager:
children_map = {} # ppid -> [pid]
process_info = {} # pid -> (ppid, command)
for line in stdout.decode().strip().split('\n'):
for line in stdout.decode().strip().split("\n"):
parts = line.split(None, 2)
if len(parts) < 3:
continue
@ -371,9 +372,7 @@ class WebWiresharkManager:
logger.info(f"Killing {len(matching_pids)} processes: {matching_pids}")
# Kill all matching PIDs from host perspective
proc = await asyncio.create_subprocess_exec(
"kill", "-9", *matching_pids,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
"kill", "-9", *matching_pids, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
logger.debug("Kill completed from host perspective")
@ -396,16 +395,12 @@ class WebWiresharkManager:
"""
try:
# Combine all patterns into a single regex using OR operator
combined_pattern = '|'.join(f'({pattern})' for pattern in patterns)
combined_pattern = "|".join(f"({pattern})" for pattern in patterns)
# Single pgrep to find all matching processes
pgrep_cmd = f'pids=$(pgrep -f "{combined_pattern}" 2>/dev/null || true); if [ -n "$pids" ]; then echo "Found processes: $pids"; kill -9 $pids 2>/dev/null || true; fi'
returncode, stdout, stderr = await self._exec_in_container(
container_id,
pgrep_cmd,
timeout=5
)
returncode, stdout, stderr = await self._exec_in_container(container_id, pgrep_cmd, timeout=5)
if "Found processes:" in stdout:
logger.info(stdout.strip())
@ -422,10 +417,12 @@ class WebWiresharkManager:
"""
# Clean up X lock files and xpra socket files in a single docker exec
# This reduces docker exec calls from 2 to 1, improving stop performance
cmd = (f"rm -f /tmp/.X{display}-lock /tmp/.X11-unix/X{display} "
f"/run/user/1000/xpra/{display}/socket "
f"/run/user/1000/xpra/*-{display} "
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true")
cmd = (
f"rm -f /tmp/.X{display}-lock /tmp/.X11-unix/X{display} "
f"/run/user/1000/xpra/{display}/socket "
f"/run/user/1000/xpra/*-{display} "
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true"
)
returncode, stdout, stderr = await self._exec_in_container(container_id, cmd)
logger.debug(f"Cleanup X locks and xpra sockets: returncode={returncode}")
@ -436,9 +433,11 @@ class WebWiresharkManager:
container_id: Container ID
display: Display number (e.g., 10210)
"""
cmd = (f"rm -f /run/user/1000/xpra/{display}/socket "
f"/run/user/1000/xpra/*-{display} "
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true")
cmd = (
f"rm -f /run/user/1000/xpra/{display}/socket "
f"/run/user/1000/xpra/*-{display} "
f"/home/gns3/.xpra/*-{display} 2>/dev/null || true"
)
returncode, stdout, stderr = await self._exec_in_container(container_id, cmd)
logger.debug(f"Cleanup socket files: returncode={returncode}")
@ -457,20 +456,15 @@ class WebWiresharkManager:
return 0
# Extract number and unit
match = re.match(r'(\d+(?:\.\d+)?)\s*([kmg]b?)?', memory_str)
match = re.match(r"(\d+(?:\.\d+)?)\s*([kmg]b?)?", memory_str)
if not match:
raise ValueError(f"Invalid memory format: {memory_str}")
value = float(match.group(1))
unit = match.group(2) or 'b'
unit = match.group(2) or "b"
# Convert to bytes
multipliers = {
'b': 1,
'k': 1024,
'm': 1024 * 1024,
'g': 1024 * 1024 * 1024
}
multipliers = {"b": 1, "k": 1024, "m": 1024 * 1024, "g": 1024 * 1024 * 1024}
unit = unit[0] # Take first character
return int(value * multipliers.get(unit, 1))
@ -511,8 +505,7 @@ class WebWiresharkManager:
if container_id:
try:
returncode, stdout, stderr = await self._exec_in_container(
container_id,
"cat /proc/net/route | grep -E '^eth0\\s+00000000' | awk '{print $3}' | head -1"
container_id, "cat /proc/net/route | grep -E '^eth0\\s+00000000' | awk '{print $3}' | head -1"
)
logger.info(f"Gateway detection - fallback method: returncode={returncode}, stdout='{stdout}'")
if returncode == 0 and stdout.strip():
@ -557,8 +550,7 @@ class WebWiresharkManager:
# Use 'hostname -I' to get all IP addresses and take the first one
# This works on most Linux systems and is more portable than 'ip' command
returncode, stdout, stderr = await self._exec_in_container(
container_id,
"hostname -I 2>/dev/null | awk '{print $1}'"
container_id, "hostname -I 2>/dev/null | awk '{print $1}'"
)
if returncode == 0 and stdout.strip():
container_ip = stdout.strip()
@ -585,7 +577,7 @@ class WebWiresharkManager:
"""
try:
parsed = urlparse(url)
if parsed.hostname in ('localhost', '127.0.0.1', '0.0.0.0'):
if parsed.hostname in ("localhost", "127.0.0.1", "0.0.0.0"):
gateway = await self._get_container_gateway_ip(container_id)
if gateway:
fixed_url = f"{parsed.scheme}://{gateway}:{parsed.port or 3080}{parsed.path}"
@ -627,11 +619,7 @@ class WebWiresharkManager:
config = Config.instance()
subnet = getattr(config.settings.WebWireshark, "network_subnet", "172.31.0.0/22")
logger.info(f"Creating network {self.network_name} with subnet {subnet}")
await self.docker.create_network(
self.network_name,
driver="bridge",
subnet=subnet
)
await self.docker.create_network(self.network_name, driver="bridge", subnet=subnet)
async def get_or_create_container(
self,
@ -640,7 +628,7 @@ class WebWiresharkManager:
memory: str = "2g",
memory_swap: str = None,
cpus: float = 1.0,
pids_limit: int = 1000
pids_limit: int = 1000,
) -> str:
"""Get or create project's Web Wireshark container.
@ -716,21 +704,15 @@ class WebWiresharkManager:
"PidsLimit": pids_limit,
"RestartPolicy": {"Name": "unless-stopped"},
"Init": True, # Use init system (tini) as PID 1 to reap zombie processes
"LogConfig": {
"Type": "json-file",
"Config": {
"max-size": "10m",
"max-file": "3"
}
}
"LogConfig": {"Type": "json-file", "Config": {"max-size": "10m", "max-file": "3"}},
}
# Health check configuration
health_config = {
"Test": ["CMD-SHELL", "xpra list"],
"Interval": 30000000000, # 30 seconds (nanoseconds)
"Timeout": 10000000000, # 10 seconds
"Retries": 3
"Timeout": 10000000000, # 10 seconds
"Retries": 3,
}
try:
@ -740,11 +722,7 @@ class WebWiresharkManager:
network=self.network_name,
host_config=host_config,
health_config=health_config,
environment={
"XDG_RUNTIME_DIR": "/run/user/1000",
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8"
}
environment={"XDG_RUNTIME_DIR": "/run/user/1000", "LANG": "C.UTF-8", "LC_ALL": "C.UTF-8"},
)
except RuntimeError as e:
error_msg = str(e)
@ -773,7 +751,7 @@ class WebWiresharkManager:
memory: str = "2g",
memory_swap: Optional[str] = None,
cpus: float = 1.0,
pids_limit: int = 1000
pids_limit: int = 1000,
) -> dict:
"""Start Web Wireshark session.
@ -799,20 +777,10 @@ class WebWiresharkManager:
# Auto-detect capture_stream_url if not provided
if not capture_stream_url:
gns3_url = self.detect_gns3_url()
capture_stream_url = (
f"{gns3_url}/v3/projects/{project_id}/links/"
f"{link_id}/capture/stream"
)
capture_stream_url = f"{gns3_url}/v3/projects/{project_id}/links/{link_id}/capture/stream"
logger.info(f"Auto-detected capture stream URL: {capture_stream_url}")
container_id = await self.get_or_create_container(
project_id,
image,
memory,
memory_swap,
cpus,
pids_limit
)
container_id = await self.get_or_create_container(project_id, image, memory, memory_swap, cpus, pids_limit)
container_name = f"gns3-wireshark-{project_id}"
# Fix localhost URL after we have container_id
@ -828,13 +796,15 @@ class WebWiresharkManager:
has_process_residuals, has_socket_residuals = await self._check_residuals_exist(container_id, display)
if has_process_residuals or has_socket_residuals:
logger.info(f"Found residual processes={has_process_residuals} sockets={has_socket_residuals} on display :{display}, cleaning up...")
logger.info(
f"Found residual processes={has_process_residuals} sockets={has_socket_residuals} on display :{display}, cleaning up..."
)
if has_process_residuals:
patterns = [
f'xpra.*:{display}',
f'Xvfb.*:{display}',
f'wireshark.*:{display}',
f'pulseaudio.*display=:{display}'
f"xpra.*:{display}",
f"Xvfb.*:{display}",
f"wireshark.*:{display}",
f"pulseaudio.*display=:{display}",
]
await self._kill_process_tree_batch(container_id, patterns)
if has_socket_residuals:
@ -848,29 +818,24 @@ class WebWiresharkManager:
xpra_cmd = [
"XPRA_CLIENT_CAN_SHUTDOWN=false",
"xpra", "start", f":{display}",
"xpra",
"start",
f":{display}",
'--xvfb="Xvfb -screen 0 1920x1080x24 +extension RANDR"',
"--html=off",
f"--bind-ws=0.0.0.0:{port}",
f"--session-name={session_name}",
"--daemon=yes",
"--dbus-launch=no",
"--resize-display=yes"
"--resize-display=yes",
]
# Parallel execution: get container info + start xpra
container_info_task = asyncio.create_task(
self.docker.get_container(container_name)
)
xpra_start_task = asyncio.create_task(
self._exec_in_container(container_id, " ".join(xpra_cmd))
)
container_info_task = asyncio.create_task(self.docker.get_container(container_name))
xpra_start_task = asyncio.create_task(self._exec_in_container(container_id, " ".join(xpra_cmd)))
# Wait for both tasks to complete
container, (returncode, stdout, stderr) = await asyncio.gather(
container_info_task,
xpra_start_task
)
container, (returncode, stdout, stderr) = await asyncio.gather(container_info_task, xpra_start_task)
# Check xpra start result
if returncode != 0:
@ -901,10 +866,14 @@ class WebWiresharkManager:
# Execute Wireshark command without waiting for completion
await asyncio.create_subprocess_exec(
"docker", "exec", container_id,
"bash", "-c", wireshark_cmd,
"docker",
"exec",
container_id,
"bash",
"-c",
wireshark_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stderr=asyncio.subprocess.PIPE,
)
# Don't wait - Wireshark runs in background
@ -918,7 +887,7 @@ class WebWiresharkManager:
"container_name": container_name,
"container_id": container_id,
"session_name": session_name,
"capture_stream_url": capture_stream_url
"capture_stream_url": capture_stream_url,
}
logger.info(f"Web Wireshark session started successfully: {result['ws_url']}")
@ -947,11 +916,11 @@ class WebWiresharkManager:
# This ensures child processes are properly terminated, not left as zombies
logger.info(f"Stopping all processes on display :{display}")
patterns = [
f'xpra.*:{display}',
f'Xvfb.*:{display}',
f'Xvfb-for-Xpra-{display}',
f'wireshark.*:{display}',
f'pulseaudio.*display=:{display}'
f"xpra.*:{display}",
f"Xvfb.*:{display}",
f"Xvfb-for-Xpra-{display}",
f"wireshark.*:{display}",
f"pulseaudio.*display=:{display}",
]
await self._kill_process_tree_batch(container["Id"], patterns)
await self._cleanup_x_lock(container["Id"], display)
@ -971,7 +940,7 @@ class WebWiresharkManager:
memory: str = "2g",
memory_swap: str = None,
cpus: float = 1.0,
pids_limit: int = 1000
pids_limit: int = 1000,
):
"""Restart Web Wireshark session.
@ -1001,7 +970,7 @@ class WebWiresharkManager:
memory=memory,
memory_swap=memory_swap,
cpus=cpus,
pids_limit=pids_limit
pids_limit=pids_limit,
)
async def stop_all_sessions(self, project_id: str):
@ -1021,12 +990,7 @@ class WebWiresharkManager:
# Kill all wireshark, xpra and Xvfb processes for link sessions
# This ensures clean removal of all session processes
patterns = [
"xpra.*--session-name=link-",
"Xvfb-for-Xpra-",
"wireshark.*display :",
"pulseaudio.*display :"
]
patterns = ["xpra.*--session-name=link-", "Xvfb-for-Xpra-", "wireshark.*display :", "pulseaudio.*display :"]
await self._kill_process_tree_batch(container["Id"], patterns)
logger.info(f"All Web Wireshark sessions stopped for project {project_id}")

View File

@ -62,11 +62,7 @@ def check_docker():
# Check if Docker daemon is running
try:
result = subprocess.run(
["docker", "info"],
capture_output=True,
text=True
)
result = subprocess.run(["docker", "info"], capture_output=True, text=True)
if result.returncode != 0:
print("Error: Docker daemon is not running", file=sys.stderr)
print("Please start Docker and try again", file=sys.stderr)
@ -82,11 +78,7 @@ def image_exists():
"""Check if the Docker image already exists."""
try:
result = subprocess.run(
["docker", "image", "inspect", DOCKER_IMAGE],
capture_output=True,
text=True
)
result = subprocess.run(["docker", "image", "inspect", DOCKER_IMAGE], capture_output=True, text=True)
return result.returncode == 0
except Exception:
return False
@ -115,11 +107,7 @@ def pull_image():
print(f"Pulling Docker image: {DOCKER_IMAGE}")
print("-" * 60)
result = subprocess.run(
["docker", "pull", DOCKER_IMAGE],
capture_output=True,
text=True
)
result = subprocess.run(["docker", "pull", DOCKER_IMAGE], capture_output=True, text=True)
# Store output for network error detection
pull_output = result.stdout + result.stderr
@ -140,33 +128,17 @@ def build_image(dockerfile_path):
print("-" * 60)
result = subprocess.run(
["docker", "build", "-t", DOCKER_IMAGE, "-f", dockerfile_path, "."],
cwd=dockerfile_dir,
pass_fds=(1, 2)
["docker", "build", "-t", DOCKER_IMAGE, "-f", dockerfile_path, "."], cwd=dockerfile_dir, pass_fds=(1, 2)
)
return result.returncode == 0
def main():
parser = argparse.ArgumentParser(
description="Setup Web Wireshark Docker image for GNS3"
)
parser.add_argument(
"--force",
action="store_true",
help="Force rebuild even if image exists"
)
parser.add_argument(
"--build-only",
action="store_true",
help="Only build locally, skip pull"
)
parser.add_argument(
"--pull-only",
action="store_true",
help="Only pull from registry, skip build"
)
parser = argparse.ArgumentParser(description="Setup Web Wireshark Docker image for GNS3")
parser.add_argument("--force", action="store_true", help="Force rebuild even if image exists")
parser.add_argument("--build-only", action="store_true", help="Only build locally, skip pull")
parser.add_argument("--pull-only", action="store_true", help="Only pull from registry, skip build")
args = parser.parse_args()
@ -183,7 +155,7 @@ def main():
if image_exists() and not args.force:
print(f"Image {DOCKER_IMAGE} already exists.")
response = input("Do you want to rebuild it? [y/N]: ").strip().lower()
if response != 'y':
if response != "y":
print("Setup cancelled.")
sys.exit(0)
print()

View File

@ -30,12 +30,7 @@ async def collect_webwireshark_stats(projects: List) -> Dict:
"""
from .manager import WebWiresharkManager
stats = {
"total_containers": 0,
"running_containers": 0,
"active_sessions": 0,
"containers": []
}
stats = {"total_containers": 0, "running_containers": 0, "active_sessions": 0, "containers": []}
# Create a single manager instance and reuse it
manager = WebWiresharkManager()
@ -70,15 +65,14 @@ async def collect_webwireshark_stats(projects: List) -> Dict:
pids_limit = host_config.get("PidsLimit", 0)
# Count active capture sessions
active_sessions = sum(
1 for link in project.links.values()
if getattr(link, "capturing", False)
)
active_sessions = sum(1 for link in project.links.values() if getattr(link, "capturing", False))
stats["active_sessions"] += active_sessions
container_info["active_sessions"] = active_sessions
# Add resource limits
container_info["memory_limit"] = f"{memory_limit / (1024**3):.1f} GB" if memory_limit > 0 else "unlimited"
container_info["memory_limit"] = (
f"{memory_limit / (1024**3):.1f} GB" if memory_limit > 0 else "unlimited"
)
container_info["cpu_limit"] = f"{cpu_quota / 1000000000:.1f}" if cpu_quota > 0 else "unlimited"
container_info["pids_limit"] = pids_limit if pids_limit > 0 else "unlimited"
@ -111,21 +105,15 @@ async def _get_container_resource_stats(container_id: str) -> Optional[Dict]:
"""
try:
result = subprocess.run(
["docker", "stats", "--no-stream", "--format",
"{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}",
container_id],
["docker", "stats", "--no-stream", "--format", "{{.MemUsage}}\t{{.CPUPerc}}\t{{.PIDs}}", container_id],
capture_output=True,
text=True,
timeout=2
timeout=2,
)
if result.returncode == 0:
parts = result.stdout.strip().split("\t")
if len(parts) >= 3:
return {
"memory": parts[0],
"cpu": parts[1],
"pids": int(parts[2])
}
return {"memory": parts[0], "cpu": parts[1], "pids": int(parts[2])}
except subprocess.TimeoutExpired:
logger.debug(f"Docker stats timeout for container {container_id[:12]}")
except Exception as e:

View File

@ -155,113 +155,71 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
)
compute_api.include_router(
capabilities.router,
dependencies=[Depends(compute_authentication)],
tags=["Capabilities"]
)
compute_api.include_router(capabilities.router, dependencies=[Depends(compute_authentication)], tags=["Capabilities"])
compute_api.include_router(
compute.router,
dependencies=[Depends(compute_authentication)],
tags=["Compute"]
)
compute_api.include_router(compute.router, dependencies=[Depends(compute_authentication)], tags=["Compute"])
compute_api.include_router(
notifications.router,
tags=["Notifications"]
)
compute_api.include_router(notifications.router, tags=["Notifications"])
compute_api.include_router(
projects.router,
dependencies=[Depends(compute_authentication)],
tags=["Projects"]
)
compute_api.include_router(projects.router, dependencies=[Depends(compute_authentication)], tags=["Projects"])
compute_api.include_router(
images.router,
dependencies=[Depends(compute_authentication)],
tags=["Images"]
)
compute_api.include_router(images.router, dependencies=[Depends(compute_authentication)], tags=["Images"])
compute_api.include_router(
atm_switch_nodes.router,
dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/atm_switch/nodes",
tags=["ATM switch"]
tags=["ATM switch"],
)
compute_api.include_router(
cloud_nodes.router,
dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/cloud/nodes",
tags=["Cloud nodes"]
tags=["Cloud nodes"],
)
compute_api.include_router(
docker_nodes.router,
prefix="/projects/{project_id}/docker/nodes",
tags=["Docker nodes"]
)
compute_api.include_router(docker_nodes.router, prefix="/projects/{project_id}/docker/nodes", tags=["Docker nodes"])
compute_api.include_router(
dynamips_nodes.router,
prefix="/projects/{project_id}/dynamips/nodes",
tags=["Dynamips nodes"]
dynamips_nodes.router, prefix="/projects/{project_id}/dynamips/nodes", tags=["Dynamips nodes"]
)
compute_api.include_router(
ethernet_hub_nodes.router,
dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/ethernet_hub/nodes",
tags=["Ethernet hub nodes"]
tags=["Ethernet hub nodes"],
)
compute_api.include_router(
ethernet_switch_nodes.router,
dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/ethernet_switch/nodes",
tags=["Ethernet switch nodes"]
tags=["Ethernet switch nodes"],
)
compute_api.include_router(
frame_relay_switch_nodes.router,
dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/frame_relay_switch/nodes",
tags=["Frame Relay switch nodes"]
tags=["Frame Relay switch nodes"],
)
compute_api.include_router(
iou_nodes.router,
prefix="/projects/{project_id}/iou/nodes",
tags=["IOU nodes"])
compute_api.include_router(iou_nodes.router, prefix="/projects/{project_id}/iou/nodes", tags=["IOU nodes"])
compute_api.include_router(
nat_nodes.router,
dependencies=[Depends(compute_authentication)],
prefix="/projects/{project_id}/nat/nodes",
tags=["NAT nodes"]
tags=["NAT nodes"],
)
compute_api.include_router(
qemu_nodes.router,
prefix="/projects/{project_id}/qemu/nodes",
tags=["Qemu nodes"]
)
compute_api.include_router(qemu_nodes.router, prefix="/projects/{project_id}/qemu/nodes", tags=["Qemu nodes"])
compute_api.include_router(
virtualbox_nodes.router,
prefix="/projects/{project_id}/virtualbox/nodes",
tags=["VirtualBox nodes"]
virtualbox_nodes.router, prefix="/projects/{project_id}/virtualbox/nodes", tags=["VirtualBox nodes"]
)
compute_api.include_router(
vmware_nodes.router,
prefix="/projects/{project_id}/vmware/nodes",
tags=["VMware nodes"]
)
compute_api.include_router(vmware_nodes.router, prefix="/projects/{project_id}/vmware/nodes", tags=["VMware nodes"])
compute_api.include_router(
vpcs_nodes.router,
prefix="/projects/{project_id}/vpcs/nodes",
tags=["VPCS nodes"]
)
compute_api.include_router(vpcs_nodes.router, prefix="/projects/{project_id}/vpcs/nodes", tags=["VPCS nodes"])

View File

@ -79,8 +79,7 @@ def get_atm_switch(node: ATMSwitch = Depends(dep_node)) -> schemas.ATMSwitch:
@router.post("/{node_id}/duplicate", response_model=schemas.ATMSwitch, status_code=status.HTTP_201_CREATED)
async def duplicate_atm_switch(
destination_node_id: UUID = Body(..., embed=True),
node: ATMSwitch = Depends(dep_node)
destination_node_id: UUID = Body(..., embed=True), node: ATMSwitch = Depends(dep_node)
) -> schemas.ATMSwitch:
"""
Duplicate an ATM switch node.
@ -92,8 +91,7 @@ async def duplicate_atm_switch(
@router.put("/{node_id}", response_model=schemas.ATMSwitch)
async def update_atm_switch(
node_data: schemas.ATMSwitchUpdate,
node: ATMSwitch = Depends(dep_node)
node_data: schemas.ATMSwitchUpdate, node: ATMSwitch = Depends(dep_node)
) -> schemas.ATMSwitch:
"""
Update an ATM switch node.
@ -124,8 +122,7 @@ def start_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Start is not supported for ATM switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for ATM switches"
)
@ -135,10 +132,7 @@ def stop_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
Stop an ATM switch node.
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for ATM switches"
)
raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for ATM switches")
@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
@ -149,8 +143,7 @@ def suspend_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for ATM switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for ATM switches"
)
@ -160,11 +153,11 @@ def suspend_atm_switch(node: ATMSwitch = Depends(dep_node)) -> None:
response_model=schemas.UDPNIO,
)
async def create_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: ATMSwitch = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: ATMSwitch = Depends(dep_node),
) -> schemas.UDPNIO:
"""
Add a NIO (Network Input/Output) to the node.
@ -189,11 +182,11 @@ async def delete_nio(adapter_number: int, port_number: int, node: ATMSwitch = De
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
async def start_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: ATMSwitch = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: ATMSwitch = Depends(dep_node),
) -> dict:
"""
Start a packet capture on the node.
@ -210,10 +203,7 @@ async def start_capture(
status_code=status.HTTP_204_NO_CONTENT,
)
async def stop_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: ATMSwitch = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: ATMSwitch = Depends(dep_node)
) -> None:
"""
Stop a packet capture on the node.
@ -225,10 +215,7 @@ async def stop_capture(
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
async def stream_pcap_file(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: ATMSwitch = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: ATMSwitch = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.

View File

@ -122,10 +122,7 @@ async def stop_cloud(node: Cloud = Depends(dep_node)) -> None:
Stop a cloud node.
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for cloud nodes"
)
raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for cloud nodes")
@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
@ -136,8 +133,7 @@ async def suspend_cloud(node: Cloud = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for cloud nodes"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for cloud nodes"
)
@ -147,11 +143,11 @@ async def suspend_cloud(node: Cloud = Depends(dep_node)) -> None:
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
)
async def create_cloud_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Cloud = Depends(dep_node),
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Cloud = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
"""
Add a NIO (Network Input/Output) to the node.
@ -169,11 +165,11 @@ async def create_cloud_nio(
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
)
async def update_cloud_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Cloud = Depends(dep_node),
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Cloud = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
"""
Update a NIO (Network Input/Output) to the node.
@ -192,10 +188,7 @@ async def update_cloud_nio(
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
async def delete_cloud_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: Cloud = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Cloud = Depends(dep_node)
) -> None:
"""
Remove a NIO (Network Input/Output) from the node.
@ -207,11 +200,11 @@ async def delete_cloud_nio(
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
async def start_cloud_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: Cloud = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: Cloud = Depends(dep_node),
) -> dict:
"""
Start a packet capture on the node.
@ -227,10 +220,7 @@ async def start_cloud_capture(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
)
async def stop_cloud_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: Cloud = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Cloud = Depends(dep_node)
) -> None:
"""
Stop a packet capture on the node.
@ -242,10 +232,7 @@ async def stop_cloud_capture(
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
async def stream_pcap_file(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: Cloud = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Cloud = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.
@ -257,13 +244,9 @@ async def stream_pcap_file(
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.put(
"/{node_id}/markers/{marker_name}"
)
@router.put("/{node_id}/markers/{marker_name}")
async def toggle_cloud_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: Cloud = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: Cloud = Depends(dep_node)
) -> dict:
"""
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -278,19 +261,13 @@ async def toggle_cloud_marker(
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
@router.post(
"/{node_id}/markers/pause",
status_code=status.HTTP_204_NO_CONTENT
)
@router.post("/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT)
async def pause_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
await node._ubridge_marker_pause()
@router.post(
"/{node_id}/markers/resume",
status_code=status.HTTP_204_NO_CONTENT
)
@router.post("/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT)
async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
await node._ubridge_marker_resume()
@ -298,7 +275,7 @@ async def resume_cloud_markers(node: Cloud = Depends(dep_node)) -> None:
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_cloud_marker_capture(
*,
@ -306,7 +283,7 @@ async def delete_cloud_marker_capture(
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
link_id: str = "",
node: Cloud = Depends(dep_node)
node: Cloud = Depends(dep_node),
) -> None:
"""
Delete a marker's capture pcap (called by the controller when the marker is
@ -321,9 +298,7 @@ async def delete_cloud_marker_capture(
@router.put("/{node_id}/markers/{marker_name}/rebuild")
async def rebuild_cloud_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: Cloud = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: Cloud = Depends(dep_node)
) -> dict:
"""
Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -331,7 +306,11 @@ async def rebuild_cloud_marker(
"""
await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
return {"marker_name": marker_name}

View File

@ -32,7 +32,7 @@ security = HTTPBasic(auto_error=False)
def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends(security)) -> None:
"""
Authenticate compute requests.
Returns None if authentication is disabled or if authentication succeeds
Raises HTTPException if authentication is required but credentials are invalid
"""
@ -58,9 +58,9 @@ def compute_authentication(credentials: Optional[HTTPBasicCredentials] = Depends
headers={"WWW-Authenticate": "Basic"},
)
async def ws_compute_authentication(websocket: WebSocket) -> Union[None, WebSocket]:
"""
"""
""" """
server_settings = Config.instance().settings.Server

View File

@ -51,7 +51,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> DockerVM:
response_model=schemas.Docker,
status_code=status.HTTP_201_CREATED,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Docker node"}},
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate) -> schemas.Docker:
"""
@ -81,15 +81,29 @@ async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate)
extra_configs=node_data.get("extra_configs"),
memory=node_data.get("memory", 0),
cpus=node_data.get("cpus", 0),
image_digest=node_data.get("image_digest"),
)
# Pop keys already consumed by create_node above so the setattr
# fallback loop below only applies truly extra keys and does not
# re-trigger console/aux port setter logging.
for key in (
"console", "console_type", "console_resolution", "console_http_port",
"console_http_path", "aux", "aux_type", "start_command", "environment",
"adapters", "mac_address", "extra_hosts", "extra_volumes", "extra_configs",
"memory", "cpus",
"console",
"console_type",
"console_resolution",
"console_http_port",
"console_http_path",
"aux",
"aux_type",
"start_command",
"environment",
"adapters",
"mac_address",
"extra_hosts",
"extra_volumes",
"extra_configs",
"memory",
"cpus",
"image_digest",
):
node_data.pop(key, None)
for name, value in node_data.items():
@ -100,11 +114,7 @@ async def create_docker_node(project_id: UUID, node_data: schemas.DockerCreate)
return container.asdict()
@router.get(
"/{node_id}",
response_model=schemas.Docker,
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}", response_model=schemas.Docker, dependencies=[Depends(compute_authentication)])
def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker:
"""
Return a Docker node.
@ -113,11 +123,7 @@ def get_docker_node(node: DockerVM = Depends(dep_node)) -> schemas.Docker:
return node.asdict()
@router.put(
"/{node_id}",
response_model=schemas.Docker,
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}", response_model=schemas.Docker, dependencies=[Depends(compute_authentication)])
async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = Depends(dep_node)) -> schemas.Docker:
"""
Update a Docker node.
@ -140,6 +146,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 +154,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
@ -157,11 +165,7 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D
return node.asdict()
@router.post(
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None:
"""
Start a Docker node.
@ -170,11 +174,7 @@ async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None:
await node.start()
@router.post(
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def stop_docker_node(node: DockerVM = Depends(dep_node)) -> None:
"""
Stop a Docker node. This is the explicit user stop — the only path that
@ -186,9 +186,7 @@ async def stop_docker_node(node: DockerVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/suspend",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def suspend_docker_node(node: DockerVM = Depends(dep_node)) -> None:
"""
@ -199,9 +197,7 @@ async def suspend_docker_node(node: DockerVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/reload",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reload_docker_node(node: DockerVM = Depends(dep_node)) -> None:
"""
@ -211,11 +207,7 @@ async def reload_docker_node(node: DockerVM = Depends(dep_node)) -> None:
await node.restart()
@router.post(
"/{node_id}/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def pause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
"""
Pause a Docker node.
@ -225,9 +217,7 @@ async def pause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/unpause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/unpause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def unpause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
"""
@ -237,11 +227,7 @@ async def unpause_docker_node(node: DockerVM = Depends(dep_node)) -> None:
await node.unpause()
@router.delete(
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None:
"""
Delete a Docker node.
@ -255,11 +241,10 @@ async def delete_docker_node(node: DockerVM = Depends(dep_node)) -> None:
"/{node_id}/duplicate",
response_model=schemas.Docker,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def duplicate_docker_node(
destination_node_id: UUID = Body(..., embed=True),
node: DockerVM = Depends(dep_node)
destination_node_id: UUID = Body(..., embed=True), node: DockerVM = Depends(dep_node)
) -> schemas.Docker:
"""
Duplicate a Docker node.
@ -273,7 +258,7 @@ async def duplicate_docker_node(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_docker_node_nio(
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node)
@ -284,7 +269,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()
@ -292,7 +277,7 @@ async def create_docker_node_nio(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_docker_node_nio(
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node)
@ -302,42 +287,36 @@ 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 {}
await node.adapter_update_nio_binding(adapter_number, nio)
nio.suspend = nio_data.suspend
await node.adapter_update_nio_binding(adapter_number, nio, port_number)
return nio.asdict()
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_docker_node_nio(
adapter_number: int,
port_number: int,
node: DockerVM = Depends(dep_node)
) -> None:
async def delete_docker_node_nio(adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)) -> None:
"""
Delete a NIO (Network Input/Output) from the node.
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(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def start_docker_node_capture(
adapter_number: int,
port_number: int,
node_capture_data: schemas.NodeCapture,
node: DockerVM = Depends(dep_node)
adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: DockerVM = Depends(dep_node)
) -> dict:
"""
Start a packet capture on the node.
@ -345,51 +324,44 @@ 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)}
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stop_docker_node_capture(
adapter_number: int,
port_number: int,
node: DockerVM = Depends(dep_node)
) -> None:
async def stop_docker_node_capture(adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)) -> None:
"""
Stop a packet capture on the node.
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(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stream_pcap_file(
adapter_number: int,
port_number: int,
node: DockerVM = Depends(dep_node)
adapter_number: int, port_number: int, node: DockerVM = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture 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")
@router.websocket("/{node_id}/console/ws")
async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: DockerVM = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: DockerVM = Depends(dep_node)
) -> None:
"""
Console WebSocket.
@ -399,12 +371,9 @@ async def console_ws(
await node.start_websocket_console(websocket)
@router.websocket(
"/{node_id}/console/vnc"
)
@router.websocket("/{node_id}/console/vnc")
async def vnc_console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: DockerVM = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: DockerVM = Depends(dep_node)
) -> None:
"""
VNC Console WebSocket.
@ -415,23 +384,16 @@ async def vnc_console_ws(
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reset_console(node: DockerVM = Depends(dep_node)) -> None:
await node.reset_console()
@router.put(
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
async def toggle_docker_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: DockerVM = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: DockerVM = Depends(dep_node)
) -> dict:
"""
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -447,9 +409,7 @@ async def toggle_docker_marker(
@router.post(
"/{node_id}/markers/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
@ -457,9 +417,7 @@ async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/markers/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
@ -469,14 +427,10 @@ async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None:
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_docker_marker_capture(
marker_name: str,
adapter_number: int,
port_number: int,
link_id: str = "",
node: DockerVM = Depends(dep_node)
marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: DockerVM = Depends(dep_node)
) -> None:
"""
Delete a marker's capture pcap (called by the controller when the marker is
@ -489,14 +443,9 @@ async def delete_docker_marker_capture(
await node.delete_marker_capture(marker_name, link_id, nio)
@router.put(
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
async def rebuild_docker_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: DockerVM = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: DockerVM = Depends(dep_node)
) -> dict:
"""
Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -504,7 +453,11 @@ async def rebuild_docker_marker(
"""
await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
return {"marker_name": marker_name}

View File

@ -55,7 +55,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> Router:
response_model=schemas.Dynamips,
status_code=status.HTTP_201_CREATED,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Dynamips node"}},
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_router(project_id: UUID, node_data: schemas.DynamipsCreate) -> schemas.Dynamips:
"""
@ -86,11 +86,7 @@ async def create_router(project_id: UUID, node_data: schemas.DynamipsCreate) ->
return vm.asdict()
@router.get(
"/{node_id}",
response_model=schemas.Dynamips,
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}", response_model=schemas.Dynamips, dependencies=[Depends(compute_authentication)])
def get_router(node: Router = Depends(dep_node)) -> schemas.Dynamips:
"""
Return Dynamips router.
@ -99,11 +95,7 @@ def get_router(node: Router = Depends(dep_node)) -> schemas.Dynamips:
return node.asdict()
@router.put(
"/{node_id}",
response_model=schemas.Dynamips,
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}", response_model=schemas.Dynamips, dependencies=[Depends(compute_authentication)])
async def update_router(node_data: schemas.DynamipsUpdate, node: Router = Depends(dep_node)) -> schemas.Dynamips:
"""
Update a Dynamips router.
@ -114,11 +106,7 @@ async def update_router(node_data: schemas.DynamipsUpdate, node: Router = Depend
return node.asdict()
@router.delete(
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def delete_router(node: Router = Depends(dep_node)) -> None:
"""
Delete a Dynamips router.
@ -127,11 +115,7 @@ async def delete_router(node: Router = Depends(dep_node)) -> None:
await Dynamips.instance().delete_node(node.id)
@router.post(
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def start_router(node: Router = Depends(dep_node)) -> None:
"""
Start a Dynamips router.
@ -144,11 +128,7 @@ async def start_router(node: Router = Depends(dep_node)) -> None:
await node.start()
@router.post(
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def stop_router(node: Router = Depends(dep_node)) -> None:
"""
Stop a Dynamips router.
@ -158,9 +138,7 @@ async def stop_router(node: Router = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/suspend",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def suspend_router(node: Router = Depends(dep_node)) -> None:
@ -168,9 +146,7 @@ async def suspend_router(node: Router = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def resume_router(node: Router = Depends(dep_node)) -> None:
"""
@ -181,9 +157,7 @@ async def resume_router(node: Router = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/reload",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reload_router(node: Router = Depends(dep_node)) -> None:
"""
@ -197,13 +171,10 @@ async def reload_router(node: Router = Depends(dep_node)) -> None:
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_nio(
adapter_number: int,
port_number: int,
nio_data: schemas.UDPNIO,
node: Router = Depends(dep_node)
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node)
) -> schemas.UDPNIO:
"""
Add a NIO (Network Input/Output) to the node.
@ -218,13 +189,10 @@ async def create_nio(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_nio(
adapter_number: int,
port_number: int,
nio_data: schemas.UDPNIO,
node: Router = Depends(dep_node)
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node)
) -> schemas.UDPNIO:
"""
Update a NIO (Network Input/Output) on the node.
@ -242,7 +210,7 @@ async def update_nio(
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_nio(adapter_number: int, port_number: int, node: Router = Depends(dep_node)) -> None:
"""
@ -255,13 +223,10 @@ async def delete_nio(adapter_number: int, port_number: int, node: Router = Depen
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def start_capture(
adapter_number: int,
port_number: int,
node_capture_data: schemas.NodeCapture,
node: Router = Depends(dep_node)
adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: Router = Depends(dep_node)
) -> dict:
"""
Start a packet capture on the node.
@ -275,7 +240,7 @@ async def start_capture(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stop_capture(adapter_number: int, port_number: int, node: Router = Depends(dep_node)) -> None:
"""
@ -287,12 +252,10 @@ async def stop_capture(adapter_number: int, port_number: int, node: Router = Dep
@router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stream_pcap_file(
adapter_number: int,
port_number: int,
node: Router = Depends(dep_node)
adapter_number: int, port_number: int, node: Router = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.
@ -303,10 +266,7 @@ async def stream_pcap_file(
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.get(
"/{node_id}/idlepc_proposals",
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}/idlepc_proposals", dependencies=[Depends(compute_authentication)])
async def get_idlepcs(node: Router = Depends(dep_node)) -> List[str]:
"""
Retrieve Dynamips idle-pc proposals
@ -316,10 +276,7 @@ async def get_idlepcs(node: Router = Depends(dep_node)) -> List[str]:
return await node.get_idle_pc_prop()
@router.get(
"/{node_id}/auto_idlepc",
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}/auto_idlepc", dependencies=[Depends(compute_authentication)])
async def get_auto_idlepc(node: Router = Depends(dep_node)) -> dict:
"""
Get an automatically guessed best idle-pc value.
@ -333,9 +290,11 @@ async def get_auto_idlepc(node: Router = Depends(dep_node)) -> dict:
"/{node_id}/duplicate",
response_model=schemas.Dynamips,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def duplicate_router(destination_node_id: UUID = Body(..., embed=True), node: Router = Depends(dep_node)) -> schemas.Dynamips:
async def duplicate_router(
destination_node_id: UUID = Body(..., embed=True), node: Router = Depends(dep_node)
) -> schemas.Dynamips:
"""
Duplicate a router.
"""
@ -346,9 +305,7 @@ async def duplicate_router(destination_node_id: UUID = Body(..., embed=True), no
@router.websocket("/{node_id}/console/ws")
async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: Router = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: Router = Depends(dep_node)
) -> None:
"""
Console WebSocket.
@ -359,23 +316,16 @@ async def console_ws(
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reset_console(node: Router = Depends(dep_node)) -> None:
await node.reset_console()
@router.put(
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
async def toggle_dynamips_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: Router = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: Router = Depends(dep_node)
) -> dict:
"""
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -391,9 +341,7 @@ async def toggle_dynamips_marker(
@router.post(
"/{node_id}/markers/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None:
@ -401,9 +349,7 @@ async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/markers/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None:
@ -413,14 +359,10 @@ async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None:
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_dynamips_marker_capture(
marker_name: str,
adapter_number: int,
port_number: int,
link_id: str = "",
node: Router = Depends(dep_node)
marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: Router = Depends(dep_node)
) -> None:
"""
Delete a marker's capture pcap (called by the controller when the marker is
@ -433,14 +375,9 @@ async def delete_dynamips_marker_capture(
await node.delete_marker_capture(marker_name, link_id, nio)
@router.put(
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
async def rebuild_dynamips_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: Router = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: Router = Depends(dep_node)
) -> dict:
"""
Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -448,7 +385,11 @@ async def rebuild_dynamips_marker(
"""
await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
return {"marker_name": marker_name}

View File

@ -91,8 +91,7 @@ async def duplicate_ethernet_hub(
@router.put("/{node_id}", response_model=schemas.EthernetHub)
async def update_ethernet_hub(
node_data: schemas.EthernetHubUpdate,
node: EthernetHub = Depends(dep_node)
node_data: schemas.EthernetHubUpdate, node: EthernetHub = Depends(dep_node)
) -> schemas.EthernetHub:
"""
Update an Ethernet hub.
@ -124,8 +123,7 @@ def start_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Start is not supported for Ethernet hubs"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Ethernet hubs"
)
@ -136,8 +134,7 @@ def stop_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for Ethernet hubs"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Ethernet hubs"
)
@ -148,8 +145,7 @@ def suspend_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for Ethernet hubs"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Ethernet hubs"
)
@ -159,11 +155,11 @@ def suspend_ethernet_hub(node: EthernetHub = Depends(dep_node)) -> None:
response_model=schemas.UDPNIO,
)
async def create_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetHub = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetHub = Depends(dep_node),
) -> schemas.UDPNIO:
"""
Add a NIO (Network Input/Output) to the node.
@ -177,10 +173,7 @@ async def create_nio(
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
async def delete_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: EthernetHub = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetHub = Depends(dep_node)
) -> None:
"""
Delete a NIO (Network Input/Output) from the node.
@ -193,11 +186,11 @@ async def delete_nio(
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
async def start_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: EthernetHub = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: EthernetHub = Depends(dep_node),
) -> dict:
"""
Start a packet capture on the node.
@ -213,10 +206,7 @@ async def start_capture(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
)
async def stop_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: EthernetHub = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetHub = Depends(dep_node)
) -> None:
"""
Stop a packet capture on the node.
@ -228,10 +218,7 @@ async def stop_capture(
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
async def stream_pcap_file(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: EthernetHub = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetHub = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.

View File

@ -82,8 +82,7 @@ def get_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> schemas.Eth
@router.post("/{node_id}/duplicate", response_model=schemas.EthernetSwitch, status_code=status.HTTP_201_CREATED)
async def duplicate_ethernet_switch(
destination_node_id: UUID = Body(..., embed=True),
node: EthernetSwitch = Depends(dep_node)
destination_node_id: UUID = Body(..., embed=True), node: EthernetSwitch = Depends(dep_node)
) -> schemas.EthernetSwitch:
"""
Duplicate an Ethernet switch.
@ -95,8 +94,7 @@ async def duplicate_ethernet_switch(
@router.put("/{node_id}", response_model=schemas.EthernetSwitch)
async def update_ethernet_switch(
node_data: schemas.EthernetSwitchUpdate,
node: EthernetSwitch = Depends(dep_node)
node_data: schemas.EthernetSwitchUpdate, node: EthernetSwitch = Depends(dep_node)
) -> schemas.EthernetSwitch:
"""
Update an Ethernet switch.
@ -132,8 +130,7 @@ def start_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Start is not supported for Ethernet switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Ethernet switches"
)
@ -144,8 +141,7 @@ def stop_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for Ethernet switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Ethernet switches"
)
@ -156,8 +152,7 @@ def suspend_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for Ethernet switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Ethernet switches"
)
@ -169,8 +164,7 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Reload is not supported for Ethernet switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Reload is not supported for Ethernet switches"
)
@ -180,11 +174,11 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
response_model=schemas.UDPNIO,
)
async def create_ethernet_switch_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetSwitch = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetSwitch = Depends(dep_node),
) -> schemas.UDPNIO:
nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
@ -198,11 +192,11 @@ async def create_ethernet_switch_nio(
response_model=schemas.UDPNIO,
)
async def update_ethernet_switch_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetSwitch = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: EthernetSwitch = Depends(dep_node),
) -> schemas.UDPNIO:
"""
Update a NIO (Network Input/Output) on the node: re-apply the packet
@ -221,10 +215,7 @@ async def update_ethernet_switch_nio(
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
async def delete_ethernet_switch_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: EthernetSwitch = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetSwitch = Depends(dep_node)
) -> None:
"""
Delete a NIO (Network Input/Output) from the node.
@ -236,11 +227,11 @@ async def delete_ethernet_switch_nio(
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
async def start_ethernet_switch_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: EthernetSwitch = Depends(dep_node),
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: EthernetSwitch = Depends(dep_node),
) -> dict:
"""
Start a packet capture on the node.
@ -256,10 +247,7 @@ async def start_ethernet_switch_capture(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
)
async def stop_ethernet_switch_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: EthernetSwitch = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetSwitch = Depends(dep_node)
) -> None:
"""
Stop a packet capture on the node.
@ -271,10 +259,7 @@ async def stop_ethernet_switch_capture(
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
async def stream_pcap_file(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: EthernetSwitch = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: EthernetSwitch = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.
@ -288,9 +273,7 @@ async def stream_pcap_file(
@router.put("/{node_id}/markers/{marker_name}")
async def toggle_ethernet_switch_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: EthernetSwitch = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: EthernetSwitch = Depends(dep_node)
) -> dict:
"""
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -322,12 +305,12 @@ async def resume_ethernet_switch_markers(node: EthernetSwitch = Depends(dep_node
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_ethernet_switch_marker_capture(
*,
marker_name: str,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
link_id: str = "",
node: EthernetSwitch = Depends(dep_node)
*,
marker_name: str,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
link_id: str = "",
node: EthernetSwitch = Depends(dep_node),
) -> None:
"""
Delete a marker's capture pcap (called by the controller when the marker is
@ -342,9 +325,7 @@ async def delete_ethernet_switch_marker_capture(
@router.put("/{node_id}/markers/{marker_name}/rebuild")
async def rebuild_ethernet_switch_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: EthernetSwitch = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: EthernetSwitch = Depends(dep_node)
) -> dict:
"""
Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -352,7 +333,11 @@ async def rebuild_ethernet_switch_marker(
"""
await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
return {"marker_name": marker_name}

View File

@ -51,8 +51,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> FrameRelaySwitch:
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Frame Relay switch node"}},
)
async def create_frame_relay_switch(
project_id: UUID,
node_data: schemas.FrameRelaySwitchCreate
project_id: UUID, node_data: schemas.FrameRelaySwitchCreate
) -> schemas.FrameRelaySwitch:
"""
Create a new Frame Relay switch node.
@ -82,8 +81,7 @@ def get_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> schema
@router.post("/{node_id}/duplicate", response_model=schemas.FrameRelaySwitch, status_code=status.HTTP_201_CREATED)
async def duplicate_frame_relay_switch(
destination_node_id: UUID = Body(..., embed=True),
node: FrameRelaySwitch = Depends(dep_node)
destination_node_id: UUID = Body(..., embed=True), node: FrameRelaySwitch = Depends(dep_node)
) -> schemas.FrameRelaySwitch:
"""
Duplicate a Frame Relay switch node.
@ -95,8 +93,7 @@ async def duplicate_frame_relay_switch(
@router.put("/{node_id}", response_model=schemas.FrameRelaySwitch)
async def update_frame_relay_switch(
node_data: schemas.FrameRelaySwitchUpdate,
node: FrameRelaySwitch = Depends(dep_node)
node_data: schemas.FrameRelaySwitchUpdate, node: FrameRelaySwitch = Depends(dep_node)
) -> schemas.FrameRelaySwitch:
"""
Update an Frame Relay switch node.
@ -127,8 +124,7 @@ def start_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> None
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Start is not supported for Frame Relay switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Start is not supported for Frame Relay switches"
)
@ -139,8 +135,7 @@ def stop_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> None:
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for Frame Relay switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for Frame Relay switches"
)
@ -151,8 +146,7 @@ def suspend_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> No
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for Frame Relay switches"
status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for Frame Relay switches"
)
@ -162,11 +156,11 @@ def suspend_frame_relay_switch(node: FrameRelaySwitch = Depends(dep_node)) -> No
response_model=schemas.UDPNIO,
)
async def create_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: FrameRelaySwitch = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
node: FrameRelaySwitch = Depends(dep_node),
) -> schemas.UDPNIO:
"""
Add a NIO (Network Input/Output) to the node.
@ -180,10 +174,7 @@ async def create_nio(
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
async def delete_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: FrameRelaySwitch = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: FrameRelaySwitch = Depends(dep_node)
) -> None:
"""
Remove a NIO (Network Input/Output) from the node.
@ -196,11 +187,11 @@ async def delete_nio(
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
async def start_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: FrameRelaySwitch = Depends(dep_node),
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: FrameRelaySwitch = Depends(dep_node),
) -> dict:
"""
Start a packet capture on the node.
@ -216,10 +207,7 @@ async def start_capture(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
)
async def stop_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: FrameRelaySwitch = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: FrameRelaySwitch = Depends(dep_node)
) -> None:
"""
Stop a packet capture on the node.
@ -231,10 +219,7 @@ async def stop_capture(
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
async def stream_pcap_file(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: FrameRelaySwitch = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: FrameRelaySwitch = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.

View File

@ -53,6 +53,16 @@ async def pull_docker_image(image: str = Body(..., embed=True, min_length=1, pat
await docker_manager.pull_image(image, force=True)
@router.post("/docker/images/load", status_code=status.HTTP_204_NO_CONTENT)
async def load_docker_image(request: Request) -> None:
"""
Load a Docker image into the Docker daemon from a docker save tar stream.
"""
docker_manager = Docker.instance()
await docker_manager.load_image(request.stream())
@router.get("/dynamips/images")
async def get_dynamips_images() -> List[dict]:
"""

View File

@ -52,7 +52,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> IOUVM:
response_model=schemas.IOU,
status_code=status.HTTP_201_CREATED,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create IOU node"}},
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> schemas.IOU:
"""
@ -85,11 +85,7 @@ async def create_iou_node(project_id: UUID, node_data: schemas.IOUCreate) -> sch
return vm.asdict()
@router.get(
"/{node_id}",
response_model=schemas.IOU,
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}", response_model=schemas.IOU, dependencies=[Depends(compute_authentication)])
def get_iou_node(node: IOUVM = Depends(dep_node)) -> schemas.IOU:
"""
Return an IOU node.
@ -98,11 +94,7 @@ def get_iou_node(node: IOUVM = Depends(dep_node)) -> schemas.IOU:
return node.asdict()
@router.put(
"/{node_id}",
response_model=schemas.IOU,
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}", response_model=schemas.IOU, dependencies=[Depends(compute_authentication)])
async def update_iou_node(node_data: schemas.IOUUpdate, node: IOUVM = Depends(dep_node)) -> schemas.IOU:
"""
Update an IOU node.
@ -123,11 +115,7 @@ async def update_iou_node(node_data: schemas.IOUUpdate, node: IOUVM = Depends(de
return node.asdict()
@router.delete(
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def delete_iou_node(node: IOUVM = Depends(dep_node)) -> None:
"""
Delete an IOU node.
@ -140,11 +128,10 @@ async def delete_iou_node(node: IOUVM = Depends(dep_node)) -> None:
"/{node_id}/duplicate",
response_model=schemas.IOU,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def duplicate_iou_node(
destination_node_id: UUID = Body(..., embed=True),
node: IOUVM = Depends(dep_node)
destination_node_id: UUID = Body(..., embed=True), node: IOUVM = Depends(dep_node)
) -> schemas.IOU:
"""
Duplicate an IOU node.
@ -154,11 +141,7 @@ async def duplicate_iou_node(
return new_node.asdict()
@router.post(
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def start_iou_node(start_data: schemas.IOUStart, node: IOUVM = Depends(dep_node)) -> None:
"""
Start an IOU node.
@ -172,11 +155,7 @@ async def start_iou_node(start_data: schemas.IOUStart, node: IOUVM = Depends(dep
await node.start()
@router.post(
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def stop_iou_node(node: IOUVM = Depends(dep_node)) -> None:
"""
Stop an IOU node.
@ -185,25 +164,17 @@ async def stop_iou_node(node: IOUVM = Depends(dep_node)) -> None:
await node.stop()
@router.post(
"/{node_id}/suspend",
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/suspend", dependencies=[Depends(compute_authentication)])
def suspend_iou_node(node: IOUVM = Depends(dep_node)) -> None:
"""
Suspend an IOU node.
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for IOU nodes"
)
raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for IOU nodes")
@router.post(
"/{node_id}/reload",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None:
"""
@ -217,7 +188,7 @@ async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None:
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_iou_node_nio(
adapter_number: int,
@ -238,7 +209,7 @@ async def create_iou_node_nio(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_iou_node_nio(
adapter_number: int,
@ -263,7 +234,7 @@ async def update_iou_node_nio(
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_iou_node_nio(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> None:
"""
@ -275,13 +246,10 @@ async def delete_iou_node_nio(adapter_number: int, port_number: int, node: IOUVM
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def start_iou_node_capture(
adapter_number: int,
port_number: int,
node_capture_data: schemas.NodeCapture,
node: IOUVM = Depends(dep_node)
adapter_number: int, port_number: int, node_capture_data: schemas.NodeCapture, node: IOUVM = Depends(dep_node)
) -> dict:
"""
Start a packet capture on the node.
@ -295,7 +263,7 @@ async def start_iou_node_capture(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stop_iou_node_capture(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> None:
"""
@ -307,13 +275,9 @@ async def stop_iou_node_capture(adapter_number: int, port_number: int, node: IOU
@router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stream_pcap_file(
adapter_number: int,
port_number: int,
node: IOUVM = Depends(dep_node)
) -> StreamingResponse:
async def stream_pcap_file(adapter_number: int, port_number: int, node: IOUVM = Depends(dep_node)) -> StreamingResponse:
"""
Stream the pcap capture file.
"""
@ -327,8 +291,7 @@ async def stream_pcap_file(
"/{node_id}/console/ws",
)
async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: IOUVM = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: IOUVM = Depends(dep_node)
) -> None:
"""
Console WebSocket.
@ -339,23 +302,16 @@ async def console_ws(
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reset_console(node: IOUVM = Depends(dep_node)) -> None:
await node.reset_console()
@router.put(
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
async def toggle_iou_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: IOUVM = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: IOUVM = Depends(dep_node)
) -> dict:
"""
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -371,9 +327,7 @@ async def toggle_iou_marker(
@router.post(
"/{node_id}/markers/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
@ -381,9 +335,7 @@ async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/markers/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
@ -393,14 +345,10 @@ async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None:
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_iou_marker_capture(
marker_name: str,
adapter_number: int,
port_number: int,
link_id: str = "",
node: IOUVM = Depends(dep_node)
marker_name: str, adapter_number: int, port_number: int, link_id: str = "", node: IOUVM = Depends(dep_node)
) -> None:
"""
Delete a marker's capture pcap (called by the controller when the marker is
@ -413,14 +361,9 @@ async def delete_iou_marker_capture(
await node.delete_marker_capture(marker_name, link_id, nio)
@router.put(
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
async def rebuild_iou_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: IOUVM = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: IOUVM = Depends(dep_node)
) -> dict:
"""
Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -428,7 +371,11 @@ async def rebuild_iou_marker(
"""
await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
return {"marker_name": marker_name}

View File

@ -117,10 +117,7 @@ async def stop_nat_node(node: Nat = Depends(dep_node)) -> None:
Stop a NAT node.
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Stop is not supported for NAT nodes"
)
raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Stop is not supported for NAT nodes")
@router.post("/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT)
@ -129,10 +126,7 @@ async def suspend_nat_node(node: Nat = Depends(dep_node)) -> None:
Suspend a NAT node.
"""
raise HTTPException(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail="Suspend is not supported for NAT nodes"
)
raise HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported for NAT nodes")
@router.post(
@ -141,11 +135,11 @@ async def suspend_nat_node(node: Nat = Depends(dep_node)) -> None:
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
)
async def create_nat_node_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Nat = Depends(dep_node),
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Nat = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
"""
Add a NIO (Network Input/Output) to the node.
@ -163,11 +157,11 @@ async def create_nat_node_nio(
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
)
async def update_nat_node_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Nat = Depends(dep_node),
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
node: Nat = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
"""
Update a NIO (Network Input/Output) to the node.
@ -184,10 +178,7 @@ async def update_nat_node_nio(
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
async def delete_nat_node_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: Nat = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Nat = Depends(dep_node)
) -> None:
"""
Remove a NIO (Network Input/Output) from the node.
@ -199,11 +190,11 @@ async def delete_nat_node_nio(
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
async def start_nat_node_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: Nat = Depends(dep_node)
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node_capture_data: schemas.NodeCapture,
node: Nat = Depends(dep_node),
) -> dict:
"""
Start a packet capture on the node.
@ -219,10 +210,7 @@ async def start_nat_node_capture(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop", status_code=status.HTTP_204_NO_CONTENT
)
async def stop_nat_node_capture(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: Nat = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Nat = Depends(dep_node)
) -> None:
"""
Stop a packet capture on the node.
@ -234,10 +222,7 @@ async def stop_nat_node_capture(
@router.get("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream")
async def stream_pcap_file(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
node: Nat = Depends(dep_node)
*, adapter_number: int = Path(..., ge=0, le=0), port_number: int, node: Nat = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.

View File

@ -18,7 +18,6 @@
API routes for compute notifications.
"""
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from typing import Union
from websockets.exceptions import ConnectionClosed, WebSocketException
@ -53,7 +52,6 @@ async def project_ws_notifications(websocket: Union[None, WebSocket] = Depends(w
if __name__ == "__main__":
import uvicorn
from fastapi import FastAPI
from starlette.responses import HTMLResponse

View File

@ -86,8 +86,7 @@ def create_compute_project(project_data: schemas.ProjectCreate) -> schemas.Proje
@router.put("/projects/{project_id}", response_model=schemas.Project)
async def update_compute_project(
project_data: schemas.ProjectUpdate,
project: Project = Depends(dep_project)
project_data: schemas.ProjectUpdate, project: Project = Depends(dep_project)
) -> schemas.Project:
"""
Update project on the compute.
@ -144,8 +143,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 +181,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)
@ -187,6 +194,7 @@ def _get_existing_nio(node, adapter_number, port_number):
# via get_nio(port).
if hasattr(node, "get_nio"):
import inspect as _inspect
if len(_inspect.signature(node.get_nio).parameters) >= 2:
return node.get_nio(adapter_number, port_number)
return node.get_nio(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)
@ -232,9 +242,9 @@ async def _update_nio_binding(node, adapter_number, port_number, nio):
status_code=status.HTTP_201_CREATED,
)
async def create_batch_nios(
project_id: UUID,
batch: schemas.BatchNIOCreate,
project: Project = Depends(dep_project),
project_id: UUID,
batch: schemas.BatchNIOCreate,
project: Project = Depends(dep_project),
) -> dict:
"""
Create many NIO bindings across nodes in a single request.
@ -271,9 +281,7 @@ async def create_batch_nios(
nio = node.manager.create_nio(nio_settings)
await _add_nio_binding(node, entry.adapter_number, entry.port_number, nio)
await asyncio.gather(
*[_create_one_node(nid, ents) for nid, ents in per_node.items()]
)
await asyncio.gather(*[_create_one_node(nid, ents) for nid, ents in per_node.items()])
return {"added": len(batch.nios)}
@ -282,9 +290,9 @@ async def create_batch_nios(
status_code=status.HTTP_200_OK,
)
async def update_batch_nios(
project_id: UUID,
batch: schemas.BatchNIOCreate,
project: Project = Depends(dep_project),
project_id: UUID,
batch: schemas.BatchNIOCreate,
project: Project = Depends(dep_project),
) -> dict:
"""
Update many NIO bindings (filters + markers) across nodes in a single
@ -312,9 +320,7 @@ async def update_batch_nios(
nio.markers = e.nio.markers or {}
await _update_nio_binding(node, e.adapter_number, e.port_number, nio)
await asyncio.gather(
*[_update_one_node(nid, ents) for nid, ents in per_node.items()]
)
await asyncio.gather(*[_update_one_node(nid, ents) for nid, ents in per_node.items()])
return {"updated": len(batch.nios)}
@ -333,7 +339,7 @@ async def get_compute_node_files(
node_id: str,
project: Project = Depends(dep_project),
path: str = Query("", description="Subdirectory path within node directory"),
recursive: bool = Query(False, description="Recursively list all files")
recursive: bool = Query(False, description="Recursively list all files"),
) -> List[schemas.NodeFile]:
"""
Return files belonging to a specific node with detailed metadata.
@ -364,11 +370,7 @@ async def get_compute_project_file(file_path: str, project: Project = Depends(de
@router.post("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
async def write_compute_project_file(
file_path: str,
request: Request,
project: Project = Depends(dep_project)
) -> None:
async def write_compute_project_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None:
file_path = urllib.parse.unquote(file_path)
path = os.path.normpath(file_path)
@ -395,10 +397,7 @@ async def write_compute_project_file(
@router.delete("/projects/{project_id}/files/{file_path:path}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_compute_project_file(
file_path: str,
project: Project = Depends(dep_project)
) -> None:
async def delete_compute_project_file(file_path: str, project: Project = Depends(dep_project)) -> None:
file_path = urllib.parse.unquote(file_path)
path = os.path.normpath(file_path)

View File

@ -33,6 +33,7 @@ from gns3server.compute.qemu.qemu_vm import QemuVM
from .dependencies.authentication import compute_authentication, ws_compute_authentication
import logging
log = logging.getLogger(__name__)
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Qemu node"}}
@ -55,7 +56,7 @@ def dep_node(project_id: UUID, node_id: UUID) -> QemuVM:
response_model=schemas.Qemu,
status_code=status.HTTP_201_CREATED,
responses={409: {"model": schemas.ErrorMessage, "description": "Could not create Qemu node"}},
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> schemas.Qemu:
"""
@ -64,6 +65,7 @@ async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> s
qemu = Qemu.instance()
node_data = jsonable_encoder(node_data, exclude_unset=True)
disk_images_to_reset = set(node_data.pop("disk_images_to_reset", []))
vm = await qemu.create_node(
node_data.pop("name"),
str(project_id),
@ -80,12 +82,35 @@ async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> s
# update the disk image with the backing file if provided
# this is needed when duplicating a node that uses backed disk images
drives = ["a", "b", "c", "d"]
for disk_index, drive in enumerate(drives):
for drive in drives:
disk_image_backing_file = node_data.get(f"hd{drive}_disk_image_backing_file")
if disk_image_backing_file:
log.debug(f"Updating disk image for drive {drive} with backing file {disk_image_backing_file}")
node_data[f"hd{drive}_disk_image"] = disk_image_backing_file
# Validate every explicitly replaced disk before removing its stale
# overlay. Other unresolved disks may still make this create request fail,
# but a valid replacement applied in stages must not retain its old layer.
for drive in drives:
disk_image_property = f"hd{drive}_disk_image"
if disk_image_property in disk_images_to_reset:
replacement_image = node_data.get(disk_image_property)
if replacement_image:
vm.manager.get_abs_image_path(replacement_image, vm.working_dir)
local_disk_name = f"hd{drive}_disk.qcow2"
local_disk = os.path.join(vm.working_dir, local_disk_name)
if vm.linked_clone and os.path.exists(local_disk):
# A degraded linked clone is being assigned a new base image.
# Its old overlay depends on the unavailable base and cannot
# safely be rebased onto an arbitrary replacement. Discard it
# so start creates a fresh overlay from the selected image.
log.info(
"Removing stale linked-clone disk '%s' before using replacement image '%s'",
local_disk,
node_data.get(disk_image_property),
)
vm.delete_disk_image(local_disk_name)
for name, value in node_data.items():
if hasattr(vm, name) and getattr(vm, name) != value:
setattr(vm, name, value)
@ -93,11 +118,7 @@ async def create_qemu_node(project_id: UUID, node_data: schemas.QemuCreate) -> s
return vm.asdict()
@router.get(
"/{node_id}",
response_model=schemas.Qemu,
dependencies=[Depends(compute_authentication)]
)
@router.get("/{node_id}", response_model=schemas.Qemu, dependencies=[Depends(compute_authentication)])
def get_qemu_node(node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
"""
Return a Qemu node.
@ -106,11 +127,7 @@ def get_qemu_node(node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
return node.asdict()
@router.put(
"/{node_id}",
response_model=schemas.Qemu,
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}", response_model=schemas.Qemu, dependencies=[Depends(compute_authentication)])
async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends(dep_node)) -> schemas.Qemu:
"""
Update a Qemu node.
@ -126,11 +143,7 @@ async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends
return node.asdict()
@router.delete(
"/{node_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def delete_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
"""
Delete a Qemu node.
@ -143,11 +156,10 @@ async def delete_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
"/{node_id}/duplicate",
response_model=schemas.Qemu,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def duplicate_qemu_node(
destination_node_id: UUID = Body(..., embed=True),
node: QemuVM = Depends(dep_node)
destination_node_id: UUID = Body(..., embed=True), node: QemuVM = Depends(dep_node)
) -> schemas.Qemu:
"""
Duplicate a Qemu node.
@ -160,12 +172,10 @@ async def duplicate_qemu_node(
@router.post(
"/{node_id}/disk_image/{disk_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_qemu_disk_image(
disk_name: str,
disk_data: schemas.QemuDiskImageCreate,
node: QemuVM = Depends(dep_node)
disk_name: str, disk_data: schemas.QemuDiskImageCreate, node: QemuVM = Depends(dep_node)
) -> None:
"""
Create a Qemu disk image.
@ -178,12 +188,10 @@ async def create_qemu_disk_image(
@router.put(
"/{node_id}/disk_image/{disk_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_qemu_disk_image(
disk_name: str,
disk_data: schemas.QemuDiskImageUpdate,
node: QemuVM = Depends(dep_node)
disk_name: str, disk_data: schemas.QemuDiskImageUpdate, node: QemuVM = Depends(dep_node)
) -> None:
"""
Update a Qemu disk image.
@ -196,12 +204,9 @@ async def update_qemu_disk_image(
@router.delete(
"/{node_id}/disk_image/{disk_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_qemu_disk_image(
disk_name: str,
node: QemuVM = Depends(dep_node)
) -> None:
async def delete_qemu_disk_image(disk_name: str, node: QemuVM = Depends(dep_node)) -> None:
"""
Delete a Qemu disk image.
"""
@ -209,11 +214,7 @@ async def delete_qemu_disk_image(
node.delete_disk_image(disk_name)
@router.post(
"/{node_id}/start",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def start_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
"""
Start a Qemu node.
@ -222,11 +223,7 @@ async def start_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
await node.start()
@router.post(
"/{node_id}/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
)
@router.post("/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)])
async def stop_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
"""
Stop a Qemu node.
@ -236,9 +233,7 @@ async def stop_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/reload",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reload_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
"""
@ -249,9 +244,7 @@ async def reload_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/suspend",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/suspend", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def suspend_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
"""
@ -262,9 +255,7 @@ async def suspend_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def resume_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
"""
@ -278,14 +269,14 @@ async def resume_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def create_qemu_node_nio(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: QemuVM = Depends(dep_node)
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: QemuVM = Depends(dep_node),
) -> schemas.UDPNIO:
"""
Add a NIO (Network Input/Output) to the node.
@ -301,14 +292,14 @@ async def create_qemu_node_nio(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def update_qemu_node_nio(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: QemuVM = Depends(dep_node)
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
node: QemuVM = Depends(dep_node),
) -> schemas.UDPNIO:
"""
Update a NIO (Network Input/Output) on the node.
@ -328,12 +319,10 @@ async def update_qemu_node_nio(
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_qemu_node_nio(
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node: QemuVM = Depends(dep_node)
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node)
) -> None:
"""
Delete a NIO (Network Input/Output) from the node.
@ -345,14 +334,14 @@ async def delete_qemu_node_nio(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def start_qemu_node_capture(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node_capture_data: schemas.NodeCapture,
node: QemuVM = Depends(dep_node)
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node_capture_data: schemas.NodeCapture,
node: QemuVM = Depends(dep_node),
) -> dict:
"""
Start a packet capture on the node.
@ -367,12 +356,10 @@ async def start_qemu_node_capture(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stop",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stop_qemu_node_capture(
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node: QemuVM = Depends(dep_node)
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node)
) -> None:
"""
Stop a packet capture on the node.
@ -384,12 +371,11 @@ async def stop_qemu_node_capture(
@router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def stream_pcap_file(
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
node: QemuVM = Depends(dep_node)) -> StreamingResponse:
adapter_number: int, port_number: int = Path(..., ge=0, le=0), node: QemuVM = Depends(dep_node)
) -> StreamingResponse:
"""
Stream the pcap capture file.
The port number on the Qemu node is always 0.
@ -400,12 +386,9 @@ async def stream_pcap_file(
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@router.websocket(
"/{node_id}/console/ws"
)
@router.websocket("/{node_id}/console/ws")
async def console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: QemuVM = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: QemuVM = Depends(dep_node)
) -> None:
"""
Console WebSocket.
@ -415,12 +398,9 @@ async def console_ws(
await node.start_websocket_console(websocket)
@router.websocket(
"/{node_id}/console/vnc"
)
@router.websocket("/{node_id}/console/vnc")
async def vnc_console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: QemuVM = Depends(dep_node)
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication), node: QemuVM = Depends(dep_node)
) -> None:
"""
VNC Console WebSocket.
@ -431,23 +411,16 @@ async def vnc_console_ws(
@router.post(
"/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def reset_console(node: QemuVM = Depends(dep_node)) -> None:
await node.reset_console()
@router.put(
"/{node_id}/markers/{marker_name}",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}", dependencies=[Depends(compute_authentication)])
async def toggle_qemu_marker(
marker_name: str,
toggle_data: schemas.MarkerToggle,
node: QemuVM = Depends(dep_node)
marker_name: str, toggle_data: schemas.MarkerToggle, node: QemuVM = Depends(dep_node)
) -> dict:
"""
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
@ -463,9 +436,7 @@ async def toggle_qemu_marker(
@router.post(
"/{node_id}/markers/pause",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
@ -473,9 +444,7 @@ async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/markers/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
"/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(compute_authentication)]
)
async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
@ -485,14 +454,14 @@ async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None:
@router.delete(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(compute_authentication)]
dependencies=[Depends(compute_authentication)],
)
async def delete_qemu_marker_capture(
marker_name: str,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
link_id: str = "",
node: QemuVM = Depends(dep_node)
node: QemuVM = Depends(dep_node),
) -> None:
"""
Delete a marker's capture pcap (called by the controller when the marker is
@ -505,14 +474,9 @@ async def delete_qemu_marker_capture(
await node.delete_marker_capture(marker_name, link_id, nio)
@router.put(
"/{node_id}/markers/{marker_name}/rebuild",
dependencies=[Depends(compute_authentication)]
)
@router.put("/{node_id}/markers/{marker_name}/rebuild", dependencies=[Depends(compute_authentication)])
async def rebuild_qemu_marker(
marker_name: str,
rebuild_data: schemas.MarkerRebuild,
node: QemuVM = Depends(dep_node)
marker_name: str, rebuild_data: schemas.MarkerRebuild, node: QemuVM = Depends(dep_node)
) -> dict:
"""
Re-install a single marker filter with new BPF/tag/direction (delete + add,
@ -520,7 +484,11 @@ async def rebuild_qemu_marker(
"""
await node.rebuild_marker_filter(
marker_name, rebuild_data.link_id, rebuild_data.bpf,
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
marker_name,
rebuild_data.link_id,
rebuild_data.bpf,
rebuild_data.tag,
rebuild_data.direction,
rebuild_data.enabled,
)
return {"marker_name": marker_name}

Some files were not shown because too many files have changed in this diff Show More