From 629bb9194f9fba014d8313d7ad0f98d1cf524165 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 09:15:32 +0800 Subject: [PATCH] refactor: sink shared REST handlers into gns3_client, drop gns3fy wrappers - Slim custom_gns3fy.py to connector-only Gns3Connector and rename to connector.py; delete the unused Node/Link/Project dataclasses and endpoint wrapper methods (~3000 lines) - Move the MCP node/link handler implementations into gns3_client/api_handlers.py as the shared REST client layer consumed by both the MCP service and copilot tools; add available_filters handler (exposed as link_available_filters MCP tool) and build_gns3_ctx() for copilot callers - Rewrite the tools_v2 node/link tools on top of the handlers: batch lifecycle actions now run in parallel, node creation is a single POST, project-wide status reads replace per-node GETs - Port Project.nodes_inventory/links_summary aggregation into project_inventory.py (output shape preserved) and rewrite the topology reader / project info tools on it, dropping the unused stats/snapshots/drawings calls - Delete the dead mcp/nodes.py and mcp/links.py (NODE_TOOLS/LINK_TOOLS had no consumers; __init__ imports handlers from api_handlers) - Retarget mcp handler tests to patch api_handlers._get_connector and replace test_custom_gns3fy.py with inventory contract tests --- .claude/memory/mcp-service-design.md | 15 +- docs/features/mcp-service.md | 2 +- docs/features/project-open-performance.md | 6 +- .../implemented/node-control-tools.md | 40 +- .../gns3_copilot/gns3_client/__init__.py | 38 +- .../gns3_copilot/gns3_client/api_handlers.py | 990 +++++ .../gns3_copilot/gns3_client/connector.py | 309 ++ .../gns3_client/connector_factory.py | 2 +- .../gns3_copilot/gns3_client/custom_gns3fy.py | 3341 ----------------- .../gns3_client/gns3_project_info.py | 40 +- .../gns3_client/gns3_topology_reader.py | 27 +- .../gns3_client/project_inventory.py | 148 + .../gns3_copilot/tools_v2/gns3_create_link.py | 96 +- .../gns3_copilot/tools_v2/gns3_create_node.py | 40 +- .../tools_v2/gns3_get_node_temp.py | 4 +- .../tools_v2/gns3_packet_filter.py | 138 +- .../gns3_copilot/tools_v2/gns3_start_node.py | 222 +- .../gns3_copilot/tools_v2/gns3_stop_node.py | 109 +- .../tools_v2/gns3_suspend_node.py | 111 +- .../tools_v2/gns3_update_node_name.py | 51 +- gns3server/agent/mcp/__init__.py | 21 +- gns3server/agent/mcp/appliances.py | 2 +- gns3server/agent/mcp/computes.py | 2 +- gns3server/agent/mcp/drawings.py | 2 +- gns3server/agent/mcp/images.py | 2 +- gns3server/agent/mcp/links.py | 633 ---- gns3server/agent/mcp/nodes.py | 678 ---- gns3server/agent/mcp/projects.py | 4 +- gns3server/agent/mcp/server.py | 2 +- gns3server/agent/mcp/snapshots.py | 2 +- gns3server/agent/mcp/symbols.py | 2 +- gns3server/agent/mcp/templates.py | 4 +- tests/agent/mcp/test_handlers.py | 123 +- tests/agent/mcp/test_tool_params.py | 70 +- ...t_custom_gns3fy.py => test_gns3_client.py} | 202 +- 35 files changed, 2180 insertions(+), 5298 deletions(-) create mode 100644 gns3server/agent/gns3_copilot/gns3_client/api_handlers.py create mode 100644 gns3server/agent/gns3_copilot/gns3_client/connector.py delete mode 100644 gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py create mode 100644 gns3server/agent/gns3_copilot/gns3_client/project_inventory.py delete mode 100644 gns3server/agent/mcp/links.py delete mode 100644 gns3server/agent/mcp/nodes.py rename tests/agent/{test_custom_gns3fy.py => test_gns3_client.py} (58%) 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..e1f45fc3e 100644 --- a/docs/features/mcp-service.md +++ b/docs/features/mcp-service.md @@ -440,7 +440,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..dc674ccb8 --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py @@ -0,0 +1,990 @@ +# 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, as_completed + +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") + 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)} + + +# ── 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"} + 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, link): link for link 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} 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/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/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 47438b9c8..538f0d6e7 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -84,7 +84,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 +95,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, @@ -676,7 +675,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( diff --git a/gns3server/agent/mcp/appliances.py b/gns3server/agent/mcp/appliances.py index a2bc9dd0f..dd2be1735 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"], diff --git a/gns3server/agent/mcp/computes.py b/gns3server/agent/mcp/computes.py index 29072ea04..e3abb65bb 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"], 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..3b8169a71 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"], 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..24221db5c 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"], 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/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index d9c3490c0..42738dc37 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -16,6 +16,7 @@ def _mock_conn(json_result=None): BASE = "gns3server.agent.mcp" +AH = "gns3server.agent.gns3_copilot.gns3_client.api_handlers" # node/link handlers sunk here @pytest.fixture @@ -110,11 +111,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 +122,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 +151,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 +161,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 +173,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", @@ -183,40 +183,40 @@ class TestNode: assert result[0]["status"] == "success" 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 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 @@ -227,25 +227,24 @@ class TestNode: 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 +260,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 +273,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 +286,15 @@ 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 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, @@ -381,11 +380,10 @@ class TestTemplate: 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 +396,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 +410,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 +424,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( @@ -440,8 +438,8 @@ class TestLinkMarker: ) 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 +460,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 +476,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 +490,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 +504,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 +518,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..281293c56 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 = { @@ -34,37 +37,38 @@ HANDLER_FILES = { "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 +113,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