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:
YueGuobin committed 2026-08-25 09:15:32 +08:00
1 parent 1649fb7b7a
commit 629bb9194f
35 files changed
+2180 -5298

No files matched your search

+60 -63
View File
@@ -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,
)
+37 -33
View File
@@ -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