api: expose installed netmiko device types for the web UI

New GET /v3/netmiko/device_types endpoint returns the device types
supported by the netmiko library installed on the server, including
the gns3-copilot custom drivers, so the web UI can populate the
netmiko_device_type dropdown on templates and nodes.

The list is read at runtime from netmiko's ssh_dispatcher.CLASS_MAPPER
registry (the same table ConnectHandler dispatches on), filtered to
drop the '_ssh' aliases and the 'autodetect' pseudo type, and cached
for the process lifetime. Returns 501 when netmiko is not installed
(ai-features extra).
This commit is contained in:
YueGuobin 2026-08-17 21:24:47 +08:00
parent 92d4e3d378
commit 7e8c515a3c
No known key found for this signature in database
8 changed files with 269 additions and 48 deletions

View File

@ -1,32 +1,32 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
<!DOCTYPE html>
<html>
<head>
<title>GNS3 controller API - ReDoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
<link rel="shortcut icon" href="https://fastapi.tiangolo.com/img/favicon.png">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
<title>GNS3 controller API - Swagger UI</title>
</head>
<body>
<redoc spec-url="openapi.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
<div id="swagger-ui">
</div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<!-- `SwaggerUIBundle` is now available on the page -->
<script>
const ui = SwaggerUIBundle({
url: 'openapi.json',
"dom_id": "#swagger-ui",
"layout": "BaseLayout",
"deepLinking": true,
"showExtensions": true,
"showCommonExtensions": true,
oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
})
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -1,35 +1,31 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
<!DOCTYPE html>
<html>
<head>
<link type="text/css" rel="stylesheet" href="/static/swagger-ui.css">
<title>GNS3 controller API - ReDoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<link rel="shortcut icon" href="https://fastapi.tiangolo.com/img/favicon.png">
<title>GNS3 controller API - Swagger UI</title>
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div id="swagger-ui">
</div>
<script src="/static/swagger-ui-bundle.js"></script>
<!-- `SwaggerUIBundle` is now available on the page -->
<script>
const ui = SwaggerUIBundle({
url: 'openapi.json',
oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout",
deepLinking: true,
showExtensions: true,
showCommonExtensions: true
})
</script>
<noscript>
ReDoc requires Javascript to function. Please enable it to browse the documentation.
</noscript>
<redoc spec-url="openapi.json"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"> </script>
</body>
</html>

View File

