mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-18 17:02:26 +03:00
The device tools (device_config_send / device_show_run / vpcs_config_set) built their Nornir inventory with only the console port and inherited the group hostname, which is always the controller host. Console ports of nodes on remote computes are bound on the compute's own host, so those nodes were unreachable from the MCP tools - only local-compute nodes worked. Pass the per-node console_host through the project inventory and inject it as a host-level Nornir hostname (which overrides the group's controller-wide default). Missing or wildcard (0.0.0.0/::) values keep the group inheritance, so single-machine setups behave exactly as before. VPCS builds its own host entries, so it carries the hostname over explicitly.
323 lines
12 KiB
Python
323 lines
12 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_nodes_inventory_carries_console_host():
|
|
"""
|
|
The inventory must keep the per-node console_host: remote computes bind
|
|
console ports on their own host, and the Nornir inventory needs it to
|
|
dial devices outside the controller machine.
|
|
"""
|
|
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_host": "192.168.122.10",
|
|
"console_type": "telnet",
|
|
"status": "started",
|
|
},
|
|
]
|
|
|
|
inventory = build_nodes_inventory(nodes, "127.0.0.1")
|
|
assert inventory["R1"]["console_host"] == "192.168.122.10"
|
|
# the controller-wide "server" stays for backward compatibility
|
|
assert inventory["R1"]["server"] == "127.0.0.1"
|
|
|
|
|
|
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 import gns3_client
|
|
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
|
|
|
|
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 import gns3_client
|
|
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
|
|
|
|
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 import gns3_client
|
|
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
|
|
|
|
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"]
|
|
|
|
|
|
def test_device_ports_carry_per_node_console_host(monkeypatch):
|
|
"""
|
|
Remote computes bind console ports on their own host: console_host from
|
|
the topology becomes a host-level nornir hostname (overriding the
|
|
group's controller-wide default). Missing or wildcard values keep the
|
|
group inheritance.
|
|
"""
|
|
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
|
from gns3server.agent.gns3_copilot import gns3_client
|
|
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
|
|
|
|
class _FakeTopology:
|
|
def _run(self, project_id=None, jwt_token=None, url=None):
|
|
return {
|
|
"nodes": {
|
|
# node on a remote compute (another GNS3 VM)
|
|
"R1": {
|
|
"console_port": 5000,
|
|
"console_host": "192.168.122.10",
|
|
"tags": [],
|
|
"netmiko_device_type": "cisco_ios_telnet",
|
|
},
|
|
# node on the local compute
|
|
"R2": {
|
|
"console_port": 5001,
|
|
"console_host": "127.0.0.1",
|
|
"tags": [],
|
|
"netmiko_device_type": "cisco_ios_telnet",
|
|
},
|
|
# legacy topology payload without console_host
|
|
"R3": {
|
|
"console_port": 5002,
|
|
"tags": [],
|
|
"netmiko_device_type": "cisco_ios_telnet",
|
|
},
|
|
# wildcard host cannot be dialed; keep group inheritance
|
|
"R4": {
|
|
"console_port": 5003,
|
|
"console_host": "0.0.0.0",
|
|
"tags": [],
|
|
"netmiko_device_type": "cisco_ios_telnet",
|
|
},
|
|
}
|
|
}
|
|
|
|
# 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(["R1", "R2", "R3", "R4"])
|
|
|
|
# remote and local consoles dial their own host
|
|
assert hosts["R1"]["hostname"] == "192.168.122.10"
|
|
assert hosts["R2"]["hostname"] == "127.0.0.1"
|
|
# missing or wildcard console_host keeps inheriting the group hostname
|
|
assert "hostname" not in hosts["R3"]
|
|
assert "hostname" not in hosts["R4"]
|