mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
Merge pull request #2861 from yueguobin/refactor/gns3-client-and-mcp-fixes
refactor: sink shared REST handlers into gns3_client + MCP regression fixes
This commit is contained in:
commit
ae317608e8
@ -49,7 +49,7 @@ Tools are separated by domain into individual files under `gns3server/api/routes
|
||||
- Synchronous functions receiving `(params: dict, gns3_ctx: dict)`
|
||||
- Run via `asyncio.to_thread()` to avoid blocking the event loop
|
||||
- `gns3_ctx` contains `server_url` and `jwt_token`
|
||||
- `Gns3Connector` is created per-handler from `custom_gns3fy`
|
||||
- `Gns3Connector` is created per-handler from `gns3_client.connector` (per-handler instantiation keeps each tool call isolated)
|
||||
|
||||
### Token Lifetime
|
||||
- Default: 1440 minutes (24 hours)
|
||||
@ -61,14 +61,11 @@ Tools are separated by domain into individual files under `gns3server/api/routes
|
||||
- **Why not stdio**: stdio is local-only; SSE supports both local and remote deployments
|
||||
|
||||
## Related Files
|
||||
- `gns3server/api/routes/mcp/__init__.py` — FastMCP server, tool decorators, auth wrapper
|
||||
- `gns3server/api/routes/mcp/projects.py` — Project tool handlers
|
||||
- `gns3server/api/routes/mcp/nodes.py` — Node tool handlers
|
||||
- `gns3server/api/routes/mcp/links.py` — Link tool handlers
|
||||
- `gns3server/api/routes/mcp/templates.py` — Template tool handlers
|
||||
- `gns3server/api/routes/mcp/computes.py` — Compute tool handlers
|
||||
- `gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py` — Gns3Connector client
|
||||
- `gns3server/api/server.py:87` — MCP route registration
|
||||
- `gns3server/agent/mcp/__init__.py` — FastMCP server, `@mcp.tool()` decorators, auth wrapper (`_resolve_token` exchanges API keys for JWTs)
|
||||
- `gns3server/agent/mcp/*.py` — tool handlers for projects/templates/computes/snapshots/drawings/symbols/appliances/images
|
||||
- `gns3server/agent/gns3_copilot/gns3_client/api_handlers.py` — shared node/link handler layer (single implementation, consumed by both MCP tools and copilot `tools_v2`; tests must patch `_get_connector` HERE, not in mcp modules)
|
||||
- `gns3server/agent/gns3_copilot/gns3_client/connector.py` — Gns3Connector (JWT auth + http_call only; the old `custom_gns3fy.py` Node/Link/Project wrappers were removed)
|
||||
- `gns3server/agent/gns3_copilot/gns3_client/project_inventory.py` — nodes/links aggregation feeding the topology context and Nornir inventory
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@ -94,9 +94,9 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt
|
||||
|
||||
## Available Tools
|
||||
|
||||
**82 tools** across 12 categories:
|
||||
**76 tools** across 11 categories:
|
||||
|
||||
### Project (15)
|
||||
### Project (14)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
@ -113,7 +113,6 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt
|
||||
| `project_readme_update` | Update project README |
|
||||
| `project_lock` | Lock project (prevent edits) |
|
||||
| `project_unlock` | Unlock project |
|
||||
| `project_load` | Load project from path |
|
||||
| `project_locked` | Check if project is locked |
|
||||
|
||||
### Node (22)
|
||||
@ -192,16 +191,11 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt
|
||||
| `drawing_update` | Update drawing (position, rotation, SVG) |
|
||||
| `drawing_delete` | Delete a drawing |
|
||||
|
||||
### Symbol (6)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `symbol_list` | List all symbols |
|
||||
| `symbol_get` | Get symbol download URL |
|
||||
| `symbol_dimensions` | Get symbol dimensions |
|
||||
| `symbol_defaults` | Get default symbol mapping |
|
||||
| `symbol_upload` | Upload a custom symbol (SVG content) |
|
||||
| `symbol_delete` | Delete a custom symbol (built-in: 403) |
|
||||
<!--
|
||||
Symbol tools (symbol_list / symbol_get / symbol_dimensions /
|
||||
symbol_defaults / symbol_upload / symbol_delete) are disabled for now:
|
||||
they require a vision-capable model to be genuinely useful. Revisit later.
|
||||
-->
|
||||
|
||||
### Appliance (3)
|
||||
|
||||
@ -440,7 +434,7 @@ sequenceDiagram
|
||||
- **FastMCP** (Anthropic MCP SDK) is used for tool registration and SSE transport
|
||||
- The SSE app is mounted as a Starlette sub-application under `/v3/mcp/transport`
|
||||
- **Auth:** JWT validation via `auth_service`. API key (`gns3_<uuid>_<secret>`) extracts UUID for O(1) DB lookup, runs bcrypt in thread pool, returns a fresh JWT — subsequent calls use the JWT with zero extra bcrypt.
|
||||
- Tool handlers use `Gns3Connector` (from `custom_gns3fy`) to call GNS3's own REST API, keeping the MCP layer decoupled
|
||||
- Tool handlers use `Gns3Connector` (from `gns3_copilot.gns3_client.connector`) via the shared handler layer (`gns3_copilot.gns3_client.api_handlers`), keeping the MCP layer decoupled
|
||||
- The JWT token is stored in a `contextvars.ContextVar` so it is available within tool handler threads (Python ≥ 3.9 propagates contextvars through `asyncio.to_thread`)
|
||||
|
||||
### Console WebSocket
|
||||
|
||||
@ -114,8 +114,8 @@ if await asyncio.to_thread(bcrypt.checkpw, secret.encode(), db_key.key_hash.enco
|
||||
| Node creation Pool | 5 | 100 | `controller/project.py` |
|
||||
| Link creation Pool | 5 | 100 | `controller/project.py` |
|
||||
| MCP BATCH_MAX_WORKERS | 10 | 100 | `agent/mcp/nodes.py` |
|
||||
| MCP HTTP timeout | 10s | 30s | `agent/gns3_copilot/gns3_client/custom_gns3fy.py` |
|
||||
| HTTP connection pool | 10 (default) | 500/1000 | `agent/gns3_copilot/gns3_client/custom_gns3fy.py` |
|
||||
| MCP HTTP timeout | 10s | 30s | `agent/gns3_copilot/gns3_client/connector.py` |
|
||||
| HTTP connection pool | 10 (default) | 500/1000 | `agent/gns3_copilot/gns3_client/connector.py` |
|
||||
| Start nodes Pool | 3 | 3 (unchanged) | `controller/project.py` |
|
||||
|
||||
### 8. MCP Auth Returns JWT
|
||||
@ -144,5 +144,5 @@ if user:
|
||||
| `gns3server/agent/mcp/__init__.py` | Auth returns JWT, tool enhancements |
|
||||
| `gns3server/agent/mcp/nodes.py` | fields filter, inherited template_id, name passthrough |
|
||||
| `gns3server/agent/mcp/links.py` | fields filter, compact array format |
|
||||
| `gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py` | Timeout 30s, connection pool 500/1000 |
|
||||
| `gns3server/agent/gns3_copilot/gns3_client/connector.py` | Timeout 30s, connection pool 500/1000 |
|
||||
| `gns3server/utils/images.py` | md5sum cache error → warning |
|
||||
|
||||
@ -516,29 +516,39 @@ gns3server/agent/gns3_copilot/tools_v2/
|
||||
|
||||
### API Integration
|
||||
|
||||
The tools use the `Node` and `Link` classes from `custom_gns3fy`:
|
||||
The tools call the shared REST handler layer (`gns3_copilot.gns3_client.api_handlers`), the same functions the MCP service exposes as MCP tools:
|
||||
|
||||
```python
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node, Link, get_gns3_connector
|
||||
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,
|
||||
)
|
||||
|
||||
# Get templates
|
||||
templates = get_gns3_connector().get_templates()
|
||||
gns3_ctx = build_gns3_ctx() # JWT + server URL from the request context
|
||||
|
||||
# Create node
|
||||
node = Node(project_id=project_id, template_id=template_id, x=x, y=y, connector=gns3_server)
|
||||
node.create()
|
||||
# Create node (single POST, batch mode is parallel)
|
||||
created = create_node_handler(
|
||||
{"project_id": project_id, "template_id": template_id, "x": x, "y": y, "name": name},
|
||||
gns3_ctx,
|
||||
)
|
||||
|
||||
# Create link
|
||||
link = Link(project_id=project_id, connector=gns3_server, nodes=[...])
|
||||
link.create()
|
||||
link = create_link_handler(
|
||||
{"project_id": project_id, "nodes": [{"node_id": nid, "adapter_number": 0, "port_number": 0}, ...]},
|
||||
gns3_ctx,
|
||||
)
|
||||
|
||||
# Update node name
|
||||
node = Node(project_id=project_id, node_id=node_id, connector=gns3_server)
|
||||
node.update(name=new_name)
|
||||
# Update node name — the PUT response is the updated node
|
||||
updated = update_node_handler({"project_id": project_id, "node_id": node_id, "name": new_name}, gns3_ctx)
|
||||
|
||||
# Start/stop/suspend node
|
||||
node = Node(project_id=project_id, node_id=node_id, connector=gns3_server)
|
||||
node.start() # or node.stop() / node.suspend()
|
||||
# Start/stop/suspend nodes (node_ids batch runs in parallel)
|
||||
start_node_handler({"project_id": project_id, "node_ids": [nid1, nid2]}, gns3_ctx)
|
||||
stop_node_handler({"project_id": project_id, "node_ids": [nid1, nid2]}, gns3_ctx)
|
||||
suspend_node_handler({"project_id": project_id, "node_ids": [nid1, nid2]}, gns3_ctx)
|
||||
|
||||
# Node listing/status (single call for the whole project)
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
```
|
||||
|
||||
### Progress Tracking
|
||||
|
||||
@ -26,27 +26,27 @@
|
||||
"""
|
||||
GNS3 Client Package
|
||||
|
||||
This package provides a Python interface for interacting with GNS3 servers.
|
||||
Adapted from the upstream gns3fy project with modifications for compatibility
|
||||
with langchain and reduced dependency conflicts.
|
||||
This package provides the shared GNS3 REST client layer:
|
||||
|
||||
Main classes:
|
||||
- Gns3Connector: Connector for GNS3 server API interaction
|
||||
- Project: GNS3 Project management
|
||||
- Node: GNS3 Node management
|
||||
- Link: GNS3 Link management
|
||||
- GNS3TopologyTool: GNS3 topology reading tool
|
||||
- GNS3ProjectInfoTool: GNS3 project info tool
|
||||
- Gns3Connector (connector.py): authenticated HTTP session for the
|
||||
controller API — v2 basic / v3 JWT auth, token refresh, error extraction
|
||||
- api_handlers.py: endpoint handlers taking ``(params, gns3_ctx)`` dicts,
|
||||
shared by the copilot tools and the MCP service
|
||||
- project_inventory.py: nodes/links aggregation for the topology context
|
||||
- GNS3TopologyTool / GNS3ProjectInfoTool: LangChain reader tools
|
||||
|
||||
Main functions:
|
||||
- get_gns3_connector: Factory function to create Gns3Connector
|
||||
- get_gns3_connector_with_llm_config: Create connector AND retrieve LLM config
|
||||
- get_gns3_server_host: Get GNS3 server hostname from Controller or Config
|
||||
- get_llm_config: Get LLM model configuration for a user
|
||||
- get_llm_config: Get user's default LLM config with API key
|
||||
|
||||
Upstream gns3fy: https://github.com/davidban77/gns3fy
|
||||
The connector is adapted from the upstream gns3fy project
|
||||
(https://github.com/davidban77/gns3fy).
|
||||
"""
|
||||
|
||||
from .api_handlers import build_gns3_ctx
|
||||
from .connector import Gns3Connector
|
||||
from .connector_factory import get_gns3_connector
|
||||
from .connector_factory import get_gns3_connector_with_llm_config
|
||||
from .connector_factory import get_gns3_server_host
|
||||
@ -55,13 +55,6 @@ from .context_helpers import get_current_jwt_token
|
||||
from .context_helpers import get_current_llm_config
|
||||
from .context_helpers import set_current_jwt_token
|
||||
from .context_helpers import set_current_llm_config
|
||||
from .custom_gns3fy import CONSOLE_TYPES
|
||||
from .custom_gns3fy import LINK_TYPES
|
||||
from .custom_gns3fy import NODE_TYPES
|
||||
from .custom_gns3fy import Gns3Connector
|
||||
from .custom_gns3fy import Link
|
||||
from .custom_gns3fy import Node
|
||||
from .custom_gns3fy import Project
|
||||
from .gns3_project_info import GNS3ProjectInfoTool
|
||||
from .gns3_topology_reader import GNS3TopologyTool
|
||||
|
||||
@ -79,12 +72,7 @@ __url__ = "https://github.com/yueguobin/gns3-copilot"
|
||||
|
||||
__all__ = [
|
||||
"Gns3Connector",
|
||||
"Project",
|
||||
"Node",
|
||||
"Link",
|
||||
"NODE_TYPES",
|
||||
"CONSOLE_TYPES",
|
||||
"LINK_TYPES",
|
||||
"build_gns3_ctx",
|
||||
"GNS3TopologyTool",
|
||||
"GNS3ProjectInfoTool",
|
||||
"get_gns3_connector",
|
||||
|
||||
997
gns3server/agent/gns3_copilot/gns3_client/api_handlers.py
Normal file
997
gns3server/agent/gns3_copilot/gns3_client/api_handlers.py
Normal file
@ -0,0 +1,997 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Shared GNS3 REST API handler layer.
|
||||
|
||||
Handlers receive ``(params: dict, gns3_ctx: dict)`` and call the GNS3 REST
|
||||
API directly via ``Gns3Connector.http_call`` — no ORM-style wrapper objects.
|
||||
|
||||
This module is the single implementation shared by two consumers:
|
||||
|
||||
- the MCP service (``gns3server.agent.mcp``) re-exports these handlers as
|
||||
MCP tools, and
|
||||
- gns3-copilot tools (``tools_v2``) call them directly.
|
||||
|
||||
``gns3_ctx`` carries the per-request connection info:
|
||||
|
||||
- ``server_url`` (str): GNS3 server base URL
|
||||
- ``jwt_token`` (str): a JWT — API keys must be exchanged for a JWT by the
|
||||
entry point before calling handlers (see ``mcp._resolve_token``)
|
||||
- ``jwt_username`` / ``jwt_token_version`` (optional): only needed by
|
||||
handlers that mint short-lived tokens for console/download URLs
|
||||
|
||||
Copilot-side callers build the context with :func:`build_gns3_ctx`, which
|
||||
pulls the request-scoped user JWT from the context variables.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from gns3server.services import auth_service
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BATCH_MAX_WORKERS = 100
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Maximum bytes to return from get_node_file (safety net).
|
||||
# Larger files are truncated with a truncated=True flag.
|
||||
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",
|
||||
# Node
|
||||
"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_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
The JWT is taken from the request-scoped context variable when not
|
||||
passed explicitly (mirroring ``get_gns3_connector``); the URL uses the
|
||||
same Controller → Config → fallback detection order.
|
||||
|
||||
Returns None when no JWT token is available.
|
||||
"""
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector_factory import (
|
||||
_detect_url_for_api,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
|
||||
get_current_jwt_token,
|
||||
)
|
||||
|
||||
token = jwt_token or get_current_jwt_token()
|
||||
if not token:
|
||||
return None
|
||||
return {
|
||||
"server_url": url or _detect_url_for_api(),
|
||||
"jwt_token": token,
|
||||
"jwt_username": None,
|
||||
"jwt_token_version": 0,
|
||||
}
|
||||
|
||||
|
||||
def _filter_node_response(node: dict, fields: list[str] = None) -> dict:
|
||||
"""Filter node response to only include requested fields."""
|
||||
if not fields:
|
||||
fields = ["node_id", "name", "node_type", "status", "console"]
|
||||
return {k: node[k] for k in fields if k in node}
|
||||
|
||||
|
||||
def _filter_link_response(link: dict, fields: list[str] = None) -> dict:
|
||||
"""Filter link response to only include requested fields."""
|
||||
if not fields:
|
||||
fields = LINK_DEFAULT_FIELDS
|
||||
return {k: link[k] for k in fields if k in link}
|
||||
|
||||
|
||||
def _normalize_link_nodes(nodes) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Normalize link node entries, accepting both standard object format and
|
||||
compact array format to reduce token usage.
|
||||
|
||||
Standard: [{"node_id": "uuid", "adapter_number": 0, "port_number": 0}]
|
||||
Compact: ["uuid", 0, 0, "uuid", 0, 0]
|
||||
|
||||
Returns the normalized list, or raises ValueError with a clear message
|
||||
on format errors so the AI can self-correct.
|
||||
"""
|
||||
if not nodes:
|
||||
return nodes
|
||||
if not isinstance(nodes, list):
|
||||
raise ValueError(f"nodes must be a list, got {type(nodes).__name__}: {nodes}")
|
||||
# Standard object format: [{"node_id": "...", ...}]
|
||||
if isinstance(nodes[0], dict):
|
||||
return nodes
|
||||
# Compact array format: ["uuid", ad, pt, "uuid", ad, pt"]
|
||||
if all(not isinstance(n, dict) for n in nodes):
|
||||
if len(nodes) != 6:
|
||||
raise ValueError(
|
||||
f"Compact link format requires exactly 6 elements "
|
||||
f"[node_id, adapter, port, node_id, adapter, port], "
|
||||
f"but got {len(nodes)} elements: {nodes}"
|
||||
)
|
||||
if not isinstance(nodes[0], str) or not isinstance(nodes[3], str):
|
||||
raise ValueError(
|
||||
f"Compact link format expects node_id (string) at positions 0 and 3, "
|
||||
f"got types {type(nodes[0]).__name__} and {type(nodes[3]).__name__}: {nodes}"
|
||||
)
|
||||
return [
|
||||
{"node_id": nodes[0], "adapter_number": nodes[1], "port_number": nodes[2]},
|
||||
{"node_id": nodes[3], "adapter_number": nodes[4], "port_number": nodes[5]},
|
||||
]
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
# ── 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:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
nodes = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes").json()
|
||||
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\"]"}
|
||||
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_NODE_FIELDS),
|
||||
}
|
||||
nodes = [{k: n[k] for k in fields if k in n} for n in nodes]
|
||||
return {"nodes": nodes, "count": len(nodes)}
|
||||
|
||||
|
||||
def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
|
||||
|
||||
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\"]"}
|
||||
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_NODE_FIELDS),
|
||||
}
|
||||
return {k: node[k] for k in fields if k in node}
|
||||
|
||||
return node
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
if not isinstance(node_ids, list):
|
||||
return {"error": "node_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_lifecycle(project_id, node_ids, "start", conn, "started")
|
||||
node_id = params.get("node_id")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/start", json_data={})
|
||||
return {"message": f"Node {node_id} started", "node_id": node_id}
|
||||
|
||||
|
||||
def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
if not isinstance(node_ids, list):
|
||||
return {"error": "node_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_lifecycle(project_id, node_ids, "stop", conn, "stopped")
|
||||
node_id = params.get("node_id")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/stop", json_data={})
|
||||
return {"message": f"Node {node_id} stopped", "node_id": node_id}
|
||||
|
||||
|
||||
def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
if not isinstance(node_ids, list):
|
||||
return {"error": "node_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_lifecycle(project_id, node_ids, "suspend", conn, "suspended")
|
||||
node_id = params.get("node_id")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/suspend")
|
||||
return {"message": f"Node {node_id} suspended", "node_id": node_id}
|
||||
|
||||
|
||||
def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
|
||||
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\"]"}
|
||||
|
||||
nodes = params.get("nodes")
|
||||
# Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}]
|
||||
# When top-level template_id is set, it applies to all nodes as a default
|
||||
if nodes is not None:
|
||||
if not isinstance(nodes, list) or not nodes:
|
||||
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:
|
||||
return {"template_id": tid, "status": "error", "error": "template_id is required"}
|
||||
try:
|
||||
url = f"{conn.base_url}/projects/{project_id}/templates/{tid}"
|
||||
body = {
|
||||
"x": node_data.get("x", 0),
|
||||
"y": node_data.get("y", 0),
|
||||
"compute_id": node_data.get("compute_id", "local"),
|
||||
}
|
||||
node_name = node_data.get("name")
|
||||
if node_name:
|
||||
body["name"] = node_name
|
||||
resp = conn.http_call("post", url, json_data=body).json()
|
||||
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
|
||||
# arrival order depend on thread scheduling. Batches that rely on
|
||||
# default naming are therefore created sequentially so those
|
||||
# server-side assignments follow the submission order; batches
|
||||
# where every node has an explicit name stay parallel.
|
||||
return [_create_one(node) for node in nodes]
|
||||
with ThreadPoolExecutor(max_workers=min(len(nodes), BATCH_MAX_WORKERS)) as pool:
|
||||
# pool.map keeps the submission order, so callers can correlate
|
||||
# results with the nodes they sent regardless of completion order
|
||||
return list(pool.map(_create_one, nodes))
|
||||
|
||||
# Single mode
|
||||
template_id = params.get("template_id")
|
||||
if not template_id:
|
||||
return {"error": "template_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {
|
||||
"x": params.get("x", 0),
|
||||
"y": params.get("y", 0),
|
||||
"compute_id": params.get("compute_id", "local"),
|
||||
}
|
||||
node_name = params.get("name")
|
||||
if node_name:
|
||||
data["name"] = node_name
|
||||
url = f"{conn.base_url}/projects/{project_id}/templates/{template_id}"
|
||||
resp = conn.http_call("post", url, json_data=data).json()
|
||||
return _filter_node_response(resp, fields)
|
||||
|
||||
|
||||
def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
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")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}")
|
||||
return {"message": f"Node {node_id} deleted", "node_id": node_id}
|
||||
|
||||
|
||||
def update_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
# Extract update parameters - handle nested kwargs structure from MCP clients
|
||||
if "kwargs" in params and isinstance(params["kwargs"], dict):
|
||||
update_data = params["kwargs"]
|
||||
else:
|
||||
update_data = {k: v for k, v in params.items() if k not in ("project_id", "node_id", "kwargs")}
|
||||
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}"
|
||||
return conn.http_call("put", url, json_data=update_data).json()
|
||||
|
||||
|
||||
def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
|
||||
|
||||
console_type = node.get("console_type", "unknown")
|
||||
# Short-lived JWT for the WebSocket URL (10 min)
|
||||
username = gns3_ctx.get("jwt_username")
|
||||
ws_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
|
||||
if ws_token:
|
||||
raw_url += f"?token={ws_token}"
|
||||
# Convert http scheme to ws for direct websocat usage
|
||||
ws_url = raw_url.replace("https://", "wss://").replace("http://", "ws://")
|
||||
|
||||
result = {
|
||||
"node_id": node_id,
|
||||
"node_name": node.get("name"),
|
||||
"console_type": console_type,
|
||||
"ws_url": ws_url,
|
||||
"command": f"websocat -t --no-close {ws_url}",
|
||||
}
|
||||
if ws_token:
|
||||
# Fingerprint of the minted token: compare it against what actually reached the
|
||||
# server (logged on WebSocket auth rejection) to detect copy corruption, and
|
||||
# re-request the URL once token_ttl_seconds has elapsed.
|
||||
result["token_sha256_prefix"] = hashlib.sha256(ws_token.encode()).hexdigest()[:8]
|
||||
result["token_ttl_seconds"] = 600
|
||||
if console_type in ("vnc",):
|
||||
result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}"
|
||||
return result
|
||||
|
||||
|
||||
# ── Node file handlers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def list_node_files_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files"
|
||||
query = {}
|
||||
if params.get("path"):
|
||||
query["path"] = params["path"]
|
||||
if params.get("recursive"):
|
||||
query["recursive"] = "true"
|
||||
files = conn.http_call("get", url, params=query if query else None).json()
|
||||
return {"files": files, "count": len(files)}
|
||||
|
||||
|
||||
def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
file_path = params.get("file_path")
|
||||
if not project_id or not node_id or not file_path:
|
||||
return {"error": "project_id, node_id and file_path are required"}
|
||||
|
||||
offset = params.get("offset", 0)
|
||||
limit = params.get("limit", 200)
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
|
||||
raw = conn.http_call("get", url).text
|
||||
|
||||
total_bytes = len(raw.encode("utf-8"))
|
||||
truncated = False
|
||||
if total_bytes > MAX_NODE_FILE_BYTES:
|
||||
raw = raw[:MAX_NODE_FILE_BYTES]
|
||||
truncated = True
|
||||
|
||||
# keepends keeps the content byte-faithful: the trailing newline of the
|
||||
# last line and any \r\n endings survive the round trip
|
||||
lines = raw.splitlines(keepends=True)
|
||||
total_lines = len(lines)
|
||||
|
||||
# Apply offset/limit
|
||||
selected = lines[offset: offset + limit] if offset < total_lines else []
|
||||
has_more = (offset + limit) < total_lines or truncated
|
||||
content = "".join(selected)
|
||||
|
||||
return {
|
||||
"file_path": file_path,
|
||||
"content": content,
|
||||
"metadata": {
|
||||
"total_lines": total_lines,
|
||||
"total_bytes": total_bytes,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"returned_lines": len(selected),
|
||||
"returned_bytes": len(content.encode("utf-8")),
|
||||
"truncated": truncated or has_more,
|
||||
"has_more": has_more,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
file_path = params.get("file_path")
|
||||
content = params.get("content")
|
||||
if not project_id or not node_id or not file_path or content is None:
|
||||
return {"error": "project_id, node_id, file_path and content are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
|
||||
conn.http_call("post", url, data=content, headers={"Content-Type": "text/plain"})
|
||||
return {"message": f"File {file_path} written to node {node_id}", "file_path": file_path, "node_id": node_id}
|
||||
|
||||
|
||||
def delete_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
file_path = params.get("file_path")
|
||||
if not project_id or not node_id or not file_path:
|
||||
return {"error": "project_id, node_id and file_path are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
|
||||
conn.http_call("delete", url)
|
||||
return {"message": f"File {file_path} deleted from node {node_id}", "file_path": file_path, "node_id": node_id}
|
||||
|
||||
|
||||
# ── Node bulk / advanced handlers ────────────────────────────────────────
|
||||
|
||||
|
||||
def start_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/start")
|
||||
return {"message": "All nodes started", "project_id": project_id}
|
||||
|
||||
|
||||
def stop_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/stop")
|
||||
return {"message": "All nodes stopped", "project_id": project_id}
|
||||
|
||||
|
||||
def suspend_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/suspend")
|
||||
return {"message": "All nodes suspended", "project_id": project_id}
|
||||
|
||||
|
||||
def duplicate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
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()
|
||||
return {"message": f"Node {node_id} duplicated", "node": result}
|
||||
|
||||
|
||||
def isolate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/isolate")
|
||||
return {"message": f"Node {node_id} isolated (all links suspended)", "node_id": node_id}
|
||||
|
||||
|
||||
def unisolate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/unisolate")
|
||||
return {"message": f"Node {node_id} unisolated (links resumed)", "node_id": node_id}
|
||||
|
||||
|
||||
def get_node_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
links = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/links").json()
|
||||
return {"links": links, "count": len(links)}
|
||||
|
||||
|
||||
# ── 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:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
links = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links").json()
|
||||
fields = params.get("fields")
|
||||
if fields:
|
||||
if not isinstance(fields, list):
|
||||
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 {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_LINK_FIELDS),
|
||||
}
|
||||
links = [{k: link[k] for k in fields if k in link} for link in links]
|
||||
return {"links": links, "count": len(links)}
|
||||
|
||||
|
||||
def get_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
link_id = params.get("link_id")
|
||||
if not project_id or not link_id:
|
||||
return {"error": "project_id and link_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links/{link_id}").json()
|
||||
|
||||
|
||||
def available_filters_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
List the packet filter types available for a link (GNS3 API v3 only).
|
||||
|
||||
Returns a list of filter descriptors (frequency_drop, packet_loss,
|
||||
delay, corrupt, bpf) with their parameters.
|
||||
"""
|
||||
project_id = params.get("project_id")
|
||||
link_id = params.get("link_id")
|
||||
if not project_id or not link_id:
|
||||
return {"error": "project_id and link_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/available_filters"
|
||||
return conn.http_call("get", url).json()
|
||||
|
||||
|
||||
def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
|
||||
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\"]"}
|
||||
|
||||
links = params.get("links")
|
||||
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}]
|
||||
if links is not None:
|
||||
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:
|
||||
return {"status": "error", "error": "nodes is required for each link"}
|
||||
try:
|
||||
body = {"nodes": _normalize_link_nodes(raw_nodes)}
|
||||
if link_data.get("link_type"):
|
||||
body["link_type"] = link_data["link_type"]
|
||||
if link_data.get("filters"):
|
||||
body["filters"] = link_data["filters"]
|
||||
if link_data.get("suspend"):
|
||||
body["suspend"] = link_data["suspend"]
|
||||
url = f"{conn.base_url}/projects/{project_id}/links"
|
||||
resp = conn.http_call("post", url, json_data=body).json()
|
||||
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
|
||||
return list(pool.map(_create_one, links))
|
||||
|
||||
# Single mode
|
||||
nodes = params.get("nodes")
|
||||
if not nodes:
|
||||
return {"error": "nodes is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {"nodes": _normalize_link_nodes(nodes)}
|
||||
if "link_type" in params:
|
||||
data["link_type"] = params["link_type"]
|
||||
if "filters" in params:
|
||||
data["filters"] = params["filters"]
|
||||
if "suspend" in params:
|
||||
data["suspend"] = params["suspend"]
|
||||
url = f"{conn.base_url}/projects/{project_id}/links"
|
||||
resp = conn.http_call("post", url, json_data=data).json()
|
||||
return _filter_link_response(resp, fields)
|
||||
|
||||
|
||||
def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
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")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{link_id}")
|
||||
return {"message": f"Link {link_id} deleted", "link_id": link_id}
|
||||
|
||||
|
||||
def update_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
link_id = params.get("link_id")
|
||||
if not project_id or not link_id:
|
||||
return {"error": "project_id and link_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
# Extract update parameters - handle nested kwargs structure from MCP clients
|
||||
if "kwargs" in params and isinstance(params["kwargs"], dict):
|
||||
update_data = params["kwargs"]
|
||||
else:
|
||||
update_data = {k: v for k, v in params.items() if k not in ("project_id", "link_id", "kwargs")}
|
||||
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}"
|
||||
return conn.http_call("put", url, json_data=update_data).json()
|
||||
|
||||
|
||||
# ── Link capture / reset handlers ──────────────────────────────────────
|
||||
|
||||
|
||||
def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
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"
|
||||
r = conn.http_call("post", url).json()
|
||||
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")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/reset"
|
||||
result = conn.http_call("post", url).json()
|
||||
return {"message": f"Link {link_id} reset", "link": result}
|
||||
|
||||
|
||||
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}"
|
||||
kwargs = data_builder(lid) if data_builder else {}
|
||||
conn.http_call("post", url, **kwargs)
|
||||
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))
|
||||
|
||||
|
||||
def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
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:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {
|
||||
"data_link_type": params.get("data_link_type", "DLT_EN10MB"),
|
||||
"wireshark": params.get("wireshark", False),
|
||||
}
|
||||
if params.get("capture_file_name"):
|
||||
data["capture_file_name"] = params["capture_file_name"]
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/start"
|
||||
result = conn.http_call("post", url, json_data=data).json()
|
||||
return {"message": f"Capture started on link {link_id}", "link": result}
|
||||
|
||||
|
||||
def stop_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_capture(project_id, link_ids, "stop", None, conn)
|
||||
link_id = params.get("link_id")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/stop"
|
||||
conn.http_call("post", url)
|
||||
return {"message": f"Capture stopped on link {link_id}", "link_id": link_id}
|
||||
|
||||
|
||||
def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
username = gns3_ctx.get("jwt_username")
|
||||
download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
results = []
|
||||
for lid in link_ids:
|
||||
url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{lid}/capture/file"
|
||||
entry = {"link_id": lid, "download_url": url}
|
||||
if download_token:
|
||||
cmd = f"curl -L -o capture_{lid}.pcap -H 'Authorization: Bearer {download_token}' '{url}'"
|
||||
entry["curl_command"] = cmd
|
||||
results.append(entry)
|
||||
return {"downloads": results, "count": len(results), "note": "Files are in pcap format. Links include a 10-minute token."}
|
||||
|
||||
link_id = params.get("link_id")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
download_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{link_id}/capture/file"
|
||||
result = {
|
||||
"link_id": link_id,
|
||||
"download_url": download_url,
|
||||
"note": "The file is in pcap format and can be analyzed with Wireshark or tcpdump.",
|
||||
}
|
||||
if download_token:
|
||||
result["curl_command"] = f"curl -L -o capture.pcap -H 'Authorization: Bearer {download_token}' '{download_url}'"
|
||||
result["note"] += " The download link includes a 10-minute token."
|
||||
return result
|
||||
|
||||
|
||||
# ── Marker (traffic-insight) handlers ──────────────────────────────────
|
||||
|
||||
|
||||
def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Manage traffic-insight markers on a specific link.
|
||||
|
||||
Actions:
|
||||
- create: POST /projects/{pid}/links/{lid}/markers
|
||||
- update: PUT /projects/{pid}/links/{lid}/markers/{name}
|
||||
- delete: DELETE /projects/{pid}/links/{lid}/markers/{name}
|
||||
"""
|
||||
project_id = params.get("project_id")
|
||||
link_id = params.get("link_id")
|
||||
action = params.get("action")
|
||||
if not all([project_id, link_id, action]):
|
||||
return {"error": "project_id, link_id and action are required"}
|
||||
if action not in ("create", "update", "delete"):
|
||||
return {"error": f"Unknown action: {action}. Supported: create, update, delete"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
base = f"{conn.base_url}/projects/{project_id}/links/{link_id}/markers"
|
||||
|
||||
if action == "create":
|
||||
bpf = params.get("bpf")
|
||||
if not bpf:
|
||||
return {"error": "bpf is required for create action"}
|
||||
body: dict[str, Any] = {"bpf": bpf}
|
||||
for opt in ("name", "tag", "capture_node_id", "color", "highlight_duration", "data_link_type"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
# direction: "tx"/"rx" set a one-way filter; "both"/omitted = no filter.
|
||||
if params.get("direction") in ("tx", "rx"):
|
||||
body["direction"] = params["direction"]
|
||||
return conn.http_call("post", base, json_data=body).json()
|
||||
|
||||
marker_name = params.get("marker_name")
|
||||
if not marker_name:
|
||||
return {"error": "marker_name is required for update/delete actions"}
|
||||
|
||||
url = f"{base}/{marker_name}"
|
||||
|
||||
if action == "update":
|
||||
body = {}
|
||||
for opt in ("bpf", "tag", "enabled", "color", "highlight_duration"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
# direction tri-state: omitted=preserve, "tx"/"rx"=set, "both"=clear (→ null).
|
||||
direction = params.get("direction")
|
||||
if direction == "both":
|
||||
body["direction"] = None
|
||||
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 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}
|
||||
|
||||
|
||||
def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Manage project-level marker definitions (auto-fanout to all links).
|
||||
|
||||
Actions:
|
||||
- create: POST /projects/{pid}/marker-definitions → fans out global-{name} to every link
|
||||
- update: PUT /projects/{pid}/marker-definitions/{name}
|
||||
- delete: DELETE /projects/{pid}/marker-definitions/{name}
|
||||
- list: GET /projects/{pid}/marker-definitions
|
||||
"""
|
||||
project_id = params.get("project_id")
|
||||
action = params.get("action")
|
||||
if not all([project_id, action]):
|
||||
return {"error": "project_id and action are required"}
|
||||
if action not in ("create", "update", "delete", "list"):
|
||||
return {"error": f"Unknown action: {action}. Supported: create, update, delete, list"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
base = f"{conn.base_url}/projects/{project_id}/marker-definitions"
|
||||
|
||||
if action == "list":
|
||||
return conn.http_call("get", base).json()
|
||||
|
||||
if action == "create":
|
||||
bpf = params.get("bpf")
|
||||
if not bpf:
|
||||
return {"error": "bpf is required for create action"}
|
||||
body: dict[str, Any] = {"bpf": bpf}
|
||||
for opt in ("name", "tag", "color", "highlight_duration", "data_link_type"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
# No direction: a definition fans out to every link and auto-selects its
|
||||
# capture node on each, so tx/rx (which is relative to that node) has no
|
||||
# consistent meaning. Encode direction in the BPF instead.
|
||||
return conn.http_call("post", base, json_data=body).json()
|
||||
|
||||
def_name = params.get("def_name")
|
||||
if not def_name:
|
||||
return {"error": "def_name is required for update/delete actions"}
|
||||
|
||||
url = f"{base}/{def_name}"
|
||||
|
||||
if action == "update":
|
||||
body = {}
|
||||
for opt in ("bpf", "tag", "color", "highlight_duration", "data_link_type"):
|
||||
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 conn.http_call("put", url, json_data=body).json()
|
||||
|
||||
# action == "delete"
|
||||
conn.http_call("delete", url)
|
||||
return {"message": f"Marker definition '{def_name}' deleted", "project_id": project_id, "def_name": def_name}
|
||||
309
gns3server/agent/gns3_copilot/gns3_client/connector.py
Normal file
309
gns3server/agent/gns3_copilot/gns3_client/connector.py
Normal file
@ -0,0 +1,309 @@
|
||||
# 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 REST API connector.
|
||||
|
||||
A minimal authenticated HTTP session for the GNS3 controller API: URL/base
|
||||
URL handling, v2 basic / v3 JWT authentication and token refresh, plus GNS3
|
||||
error extraction. Callers make requests through ``http_call`` — the
|
||||
endpoint-specific logic lives in ``api_handlers``.
|
||||
|
||||
The class is adapted from the upstream gns3fy project
|
||||
(https://github.com/davidban77/gns3fy) Gns3Connector.
|
||||
|
||||
⚠️ WARNING: This module is shared with the MCP (Model Context Protocol)
|
||||
service. Modifications must be tested with BOTH gns3-copilot AND MCP.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
import urllib3
|
||||
from requests import HTTPError
|
||||
|
||||
|
||||
class Gns3Connector:
|
||||
"""
|
||||
Connector to be used for interaction against the GNS3 server controller API.
|
||||
|
||||
**Attributes:**
|
||||
|
||||
- `url` (str): URL of the GNS3 server (**required**)
|
||||
- `user` (str): User used for authentication
|
||||
- `cred` (str): Password used for authentication
|
||||
- `jwt_token` (str): JWT token for direct authentication (API v3)
|
||||
- `verify` (bool): Whether or not to verify SSL
|
||||
- `api_version` (int): GNS3 server REST API version
|
||||
- `api_calls`: Counter of amount of `http_calls` has been performed
|
||||
- `base_url`: url passed + api_version
|
||||
- `session`: Requests Session object
|
||||
|
||||
**Returns:**
|
||||
|
||||
`Gns3Connector` instance
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
>>> # API v2 with basic auth
|
||||
>>> server = Gns3Connector(
|
||||
... url="http://<address>:3080", user="admin", cred="password",
|
||||
... api_version=2
|
||||
... )
|
||||
>>> # API v3 with username/password (auto-fetches JWT token)
|
||||
>>> server = Gns3Connector(
|
||||
... url="http://<address>:3080", user="admin", cred="password",
|
||||
... api_version=3
|
||||
... )
|
||||
>>> # API v3 with direct JWT token
|
||||
>>> server = Gns3Connector(
|
||||
... url="http://<address>:3080",
|
||||
... jwt_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
... api_version=3
|
||||
... )
|
||||
>>> print(server.http_call("get", f"{server.base_url}/version").json())
|
||||
{'local': False, 'version': '2.2.0b4'}
|
||||
```
|
||||
"""
|
||||
|
||||
access_token: str | None
|
||||
token_expiry: float | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str | None = None,
|
||||
user: str | None = None,
|
||||
cred: str | None = None,
|
||||
jwt_token: str | None = None,
|
||||
verify: bool = False,
|
||||
api_version: int = 2,
|
||||
) -> None:
|
||||
# Disable SSL warnings
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
if url is None:
|
||||
raise ValueError("URL is required for Gns3Connector")
|
||||
self.url = url.strip("/") # Store original URL for reference
|
||||
self.base_url = f"{self.url}/v{api_version}"
|
||||
self.user = user
|
||||
self.cred = cred
|
||||
self.headers = {"Content-Type": "application/json"}
|
||||
self.verify = verify
|
||||
self.api_calls = 0
|
||||
|
||||
# v3 authentication attributes
|
||||
# If jwt_token is provided directly, use it; otherwise will be
|
||||
# fetched via username/password
|
||||
self.access_token = jwt_token
|
||||
self.token_expiry = None
|
||||
self.auth_type = "basic" if api_version == 2 else "jwt"
|
||||
self.api_version = api_version
|
||||
|
||||
# Create session object
|
||||
self._create_session()
|
||||
|
||||
def _create_session(self) -> None:
|
||||
"""
|
||||
Creates the requests.Session object and applies the necessary parameters
|
||||
"""
|
||||
self.session = requests.Session() # pragma: no cover
|
||||
# Increase connection pool size to support concurrent MCP batch operations
|
||||
adapter = requests.adapters.HTTPAdapter(pool_connections=500, pool_maxsize=1000)
|
||||
self.session.mount("http://", adapter)
|
||||
self.session.mount("https://", adapter)
|
||||
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
|
||||
):
|
||||
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}"
|
||||
)
|
||||
|
||||
def _authenticate_v3(self) -> None:
|
||||
"""
|
||||
Performs v3 API authentication using username and password to get JWT token.
|
||||
Skips authentication if a JWT token is already provided.
|
||||
"""
|
||||
# If token is already provided, skip authentication
|
||||
if self.access_token:
|
||||
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"
|
||||
)
|
||||
|
||||
# Construct authentication URL (v3 API uses different base URL)
|
||||
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
|
||||
temp_session = requests.Session()
|
||||
temp_session.headers["Content-Type"] = "application/json"
|
||||
|
||||
try:
|
||||
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}"
|
||||
)
|
||||
else:
|
||||
raise HTTPError(
|
||||
f"v3 API authentication failed: {response.status_code} - "
|
||||
f"{response.text}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPError(f"v3 API authentication error: {str(e)}") from e
|
||||
|
||||
def _is_token_expired(self) -> bool:
|
||||
"""
|
||||
Check if the JWT token is expired (basic implementation)
|
||||
"""
|
||||
token = self.access_token
|
||||
if not token:
|
||||
return True
|
||||
|
||||
try:
|
||||
# Decode token without verification to check expiry
|
||||
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)
|
||||
return False
|
||||
except (jwt.PyJWTError, ValueError, TypeError):
|
||||
return True
|
||||
|
||||
def _refresh_token(self) -> None:
|
||||
"""
|
||||
Refresh the JWT token (for now, just re-authenticate)
|
||||
"""
|
||||
print("Refreshing v3 API token...")
|
||||
self._authenticate_v3()
|
||||
|
||||
def http_call(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
data: Any | None = None,
|
||||
json_data: dict[str, Any] | list[Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
verify: bool = False,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> requests.Response:
|
||||
"""
|
||||
Executes HTTP operations and handles GNS3-specific error logic.
|
||||
"""
|
||||
|
||||
# Handle JWT authentication
|
||||
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)
|
||||
caller = getattr(self.session, method.lower())
|
||||
|
||||
# Prepare request parameters, avoiding multiple repeated calls to caller
|
||||
kwargs: dict[str, Any] = {
|
||||
"headers": headers,
|
||||
"params": params,
|
||||
"verify": verify,
|
||||
"timeout": 30.0, # Main request timeout (auth call uses 10s)
|
||||
}
|
||||
if data is not None:
|
||||
kwargs["data"] = data
|
||||
elif json_data is not None:
|
||||
kwargs["json"] = json_data
|
||||
|
||||
# Execute request
|
||||
_response: requests.Response = caller(url, **kwargs)
|
||||
|
||||
self.api_calls += 1
|
||||
|
||||
try:
|
||||
_response.raise_for_status()
|
||||
except HTTPError as e:
|
||||
# Throw enhanced error
|
||||
raise self._extract_gns3_error(e) from e
|
||||
|
||||
return _response
|
||||
|
||||
def _extract_gns3_error(self, e: HTTPError) -> HTTPError:
|
||||
"""
|
||||
Extract GNS3-specific JSON error information from HTTPError.
|
||||
If parsing fails, return the original error.
|
||||
"""
|
||||
# e.response might be None, need explicit check
|
||||
response = e.response
|
||||
if response is None:
|
||||
return e
|
||||
|
||||
try:
|
||||
# Only attempt parsing when Content-Type is JSON
|
||||
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."
|
||||
)
|
||||
# Construct a more descriptive new error
|
||||
new_err = HTTPError(
|
||||
f"{status}: {message} (Original {response.status_code} Error)",
|
||||
response=response,
|
||||
)
|
||||
return new_err
|
||||
except Exception:
|
||||
# If JSON parsing fails, return error with original text
|
||||
return HTTPError(
|
||||
f"Original Error: {str(e)}. GNS3 response text: {response.text}",
|
||||
response=response,
|
||||
)
|
||||
return e
|
||||
@ -54,7 +54,7 @@ from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
|
||||
)
|
||||
|
||||
# Local imports
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import (
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import (
|
||||
Gns3Connector,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -38,8 +38,12 @@ from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Project
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
|
||||
fetch_project_inventory,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -101,11 +105,11 @@ class GNS3ProjectInfoTool(BaseTool):
|
||||
)
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.debug("Connecting to GNS3 server...")
|
||||
server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": (
|
||||
@ -118,37 +122,33 @@ class GNS3ProjectInfoTool(BaseTool):
|
||||
logger.info(
|
||||
f"Retrieving project info for project_id: {project_id}"
|
||||
)
|
||||
project = Project(project_id=project_id, connector=server)
|
||||
project.get() # Load project details
|
||||
inventory = fetch_project_inventory(gns3_ctx, project_id)
|
||||
|
||||
# Get node and link counts
|
||||
nodes_inventory = project.nodes_inventory()
|
||||
links_summary = project.links_summary(is_print=False)
|
||||
|
||||
node_count = len(nodes_inventory) if nodes_inventory else 0
|
||||
link_count = len(links_summary) if links_summary else 0
|
||||
node_count = len(inventory["nodes_inventory"])
|
||||
link_count = len(inventory["links_summary"])
|
||||
|
||||
# Build result in tuple format consistent with GNS3ProjectList
|
||||
result = {
|
||||
"project_id": project.project_id,
|
||||
"name": project.name,
|
||||
"status": project.status,
|
||||
"project_id": inventory["project_id"],
|
||||
"name": inventory["name"],
|
||||
"status": inventory["status"],
|
||||
"node_count": node_count,
|
||||
"link_count": link_count,
|
||||
"tuple": (
|
||||
project.name,
|
||||
project.project_id,
|
||||
inventory["name"],
|
||||
inventory["project_id"],
|
||||
node_count,
|
||||
link_count,
|
||||
project.status,
|
||||
inventory["status"],
|
||||
),
|
||||
}
|
||||
|
||||
# Log result
|
||||
logger.info(
|
||||
"Project info retrieved: name=%s, status=%s, nodes=%d, links=%d",
|
||||
project.name,
|
||||
project.status,
|
||||
inventory["name"],
|
||||
inventory["status"],
|
||||
node_count,
|
||||
link_count,
|
||||
)
|
||||
|
||||
@ -43,8 +43,12 @@ from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Project
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
)
|
||||
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
|
||||
fetch_project_inventory,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -109,13 +113,13 @@ class GNS3TopologyTool(BaseTool):
|
||||
"Please provide a valid project UUID."
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL)
|
||||
# jwt_token/url can be passed explicitly (e.g. from MCP handlers)
|
||||
# or auto-detected (e.g. from gns3-copilot agent)
|
||||
logger.debug("Connecting to GNS3 server...")
|
||||
server = get_gns3_connector(jwt_token=jwt_token, url=url)
|
||||
gns3_ctx = build_gns3_ctx(jwt_token=jwt_token, url=url)
|
||||
|
||||
if server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. Please check "
|
||||
@ -124,18 +128,17 @@ class GNS3TopologyTool(BaseTool):
|
||||
|
||||
# Use the provided project_id directly
|
||||
logger.info(f"Retrieving topology for project_id: {project_id}")
|
||||
project = Project(project_id=project_id, connector=server)
|
||||
project.get() # Load project details
|
||||
inventory = fetch_project_inventory(gns3_ctx, project_id)
|
||||
|
||||
# Get topology JSON: includes nodes (devices), links, etc.
|
||||
topology = {
|
||||
"project_id": project.project_id,
|
||||
"name": project.name,
|
||||
"status": project.status,
|
||||
"project_id": inventory["project_id"],
|
||||
"name": inventory["name"],
|
||||
"status": inventory["status"],
|
||||
"nodes": self._clean_nodes_ports(
|
||||
copy.deepcopy(project.nodes_inventory())
|
||||
copy.deepcopy(inventory["nodes_inventory"])
|
||||
),
|
||||
"links": project.links_summary(is_print=False),
|
||||
"links": inventory["links_summary"],
|
||||
}
|
||||
|
||||
# Log topology result
|
||||
|
||||
148
gns3server/agent/gns3_copilot/gns3_client/project_inventory.py
Normal file
148
gns3server/agent/gns3_copilot/gns3_client/project_inventory.py
Normal file
@ -0,0 +1,148 @@
|
||||
# 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
|
||||
#
|
||||
|
||||
"""
|
||||
Project inventory aggregation over the raw GNS3 REST listings.
|
||||
|
||||
Replaces the aggregation previously living on the ``Project`` dataclass
|
||||
(``nodes_inventory`` / ``links_summary``). The output shapes are kept
|
||||
field-for-field: they feed the LLM topology context and the Nornir
|
||||
inventory, so any change here is consumer-visible.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
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]:
|
||||
"""
|
||||
Build an inventory-style dict keyed by node name.
|
||||
|
||||
Shape (per node name):
|
||||
{server, name, node_id, console_port, console_type, type, ports,
|
||||
status, x, y, tags, netmiko_device_type, default_username,
|
||||
default_password}
|
||||
"""
|
||||
inventory: dict[str, Any] = {}
|
||||
for n in nodes:
|
||||
inventory[n.get("name")] = {
|
||||
"server": server_host,
|
||||
"name": n.get("name"),
|
||||
"node_id": n.get("node_id"),
|
||||
"console_port": n.get("console"),
|
||||
"console_type": n.get("console_type"),
|
||||
"type": n.get("node_type"),
|
||||
"ports": n.get("ports"),
|
||||
"status": n.get("status"),
|
||||
"x": n.get("x"),
|
||||
"y": n.get("y"),
|
||||
"tags": n.get("tags") if n.get("tags") else [],
|
||||
"netmiko_device_type": n.get("netmiko_device_type"),
|
||||
"default_username": n.get("default_username"),
|
||||
"default_password": n.get("default_password"),
|
||||
}
|
||||
return inventory
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Shape (per link): {link_id, node_a, port_a, node_b, port_b}.
|
||||
Links whose endpoints cannot be resolved are skipped, mirroring the
|
||||
original Project.links_summary behavior.
|
||||
"""
|
||||
summary: list[dict[str, str]] = []
|
||||
for link in links:
|
||||
if not link.get("nodes"):
|
||||
continue
|
||||
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"]
|
||||
)
|
||||
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"]
|
||||
)
|
||||
)
|
||||
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"]
|
||||
)
|
||||
)
|
||||
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,
|
||||
})
|
||||
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]:
|
||||
"""
|
||||
Fetch a project's metadata, nodes and links and return the aggregated
|
||||
inventory — the equivalent of the old ``Project.get()`` +
|
||||
``nodes_inventory()`` + ``links_summary()`` sequence (minus the
|
||||
stats/snapshots/drawings calls no consumer ever read).
|
||||
"""
|
||||
conn = _get_connector(gns3_ctx)
|
||||
base = conn.base_url
|
||||
project = conn.http_call("get", f"{base}/projects/{project_id}").json()
|
||||
nodes = conn.http_call("get", f"{base}/projects/{project_id}/nodes").json()
|
||||
links = conn.http_call("get", f"{base}/projects/{project_id}/links").json()
|
||||
server_host = urlparse(gns3_ctx["server_url"]).hostname
|
||||
return {
|
||||
"project_id": project.get("project_id", project_id),
|
||||
"name": project.get("name"),
|
||||
"status": project.get("status"),
|
||||
"nodes_inventory": build_nodes_inventory(nodes, server_host),
|
||||
"links_summary": build_links_summary(nodes, links),
|
||||
}
|
||||
@ -227,7 +227,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
return [{"status": "failed", "error": str(e)}]
|
||||
|
||||
# Check if any devices have errors (e.g., missing device_type tag)
|
||||
error_devices = {
|
||||
@ -254,7 +254,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
dynamic_nr = self._initialize_nornir(hosts_data)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
return [{"status": "failed", "error": str(e)}]
|
||||
|
||||
results = []
|
||||
|
||||
@ -278,7 +278,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
logger.error(
|
||||
"Error executing configurations on all devices: %s", e
|
||||
)
|
||||
return [{"error": f"Execution error: {str(e)}"}]
|
||||
return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
|
||||
|
||||
logger.info(
|
||||
"Multiple device configuration execution completed. Results: %s",
|
||||
@ -368,7 +368,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
"Invalid JSON string received as tool input: %s", e
|
||||
)
|
||||
return (
|
||||
[{"error": f"Invalid JSON string input from model: {e}"}],
|
||||
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
|
||||
None,
|
||||
)
|
||||
else:
|
||||
@ -389,7 +389,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
if not project_id:
|
||||
error_msg = "Missing required 'project_id' field in input"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
if not self._validate_project_id(project_id):
|
||||
error_msg = (
|
||||
@ -397,13 +397,13 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
"Expected UUID format."
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
# Validate device_configs
|
||||
if not isinstance(device_configs, list):
|
||||
error_msg = "'device_configs' must be an array"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
if not device_configs:
|
||||
logger.warning("Device configs list is empty.")
|
||||
@ -426,7 +426,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
|
||||
f"{type(parsed_input).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
def _validate_project_id(self, project_id: str) -> bool:
|
||||
"""
|
||||
|
||||
@ -223,7 +223,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
return [{"status": "failed", "error": str(e)}]
|
||||
|
||||
# Check if any devices have errors (e.g., missing device_type tag)
|
||||
error_devices = {
|
||||
@ -250,7 +250,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
dynamic_nr = self._initialize_nornir(hosts_data)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
return [{"status": "failed", "error": str(e)}]
|
||||
|
||||
results = []
|
||||
|
||||
@ -272,7 +272,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
except Exception as e:
|
||||
# Overall execution failed
|
||||
logger.error("Error executing display on all devices: %s", e)
|
||||
return [{"error": f"Execution error: {str(e)}"}]
|
||||
return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
|
||||
|
||||
logger.debug(
|
||||
"Multiple device display execution completed. Results: %s",
|
||||
@ -370,7 +370,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
"Invalid JSON string received as tool input: %s", e
|
||||
)
|
||||
return (
|
||||
[{"error": f"Invalid JSON string input from model: {e}"}],
|
||||
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
|
||||
None,
|
||||
)
|
||||
else:
|
||||
@ -391,18 +391,18 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
if not project_id:
|
||||
error_msg = "Missing required 'project_id' field in input"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
if not self._validate_project_id(project_id):
|
||||
error_msg = f"Invalid project_id: {project_id}. Expected UUID."
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
# Validate device_configs
|
||||
if not isinstance(device_configs, list):
|
||||
error_msg = "'device_configs' must be an array"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
if not device_configs:
|
||||
logger.warning("Device configs list is empty.")
|
||||
@ -423,7 +423,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
|
||||
f"or legacy JSON array, got {type(parsed_input).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
def _validate_project_id(self, project_id: str) -> bool:
|
||||
"""
|
||||
|
||||
@ -38,8 +38,11 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Link
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
create_link_handler,
|
||||
get_nodes_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -133,11 +136,11 @@ class GNS3LinkTool(BaseTool):
|
||||
{"error": "Invalid links data: must be a non-empty array"}
|
||||
]
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return [
|
||||
{
|
||||
@ -148,6 +151,12 @@ class GNS3LinkTool(BaseTool):
|
||||
}
|
||||
]
|
||||
|
||||
# Fetch all nodes once for port resolution
|
||||
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"]}
|
||||
|
||||
created_links = []
|
||||
|
||||
# Process each link definition
|
||||
@ -170,13 +179,9 @@ class GNS3LinkTool(BaseTool):
|
||||
created_links.append({"error": error_msg})
|
||||
continue
|
||||
|
||||
# Get node details
|
||||
node1 = gns3_server.get_node(
|
||||
project_id=project_id, node_id=node_id1
|
||||
)
|
||||
node2 = gns3_server.get_node(
|
||||
project_id=project_id, node_id=node_id2
|
||||
)
|
||||
# Get node details from the pre-fetched map
|
||||
node1 = nodes_by_id.get(node_id1)
|
||||
node2 = nodes_by_id.get(node_id2)
|
||||
if not node1 or not node2:
|
||||
error_msg = f"Node not found in link {i}"
|
||||
logger.error(error_msg)
|
||||
@ -206,39 +211,48 @@ class GNS3LinkTool(BaseTool):
|
||||
created_links.append({"error": error_msg})
|
||||
continue
|
||||
|
||||
# Create the link
|
||||
link = Link(
|
||||
project_id=project_id,
|
||||
connector=gns3_server,
|
||||
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},
|
||||
},
|
||||
{
|
||||
"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},
|
||||
},
|
||||
],
|
||||
# Create the link via the shared REST handler
|
||||
link_resp = create_link_handler(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"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
|
||||
},
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
},
|
||||
],
|
||||
"fields": ["link_id"],
|
||||
},
|
||||
gns3_ctx,
|
||||
)
|
||||
link.create()
|
||||
link.get()
|
||||
if "error" in link_resp:
|
||||
raise RuntimeError(link_resp["error"])
|
||||
|
||||
# Collect link details
|
||||
link_info = {
|
||||
"link_id": link.link_id,
|
||||
"link_id": link_resp.get("link_id"),
|
||||
"node_id1": node_id1,
|
||||
"port1": port1,
|
||||
"node_id2": node_id2,
|
||||
|
||||
@ -38,8 +38,10 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
create_node_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -194,11 +196,11 @@ class GNS3CreateNodeTool(BaseTool):
|
||||
f"template_id, x, or y."
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
@ -228,22 +230,22 @@ class GNS3CreateNodeTool(BaseTool):
|
||||
name,
|
||||
)
|
||||
|
||||
# Create node
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
template_id=template_id,
|
||||
x=x,
|
||||
y=y,
|
||||
name=name,
|
||||
connector=gns3_server,
|
||||
# Create node via the shared REST handler
|
||||
created = create_node_handler(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"template_id": template_id,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"name": name,
|
||||
},
|
||||
gns3_ctx,
|
||||
)
|
||||
node.create()
|
||||
|
||||
# Retrieve node details
|
||||
node.get()
|
||||
if "error" in created:
|
||||
raise RuntimeError(created["error"])
|
||||
node_info = {
|
||||
"node_id": node.node_id,
|
||||
"name": node.name,
|
||||
"node_id": created.get("node_id"),
|
||||
"name": created.get("name"),
|
||||
"status": "success",
|
||||
}
|
||||
|
||||
|
||||
@ -139,7 +139,9 @@ class GNS3TemplateTool(BaseTool):
|
||||
}
|
||||
|
||||
# Retrieve all available templates
|
||||
templates = gns3_server.get_templates()
|
||||
templates = gns3_server.http_call(
|
||||
"get", f"{gns3_server.base_url}/templates"
|
||||
).json()
|
||||
|
||||
# Filter out utility templates and extract relevant info
|
||||
template_info = []
|
||||
|
||||
@ -40,8 +40,12 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Link
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
available_filters_handler,
|
||||
build_gns3_ctx,
|
||||
get_link_handler,
|
||||
update_link_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -207,35 +211,34 @@ class GNS3PacketFilterTool(BaseTool):
|
||||
"error": "'set' action requires 'filters' dict with filter configuration."
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# Create Link object
|
||||
# Execute action
|
||||
logger.info(
|
||||
"Processing packet filter action '%s' for link %s...", action, link_id
|
||||
)
|
||||
link = Link(
|
||||
project_id=project_id, link_id=link_id, connector=gns3_server
|
||||
)
|
||||
|
||||
# Execute action
|
||||
if action == "get_available":
|
||||
result = self._get_available_filters(link)
|
||||
result = self._get_available_filters(gns3_ctx, project_id, link_id)
|
||||
elif action == "set":
|
||||
filters = input_data.get("filters", {})
|
||||
result = self._set_filters(link, 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(link)
|
||||
result = self._get_filters(gns3_ctx, project_id, link_id)
|
||||
elif action == "clear":
|
||||
result = self._clear_filters(link, show_filters_icon)
|
||||
result = self._clear_filters(
|
||||
gns3_ctx, project_id, link_id, show_filters_icon
|
||||
)
|
||||
else:
|
||||
result = {"error": f"Unknown action: {action}"}
|
||||
|
||||
@ -253,14 +256,20 @@ class GNS3PacketFilterTool(BaseTool):
|
||||
"error": f"Failed to process packet filter request: {str(e)}"
|
||||
}
|
||||
|
||||
def _get_available_filters(self, link: Link) -> 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 = link.available_filters()
|
||||
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))
|
||||
return {
|
||||
"action": "get_available",
|
||||
"link_id": link.link_id,
|
||||
"link_id": link_id,
|
||||
"available_filters": filters,
|
||||
"count": len(filters),
|
||||
"status": "success",
|
||||
@ -269,7 +278,7 @@ class GNS3PacketFilterTool(BaseTool):
|
||||
logger.error("Failed to get available filters: %s", e)
|
||||
return {
|
||||
"action": "get_available",
|
||||
"link_id": link.link_id,
|
||||
"link_id": link_id,
|
||||
"error": f"Failed to get available filters: {str(e)}",
|
||||
"status": "failed",
|
||||
}
|
||||
@ -338,7 +347,12 @@ class GNS3PacketFilterTool(BaseTool):
|
||||
return {"valid": False, "error": f"BPF validation error: {str(e)}"}
|
||||
|
||||
def _set_filters(
|
||||
self, link: Link, filters: dict[str, Any], show_filters_icon: bool = False
|
||||
self,
|
||||
gns3_ctx: dict,
|
||||
project_id: str,
|
||||
link_id: str,
|
||||
filters: dict[str, Any],
|
||||
show_filters_icon: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Set packet filters on the link."""
|
||||
try:
|
||||
@ -353,7 +367,7 @@ class GNS3PacketFilterTool(BaseTool):
|
||||
if not validation["valid"]:
|
||||
return {
|
||||
"action": "set",
|
||||
"link_id": link.link_id,
|
||||
"link_id": link_id,
|
||||
"error": f"BPF syntax error at index {idx}: {validation['error']}",
|
||||
"status": "failed",
|
||||
}
|
||||
@ -363,22 +377,31 @@ class GNS3PacketFilterTool(BaseTool):
|
||||
if not validation["valid"]:
|
||||
return {
|
||||
"action": "set",
|
||||
"link_id": link.link_id,
|
||||
"link_id": link_id,
|
||||
"error": f"BPF syntax error: {validation['error']}",
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
# Update filters
|
||||
link.update(filters=filters, show_filters_icon=show_filters_icon)
|
||||
# Update filters — the PUT response is the updated link
|
||||
updated = update_link_handler(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"link_id": link_id,
|
||||
"kwargs": {
|
||||
"filters": filters,
|
||||
"show_filters_icon": show_filters_icon,
|
||||
},
|
||||
},
|
||||
gns3_ctx,
|
||||
)
|
||||
if "error" in updated:
|
||||
raise RuntimeError(updated["error"])
|
||||
|
||||
# Get updated link info
|
||||
link.get()
|
||||
|
||||
logger.info("Successfully set filters on link %s", link.link_id)
|
||||
logger.info("Successfully set filters on link %s", link_id)
|
||||
return {
|
||||
"action": "set",
|
||||
"link_id": link.link_id,
|
||||
"filters": link.filters,
|
||||
"link_id": link_id,
|
||||
"filters": updated.get("filters"),
|
||||
"status": "success",
|
||||
"message": "Filters applied successfully",
|
||||
}
|
||||
@ -386,49 +409,68 @@ class GNS3PacketFilterTool(BaseTool):
|
||||
logger.error("Failed to set filters: %s", e)
|
||||
return {
|
||||
"action": "set",
|
||||
"link_id": link.link_id,
|
||||
"link_id": link_id,
|
||||
"error": f"Failed to set filters: {str(e)}",
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
def _get_filters(self, link: Link) -> 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:
|
||||
# Get link information
|
||||
link.get()
|
||||
link = get_link_handler(
|
||||
{"project_id": project_id, "link_id": link_id}, gns3_ctx
|
||||
)
|
||||
if "error" in link:
|
||||
raise RuntimeError(link["error"])
|
||||
|
||||
logger.info("Retrieved current filters for link %s", link.link_id)
|
||||
logger.info("Retrieved current filters for link %s", link_id)
|
||||
return {
|
||||
"action": "get",
|
||||
"link_id": link.link_id,
|
||||
"filters": link.filters,
|
||||
"link_id": link_id,
|
||||
"filters": link.get("filters"),
|
||||
"status": "success",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Failed to get filters: %s", e)
|
||||
return {
|
||||
"action": "get",
|
||||
"link_id": link.link_id,
|
||||
"link_id": link_id,
|
||||
"error": f"Failed to get filters: {str(e)}",
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
def _clear_filters(
|
||||
self, link: Link, show_filters_icon: bool = False
|
||||
self,
|
||||
gns3_ctx: dict,
|
||||
project_id: str,
|
||||
link_id: str,
|
||||
show_filters_icon: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Clear all filters from the link."""
|
||||
try:
|
||||
# Clear filters by setting empty dict
|
||||
link.update(filters={}, show_filters_icon=show_filters_icon)
|
||||
# Clear filters by setting an empty dict — the PUT response
|
||||
# is the updated link
|
||||
updated = update_link_handler(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"link_id": link_id,
|
||||
"kwargs": {
|
||||
"filters": {},
|
||||
"show_filters_icon": show_filters_icon,
|
||||
},
|
||||
},
|
||||
gns3_ctx,
|
||||
)
|
||||
if "error" in updated:
|
||||
raise RuntimeError(updated["error"])
|
||||
|
||||
# Get updated link info to confirm
|
||||
link.get()
|
||||
|
||||
logger.info("Successfully cleared filters on link %s", link.link_id)
|
||||
logger.info("Successfully cleared filters on link %s", link_id)
|
||||
return {
|
||||
"action": "clear",
|
||||
"link_id": link.link_id,
|
||||
"filters": link.filters,
|
||||
"link_id": link_id,
|
||||
"filters": updated.get("filters"),
|
||||
"status": "success",
|
||||
"message": "Filters cleared successfully",
|
||||
}
|
||||
@ -436,7 +478,7 @@ class GNS3PacketFilterTool(BaseTool):
|
||||
logger.error("Failed to clear filters: %s", e)
|
||||
return {
|
||||
"action": "clear",
|
||||
"link_id": link.link_id,
|
||||
"link_id": link_id,
|
||||
"error": f"Failed to clear filters: {str(e)}",
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
@ -39,8 +39,11 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
get_nodes_handler,
|
||||
start_node_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -64,7 +67,7 @@ def calculate_startup_time(nodes: list) -> int:
|
||||
- If any node is a slow device: use conservative startup time
|
||||
|
||||
Args:
|
||||
nodes: List of node objects with node_type attribute
|
||||
nodes: List of node dicts with a "node_type" key
|
||||
|
||||
Returns:
|
||||
Calculated wait time in seconds
|
||||
@ -73,7 +76,7 @@ def calculate_startup_time(nodes: list) -> int:
|
||||
return 60 # Default: 60 seconds for empty list
|
||||
|
||||
# Get all node types
|
||||
node_types = [getattr(node, "node_type", "default") for node in nodes]
|
||||
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"}
|
||||
@ -198,106 +201,88 @@ class GNS3StartNodeTool(BaseTool):
|
||||
logger.error("node_ids must be a list.")
|
||||
return {"error": "node_ids must be a list."}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# First loop: Get node info and send start commands for all nodes
|
||||
# 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,
|
||||
)
|
||||
nodes = []
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
)
|
||||
# Get node info (including node_type)
|
||||
node.get()
|
||||
if node.node_id:
|
||||
nodes.append(node)
|
||||
logger.info(
|
||||
"Node %s (%s) type: %s",
|
||||
node_id,
|
||||
node.name,
|
||||
node.node_type,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Node %s not found in project %s",
|
||||
node_id,
|
||||
project_id,
|
||||
)
|
||||
except Exception as e:
|
||||
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(
|
||||
"Failed to get node info for %s: %s",
|
||||
node_id,
|
||||
e,
|
||||
"Node %s not found in project %s", nid, project_id
|
||||
)
|
||||
|
||||
# Calculate startup time based on node types
|
||||
wait_time = calculate_startup_time(nodes)
|
||||
|
||||
# Send start commands for all 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,
|
||||
)
|
||||
for node in nodes:
|
||||
try:
|
||||
node.start()
|
||||
logger.info("Start command sent for node %s", node.node_id)
|
||||
except Exception as e:
|
||||
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",
|
||||
node.node_id,
|
||||
e,
|
||||
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)
|
||||
)
|
||||
|
||||
# Second loop: Get status for all 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:
|
||||
try:
|
||||
node.get() # Get latest status
|
||||
node_info = {
|
||||
"node_id": node.node_id,
|
||||
"name": node.name or "N/A",
|
||||
"status": node.status or "unknown",
|
||||
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",
|
||||
}
|
||||
results.append(node_info)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to get status for node %s: %s", node.node_id, e
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node.node_id,
|
||||
"name": getattr(node, "name", "N/A"),
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Handle nodes that failed to be retrieved initially
|
||||
retrieved_node_ids = {node.node_id for node in nodes}
|
||||
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(
|
||||
@ -405,77 +390,90 @@ class GNS3StartNodeQuickTool(BaseTool):
|
||||
logger.error("node_ids must be a list.")
|
||||
return {"error": "node_ids must be a list."}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# Send start commands for all nodes and collect initial status
|
||||
# Verify nodes exist and capture pre-start info (one call)
|
||||
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"]}
|
||||
|
||||
# Send start commands for all nodes (parallel batch)
|
||||
logger.info(
|
||||
"Sending start commands for %d nodes in project %s...",
|
||||
len(node_ids),
|
||||
project_id,
|
||||
)
|
||||
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"
|
||||
}
|
||||
|
||||
# Get immediate status (likely 'starting' or 'stopped') — one call
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
after_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
if node_id not in nodes_by_id:
|
||||
logger.error(
|
||||
"Node %s not found in project %s", node_id, project_id
|
||||
)
|
||||
# Verify node exists and get current info
|
||||
node.get()
|
||||
if not node.node_id:
|
||||
logger.error(
|
||||
"Node %s not found in project %s",
|
||||
node_id,
|
||||
project_id,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": "Node not found",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Send start command
|
||||
node.start()
|
||||
logger.info(
|
||||
"Start command sent for node %s (%s)",
|
||||
node_id,
|
||||
node.name,
|
||||
)
|
||||
|
||||
# Get immediate status (likely 'starting' or 'stopped')
|
||||
node.get()
|
||||
node_info = {
|
||||
"node_id": node.node_id,
|
||||
"name": node.name or "N/A",
|
||||
"status": node.status or "unknown",
|
||||
}
|
||||
results.append(node_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to start node %s: %s", node_id, e)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"error": "Node not found",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if node_id in start_errors:
|
||||
logger.error(
|
||||
"Failed to start node %s: %s",
|
||||
node_id,
|
||||
start_errors[node_id],
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": nodes_by_id[node_id].get("name") or "N/A",
|
||||
"status": "error",
|
||||
"error": start_errors[node_id],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"Start command sent for node %s (%s)",
|
||||
node_id,
|
||||
nodes_by_id[node_id].get("name"),
|
||||
)
|
||||
current = after_by_id.get(node_id, nodes_by_id[node_id])
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": current.get("name") or "N/A",
|
||||
"status": current.get("status") or "unknown",
|
||||
}
|
||||
)
|
||||
|
||||
# Analyze results (count based on successful command sending)
|
||||
successful_nodes = [
|
||||
|
||||
@ -38,8 +38,11 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
get_nodes_handler,
|
||||
stop_node_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -103,75 +106,83 @@ class GNS3StopNodeTool(BaseTool):
|
||||
logger.error("node_ids must be a list.")
|
||||
return {"error": "node_ids must be a list."}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# Stop all nodes and collect results
|
||||
# Verify nodes exist and capture names (one call)
|
||||
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"]}
|
||||
|
||||
# Stop all nodes (parallel batch) and collect results
|
||||
logger.info(
|
||||
"Stopping %d nodes in project %s...",
|
||||
len(node_ids),
|
||||
project_id,
|
||||
)
|
||||
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"
|
||||
}
|
||||
|
||||
# Get updated status — one call
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
after_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
if node_id not in nodes_by_id:
|
||||
logger.error(
|
||||
"Node %s not found in project %s", node_id, project_id
|
||||
)
|
||||
# Verify node exists and get current info
|
||||
node.get()
|
||||
if not node.node_id:
|
||||
logger.error(
|
||||
"Node %s not found in project %s",
|
||||
node_id,
|
||||
project_id,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": "Node not found",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Send stop command
|
||||
node.stop()
|
||||
logger.info(
|
||||
"Stop command sent for node %s (%s)",
|
||||
node_id,
|
||||
node.name,
|
||||
)
|
||||
|
||||
# Get updated status
|
||||
node.get()
|
||||
node_info = {
|
||||
"node_id": node.node_id,
|
||||
"name": node.name or "N/A",
|
||||
"status": node.status or "unknown",
|
||||
}
|
||||
results.append(node_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to stop node %s: %s", node_id, e)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"error": "Node not found",
|
||||
}
|
||||
)
|
||||
elif node_id in stop_errors:
|
||||
logger.error(
|
||||
"Failed to stop node %s: %s", node_id, stop_errors[node_id]
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": nodes_by_id[node_id].get("name") or "N/A",
|
||||
"status": "error",
|
||||
"error": stop_errors[node_id],
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Stop command sent for node %s (%s)",
|
||||
node_id,
|
||||
nodes_by_id[node_id].get("name"),
|
||||
)
|
||||
current = after_by_id.get(node_id, nodes_by_id[node_id])
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": current.get("name") or "N/A",
|
||||
"status": current.get("status") or "unknown",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -39,8 +39,11 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
get_nodes_handler,
|
||||
suspend_node_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -109,75 +112,85 @@ class GNS3SuspendNodeTool(BaseTool):
|
||||
logger.error("node_ids must be a list.")
|
||||
return {"error": "node_ids must be a list."}
|
||||
|
||||
# Initialize Gns3Connector using factory function
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {
|
||||
"error": "Failed to connect to GNS3 server. "
|
||||
"Please check your configuration."
|
||||
}
|
||||
|
||||
# Suspend all nodes and collect results
|
||||
# Verify nodes exist and capture names (one call)
|
||||
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"]}
|
||||
|
||||
# Suspend all nodes (parallel batch) and collect results
|
||||
logger.info(
|
||||
"Suspending %d nodes in project %s...",
|
||||
len(node_ids),
|
||||
project_id,
|
||||
)
|
||||
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"
|
||||
}
|
||||
|
||||
# Get updated status — one call
|
||||
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
after_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
if node_id not in nodes_by_id:
|
||||
logger.error(
|
||||
"Node %s not found in project %s", node_id, project_id
|
||||
)
|
||||
# Verify node exists and get current info
|
||||
node.get()
|
||||
if not node.node_id:
|
||||
logger.error(
|
||||
"Node %s not found in project %s",
|
||||
node_id,
|
||||
project_id,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": "Node not found",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Send suspend command
|
||||
node.suspend()
|
||||
logger.info(
|
||||
"Suspend command sent for node %s (%s)",
|
||||
node_id,
|
||||
node.name,
|
||||
)
|
||||
|
||||
# Get updated status
|
||||
node.get()
|
||||
node_info = {
|
||||
"node_id": node.node_id,
|
||||
"name": node.name or "N/A",
|
||||
"status": node.status or "unknown",
|
||||
}
|
||||
results.append(node_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to suspend node %s: %s", node_id, e)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": "N/A",
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"error": "Node not found",
|
||||
}
|
||||
)
|
||||
elif node_id in suspend_errors:
|
||||
logger.error(
|
||||
"Failed to suspend node %s: %s",
|
||||
node_id,
|
||||
suspend_errors[node_id],
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": nodes_by_id[node_id].get("name") or "N/A",
|
||||
"status": "error",
|
||||
"error": suspend_errors[node_id],
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Suspend command sent for node %s (%s)",
|
||||
node_id,
|
||||
nodes_by_id[node_id].get("name"),
|
||||
)
|
||||
current = after_by_id.get(node_id, nodes_by_id[node_id])
|
||||
results.append(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"name": current.get("name") or "N/A",
|
||||
"status": current.get("status") or "unknown",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -38,8 +38,11 @@ from typing import Any
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client import Node
|
||||
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
get_nodes_handler,
|
||||
update_node_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -126,17 +129,23 @@ class GNS3UpdateNodeNameTool(BaseTool):
|
||||
"error": f"Node {i + 1} missing node_id or new_name."
|
||||
}
|
||||
|
||||
# Initialize Gns3Connector
|
||||
# Build handler context (JWT + server URL from request context)
|
||||
logger.info("Connecting to GNS3 server...")
|
||||
gns3_server = get_gns3_connector()
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
|
||||
if gns3_server is None:
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
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)
|
||||
if "error" in listing:
|
||||
return {"error": listing["error"]}
|
||||
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
|
||||
|
||||
# Update node names
|
||||
logger.info(
|
||||
"Updating names for %d nodes in project %s...",
|
||||
@ -158,21 +167,25 @@ class GNS3UpdateNodeNameTool(BaseTool):
|
||||
new_name,
|
||||
)
|
||||
|
||||
# Get node to retrieve current name
|
||||
node = Node(
|
||||
project_id=project_id,
|
||||
node_id=node_id,
|
||||
connector=gns3_server,
|
||||
node = nodes_by_id.get(node_id)
|
||||
if node is None:
|
||||
raise ValueError("Node not found")
|
||||
old_name = node.get("name")
|
||||
|
||||
# Update node name — the PUT response is the updated node
|
||||
updated = update_node_handler(
|
||||
{
|
||||
"project_id": project_id,
|
||||
"node_id": node_id,
|
||||
"name": new_name,
|
||||
},
|
||||
gns3_ctx,
|
||||
)
|
||||
node.get()
|
||||
old_name = node.name
|
||||
if "error" in updated:
|
||||
raise RuntimeError(updated["error"])
|
||||
current_name = updated.get("name")
|
||||
|
||||
# Update node name
|
||||
node.update(name=new_name)
|
||||
|
||||
# Verify update
|
||||
node.get()
|
||||
if node.name == new_name:
|
||||
if current_name == new_name:
|
||||
node_info = {
|
||||
"node_id": node_id,
|
||||
"old_name": old_name,
|
||||
@ -190,7 +203,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
|
||||
"node_id": node_id,
|
||||
"old_name": old_name,
|
||||
"new_name": new_name,
|
||||
"current_name": node.name,
|
||||
"current_name": current_name,
|
||||
"status": "failed",
|
||||
"error": "Name verification failed",
|
||||
}
|
||||
|
||||
@ -196,7 +196,7 @@ class VPCSCommands(BaseTool):
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to prepare device hosts data: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
return [{"status": "failed", "error": str(e)}]
|
||||
|
||||
# Check if any devices have errors (e.g., missing device)
|
||||
error_devices = {
|
||||
@ -223,7 +223,7 @@ class VPCSCommands(BaseTool):
|
||||
dynamic_nr = self._initialize_nornir(hosts_data)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to initialize Nornir: %s", e)
|
||||
return [{"error": str(e)}]
|
||||
return [{"status": "failed", "error": str(e)}]
|
||||
|
||||
results = []
|
||||
|
||||
@ -244,7 +244,7 @@ class VPCSCommands(BaseTool):
|
||||
except Exception as e:
|
||||
# Overall execution failed
|
||||
logger.error("Error executing commands on all VPCS devices: %s", e)
|
||||
return [{"error": f"Execution error: {str(e)}"}]
|
||||
return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
|
||||
|
||||
logger.debug(
|
||||
"VPCS command execution completed. Results: %s",
|
||||
@ -338,7 +338,7 @@ class VPCSCommands(BaseTool):
|
||||
"Invalid JSON string received as tool input: %s", e
|
||||
)
|
||||
return (
|
||||
[{"error": f"Invalid JSON string input from model: {e}"}],
|
||||
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
|
||||
None,
|
||||
)
|
||||
else:
|
||||
@ -357,18 +357,18 @@ class VPCSCommands(BaseTool):
|
||||
if not project_id:
|
||||
error_msg = "Missing required 'project_id' field in input"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
if not self._validate_project_id(project_id):
|
||||
error_msg = f"Invalid project_id: {project_id}. Expected UUID."
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
# Validate device_configs
|
||||
if not isinstance(device_configs, list):
|
||||
error_msg = "'device_configs' must be an array"
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
if not device_configs:
|
||||
logger.warning("Device configs list is empty.")
|
||||
@ -382,7 +382,7 @@ class VPCSCommands(BaseTool):
|
||||
f"got {type(parsed_input).__name__}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
return ([{"error": error_msg}], None)
|
||||
return ([{"status": "failed", "error": error_msg}], None)
|
||||
|
||||
def _validate_project_id(self, project_id: str) -> bool:
|
||||
"""
|
||||
@ -461,6 +461,25 @@ class VPCSCommands(BaseTool):
|
||||
|
||||
port = device_ports[device_name]["port"]
|
||||
|
||||
node_type = device_ports[device_name].get("node_type")
|
||||
if node_type != "vpcs":
|
||||
# VPCS syntax typed into another node's CLI is silently
|
||||
# discarded (e.g. IOS answers "% Invalid input"), so reject
|
||||
# mismatched devices before a console session is opened
|
||||
logger.error(
|
||||
"Device '%s' is a %s node, not a VPCS node",
|
||||
device_name,
|
||||
node_type or "unknown-type",
|
||||
)
|
||||
hosts_data[device_name] = {
|
||||
"error": (
|
||||
f"Device '{device_name}' is a {node_type or 'unknown-type'} node, "
|
||||
"not a VPCS node; use device_config_send / device_show_run "
|
||||
"for network devices"
|
||||
)
|
||||
}
|
||||
continue
|
||||
|
||||
# VPCS devices use gns3_vpcs_telnet device type
|
||||
hosts_data[device_name] = {
|
||||
"port": port,
|
||||
@ -559,8 +578,8 @@ class VPCSCommands(BaseTool):
|
||||
if device_name in hosts_data and "error" in hosts_data[device_name]:
|
||||
results.append({
|
||||
"device_name": device_name,
|
||||
"status": "error",
|
||||
"output": hosts_data[device_name]["error"],
|
||||
"status": "failed",
|
||||
"error": hosts_data[device_name]["error"],
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
continue
|
||||
@ -574,8 +593,8 @@ class VPCSCommands(BaseTool):
|
||||
error_msg = str(host_result.result) if host_result.result else "Unknown error"
|
||||
results.append({
|
||||
"device_name": device_name,
|
||||
"status": "error",
|
||||
"output": error_msg,
|
||||
"status": "failed",
|
||||
"error": error_msg,
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
else:
|
||||
@ -590,8 +609,8 @@ class VPCSCommands(BaseTool):
|
||||
# Device not in task result (shouldn't happen)
|
||||
results.append({
|
||||
"device_name": device_name,
|
||||
"status": "error",
|
||||
"output": f"Device '{device_name}' not in task results",
|
||||
"status": "failed",
|
||||
"error": f"Device '{device_name}' not in task results",
|
||||
"commands": device_config["commands"],
|
||||
})
|
||||
|
||||
|
||||
@ -59,6 +59,7 @@ def get_device_ports_from_topology(
|
||||
"device_name": {
|
||||
"port": console_port,
|
||||
"platform": "huawei", # Extracted from tags
|
||||
"node_type": "vpcs", # GNS3 node type from the topology
|
||||
"groups": ["network_devices"], # For inheriting shared settings
|
||||
"connection_options": {
|
||||
"netmiko": {
|
||||
@ -160,9 +161,14 @@ def get_device_ports_from_topology(
|
||||
# This is the Nornir best practice - each host has its own
|
||||
# connection configuration (device_type), while sharing common
|
||||
# settings (hostname, timeout) via group inheritance.
|
||||
# node_type (the GNS3 node type, e.g. vpcs/iou/docker) lets callers
|
||||
# reject mismatched devices before opening a console connection;
|
||||
# DictInventory ignores keys it does not know, so carrying it here
|
||||
# is safe for entries fed straight into Nornir.
|
||||
host_entry = {
|
||||
"port": node_info["console_port"],
|
||||
"platform": platform,
|
||||
"node_type": node_info.get("type"),
|
||||
"groups": ["network_devices"], # For inheriting hostname, timeout, etc.
|
||||
"connection_options": {
|
||||
"netmiko": {
|
||||
|
||||
@ -31,7 +31,6 @@ import json
|
||||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
import uuid
|
||||
from uuid import UUID
|
||||
import bcrypt
|
||||
from typing import Any, Annotated
|
||||
@ -61,16 +60,19 @@ from .projects import (
|
||||
get_project_stats_handler, update_project_handler, duplicate_project_handler,
|
||||
get_project_readme_handler, update_project_readme_handler,
|
||||
lock_project_handler, unlock_project_handler,
|
||||
load_project_handler, get_locked_project_handler,
|
||||
get_locked_project_handler,
|
||||
)
|
||||
from .server import (
|
||||
get_version_handler, get_statistics_handler,
|
||||
)
|
||||
from .symbols import (
|
||||
get_symbols_handler, get_symbol_handler,
|
||||
get_symbol_dimensions_handler, get_default_symbols_handler,
|
||||
upload_symbol_handler, delete_symbol_handler,
|
||||
)
|
||||
# Symbol tools are disabled for now: they require a vision-capable model to
|
||||
# be genuinely useful (the tools shuttle SVG content, which a text-only LLM
|
||||
# cannot inspect or produce). Revisit later.
|
||||
# from .symbols import (
|
||||
# get_symbols_handler, get_symbol_handler,
|
||||
# get_symbol_dimensions_handler, get_default_symbols_handler,
|
||||
# upload_symbol_handler, delete_symbol_handler,
|
||||
# )
|
||||
from .appliances import (
|
||||
get_appliances_handler, get_appliance_handler,
|
||||
install_appliance_handler,
|
||||
@ -84,7 +86,7 @@ from .device_config import (
|
||||
device_config_send_handler, device_show_run_handler,
|
||||
vpcs_config_set_handler,
|
||||
)
|
||||
from .nodes import (
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
get_nodes_handler, get_node_handler, start_node_handler,
|
||||
stop_node_handler, suspend_node_handler,
|
||||
create_node_handler, delete_node_handler, update_node_handler,
|
||||
@ -95,9 +97,8 @@ from .nodes import (
|
||||
suspend_all_nodes_handler,
|
||||
duplicate_node_handler, isolate_node_handler,
|
||||
unisolate_node_handler, get_node_links_handler,
|
||||
)
|
||||
from .links import (
|
||||
get_links_handler, get_link_handler, create_link_handler,
|
||||
get_links_handler, get_link_handler, available_filters_handler,
|
||||
create_link_handler,
|
||||
delete_link_handler, update_link_handler,
|
||||
reset_link_handler, start_capture_handler, stop_capture_handler,
|
||||
download_capture_file_handler,
|
||||
@ -508,6 +509,9 @@ async def node_create(
|
||||
Single mode: provide template_id, x, y (optional compute_id)
|
||||
Batch mode: provide nodes=[{name, template_id?, x?, y?, compute_id?}] — creates up to 100 in parallel.
|
||||
Top-level template_id applies to all nodes; individual nodes can override.
|
||||
Results are always returned in submission order; correlate nodes by node_id, not name.
|
||||
When a node omits `name`, the server assigns a default name (R-1, R-2, ...) and console
|
||||
port — such batches are created sequentially so those assignments follow submission order.
|
||||
"""
|
||||
if nodes is not None:
|
||||
return await asyncio.to_thread(_run_handler_sync, create_node_handler, {
|
||||
@ -676,7 +680,19 @@ async def link_update(
|
||||
return await asyncio.to_thread(_run_handler_sync, update_link_handler, params)
|
||||
|
||||
|
||||
# ── Template tools ────────────────────────────────────────────────────
|
||||
@mcp.tool()
|
||||
async def link_available_filters(
|
||||
project_id: Annotated[str, Field(description="UUID of the project")],
|
||||
link_id: Annotated[str, Field(description="UUID of the link")],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List the packet filter types available for a link (frequency_drop, packet_loss, delay, corrupt, bpf)
|
||||
with their parameters. Use before setting filters with link_update."""
|
||||
return await asyncio.to_thread(_run_handler_sync, available_filters_handler, {
|
||||
"project_id": project_id, "link_id": link_id,
|
||||
})
|
||||
|
||||
|
||||
# ── Template tools ────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def template_list(
|
||||
@ -755,12 +771,12 @@ async def compute_list() -> list[dict[str, Any]]:
|
||||
|
||||
@mcp.tool()
|
||||
async def compute_get(
|
||||
compute_id: Annotated[uuid.UUID, Field(description="Compute UUID from compute_list output")],
|
||||
compute_id: Annotated[str, Field(description="Compute ID: 'local' (default) for the built-in local compute, or a compute UUID from compute_list")] = "local",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get detailed information about a registered remote compute node.
|
||||
"""Get detailed information about a compute node.
|
||||
|
||||
NOTE: Only works for computes registered in the database (returned by compute_list).
|
||||
For the built-in local compute info, use server_statistics instead.
|
||||
Accepts 'local' for the built-in local compute or a UUID from compute_list
|
||||
for a registered remote compute.
|
||||
"""
|
||||
return await asyncio.to_thread(_run_handler_sync, get_compute_handler, {"compute_id": compute_id})
|
||||
|
||||
@ -768,12 +784,12 @@ async def compute_get(
|
||||
@mcp.tool()
|
||||
async def compute_images(
|
||||
emulator: Annotated[str, Field(description="Emulator type (e.g. qemu, iou, docker)")],
|
||||
compute_id: Annotated[uuid.UUID, Field(description="Compute UUID from compute_list output")],
|
||||
compute_id: Annotated[str, Field(description="Compute ID: 'local' (default) for the built-in local compute, or a compute UUID from compute_list")] = "local",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List available images for an emulator on a registered compute node.
|
||||
"""List available images for an emulator on a compute node.
|
||||
|
||||
NOTE: Only works for computes registered in the database.
|
||||
For the local compute, the default compute_id is typically found via server_statistics.
|
||||
Accepts 'local' for the built-in local compute or a UUID from compute_list
|
||||
for a registered remote compute.
|
||||
"""
|
||||
return await asyncio.to_thread(_run_handler_sync, get_compute_images_handler, {
|
||||
"emulator": emulator, "compute_id": compute_id,
|
||||
@ -1021,6 +1037,7 @@ async def link_marker(
|
||||
capture_node_id: Annotated[str | None, Field(description="UUID of the endpoint whose uBridge hosts the marker (the observer; tx/rx are from its perspective). Must be a link endpoint and marker-capable. Omit to auto-pick.")] = None,
|
||||
color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None,
|
||||
highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None,
|
||||
data_link_type: Annotated[str | None, Field(description="pcap link-layer type for serial links (create-only): DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / DLT_ATM_RFC1483, matching the encapsulation on the serial link. Omit = DLT_EN10MB (Ethernet). Ignored on update — changing it would invalidate the capture file.")] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Manage traffic-insight markers on a link.
|
||||
|
||||
@ -1037,7 +1054,7 @@ async def link_marker(
|
||||
and cannot be modified or deleted via this tool.
|
||||
"""
|
||||
params = {"project_id": project_id, "link_id": link_id, "action": action}
|
||||
for opt in ("bpf", "marker_name", "name", "tag", "enabled", "direction", "capture_node_id", "color", "highlight_duration"):
|
||||
for opt in ("bpf", "marker_name", "name", "tag", "enabled", "direction", "capture_node_id", "color", "highlight_duration", "data_link_type"):
|
||||
val = locals().get(opt)
|
||||
if val is not None:
|
||||
params[opt] = val
|
||||
@ -1253,16 +1270,6 @@ async def project_locked(
|
||||
})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def project_load(
|
||||
path: Annotated[str, Field(description="Filesystem path to the .gns3 project file")],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Load a project from a file path on the server's filesystem."""
|
||||
return await asyncio.to_thread(_run_handler_sync, load_project_handler, {
|
||||
"path": path,
|
||||
})
|
||||
|
||||
|
||||
# ── Server info tools ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -1279,64 +1286,67 @@ async def server_statistics() -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
# ── Symbol tools ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def symbol_list() -> list[dict[str, Any]]:
|
||||
"""List all available symbols on the server."""
|
||||
return await asyncio.to_thread(_run_handler_sync, get_symbols_handler, {})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def symbol_get(
|
||||
symbol_id: Annotated[str, Field(description="Symbol ID (e.g. ':/symbols/router.svg')")],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get a download URL for a symbol file (SVG). The URL includes a short-lived JWT (10 min). Use curl to download."""
|
||||
return await asyncio.to_thread(_run_handler_sync, get_symbol_handler, {
|
||||
"symbol_id": symbol_id,
|
||||
})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def symbol_dimensions(
|
||||
symbol_id: Annotated[str, Field(description="Symbol ID to get dimensions for")],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get the dimensions (width, height) of a symbol."""
|
||||
return await asyncio.to_thread(_run_handler_sync, get_symbol_dimensions_handler, {
|
||||
"symbol_id": symbol_id,
|
||||
})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def symbol_defaults() -> list[dict[str, Any]]:
|
||||
"""Get the default symbol mapping for each node type."""
|
||||
return await asyncio.to_thread(_run_handler_sync, get_default_symbols_handler, {})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def symbol_upload(
|
||||
symbol_id: Annotated[str, Field(description="Symbol ID to upload (e.g. ':/symbols/my_symbol.svg')")],
|
||||
content: Annotated[str, Field(description="SVG content of the symbol")],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Upload or update a custom symbol on the server. Provide the SVG content as a string."""
|
||||
return await asyncio.to_thread(_run_handler_sync, upload_symbol_handler, {
|
||||
"symbol_id": symbol_id, "content": content,
|
||||
})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def symbol_delete(
|
||||
symbol_id: Annotated[str, Field(description="Symbol ID to delete (e.g. ':/symbols/my_custom_symbol.svg'). Use symbol_list to get existing IDs.")],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Delete a custom symbol from the server.
|
||||
|
||||
NOTE: Only custom (user-uploaded) symbols can be deleted.
|
||||
Built-in symbols (starting with ':/symbols/') will be rejected with 403.
|
||||
Use symbol_list to see which symbols are available and their IDs.
|
||||
"""
|
||||
return await asyncio.to_thread(_run_handler_sync, delete_symbol_handler, {
|
||||
"symbol_id": symbol_id,
|
||||
})
|
||||
#
|
||||
# Disabled for now: symbol handling requires a vision-capable model (the
|
||||
# tools shuttle SVG content, which a text-only LLM cannot inspect or
|
||||
# produce). Revisit later.
|
||||
#
|
||||
# @mcp.tool()
|
||||
# async def symbol_list() -> list[dict[str, Any]]:
|
||||
# """List all available symbols on the server."""
|
||||
# return await asyncio.to_thread(_run_handler_sync, get_symbols_handler, {})
|
||||
#
|
||||
#
|
||||
# @mcp.tool()
|
||||
# async def symbol_get(
|
||||
# symbol_id: Annotated[str, Field(description="Symbol ID (e.g. ':/symbols/router.svg')")],
|
||||
# ) -> list[dict[str, Any]]:
|
||||
# """Get a download URL for a symbol file (SVG). The URL includes a short-lived JWT (10 min). Use curl to download."""
|
||||
# return await asyncio.to_thread(_run_handler_sync, get_symbol_handler, {
|
||||
# "symbol_id": symbol_id,
|
||||
# })
|
||||
#
|
||||
#
|
||||
# @mcp.tool()
|
||||
# async def symbol_dimensions(
|
||||
# symbol_id: Annotated[str, Field(description="Symbol ID to get dimensions for")],
|
||||
# ) -> list[dict[str, Any]]:
|
||||
# """Get the dimensions (width, height) of a symbol."""
|
||||
# return await asyncio.to_thread(_run_handler_sync, get_symbol_dimensions_handler, {
|
||||
# "symbol_id": symbol_id,
|
||||
# })
|
||||
#
|
||||
#
|
||||
# @mcp.tool()
|
||||
# async def symbol_defaults() -> list[dict[str, Any]]:
|
||||
# """Get the default symbol mapping for each node type."""
|
||||
# return await asyncio.to_thread(_run_handler_sync, get_default_symbols_handler, {})
|
||||
#
|
||||
#
|
||||
# @mcp.tool()
|
||||
# async def symbol_upload(
|
||||
# symbol_id: Annotated[str, Field(description="Symbol ID to upload (e.g. ':/symbols/my_symbol.svg')")],
|
||||
# content: Annotated[str, Field(description="SVG content of the symbol")],
|
||||
# ) -> list[dict[str, Any]]:
|
||||
# """Upload or update a custom symbol on the server. Provide the SVG content as a string."""
|
||||
# return await asyncio.to_thread(_run_handler_sync, upload_symbol_handler, {
|
||||
# "symbol_id": symbol_id, "content": content,
|
||||
# })
|
||||
#
|
||||
#
|
||||
# @mcp.tool()
|
||||
# async def symbol_delete(
|
||||
# symbol_id: Annotated[str, Field(description="Symbol ID to delete (e.g. ':/symbols/my_custom_symbol.svg'). Use symbol_list to get existing IDs.")],
|
||||
# ) -> list[dict[str, Any]]:
|
||||
# """Delete a custom symbol from the server.
|
||||
#
|
||||
# NOTE: Only custom (user-uploaded) symbols can be deleted.
|
||||
# Built-in symbols (starting with ':/symbols/') will be rejected with 403.
|
||||
# Use symbol_list to see which symbols are available and their IDs.
|
||||
# """
|
||||
# return await asyncio.to_thread(_run_handler_sync, delete_symbol_handler, {
|
||||
# "symbol_id": symbol_id,
|
||||
# })
|
||||
|
||||
|
||||
# ── Appliance tools ───────────────────────────────────────────────────
|
||||
@ -1365,7 +1375,7 @@ async def appliance_install(
|
||||
appliance_id: Annotated[str, Field(description="UUID of the appliance to install")],
|
||||
version: Annotated[str | None, Field(description="Version to install (e.g. '2.7.0.356'). Required if the appliance has multiple versions. Use appliance_get to see available versions.")] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Create a template from a GNS3 appliance definition.
|
||||
"""Create a template from a GNS3 appliance definition and return the created template.
|
||||
|
||||
NOTE: This does NOT download images. Images must be placed in the
|
||||
GNS3 images directory (e.g. ~/GNS3/images/) beforehand.
|
||||
@ -1424,7 +1434,9 @@ async def image_install() -> list[dict[str, Any]]:
|
||||
|
||||
This is NOT for downloading images. Images must be uploaded first (via the GNS3 Web UI).
|
||||
If an uploaded image matches a known appliance, a template is automatically created.
|
||||
Images already referenced by existing templates are skipped.
|
||||
Returns {"created": [...], "skipped": [...]}: images already referenced by existing
|
||||
templates are skipped, and no template is auto-created when one with the same name
|
||||
already exists (regardless of version).
|
||||
"""
|
||||
return await asyncio.to_thread(_run_handler_sync, install_images_handler, {})
|
||||
|
||||
@ -1458,6 +1470,10 @@ async def device_config_send(
|
||||
Devices must be started first (use node_start or node_start_all).
|
||||
Device type is auto-detected from the 'device_type:<type>' tag on each node.
|
||||
Common device types: cisco_ios_telnet, cisco_xr_telnet, huawei_telnet, gns3_huawei_telnet_ce
|
||||
|
||||
Error contract: every failure is reported in-band as an entry with
|
||||
status "failed" and an "error" message (per-device entries also carry
|
||||
device_name and commands).
|
||||
"""
|
||||
params = {"project_id": project_id, "device_configs": device_configs}
|
||||
if template is not None:
|
||||
@ -1488,6 +1504,10 @@ async def device_show_run(
|
||||
(e.g. device_type:cisco_ios_telnet, device_type:gns3_huawei_telnet_ce).
|
||||
Nodes without this tag will fail with "device_type tag not found".
|
||||
Docker/Linux nodes are not supported (use node_console instead).
|
||||
|
||||
Error contract: every failure is reported in-band as an entry with
|
||||
status "failed" and an "error" message (per-device entries also carry
|
||||
device_name and commands).
|
||||
"""
|
||||
params = {"project_id": project_id, "device_configs": device_configs}
|
||||
if template is not None:
|
||||
@ -1504,6 +1524,11 @@ async def vpcs_config_set(
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Configure VPCS devices (set IP addresses, gateway, etc.).
|
||||
|
||||
Only VPCS nodes are accepted: any other node type in device_configs fails
|
||||
with a per-device error instead of typing VPCS syntax into its CLI.
|
||||
Every failure is reported in-band as an entry with status "failed"
|
||||
and an "error" message.
|
||||
|
||||
VPCS-specific configuration commands:
|
||||
- ip <address>/<mask> <gateway> Set IP and gateway
|
||||
- save Save config to startup.vpc
|
||||
|
||||
@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
@ -85,5 +85,13 @@ def install_appliance_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
|
||||
version = params.get("version")
|
||||
if version:
|
||||
request_params["version"] = version
|
||||
result = conn.http_call("post", url, params=request_params).json()
|
||||
return {"message": f"Appliance {appliance_id} installation requested", "result": result}
|
||||
response = conn.http_call("post", url, params=request_params)
|
||||
result = {"message": f"Appliance {appliance_id} installed"}
|
||||
if response.content:
|
||||
# the install endpoint returns the created template (201); tolerate an
|
||||
# empty body in case an older server still replies with 204
|
||||
template = response.json()
|
||||
result["template"] = {
|
||||
k: template[k] for k in ("template_id", "name", "version", "template_type") if k in template
|
||||
}
|
||||
return result
|
||||
|
||||
@ -26,7 +26,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
@ -42,20 +42,16 @@ def list_computes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
|
||||
|
||||
|
||||
def get_compute_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
compute_id = params.get("compute_id")
|
||||
if not compute_id:
|
||||
return {"error": "compute_id is required (use compute_list to get the UUID)"}
|
||||
compute_id = params.get("compute_id") or "local"
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/computes/{compute_id}").json()
|
||||
|
||||
|
||||
def get_compute_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
emulator = params.get("emulator")
|
||||
compute_id = params.get("compute_id")
|
||||
compute_id = params.get("compute_id") or "local"
|
||||
if not emulator:
|
||||
return {"error": "emulator is required (e.g. qemu, iou, docker)"}
|
||||
if not compute_id:
|
||||
return {"error": "compute_id is required (use compute_list to get the UUID)"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
images = conn.http_call("get", f"{conn.base_url}/computes/{compute_id}/{emulator}/images").json()
|
||||
return {"images": images, "count": len(images)}
|
||||
|
||||
@ -51,7 +51,13 @@ def _render_template(template: str, device_configs: list[dict], commands_field:
|
||||
Args:
|
||||
commands_field: field name for the rendered commands, e.g. "config_commands", "commands"
|
||||
"""
|
||||
jinja = JinjaTemplate(template)
|
||||
try:
|
||||
jinja = JinjaTemplate(template)
|
||||
except JinjaError as e:
|
||||
# a syntactically invalid template must not escape as a raw exception
|
||||
error_msg = f"Template rendering failed: {e}"
|
||||
log.error(error_msg)
|
||||
return [{"status": "failed", "error": error_msg}]
|
||||
merged: dict[str, dict] = {}
|
||||
for dev in device_configs:
|
||||
name = dev.get("device_name")
|
||||
@ -69,7 +75,7 @@ def _render_template(template: str, device_configs: list[dict], commands_field:
|
||||
except JinjaError as e:
|
||||
error_msg = f"Template rendering failed for '{name}': {e}"
|
||||
log.error(error_msg)
|
||||
return [{"error": error_msg}]
|
||||
return [{"status": "failed", "error": error_msg}]
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
@ -81,7 +87,7 @@ def device_config_send_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 [{"error": "project_id and device_configs are required"}]
|
||||
return [{"status": "failed", "error": "project_id and device_configs are required"}]
|
||||
|
||||
if template:
|
||||
device_configs = _render_template(template, device_configs, commands_field="config_commands")
|
||||
@ -108,7 +114,7 @@ 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 [{"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")
|
||||
@ -134,7 +140,7 @@ def vpcs_config_set_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
||||
project_id = params.get("project_id")
|
||||
device_configs = params.get("device_configs")
|
||||
if not project_id or not device_configs:
|
||||
return [{"error": "project_id and device_configs are required"}]
|
||||
return [{"status": "failed", "error": "project_id and device_configs are required"}]
|
||||
|
||||
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
|
||||
@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
@ -72,6 +72,9 @@ def prune_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di
|
||||
|
||||
def install_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
conn = _get_connector(gns3_ctx)
|
||||
# Returns 204 No Content on success (empty body, no .json())
|
||||
conn.http_call("post", f"{conn.base_url}/images/install")
|
||||
response = conn.http_call("post", f"{conn.base_url}/images/install")
|
||||
if response.content:
|
||||
# the install endpoint reports which templates were created or skipped
|
||||
return response.json()
|
||||
# tolerate an empty body in case an older server still replies with 204
|
||||
return {"message": "Image installation completed"}
|
||||
|
||||
@ -1,633 +0,0 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 link management.
|
||||
|
||||
Handlers receive (params, gns3_ctx) and call GNS3's REST API
|
||||
via Gns3Connector (from custom_gns3fy).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import logging
|
||||
|
||||
from gns3server.services import auth_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BATCH_MAX_WORKERS = 100
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_link_nodes(nodes) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Normalize link node entries, accepting both standard object format and
|
||||
compact array format to reduce token usage.
|
||||
|
||||
Standard: [{"node_id": "uuid", "adapter_number": 0, "port_number": 0}]
|
||||
Compact: ["uuid", 0, 0, "uuid", 0, 0]
|
||||
|
||||
Returns the normalized list, or raises ValueError with a clear message
|
||||
on format errors so the AI can self-correct.
|
||||
"""
|
||||
if not nodes:
|
||||
return nodes
|
||||
if not isinstance(nodes, list):
|
||||
raise ValueError(f"nodes must be a list, got {type(nodes).__name__}: {nodes}")
|
||||
# Standard object format: [{"node_id": "...", ...}]
|
||||
if isinstance(nodes[0], dict):
|
||||
return nodes
|
||||
# Compact array format: ["uuid", ad, pt, "uuid", ad, pt]
|
||||
if all(not isinstance(n, dict) for n in nodes):
|
||||
if len(nodes) != 6:
|
||||
raise ValueError(
|
||||
f"Compact link format requires exactly 6 elements "
|
||||
f"[node_id, adapter, port, node_id, adapter, port], "
|
||||
f"but got {len(nodes)} elements: {nodes}"
|
||||
)
|
||||
if not isinstance(nodes[0], str) or not isinstance(nodes[3], str):
|
||||
raise ValueError(
|
||||
f"Compact link format expects node_id (string) at positions 0 and 3, "
|
||||
f"got types {type(nodes[0]).__name__} and {type(nodes[3]).__name__}: {nodes}"
|
||||
)
|
||||
return [
|
||||
{"node_id": nodes[0], "adapter_number": nodes[1], "port_number": nodes[2]},
|
||||
{"node_id": nodes[3], "adapter_number": nodes[4], "port_number": nodes[5]},
|
||||
]
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
# ── Tool handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
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_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
|
||||
|
||||
|
||||
def _filter_link_response(link: dict, fields: list[str] = None) -> dict:
|
||||
"""Filter link response to only include requested fields."""
|
||||
if not fields:
|
||||
fields = LINK_DEFAULT_FIELDS
|
||||
return {k: link[k] for k in fields if k in link}
|
||||
|
||||
|
||||
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:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
links = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links").json()
|
||||
fields = params.get("fields")
|
||||
if fields:
|
||||
if not isinstance(fields, list):
|
||||
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 {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_LINK_FIELDS),
|
||||
}
|
||||
links = [{k: l[k] for k in fields if k in l} for l in links]
|
||||
return {"links": links, "count": len(links)}
|
||||
|
||||
|
||||
def get_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
link_id = params.get("link_id")
|
||||
if not project_id or not link_id:
|
||||
return {"error": "project_id and link_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links/{link_id}").json()
|
||||
|
||||
|
||||
def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
|
||||
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\"]"}
|
||||
|
||||
links = params.get("links")
|
||||
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}]
|
||||
if links is not None:
|
||||
if not isinstance(links, list) or not links:
|
||||
return {"error": "links must be a non-empty array"}
|
||||
results = []
|
||||
conn = _get_connector(gns3_ctx)
|
||||
def _create_one(link_data):
|
||||
raw_nodes = link_data.get("nodes")
|
||||
if not raw_nodes:
|
||||
return {"status": "error", "error": "nodes is required for each link"}
|
||||
try:
|
||||
body = {"nodes": _normalize_link_nodes(raw_nodes)}
|
||||
if link_data.get("link_type"):
|
||||
body["link_type"] = link_data["link_type"]
|
||||
if link_data.get("filters"):
|
||||
body["filters"] = link_data["filters"]
|
||||
if link_data.get("suspend"):
|
||||
body["suspend"] = link_data["suspend"]
|
||||
url = f"{conn.base_url}/projects/{project_id}/links"
|
||||
resp = conn.http_call("post", url, json_data=body).json()
|
||||
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:
|
||||
futures = {pool.submit(_create_one, l): l for l in links}
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
return results
|
||||
|
||||
# Single mode
|
||||
nodes = params.get("nodes")
|
||||
if not nodes:
|
||||
return {"error": "nodes is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {"nodes": _normalize_link_nodes(nodes)}
|
||||
if "link_type" in params:
|
||||
data["link_type"] = params["link_type"]
|
||||
if "filters" in params:
|
||||
data["filters"] = params["filters"]
|
||||
if "suspend" in params:
|
||||
data["suspend"] = params["suspend"]
|
||||
url = f"{conn.base_url}/projects/{project_id}/links"
|
||||
resp = conn.http_call("post", url, json_data=data).json()
|
||||
return _filter_link_response(resp, fields)
|
||||
|
||||
|
||||
def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
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": "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")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{link_id}")
|
||||
return {"message": f"Link {link_id} deleted", "link_id": link_id}
|
||||
|
||||
|
||||
def update_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
link_id = params.get("link_id")
|
||||
if not project_id or not link_id:
|
||||
return {"error": "project_id and link_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
# Extract update parameters - handle nested kwargs structure from MCP clients
|
||||
if "kwargs" in params and isinstance(params["kwargs"], dict):
|
||||
update_data = params["kwargs"]
|
||||
else:
|
||||
update_data = {k: v for k, v in params.items() if k not in ("project_id", "link_id", "kwargs")}
|
||||
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}"
|
||||
return conn.http_call("put", url, json_data=update_data).json()
|
||||
|
||||
|
||||
# ── Link capture / reset handlers ──────────────────────────────────────
|
||||
|
||||
|
||||
def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
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"
|
||||
r = conn.http_call("post", url).json()
|
||||
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")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/reset"
|
||||
result = conn.http_call("post", url).json()
|
||||
return {"message": f"Link {link_id} reset", "link": result}
|
||||
|
||||
|
||||
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}"
|
||||
kwargs = data_builder(lid) if data_builder else {}
|
||||
conn.http_call("post", url, **kwargs)
|
||||
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))
|
||||
|
||||
|
||||
def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
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:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {
|
||||
"data_link_type": params.get("data_link_type", "DLT_EN10MB"),
|
||||
"wireshark": params.get("wireshark", False),
|
||||
}
|
||||
if params.get("capture_file_name"):
|
||||
data["capture_file_name"] = params["capture_file_name"]
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/start"
|
||||
result = conn.http_call("post", url, json_data=data).json()
|
||||
return {"message": f"Capture started on link {link_id}", "link": result}
|
||||
|
||||
|
||||
def stop_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_capture(project_id, link_ids, "stop", None, conn)
|
||||
link_id = params.get("link_id")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/stop"
|
||||
conn.http_call("post", url)
|
||||
return {"message": f"Capture stopped on link {link_id}", "link_id": link_id}
|
||||
|
||||
|
||||
def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
username = gns3_ctx.get("jwt_username")
|
||||
download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||
|
||||
link_ids = params.get("link_ids")
|
||||
if link_ids:
|
||||
if not isinstance(link_ids, list):
|
||||
return {"error": "link_ids must be a list"}
|
||||
results = []
|
||||
for lid in link_ids:
|
||||
url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{lid}/capture/file"
|
||||
entry = {"link_id": lid, "download_url": url}
|
||||
if download_token:
|
||||
cmd = f"curl -L -o capture_{lid}.pcap -H 'Authorization: Bearer {download_token}' '{url}'"
|
||||
entry["curl_command"] = cmd
|
||||
results.append(entry)
|
||||
return {"downloads": results, "count": len(results), "note": "Files are in pcap format. Links include a 10-minute token."}
|
||||
|
||||
link_id = params.get("link_id")
|
||||
if not link_id:
|
||||
return {"error": "link_id or link_ids is required"}
|
||||
download_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{link_id}/capture/file"
|
||||
result = {
|
||||
"link_id": link_id,
|
||||
"download_url": download_url,
|
||||
"note": "The file is in pcap format and can be analyzed with Wireshark or tcpdump.",
|
||||
}
|
||||
if download_token:
|
||||
result["curl_command"] = f"curl -L -o capture.pcap -H 'Authorization: Bearer {download_token}' '{download_url}'"
|
||||
result["note"] += " The download link includes a 10-minute token."
|
||||
return result
|
||||
|
||||
|
||||
# ── Marker (traffic-insight) handlers ──────────────────────────────────
|
||||
|
||||
|
||||
def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Manage traffic-insight markers on a specific link.
|
||||
|
||||
Actions:
|
||||
- create: POST /projects/{pid}/links/{lid}/markers
|
||||
- update: PUT /projects/{pid}/links/{lid}/markers/{name}
|
||||
- delete: DELETE /projects/{pid}/links/{lid}/markers/{name}
|
||||
"""
|
||||
project_id = params.get("project_id")
|
||||
link_id = params.get("link_id")
|
||||
action = params.get("action")
|
||||
if not all([project_id, link_id, action]):
|
||||
return {"error": "project_id, link_id and action are required"}
|
||||
if action not in ("create", "update", "delete"):
|
||||
return {"error": f"Unknown action: {action}. Supported: create, update, delete"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
base = f"{conn.base_url}/projects/{project_id}/links/{link_id}/markers"
|
||||
|
||||
if action == "create":
|
||||
bpf = params.get("bpf")
|
||||
if not bpf:
|
||||
return {"error": "bpf is required for create action"}
|
||||
body: dict[str, Any] = {"bpf": bpf}
|
||||
for opt in ("name", "tag", "capture_node_id", "color", "highlight_duration"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
# direction: "tx"/"rx" set a one-way filter; "both"/omitted = no filter.
|
||||
if params.get("direction") in ("tx", "rx"):
|
||||
body["direction"] = params["direction"]
|
||||
return conn.http_call("post", base, json_data=body).json()
|
||||
|
||||
marker_name = params.get("marker_name")
|
||||
if not marker_name:
|
||||
return {"error": "marker_name is required for update/delete actions"}
|
||||
|
||||
url = f"{base}/{marker_name}"
|
||||
|
||||
if action == "update":
|
||||
body = {}
|
||||
for opt in ("bpf", "tag", "enabled", "color", "highlight_duration"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
# direction tri-state: omitted=preserve, "tx"/"rx"=set, "both"=clear (→ null).
|
||||
direction = params.get("direction")
|
||||
if direction == "both":
|
||||
body["direction"] = None
|
||||
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 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}
|
||||
|
||||
|
||||
def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Manage project-level marker definitions (auto-fanout to all links).
|
||||
|
||||
Actions:
|
||||
- create: POST /projects/{pid}/marker-definitions → fans out global-{name} to every link
|
||||
- update: PUT /projects/{pid}/marker-definitions/{name}
|
||||
- delete: DELETE /projects/{pid}/marker-definitions/{name}
|
||||
- list: GET /projects/{pid}/marker-definitions
|
||||
"""
|
||||
project_id = params.get("project_id")
|
||||
action = params.get("action")
|
||||
if not all([project_id, action]):
|
||||
return {"error": "project_id and action are required"}
|
||||
if action not in ("create", "update", "delete", "list"):
|
||||
return {"error": f"Unknown action: {action}. Supported: create, update, delete, list"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
base = f"{conn.base_url}/projects/{project_id}/marker-definitions"
|
||||
|
||||
if action == "list":
|
||||
return conn.http_call("get", base).json()
|
||||
|
||||
if action == "create":
|
||||
bpf = params.get("bpf")
|
||||
if not bpf:
|
||||
return {"error": "bpf is required for create action"}
|
||||
body: dict[str, Any] = {"bpf": bpf}
|
||||
for opt in ("name", "tag", "color", "highlight_duration", "data_link_type"):
|
||||
if params.get(opt) is not None:
|
||||
body[opt] = params[opt]
|
||||
# No direction: a definition fans out to every link and auto-selects its
|
||||
# capture node on each, so tx/rx (which is relative to that node) has no
|
||||
# consistent meaning. Encode direction in the BPF instead.
|
||||
return conn.http_call("post", base, json_data=body).json()
|
||||
|
||||
def_name = params.get("def_name")
|
||||
if not def_name:
|
||||
return {"error": "def_name is required for update/delete actions"}
|
||||
|
||||
url = f"{base}/{def_name}"
|
||||
|
||||
if action == "update":
|
||||
body = {}
|
||||
for opt in ("bpf", "tag", "color", "highlight_duration", "data_link_type"):
|
||||
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 conn.http_call("put", url, json_data=body).json()
|
||||
|
||||
# action == "delete"
|
||||
conn.http_call("delete", url)
|
||||
return {"message": f"Marker definition '{def_name}' deleted", "project_id": project_id, "def_name": def_name}
|
||||
|
||||
|
||||
# ── Tool definitions ───────────────────────────────────────────────────────
|
||||
|
||||
LINK_TOOLS = [
|
||||
{
|
||||
"name": "get_links",
|
||||
"description": "List all links in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": get_links_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_link",
|
||||
"description": "Get detailed information about a specific link",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": get_link_handler,
|
||||
},
|
||||
{
|
||||
"name": "create_link",
|
||||
"description": "Create a link between two nodes in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"description": "List of node connections, each with node_id, adapter_number, port_number",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node_id": {"type": "string"},
|
||||
"adapter_number": {"type": "integer"},
|
||||
"port_number": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"link_type": {"type": "string", "description": "Link type: ethernet or serial (optional)"},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"description": "Packet filters (optional). Must use array format: frequency_drop: [N], packet_loss: [rate], delay: [ms, jitter], corrupt: [rate], bpf: [expression]"
|
||||
},
|
||||
},
|
||||
"required": ["project_id", "nodes"],
|
||||
},
|
||||
"handler": create_link_handler,
|
||||
},
|
||||
{
|
||||
"name": "delete_link",
|
||||
"description": "Delete a link from a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": delete_link_handler,
|
||||
},
|
||||
{
|
||||
"name": "update_link",
|
||||
"description": "Update a link's properties (suspend, filters, etc.)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
"suspend": {"type": "boolean", "description": "Suspend the link (optional)"},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"description": "Packet filters (optional). Must use array format: frequency_drop: [N], packet_loss: [rate], delay: [ms, jitter], corrupt: [rate], bpf: [expression]. Example: {\"frequency_drop\": [10], \"packet_loss\": [5]}"
|
||||
},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": update_link_handler,
|
||||
},
|
||||
{
|
||||
"name": "reset_link",
|
||||
"description": "Reset a link, clearing its state (counters, filters, etc.)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": reset_link_handler,
|
||||
},
|
||||
{
|
||||
"name": "start_capture",
|
||||
"description": "Start packet capture on a link. The capture file can later be downloaded with download_capture_file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
"data_link_type": {"type": "string", "description": "Data link type (optional, default: DLT_EN10MB)"},
|
||||
"capture_file_name": {"type": "string", "description": "Capture file name (optional)"},
|
||||
"wireshark": {"type": "boolean", "description": "Open Wireshark automatically (optional, default: false)"},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": start_capture_handler,
|
||||
},
|
||||
{
|
||||
"name": "stop_capture",
|
||||
"description": "Stop packet capture on a link. After stopping, the capture file can be downloaded.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": stop_capture_handler,
|
||||
},
|
||||
{
|
||||
"name": "download_capture_file",
|
||||
"description": "Get the download URL and instructions for a PCAP capture file from a link. "
|
||||
"Use the returned curl command to download the file. "
|
||||
"The PCAP file can be analyzed with Wireshark or tcpdump.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"link_id": {"type": "string", "description": "Link UUID"},
|
||||
},
|
||||
"required": ["project_id", "link_id"],
|
||||
},
|
||||
"handler": download_capture_file_handler,
|
||||
},
|
||||
]
|
||||
@ -1,678 +0,0 @@
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
# Author: Yue Guobin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
MCP tool handlers for GNS3 node management.
|
||||
|
||||
Handlers receive (params, gns3_ctx) and call GNS3's REST API
|
||||
via Gns3Connector (from custom_gns3fy).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from gns3server.services import auth_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BATCH_MAX_WORKERS = 100
|
||||
|
||||
# ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Maximum bytes to return from get_node_file (safety net).
|
||||
# Larger files are truncated with a truncated=True flag.
|
||||
MAX_NODE_FILE_BYTES = 50 * 1024 # 50 KiB
|
||||
|
||||
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
api_version=3,
|
||||
verify=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Tool 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:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
nodes = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes").json()
|
||||
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\"]"}
|
||||
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_NODE_FIELDS),
|
||||
}
|
||||
nodes = [{k: n[k] for k in fields if k in n} for n in nodes]
|
||||
return {"nodes": nodes, "count": len(nodes)}
|
||||
|
||||
|
||||
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",
|
||||
# Node
|
||||
"template_id", "project_id", "node_directory", "status",
|
||||
"command_line", "width", "height", "ports", "console_host",
|
||||
}
|
||||
|
||||
|
||||
def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
|
||||
|
||||
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\"]"}
|
||||
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
|
||||
if invalid:
|
||||
return {
|
||||
"error": f"Unknown fields: {invalid}",
|
||||
"available_fields": sorted(VALID_NODE_FIELDS),
|
||||
}
|
||||
return {k: node[k] for k in fields if k in node}
|
||||
|
||||
return node
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
if not isinstance(node_ids, list):
|
||||
return {"error": "node_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_lifecycle(project_id, node_ids, "start", conn, "started")
|
||||
node_id = params.get("node_id")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/start", json_data={})
|
||||
return {"message": f"Node {node_id} started", "node_id": node_id}
|
||||
|
||||
|
||||
def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
if not isinstance(node_ids, list):
|
||||
return {"error": "node_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_lifecycle(project_id, node_ids, "stop", conn, "stopped")
|
||||
node_id = params.get("node_id")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/stop", json_data={})
|
||||
return {"message": f"Node {node_id} stopped", "node_id": node_id}
|
||||
|
||||
|
||||
def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
if not isinstance(node_ids, list):
|
||||
return {"error": "node_ids must be a list"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
return _batch_lifecycle(project_id, node_ids, "suspend", conn, "suspended")
|
||||
node_id = params.get("node_id")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/suspend")
|
||||
return {"message": f"Node {node_id} suspended", "node_id": node_id}
|
||||
|
||||
|
||||
def _filter_node_response(node: dict, fields: list[str] = None) -> dict:
|
||||
"""Filter node response to only include requested fields."""
|
||||
if not fields:
|
||||
fields = ["node_id", "name", "node_type", "status", "console"]
|
||||
return {k: node[k] for k in fields if k in node}
|
||||
|
||||
|
||||
def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
|
||||
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\"]"}
|
||||
|
||||
nodes = params.get("nodes")
|
||||
# Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}]
|
||||
# When top-level template_id is set, it applies to all nodes as a default
|
||||
if nodes is not None:
|
||||
if not isinstance(nodes, list) or not nodes:
|
||||
return {"error": "nodes must be a non-empty array"}
|
||||
default_tid = params.get("template_id")
|
||||
results = []
|
||||
conn = _get_connector(gns3_ctx)
|
||||
def _create_one(node_data):
|
||||
tid = node_data.get("template_id", default_tid)
|
||||
if not tid:
|
||||
return {"template_id": tid, "status": "error", "error": "template_id is required"}
|
||||
try:
|
||||
url = f"{conn.base_url}/projects/{project_id}/templates/{tid}"
|
||||
body = {
|
||||
"x": node_data.get("x", 0),
|
||||
"y": node_data.get("y", 0),
|
||||
"compute_id": node_data.get("compute_id", "local"),
|
||||
}
|
||||
node_name = node_data.get("name")
|
||||
if node_name:
|
||||
body["name"] = node_name
|
||||
resp = conn.http_call("post", url, json_data=body).json()
|
||||
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)}
|
||||
with ThreadPoolExecutor(max_workers=min(len(nodes), BATCH_MAX_WORKERS)) as pool:
|
||||
futures = {pool.submit(_create_one, n): n for n in nodes}
|
||||
for future in as_completed(futures):
|
||||
results.append(future.result())
|
||||
return results
|
||||
|
||||
# Single mode
|
||||
template_id = params.get("template_id")
|
||||
if not template_id:
|
||||
return {"error": "template_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {
|
||||
"x": params.get("x", 0),
|
||||
"y": params.get("y", 0),
|
||||
"compute_id": params.get("compute_id", "local"),
|
||||
}
|
||||
node_name = params.get("name")
|
||||
if node_name:
|
||||
data["name"] = node_name
|
||||
url = f"{conn.base_url}/projects/{project_id}/templates/{template_id}"
|
||||
resp = conn.http_call("post", url, json_data=data).json()
|
||||
return _filter_node_response(resp, fields)
|
||||
|
||||
|
||||
def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
node_ids = params.get("node_ids")
|
||||
if node_ids:
|
||||
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": "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")
|
||||
if not node_id:
|
||||
return {"error": "node_id or node_ids is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}")
|
||||
return {"message": f"Node {node_id} deleted", "node_id": node_id}
|
||||
|
||||
|
||||
def update_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
|
||||
# Extract update parameters - handle nested kwargs structure from MCP clients
|
||||
if "kwargs" in params and isinstance(params["kwargs"], dict):
|
||||
update_data = params["kwargs"]
|
||||
else:
|
||||
update_data = {k: v for k, v in params.items() if k not in ("project_id", "node_id", "kwargs")}
|
||||
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}"
|
||||
return conn.http_call("put", url, json_data=update_data).json()
|
||||
|
||||
|
||||
def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
|
||||
|
||||
console_type = node.get("console_type", "unknown")
|
||||
# Short-lived JWT for the WebSocket URL (10 min)
|
||||
username = gns3_ctx.get("jwt_username")
|
||||
ws_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
|
||||
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
|
||||
if ws_token:
|
||||
raw_url += f"?token={ws_token}"
|
||||
# Convert http scheme to ws for direct websocat usage
|
||||
ws_url = raw_url.replace("https://", "wss://").replace("http://", "ws://")
|
||||
|
||||
result = {
|
||||
"node_id": node_id,
|
||||
"node_name": node.get("name"),
|
||||
"console_type": console_type,
|
||||
"ws_url": ws_url,
|
||||
"command": f"websocat -t --no-close {ws_url}",
|
||||
}
|
||||
if ws_token:
|
||||
# Fingerprint of the minted token: compare it against what actually reached the
|
||||
# server (logged on WebSocket auth rejection) to detect copy corruption, and
|
||||
# re-request the URL once token_ttl_seconds has elapsed.
|
||||
result["token_sha256_prefix"] = hashlib.sha256(ws_token.encode()).hexdigest()[:8]
|
||||
result["token_ttl_seconds"] = 600
|
||||
if console_type in ("vnc",):
|
||||
result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}"
|
||||
return result
|
||||
|
||||
|
||||
# ── Node file handlers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def list_node_files_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files"
|
||||
query = {}
|
||||
if params.get("path"):
|
||||
query["path"] = params["path"]
|
||||
if params.get("recursive"):
|
||||
query["recursive"] = "true"
|
||||
files = conn.http_call("get", url, params=query if query else None).json()
|
||||
return {"files": files, "count": len(files)}
|
||||
|
||||
|
||||
def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
file_path = params.get("file_path")
|
||||
if not project_id or not node_id or not file_path:
|
||||
return {"error": "project_id, node_id and file_path are required"}
|
||||
|
||||
offset = params.get("offset", 0)
|
||||
limit = params.get("limit", 200)
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
|
||||
raw = conn.http_call("get", url).text
|
||||
|
||||
total_bytes = len(raw.encode("utf-8"))
|
||||
truncated = False
|
||||
if total_bytes > MAX_NODE_FILE_BYTES:
|
||||
raw = raw[:MAX_NODE_FILE_BYTES]
|
||||
truncated = True
|
||||
|
||||
lines = raw.splitlines(keepends=False)
|
||||
total_lines = len(lines)
|
||||
|
||||
# Apply offset/limit
|
||||
selected = lines[offset: offset + limit] if offset < total_lines else []
|
||||
has_more = (offset + limit) < total_lines or truncated
|
||||
|
||||
return {
|
||||
"file_path": file_path,
|
||||
"content": "\n".join(selected),
|
||||
"metadata": {
|
||||
"total_lines": total_lines,
|
||||
"total_bytes": total_bytes,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"returned_lines": len(selected),
|
||||
"returned_bytes": len("\n".join(selected).encode("utf-8")),
|
||||
"truncated": truncated or has_more,
|
||||
"has_more": has_more,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
file_path = params.get("file_path")
|
||||
content = params.get("content")
|
||||
if not project_id or not node_id or not file_path or content is None:
|
||||
return {"error": "project_id, node_id, file_path and content are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
|
||||
conn.http_call("post", url, data=content, headers={"Content-Type": "text/plain"})
|
||||
return {"message": f"File {file_path} written to node {node_id}", "file_path": file_path, "node_id": node_id}
|
||||
|
||||
|
||||
def delete_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
file_path = params.get("file_path")
|
||||
if not project_id or not node_id or not file_path:
|
||||
return {"error": "project_id, node_id and file_path are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
|
||||
conn.http_call("delete", url)
|
||||
return {"message": f"File {file_path} deleted from node {node_id}", "file_path": file_path, "node_id": node_id}
|
||||
|
||||
|
||||
# ── Node bulk / advanced handlers ────────────────────────────────────
|
||||
|
||||
|
||||
def start_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/start")
|
||||
return {"message": "All nodes started", "project_id": project_id}
|
||||
|
||||
|
||||
def stop_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/stop")
|
||||
return {"message": "All nodes stopped", "project_id": project_id}
|
||||
|
||||
|
||||
def suspend_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/suspend")
|
||||
return {"message": "All nodes suspended", "project_id": project_id}
|
||||
|
||||
|
||||
def duplicate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
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()
|
||||
return {"message": f"Node {node_id} duplicated", "node": result}
|
||||
|
||||
|
||||
def isolate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/isolate")
|
||||
return {"message": f"Node {node_id} isolated (all links suspended)", "node_id": node_id}
|
||||
|
||||
|
||||
def unisolate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/unisolate")
|
||||
return {"message": f"Node {node_id} unisolated (links resumed)", "node_id": node_id}
|
||||
|
||||
|
||||
def get_node_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
node_id = params.get("node_id")
|
||||
if not project_id or not node_id:
|
||||
return {"error": "project_id and node_id are required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
links = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/links").json()
|
||||
return {"links": links, "count": len(links)}
|
||||
|
||||
|
||||
# ── Tool definitions ───────────────────────────────────────────────────────
|
||||
|
||||
NODE_TOOLS = [
|
||||
{
|
||||
"name": "get_nodes",
|
||||
"description": "List all nodes in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
},
|
||||
"required": ["project_id"],
|
||||
},
|
||||
"handler": get_nodes_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_node",
|
||||
"description": "Get detailed information about a specific node",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": get_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "start_node",
|
||||
"description": "Start a node in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": start_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "stop_node",
|
||||
"description": "Stop a node in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": stop_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "suspend_node",
|
||||
"description": "Suspend a node in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": suspend_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "create_node",
|
||||
"description": "Create a new node from a template in a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"template_id": {"type": "string", "description": "Template UUID"},
|
||||
"x": {"type": "integer", "description": "X coordinate (optional)"},
|
||||
"y": {"type": "integer", "description": "Y coordinate (optional)"},
|
||||
"compute_id": {"type": "string", "description": "Compute ID (optional, default: local)"},
|
||||
},
|
||||
"required": ["project_id", "template_id"],
|
||||
},
|
||||
"handler": create_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "delete_node",
|
||||
"description": "Delete a node from a project",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": delete_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "update_node",
|
||||
"description": "Update a node's properties (name, position, etc.)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
"name": {"type": "string", "description": "New node name (optional)"},
|
||||
"x": {"type": "integer", "description": "New X position (optional)"},
|
||||
"y": {"type": "integer", "description": "New Y position (optional)"},
|
||||
"compute_id": {"type": "string", "description": "Compute ID (optional)"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": update_node_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_node_console_info",
|
||||
"description": "Get console WebSocket URL for a node (use websocat to connect)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": get_node_console_info_handler,
|
||||
},
|
||||
{
|
||||
"name": "list_node_files",
|
||||
"description": "List files in a node directory with metadata (name, size, type, modified time). "
|
||||
"Use recursive=true for a full recursive listing. "
|
||||
"Check file sizes before reading large files with get_node_file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
"path": {"type": "string", "description": "Subdirectory path within node directory (optional)"},
|
||||
"recursive": {"type": "boolean", "description": "Recursively list all files (optional, default: false)"},
|
||||
},
|
||||
"required": ["project_id", "node_id"],
|
||||
},
|
||||
"handler": list_node_files_handler,
|
||||
},
|
||||
{
|
||||
"name": "get_node_file",
|
||||
"description": "Read a text file from a node directory. Returns file content line-by-line with offset/limit support. "
|
||||
"Best practice: start with offset=0, limit=200 to preview, then increase offset to read more. "
|
||||
"Large files (>50KB) are auto-truncated; check the metadata.truncated flag. "
|
||||
"For binary files, check file type via list_node_files first.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
"file_path": {"type": "string", "description": "Path to the file within the node directory"},
|
||||
"offset": {"type": "integer", "description": "Line offset to start reading from (optional, default: 0)"},
|
||||
"limit": {"type": "integer", "description": "Maximum number of lines to return (optional, default: 200)"},
|
||||
},
|
||||
"required": ["project_id", "node_id", "file_path"],
|
||||
},
|
||||
"handler": get_node_file_handler,
|
||||
},
|
||||
{
|
||||
"name": "write_node_file",
|
||||
"description": "Write content to a file in a node directory. Creates the file if it doesn't exist. "
|
||||
"Overwrites existing content. Useful for updating configuration files on nodes.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
"file_path": {"type": "string", "description": "Path to the file within the node directory"},
|
||||
"content": {"type": "string", "description": "Content to write to the file"},
|
||||
},
|
||||
"required": ["project_id", "node_id", "file_path", "content"],
|
||||
},
|
||||
"handler": write_node_file_handler,
|
||||
},
|
||||
{
|
||||
"name": "delete_node_file",
|
||||
"description": "Delete a file from a node directory. Cannot be undone. "
|
||||
"Use list_node_files to confirm the file path before deleting.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": {"type": "string", "description": "Project UUID"},
|
||||
"node_id": {"type": "string", "description": "Node UUID"},
|
||||
"file_path": {"type": "string", "description": "Path to the file within the node directory"},
|
||||
},
|
||||
"required": ["project_id", "node_id", "file_path"],
|
||||
},
|
||||
"handler": delete_node_file_handler,
|
||||
},
|
||||
]
|
||||
@ -19,7 +19,7 @@
|
||||
MCP tools for GNS3 project management.
|
||||
|
||||
Tool handlers receive (params, gns3_ctx) and call GNS3's REST API
|
||||
via Gns3Connector (from custom_gns3fy).
|
||||
via Gns3Connector (from gns3_copilot.gns3_client.connector).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
@ -33,7 +33,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
"""Create a Gns3Connector from the GNS3 context dict."""
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
@ -176,15 +176,6 @@ def unlock_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
||||
return {"message": f"Project {project_id} unlocked", "project_id": project_id}
|
||||
|
||||
|
||||
def load_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
path = params.get("path")
|
||||
if not path:
|
||||
return {"error": "path is required"}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/load", json_data={"path": path}).json()
|
||||
return {"message": f"Project loaded from {path}", "project": result}
|
||||
|
||||
|
||||
def get_locked_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
|
||||
@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
|
||||
@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
|
||||
@ -31,7 +31,7 @@ log = logging.getLogger(__name__)
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
MCP tool handlers for GNS3 template management.
|
||||
|
||||
Handlers receive (params, gns3_ctx) and call GNS3's REST API
|
||||
via Gns3Connector (from custom_gns3fy).
|
||||
via Gns3Connector (from gns3_copilot.gns3_client.connector).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
@ -32,7 +32,7 @@ log = logging.getLogger(__name__)
|
||||
# ── Helper ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_connector(gns3_ctx: dict[str, Any]):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
return Gns3Connector(
|
||||
url=gns3_ctx["server_url"],
|
||||
jwt_token=gns3_ctx["jwt_token"],
|
||||
|
||||
@ -122,7 +122,8 @@ def add_appliance_version(appliance_id: UUID, appliance_version: Union[schemas.A
|
||||
|
||||
@router.post(
|
||||
"/{appliance_id}/install",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=schemas.Template,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(has_privilege("Appliance.Allocate"))]
|
||||
)
|
||||
async def install_appliance(
|
||||
@ -132,15 +133,15 @@ async def install_appliance(
|
||||
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
|
||||
current_user: schemas.User = Depends(get_current_active_user),
|
||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
|
||||
) -> None:
|
||||
) -> schemas.Template:
|
||||
"""
|
||||
Install an appliance.
|
||||
Install an appliance and return the created template.
|
||||
|
||||
Required privilege: Appliance.Allocate
|
||||
"""
|
||||
|
||||
controller = Controller.instance()
|
||||
await controller.appliance_manager.install_appliance(
|
||||
return await controller.appliance_manager.install_appliance(
|
||||
appliance_id,
|
||||
version,
|
||||
images_repo,
|
||||
|
||||
@ -206,19 +206,24 @@ async def prune_images(
|
||||
|
||||
@router.post(
|
||||
"/install",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
status_code=status.HTTP_200_OK,
|
||||
dependencies=[Depends(has_privilege("Image.Allocate"))]
|
||||
)
|
||||
async def install_images(
|
||||
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
|
||||
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
|
||||
) -> None:
|
||||
) -> dict:
|
||||
"""
|
||||
Attempt to automatically create templates based on image checksums.
|
||||
|
||||
Returns the list of created templates and the list of skipped
|
||||
candidates (with the reason why they were skipped).
|
||||
|
||||
Required privilege: Image.Allocate
|
||||
"""
|
||||
|
||||
created = []
|
||||
skipped = []
|
||||
skip_images = get_builtin_disks()
|
||||
images = await images_repo.get_images()
|
||||
for image in images:
|
||||
@ -229,8 +234,12 @@ async def install_images(
|
||||
if templates:
|
||||
# the image is already used by a template
|
||||
log.warning(f"Image '{image.path}' is used by one or more templates")
|
||||
skipped.append({
|
||||
"name": image.filename,
|
||||
"reason": "image is already used by one or more templates",
|
||||
})
|
||||
continue
|
||||
await Controller.instance().appliance_manager.install_appliances_from_image(
|
||||
results = await Controller.instance().appliance_manager.install_appliances_from_image(
|
||||
image.path,
|
||||
image.checksum,
|
||||
images_repo,
|
||||
@ -239,6 +248,12 @@ async def install_images(
|
||||
None,
|
||||
os.path.dirname(image.path)
|
||||
)
|
||||
for result in results:
|
||||
if result.get("status") == "created":
|
||||
created.append({k: v for k, v in result.items() if k != "status"})
|
||||
else:
|
||||
skipped.append({k: v for k, v in result.items() if k != "status"})
|
||||
return {"created": created, "skipped": skipped}
|
||||
|
||||
|
||||
@router.get(
|
||||
|
||||
@ -372,14 +372,13 @@ async def suspend_node(node: Node = Depends(dep_node)) -> None:
|
||||
"""
|
||||
Suspend a node.
|
||||
|
||||
Node types without suspend support return a 405 error instead of a
|
||||
silent no-op, so the caller cannot mistake it for a suspended node.
|
||||
|
||||
Required privilege: Node.PowerMgmt
|
||||
"""
|
||||
|
||||
try:
|
||||
await node.suspend()
|
||||
except HTTPException as e:
|
||||
if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED:
|
||||
raise
|
||||
await node.suspend()
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@ -745,6 +745,17 @@ class Controller:
|
||||
if not os.path.exists(path):
|
||||
raise ControllerError(f"'{path}' does not exist on the controller")
|
||||
|
||||
# A .gns3 file must live in its own directory: the project path is
|
||||
# the file's parent directory. A file placed directly in the
|
||||
# projects directory would register the shared projects root as the
|
||||
# project directory, and deleting that project would wipe every
|
||||
# project on the controller.
|
||||
projects_path = os.path.realpath(self.projects_directory())
|
||||
if os.path.realpath(os.path.dirname(path)) == projects_path:
|
||||
raise ControllerError(
|
||||
f"'{path}' cannot be loaded: the .gns3 file must be in its own subdirectory of '{projects_path}'"
|
||||
)
|
||||
|
||||
topo_data = load_topology(path)
|
||||
topo_data.pop("topology")
|
||||
topo_data.pop("version")
|
||||
|
||||
@ -204,9 +204,9 @@ class ApplianceManager:
|
||||
else:
|
||||
raise ControllerError(f"Could not find '{appliance_file}'")
|
||||
|
||||
async def _create_template(self, template_data, templates_repo, rbac_repo, current_user):
|
||||
async def _create_template(self, template_data, templates_repo, rbac_repo, current_user) -> dict:
|
||||
"""
|
||||
Create a new template
|
||||
Create a new template and return it as a dict.
|
||||
"""
|
||||
|
||||
try:
|
||||
@ -217,6 +217,7 @@ class ApplianceManager:
|
||||
#template_id = template.get("template_id")
|
||||
#await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*")
|
||||
log.info(f"Template '{template.get('name')}' has been created")
|
||||
return template
|
||||
|
||||
async def _appliance_to_template(self, appliance: Appliance, version: str = None) -> dict:
|
||||
"""
|
||||
@ -244,11 +245,16 @@ class ApplianceManager:
|
||||
rbac_repo: RbacRepository,
|
||||
current_user: schemas.User,
|
||||
image_dir: str
|
||||
) -> None:
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Install appliances using an image checksum
|
||||
Install appliances using an image checksum.
|
||||
|
||||
Returns a manifest of what happened: one entry per attempted template,
|
||||
either {"status": "created", ...template fields} or
|
||||
{"status": "skipped", "name", "reason"}.
|
||||
"""
|
||||
|
||||
results: List[dict] = []
|
||||
appliances_info = self._find_appliances_from_image_checksum(image_checksum)
|
||||
for appliance, image_version in appliances_info:
|
||||
try:
|
||||
@ -256,15 +262,48 @@ class ApplianceManager:
|
||||
ApplianceModel.model_validate(appliance.asdict())
|
||||
except ValidationError as e:
|
||||
log.warning(f"Could not validate appliance '{appliance.id}': {e}")
|
||||
results.append({
|
||||
"status": "skipped",
|
||||
"name": appliance.name,
|
||||
"reason": f"could not validate appliance '{appliance.id}': {e}",
|
||||
})
|
||||
continue
|
||||
if appliance.versions:
|
||||
for version in appliance.versions:
|
||||
if version.get("name") == image_version:
|
||||
try:
|
||||
await self._find_appliance_version_images(appliance, version, images_repo, image_dir)
|
||||
template_data = await self._appliance_to_template(appliance, version)
|
||||
await self._create_template(template_data, templates_repo, rbac_repo, current_user)
|
||||
name = template_data.get("name")
|
||||
existing = await templates_repo.get_template_by_name(name) if name else None
|
||||
if existing is not None:
|
||||
# never automatically create a second template with the same
|
||||
# name: the name+version check in TemplatesService would allow
|
||||
# duplicates when the appliance version differs, but two
|
||||
# templates sharing a name is never what the user asked for here
|
||||
log.warning(f"Template '{name}' already exists, skipping automatic template creation")
|
||||
results.append({
|
||||
"status": "skipped",
|
||||
"name": name,
|
||||
"reason": f"a template named '{name}' already exists",
|
||||
})
|
||||
continue
|
||||
template = await self._create_template(template_data, templates_repo, rbac_repo, current_user)
|
||||
results.append({
|
||||
"status": "created",
|
||||
"template_id": str(template.get("template_id")),
|
||||
"name": template.get("name"),
|
||||
"version": template.get("version"),
|
||||
"template_type": template.get("template_type"),
|
||||
})
|
||||
except (ControllerError, InvalidImageError) as e:
|
||||
log.warning(f"Could not automatically create template using image '{image_path}': {e}")
|
||||
results.append({
|
||||
"status": "skipped",
|
||||
"name": appliance.name,
|
||||
"reason": str(e),
|
||||
})
|
||||
return results
|
||||
|
||||
async def install_appliance(
|
||||
self,
|
||||
|
||||
@ -471,6 +471,18 @@ class Project:
|
||||
@path.setter
|
||||
def path(self, path):
|
||||
check_path_allowed(path)
|
||||
|
||||
# The projects directory itself (or one of its ancestors) must
|
||||
# never become a project directory: deleting such a "project"
|
||||
# would wipe every project on the controller.
|
||||
real_path = os.path.realpath(path)
|
||||
real_projects_path = os.path.realpath(get_default_project_directory())
|
||||
if os.path.commonpath([real_path, real_projects_path]) == real_path:
|
||||
raise ControllerForbiddenError(
|
||||
f"The project directory cannot be '{path}': it must be a subdirectory "
|
||||
f"of '{real_projects_path}', not the projects directory itself or one of its parents"
|
||||
)
|
||||
|
||||
try:
|
||||
os.makedirs(path, exist_ok=True)
|
||||
except OSError as e:
|
||||
@ -1608,11 +1620,19 @@ class Project:
|
||||
await self._cleanup_web_wireshark_container()
|
||||
|
||||
try:
|
||||
project_directory = get_default_project_directory()
|
||||
if not os.path.commonprefix([project_directory, self.path]) == project_directory:
|
||||
project_directory = os.path.realpath(get_default_project_directory())
|
||||
path = os.path.realpath(self.path)
|
||||
if os.path.commonpath([path, project_directory]) != project_directory:
|
||||
raise ControllerError(
|
||||
f"Project '{self._name}' cannot be deleted because it is not in the default project directory: '{project_directory}'"
|
||||
)
|
||||
if path == project_directory:
|
||||
# A poisoned or hand-crafted entry whose path is the
|
||||
# projects root itself must never be deletable: rmtree
|
||||
# would wipe every project on the controller.
|
||||
raise ControllerError(
|
||||
f"Project '{self._name}' cannot be deleted because its directory is the projects directory itself: '{path}'"
|
||||
)
|
||||
shutil.rmtree(self.path)
|
||||
except OSError as e:
|
||||
raise ControllerError(f"Cannot delete project directory {self.path}: {str(e)}")
|
||||
@ -2121,6 +2141,10 @@ class Project:
|
||||
Check if all items in a project are locked and not
|
||||
"""
|
||||
|
||||
if not self._drawings and not self._nodes:
|
||||
# a project without drawings or nodes has nothing to lock and would
|
||||
# otherwise always report as locked, even after unlocking it
|
||||
return False
|
||||
for drawing in self._drawings.values():
|
||||
if not drawing.locked:
|
||||
return False
|
||||
|
||||
@ -71,6 +71,17 @@ class TemplatesRepository(BaseRepository):
|
||||
result = await self._db_session.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_template_by_name(self, name: str) -> Union[None, models.Template]:
|
||||
"""
|
||||
Return the first template with this name, regardless of version.
|
||||
"""
|
||||
|
||||
query = select(models.Template).\
|
||||
options(selectinload(models.Template.images)).\
|
||||
where(models.Template.name == name)
|
||||
result = await self._db_session.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_templates(self) -> List[models.Template]:
|
||||
|
||||
query = select(models.Template).options(selectinload(models.Template.images))
|
||||
|
||||
@ -54,8 +54,12 @@ class ComputesService:
|
||||
self._controller.notification.controller_emit("compute.created", compute.asdict())
|
||||
return db_compute
|
||||
|
||||
async def get_compute(self, compute_id: Union[str, UUID]) -> models.Compute:
|
||||
async def get_compute(self, compute_id: Union[str, UUID]) -> Union[models.Compute, dict]:
|
||||
|
||||
if str(compute_id) == "local":
|
||||
# the built-in local compute only lives in the controller, not in the database;
|
||||
# drop unset fields (e.g. user) as the response schema types them as str
|
||||
return {k: v for k, v in self._controller.get_compute("local").asdict().items() if v is not None}
|
||||
db_compute = await self._computes_repo.get_compute(compute_id)
|
||||
if not db_compute:
|
||||
raise ControllerNotFoundError(f"Compute '{compute_id}' not found")
|
||||
|
||||
139
tests/agent/mcp/test_device_tools.py
Normal file
139
tests/agent/mcp/test_device_tools.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""
|
||||
Device config tool tests with mocked topology and Nornir layers.
|
||||
|
||||
Covers the VPCS node-type guard and the error contract shared by
|
||||
device_config_send / device_show_run / vpcs_config_set.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
VPCS_MOD = "gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko"
|
||||
|
||||
|
||||
def _topology_ports(node_type):
|
||||
"""Mocked get_device_ports_from_topology return value for one device."""
|
||||
return {"PC1": {"port": 5000, "node_type": node_type}}
|
||||
|
||||
|
||||
class TestVPCSNodeTypeGuard:
|
||||
|
||||
def test_non_vpcs_node_is_rejected(self):
|
||||
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
|
||||
|
||||
with patch(f"{VPCS_MOD}.get_device_ports_from_topology",
|
||||
return_value=_topology_ports("iou")) as topo:
|
||||
result = VPCSCommands()._run(json.dumps({
|
||||
"project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291",
|
||||
"device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}],
|
||||
}))
|
||||
assert topo.called
|
||||
assert len(result) == 1
|
||||
assert result[0]["device_name"] == "PC1"
|
||||
assert result[0]["status"] == "failed"
|
||||
assert "not a VPCS node" in result[0]["error"]
|
||||
|
||||
def test_missing_node_type_is_rejected(self):
|
||||
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
|
||||
|
||||
with patch(f"{VPCS_MOD}.get_device_ports_from_topology",
|
||||
return_value={"PC1": {"port": 5000}}):
|
||||
result = VPCSCommands()._run(json.dumps({
|
||||
"project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291",
|
||||
"device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}],
|
||||
}))
|
||||
assert result[0]["status"] == "failed"
|
||||
assert "unknown-type" in result[0]["error"]
|
||||
|
||||
def test_vpcs_node_passes_the_guard(self):
|
||||
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
|
||||
|
||||
tool = VPCSCommands()
|
||||
nornir = MagicMock()
|
||||
host_result = MagicMock(failed=False)
|
||||
host_result.result = "OK"
|
||||
nornir.run.return_value = {"PC1": host_result}
|
||||
with patch(f"{VPCS_MOD}.get_device_ports_from_topology",
|
||||
return_value=_topology_ports("vpcs")), \
|
||||
patch.object(VPCSCommands, "_initialize_nornir", return_value=nornir):
|
||||
result = tool._run(json.dumps({
|
||||
"project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291",
|
||||
"device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}],
|
||||
}))
|
||||
assert result[0]["status"] == "success"
|
||||
assert result[0]["output"] == "OK"
|
||||
|
||||
def test_execution_failure_reports_failed_with_error(self):
|
||||
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
|
||||
|
||||
tool = VPCSCommands()
|
||||
nornir = MagicMock()
|
||||
host_result = MagicMock(failed=True)
|
||||
host_result.result = "Command failed (ReadTimeout)"
|
||||
nornir.run.return_value = {"PC1": host_result}
|
||||
with patch(f"{VPCS_MOD}.get_device_ports_from_topology",
|
||||
return_value=_topology_ports("vpcs")), \
|
||||
patch.object(VPCSCommands, "_initialize_nornir", return_value=nornir):
|
||||
result = tool._run(json.dumps({
|
||||
"project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291",
|
||||
"device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}],
|
||||
}))
|
||||
assert result[0]["status"] == "failed"
|
||||
assert result[0]["error"] == "Command failed (ReadTimeout)"
|
||||
assert "output" not in result[0]
|
||||
|
||||
|
||||
class TestDeviceToolErrorContract:
|
||||
"""
|
||||
Every in-band error entry carries status "failed" plus an "error"
|
||||
message, whether it is topology-level (no device) or per-device.
|
||||
"""
|
||||
|
||||
def test_topology_level_error_has_status(self):
|
||||
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
|
||||
|
||||
with patch(f"{VPCS_MOD}.get_device_ports_from_topology",
|
||||
side_effect=ValueError("topology unreachable")):
|
||||
result = VPCSCommands()._run(json.dumps({
|
||||
"project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291",
|
||||
"device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}],
|
||||
}))
|
||||
assert result == [{"status": "failed", "error": "topology unreachable"}]
|
||||
|
||||
def test_config_tool_topology_level_error_has_status(self):
|
||||
from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import (
|
||||
ExecuteMultipleDeviceConfigCommands,
|
||||
)
|
||||
|
||||
with patch("gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir"
|
||||
".get_device_ports_from_topology",
|
||||
side_effect=ValueError("no valid devices")):
|
||||
result = ExecuteMultipleDeviceConfigCommands()._run(json.dumps({
|
||||
"project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291",
|
||||
"device_configs": [{"device_name": "R1", "config_commands": ["int lo0"]}],
|
||||
}))
|
||||
assert result == [{"status": "failed", "error": "no valid devices"}]
|
||||
|
||||
def test_mcp_handler_param_error_has_status(self, ctx=None):
|
||||
from gns3server.agent.mcp.device_config import (
|
||||
device_config_send_handler,
|
||||
device_show_run_handler,
|
||||
vpcs_config_set_handler,
|
||||
)
|
||||
|
||||
for handler in (device_config_send_handler, device_show_run_handler, vpcs_config_set_handler):
|
||||
result = handler({}, {"server_url": "http://x", "jwt_token": "t"})
|
||||
assert result == [{
|
||||
"status": "failed",
|
||||
"error": result[0]["error"], # message text may differ per handler
|
||||
}]
|
||||
assert "required" in result[0]["error"]
|
||||
|
||||
def test_template_render_error_has_status(self):
|
||||
from gns3server.agent.mcp.device_config import _render_template
|
||||
|
||||
result = _render_template("{{ unclosed", [{"device_name": "R1", "vars": {"n": 1}}])
|
||||
assert len(result) == 1
|
||||
assert result[0]["status"] == "failed"
|
||||
assert "Template rendering failed" in result[0]["error"]
|
||||
@ -3,6 +3,8 @@ MCP handler unit tests with mocked Gns3Connector.
|
||||
|
||||
Tests that handlers correctly transform tool parameters into HTTP calls.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@ -12,10 +14,12 @@ def _mock_conn(json_result=None):
|
||||
conn = MagicMock()
|
||||
conn.base_url = "http://192.168.1.3:3080/v3"
|
||||
conn.http_call.return_value.json.return_value = json_result or {"status": "ok"}
|
||||
conn.http_call.return_value.content = b"{}" # non-empty body by default
|
||||
return conn
|
||||
|
||||
|
||||
BASE = "gns3server.agent.mcp"
|
||||
AH = "gns3server.agent.gns3_copilot.gns3_client.api_handlers" # node/link handlers sunk here
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -110,11 +114,10 @@ class TestProject:
|
||||
|
||||
class TestNode:
|
||||
|
||||
mod = "nodes"
|
||||
|
||||
def test_list_fields(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import get_nodes_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_nodes_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn([
|
||||
{"node_id": "n1", "name": "R1", "status": "started", "node_type": "qemu", "console": 5000},
|
||||
])
|
||||
@ -122,22 +125,22 @@ class TestNode:
|
||||
assert result == {"nodes": [{"name": "R1", "status": "started"}], "count": 1}
|
||||
|
||||
def test_list_invalid_fields(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import get_nodes_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_nodes_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn([])
|
||||
result = get_nodes_handler({"project_id": "p1", "fields": "not-a-list"}, ctx)
|
||||
assert "error" in result
|
||||
|
||||
def test_get(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import get_node_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn({"node_id": "n1", "name": "R1"})
|
||||
result = get_node_handler({"project_id": "p1", "node_id": "n1"}, ctx)
|
||||
assert result["name"] == "R1"
|
||||
|
||||
def test_create_single_passes_name(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import create_node_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"node_id": "n1", "name": "MyRouter"})
|
||||
m.return_value = conn
|
||||
result = create_node_handler({
|
||||
@ -151,8 +154,8 @@ class TestNode:
|
||||
assert result == {"node_id": "n1", "name": "MyRouter"}
|
||||
|
||||
def test_create_fields_filter(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import create_node_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn({"node_id": "n1", "name": "R1", "status": "started"})
|
||||
result = create_node_handler({
|
||||
"project_id": "p1", "template_id": "t1",
|
||||
@ -161,8 +164,8 @@ class TestNode:
|
||||
assert result == {"node_id": "n1", "name": "R1"}
|
||||
|
||||
def test_create_fields_validation(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import create_node_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn()
|
||||
m.return_value = conn
|
||||
result = create_node_handler({
|
||||
@ -173,8 +176,8 @@ class TestNode:
|
||||
conn.http_call.assert_not_called()
|
||||
|
||||
def test_create_batch_inherits_template_id(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import create_node_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn({"node_id": "n1", "name": "R1"})
|
||||
result = create_node_handler({
|
||||
"project_id": "p1", "template_id": "default-tpl",
|
||||
@ -182,70 +185,173 @@ class TestNode:
|
||||
}, ctx)
|
||||
assert result[0]["status"] == "success"
|
||||
|
||||
def test_create_batch_preserves_submission_order(self, ctx):
|
||||
import time
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn()
|
||||
def _http_call(method, url, json_data=None, **kwargs):
|
||||
# first submissions sleep longest so completion order is reversed
|
||||
time.sleep({"slow": 0.25, "mid": 0.1}.get(json_data.get("name"), 0.0))
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"node_id": "n1", "name": json_data["name"]}
|
||||
return resp
|
||||
conn.http_call.side_effect = _http_call
|
||||
m.return_value = conn
|
||||
result = create_node_handler({
|
||||
"project_id": "p1", "template_id": "t1",
|
||||
"nodes": [{"name": "slow"}, {"name": "mid"}, {"name": "fast"}],
|
||||
}, ctx)
|
||||
assert [r["node"]["name"] for r in result] == ["slow", "mid", "fast"]
|
||||
|
||||
def test_create_batch_default_names_created_sequentially(self, ctx):
|
||||
import threading
|
||||
import time
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
|
||||
|
||||
def _run(nodes_param):
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn()
|
||||
lock = threading.Lock()
|
||||
active = [0, 0] # in-flight requests, high-water mark
|
||||
counter = [0]
|
||||
|
||||
def _http_call(method, url, json_data=None, **kwargs):
|
||||
with lock:
|
||||
active[0] += 1
|
||||
active[1] = max(active[1], active[0])
|
||||
counter[0] += 1
|
||||
seq = counter[0]
|
||||
time.sleep(0.05) # wide enough that parallel calls would overlap
|
||||
with lock:
|
||||
active[0] -= 1
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"node_id": f"n{seq}", "name": json_data.get("name", f"R-{seq}")}
|
||||
return resp
|
||||
|
||||
conn.http_call.side_effect = _http_call
|
||||
m.return_value = conn
|
||||
result = create_node_handler({"project_id": "p1", "template_id": "t1", "nodes": nodes_param}, ctx)
|
||||
return result, active[1]
|
||||
|
||||
# nodes relying on default naming are created one at a time so the
|
||||
# server assigns default names/console ports in submission order
|
||||
result, max_active = _run([{}, {}, {}])
|
||||
assert [r["node"]["name"] for r in result] == ["R-1", "R-2", "R-3"]
|
||||
assert max_active == 1
|
||||
# one nameless node is enough to serialize the whole batch
|
||||
result, max_active = _run([{"name": "explicit"}, {}])
|
||||
assert [r["node"]["name"] for r in result] == ["explicit", "R-2"]
|
||||
assert max_active == 1
|
||||
|
||||
def test_create_missing_project_id(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import create_node_handler
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
|
||||
assert create_node_handler({}, ctx) == {"error": "project_id is required"}
|
||||
|
||||
def test_delete_batch(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import delete_node_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import delete_node_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn({})
|
||||
result = delete_node_handler({"project_id": "p1", "node_ids": ["n1", "n2"]}, ctx)
|
||||
assert len(result) == 2
|
||||
# same status vocabulary as create/start/stop batches
|
||||
assert all(r["status"] == "success" for r in result)
|
||||
|
||||
def test_start_batch(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import start_node_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import start_node_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn({"status": "started"})
|
||||
result = start_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx)
|
||||
assert result[0]["status"] == "success"
|
||||
|
||||
def test_stop_batch(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import stop_node_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import stop_node_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn({"status": "stopped"})
|
||||
result = stop_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx)
|
||||
assert result[0]["status"] == "success"
|
||||
|
||||
def test_suspend_batch(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import suspend_node_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import suspend_node_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn({"status": "suspended"})
|
||||
result = suspend_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx)
|
||||
assert result[0]["status"] == "success"
|
||||
|
||||
def test_console(self, ctx):
|
||||
from gns3server.agent.mcp.nodes import get_node_console_info_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn({"console_url": "ws://host/console"})
|
||||
result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx)
|
||||
assert "command" in result
|
||||
|
||||
@staticmethod
|
||||
def _file_conn(text):
|
||||
conn = _mock_conn()
|
||||
conn.http_call.return_value.text = text
|
||||
return conn
|
||||
|
||||
def test_file_get_keeps_trailing_newline(self, ctx):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = self._file_conn("line1\nline2\n")
|
||||
result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx)
|
||||
assert result["content"] == "line1\nline2\n"
|
||||
assert result["metadata"]["total_bytes"] == 12
|
||||
assert result["metadata"]["returned_bytes"] == 12
|
||||
assert result["metadata"]["has_more"] is False
|
||||
|
||||
def test_file_get_keeps_crlf_endings(self, ctx):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = self._file_conn("line1\r\nline2\r\n")
|
||||
result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx)
|
||||
assert result["content"] == "line1\r\nline2\r\n"
|
||||
assert result["metadata"]["returned_bytes"] == 14
|
||||
|
||||
def test_file_get_without_trailing_newline(self, ctx):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = self._file_conn("line1\nline2")
|
||||
result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx)
|
||||
assert result["content"] == "line1\nline2"
|
||||
|
||||
def test_file_get_pagination(self, ctx):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = self._file_conn("line1\nline2\nline3\n")
|
||||
result = get_node_file_handler(
|
||||
{"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg", "offset": 1, "limit": 1}, ctx
|
||||
)
|
||||
assert result["content"] == "line2\n"
|
||||
assert result["metadata"]["total_lines"] == 3
|
||||
assert result["metadata"]["returned_lines"] == 1
|
||||
assert result["metadata"]["has_more"] is True
|
||||
|
||||
|
||||
# ── Link ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLink:
|
||||
|
||||
mod = "links"
|
||||
|
||||
def test_list(self, ctx):
|
||||
from gns3server.agent.mcp.links import get_links_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_links_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn([{"link_id": "l1", "link_type": "ethernet"}])
|
||||
result = get_links_handler({"project_id": "p1", "fields": ["link_id"]}, ctx)
|
||||
assert result["links"] == [{"link_id": "l1"}]
|
||||
|
||||
def test_get(self, ctx):
|
||||
from gns3server.agent.mcp.links import get_link_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_link_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn({"link_id": "l1", "link_type": "ethernet"})
|
||||
result = get_link_handler({"project_id": "p1", "link_id": "l1"}, ctx)
|
||||
assert result["link_id"] == "l1"
|
||||
|
||||
def test_create_compact_format(self, ctx):
|
||||
from gns3server.agent.mcp.links import create_link_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"link_id": "l1", "link_type": "ethernet", "nodes": []})
|
||||
m.return_value = conn
|
||||
result = create_link_handler({
|
||||
@ -261,8 +367,8 @@ class TestLink:
|
||||
)
|
||||
|
||||
def test_create_standard_format(self, ctx):
|
||||
from gns3server.agent.mcp.links import create_link_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn({"link_id": "l1"})
|
||||
result = create_link_handler({
|
||||
"project_id": "p1",
|
||||
@ -274,8 +380,8 @@ class TestLink:
|
||||
assert result["link_id"] == "l1"
|
||||
|
||||
def test_create_fields_validation(self, ctx):
|
||||
from gns3server.agent.mcp.links import create_link_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn()
|
||||
m.return_value = conn
|
||||
result = create_link_handler({
|
||||
@ -287,15 +393,41 @@ class TestLink:
|
||||
conn.http_call.assert_not_called()
|
||||
|
||||
def test_delete_batch(self, ctx):
|
||||
from gns3server.agent.mcp.links import delete_link_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import delete_link_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn({})
|
||||
result = delete_link_handler({"project_id": "p1", "link_ids": ["l1", "l2"]}, ctx)
|
||||
assert len(result) == 2
|
||||
# same status vocabulary as create batches
|
||||
assert all(r["status"] == "success" for r in result)
|
||||
|
||||
def test_create_batch_preserves_submission_order(self, ctx):
|
||||
import time
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn()
|
||||
def _http_call(method, url, json_data=None, **kwargs):
|
||||
# first submission sleeps longest so completion order is reversed
|
||||
first_node = json_data["nodes"][0]["node_id"]
|
||||
time.sleep(0.25 if first_node == "n1" else 0.0)
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"link_id": f"link-{first_node}"}
|
||||
return resp
|
||||
conn.http_call.side_effect = _http_call
|
||||
m.return_value = conn
|
||||
result = create_link_handler({
|
||||
"project_id": "p1",
|
||||
"links": [
|
||||
{"nodes": ["n1", 0, 0, "n2", 0, 0]},
|
||||
{"nodes": ["n3", 0, 0, "n4", 0, 0]},
|
||||
],
|
||||
"fields": ["link_id"],
|
||||
}, ctx)
|
||||
assert [r["link"]["link_id"] for r in result] == ["link-n1", "link-n3"]
|
||||
|
||||
def test_update(self, ctx):
|
||||
from gns3server.agent.mcp.links import update_link_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import update_link_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
m.return_value = _mock_conn({"link_id": "l1", "suspend": True})
|
||||
result = update_link_handler({
|
||||
"project_id": "p1", "link_id": "l1", "suspend": True,
|
||||
@ -320,7 +452,7 @@ class TestAppliance:
|
||||
def test_install_with_version(self, ctx):
|
||||
from gns3server.agent.mcp.appliances import install_appliance_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({"status": "installed"})
|
||||
conn = _mock_conn({"template_id": "t1", "name": "FRR", "version": "8.2.2", "template_type": "docker"})
|
||||
m.return_value = conn
|
||||
result = install_appliance_handler({
|
||||
"appliance_id": "a1", "version": "2.7.0.356",
|
||||
@ -329,6 +461,22 @@ class TestAppliance:
|
||||
"post", "http://192.168.1.3:3080/v3/appliances/a1/install",
|
||||
params={"version": "2.7.0.356"},
|
||||
)
|
||||
assert result["template"] == {
|
||||
"template_id": "t1", "name": "FRR", "version": "8.2.2", "template_type": "docker",
|
||||
}
|
||||
|
||||
def test_install_empty_body(self, ctx):
|
||||
# a 204-style empty response must not blow up with a JSON decode error
|
||||
# (the template is still created server-side)
|
||||
from gns3server.agent.mcp.appliances import install_appliance_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn()
|
||||
conn.http_call.return_value.content = b""
|
||||
conn.http_call.return_value.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
|
||||
m.return_value = conn
|
||||
result = install_appliance_handler({"appliance_id": "a1"}, ctx)
|
||||
assert "template" not in result
|
||||
assert result["message"] == "Appliance a1 installed"
|
||||
|
||||
def test_install_missing_id(self, ctx):
|
||||
from gns3server.agent.mcp.appliances import install_appliance_handler
|
||||
@ -375,17 +523,84 @@ class TestTemplate:
|
||||
assert "deleted" in str(result).lower()
|
||||
|
||||
|
||||
# ── Image ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestImage:
|
||||
|
||||
mod = "images"
|
||||
|
||||
def test_install_manifest(self, ctx):
|
||||
from gns3server.agent.mcp.images import install_images_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({
|
||||
"created": [{"template_id": "t1", "name": "Empty VM", "version": "100G", "template_type": "qemu"}],
|
||||
"skipped": [{"name": "csr1000v.qcow2", "reason": "image is already used by one or more templates"}],
|
||||
})
|
||||
m.return_value = conn
|
||||
result = install_images_handler({}, ctx)
|
||||
assert result["created"][0]["name"] == "Empty VM"
|
||||
assert result["skipped"][0]["name"] == "csr1000v.qcow2"
|
||||
|
||||
def test_install_empty_body(self, ctx):
|
||||
from gns3server.agent.mcp.images import install_images_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn()
|
||||
conn.http_call.return_value.content = b""
|
||||
conn.http_call.return_value.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
|
||||
m.return_value = conn
|
||||
result = install_images_handler({}, ctx)
|
||||
assert result == {"message": "Image installation completed"}
|
||||
|
||||
|
||||
class TestCompute:
|
||||
|
||||
mod = "computes"
|
||||
|
||||
def test_get_local_by_default(self, ctx):
|
||||
from gns3server.agent.mcp.computes import get_compute_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({"compute_id": "local", "name": "local"})
|
||||
m.return_value = conn
|
||||
result = get_compute_handler({}, ctx)
|
||||
assert result["compute_id"] == "local"
|
||||
url = conn.http_call.call_args[0][1]
|
||||
assert url.endswith("/computes/local")
|
||||
|
||||
def test_get_explicit_compute_id(self, ctx):
|
||||
from gns3server.agent.mcp.computes import get_compute_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn({"compute_id": "4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6"})
|
||||
m.return_value = conn
|
||||
get_compute_handler({"compute_id": "4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6"}, ctx)
|
||||
url = conn.http_call.call_args[0][1]
|
||||
assert url.endswith("/computes/4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6")
|
||||
|
||||
def test_images_local_by_default(self, ctx):
|
||||
from gns3server.agent.mcp.computes import get_compute_images_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
conn = _mock_conn(["img1.qcow2"])
|
||||
m.return_value = conn
|
||||
result = get_compute_images_handler({"emulator": "qemu"}, ctx)
|
||||
assert result["count"] == 1
|
||||
url = conn.http_call.call_args[0][1]
|
||||
assert url.endswith("/computes/local/qemu/images")
|
||||
|
||||
def test_images_requires_emulator(self, ctx):
|
||||
from gns3server.agent.mcp.computes import get_compute_images_handler
|
||||
assert "error" in get_compute_images_handler({}, ctx)
|
||||
|
||||
|
||||
# ── Marker (traffic-insight) ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLinkMarker:
|
||||
"""link_marker_handler direction tri-state: omit=preserve, tx/rx=set, both=clear (→ null)."""
|
||||
|
||||
mod = "links"
|
||||
|
||||
def test_update_direction_both_clears(self, ctx):
|
||||
from gns3server.agent.mcp.links import link_marker_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "icmp"})
|
||||
m.return_value = conn
|
||||
link_marker_handler(
|
||||
@ -398,8 +613,8 @@ class TestLinkMarker:
|
||||
)
|
||||
|
||||
def test_update_direction_tx_sets(self, ctx):
|
||||
from gns3server.agent.mcp.links import link_marker_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "icmp"})
|
||||
m.return_value = conn
|
||||
link_marker_handler(
|
||||
@ -412,8 +627,8 @@ class TestLinkMarker:
|
||||
)
|
||||
|
||||
def test_update_direction_omitted_preserved(self, ctx):
|
||||
from gns3server.agent.mcp.links import link_marker_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "icmp"})
|
||||
m.return_value = conn
|
||||
link_marker_handler(
|
||||
@ -426,8 +641,8 @@ class TestLinkMarker:
|
||||
)
|
||||
|
||||
def test_create_direction_both_omitted(self, ctx):
|
||||
from gns3server.agent.mcp.links import link_marker_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "icmp"})
|
||||
m.return_value = conn
|
||||
link_marker_handler(
|
||||
@ -439,9 +654,36 @@ class TestLinkMarker:
|
||||
json_data={"bpf": "icmp"},
|
||||
)
|
||||
|
||||
def test_create_data_link_type_passthrough(self, ctx):
|
||||
"""create passes a serial WAN encapsulation through; update ignores it."""
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "icmp"})
|
||||
m.return_value = conn
|
||||
link_marker_handler(
|
||||
{"project_id": "p", "link_id": "l", "action": "create",
|
||||
"bpf": "icmp", "data_link_type": "DLT_C_HDLC"}, ctx,
|
||||
)
|
||||
conn.http_call.assert_called_with(
|
||||
"post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers",
|
||||
json_data={"bpf": "icmp", "data_link_type": "DLT_C_HDLC"},
|
||||
)
|
||||
|
||||
conn = _mock_conn({"name": "icmp"})
|
||||
m.return_value = conn
|
||||
link_marker_handler(
|
||||
{"project_id": "p", "link_id": "l", "action": "update",
|
||||
"marker_name": "icmp", "tag": 1, "data_link_type": "DLT_PPP_SERIAL"}, ctx,
|
||||
)
|
||||
# create-only: dropped from the update body
|
||||
conn.http_call.assert_called_with(
|
||||
"put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp",
|
||||
json_data={"tag": 1},
|
||||
)
|
||||
|
||||
def test_create_direction_tx(self, ctx):
|
||||
from gns3server.agent.mcp.links import link_marker_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "icmp"})
|
||||
m.return_value = conn
|
||||
link_marker_handler(
|
||||
@ -462,11 +704,10 @@ class TestMarkerDefinition:
|
||||
meaning — any direction passed is ignored, never reaching the request body.
|
||||
"""
|
||||
|
||||
mod = "links"
|
||||
|
||||
def test_create_builds_body(self, ctx):
|
||||
from gns3server.agent.mcp.links import marker_definition_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "arp"})
|
||||
m.return_value = conn
|
||||
marker_definition_handler(
|
||||
@ -479,8 +720,8 @@ class TestMarkerDefinition:
|
||||
)
|
||||
|
||||
def test_create_ignores_direction(self, ctx):
|
||||
from gns3server.agent.mcp.links import marker_definition_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "arp"})
|
||||
m.return_value = conn
|
||||
marker_definition_handler(
|
||||
@ -493,8 +734,8 @@ class TestMarkerDefinition:
|
||||
)
|
||||
|
||||
def test_update_builds_body(self, ctx):
|
||||
from gns3server.agent.mcp.links import marker_definition_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "arp"})
|
||||
m.return_value = conn
|
||||
marker_definition_handler(
|
||||
@ -507,8 +748,8 @@ class TestMarkerDefinition:
|
||||
)
|
||||
|
||||
def test_update_ignores_direction(self, ctx):
|
||||
from gns3server.agent.mcp.links import marker_definition_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector") as m:
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
|
||||
with patch(f"{AH}._get_connector") as m:
|
||||
conn = _mock_conn({"name": "arp"})
|
||||
m.return_value = conn
|
||||
marker_definition_handler(
|
||||
@ -521,8 +762,8 @@ class TestMarkerDefinition:
|
||||
)
|
||||
|
||||
def test_update_requires_a_field(self, ctx):
|
||||
from gns3server.agent.mcp.links import marker_definition_handler
|
||||
with patch(f"{BASE}.{self.mod}._get_connector"):
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
|
||||
with patch(f"{AH}._get_connector"):
|
||||
result = marker_definition_handler(
|
||||
{"project_id": "p", "action": "update", "def_name": "arp"}, ctx,
|
||||
)
|
||||
|
||||
@ -15,7 +15,10 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
MCP_DIR = Path(__file__).resolve().parents[3] / "gns3server" / "agent" / "mcp"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
MCP_DIR = REPO_ROOT / "gns3server" / "agent" / "mcp"
|
||||
# Node/link handlers live in the shared REST client layer, not MCP_DIR.
|
||||
API_HANDLERS_FILE = "gns3server/agent/gns3_copilot/gns3_client/api_handlers.py"
|
||||
TOOL_FILE = MCP_DIR / "__init__.py"
|
||||
|
||||
HANDLER_FILES = {
|
||||
@ -33,38 +36,38 @@ HANDLER_FILES = {
|
||||
"lock_project_handler": "projects.py",
|
||||
"unlock_project_handler": "projects.py",
|
||||
"get_locked_project_handler": "projects.py",
|
||||
"load_project_handler": "projects.py",
|
||||
"get_nodes_handler": "nodes.py",
|
||||
"get_node_handler": "nodes.py",
|
||||
"start_node_handler": "nodes.py",
|
||||
"stop_node_handler": "nodes.py",
|
||||
"suspend_node_handler": "nodes.py",
|
||||
"create_node_handler": "nodes.py",
|
||||
"delete_node_handler": "nodes.py",
|
||||
"update_node_handler": "nodes.py",
|
||||
"get_node_console_info_handler": "nodes.py",
|
||||
"list_node_files_handler": "nodes.py",
|
||||
"get_node_file_handler": "nodes.py",
|
||||
"write_node_file_handler": "nodes.py",
|
||||
"delete_node_file_handler": "nodes.py",
|
||||
"start_all_nodes_handler": "nodes.py",
|
||||
"stop_all_nodes_handler": "nodes.py",
|
||||
"suspend_all_nodes_handler": "nodes.py",
|
||||
"duplicate_node_handler": "nodes.py",
|
||||
"isolate_node_handler": "nodes.py",
|
||||
"unisolate_node_handler": "nodes.py",
|
||||
"get_node_links_handler": "nodes.py",
|
||||
"get_links_handler": "links.py",
|
||||
"get_link_handler": "links.py",
|
||||
"create_link_handler": "links.py",
|
||||
"delete_link_handler": "links.py",
|
||||
"update_link_handler": "links.py",
|
||||
"reset_link_handler": "links.py",
|
||||
"start_capture_handler": "links.py",
|
||||
"stop_capture_handler": "links.py",
|
||||
"download_capture_file_handler": "links.py",
|
||||
"link_marker_handler": "links.py",
|
||||
"marker_definition_handler": "links.py",
|
||||
"get_nodes_handler": API_HANDLERS_FILE,
|
||||
"get_node_handler": API_HANDLERS_FILE,
|
||||
"start_node_handler": API_HANDLERS_FILE,
|
||||
"stop_node_handler": API_HANDLERS_FILE,
|
||||
"suspend_node_handler": API_HANDLERS_FILE,
|
||||
"create_node_handler": API_HANDLERS_FILE,
|
||||
"delete_node_handler": API_HANDLERS_FILE,
|
||||
"update_node_handler": API_HANDLERS_FILE,
|
||||
"get_node_console_info_handler": API_HANDLERS_FILE,
|
||||
"list_node_files_handler": API_HANDLERS_FILE,
|
||||
"get_node_file_handler": API_HANDLERS_FILE,
|
||||
"write_node_file_handler": API_HANDLERS_FILE,
|
||||
"delete_node_file_handler": API_HANDLERS_FILE,
|
||||
"start_all_nodes_handler": API_HANDLERS_FILE,
|
||||
"stop_all_nodes_handler": API_HANDLERS_FILE,
|
||||
"suspend_all_nodes_handler": API_HANDLERS_FILE,
|
||||
"duplicate_node_handler": API_HANDLERS_FILE,
|
||||
"isolate_node_handler": API_HANDLERS_FILE,
|
||||
"unisolate_node_handler": API_HANDLERS_FILE,
|
||||
"get_node_links_handler": API_HANDLERS_FILE,
|
||||
"available_filters_handler": API_HANDLERS_FILE,
|
||||
"get_links_handler": API_HANDLERS_FILE,
|
||||
"get_link_handler": API_HANDLERS_FILE,
|
||||
"create_link_handler": API_HANDLERS_FILE,
|
||||
"delete_link_handler": API_HANDLERS_FILE,
|
||||
"update_link_handler": API_HANDLERS_FILE,
|
||||
"reset_link_handler": API_HANDLERS_FILE,
|
||||
"start_capture_handler": API_HANDLERS_FILE,
|
||||
"stop_capture_handler": API_HANDLERS_FILE,
|
||||
"download_capture_file_handler": API_HANDLERS_FILE,
|
||||
"link_marker_handler": API_HANDLERS_FILE,
|
||||
"marker_definition_handler": API_HANDLERS_FILE,
|
||||
"list_templates_handler": "templates.py",
|
||||
"get_template_handler": "templates.py",
|
||||
"create_template_handler": "templates.py",
|
||||
@ -109,7 +112,7 @@ def _get_handler_params(handler_name):
|
||||
filename = HANDLER_FILES.get(handler_name)
|
||||
if not filename:
|
||||
return None
|
||||
filepath = MCP_DIR / filename
|
||||
filepath = (MCP_DIR if not filename.startswith('gns3server/') else REPO_ROOT) / filename
|
||||
if not filepath.exists():
|
||||
return None
|
||||
|
||||
|
||||
@ -16,79 +16,113 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
The vendored gns3fy copy keeps its node/console type lists as literals
|
||||
(it is shared with the standalone MCP service and cannot import server
|
||||
enums). These tests fail when the server enums grow a value the vendored
|
||||
lists have not picked up — exactly what happened with "docker_exec": one
|
||||
vendor node failing validation made the copilot's topology reader drop
|
||||
the whole project.
|
||||
Tests for the shared GNS3 REST client layer (gns3_copilot.gns3_client):
|
||||
|
||||
- project_inventory: the nodes/links aggregation feeding the topology
|
||||
context and the Nornir inventory — its output shape is consumer-visible
|
||||
and must stay field-for-field stable
|
||||
- get_gns3_device_port: netmiko device_type resolution and per-node
|
||||
credentials over the topology inventory
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_console_types_cover_server_enum():
|
||||
# ── project_inventory ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_nodes_inventory_emits_default_credentials():
|
||||
"""
|
||||
Every server ConsoleType value must be accepted by the vendored Node model.
|
||||
The inventory dict consumed by get_device_ports_from_topology must
|
||||
carry the per-node default credentials.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import CONSOLE_TYPES
|
||||
from gns3server.schemas.common import ConsoleType
|
||||
|
||||
missing = {e.value for e in ConsoleType} - set(CONSOLE_TYPES)
|
||||
assert not missing, f"CONSOLE_TYPES drifted from ConsoleType, missing: {missing}"
|
||||
|
||||
|
||||
def test_node_types_cover_server_enum():
|
||||
"""
|
||||
Every server NodeType value must be accepted by the vendored Node model.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import NODE_TYPES
|
||||
from gns3server.schemas.controller.nodes import NodeType
|
||||
|
||||
missing = {e.value for e in NodeType} - set(NODE_TYPES)
|
||||
assert not missing, f"NODE_TYPES drifted from NodeType, missing: {missing}"
|
||||
|
||||
|
||||
def test_node_accepts_docker_exec_console():
|
||||
"""
|
||||
Vendor NOS nodes use console_type "docker_exec"; the topology reader
|
||||
validates the whole node list in one pass, so rejecting it poisoned
|
||||
every copilot device tool for the project.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
|
||||
|
||||
node = Node(
|
||||
name="R1",
|
||||
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
|
||||
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
node_type="docker",
|
||||
console_type="docker_exec",
|
||||
status="started",
|
||||
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
|
||||
build_nodes_inventory,
|
||||
)
|
||||
assert node.console_type == "docker_exec"
|
||||
|
||||
nodes = [
|
||||
{
|
||||
"name": "R1",
|
||||
"node_id": "0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
"node_type": "dynamips",
|
||||
"console": 5000,
|
||||
"console_type": "telnet",
|
||||
"status": "started",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"default_username": "admin",
|
||||
"default_password": "admin123",
|
||||
},
|
||||
]
|
||||
|
||||
inventory = build_nodes_inventory(nodes, "127.0.0.1")
|
||||
assert inventory["R1"]["default_username"] == "admin"
|
||||
assert inventory["R1"]["default_password"] == "admin123"
|
||||
assert inventory["R1"]["console_port"] == 5000
|
||||
assert inventory["R1"]["type"] == "dynamips"
|
||||
assert inventory["R1"]["server"] == "127.0.0.1"
|
||||
assert inventory["R1"]["tags"] == []
|
||||
|
||||
|
||||
def test_node_accepts_netmiko_device_type():
|
||||
def test_links_summary_resolves_names_and_ports():
|
||||
"""
|
||||
The vendored Node model must keep the netmiko_device_type field so the
|
||||
device-port tools can prefer it over the device_type:<type> tag.
|
||||
links_summary maps raw link endpoint lists to
|
||||
{link_id, node_a, port_a, node_b, port_b} using port/adapter numbers.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
|
||||
|
||||
node = Node(
|
||||
name="SR1",
|
||||
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
|
||||
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
node_type="docker",
|
||||
console_type="docker_exec",
|
||||
status="started",
|
||||
netmiko_device_type="nokia_srl",
|
||||
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
|
||||
build_links_summary,
|
||||
)
|
||||
assert node.netmiko_device_type == "nokia_srl"
|
||||
|
||||
nodes = [
|
||||
{
|
||||
"name": "R1",
|
||||
"node_id": "n1",
|
||||
"ports": [
|
||||
{"name": "GigabitEthernet0/0", "port_number": 0, "adapter_number": 0},
|
||||
{"name": "GigabitEthernet0/1", "port_number": 1, "adapter_number": 0},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "R2",
|
||||
"node_id": "n2",
|
||||
"ports": [
|
||||
{"name": "Ethernet0", "port_number": 0, "adapter_number": 0},
|
||||
],
|
||||
},
|
||||
]
|
||||
links = [
|
||||
{
|
||||
"link_id": "l1",
|
||||
"nodes": [
|
||||
{"node_id": "n1", "port_number": 0, "adapter_number": 0},
|
||||
{"node_id": "n2", "port_number": 0, "adapter_number": 0},
|
||||
],
|
||||
},
|
||||
# endpoint not resolvable → skipped, not an error
|
||||
{
|
||||
"link_id": "l2",
|
||||
"nodes": [
|
||||
{"node_id": "missing", "port_number": 0, "adapter_number": 0},
|
||||
{"node_id": "n2", "port_number": 0, "adapter_number": 0},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
summary = build_links_summary(nodes, links)
|
||||
assert summary == [
|
||||
{
|
||||
"link_id": "l1",
|
||||
"node_a": "R1",
|
||||
"port_a": "GigabitEthernet0/0",
|
||||
"node_b": "R2",
|
||||
"port_b": "Ethernet0",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ── get_gns3_device_port (over the topology inventory) ──────────────────
|
||||
|
||||
|
||||
def test_device_ports_prefer_netmiko_field_over_tag(monkeypatch):
|
||||
@ -152,58 +186,6 @@ def test_device_ports_error_without_any_device_type(monkeypatch):
|
||||
assert "netmiko_device_type" in hosts["R2"]["error"]
|
||||
|
||||
|
||||
def test_node_accepts_default_credentials():
|
||||
"""
|
||||
The vendored Node model must keep the default credentials so the
|
||||
device-port tools can log into devices that require authentication.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
|
||||
|
||||
node = Node(
|
||||
name="R1",
|
||||
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
|
||||
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
node_type="docker",
|
||||
console_type="telnet",
|
||||
status="started",
|
||||
default_username="admin",
|
||||
default_password="admin123",
|
||||
)
|
||||
assert node.default_username == "admin"
|
||||
assert node.default_password == "admin123"
|
||||
|
||||
|
||||
def test_nodes_inventory_emits_default_credentials():
|
||||
"""
|
||||
The inventory dict consumed by get_device_ports_from_topology must
|
||||
carry the per-node default credentials.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from types import SimpleNamespace
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node, Project
|
||||
|
||||
project = Project(
|
||||
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
|
||||
connector=SimpleNamespace(base_url="http://127.0.0.1:3080"),
|
||||
)
|
||||
project.nodes = [
|
||||
Node(
|
||||
name="R1",
|
||||
project_id=project.project_id,
|
||||
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
node_type="dynamips",
|
||||
console=5000,
|
||||
default_username="admin",
|
||||
default_password="admin123",
|
||||
),
|
||||
]
|
||||
|
||||
inventory = project.nodes_inventory()
|
||||
assert inventory["R1"]["default_username"] == "admin"
|
||||
assert inventory["R1"]["default_password"] == "admin123"
|
||||
|
||||
|
||||
def test_device_ports_inject_default_credentials(monkeypatch):
|
||||
"""
|
||||
Per-node default credentials become host-level nornir values (which
|
||||
@ -53,7 +53,8 @@ class TestApplianceRoutes:
|
||||
|
||||
appliance_id = "fc520ae2-a4e5-48c3-9a13-516bb2e94668" # Alpine Linux appliance
|
||||
response = await client.post(app.url_path_for("install_appliance", appliance_id=appliance_id))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["name"] == "Alpine Linux"
|
||||
|
||||
async def test_docker_appliance_install_with_version(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
@ -68,7 +69,9 @@ class TestApplianceRoutes:
|
||||
appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance
|
||||
params = {"version": "8G"}
|
||||
response = await client.post(app.url_path_for("install_appliance", appliance_id=appliance_id), params=params)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["name"] == "Empty VM"
|
||||
assert response.json()["version"] == "8G"
|
||||
|
||||
async def test_qemu_appliance_install_without_version(self, app: FastAPI, client: AsyncClient, images_dir: str) -> None:
|
||||
|
||||
|
||||
@ -79,6 +79,16 @@ class TestComputeRoutes:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["compute_id"] == str(test_compute.compute_id)
|
||||
|
||||
async def test_compute_get_local(self, app: FastAPI, client: AsyncClient, controller) -> None:
|
||||
|
||||
await controller.add_compute(
|
||||
compute_id="local", name="local", host="127.0.0.1", port=3080, force=True, connect=False)
|
||||
|
||||
response = await client.get(app.url_path_for("get_compute", compute_id="local"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["compute_id"] == "local"
|
||||
assert response.json()["name"] == "local"
|
||||
|
||||
async def test_compute_update(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
|
||||
|
||||
params = {
|
||||
|
||||
@ -328,10 +328,54 @@ class TestImageRoutes:
|
||||
with asyncio_patch("gns3server.api.routes.controller.images.get_builtin_disks", return_value=[]) as mock:
|
||||
response = await client.post(app.url_path_for("install_images"))
|
||||
assert mock.called
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
created = response.json()["created"]
|
||||
assert len(created) == 1
|
||||
assert created[0]["name"] == "Empty VM"
|
||||
assert created[0]["version"] == "100G"
|
||||
|
||||
templates_repo = TemplatesRepository(db_session)
|
||||
templates = await templates_repo.get_templates()
|
||||
assert len(templates) == 1
|
||||
assert templates[0].name == "Empty VM"
|
||||
assert templates[0].version == "100G"
|
||||
assert templates[0].version == "100G"
|
||||
await templates_repo.delete_template(templates[0].template_id)
|
||||
|
||||
async def test_install_all_skips_existing_template_name(
|
||||
self, app: FastAPI,
|
||||
client: AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
controller: Controller
|
||||
) -> None:
|
||||
# two images matching two versions of the same appliance must not
|
||||
# produce two templates with the same name
|
||||
#
|
||||
# earlier tests in this class uploaded the same filenames from different
|
||||
# (function-scoped) images directories; drop those stale rows so the
|
||||
# install route only sees this test's uploads
|
||||
images_repo = ImagesRepository(db_session)
|
||||
for image_name in ("empty30G.qcow2", "empty100G.qcow2"):
|
||||
await images_repo.delete_image(image_name)
|
||||
for image_path in ("tests/resources/empty30G.qcow2", "tests/resources/empty100G.qcow2"):
|
||||
with open(image_path, "rb") as f:
|
||||
image_data = f.read()
|
||||
response = await client.post(
|
||||
app.url_path_for("upload_image", image_path=os.path.basename(image_path)),
|
||||
content=image_data)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
controller.appliance_manager.load_appliances() # make sure appliances are loaded
|
||||
with asyncio_patch("gns3server.api.routes.controller.images.get_builtin_disks", return_value=[]):
|
||||
response = await client.post(app.url_path_for("install_images"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
manifest = response.json()
|
||||
assert len(manifest["created"]) == 1
|
||||
assert manifest["created"][0]["name"] == "Empty VM"
|
||||
assert any("already exists" in skipped["reason"] for skipped in manifest["skipped"])
|
||||
|
||||
templates_repo = TemplatesRepository(db_session)
|
||||
templates = await templates_repo.get_templates()
|
||||
assert len(templates) == 1
|
||||
assert templates[0].name == "Empty VM"
|
||||
await templates_repo.delete_template(templates[0].template_id)
|
||||
@ -18,7 +18,7 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from httpx import AsyncClient
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
@ -295,10 +295,44 @@ class TestNodeRoutes:
|
||||
compute: Compute,
|
||||
node: Node
|
||||
) -> None:
|
||||
|
||||
|
||||
compute.post = AsyncioMagicMock()
|
||||
response = await client.post(app.url_path_for("suspend_node", project_id=project.id, node_id=node.id))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
async def test_suspend_node_unsupported(
|
||||
self,
|
||||
app: FastAPI,
|
||||
client: AsyncClient,
|
||||
project: Project,
|
||||
compute: Compute,
|
||||
node: Node
|
||||
) -> None:
|
||||
|
||||
# node types without suspend support (e.g. VPCS, IOU) must surface the
|
||||
# compute 405 instead of reporting a fake success
|
||||
compute.post = AsyncioMagicMock(
|
||||
side_effect=HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported")
|
||||
)
|
||||
response = await client.post(app.url_path_for("suspend_node", project_id=project.id, node_id=node.id))
|
||||
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
|
||||
|
||||
async def test_suspend_all_nodes_tolerates_unsupported(
|
||||
self,
|
||||
app: FastAPI,
|
||||
client: AsyncClient,
|
||||
project: Project,
|
||||
compute: Compute,
|
||||
node: Node
|
||||
) -> None:
|
||||
|
||||
# suspending all nodes of a mixed project stays best-effort: nodes
|
||||
# without suspend support are skipped without failing the request
|
||||
compute.post = AsyncioMagicMock(
|
||||
side_effect=HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported")
|
||||
)
|
||||
response = await client.post(app.url_path_for("suspend_all_nodes", project_id=project.id))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
|
||||
async def test_reload_node(
|
||||
|
||||
@ -55,6 +55,11 @@ class TestControllerProjectRoutes:
|
||||
|
||||
params = {"name": "test", "path": str(config.settings.Server.projects_path), "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"}
|
||||
response = await client.post(app.url_path_for("create_project"), json=params)
|
||||
# The projects directory itself must never become a project directory
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
params = {"name": "test", "path": os.path.join(str(config.settings.Server.projects_path), "custom"), "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"}
|
||||
response = await client.post(app.url_path_for("create_project"), json=params)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["name"] == "test"
|
||||
assert response.json()["project_id"] == "00010203-0405-0607-0809-0a0b0c0d0e0f"
|
||||
@ -608,3 +613,29 @@ class TestControllerProjectRoutes:
|
||||
assert drawing.locked is False
|
||||
for node in project.nodes.values():
|
||||
assert node.locked is False
|
||||
|
||||
response = await client.get(app.url_path_for("locked_project", project_id=project.id))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() is False
|
||||
|
||||
async def test_lock_unlock_empty_project(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
# a project without drawings or nodes has nothing to lock and must
|
||||
# never report as locked, otherwise it could not be unlocked
|
||||
response = await client.get(app.url_path_for("locked_project", project_id=project.id))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() is False
|
||||
|
||||
response = await client.post(app.url_path_for("lock_project", project_id=project.id))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
response = await client.get(app.url_path_for("locked_project", project_id=project.id))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() is False
|
||||
|
||||
response = await client.post(app.url_path_for("unlock_project", project_id=project.id))
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
response = await client.get(app.url_path_for("locked_project", project_id=project.id))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() is False
|
||||
|
||||
@ -125,6 +125,54 @@ async def test_load_projects_skip_unexpected_errors(controller, projects_dir):
|
||||
mock_load_project.assert_called_with(os.path.join(projects_dir, "broken_project", "broken.gns3"), load=False)
|
||||
|
||||
|
||||
def _write_topology_file(path, project_id, name):
|
||||
with open(path, "w+") as f:
|
||||
json.dump(
|
||||
{
|
||||
"name": name,
|
||||
"project_id": project_id,
|
||||
"version": __version__,
|
||||
"revision": 10,
|
||||
"type": "topology",
|
||||
"topology": {"computes": [], "drawings": [], "links": [], "nodes": []},
|
||||
},
|
||||
f,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_project_refuses_gns3_in_projects_directory(controller, projects_dir):
|
||||
"""
|
||||
A .gns3 placed directly in the projects directory must not be
|
||||
loadable: its parent directory (the shared projects root) would become
|
||||
the project directory, and deleting that project would wipe every
|
||||
project on the controller.
|
||||
"""
|
||||
|
||||
topology_file = os.path.join(projects_dir, "root-level.gns3")
|
||||
_write_topology_file(topology_file, str(uuid.uuid4()), "root-level")
|
||||
|
||||
with pytest.raises(ControllerError):
|
||||
await controller.load_project(topology_file)
|
||||
assert not controller._projects
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_project_from_own_subdirectory(controller, projects_dir):
|
||||
"""
|
||||
The normal layout — a .gns3 inside its own subdirectory — keeps
|
||||
loading, with the subdirectory as the project directory.
|
||||
"""
|
||||
|
||||
project_dir = os.path.join(projects_dir, "sub-project")
|
||||
os.makedirs(project_dir)
|
||||
topology_file = os.path.join(project_dir, "sub-project.gns3")
|
||||
_write_topology_file(topology_file, str(uuid.uuid4()), "sub-project")
|
||||
|
||||
project = await controller.load_project(topology_file, load=False)
|
||||
assert project.path == project_dir
|
||||
|
||||
|
||||
def test_projects_directory_event_handler_filters_events(controller):
|
||||
|
||||
controller._notify_projects_directory_event = MagicMock()
|
||||
|
||||
@ -87,7 +87,8 @@ async def test_import_project_override(projects_dir, controller):
|
||||
override the previous keeping the same project id & location
|
||||
"""
|
||||
|
||||
tmpdir = Path(projects_dir)
|
||||
tmpdir = Path(projects_dir) / "override-location"
|
||||
tmpdir.mkdir(parents=True, exist_ok=True)
|
||||
project_id = str(uuid.uuid4())
|
||||
topology = {
|
||||
"project_id": project_id,
|
||||
|
||||
@ -741,6 +741,37 @@ async def test_delete(project):
|
||||
assert not os.path.exists(project.path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_refuses_to_delete_projects_directory(project, projects_dir):
|
||||
"""
|
||||
A poisoned entry whose path is the projects directory itself (a .gns3
|
||||
loaded directly from the projects root before the guard existed) must
|
||||
not be deletable: rmtree would wipe every project on the controller.
|
||||
"""
|
||||
|
||||
other_project = os.path.join(projects_dir, "another-project")
|
||||
os.makedirs(other_project, exist_ok=True)
|
||||
|
||||
# Simulate the poisoned in-memory state directly: the path setter now
|
||||
# rejects such an assignment, but a long-running server can still hold
|
||||
# an entry created before the fix.
|
||||
project._path = projects_dir
|
||||
|
||||
with pytest.raises(ControllerError):
|
||||
await project.delete()
|
||||
assert os.path.exists(other_project)
|
||||
|
||||
|
||||
def test_path_setter_rejects_projects_directory(project, projects_dir):
|
||||
"""
|
||||
The projects directory itself must never become a project directory.
|
||||
"""
|
||||
|
||||
with pytest.raises(ControllerForbiddenError):
|
||||
project.path = projects_dir
|
||||
assert project.path == os.path.join(projects_dir, project.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_does_not_start_nodes(project):
|
||||
"""
|
||||
|
||||
@ -192,12 +192,14 @@ async def test_open(controller, projects_dir):
|
||||
"version": "2.0.0"
|
||||
}
|
||||
|
||||
with open(os.path.join(projects_dir, "demo.gns3"), "w+") as f:
|
||||
project_dir = os.path.join(projects_dir, "demo")
|
||||
os.makedirs(project_dir)
|
||||
with open(os.path.join(project_dir, "demo.gns3"), "w+") as f:
|
||||
json.dump(simple_topology, f)
|
||||
|
||||
project = Project(name="demo",
|
||||
project_id="64ba8408-afbf-4b66-9cdd-1fd854427478",
|
||||
path=str(projects_dir),
|
||||
path=project_dir,
|
||||
controller=controller,
|
||||
filename="demo.gns3",
|
||||
status="closed")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user