mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-18 17:02:26 +03:00
Merge pull request #3003 from yueguobin/fix/mcp-device-config-remote-compute
Fix MCP device tools for nodes on remote computes
This commit is contained in:
commit
578dbdceeb
@ -67,7 +67,7 @@ class GNS3TopologyTool(BaseTool):
|
|||||||
Output: Dictionary with:
|
Output: Dictionary with:
|
||||||
- `project_id`, `name`, `status`: Project metadata
|
- `project_id`, `name`, `status`: Project metadata
|
||||||
- `nodes`: Dict of node details (node_id, name, ports, console_port,
|
- `nodes`: Dict of node details (node_id, name, ports, console_port,
|
||||||
type, etc.)
|
console_host, type, etc.)
|
||||||
- `links`: List of link connections
|
- `links`: List of link connections
|
||||||
|
|
||||||
Use this to understand network structure before making changes.
|
Use this to understand network structure before making changes.
|
||||||
|
|||||||
@ -43,9 +43,9 @@ def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None)
|
|||||||
Build an inventory-style dict keyed by node name.
|
Build an inventory-style dict keyed by node name.
|
||||||
|
|
||||||
Shape (per node name):
|
Shape (per node name):
|
||||||
{server, name, node_id, console_port, console_type, type, ports,
|
{server, name, node_id, console_port, console_host, console_type,
|
||||||
status, x, y, tags, netmiko_device_type, default_username,
|
type, ports, status, x, y, tags, netmiko_device_type,
|
||||||
default_password}
|
default_username, default_password}
|
||||||
"""
|
"""
|
||||||
inventory: dict[str, Any] = {}
|
inventory: dict[str, Any] = {}
|
||||||
for n in nodes:
|
for n in nodes:
|
||||||
@ -54,6 +54,9 @@ def build_nodes_inventory(nodes: list[dict[str, Any]], server_host: str | None)
|
|||||||
"name": n.get("name"),
|
"name": n.get("name"),
|
||||||
"node_id": n.get("node_id"),
|
"node_id": n.get("node_id"),
|
||||||
"console_port": n.get("console"),
|
"console_port": n.get("console"),
|
||||||
|
# Where the console port is bound: the compute's own host for
|
||||||
|
# remote computes, not the controller running this code.
|
||||||
|
"console_host": n.get("console_host"),
|
||||||
"console_type": n.get("console_type"),
|
"console_type": n.get("console_type"),
|
||||||
"type": n.get("node_type"),
|
"type": n.get("node_type"),
|
||||||
"ports": n.get("ports"),
|
"ports": n.get("ports"),
|
||||||
|
|||||||
@ -451,7 +451,7 @@ class VPCSCommands(BaseTool):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# VPCS devices use gns3_vpcs_telnet device type
|
# VPCS devices use gns3_vpcs_telnet device type
|
||||||
hosts_data[device_name] = {
|
host_entry = {
|
||||||
"port": port,
|
"port": port,
|
||||||
"platform": "vpcs",
|
"platform": "vpcs",
|
||||||
"groups": ["vpcs_devices"], # All VPCS devices share one group
|
"groups": ["vpcs_devices"], # All VPCS devices share one group
|
||||||
@ -465,6 +465,12 @@ class VPCSCommands(BaseTool):
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
# Remote computes bind console ports on their own host; carry the
|
||||||
|
# per-node hostname from the topology so it overrides the group's
|
||||||
|
# controller-wide default.
|
||||||
|
if "hostname" in device_ports[device_name]:
|
||||||
|
host_entry["hostname"] = device_ports[device_name]["hostname"]
|
||||||
|
hosts_data[device_name] = host_entry
|
||||||
|
|
||||||
return hosts_data
|
return hosts_data
|
||||||
|
|
||||||
|
|||||||
@ -68,7 +68,12 @@ def get_device_ports_from_topology(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Devices that don't exist or missing console_port will not be included
|
Devices that don't exist or missing console_port will not be included.
|
||||||
|
Nodes whose console is bound on a remote compute also carry a
|
||||||
|
host-level "hostname" (copied from the topology's console_host) so
|
||||||
|
netmiko dials the compute hosting the console port instead of the
|
||||||
|
controller; for everything else the field is omitted and the shared
|
||||||
|
group hostname (the controller host) applies.
|
||||||
"""
|
"""
|
||||||
# Log received parameters
|
# Log received parameters
|
||||||
logger.info("Called with device_names=%s, project_id=%s", device_names, project_id)
|
logger.info("Called with device_names=%s, project_id=%s", device_names, project_id)
|
||||||
@ -167,6 +172,15 @@ def get_device_ports_from_topology(
|
|||||||
"connection_options": {"netmiko": {"extras": {"device_type": device_type}}},
|
"connection_options": {"netmiko": {"extras": {"device_type": device_type}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Per-node console hostname. Remote computes bind console ports
|
||||||
|
# on their own host, not on the controller running this code, so
|
||||||
|
# the group's controller-wide hostname cannot reach them. A
|
||||||
|
# host-level hostname (which wins over the group in nornir) fixes
|
||||||
|
# that; unset or wildcard values keep the group inheritance.
|
||||||
|
console_host = node_info.get("console_host")
|
||||||
|
if console_host and console_host not in ("0.0.0.0", "::"):
|
||||||
|
host_entry["hostname"] = console_host
|
||||||
|
|
||||||
# Per-node default credentials (seeded from the template appliance
|
# Per-node default credentials (seeded from the template appliance
|
||||||
# metadata) override the group's empty fallback. Only inject when
|
# metadata) override the group's empty fallback. Only inject when
|
||||||
# set, so credential-less devices keep inheriting the group values.
|
# set, so credential-less devices keep inheriting the group values.
|
||||||
|
|||||||
@ -130,6 +130,36 @@ class TestVPCSNodeTypeGuard:
|
|||||||
assert "output" not in result[0]
|
assert "output" not in result[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestVPCSConsoleHostPropagation:
|
||||||
|
"""
|
||||||
|
VPCS builds its own nornir host entries, so the per-node console
|
||||||
|
hostname resolved by get_device_ports_from_topology must be carried
|
||||||
|
over instead of silently falling back to the controller host.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_remote_compute_hostname_reaches_nornir_inventory(self):
|
||||||
|
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
f"{VPCS_MOD}.get_device_ports_from_topology",
|
||||||
|
return_value={
|
||||||
|
"PC1": {"port": 5000, "node_type": "vpcs", "hostname": "192.168.122.10"},
|
||||||
|
"PC2": {"port": 5001, "node_type": "vpcs"},
|
||||||
|
},
|
||||||
|
):
|
||||||
|
hosts_data = VPCSCommands()._prepare_device_hosts_data(
|
||||||
|
[
|
||||||
|
{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]},
|
||||||
|
{"device_name": "PC2", "commands": ["ip 10.0.0.2/24"]},
|
||||||
|
],
|
||||||
|
PROJECT_ID,
|
||||||
|
)
|
||||||
|
# remote-compute console hostname lands at host level
|
||||||
|
assert hosts_data["PC1"]["hostname"] == "192.168.122.10"
|
||||||
|
# local-compute entries keep inheriting the group hostname
|
||||||
|
assert "hostname" not in hosts_data["PC2"]
|
||||||
|
|
||||||
|
|
||||||
class TestDeviceToolErrorContract:
|
class TestDeviceToolErrorContract:
|
||||||
"""
|
"""
|
||||||
Every in-band error entry carries status "failed" plus an "error"
|
Every in-band error entry carries status "failed" plus an "error"
|
||||||
|
|||||||
@ -64,6 +64,35 @@ def test_nodes_inventory_emits_default_credentials():
|
|||||||
assert inventory["R1"]["tags"] == []
|
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():
|
def test_links_summary_resolves_names_and_ports():
|
||||||
"""
|
"""
|
||||||
links_summary maps raw link endpoint lists to
|
links_summary maps raw link endpoint lists to
|
||||||
@ -234,3 +263,60 @@ def test_device_ports_inject_default_credentials(monkeypatch):
|
|||||||
assert "password" not in hosts["R2"]
|
assert "password" not in hosts["R2"]
|
||||||
assert "username" not in hosts["R3"]
|
assert "username" not in hosts["R3"]
|
||||||
assert "password" 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"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user