mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-28 14:50:14 +03:00
Merge remote-tracking branch 'origin/3.1' into gh-pages
This commit is contained in:
commit
6dc3708237
@ -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.
|
||||
|
||||
@ -1,2 +1,9 @@
|
||||
[run]
|
||||
source = gns3server
|
||||
branch = True
|
||||
|
||||
[report]
|
||||
fail_under = 55
|
||||
|
||||
[paths]
|
||||
source = gns3server
|
||||
|
||||
15
.github/workflows/testing.yml
vendored
15
.github/workflows/testing.yml
vendored
@ -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
4
.gitignore
vendored
@ -37,6 +37,10 @@ pip-log.txt
|
||||
.tox
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
||||
87
CHANGELOG
87
CHANGELOG
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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_
|
||||
|
||||
@ -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,
|
||||
|
||||
144
docs/design/docker-image-type.md
Normal file
144
docs/design/docker-image-type.md
Normal file
@ -0,0 +1,144 @@
|
||||
# Docker Image Types (Vendor Profiles) — Roadmap
|
||||
|
||||
Status: **proposal, not implemented**. This is the agreed direction for the
|
||||
next iteration of vendor Docker support. Four vendor profiles already
|
||||
exist across branches and prototypes — iol-runner (this branch), XRd and
|
||||
the SR Linux prototype (vendor/skip-init family), and the FRR registry
|
||||
appliance — enough evidence to justify the registry. Implement it as a
|
||||
follow-up to the current PR series, shaped by all four rather than by
|
||||
iol-runner alone.
|
||||
|
||||
## Problem
|
||||
|
||||
All Docker nodes share one template type (`template_type: "docker"`) and
|
||||
one template schema. Vendor images (iol-runner, SR Linux, XRd, …) are
|
||||
selected and configured today through **environment markers** — free-form
|
||||
strings parsed by the compute (`GNS3_IOL_RUNNER`, `GNS3_IOL_MEMORY`,
|
||||
`GNS3_IOL_STARTUP_CONFIG`, `GNS3_SKIP_INIT`, `GNS3_UNIX_SOCKET_NIO`, …).
|
||||
Three pains have already surfaced:
|
||||
|
||||
1. **Invisible and unvalidated.** The markers do not exist in the API
|
||||
schema: no OpenAPI documentation, no validation, typo or bad value
|
||||
silently falls back to a default (`GNS3_IOL_MEMORY=notanumber` → 2048).
|
||||
2. **The shared-schema dilemma.** Adding a vendor-specific field to the
|
||||
Docker template schema pollutes every Docker template with a field only
|
||||
one vendor reads; not adding it pushes everything into environment
|
||||
strings. The `startup_config` discussion (ended in the
|
||||
`GNS3_IOL_STARTUP_CONFIG` knob) is the canonical example — the tension
|
||||
exists because there is no legitimate discriminator dimension.
|
||||
3. **Generic-feature applicability is implicit.** Which generic Docker
|
||||
features apply to which image is only documented in code comments:
|
||||
`mac_address` is meaningless under unix-socket NIO, `/etc/network`
|
||||
seeding is dead weight for skip-init images, `extra_configs` targets
|
||||
under persisted volumes get a (correct-but-noisy) shadow warning.
|
||||
|
||||
## Non-goal: new node types
|
||||
|
||||
A `template_type`/`node_type` per vendor (`"iol"`, `"srlinux"`, …) is
|
||||
explicitly rejected. `node_type` is a top-level concept: it drives compute
|
||||
module routing (`/projects/{id}/{node_type}/nodes`), capability
|
||||
reporting, GUI node types and link handling. N vendor types that are all
|
||||
Docker underneath would multiply routing and schema surface for zero
|
||||
mechanism — vendor images are *content*, not mechanism, and GNS3's
|
||||
appliance/template system is where content belongs.
|
||||
|
||||
## Design
|
||||
|
||||
### One discriminator field on Docker templates
|
||||
|
||||
```json
|
||||
{
|
||||
"template_type": "docker",
|
||||
"image_type": "iol-runner",
|
||||
"image": "iol-xe/iol-xe:17-18-02"
|
||||
}
|
||||
```
|
||||
|
||||
`image_type` is optional; absent (or `"generic"`) keeps today's plain
|
||||
`DockerVM` behavior and marker sniffing. The profiles that exist today,
|
||||
mapped to their current mechanisms and archetypes:
|
||||
|
||||
| Profile | Current mechanism | Archetype |
|
||||
|---|---|---|
|
||||
| FRR appliance | plain `DockerVM`: init.sh `/etc/network` + `start_command` (frrinit.sh), console on PID 1 | generic |
|
||||
| XRd | `VendorDockerVM` skip-init + `GNS3_SHM_SIZE`/`GNS3_DEVICES`, `extra_configs`, udev masking | vendor skip-init |
|
||||
| SR Linux (prototype) | `VendorDockerVM` skip-init + `docker_exec` console + `GNS3_INTERFACE_NAMES` | vendor skip-init |
|
||||
| iol-runner | `IOLDockerVM`: unix-socket NIO, per-start config generation, NVRAM startup-config | iol-runner |
|
||||
|
||||
Possible first values: `generic`, `iol-runner`, and a value for the
|
||||
skip-init vendor NOS family once its common shape settles (XRd and SR
|
||||
Linux may end up sharing it or splitting — that is exactly what the
|
||||
registry should decide with all four in front of us).
|
||||
|
||||
### A compute-side registry
|
||||
|
||||
```python
|
||||
IMAGE_PROFILES = {
|
||||
"iol-runner": {
|
||||
"class": IOLDockerVM,
|
||||
"fields": ("iol_memory", "startup_config"), # schema-gated
|
||||
"capabilities": {...}, # see below
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
* **Class selection** moves from environment sniffing
|
||||
(`Docker._select_node_class`) to the field, with the existing markers
|
||||
kept as a fallback for templates created before the field existed
|
||||
(zero-migration compatibility).
|
||||
* **Vendor parameters graduate into schema fields**, gated by pydantic
|
||||
conditional validation (accepted — and validated — only when
|
||||
`image_type` matches). This resolves the shared-schema dilemma properly:
|
||||
the field exists, but only means something for its profile. The
|
||||
environment knobs remain as the wire-level compatibility entry.
|
||||
* **Capability declaration** makes generic-feature applicability data
|
||||
instead of comments:
|
||||
|
||||
| Capability | plain Docker | vendor skip-init | iol-runner |
|
||||
|---|---|---|---|
|
||||
| `mac_address` honored | yes | yes | **no** (IOL derives MACs from the app id) |
|
||||
| `/etc/network` seeding | yes | no | no |
|
||||
| `extra_configs` targets under volumes | shadowed | works (real-path binds) | works |
|
||||
| console | telnet/http/… | `docker_exec` | PID 1 stdio (telnet) |
|
||||
| startup config | `extra_configs` injection | image-specific | nvram build (`nvram_import`) |
|
||||
|
||||
Consumers: error/warning quality (don't warn about inapplicable
|
||||
features), the WebUI (vendor-aware template forms — a bonus, not a
|
||||
driver), and documentation generation.
|
||||
|
||||
The existing class hierarchy (`DockerVM` → `VendorDockerVM` →
|
||||
`IOLDockerVM`) is unchanged — the registry only externalizes selection
|
||||
and declaration.
|
||||
|
||||
## Migration & compatibility
|
||||
|
||||
* Old templates (markers only) keep working via the fallback; a one-time
|
||||
optional converter can rewrite markers → `image_type` + fields.
|
||||
* The controller-side materialization conventions
|
||||
(`GNS3_IOL_STARTUP_CONFIG` file → `startup_config_content`, sent once)
|
||||
carry over unchanged — the field version references the same content
|
||||
pipeline.
|
||||
|
||||
## Roadmap steps
|
||||
|
||||
1. Add `image_type` to the Docker template schema (+ DB column +
|
||||
Alembic migration — see the three-place rule for template fields) and
|
||||
wire class selection through the registry, markers as fallback.
|
||||
2. Move iol-runner knobs to gated fields (`iol_memory`,
|
||||
`startup_config`), deprecating-but-supporting the env forms.
|
||||
3. Introduce the capability table and use it to silence inapplicable
|
||||
warnings (`mac_address`, `/etc/network`, extra-config shadowing).
|
||||
4. Revisit per-vendor *schemas* (a sub-model per profile) only if a
|
||||
profile grows more than a handful of fields — not before.
|
||||
|
||||
## Open questions
|
||||
|
||||
* Field name: `image_type` vs `vendor` vs `runner` — decide when the
|
||||
second profile lands; the value vocabulary should name the *runtime
|
||||
contract*, not the vendor.
|
||||
* Should appliances (`.gns3a`) carry `image_type` explicitly, or should
|
||||
installation keep deriving it from the appliance's environment block?
|
||||
* Where the gated vendor fields live long-term: flat on the Docker
|
||||
template (simple, gated) vs nested `{"image_type": ..., "settings":
|
||||
{...}}` (cleaner, more schema churn).
|
||||
218
docs/features/iol-runner-docker.md
Normal file
218
docs/features/iol-runner-docker.md
Normal file
@ -0,0 +1,218 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt.
|
||||
|
||||
|
||||
# IOL Images with iol-runner (Cisco CML containerized IOL) as Docker Nodes
|
||||
|
||||
## Overview
|
||||
|
||||
IOL images packaged with Cisco CML's container runner — for example
|
||||
`iol-xe/iol-xe:17-18-02` (IOS-XE 17.18.02 IOL in a scratch image driven by
|
||||
`iol-runner`, module `virl.lab/cmd/iol-runner`) — run as first-class GNS3
|
||||
Docker router nodes with **zero changes to the image**. The integration adds
|
||||
two generic server mechanisms:
|
||||
|
||||
1. **Unix-socket NIO** (`GNS3_UNIX_SOCKET_NIO=1`, on `VendorDockerVM`): link
|
||||
adapters through per-interface AF_UNIX datagram sockets instead of a TAP
|
||||
interface moved into the container's network namespace.
|
||||
2. **`IOLDockerVM`** (marker `GNS3_IOL_RUNNER=1`): generates the runner's
|
||||
config file per start, prepares its runtime directory and cleans up stale
|
||||
sockets — the iol-runner-specific glue on top of the vendor path.
|
||||
|
||||
## How the image works
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Container["scratch container (PID 1)"]
|
||||
RUNNER["/iol-runner -config /config/iol-config.json -stdio"]
|
||||
IOL["IOL process<br/>(IOS-XE 17.18.02)"]
|
||||
NETIOMUX["netiomux"]
|
||||
SOCKETS["/tmp/s00.sock (recv)<br/>/tmp/c00.sock (send-to path)<br/>… one pair per interface"]
|
||||
RUNNER -->|"spawn -e/-s/-m + app id"| IOL
|
||||
IOL -->|"netio bus /tmp/netio<uid>/"| NETIOMUX --> SOCKETS
|
||||
end
|
||||
subgraph Host
|
||||
UBRIDGE["uBridge bridgeN<br/>add_nio_unix …/gns3/unixio/<node>/cNN.sock …<br/>+ add_nio_udp (topology)"]
|
||||
RTDIR["/run/user/<uid>/gns3/unixio/<node><br/>(bind-mounted at /tmp)"]
|
||||
VOL["project-files/docker/<node>/config<br/>(+ tmp/run, nested bind at /tmp/run)"]
|
||||
end
|
||||
SOCKETS <-->|"same files, two spellings<br/>(the /tmp bind)"| RTDIR <--> UBRIDGE
|
||||
UBRIDGE -.->|"iol-config.json +<br/>persistent /tmp/run"| VOL
|
||||
```
|
||||
|
||||
* **Console**: the runner muxes the IOS console onto PID 1 stdio (`-stdio`
|
||||
entrypoint flag). The plain `console_type: "telnet"` attaches to it — no
|
||||
`docker_exec` needed. The runner requires a TTY, which GNS3 always
|
||||
allocates; without one the runner exits (`inappropriate ioctl for device`).
|
||||
* **Networking**: the runner does not touch the container's network
|
||||
namespace. Per interface N it creates, inside the container's `/tmp`: a
|
||||
receive socket `s%02d.sock` (frames sent there are injected into guest
|
||||
interface N) and a send-to path `c%02d.sock` (whoever binds it receives
|
||||
the guest's frames). Frames are **raw Ethernet**, one datagram per frame —
|
||||
the same two-mailbox convention uBridge's `add_nio_unix` natively speaks
|
||||
(it binds the c-socket, sends to the s-socket). GNS3 bind-mounts a
|
||||
per-node directory from the runtime directory
|
||||
(`/run/user/<uid>/gns3/unixio/<node-id>`, next to the uBridge control
|
||||
sockets) at the container's `/tmp`, so uBridge reaches the sockets as
|
||||
plain host files: the path stays far under AF_UNIX's 107-byte `sun_path`
|
||||
cap (a projects-tree node path alone exceeds it) and the directory is
|
||||
owned by the server user, to whom the runner drops its privileges. This
|
||||
mirrors how CML itself runs the image (`source=…/tmp,target=/tmp` in its
|
||||
node definition). The directory is ephemeral and removed with the node.
|
||||
* **Licensing**: the image ships a self-consistent `/etc/hostid` + `.iourc`
|
||||
pair, and the runner regenerates the license from the host ID at boot —
|
||||
nothing to configure.
|
||||
* **Persistence**: `/tmp/run` (the IOL working directory) holds the NETMAP,
|
||||
the startup-config (`config`, plain IOS format) and NVRAM (`nvram_00001`).
|
||||
It is the only `/tmp` path that needs to survive: GNS3 bind-mounts the
|
||||
node directory's `tmp/run/` at `/tmp/run` (nested inside the runtime-dir
|
||||
bind), so the router's configuration survives stop/start and container
|
||||
recreation while sockets, netio buses and runner logs stay ephemeral.
|
||||
The generated config maps the runner to the server's uid/gid
|
||||
(`user-id`/`group-id`), so all files it creates are owned by the server
|
||||
user (no permission-fix pass needed).
|
||||
|
||||
## Template
|
||||
|
||||
Create the template once via `POST /v3/templates` (authenticated — see the
|
||||
API docs for the auth flow), or in the Web UI under
|
||||
*Edit → Preferences → Docker templates → New* with the same fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "IOS-XE 17.18.02 IOL",
|
||||
"template_type": "docker",
|
||||
"image": "iol-xe/iol-xe:17-18-02",
|
||||
"category": "router",
|
||||
"symbol": ":/symbols/router.svg",
|
||||
"adapters": 2,
|
||||
"console_type": "telnet",
|
||||
"environment": "GNS3_IOL_RUNNER=1",
|
||||
"extra_volumes": ["/config"]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Value | Why |
|
||||
|---|---|---|
|
||||
| `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=<MB>` (default 2048), `GNS3_IOL_STARTUP_CONFIG=<file>` (initial configuration, see below). |
|
||||
| `extra_volumes` | `["/config"]` | `/tmp/run` is auto-added. **Never add `/tmp`** — it would persist the socket directory into the projects tree and uBridge would reject the too-long AF_UNIX path. |
|
||||
| `adapters` | number of 4-port units | The IOU convention: one adapter = `Ethernet0/0`–`Ethernet0/3`, two adapters add `Ethernet1/0`–`1/3`, … (8 units / 32 ports max). Ports are addressed as (adapter, port 0–3) and shown grouped in the UI. |
|
||||
| `memory` | optional; `0` (default) = no cap | Unset works — Docker applies no limit. When you do set a cap, keep it at IOL memory + ~512 MB, or the cgroup OOM-killer shoots the router. |
|
||||
| `console_type` | `telnet` | The runner muxes the IOS console onto PID 1 stdio; `docker_exec` is not needed. |
|
||||
|
||||
### Verify
|
||||
|
||||
1. The node's port list shows the grouped IOL interfaces
|
||||
(`Ethernet0/0`–`Ethernet1/3` for two adapters), addressed
|
||||
(adapter, port).
|
||||
2. Drop a node into a project and start it — the console shows the
|
||||
`Linux Unix (i686)` banner within seconds.
|
||||
3. `$XDG_RUNTIME_DIR/gns3/unixio/<node-id>/` contains `s00.sock`… (one
|
||||
pair per port).
|
||||
4. A node created with a startup-config boots straight to the configured
|
||||
hostname — no initial configuration dialog (interface names in the
|
||||
config are `Ethernet0/0`, not `GigabitEthernet0/0`).
|
||||
|
||||
## Startup configuration
|
||||
|
||||
IOL Docker nodes load an initial configuration exactly like native IOU
|
||||
nodes: the **template references a config file**, and every node created
|
||||
from it boots with that configuration as its personal starting point.
|
||||
|
||||
```json
|
||||
"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=iol-xe-base.txt"
|
||||
```
|
||||
|
||||
* The file lives in the controller's configs directory (the `configs_path`
|
||||
server setting, by default `~/GNS3/configs`) — the same place IOU and
|
||||
VPCS base configs live. A minimal `iol-xe-base.txt` ships with the
|
||||
server and is installed there on startup (never overwriting a
|
||||
user-modified copy); an absolute path in the knob bypasses the directory
|
||||
entirely. `%h` in the content is replaced with the node name at start.
|
||||
* **Creation materializes it once**: the controller reads the file and
|
||||
sends its content with the node; afterwards the knob is consumed and the
|
||||
template file is never referenced again — editing it does not affect
|
||||
existing nodes. The knob also disappears from the node's `environment`
|
||||
(`GNS3_IOL_RUNNER=1` remains — it re-selects the node class on every
|
||||
recreation), so a node whose environment shows only `GNS3_IOL_RUNNER=1`
|
||||
is the normal sign that the file was found and its content delivered;
|
||||
when the file is missing the knob stays in the environment (with a
|
||||
server-side warning) and the lookup is retried at the next creation.
|
||||
* **NVRAM is the source of truth at boot** (verified on the runner): the
|
||||
content is built into the node's `tmp/run/nvram_<app id>` — IOL and IOU
|
||||
share the nvram container format, so the server-side `nvram_import`
|
||||
utility produces a file IOL boots from directly. Consequences:
|
||||
* `write memory` survives stop/start and container recreation (a plain
|
||||
restart never re-applies the startup-config over it). The first
|
||||
`write memory` after a server-built NVRAM asks for a one-time
|
||||
`[confirm]` (the builder stamps an IOS 15.4 version marker into the
|
||||
nvram header; press return — IOS then writes its own).
|
||||
* Editing a node's `startup_config_content` property (PUT) re-applies it
|
||||
on the next start, overwriting what `write memory` had saved — the
|
||||
explicit edit wins, as with IOU.
|
||||
* Renaming a node rewrites the `hostname` line in its NVRAM, so a
|
||||
duplicated/renamed node boots under its new name.
|
||||
* **Resetting a node to its template config**: stop the node, delete
|
||||
`project-files/docker/<node>/tmp/run/nvram_*`, then re-apply the content
|
||||
(or recreate the node).
|
||||
|
||||
## Server mechanisms
|
||||
|
||||
| Mechanism | Where | What it does |
|
||||
|---|---|---|
|
||||
| `GNS3_UNIX_SOCKET_NIO=1` | `VendorDockerVM` | `_add_ubridge_connection` override: `bridge create` + `bridge add_nio_unix <dir>/c{N:02d}.sock <dir>/s{N:02d}.sock` instead of TAP + `docker move_to_ns`. No TAP allocation, no `set_mac_addr`, namespace untouched. The socket directory is bound from a per-node runtime directory unless a persisted volume already covers it. |
|
||||
| `GNS3_UNIX_SOCKET_DIR=<dir>` | `VendorDockerVM` | In-container socket directory (default `/tmp`). Any image whose agent exposes the `s%02d`/`c%02d` datagram pairs can use this without the IOL specifics. |
|
||||
| `GNS3_IOL_RUNNER=1` | `IOLDockerVM` (selected in the manager) | Forces skip-init + unix-socket NIO + the `/config` and `/tmp/run` volumes; on every start writes `<node>/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `<node>/tmp/run/` (the IOL process dies without it) and removes stale sockets/netio dirs from the socket directory (`tmp/run` is never touched). |
|
||||
| `restart()` hardening | `IOLDockerVM` | The base `docker restart` would boot the runner on a stale config and leave uBridge wired to the previous run's sockets; reload becomes graceful stop (SIGTERM → NVRAM flush) + full start. |
|
||||
| `GNS3_IOL_STARTUP_CONFIG=<file>` | controller `Node._node_data` + `IOLDockerVM` | Startup-config plumbing (see above): the controller translates the file into `startup_config_content` on node creation (sent once); the compute builds it into `nvram_<app id>` at the next start via the IOU `nvram_import` utility and rewrites the hostname line on rename. |
|
||||
|
||||
`GNS3_STOP_TIMEOUT` (default 60) controls the SIGTERM grace period on stop.
|
||||
Extra iol-runner flags can be passed via `start_command`, e.g. `-keep`
|
||||
(L1 keepalives) or `-debug 9` (verbose `process.log` — very useful when
|
||||
diagnosing wiring issues).
|
||||
|
||||
## Notes and caveats
|
||||
|
||||
* **Console typing latency**: single keystrokes are echoed on a ~50 ms
|
||||
cadence — the image services console input on an internal poll.
|
||||
Measured directly on the container console (bypassing the GNS3 server
|
||||
entirely, through the same Docker attach endpoint it uses): keystroke
|
||||
echo is bimodal — 1–11 ms when the keystroke lands just before a poll
|
||||
tick, ~50–100 ms when it just misses one — while a pasted line is
|
||||
picked up as one batch (~1.4 ms) and command output streams
|
||||
back-to-back (inter-frame gaps ~0.01 ms). A control container on the
|
||||
same attach endpoint echoes in ~1 ms, so the GNS3 telnet path is not a
|
||||
factor; this matches IOS's low-priority console-input polling and there
|
||||
is nothing to fix server-side. Paste long commands instead of typing
|
||||
them.
|
||||
* **Memory sizing**: `memory` caps the whole container; the IOL process gets
|
||||
`GNS3_IOL_MEMORY` (default 2048 MB). Keep container memory at IOL memory
|
||||
+ ~512 MB headroom or the OOM-killer will shoot the router.
|
||||
* **MAC addresses**: the `mac_address` template field and per-adapter custom
|
||||
MACs are ignored — IOL derives its own scheme from the node's application
|
||||
ID (`aabb.cc{app}{iface}`), e.g. `aabb.cc03.0400`. The controller
|
||||
allocates the ID at node creation from the upper half of the id space
|
||||
(512–1022, disjoint from IOU's 1–511, limit 511 IOL Docker nodes across
|
||||
opened projects sharing computes). Starting a node without an allocation
|
||||
(raw compute API, pre-allocation topologies) is an error, not a fallback —
|
||||
an uncoordinated ID could collide with the pool and make nodes silently
|
||||
drop each other's frames as MAC loops.
|
||||
* **Interface names are IOL-style `Ethernet0/0`**, not `GigabitEthernet0/0`
|
||||
(4 ports per unit, matching the adapter-count granularity) — startup
|
||||
configs addressing `GigabitEthernet…` are rejected by the parser.
|
||||
* **Adapters**: one adapter is a 4-port unit (the IOU model): change the
|
||||
count while the node is stopped; the config is regenerated on the next
|
||||
start (`num-eth` = adapters × 4) and the runner creates the matching
|
||||
socket set.
|
||||
* **Stop before editing**: NVRAM is only flushed on a graceful stop (SIGTERM,
|
||||
"cleanup done" in `process.log`); a kill loses the running-config changes
|
||||
since the last `write memory`.
|
||||
* **Class selection is create-time**: toggling `GNS3_IOL_RUNNER` via PUT
|
||||
takes effect after a project reload (same as `docker_exec`).
|
||||
* The startup-config lives at `project-files/docker/<node>/tmp/run/config`;
|
||||
`extra_configs` targets under persisted volumes are warned against by the
|
||||
generic create path — edit the file directly or paste via the console.
|
||||
349
docs/features/marker-tag-replay.md
Normal file
349
docs/features/marker-tag-replay.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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**:
|
||||
|
||||
@ -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
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@ -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"
|
||||
```
|
||||
|
||||
|
||||
@ -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()
|
||||
```
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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},
|
||||
}
|
||||
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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()`` +
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -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)}"}
|
||||
|
||||
|
||||
|
||||
@ -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"])
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
107
gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py
Normal file
107
gns3server/agent/gns3_copilot/tools_v2/gns3_wait.py
Normal 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)}"}
|
||||
@ -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})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
```
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}")
|
||||
|
||||
@ -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)}")
|
||||
|
||||
@ -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)}")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -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
@ -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 {
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
0
gns3server/agent/web_wireshark/__init__.py
Normal file
0
gns3server/agent/web_wireshark/__init__.py
Normal 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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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]:
|
||||
"""
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user