@ -61,6 +61,7 @@ from . import acl
from . import pools
from . import privileges
from . import api_keys
from . import netmiko
from .dependencies.authentication import get_current_active_user
@ -159,6 +160,12 @@ router.include_router(
tags=["Appliances"]
)
router.include_router(
netmiko.router,
prefix="/netmiko",
tags=["Netmiko"]
)
router.include_router(
pools.router,
prefix="/pools",

View File

@ -0,0 +1,102 @@
#!/usr/bin/env python
#
# Copyright (C) 2020 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/>.
"""
API routes for Netmiko metadata.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from gns3server import schemas
from .dependencies.authentication import get_current_active_user
import logging
log = logging.getLogger(__name__)
router = APIRouter()
# Computed once per process: the list only changes if the installed
# Netmiko library changes, which requires a server restart anyway.
_device_types_cache: Optional[schemas.NetmikoDeviceTypeList] = None
def _load_netmiko_device_types() -> schemas.NetmikoDeviceTypeList:
"""
Build the list of device types supported by the installed Netmiko library.
Imports Netmiko and the GNS3-copilot custom drivers (which register
additional 'gns3_*' device types into Netmiko's CLASS_MAPPER on import),
then filters out the '<type>_ssh' aliases and the 'autodetect'
pseudo device type.
Raises:
ImportError: If Netmiko is not installed (ai-features extra).
"""
import importlib
import netmiko
# "from netmiko import ssh_dispatcher" is shadowed by a function of the same
# name in netmiko's __init__, so import the module through importlib
sd = importlib.import_module("netmiko.ssh_dispatcher")
# Importing the package auto-registers all custom drivers (in case nothing
# imported them yet); failures are logged by the package itself, do not
# fail the whole endpoint.
try:
from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401
except Exception as e:
log.warning(f"Could not register GNS3-copilot custom Netmiko drivers: {e}")
# Custom drivers all use the 'gns3_' prefix by convention, which is more
# reliable than diffing CLASS_MAPPER around the import: the drivers may
# already be registered when the copilot package got imported at startup.
device_types = [
schemas.NetmikoDeviceType(name=name, telnet="_telnet" in name, custom=name.startswith("gns3_"))
for name in sorted(sd.CLASS_MAPPER.keys())
if not name.endswith("_ssh") and name != "autodetect"
]
return schemas.NetmikoDeviceTypeList(netmiko_version=netmiko.__version__, device_types=device_types)
@router.get(
"/device_types",
response_model=schemas.NetmikoDeviceTypeList,
dependencies=[Depends(get_current_active_user)]
)
def get_netmiko_device_types() -> schemas.NetmikoDeviceTypeList:
"""
Return the device types supported by the Netmiko library installed on this server.
Required privilege: None (authenticated users only)
"""
global _device_types_cache
if _device_types_cache is None:
try:
_device_types_cache = _load_netmiko_device_types()
except ImportError:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Netmiko is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
)
return _device_types_cache

View File

@ -61,6 +61,7 @@ from .controller.tokens import Token, ApiKeyCreate, RefreshTokenRequest
from .controller.snapshots import SnapshotCreate, Snapshot
from .controller.iou_license import IOULicense
from .controller.capabilities import Capabilities
from .controller.netmiko import NetmikoDeviceType, NetmikoDeviceTypeList
# Controller template schemas
from .controller.templates.vpcs_templates import VPCSTemplate, VPCSTemplateUpdate

View File

@ -0,0 +1,38 @@
#
# Copyright (C) 2020 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/>.
from pydantic import BaseModel, Field
from typing import List
class NetmikoDeviceType(BaseModel):
"""
A Netmiko device type supported by the installed Netmiko library.
"""
name: str = Field(..., description="Device type name to store in the netmiko_device_type field")
telnet: bool = Field(False, description="Whether the device type connects over Telnet")
custom: bool = Field(False, description="Whether the device type is a GNS3-copilot custom driver (gns3_ prefix)")
class NetmikoDeviceTypeList(BaseModel):
"""
List of Netmiko device types supported by the installed Netmiko library.
"""
netmiko_version: str = Field(..., description="Version of the installed Netmiko library")
device_types: List[NetmikoDeviceType] = Field(..., description="Supported device types, sorted by name")

View File

@ -0,0 +1,77 @@
#
# Copyright (C) 2020 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/>.
import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
class TestNetmikoRoutes:
async def test_device_types(self, app: FastAPI, client: AsyncClient) -> None:
"""
Test listing the device types supported by the installed Netmiko library.
"""
pytest.importorskip("netmiko", reason="netmiko is not installed")
import netmiko
response = await client.get(app.url_path_for("get_netmiko_device_types"))
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["netmiko_version"] == netmiko.__version__
device_types = data["device_types"]
assert len(device_types) > 0
names = [entry["name"] for entry in device_types]
assert names == sorted(names)
by_name = {entry["name"]: entry for entry in device_types}
assert by_name["cisco_ios"]["telnet"] is False
assert by_name["cisco_ios"]["custom"] is False
assert by_name["cisco_ios_telnet"]["telnet"] is True
# '_ssh' aliases and the 'autodetect' pseudo device type are filtered out
assert not [name for name in names if name.endswith("_ssh")]
assert "autodetect" not in names
# GNS3-copilot custom drivers are flagged as custom
assert by_name["gns3_vpcs_telnet"]["custom"] is True
assert by_name["gns3_vpcs_telnet"]["telnet"] is True
async def test_device_types_unavailable(self, app: FastAPI, client: AsyncClient, monkeypatch) -> None:
"""
Test that a 501 is returned when Netmiko is not installed.
"""
from gns3server.api.routes.controller import netmiko as netmiko_route
def _raise_import_error():
raise ImportError("No module named 'netmiko'")
monkeypatch.setattr(netmiko_route, "_load_netmiko_device_types", _raise_import_error)
monkeypatch.setattr(netmiko_route, "_device_types_cache", None)
response = await client.get(app.url_path_for("get_netmiko_device_types"))
assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED
# the HTTP exception handler formats errors with a "message" key
assert "ai-features" in response.json()["message"]