From 435aa4c25707b4ba737fa7405b10a4ab5bcd400a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 16 Aug 2026 13:51:49 +0800 Subject: [PATCH] copilot: prefer netmiko_device_type over the device_type: tag The vendored gns3fy Node model and its nodes_inventory() now carry the node's netmiko_device_type field, and get_device_ports_from_topology() resolves the Netmiko device type from it first, falling back to the device_type: tag. Nodes created from a template inherit the value from the template automatically, so automation tooling gets the correct Netmiko driver without tags. --- .../gns3_copilot/gns3_client/custom_gns3fy.py | 9 ++- .../utils/get_gns3_device_port.py | 20 +++-- tests/agent/test_custom_gns3fy.py | 81 +++++++++++++++++++ 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py index 582e7100b..83a5cc191 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py +++ b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py @@ -535,6 +535,8 @@ class Gns3Connector: - `tags` (list): List of tags for the template (e.g., ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) + - `netmiko_device_type` (str): Netmiko device type (e.g. "cisco_ios_telnet"); + preferred over the device_type: tag - Any other template attributes supported by GNS3 API """ # Get existing template @@ -571,6 +573,8 @@ class Gns3Connector: - `tags` (list): List of tags for the template (e.g., ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) + - `netmiko_device_type` (str): Netmiko device type (e.g. "cisco_ios_telnet"); + preferred over the device_type: tag - Any other template attributes supported by GNS3 API **Example:** @@ -579,7 +583,8 @@ class Gns3Connector: >>> connector.create_template( ... name="cisco_router", ... template_type="dynamips", - ... tags=["device_type:cisco_ios_telnet", "platform:cisco_ios"] + ... tags=["device_type:cisco_ios_telnet", "platform:cisco_ios"], + ... netmiko_device_type="cisco_ios_telnet" ... ) ``` """ @@ -1366,6 +1371,7 @@ class Node: template_id: str | None = None properties: Any | None = None tags: list[str] | None = None + netmiko_device_type: str | None = None template: str | None = None links: list[Link] = field(default_factory=list, repr=False) @@ -2482,6 +2488,7 @@ class Project: "x": _n.x, "y": _n.y, "tags": _n.tags if _n.tags else [], + "netmiko_device_type": _n.netmiko_device_type, } } ) diff --git a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py index 597fd9f64..fe7401770 100644 --- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -62,7 +62,7 @@ def get_device_ports_from_topology( "groups": ["network_devices"], # For inheriting shared settings "connection_options": { "netmiko": { - "extras": {"device_type": "huawei_telnet"} # Extracted from tags + "extras": {"device_type": "huawei_telnet"} # netmiko_device_type field, tag fallback } } } @@ -102,18 +102,21 @@ def get_device_ports_from_topology( logger.warning("Device '%s' missing console_port", device_name) continue - # Extract device_type and platform from tags - device_type = None + # Extract device_type and platform. + # Precedence: the netmiko_device_type field (node/template/appliance + # level, set in GNS3 server >= 3.x) wins over the device_type: + # tag, which remains as a fallback. + device_type = node_info.get("netmiko_device_type") platform = None tags = node_info.get("tags", []) for tag in tags: - if tag.startswith("device_type:"): + if tag.startswith("device_type:") and device_type is None: device_type = tag.split(":", 1)[1].strip() elif tag.startswith("platform:"): platform = tag.split(":", 1)[1].strip() - # Return error if device_type not found in tags + # Return error if device_type not found anywhere # Using a default would cause command execution errors if device_type is None: tested_device_types = ( @@ -121,8 +124,9 @@ def get_device_ports_from_topology( "gns3_ruijie_telnet (custom Ruijie)" ) error_msg = ( - f"Device '{device_name}': device_type tag not found. " - f"Please add 'device_type:' tag to this device in GNS3. " + f"Device '{device_name}': no device type found. " + f"Set the template/node 'netmiko_device_type' field (e.g. 'cisco_ios_telnet'), " + f"or add a 'device_type:' tag to this device in GNS3. " f"To configure via Web UI: right-click the device -> Configure -> Tags -> add 'device_type:'. " f"Tested types: {tested_device_types}. " f"Current tags: {tags}" @@ -134,7 +138,7 @@ def get_device_ports_from_topology( continue logger.debug( - "Device '%s': extracted device_type=%s from tags", + "Device '%s': device_type=%s", device_name, device_type, ) diff --git a/tests/agent/test_custom_gns3fy.py b/tests/agent/test_custom_gns3fy.py index a07266432..29aec0592 100644 --- a/tests/agent/test_custom_gns3fy.py +++ b/tests/agent/test_custom_gns3fy.py @@ -69,3 +69,84 @@ def test_node_accepts_docker_exec_console(): status="started", ) assert node.console_type == "docker_exec" + + +def test_node_accepts_netmiko_device_type(): + """ + The vendored Node model must keep the netmiko_device_type field so the + device-port tools can prefer it over the device_type: tag. + """ + 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", + ) + assert node.netmiko_device_type == "nokia_srl" + + +def test_device_ports_prefer_netmiko_field_over_tag(monkeypatch): + """ + netmiko_device_type on the node wins over the device_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"]