mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 20:40:13 +03:00
- 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
238 lines
8.6 KiB
Python
238 lines
8.6 KiB
Python
#!/usr/bin/env python
|
|
#
|
|
# Copyright (C) 2026 GNS3 Technologies Inc.
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
"""
|
|
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
|
|
|
|
|
|
# ── project_inventory ────────────────────────────────────────────────────
|
|
|
|
|
|
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 gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
|
|
build_nodes_inventory,
|
|
)
|
|
|
|
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_links_summary_resolves_names_and_ports():
|
|
"""
|
|
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.project_inventory import (
|
|
build_links_summary,
|
|
)
|
|
|
|
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):
|
|
"""
|
|
netmiko_device_type on the node wins over the device_type:<type> tag;
|
|
the tag stays as fallback when the field is missing.
|
|
"""
|
|
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
|
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
|
|
from gns3server.agent.gns3_copilot import gns3_client
|
|
|
|
class _FakeTopology:
|
|
def _run(self, project_id=None, jwt_token=None, url=None):
|
|
return {
|
|
"nodes": {
|
|
"SR1": {
|
|
"console_port": 5000,
|
|
"tags": ["device_type:cisco_ios_telnet"],
|
|
"netmiko_device_type": "nokia_srl",
|
|
},
|
|
"R1": {
|
|
"console_port": 5001,
|
|
"tags": ["device_type:cisco_ios_telnet", "platform:cisco_ios"],
|
|
"netmiko_device_type": None,
|
|
},
|
|
}
|
|
}
|
|
|
|
# the function does a lazy from-import inside the body
|
|
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
|
|
hosts = get_gns3_device_port.get_device_ports_from_topology(["SR1", "R1"])
|
|
|
|
# field wins over tag
|
|
assert hosts["SR1"]["connection_options"]["netmiko"]["extras"]["device_type"] == "nokia_srl"
|
|
# tag fallback when the field is absent
|
|
assert hosts["R1"]["connection_options"]["netmiko"]["extras"]["device_type"] == "cisco_ios_telnet"
|
|
assert hosts["R1"]["platform"] == "cisco_ios"
|
|
|
|
|
|
def test_device_ports_error_without_any_device_type(monkeypatch):
|
|
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
|
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
|
|
from gns3server.agent.gns3_copilot import gns3_client
|
|
|
|
class _FakeTopology:
|
|
def _run(self, project_id=None, jwt_token=None, url=None):
|
|
return {
|
|
"nodes": {
|
|
"R2": {
|
|
"console_port": 5002,
|
|
"tags": ["platform:cisco_ios"],
|
|
},
|
|
}
|
|
}
|
|
|
|
# the function does a lazy from-import inside the body
|
|
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
|
|
hosts = get_gns3_device_port.get_device_ports_from_topology(["R2"])
|
|
|
|
assert "error" in hosts["R2"]
|
|
assert "netmiko_device_type" in hosts["R2"]["error"]
|
|
|
|
|
|
def test_device_ports_inject_default_credentials(monkeypatch):
|
|
"""
|
|
Per-node default credentials become host-level nornir values (which
|
|
override the group's empty fallback); missing or cleared ("") values
|
|
keep inheriting from the group.
|
|
"""
|
|
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
|
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
|
|
from gns3server.agent.gns3_copilot import gns3_client
|
|
|
|
class _FakeTopology:
|
|
def _run(self, project_id=None, jwt_token=None, url=None):
|
|
return {
|
|
"nodes": {
|
|
"R1": {
|
|
"console_port": 5000,
|
|
"tags": [],
|
|
"netmiko_device_type": "cisco_ios_telnet",
|
|
"default_username": "admin",
|
|
"default_password": "admin123",
|
|
},
|
|
# credentials cleared via PUT arrive as empty strings
|
|
"R2": {
|
|
"console_port": 5001,
|
|
"tags": [],
|
|
"netmiko_device_type": "cisco_ios_telnet",
|
|
"default_username": "",
|
|
"default_password": "",
|
|
},
|
|
# never seeded
|
|
"R3": {
|
|
"console_port": 5002,
|
|
"tags": [],
|
|
"netmiko_device_type": "cisco_ios_telnet",
|
|
},
|
|
}
|
|
}
|
|
|
|
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
|
|
hosts = get_gns3_device_port.get_device_ports_from_topology(["R1", "R2", "R3"])
|
|
|
|
# set credentials land at host level
|
|
assert hosts["R1"]["username"] == "admin"
|
|
assert hosts["R1"]["password"] == "admin123"
|
|
# cleared ("") and absent credentials do not override the group fallback
|
|
assert "username" not in hosts["R2"]
|
|
assert "password" not in hosts["R2"]
|
|
assert "username" not in hosts["R3"]
|
|
assert "password" not in hosts["R3"]
|