mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-04 14:31:13 +02:00
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
This commit is contained in:
35 files changed
+2180
-5298
No files matched your search
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -16,79 +16,113 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
The vendored gns3fy copy keeps its node/console type lists as literals
|
||||
(it is shared with the standalone MCP service and cannot import server
|
||||
enums). These tests fail when the server enums grow a value the vendored
|
||||
lists have not picked up — exactly what happened with "docker_exec": one
|
||||
vendor node failing validation made the copilot's topology reader drop
|
||||
the whole project.
|
||||
Tests for the shared GNS3 REST client layer (gns3_copilot.gns3_client):
|
||||
|
||||
- project_inventory: the nodes/links aggregation feeding the topology
|
||||
context and the Nornir inventory — its output shape is consumer-visible
|
||||
and must stay field-for-field stable
|
||||
- get_gns3_device_port: netmiko device_type resolution and per-node
|
||||
credentials over the topology inventory
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_console_types_cover_server_enum():
|
||||
# ── project_inventory ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_nodes_inventory_emits_default_credentials():
|
||||
"""
|
||||
Every server ConsoleType value must be accepted by the vendored Node model.
|
||||
The inventory dict consumed by get_device_ports_from_topology must
|
||||
carry the per-node default credentials.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import CONSOLE_TYPES
|
||||
from gns3server.schemas.common import ConsoleType
|
||||
|
||||
missing = {e.value for e in ConsoleType} - set(CONSOLE_TYPES)
|
||||
assert not missing, f"CONSOLE_TYPES drifted from ConsoleType, missing: {missing}"
|
||||
|
||||
|
||||
def test_node_types_cover_server_enum():
|
||||
"""
|
||||
Every server NodeType value must be accepted by the vendored Node model.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import NODE_TYPES
|
||||
from gns3server.schemas.controller.nodes import NodeType
|
||||
|
||||
missing = {e.value for e in NodeType} - set(NODE_TYPES)
|
||||
assert not missing, f"NODE_TYPES drifted from NodeType, missing: {missing}"
|
||||
|
||||
|
||||
def test_node_accepts_docker_exec_console():
|
||||
"""
|
||||
Vendor NOS nodes use console_type "docker_exec"; the topology reader
|
||||
validates the whole node list in one pass, so rejecting it poisoned
|
||||
every copilot device tool for the project.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
|
||||
|
||||
node = Node(
|
||||
name="R1",
|
||||
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
|
||||
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
node_type="docker",
|
||||
console_type="docker_exec",
|
||||
status="started",
|
||||
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
|
||||
build_nodes_inventory,
|
||||
)
|
||||
assert node.console_type == "docker_exec"
|
||||
|
||||
nodes = [
|
||||
{
|
||||
"name": "R1",
|
||||
"node_id": "0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
"node_type": "dynamips",
|
||||
"console": 5000,
|
||||
"console_type": "telnet",
|
||||
"status": "started",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"default_username": "admin",
|
||||
"default_password": "admin123",
|
||||
},
|
||||
]
|
||||
|
||||
inventory = build_nodes_inventory(nodes, "127.0.0.1")
|
||||
assert inventory["R1"]["default_username"] == "admin"
|
||||
assert inventory["R1"]["default_password"] == "admin123"
|
||||
assert inventory["R1"]["console_port"] == 5000
|
||||
assert inventory["R1"]["type"] == "dynamips"
|
||||
assert inventory["R1"]["server"] == "127.0.0.1"
|
||||
assert inventory["R1"]["tags"] == []
|
||||
|
||||
|
||||
def test_node_accepts_netmiko_device_type():
|
||||
def test_links_summary_resolves_names_and_ports():
|
||||
"""
|
||||
The vendored Node model must keep the netmiko_device_type field so the
|
||||
device-port tools can prefer it over the device_type:<type> tag.
|
||||
links_summary maps raw link endpoint lists to
|
||||
{link_id, node_a, port_a, node_b, port_b} using port/adapter numbers.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
|
||||
|
||||
node = Node(
|
||||
name="SR1",
|
||||
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
|
||||
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
node_type="docker",
|
||||
console_type="docker_exec",
|
||||
status="started",
|
||||
netmiko_device_type="nokia_srl",
|
||||
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
|
||||
build_links_summary,
|
||||
)
|
||||
assert node.netmiko_device_type == "nokia_srl"
|
||||
|
||||
nodes = [
|
||||
{
|
||||
"name": "R1",
|
||||
"node_id": "n1",
|
||||
"ports": [
|
||||
{"name": "GigabitEthernet0/0", "port_number": 0, "adapter_number": 0},
|
||||
{"name": "GigabitEthernet0/1", "port_number": 1, "adapter_number": 0},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "R2",
|
||||
"node_id": "n2",
|
||||
"ports": [
|
||||
{"name": "Ethernet0", "port_number": 0, "adapter_number": 0},
|
||||
],
|
||||
},
|
||||
]
|
||||
links = [
|
||||
{
|
||||
"link_id": "l1",
|
||||
"nodes": [
|
||||
{"node_id": "n1", "port_number": 0, "adapter_number": 0},
|
||||
{"node_id": "n2", "port_number": 0, "adapter_number": 0},
|
||||
],
|
||||
},
|
||||
# endpoint not resolvable → skipped, not an error
|
||||
{
|
||||
"link_id": "l2",
|
||||
"nodes": [
|
||||
{"node_id": "missing", "port_number": 0, "adapter_number": 0},
|
||||
{"node_id": "n2", "port_number": 0, "adapter_number": 0},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
summary = build_links_summary(nodes, links)
|
||||
assert summary == [
|
||||
{
|
||||
"link_id": "l1",
|
||||
"node_a": "R1",
|
||||
"port_a": "GigabitEthernet0/0",
|
||||
"node_b": "R2",
|
||||
"port_b": "Ethernet0",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ── get_gns3_device_port (over the topology inventory) ──────────────────
|
||||
|
||||
|
||||
def test_device_ports_prefer_netmiko_field_over_tag(monkeypatch):
|
||||
@@ -152,58 +186,6 @@ def test_device_ports_error_without_any_device_type(monkeypatch):
|
||||
assert "netmiko_device_type" in hosts["R2"]["error"]
|
||||
|
||||
|
||||
def test_node_accepts_default_credentials():
|
||||
"""
|
||||
The vendored Node model must keep the default credentials so the
|
||||
device-port tools can log into devices that require authentication.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
|
||||
|
||||
node = Node(
|
||||
name="R1",
|
||||
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
|
||||
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
node_type="docker",
|
||||
console_type="telnet",
|
||||
status="started",
|
||||
default_username="admin",
|
||||
default_password="admin123",
|
||||
)
|
||||
assert node.default_username == "admin"
|
||||
assert node.default_password == "admin123"
|
||||
|
||||
|
||||
def test_nodes_inventory_emits_default_credentials():
|
||||
"""
|
||||
The inventory dict consumed by get_device_ports_from_topology must
|
||||
carry the per-node default credentials.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from types import SimpleNamespace
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node, Project
|
||||
|
||||
project = Project(
|
||||
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
|
||||
connector=SimpleNamespace(base_url="http://127.0.0.1:3080"),
|
||||
)
|
||||
project.nodes = [
|
||||
Node(
|
||||
name="R1",
|
||||
project_id=project.project_id,
|
||||
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
node_type="dynamips",
|
||||
console=5000,
|
||||
default_username="admin",
|
||||
default_password="admin123",
|
||||
),
|
||||
]
|
||||
|
||||
inventory = project.nodes_inventory()
|
||||
assert inventory["R1"]["default_username"] == "admin"
|
||||
assert inventory["R1"]["default_password"] == "admin123"
|
||||
|
||||
|
||||
def test_device_ports_inject_default_credentials(monkeypatch):
|
||||
"""
|
||||
Per-node default credentials become host-level nornir values (which
|
||||
Reference in new issue
Block a user