copilot: log into devices using the node default credentials

The vendored gns3fy Node model dropped default_username/default_password
from the API response, and the nornir groups hardcoded empty
credentials, so drivers that require authentication (gns3_ruijie_telnet,
stock netmiko SSH/telnet) could not log in.

Carry the per-node credentials through nodes_inventory() into the
nornir hosts data at host level, where they override the group's empty
fallback. Missing or cleared ("") values keep inheriting from the
group, so no-auth drivers are unaffected.
This commit is contained in:
YueGuobin 2026-08-17 21:44:50 +08:00
parent 38742ad4b6
commit 19f20e8d75
No known key found for this signature in database
3 changed files with 118 additions and 1 deletions

View File

@ -1372,6 +1372,8 @@ class Node:
properties: Any | None = None
tags: list[str] | None = None
netmiko_device_type: str | None = None
default_username: str | None = None
default_password: str | None = None
template: str | None = None
links: list[Link] = field(default_factory=list, repr=False)
@ -2489,6 +2491,8 @@ class Project:
"y": _n.y,
"tags": _n.tags if _n.tags else [],
"netmiko_device_type": _n.netmiko_device_type,
"default_username": _n.default_username,
"default_password": _n.default_password,
}
}
)

View File

@ -160,7 +160,7 @@ def get_device_ports_from_topology(
# This is the Nornir best practice - each host has its own
# connection configuration (device_type), while sharing common
# settings (hostname, timeout) via group inheritance.
hosts_data[device_name] = {
host_entry = {
"port": node_info["console_port"],
"platform": platform,
"groups": ["network_devices"], # For inheriting hostname, timeout, etc.
@ -171,6 +171,16 @@ def get_device_ports_from_topology(
},
}
# Per-node default credentials (seeded from the template appliance
# metadata) override the group's empty fallback. Only inject when
# set, so credential-less devices keep inheriting the group values.
if node_info.get("default_username"):
host_entry["username"] = node_info["default_username"]
if node_info.get("default_password"):
host_entry["password"] = node_info["default_password"]
hosts_data[device_name] = host_entry
logger.info("Returning %d device port mappings", len(hosts_data))
return hosts_data

View File

@ -150,3 +150,106 @@ def test_device_ports_error_without_any_device_type(monkeypatch):
assert "error" in hosts["R2"]
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
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"]