diff --git a/.claude/memory/mcp-service-design.md b/.claude/memory/mcp-service-design.md index d6bbcc9de..6c3d80ff9 100644 --- a/.claude/memory/mcp-service-design.md +++ b/.claude/memory/mcp-service-design.md @@ -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 diff --git a/docs/features/mcp-service.md b/docs/features/mcp-service.md index bfcb43271..3dbfdbbd4 100644 --- a/docs/features/mcp-service.md +++ b/docs/features/mcp-service.md @@ -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) | + ### 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__`) 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 diff --git a/docs/features/project-open-performance.md b/docs/features/project-open-performance.md index 3abd49a60..38cd4334c 100644 --- a/docs/features/project-open-performance.md +++ b/docs/features/project-open-performance.md @@ -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 | diff --git a/docs/gns3-copilot/implemented/node-control-tools.md b/docs/gns3-copilot/implemented/node-control-tools.md index 6d7d105f0..b09083f08 100644 --- a/docs/gns3-copilot/implemented/node-control-tools.md +++ b/docs/gns3-copilot/implemented/node-control-tools.md @@ -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 diff --git a/gns3server/agent/gns3_copilot/gns3_client/__init__.py b/gns3server/agent/gns3_copilot/gns3_client/__init__.py index 57b618f61..100ed500c 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/__init__.py +++ b/gns3server/agent/gns3_copilot/gns3_client/__init__.py @@ -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", diff --git a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py new file mode 100644 index 000000000..2dcec5afa --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py @@ -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 . +# +# 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} diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector.py b/gns3server/agent/gns3_copilot/gns3_client/connector.py new file mode 100644 index 000000000..bd5fbcf9c --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/connector.py @@ -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 . +# +# 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://
:3080", user="admin", cred="password", + ... api_version=2 + ... ) + >>> # API v3 with username/password (auto-fetches JWT token) + >>> server = Gns3Connector( + ... url="http://
:3080", user="admin", cred="password", + ... api_version=3 + ... ) + >>> # API v3 with direct JWT token + >>> server = Gns3Connector( + ... url="http://
: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 diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py index e878ae911..724291782 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py +++ b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py @@ -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, ) diff --git a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py deleted file mode 100644 index 170e8757b..000000000 --- a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py +++ /dev/null @@ -1,3341 +0,0 @@ -# SPDX-License-Identifier: GPL-3.0-or-later -# -# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 -# -# This file is part of GNS3-Copilot project. -# -# GNS3-Copilot is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. -# -# GNS3-Copilot is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY -# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License -# for more details. -# -# You should have received a copy of the GNU General Public License -# along with GNS3-Copilot. If not, see . -# -# Copyright (C) 2025 Yue Guobin (岳国宾) -# Author: Yue Guobin (岳国宾) -# -# Project Home: https://github.com/yueguobin/gns3-copilot -# - -""" -Adapted gns3fy module for GNS3-Copilot - -This module is based on the upstream gns3fy project -(https://github.com/davidban77/gns3fy). - -⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service. -The Gns3Connector class is used by MCP handlers to make HTTP calls. -Modifications to this file must be tested with BOTH gns3-copilot AND MCP. - -Modifications made for GNS3-Copilot: -- Adjusted pydantic usages and dataclass configuration to reduce dependency - conflicts with langchain (pydantic version/api differences) -- Kept the original API surface where possible but simplified - validators/config -- Added JWT token authentication support -- Integrated with context-aware connector factory - -Note: This file is adapted from upstream gns3fy for compatibility with -GNS3-Copilot's architecture. - -Upstream: https://github.com/davidban77/gns3fy -""" - -import os -import time -from collections.abc import Callable -from dataclasses import field -from functools import wraps -from math import cos -from math import pi -from math import sin -from typing import Any -from typing import ParamSpec -from typing import TypeVar -from typing import cast -from urllib.parse import urlparse, quote - -import jwt -import requests -import urllib3 -from pydantic import ConfigDict -from pydantic import field_validator -from pydantic.dataclasses import dataclass -from requests import HTTPError - -P = ParamSpec("P") -R = TypeVar("R") -F = TypeVar("F", bound=Callable[..., Any]) - -config = ConfigDict(validate_assignment=True, extra="ignore") - - -NODE_TYPES = [ - "cloud", - "nat", - "ethernet_hub", - "ethernet_switch", - "frame_relay_switch", - "atm_switch", - "docker", - "dynamips", - "vpcs", - "traceng", - "virtualbox", - "vmware", - "iou", - "qemu", -] - -# Keep in sync with gns3server.schemas.common.ConsoleType. The values are -# duplicated as literals because this module is shared with the standalone -# MCP service and cannot import the enum. "null" is gns3fy legacy. -CONSOLE_TYPES = [ - "vnc", - "telnet", - "ssh", - "http", - "https", - "spice", - "spice+agent", - "none", - "docker_exec", - "null", -] - -LINK_TYPES = ["ethernet", "serial"] - - -class Gns3Connector: - """ - Connector to be use for interaction against 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://
:3080", user="admin", cred="password", - ... api_version=2 - ... ) - >>> # API v3 with username/password (auto-fetches JWT token) - >>> server = Gns3Connector( - ... url="http://
:3080", user="admin", cred="password", - ... api_version=3 - ... ) - >>> # API v3 with direct JWT token - >>> server = Gns3Connector( - ... url="http://
:3080", - ... jwt_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - ... api_version=3 - ... ) - >>> print(server.get_version()) - {'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}" - ) - # print(f"Successfully authenticated to v3 API, token obtained") - 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 - - def get_version(self) -> dict[str, Any]: - """ - Returns the version information of GNS3 server - """ - response = self.http_call("get", url=f"{self.base_url}/version") - return cast(dict[str, Any], response.json()) - - def projects_summary( - self, is_print: bool = True - ) -> list[tuple[str, str, int, int, str]] | None: - """ - Returns a summary of the projects in the server. If `is_print` is `False`, it - will return a list of tuples like: - - `[(name, project_id, total_nodes, total_links, status) ...]` - """ - _projects_summary = [] - for _p in self.get_projects(): - # Retrieve the project stats - _stats = self.http_call( - "get", f"{self.base_url}/projects/{_p['project_id']}/stats" - ).json() - if is_print: - print( - f"{_p['name']}: {_p['project_id']} -- Nodes: {_stats['nodes']} -- " - f"Links: {_stats['links']} -- Status: {_p['status']}" - ) - _projects_summary.append( - ( - _p["name"], - _p["project_id"], - _stats["nodes"], - _stats["links"], - _p["status"], - ) - ) - - return _projects_summary if not is_print else None - - def get_projects(self) -> list[dict[str, Any]]: - """ - Returns the list of the projects on the server - """ - response = self.http_call( - "get", url=f"{self.base_url}/projects" - ).json() - return cast(list[dict[str, Any]], response) - - def get_project( - self, name: str | None = None, project_id: str | None = None - ) -> dict[str, Any] | None: - """ - Retrieves a project from either a name or ID - - **Required Attributes:** - - - `name` or `project_id` - """ - if project_id: - _response = self.http_call( - "get", url=f"{self.base_url}/projects/{project_id}" - ) - return cast(dict[str, Any], _response.json()) - elif name: - try: - return next( - p for p in self.get_projects() if p["name"] == name - ) - except StopIteration: - # Project not found - return None - else: - raise ValueError("Must provide either a name or project_id") - - def templates_summary( - self, is_print: bool = True - ) -> list[tuple[str, str, str, bool, str, str]] | None: - """ - Returns a summary of the templates in the server. If `is_print` is `False`, it - will return a list of tuples like: - - `[(name, template_id, template_type, builtin, console_type, category) ...]` - """ - _templates_summary = [] - for _t in self.get_templates(): - if "console_type" not in _t: - _t["console_type"] = "N/A" - if is_print: - print( - f"{_t['name']}: {_t['template_id']} -- Type: {_t['template_type']}" - f" -- Builtin: {_t['builtin']} -- Console: {_t['console_type']} -- " - f"Category: {_t['category']}" - ) - _templates_summary.append( - ( - _t["name"], - _t["template_id"], - _t["template_type"], - _t["builtin"], - _t["console_type"], - _t["category"], - ) - ) - - return _templates_summary if not is_print else None - - def get_templates(self) -> list[dict[str, Any]]: - """ - Returns the templates defined on the server. - """ - _response_data = self.http_call( - "get", url=f"{self.base_url}/templates" - ).json() - return cast(list[dict[str, Any]], _response_data) - - def get_template( - self, name: str | None = None, template_id: str | None = None - ) -> dict[str, Any] | None: - """ - Retrieves a template from either a name or ID - - **Required Attributes:** - - - `name` or `template_id` - """ - if template_id: - _response_json = self.http_call( - "get", url=f"{self.base_url}/templates/{template_id}" - ).json() - return cast(dict[str, Any], _response_json) - elif name: - try: - return next( - t for t in self.get_templates() if t["name"] == name - ) - except StopIteration: - # Template name not found - return None - else: - raise ValueError("Must provide either a name or template_id") - - def update_template( - self, - name: str | None = None, - template_id: str | None = None, - **kwargs: Any, - ) -> dict[str, Any]: - """ - Updates a template by giving its name or UUID. For more information [API INFO] - (http://api.gns3.net/en/2.2/api/v2/controller/template/ - templatestemplateid.html#put-v2-templates-template-id) - - **Required Attributes:** - - - `name` or `template_id` - - **Optional Attributes (can be passed via kwargs):** - - - `tags` (list): List of tags for the template (e.g., - ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) - - `netmiko_device_type` (str): Netmiko device type (e.g. "cisco_ios_telnet"); - preferred over the device_type: tag - - Any other template attributes supported by GNS3 API - """ - # Get existing template - _template = self.get_template(name=name, template_id=template_id) - # Type check: handle case where get_template might return None - if _template is None: - raise ValueError( - f"Template not found (name={name}, id={template_id})" - ) - # Update local dictionary and send request - _template.update(**kwargs) - - response = self.http_call( - "put", - url=f"{self.base_url}/templates/{_template['template_id']}", - json_data=_template, - ) - # Return JSON and handle Any type errors - return cast(dict[str, Any], response.json()) - - def create_template(self, **kwargs: Any) -> dict[str, Any]: - """ - Creates a template by giving its attributes. For more information [API INFO] - (http://api.gns3.net/en/2.2/api/v2/controller/template/ - templates.html#post-v2-templates) - - **Required Attributes:** - - - `name` - - `compute_id` by default is 'local' - - `template_type` - - **Optional Attributes (can be passed via kwargs):** - - - `tags` (list): List of tags for the template (e.g., - ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) - - `netmiko_device_type` (str): Netmiko device type (e.g. "cisco_ios_telnet"); - preferred over the device_type: tag - - Any other template attributes supported by GNS3 API - - **Example:** - - ```python - >>> connector.create_template( - ... name="cisco_router", - ... template_type="dynamips", - ... tags=["device_type:cisco_ios_telnet", "platform:cisco_ios"], - ... netmiko_device_type="cisco_ios_telnet" - ... ) - ``` - """ - # kwargs["name"] might raise KeyError at runtime, for more robust - # code we can use get first - template_name = kwargs.get("name") - if not template_name: - raise ValueError( - "Attribute 'name' is required to create a template" - ) - - # Check if template already exists - _template = self.get_template(name=kwargs["name"]) - if _template: - raise ValueError(f"Template already used: {kwargs['name']}") - - # Set default values - if "compute_id" not in kwargs: - kwargs["compute_id"] = "local" - - # Send request - response = self.http_call( - "post", url=f"{self.base_url}/templates", json_data=kwargs - ) - # Return and convert type - return cast(dict[str, Any], response.json()) - - def delete_template( - self, name: str | None = None, template_id: str | None = None - ) -> None: - """ - Deletes a template by giving its attributes. For more information [API INFO] - (http://api.gns3.net/en/2.2/api/v2/controller/template/ - templatestemplateid.html#id16) - - **Required Attributes:** - - - `name` or `template_id` - """ - # Logic handling: if only name is given, need to first get template_id - if name and not template_id: - _template = self.get_template(name=name) - # Type narrowing: check if _template is None - if _template is None: - raise ValueError(f"Template with name '{name}' not found.") - - template_id = _template["template_id"] - - # Final check: ensure template_id has a value at this point - if not template_id: - raise ValueError( - "Must provide either a 'name' or 'template_id' to delete a template." - ) - - self.http_call( - "delete", url=f"{self.base_url}/templates/{template_id}" - ) - - def get_nodes(self, project_id: str) -> list[dict[str, Any]]: - """ - Retieves the nodes defined on the project - - **Required Attributes:** - - - `project_id` - """ - _response_data = self.http_call( - "get", url=f"{self.base_url}/projects/{project_id}/nodes" - ).json() - - return cast(list[dict[str, Any]], _response_data) - - def get_node(self, project_id: str, node_id: str) -> dict[str, Any]: - """ - Returns the node by locating its ID. - - **Required Attributes:** - - - `project_id` - - `node_id` - """ - _url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}" - _response_data = self.http_call("get", _url).json() - return cast(dict[str, Any], _response_data) - - def get_links(self, project_id: str) -> list[dict[str, Any]]: - """ - Retrieves the links defined in the project. - - **Required Attributes:** - - - `project_id` - """ - _response_data = self.http_call( - "get", url=f"{self.base_url}/projects/{project_id}/links" - ).json() - - return cast(list[dict[str, Any]], _response_data) - - def get_link(self, project_id: str, link_id: str) -> dict[str, Any]: - """ - Returns the link by locating its ID. - - **Required Attributes:** - - - `project_id` - - `link_id` - """ - _url = f"{self.base_url}/projects/{project_id}/links/{link_id}" - _response_data = self.http_call("get", _url).json() - - return cast(dict[str, Any], _response_data) - - def create_project(self, **kwargs: Any) -> dict[str, Any]: - """ - Pass a dictionary type object with the project parameters to be created. - - **Required Attributes:** - - - `name` - - **Returns** - - JSON project information - """ - _url = f"{self.base_url}/projects" - if "name" not in kwargs: - raise ValueError("Parameter 'name' is mandatory") - _response = self.http_call("post", _url, json_data=kwargs) - - return cast(dict[str, Any], _response.json()) - - def delete_project(self, project_id: str) -> None: - """ - Deletes a project from server. - - **Required Attributes:** - - - `project_id` - """ - _url = f"{self.base_url}/projects/{project_id}" - self.http_call("delete", _url) - return None - - def update_project(self, project_id: str, **kwargs: Any) -> dict[str, Any]: - """ - Update a project's properties. - - **Required Attributes:** - - - `project_id` - - **Optional Attributes:** - - - `name`, `auto_close`, `auto_open`, `auto_start` - - `scene_height`, `scene_width`, `zoom` - - `show_layers`, `snap_to_grid`, `show_grid`, `grid_size`, `drawing_grid_size` - - `show_interface_labels`, `supplier`, `variables` - - **Returns** - - JSON project information - """ - _url = f"{self.base_url}/projects/{project_id}" - _response = self.http_call("put", _url, json_data=kwargs) - return cast(dict[str, Any], _response.json()) - - def duplicate_project(self, project_id: str, **kwargs: Any) -> dict[str, Any]: - """ - Duplicate a project from a given project_id. - - **Required Attributes:** - - - `project_id` - - `name` (in kwargs) - - **Returns** - - JSON project information - """ - _url = f"{self.base_url}/projects/{project_id}/duplicate" - if "name" not in kwargs: - raise ValueError("Parameter 'name' is mandatory") - _response = self.http_call("post", _url, json_data=kwargs) - return cast(dict[str, Any], _response.json()) - - def get_project_file(self, project_id: str, file_path: str) -> str: - """ - Get the content of a file in a project. - - **Required Attributes:** - - - `project_id` - - `file_path` - - **Returns** - - File content as text string - """ - encoded_path = quote(file_path, safe="/") - _url = f"{self.base_url}/projects/{project_id}/files/{encoded_path}" - _response = self.http_call("get", _url) - return _response.text - - def write_project_file(self, project_id: str, file_path: str, content: str) -> None: - """ - Write content to a file in a project. Creates the file if it doesn't exist. - - **Required Attributes:** - - - `project_id` - - `file_path` - - `content` - """ - encoded_path = quote(file_path, safe="/") - _url = f"{self.base_url}/projects/{project_id}/files/{encoded_path}" - self.http_call("post", _url, data=content, headers={"Content-Type": "text/plain"}) - - def get_computes(self) -> list[dict[str, Any]]: - """ - Returns a list of computes. - - **Returns:** - - List of dictionaries of the computes attributes like cpu/memory usage - """ - _url = f"{self.base_url}/computes" - _response_data = self.http_call("get", _url).json() - - return cast(list[dict[str, Any]], _response_data) - - def get_compute(self, compute_id: str = "local") -> dict[str, Any]: - """ - Returns a compute. - - **Returns:** - - Dictionary of the compute attributes like cpu/memory usage - """ - _url = f"{self.base_url}/computes/{compute_id}" - _response_data = self.http_call("get", _url).json() - - return cast(dict[str, Any], _response_data) - - def get_compute_images( - self, emulator: str, compute_id: str = "local" - ) -> list[dict[str, Any]]: - """ - Returns a list of images available for a compute. - - **Required Attributes:** - - - `emulator`: the likes of 'qemu', 'iou', 'docker' ... - - `compute_id` By default is 'local' - - **Returns:** - - List of dictionaries with images available for the compute for the specified - emulator - """ - _url = f"{self.base_url}/computes/{compute_id}/{emulator}/images" - _response_data = self.http_call("get", _url).json() - - return cast(list[dict[str, Any]], _response_data) - - def upload_compute_image( - self, emulator: str, file_path: str, compute_id: str = "local" - ) -> None: - """ - uploads an image for use by a compute. - - **Required Attributes:** - - - `emulator`: the likes of 'qemu', 'iou', 'docker' ... - - `file_path`: path of file to be uploaded - - `compute_id` By default is 'local' - """ - if not os.path.exists(file_path): - raise FileNotFoundError(f"Could not find file: {file_path}") - - _filename = os.path.basename(file_path) - _url = f"{self.base_url}/computes/{compute_id}/{emulator}/images/{_filename}" - with open(file_path, "rb") as f: - self.http_call("post", _url, data=f) - - return None - - def get_compute_ports(self, compute_id: str = "local") -> dict[str, Any]: - """ - Returns ports used and configured by a compute. - - **Required Attributes:** - - - `compute_id` By default is 'local' - - **Returns:** - - Dictionary of `console_ports` used and range, as well as the `udp_ports` - """ - _url = f"{self.base_url}/computes/{compute_id}/ports" - _response_data = self.http_call("get", _url).json() - - return cast(dict[str, Any], _response_data) - - -def verify_connector_and_id(f: F) -> F: - """ - Main checker for connector object and respective object's ID for their retrieval - or actions methods. - """ - - @wraps(f) - def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: - _conn = self.connector - _project_id = self.project_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - - # Checks for Node - if self.__class__.__name__ == "Node": - if not self.node_id: - if not self.name: - raise ValueError("Need to either submit node_id or name") - - # Try to retrieve the node_id - _url = f"{_conn.base_url}/projects/{_project_id}/nodes" - _response = _conn.http_call("get", _url) - - extracted = [ - node - for node in _response.json() - if node["name"] == self.name - ] - if len(extracted) > 1: # pragma: no cover - raise ValueError( - "Multiple nodes found with same name. Need to submit node_id" - ) - self.node_id = extracted[0]["node_id"] - # Checks for Link - if self.__class__.__name__ == "Link": - if not self.link_id: - raise ValueError("Need to submit link_id") - return f(self, *args, **kwargs) - - return cast(F, wrapper) - - -@dataclass(config=config) -class Link: - """ - GNS3 Link API object. For more information visit: [Links Endpoint API information]( - http://api.gns3.net/en/2.2/api/v2/controller/link/projectsprojectidlinks.html) - - **Attributes:** - - - `link_id` (str): Link UUID (**required** to be set when using `get` method) - - `link_type` (enum): Possible values: ethernet, serial - - `link_style` (dict): Describes the visual style of the link - - `project_id` (str): Project UUID (**required**) - - `connector` (object): `Gns3Connector` instance used for interaction (**required**) - - `suspend` (bool): Suspend the link - - `nodes` (list): List of the Nodes and ports (**required** when using `create` - method, see Features/Link creation on the docs) - - `filters` (dict): Packet filter. This allow to simulate latency and errors - - `capturing` (bool): Read only property. True if a capture running on the link - - `capture_file_path` (str): Read only property. The full path of the capture file - if capture is running - - `capture_file_name` (str): Read only property. The name of the capture file if - capture is running - - **Returns:** - - `Link` instance - - **Example:** - - ```python - >>> link = Link(project_id=, link_id= connector=) - >>> link.get() - >>> print(link.link_type) - 'ethernet' - ``` - """ - - link_id: str | None = None - link_type: str | None = None - link_style: Any | None = None - project_id: str | None = None - suspend: bool | None = None - nodes: list[Any] | None = None - filters: dict | None = None - capturing: bool | None = None - capture_file_path: str | None = None - capture_file_name: str | None = None - capture_compute_id: str | None = None - - connector: Any | None = field(default=None, repr=False) - - @field_validator("link_type") - @classmethod - def _valid_link_type(cls, value: str | None) -> str | None: - if value not in LINK_TYPES and value is not None: - raise ValueError(f"Not a valid link_type - {value}") - return value - - @field_validator("suspend") - @classmethod - def _valid_suspend(cls, value: bool | None) -> bool | None: - if type(value) is not bool and value is not None: - raise ValueError(f"Not a valid suspend - {value}") - return value - - @field_validator("filters") - @classmethod - def _valid_filters( - cls, value: dict[str, Any] | None - ) -> dict[str, Any] | None: - if type(value) is not dict and value is not None: - raise ValueError(f"Not a valid filters - {value}") - return value - - def _update(self, data_dict: dict[str, Any]) -> None: - for k, v in data_dict.items(): - if k in self.__dict__.keys(): - self.__setattr__(k, v) - - @verify_connector_and_id - def get(self) -> None: - """ - Retrieves the information from the link endpoint. - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - """ - _conn = self.connector - _project_id = self.project_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - - _url = f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}" - _response = _conn.http_call("get", _url) - - # Update object - self._update(_response.json()) - - @verify_connector_and_id - def delete(self) -> None: - """ - Deletes a link endpoint from the project. It sets to `None` the attributes - `link_id` when executed sucessfully - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - """ - _conn = self.connector - _project_id = self.project_id - _link_id = self.link_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - if _link_id is None: - raise ValueError( - "Link ID is missing. The link might have already been deleted." - ) - - _url = f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}" - - _conn.http_call("delete", _url) - - self.project_id = None - self.link_id = None - - def create(self) -> None: - """ - Creates a link endpoint - - **Required Attributes:** - - - `project_id` - - `connector` - - `nodes` - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit project_id") - - _url = f"{self.connector.base_url}/projects/{self.project_id}/links" - - data = { - k: v - for k, v in self.__dict__.items() - if k not in ("connector", "__initialised__") - if v is not None - } - - _response = self.connector.http_call("post", _url, json_data=data) - - # Now update it - self._update(_response.json()) - - @verify_connector_and_id - def update(self, **kwargs: Any) -> None: - """ - Updates the link instance by passing the keyword arguments of the attributes - you want updated - - Example: - - ```python - link1.update(suspend=True) - ``` - - This will update the link `suspend` attribute to `True` - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit project_id") - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/links/" - f"{self.link_id}" - ) - - # TODO: Verify that the passed kwargs are supported ones - _response = self.connector.http_call("put", _url, json_data=kwargs) - - # Update object - self._update(_response.json()) - - @verify_connector_and_id - def available_filters(self) -> list[dict[str, Any]]: - """ - Gets the list of available packet filters for this link. - - **NOTE:** This endpoint is only available in GNS3 API v3 or later. - Attempting to call this method with a v2 connector will raise an error. - - **Required Attributes:** - - - `project_id` - - `connector` (must be API v3 or later) - - `link_id` - - **Returns:** - - List of available filter types with their parameters (e.g., frequency_drop, - packet_loss, delay, corrupt, bpf). - - **Example:** - - ```python - >>> link = Link(project_id=, link_id=, connector=) - >>> filters = link.available_filters() - >>> print(filters) - [ - { - "type": "frequency_drop", - "name": "Frequency drop", - "description": "It will drop everything with a -1 frequency...", - "parameters": [...] - }, - ... - ] - ``` - """ - _conn = self.connector - _project_id = self.project_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - - # Check API version - available_filters endpoint is only available in v3+ - if not hasattr(_conn, "api_version") or _conn.api_version < 3: - raise ValueError( - "The available_filters() method requires GNS3 API v3 or later. " - f"Current connector version: v{getattr(_conn, 'api_version', 2)}. " - "Please use api_version=3 when creating the Gns3Connector." - ) - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}/" - "available_filters" - ) - _response = _conn.http_call("get", _url) - - return cast(list[dict[str, Any]], _response.json()) - - def reset(self) -> None: - """ - Reset the link, clearing its state (counters, filters, etc.). - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - """ - _conn = self.connector - _project_id = self.project_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - - _url = f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}/reset" - _response = _conn.http_call("post", _url) - self._update(_response.json()) - - def start_capture( - self, - data_link_type: str = "DLT_EN10MB", - capture_file_name: str | None = None, - wireshark: bool = False, - ) -> None: - """ - Start packet capture on the link. - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - - **Optional Attributes:** - - - `data_link_type` Data link type (default: DLT_EN10MB) - - `capture_file_name` Name of the capture file (optional) - - `wireshark` Open Wireshark automatically (default: False) - """ - _conn = self.connector - _project_id = self.project_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - if not self.link_id: - raise ValueError("Need to submit link_id") - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/links/" - f"{self.link_id}/capture/start" - ) - _data: dict[str, Any] = { - "data_link_type": data_link_type, - "wireshark": wireshark, - } - if capture_file_name: - _data["capture_file_name"] = capture_file_name - _response = _conn.http_call("post", _url, json_data=_data) - self._update(_response.json()) - - def stop_capture(self) -> None: - """ - Stop packet capture on the link. - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - """ - _conn = self.connector - _project_id = self.project_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/links/" - f"{self.link_id}/capture/stop" - ) - _conn.http_call("post", _url) - - -@dataclass(config=config) -class Node: - """ - GNS3 Node API object. For more information visit: [Node Endpoint API information]( - http://api.gns3.net/en/2.2/api/v2/controller/node/projectsprojectidnodes.html) - - **Attributes:** - - - `name` (str): Node name (**required** when using `create` method) - - `project_id` (str): Project UUID (**required**) - - `node_id` (str): Node UUID (**required** when using `get` method) - - `compute_id` (str): Compute identifier (**required**, default=local) - - `node_type` (enum): frame_relay_switch, atm_switch, docker, dynamips, vpcs, - traceng, virtualbox, vmware, iou, qemu (**required** when using `create` method) - - `connector` (object): `Gns3Connector` instance used for interaction (**required**) - - `template_id`: Template UUID from the which the node is from. - - `template`: Template name from the which the node is from. - - `node_directory` (str): Working directory of the node. Read only - - `status` (enum): Possible values: stopped, started, suspended - - `ports` (list): List of node ports, READ only - - `port_name_format` (str): Formating for port name {0} will be replace by port - number - - `port_segment_size` (int): Size of the port segment - - `first_port_name` (str): Name of the first port - - `properties` (dict): Properties specific to an emulator - - `locked` (bool): Whether the element locked or not - - `label` (dict): TBC - - `console` (int): Console TCP port - - `console_host` (str): Console host - - `console_auto_start` (bool): Automatically start the console when the node has - started - - `command_line` (str): Command line use to start the node - - `custom_adapters` (list): TBC - - `height` (int): Height of the node, READ only - - `width` (int): Width of the node, READ only - - `symbol` (str): Symbol of the node - - `x` (int): X position of the node - - `y` (int): Y position of the node - - `z (int): Z position of the node - - **Returns:** - - `Node` instance - - **Example:** - - ```python - >>> alpine = Node(name="alpine1", node_type="docker", template="alpine", - project_id=, connector=) - >>> alpine.create() - >>> print(alpine.node_id) - 'SOME-UUID-GENERATED' - ``` - """ - - name: str | None = None - project_id: str | None = None - node_id: str | None = None - compute_id: str = "local" - node_type: str | None = None - node_directory: str | None = None - status: str | None = None - ports: list | None = None - port_name_format: str | None = None - port_segment_size: int | None = None - first_port_name: str | None = None - locked: bool | None = None - label: Any | None = None - console: int | None = None - console_host: str | None = None - console_type: str | None = None - console_auto_start: bool | None = None - command_line: str | None = None - custom_adapters: list[Any] | None = None - height: int | None = None - width: int | None = None - symbol: str | None = None - x: int | None = None - y: int | None = None - z: int | None = None - template_id: str | None = None - properties: Any | None = None - tags: list[str] | None = None - netmiko_device_type: str | None = None - default_username: str | None = None - default_password: str | None = None - - template: str | None = None - links: list[Link] = field(default_factory=list, repr=False) - connector: Any | None = field(default=None, repr=False) - - @field_validator("node_type") - @classmethod - def _valid_node_type(cls, value: Any) -> Any: - if value not in NODE_TYPES and value is not None: - raise ValueError(f"Not a valid node_type - {value}") - return value - - @field_validator("console_type") - @classmethod - def _valid_console_type(cls, value: Any) -> Any: - if value not in CONSOLE_TYPES and value is not None: - raise ValueError(f"Not a valid console_type - {value}") - return value - - @field_validator("status") - @classmethod - def _valid_status(cls, value: Any) -> Any: - if ( - value not in ("stopped", "started", "suspended") - and value is not None - ): - raise ValueError(f"Not a valid status - {value}") - return value - - def _update(self, data_dict: dict[str, Any]) -> None: - for k, v in data_dict.items(): - if k in self.__dict__: - setattr(self, k, v) - - @verify_connector_and_id - def get(self, get_links: bool = True) -> None: - """ - Retrieves the node information. When `get_links` is `True` it also retrieves the - links respective to the node. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit project_id") - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/nodes/" - f"{self.node_id}" - ) - _response = self.connector.http_call("get", _url) - - # Update object - self._update(_response.json()) - - if get_links: - self.get_links() - - @verify_connector_and_id - def get_links(self) -> None: - """ - Retrieves the links of the respective node. They will be saved at the `links` - attribute - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit project_id") - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/nodes" - f"/{self.node_id}/links" - ) - _response = self.connector.http_call("get", _url) - - # Create the Link array but cleanup cache if there is one - if self.links: - self.links = [] - for _link in _response.json(): - self.links.append(Link(connector=self.connector, **_link)) - - @verify_connector_and_id - def start(self) -> bool | None: - """ - Starts the node. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{self.node_id}/start" - if "v2" in _url.lower(): # api_version 2 - _response = _conn.http_call( - "post", - _url, - ) - - # Update object or perform get if change was not reflected - if _response.json().get("status") == "started": - self._update(_response.json()) - else: - self.get() # pragma: no cover - - return True - - else: - # api_version 3 - _response = _conn.http_call( - "post", _url, json_data={"additionalProp1": {}} - ) - # successful response code 204 - if _response.status_code in (204,): - self.get() - return True - else: - try: - error_detail = _response.json() - except Exception: - error_detail = getattr( - _response, "text", "No response body" - ) - - _msg = ( - "Failed to start node: " - f"{getattr(_response, 'status_code', 'Unknown Status')}, " - f"Detail: {error_detail}" - ) - raise RuntimeError(_msg) from None - - @verify_connector_and_id - def stop(self) -> bool | None: - """ - Stops the node. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{self.node_id}/stop" - if "v2" in _url.lower(): # api_version 2 - _response = _conn.http_call( - "post", - _url, - ) - - # Update object or perform get if change was not reflected - if _response.json().get("status") == "stopped": - self._update(_response.json()) - else: - self.get() # pragma: no cover - - return True - else: - # api_version 3 - _response = _conn.http_call( - "post", _url, json_data={"additionalProp1": {}} - ) - # successful response code 204 - if _response.status_code in (204,): - self.get() - return True - else: - try: - error_detail = _response.json() - except Exception: - error_detail = _response.text - _msg = ( - f"Failed to stop node: {_response.status_code}, " - f"Detail: {error_detail}" - ) - raise RuntimeError(_msg) from None - - @verify_connector_and_id - def reload(self) -> bool | None: - """ - Reloads the node. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/reload" - ) - _response = _conn.http_call("post", _url) - - if "v2" in _url.lower(): # api_version 2 - _response = _conn.http_call( - "post", - _url, - ) - - # Update object or perform get if change was not reflected - if _response.json().get("status") == "started": - self._update(_response.json()) - else: - self.get() # pragma: no cover - return True - - else: - # api_version 3 - _response = _conn.http_call( - "post", _url, json_data={"additionalProp1": {}} - ) - # successful response code 204 - if _response.status_code in (204,): - self.get() - return True - else: - try: - error_detail = _response.json() - except Exception: - error_detail = _response.text - _msg = ( - f"Failed to reload node: {_response.status_code}, " - f"Detail: {error_detail}" - ) - raise RuntimeError(_msg) from None - - @verify_connector_and_id - def suspend(self) -> None: - """ - Suspends the node. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/suspend" - ) - _response = _conn.http_call("post", _url) - - # Update object or perform get if change was not reflected - if _response.json().get("status") == "suspended": - self._update(_response.json()) - else: - self.get() # pragma: no cover - - @verify_connector_and_id - def update(self, **kwargs: Any) -> None: - """ - Updates the node instance by passing the keyword arguments of the attributes - you want updated - - Example: - - ```python - router01.update(name="router01-CSX") - ``` - - This will update the project `auto_close` attribute to `True` - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}" - - # TODO: Verify that the passed kwargs are supported ones - _response = _conn.http_call("put", _url, json_data=kwargs) - - # Update object - self._update(_response.json()) - - def create(self) -> None: - """ - Creates a node. - - By default it will fetch the nodes properties for creation based on the - `template` or `template_id` attribute supplied. This can be overriden/updated - by sending a dictionary of the properties under `extra_properties`. - - **Required Node instance attributes:** - - - `project_id` - - `connector` - - `compute_id`: Defaults to "local" - - `template` or `template_id` - if not passed as arguments - """ - if self.node_id: - raise ValueError("Node already created") - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Node object needs to have project_id attribute") - if not self.template_id: - if self.template: - _template = self.connector.get_template(name=self.template) - if _template is None: - raise ValueError(f"Template {self.template} not found") - self.template_id = self.connector.get_template( - name=self.template - ).get("template_id") - else: - raise ValueError("Need either 'template' of 'template_id'") - - cached_data = { - k: v - for k, v in self.__dict__.items() - if k - not in ( - "project_id", - "template", - "template_id", - "links", - "connector", - "__initialised__", - ) - if v is not None - } - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/" - f"templates/{self.template_id}" - ) - - _response = self.connector.http_call( - "post", - _url, - json_data={"x": 0, "y": 0, "compute_id": self.compute_id}, - ) - - self._update(_response.json()) - - # Update the node attributes based on cached data - self.update(**cached_data) - - @verify_connector_and_id - def delete(self) -> None: - """ - Deletes the node from the project. It sets to `None` the attributes `node_id` - and `name` when executed successfully - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}" - - _conn.http_call("delete", _url) - - self.project_id = None - self.node_id = None - self.name = None - - @verify_connector_and_id - def get_file(self, path: str) -> str: - """ - Retrieve a file in the node directory. - - **Required Attributes:** - - - `project_id` - - `connector` - - `path`: Node's relative path of the file - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/files/{path}" - - return cast(str, _conn.http_call("get", _url).text) - - @verify_connector_and_id - def list_files(self, path: str = "", recursive: bool = False) -> list[dict[str, Any]]: - """ - List files in the node directory with metadata (name, size, type, modified time). - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - - **Optional Attributes:** - - - `path`: Subdirectory path within node directory (default: "") - - `recursive`: Recursively list all files (default: False) - - **Returns:** - - List of file objects with metadata. - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/files" - _params: dict[str, str] = {} - if path: - _params["path"] = path - if recursive: - _params["recursive"] = "true" - _response = _conn.http_call("get", _url, params=_params if _params else None) - return cast(list[dict[str, Any]], _response.json()) - - @verify_connector_and_id - def delete_file(self, path: str) -> None: - """ - Delete a file from the node directory. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - - `path`: Node's relative path of the file to delete - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/files/{path}" - _conn.http_call("delete", _url) - - @verify_connector_and_id - def write_file(self, path: str, data: Any) -> None: - """ - Places a file content on a specified node file path. Used mainly for docker - images. - - Example to update an alpine docker network interfaces: - - ```python - >>> data = ''' - auto eth0 - iface eth0 inet dhcp - ''' - - >>> alpine_node.write_file(path='/etc/network/interfaces', data=data) - ``` - - **Required Attributes:** - - - `project_id` - - `connector` - - `path`: Node's relative path of the file - - `data`: Data to be included in the file - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/files/{path}" - - _conn.http_call("post", _url, data=data) - - -@dataclass(config=config) -class Project: - """ - GNS3 Project API object. For more information visit: [Project Endpoint API - information](http://api.gns3.net/en/2.2/api/v2/controller/project/projects.html) - - **Attributes:** - - - `name`: Project name (**required** when using `create` method) - - `project_id` (str): Project UUID (**required**) - - `connector` (object): `Gns3Connector` instance used for interaction (**required**) - - `status` (enum): Possible values: opened, closed - - `path` (str): Path of the project on the server - - `filename` (str): Project filename - - `auto_start` (bool): Project start when opened - - `auto_close` (bool): Project auto close when client cut off the notifications feed - - `auto_open` (bool): Project open when GNS3 start - - `drawing_grid_size` (int): Grid size for the drawing area for drawings - - `grid_size` (int): Grid size for the drawing area for nodes - - `scene_height` (int): Height of the drawing area - - `scene_width` (int): Width of the drawing area - - `show_grid` (bool): Show the grid on the drawing area - - `show_interface_labels` (bool): Show interface labels on the drawing area - - `show_layers` (bool): Show layers on the drawing area - - `snap_to_grid` (bool): Snap to grid on the drawing area - - `supplier` (dict): Supplier of the project - - `variables` (list): Variables required to run the project - - `zoom` (int): Zoom of the drawing area - - `stats` (dict): Project stats - -.`drawings` (list): List of drawings present on the project - - `nodes` (list): List of `Node` instances present on the project - - `links` (list): List of `Link` instances present on the project - - **Returns:** - - `Project` instance - - **Example:** - - ```python - >>> lab = Project(name="lab", connector=) - >>> lab.create() - >>> print(lab.status) - 'opened' - ``` - """ - - name: str | None = None - project_id: str | None = None - status: str | None = None - locked: bool | None = None - path: str | None = None - filename: str | None = None - auto_start: bool | None = None - auto_close: bool | None = None - auto_open: bool | None = None - drawing_grid_size: int | None = None - grid_size: int | None = None - scene_height: int | None = None - scene_width: int | None = None - show_grid: bool | None = None - show_interface_labels: bool | None = None - show_layers: bool | None = None - snap_to_grid: bool | None = None - supplier: Any | None = None - variables: list | None = None - zoom: int | None = None - - stats: dict[str, Any] | None = None - snapshots: list[dict] | None = None - drawings: list[dict] | None = None - nodes: list[Node] = field(default_factory=list, repr=False) - links: list[Link] = field(default_factory=list, repr=False) - connector: Any | None = field(default=None, repr=False) - - @field_validator("status") - @classmethod - def _valid_status(cls, value: Any) -> Any: - if value != "opened" and value != "closed" and value is not None: - raise ValueError("status must be opened or closed") - return value - - def _update(self, data_dict: dict[str, Any]) -> None: - for k, v in data_dict.items(): - if k in self.__dict__: - setattr(self, k, v) - - def get( - self, - get_links: bool = True, - get_nodes: bool = True, - get_stats: bool = True, - ) -> None: - """ - Retrieves the projects information. - - - `get_links`: When true it also queries for the links inside the project - - `get_nodes`: When true it also queries for the nodes inside the project - - `get_stats`: When true it also queries for the stats inside the project - - It `get_stats` is set to `True`, it also verifies if snapshots and drawings are - inside the project and stores them in their respective attributes - (`snapshots` and `drawings`) - - **Required Attributes:** - - - `connector` - - `project_id` or `name` - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - - # Get projects if no ID was provided by the name - if not self.project_id: - if not self.name: - raise ValueError("Need to submit either project_id or name") - _url = f"{self.connector.base_url}/projects" - # Get all projects and filter the respective project - _response = self.connector.http_call("get", _url) - - # Filter the respective project - for _project in _response.json(): - if _project.get("name") == self.name: - self.project_id = _project.get("project_id") - - # Get project - _url = f"{self.connector.base_url}/projects/{self.project_id}" - _response = self.connector.http_call("get", _url) - - # Update object - self._update(_response.json()) - - if get_stats: - self.get_stats() - if self.stats is not None: - if self.stats.get("snapshots", 0) > 0: - self.get_snapshots() - if self.stats.get("drawings", 0) > 0: - self.get_drawings() - if get_nodes: - self.get_nodes() - if get_links: - self.get_links() - - def create(self) -> None: - """ - Creates the project. - - **Required Attributes:** - - - `name` - - `connector` - """ - if not self.name: - raise ValueError("Need to submit project name") - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - - _url = f"{self.connector.base_url}/projects" - - data = { - k: v - for k, v in self.__dict__.items() - if k - not in ( - "stats", - "nodes", - "links", - "connector", - "__initialised__", - ) - if v is not None - } - - _response = self.connector.http_call("post", _url, json_data=data) - - # Now update it - self._update(_response.json()) - - @verify_connector_and_id - def update(self, **kwargs: Any) -> None: - """ - Updates the project instance by passing the keyword arguments of the attributes - you want updated - - Example: - - ```python - lab.update(auto_close=True) - ``` - - This will update the project `auto_close` attribute to `True` - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}" - - # TODO: Verify that the passed kwargs are supported ones - _response = _conn.http_call("put", _url, json_data=kwargs) - - # Update object - self._update(_response.json()) - - @verify_connector_and_id - def delete(self) -> None: - """ - Deletes the project from the server. It sets to `None` the attributes - `project_id` and `name` when executed successfully - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}" - - _conn.http_call("delete", _url) - - self.project_id = None - self.name = None - - @verify_connector_and_id - def close(self) -> None: - """ - Closes the project on the server. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/close" - - _response = _conn.http_call("post", _url) - - # Update object - if _response.status_code == 204: - self.status = "closed" - - @verify_connector_and_id - def open(self) -> None: - """ - Opens the project on the server. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/open" - - _response = _conn.http_call("post", _url) - - # Update object - self._update(_response.json()) - - @verify_connector_and_id - def get_stats(self) -> None: - """ - Retrieve the stats of the project. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/stats" - - _response = _conn.http_call("get", _url) - - # Update object - self.stats = _response.json() - - @verify_connector_and_id - def get_file(self, path: str) -> str: - """ - Retrieve a file in the project directory. Beware you have warranty to be able to - access only to file global to the project. - - **Required Attributes:** - - - `project_id` - - `connector` - - `path`: Project's relative path of the file - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/files/{path}" - - return cast(str, _conn.http_call("get", _url).text) - - @verify_connector_and_id - def write_file(self, path: str, data: Any) -> None: - """ - Places a file content on a specified project file path. Beware you have warranty - to be able to access only to file global to the project. - - Example to create a README.txt for the project: - - ```python - >>> data = ''' - This is a README description! - ''' - - >>> project.write_file(path='README.txt', data=data) - ``` - - **Required Attributes:** - - - `project_id` - - `connector` - - `path`: Project's relative path of the file - - `data`: Data to be included in the file - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/files/{path}" - - _conn.http_call("post", _url, data=data) - - @verify_connector_and_id - def get_nodes(self) -> None: - """ - Retrieve the nodes of the project. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes" - - _response = _conn.http_call("get", _url) - - # Create the Nodes array but cleanup cache if there is one - if self.nodes: - self.nodes = [] - for _node in _response.json(): - _n = Node(connector=self.connector, **_node) - _n.project_id = self.project_id - self.nodes.append(_n) - - @verify_connector_and_id - def get_links(self) -> None: - """ - Retrieve the links of the project. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/links" - - _response = _conn.http_call("get", _url) - - # Create the Nodes array but cleanup cache if there is one - if self.links: - self.links = [] - for _link in _response.json(): - _l = Link(connector=self.connector, **_link) - _l.project_id = self.project_id - self.links.append(_l) - - @verify_connector_and_id - def start_nodes(self, poll_wait_time: int = 5) -> None: - """ - Starts all the nodes inside the project. - - - `poll_wait_time` is used as a delay when performing the next query of the - nodes status. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/start" - - _conn.http_call("post", _url) - - # Update object - time.sleep(poll_wait_time) - self.get_nodes() - - @verify_connector_and_id - def stop_nodes(self, poll_wait_time: int = 5) -> None: - """ - Stops all the nodes inside the project. - - - `poll_wait_time` is used as a delay when performing the next query of the - nodes status. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/stop" - - _conn.http_call("post", _url) - - # Update object - time.sleep(poll_wait_time) - self.get_nodes() - - @verify_connector_and_id - def reload_nodes(self, poll_wait_time: int = 5) -> None: - """ - Reloads all the nodes inside the project. - - - `poll_wait_time` is used as a delay when performing the next query of the - nodes status. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/reload" - - _conn.http_call("post", _url) - - # Update object - time.sleep(poll_wait_time) - self.get_nodes() - - @verify_connector_and_id - def suspend_nodes(self, poll_wait_time: int = 5) -> None: - """ - Suspends all the nodes inside the project. - - - `poll_wait_time` is used as a delay when performing the next query of the - nodes status. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/suspend" - - _conn.http_call("post", _url) - - # Update object - time.sleep(poll_wait_time) - self.get_nodes() - - def nodes_summary( - self, is_print: bool = True - ) -> list[tuple[Any, ...]] | None: - """ - Returns a summary of the nodes insode the project. If `is_print` is `False`, it - will return a list of tuples like: - - `[(node_name, node_status, node_console, node_id) ...]` - - **Required Attributes:** - - - `project_id` - - `connector` - """ - - if not self.nodes: - self.get_nodes() - - _nodes_summary = [] - for _n in self.nodes: - if is_print: - print( - f"{_n.name}: {_n.status} -- Console: {_n.console} -- " - f"ID: {_n.node_id}" - ) - _nodes_summary.append((_n.name, _n.status, _n.console, _n.node_id)) - - return _nodes_summary if not is_print else None - - def nodes_inventory(self) -> dict[str | None, Any]: - """ - Returns an inventory-style dictionary of the nodes - - Example: - - `{ - "router01": { - "server": "127.0.0.1", - "name": "router01", - "node_id": uuid, - "console_port": 5077, - "type": "vEOS", - "ports": "[port detila]", - "x": 100, - "y": 200 - } - }` - - **Required Attributes:** - - - `project_id` - - `connector` - """ - - if not self.nodes: - self.get_nodes() - - _nodes_inventory = {} - conn = self.connector - if not conn: - raise ValueError( - "Gns3Connector not assigned. Please set the connector first." - ) - - _server = urlparse(conn.base_url).hostname - - for _n in self.nodes: - _nodes_inventory.update( - { - _n.name: { - "server": _server, - "name": _n.name, - "node_id": _n.node_id, - "console_port": _n.console, - "console_type": _n.console_type, - "type": _n.node_type, - "ports": _n.ports, - "status": _n.status, - # "template": _n.template, - "x": _n.x, - "y": _n.y, - "tags": _n.tags if _n.tags else [], - "netmiko_device_type": _n.netmiko_device_type, - "default_username": _n.default_username, - "default_password": _n.default_password, - } - } - ) - - return _nodes_inventory - - def links_summary( - self, is_print: bool = True - ) -> list[dict[str, str]] | None: - """ - Returns a summary of the links inside the project. If `is_print` is False, - it will return a list of dicts like: - - `[{"link_id": "xxx", "node_a": "R1", "port_a": "Eth0/0", "node_b": "R2", "port_b": "Eth0/0"}, ...]` - - **Required Attributes:** - - - `project_id` - - `connector` - """ - # Ensure data is loaded - if not self.nodes: - self.get_nodes() - if not self.links: - self.get_links() - # If None, program errors here instead of continuing - assert self.links is not None, "Links must be loaded" - assert self.nodes is not None, "Nodes must be loaded" - - _links_summary: list[dict[str, str]] = [] - - for _l in self.links: - if not _l.nodes: - continue - _side_a = _l.nodes[0] - _side_b = _l.nodes[1] - - try: - # Add type-safe lookup logic - _node_a = next( - x for x in self.nodes if x.node_id == _side_a["node_id"] - ) - # Ensure getting str to resolve [return-value] error - _port_a = str( - next( - x["name"] - for x in (_node_a.ports or []) - if x["port_number"] == _side_a["port_number"] - and x["adapter_number"] == _side_a["adapter_number"] - ) - ) - - _node_b = next( - x for x in self.nodes if x.node_id == _side_b["node_id"] - ) - _port_b = str( - next( - x["name"] - for x in (_node_b.ports or []) - if x["port_number"] == _side_b["port_number"] - and x["adapter_number"] == _side_b["adapter_number"] - ) - ) - - # Ensure name is not None - name_a = str(_node_a.name) if _node_a.name else "Unknown" - name_b = str(_node_b.name) if _node_b.name else "Unknown" - - endpoint_a = f"{name_a}: {_port_a}" - endpoint_b = f"{name_b}: {_port_b}" - - if is_print: - print(f"{endpoint_a} ---- {endpoint_b}") - - _links_summary.append({ - "link_id": _l.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 list comprehension can't match data - continue - return _links_summary if not is_print else None - - def _search_node(self, key: str, value: Any) -> Any | None: - "Performs a search based on a key and value" - # Retrive nodes if neccesary - if not self.nodes: - self.get_nodes() - - try: - return [_p for _p in self.nodes if getattr(_p, key) == value][0] - except IndexError: - return None - - def get_node( - self, name: str | None = None, node_id: str | None = None - ) -> Any | None: - """ - Returns the Node object by searching for the `name` or the `node_id`. - - **Required Attributes:** - - - `project_id` - - `connector` - - **Required keyword arguments:** - - `name` or `node_id` - - **NOTE:** Run method `get_nodes()` manually to refresh list of nodes if - necessary - """ - if node_id: - return self._search_node(key="node_id", value=node_id) - elif name: - return self._search_node(key="name", value=name) - else: - raise ValueError("name or node_ide must be provided") - - def _search_link(self, key: str, value: Any) -> Any | None: - "Performs a search based on a key and value" - # Retrive links if neccesary - if not self.links: - self.get_links() - - try: - return next(_p for _p in self.links if getattr(_p, key) == value) - except StopIteration: - return None - - def get_link(self, link_id: str) -> Any | None: - """ - Returns the Link object by locating its ID - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - - **NOTE:** Run method `get_links()` manually to refresh list of links if - necessary - """ - return self._search_link(key="link_id", value=link_id) - - def create_node(self, **kwargs: Any) -> None: - """ - Creates a node. To know available parameters see `Node` object, specifically - the `create` method. The most basic example would be: - - ```python - project.create_node(name='test-switch01', template='Ethernet switch') - ``` - - **Required Project instance attributes:** - - - `project_id` - - `connector` - - **Required keyword aguments:** - - - `template` or `template_id` - """ - if not self.nodes: - self.get_nodes() - - _node = Node( - project_id=self.project_id, connector=self.connector, **kwargs - ) - - _node.create() - self.nodes.append(_node) - print( - f"Created: {_node.name} -- Type: {_node.node_type} -- " - f"Console: {_node.console}" - ) - - def create_link( - self, node_a: str, port_a: str, node_b: str, port_b: str - ) -> None: - """ - Creates a link. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_a`: Node name of the A side - - `port_a`: Port name of the A side (must match the `name` attribute of the - port) - - `node_b`: Node name of the B side - - `port_b`: Port name of the B side (must match the `name` attribute of the - port) - """ - if not self.nodes: - self.get_nodes() - if not self.links: - self.get_links() - - _node_a = self.get_node(name=node_a) - if not _node_a: - raise ValueError(f"node_a: {node_a} not found") - try: - _port_a = [_p for _p in _node_a.ports if _p["name"] == port_a][0] - except IndexError: - raise ValueError(f"port_a: {port_a} not found") from None - - _node_b = self.get_node(name=node_b) - if not _node_b: - raise ValueError(f"node_b: {node_b} not found") - try: - _port_b = [_p for _p in _node_b.ports if _p["name"] == port_b][0] - except IndexError: - raise ValueError(f"port_b: {port_b} not found") from None - - _matches = [] - for _l in self.links: - if not _l.nodes: - continue - if ( - _l.nodes[0]["node_id"] == _node_a.node_id - and _l.nodes[0]["adapter_number"] == _port_a["adapter_number"] - and _l.nodes[0]["port_number"] == _port_a["port_number"] - ): - _matches.append(_l) - elif ( - _l.nodes[1]["node_id"] == _node_b.node_id - and _l.nodes[1]["adapter_number"] == _port_b["adapter_number"] - and _l.nodes[1]["port_number"] == _port_b["port_number"] - ): - _matches.append(_l) # pragma: no cover - if _matches: - raise ValueError( - f"At least one port is used, ID: {_matches[0].link_id}" - ) - - # Now create the link! - _link = Link( - project_id=self.project_id, - connector=self.connector, - nodes=[ - { - "node_id": _node_a.node_id, - "adapter_number": _port_a["adapter_number"], - "port_number": _port_a["port_number"], - "label": {"text": _port_a.get("short_name") or _port_a["name"]}, - }, - { - "node_id": _node_b.node_id, - "adapter_number": _port_b["adapter_number"], - "port_number": _port_b["port_number"], - "label": {"text": _port_b.get("short_name") or _port_b["name"]}, - }, - ], - ) - - _link.create() - self.links.append(_link) - print(f"Created Link-ID: {_link.link_id} -- Type: {_link.link_type}") - - def delete_link( - self, node_a: str, port_a: str, node_b: str, port_b: str - ) -> None: - """ - Deletes a link. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_a`: Node name of the A side - - `port_a`: Port name of the A side (must match the `name` attribute of the - port) - - `node_b`: Node name of the B side - - `port_b`: Port name of the B side (must match the `name` attribute of the - port) - """ - if not self.nodes: - self.get_nodes() # pragma: no cover - if not self.links: - self.get_links() # pragma: no cover - - # checking link info - _node_a = self.get_node(name=node_a) - if not _node_a: - raise ValueError(f"node_a: {node_a} not found") - try: - _port_a = [_p for _p in _node_a.ports if _p["name"] == port_a][0] - except IndexError: - raise ValueError(f"port_a: {port_a} not found") from None - - _node_b = self.get_node(name=node_b) - if not _node_b: - raise ValueError(f"node_b: {node_b} not found") - try: - _port_b = [_p for _p in _node_b.ports if _p["name"] == port_b][0] - except IndexError: - raise ValueError(f"port_b: {port_b} not found") from None - - _matches = [] - for _l in self.links: - if not _l.nodes: - continue - if ( - _l.nodes[0]["node_id"] == _node_a.node_id - and _l.nodes[0]["adapter_number"] == _port_a["adapter_number"] - and _l.nodes[0]["port_number"] == _port_a["port_number"] - ): - _matches.append(_l) - elif ( - _l.nodes[1]["node_id"] == _node_b.node_id - and _l.nodes[1]["adapter_number"] == _port_b["adapter_number"] - and _l.nodes[1]["port_number"] == _port_b["port_number"] - ): - _matches.append(_l) - if not _matches: - raise ValueError( - f"Link not found: {node_a, port_a, node_b, port_b}" - ) # pragma: no cover - - # now to delete the link via GNS3_api - _link = _matches[0] - self.links.remove(_link) - _link_id = _link.link_id - _link.delete() - print( - f"Deleted Link-ID: {_link_id} From node {node_a}, port: {port_a} <--> " - f"to node {node_b}, port: {port_b}" - ) - - @verify_connector_and_id - def get_snapshots(self) -> None: - """ - Retrieves list of snapshots of the project - - **Required Project instance attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/snapshots" - - _response = _conn.http_call("get", _url) - self.snapshots = _response.json() - - def _search_snapshot(self, key: str, value: Any) -> dict[str, Any] | None: - "Performs a search based on a key and value" - if not self.snapshots: - self.get_snapshots() - - try: - return next( - _p for _p in (self.snapshots or []) if _p[key] == value - ) - except StopIteration: - return None - - def get_snapshot( - self, name: str | None = None, snapshot_id: str | None = None - ) -> dict[str, Any] | None: - """ - Returns the Snapshot by searching for the `name` or the `snapshot_id`. - - **Required Attributes:** - - - `project_id` - - `connector` - - **Required keyword arguments:** - - `name` or `snapshot_id` - """ - if snapshot_id: - return self._search_snapshot(key="snapshot_id", value=snapshot_id) - elif name: - return self._search_snapshot(key="name", value=name) - else: - raise ValueError("name or snapshot_id must be provided") - - @verify_connector_and_id - def create_snapshot(self, name: str) -> None: - """ - Creates a snapshot of the project - - **Required Project instance attributes:** - - - `project_id` - - `connector` - - **Required keyword aguments:** - - - `name` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - self.get_snapshots() - - _snapshot = self.get_snapshot(name=name) - if _snapshot: - raise ValueError("Snapshot already created") - - _url = f"{_conn.nector.base_url}/projects/{_project_id}/snapshots" - - _response = _conn.http_call("post", _url, json_data={"name": name}) - - _snapshot = _response.json() - - if self.snapshots is None: - self.snapshots = [] - - self.snapshots.append(_snapshot) - print(f"Created snapshot: {_snapshot['name']}") - - @verify_connector_and_id - def delete_snapshot( - self, name: str | None = None, snapshot_id: str | None = None - ) -> None: - """ - Deletes a snapshot of the project - - **Required Project instance attributes:** - - - `project_id` - - `connector` - - **Required keyword aguments:** - - - `name` or `snapshot_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - self.get_snapshots() - - _snapshot = self.get_snapshot(name=name, snapshot_id=snapshot_id) - if not _snapshot: - raise ValueError("Snapshot not found") - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/snapshots/" - f"{_snapshot['snapshot_id']}" - ) - - _conn.http_call("delete", _url) - - self.get_snapshots() - - @verify_connector_and_id - def restore_snapshot( - self, name: str | None = None, snapshot_id: str | None = None - ) -> None: - """ - Restore a snapshot from disk - - **Required Project instance attributes:** - - - `project_id` - - `connector` - - **Required keyword aguments:** - - - `name` or `snapshot_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - self.get_snapshots() - - _snapshot = self.get_snapshot(name=name, snapshot_id=snapshot_id) - if not _snapshot: - raise ValueError("Snapshot not found") - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/snapshots/" - f"{_snapshot['snapshot_id']}/restore" - ) - - _conn.http_call("post", _url) - - # Update the whole project - self.get() - - def arrange_nodes_circular(self, radius: int = 120) -> None: - """ - Re-arrgange the existing nodes - in a circular fashion - - **Attributes:** - - - project instance created - - **Example** - - ```python - >>> proj = Project(name='project_name', connector=Gns3connector) - >>> proj.arrange_nodes() - ``` - """ - - self.get() - if self.status != "opened": - self.open() # pragma: no cover - - _angle = (2 * pi) / len(self.nodes) - # The Y Axis is inverted in GNS3, so the -Y is UP - for index, n in enumerate(self.nodes): - _x = int(radius * (sin(_angle * index))) - _y = int(radius * (-cos(_angle * index))) - n.update(x=_x, y=_y) - - def get_drawing( - self, drawing_id: str | None = None - ) -> dict[str, Any] | None: - """ - Returns the drawing by searching for the `svg` or the `drawing_id`. - - **Required Attributes:** - - - `project_id` - - `connector` - - **Required keyword arguments:** - - `svg` or `drawing_id` - """ - if not self.drawings: - self.get_drawings() - - try: - return next( - _drawing - for _drawing in (self.drawings or []) - if _drawing["drawing_id"] == drawing_id - ) - except (StopIteration, KeyError, TypeError): - return None - - @verify_connector_and_id - def get_drawings(self) -> None: - """ - Retrieves list of drawings of the project - - **Required Project instance attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/drawings" - - _response = _conn.http_call("get", _url) - self.drawings = _response.json() - - @verify_connector_and_id - def create_drawing( - self, - svg: str, - x: int = 0, - y: int = 0, - z: int = 0, - locked: bool = False, - rotation: int = 0, - ) -> dict[str, Any]: - """ - Creates a new drawing in the project - - API: POST /v2/projects/{project_id}/drawings - - Required Project instance attributes: - - - `project_id` - - `connector` - - Required parameters: - - - `svg`: SVG content string - - Optional parameters: - - - `x`: X coordinate (default: 0) - - `y`: Y coordinate (default: 0) - - `z`: Z layer (default: 0) - - `locked`: Whether to lock the drawing (default: False) - - `rotation`: Rotation angle in degrees, range -359 to 359 (default: 0) - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/drawings" - - # Prepare request body - request_body = { - "svg": svg, - "x": x, - "y": y, - "z": z, - "locked": locked, - "rotation": rotation, - } - - # Send POST request to create drawing - _response = _conn.http_call("post", _url, json_data=request_body) - - # Refresh drawings list - self.get_drawings() - - return cast(dict[str, Any], _response.json()) - - @verify_connector_and_id - def update_drawing( - self, - drawing_id: str, - svg: str | None = None, - locked: bool | None = None, - x: int | None = None, - y: int | None = None, - z: int | None = None, - ) -> dict[str, Any]: - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/drawings/{drawing_id}" - - # Ensure data exists - if not self.drawings: - self.get_drawings() - - # Type guard: inform Mypy that self.drawings is now an iterable list - # Use or [] with next to find target object - current_drawing = next( - ( - d - for d in (self.drawings or []) - if d.get("drawing_id") == drawing_id - ), - None, - ) - - if current_drawing is None: - raise ValueError( - f"Drawing with ID {drawing_id} not found in project." - ) - - # If parameter is None, get original value from current object - # This way, Mypy won't report errors for list comprehensions of each field - final_svg = svg if svg is not None else current_drawing.get("svg") - final_locked = ( - locked if locked is not None else current_drawing.get("locked") - ) - final_x = x if x is not None else current_drawing.get("x") - final_y = y if y is not None else current_drawing.get("y") - final_z = z if z is not None else current_drawing.get("z") - - # Execute update - response = _conn.http_call( - "put", - _url, - json_data={ - "svg": final_svg, - "locked": final_locked, - "x": final_x, - "y": final_y, - "z": final_z, - }, - ) - - # Update local cache - self.get_drawings() - - return cast(dict[str, Any], response.json()) - - @verify_connector_and_id - def delete_drawing(self, drawing_id: str | None = None) -> None: - """ - Deletes a drawing of the project - - **Required Project instance attributes:** - - - `project_id` - - `connector` - - **Required keyword aguments:** - - - `drawing_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - self.get_drawings() - - _drawing = self.get_drawing(drawing_id=drawing_id) - if not _drawing: - raise ValueError("drawing not found") - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/drawings/" - f"{_drawing['drawing_id']}" - ) - - _conn.http_call("delete", _url) - - self.get_drawings() - - @verify_connector_and_id - def get_locked(self) -> bool: - """ - Retrieve locked status of the project. - - Returns whether the project is locked or not. - - API: GET /v3/projects/{project_id}/locked - - Required Attributes: - - - `project_id` - - `connector` - - Returns: - bool: True if project is locked, False otherwise - - Raises: - ValueError: If called with GNS3 API v2 (not supported) - - Note: - This method is only available in GNS3 v3 API - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - # Check API version - only v3 supports lock operations - if _conn.api_version != 3: - raise ValueError( - "Project lock/unlock operations are only supported in GNS3 API v3. " - f"Current API version: v{_conn.api_version}" - ) - - _url = f"{_conn.base_url}/projects/{_project_id}/locked" - - _response = _conn.http_call("get", _url) - locked_status = cast(bool, _response.json()) - - # Update the locked attribute - self.locked = locked_status - - return locked_status - - @verify_connector_and_id - def lock_project(self) -> None: - """ - Lock all drawings and nodes in the project. - - API: POST /v3/projects/{project_id}/lock - - Required Attributes: - - - `project_id` - - `connector` - - Raises: - ValueError: If called with GNS3 API v2 (not supported) - - Note: - This method is only available in GNS3 v3 API - Returns 204 on success (no content) - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - # Check API version - only v3 supports lock operations - if _conn.api_version != 3: - raise ValueError( - "Project lock/unlock operations are only supported in GNS3 API v3. " - f"Current API version: v{_conn.api_version}" - ) - - _url = f"{_conn.base_url}/projects/{_project_id}/lock" - - _conn.http_call("post", _url) - - # Update the locked attribute - self.locked = True - - @verify_connector_and_id - def unlock_project(self) -> None: - """ - Unlock all drawings and nodes in the project. - - API: POST /v3/projects/{project_id}/unlock - - Required Attributes: - - - `project_id` - - `connector` - - Raises: - ValueError: If called with GNS3 API v2 (not supported) - - Note: - This method is only available in GNS3 v3 API - Returns 204 on success (no content) - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - # Check API version - only v3 supports lock operations - if _conn.api_version != 3: - raise ValueError( - "Project lock/unlock operations are only supported in GNS3 API v3. " - f"Current API version: v{_conn.api_version}" - ) - - _url = f"{_conn.base_url}/projects/{_project_id}/unlock" - - _conn.http_call("post", _url) - - # Update the locked attribute - self.locked = False diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py index f9029489a..eedb72a00 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py @@ -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, ) diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py index 8ecee9409..59bd33507 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py @@ -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 diff --git a/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py b/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py new file mode 100644 index 000000000..a4b6782da --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py @@ -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 . +# +# 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), + } diff --git a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py index 41ed58f9d..285e221dc 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py @@ -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: """ diff --git a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py index 8fa056650..7c4f45622 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py @@ -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: """ diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py index 677f7d642..86d28769f 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py @@ -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, diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py index 49f7188fe..e03e42ec4 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py @@ -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", } diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py index fabd78f5a..e10e6390f 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py @@ -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 = [] diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py index d4afb4b60..562a80f1b 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py @@ -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", } diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py index dc9a5fa10..8e749d35a 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py @@ -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 = [ diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py index 66d6de515..a6cdfc2a2 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py @@ -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", } ) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py index 0bef3190c..c7d28025e 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py @@ -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", } ) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py index eca74795f..f6b00171b 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py @@ -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", } diff --git a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py index 221bf9bed..8339baefc 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py +++ b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py @@ -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"], }) diff --git a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py index 3ef331157..b3127574e 100644 --- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -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": { diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 47438b9c8..a74031f5d 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -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:' 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
/ Set IP and gateway - save Save config to startup.vpc diff --git a/gns3server/agent/mcp/appliances.py b/gns3server/agent/mcp/appliances.py index a2bc9dd0f..ab867fe57 100644 --- a/gns3server/agent/mcp/appliances.py +++ b/gns3server/agent/mcp/appliances.py @@ -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 diff --git a/gns3server/agent/mcp/computes.py b/gns3server/agent/mcp/computes.py index 29072ea04..254dc53f1 100644 --- a/gns3server/agent/mcp/computes.py +++ b/gns3server/agent/mcp/computes.py @@ -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)} diff --git a/gns3server/agent/mcp/device_config.py b/gns3server/agent/mcp/device_config.py index 10d4b7634..990f4b097 100644 --- a/gns3server/agent/mcp/device_config.py +++ b/gns3server/agent/mcp/device_config.py @@ -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 diff --git a/gns3server/agent/mcp/drawings.py b/gns3server/agent/mcp/drawings.py index 6e47a2e02..e9135b0f9 100644 --- a/gns3server/agent/mcp/drawings.py +++ b/gns3server/agent/mcp/drawings.py @@ -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"], diff --git a/gns3server/agent/mcp/images.py b/gns3server/agent/mcp/images.py index 0022e1554..3fcbfd17d 100644 --- a/gns3server/agent/mcp/images.py +++ b/gns3server/agent/mcp/images.py @@ -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"} diff --git a/gns3server/agent/mcp/links.py b/gns3server/agent/mcp/links.py deleted file mode 100644 index 314f8b8c9..000000000 --- a/gns3server/agent/mcp/links.py +++ /dev/null @@ -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 . - -""" -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, - }, -] diff --git a/gns3server/agent/mcp/nodes.py b/gns3server/agent/mcp/nodes.py deleted file mode 100644 index 3d3a7133a..000000000 --- a/gns3server/agent/mcp/nodes.py +++ /dev/null @@ -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 . - -""" -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, - }, -] diff --git a/gns3server/agent/mcp/projects.py b/gns3server/agent/mcp/projects.py index ebcdc65c0..e095354a1 100644 --- a/gns3server/agent/mcp/projects.py +++ b/gns3server/agent/mcp/projects.py @@ -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: diff --git a/gns3server/agent/mcp/server.py b/gns3server/agent/mcp/server.py index 53743c900..0f8ac4abb 100644 --- a/gns3server/agent/mcp/server.py +++ b/gns3server/agent/mcp/server.py @@ -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"], diff --git a/gns3server/agent/mcp/snapshots.py b/gns3server/agent/mcp/snapshots.py index e5b1685db..f428292b9 100644 --- a/gns3server/agent/mcp/snapshots.py +++ b/gns3server/agent/mcp/snapshots.py @@ -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"], diff --git a/gns3server/agent/mcp/symbols.py b/gns3server/agent/mcp/symbols.py index 6026ee51f..8625d3e0c 100644 --- a/gns3server/agent/mcp/symbols.py +++ b/gns3server/agent/mcp/symbols.py @@ -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"], diff --git a/gns3server/agent/mcp/templates.py b/gns3server/agent/mcp/templates.py index af8eeaf40..7c0f678bb 100644 --- a/gns3server/agent/mcp/templates.py +++ b/gns3server/agent/mcp/templates.py @@ -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"], diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py index 1ca32d8e9..142818f66 100644 --- a/gns3server/api/routes/controller/appliances.py +++ b/gns3server/api/routes/controller/appliances.py @@ -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, diff --git a/gns3server/api/routes/controller/images.py b/gns3server/api/routes/controller/images.py index 26b9a2ed2..70c49b098 100644 --- a/gns3server/api/routes/controller/images.py +++ b/gns3server/api/routes/controller/images.py @@ -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( diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index de29db5dc..47f9e57aa 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -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( diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py index 9a519a616..9593ee1cc 100644 --- a/gns3server/controller/__init__.py +++ b/gns3server/controller/__init__.py @@ -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") diff --git a/gns3server/controller/appliance_manager.py b/gns3server/controller/appliance_manager.py index 25b0c366e..e411b9852 100644 --- a/gns3server/controller/appliance_manager.py +++ b/gns3server/controller/appliance_manager.py @@ -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, diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index ab39b9496..01506a734 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -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 diff --git a/gns3server/db/repositories/templates.py b/gns3server/db/repositories/templates.py index 0f03c74a3..a08b58053 100644 --- a/gns3server/db/repositories/templates.py +++ b/gns3server/db/repositories/templates.py @@ -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)) diff --git a/gns3server/services/computes.py b/gns3server/services/computes.py index 995cb7698..a97c8fcf4 100644 --- a/gns3server/services/computes.py +++ b/gns3server/services/computes.py @@ -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") diff --git a/tests/agent/mcp/test_device_tools.py b/tests/agent/mcp/test_device_tools.py new file mode 100644 index 000000000..5c5cd8c63 --- /dev/null +++ b/tests/agent/mcp/test_device_tools.py @@ -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"] diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index d9c3490c0..b71b33ee0 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -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, ) diff --git a/tests/agent/mcp/test_tool_params.py b/tests/agent/mcp/test_tool_params.py index fe46ea178..06f4ae173 100644 --- a/tests/agent/mcp/test_tool_params.py +++ b/tests/agent/mcp/test_tool_params.py @@ -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 diff --git a/tests/agent/test_custom_gns3fy.py b/tests/agent/test_gns3_client.py similarity index 58% rename from tests/agent/test_custom_gns3fy.py rename to tests/agent/test_gns3_client.py index 783ec59ed..36e57fd72 100644 --- a/tests/agent/test_custom_gns3fy.py +++ b/tests/agent/test_gns3_client.py @@ -16,79 +16,113 @@ # along with this program. If not, see . """ -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: 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 diff --git a/tests/api/routes/controller/test_appliances.py b/tests/api/routes/controller/test_appliances.py index d3c1cc632..356fe2b08 100644 --- a/tests/api/routes/controller/test_appliances.py +++ b/tests/api/routes/controller/test_appliances.py @@ -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: diff --git a/tests/api/routes/controller/test_computes.py b/tests/api/routes/controller/test_computes.py index c92bc1a14..5b864f02f 100644 --- a/tests/api/routes/controller/test_computes.py +++ b/tests/api/routes/controller/test_computes.py @@ -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 = { diff --git a/tests/api/routes/controller/test_images.py b/tests/api/routes/controller/test_images.py index 8760c9075..8a7a4d68c 100644 --- a/tests/api/routes/controller/test_images.py +++ b/tests/api/routes/controller/test_images.py @@ -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" \ No newline at end of file + 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) \ No newline at end of file diff --git a/tests/api/routes/controller/test_nodes.py b/tests/api/routes/controller/test_nodes.py index 4fd84d0ad..bded58728 100644 --- a/tests/api/routes/controller/test_nodes.py +++ b/tests/api/routes/controller/test_nodes.py @@ -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( diff --git a/tests/api/routes/controller/test_projects.py b/tests/api/routes/controller/test_projects.py index 3f45d0ba6..c5d32922a 100644 --- a/tests/api/routes/controller/test_projects.py +++ b/tests/api/routes/controller/test_projects.py @@ -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 diff --git a/tests/controller/test_controller.py b/tests/controller/test_controller.py index 741b2cbce..4f158c9b3 100644 --- a/tests/controller/test_controller.py +++ b/tests/controller/test_controller.py @@ -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() diff --git a/tests/controller/test_import_project.py b/tests/controller/test_import_project.py index e0cbfa1b1..0d5cff67f 100644 --- a/tests/controller/test_import_project.py +++ b/tests/controller/test_import_project.py @@ -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, diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index 5b5b823e9..31a4a2e7a 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -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): """ diff --git a/tests/controller/test_project_open.py b/tests/controller/test_project_open.py index 40ddd9ca1..073fa3877 100644 --- a/tests/controller/test_project_open.py +++ b/tests/controller/test_project_open.py @@ -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")