From a4f20e0966429883e76cf473f599526fc2a7c503 Mon Sep 17 00:00:00 2001 From: Mark Paronyan Date: Wed, 7 Oct 2026 10:39:43 +0000 Subject: [PATCH 01/12] feat(api): add discriminators to link iface, appliance version and template settings schemas --- .../api/routes/controller/appliances.py | 8 +-- gns3server/api/routes/controller/links.py | 6 +- gns3server/schemas/__init__.py | 5 +- gns3server/schemas/controller/appliances.py | 35 +++++++++- gns3server/schemas/controller/links.py | 9 ++- .../api/routes/controller/test_appliances.py | 30 +++++++++ tests/api/routes/controller/test_links.py | 2 + tests/api/test_openapi_discriminators.py | 65 +++++++++++++++++++ tests/controller/test_appliance.py | 54 ++++++++++++++- 9 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 tests/api/test_openapi_discriminators.py diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py index ad8645554..1d3f0e413 100644 --- a/gns3server/api/routes/controller/appliances.py +++ b/gns3server/api/routes/controller/appliances.py @@ -19,7 +19,7 @@ API routes for appliances. """ import logging -from typing import List, Optional, Union +from typing import List, Optional from uuid import UUID from fastapi import APIRouter, Depends, status @@ -85,9 +85,7 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance: status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Appliance.Allocate"))], ) -def add_appliance_version( - appliance_id: UUID, appliance_version: Union[schemas.ApplianceVersion, schemas.ApplianceVersionV8] -) -> dict: +def add_appliance_version(appliance_id: UUID, appliance_version: schemas.ApplianceVersionCreate) -> dict: """ Add a version to an appliance. @@ -109,7 +107,7 @@ def add_appliance_version( if version.get("name") == appliance_version.name: raise ControllerError(message=f"Appliance '{appliance_id}' already has version '{appliance_version.name}'") - appliance.versions.append(appliance_version.model_dump(exclude_unset=True)) + appliance.versions.append(appliance_version.model_dump(exclude_unset=True, exclude={"registry_version"})) return appliance.asdict() diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index b7fa2b40d..f824f75ca 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -20,7 +20,7 @@ API routes for links. import logging import os -from typing import Any, List, Union +from typing import Any, List from uuid import UUID, uuid4 import aiohttp @@ -484,7 +484,7 @@ async def update_marker(marker_name: str, marker_data: schemas.MarkerUpdate, lin @router.get( "/{link_id}/iface", - response_model=Union[schemas.UDPPortInfo, schemas.EthernetPortInfo], + response_model=schemas.LinkIfaceInfo, dependencies=[Depends(has_privilege("Link.Audit"))], ) async def get_iface(link: Link = Depends(dep_link)) -> dict: @@ -515,6 +515,7 @@ async def get_iface(link: Link = Depends(dep_link)) -> dict: port_type = port.get("type", "") if "udp" in port_type.lower(): ifaces_info = { + "kind": "udp", "node_id": node.id, "type": f"{port_type}", "lport": port["lport"], @@ -523,6 +524,7 @@ async def get_iface(link: Link = Depends(dep_link)) -> dict: } else: ifaces_info = { + "kind": "ethernet", "node_id": node.id, "type": f"{port_type}", "interface": port["interface"], diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index a5eb8406c..53d75d4a4 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -17,7 +17,7 @@ # General schemas from .common import ErrorMessage from .config import ServerConfig -from .controller.appliances import Appliance, ApplianceVersion, ApplianceVersionV8 +from .controller.appliances import Appliance, ApplianceVersion, ApplianceVersionCreate, ApplianceVersionV8 from .controller.computes import ( AutoIdlePC, Compute, @@ -37,6 +37,7 @@ from .controller.links import ( Link, LinkCapture, LinkCreate, + LinkIfaceInfo, LinkUpdate, MarkerCreate, MarkerDefinitionCreate, @@ -162,6 +163,7 @@ __all__ = [ "ApiKeyCreate", "Appliance", "ApplianceVersion", + "ApplianceVersionCreate", "ApplianceVersionV8", "AutoIdlePC", "BatchNIOCreate", @@ -244,6 +246,7 @@ __all__ = [ "Link", "LinkCapture", "LinkCreate", + "LinkIfaceInfo", "LinkUpdate", "LoggedInUserUpdate", "MarkerCreate", diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py index 4a5b48da1..ce1a71b98 100644 --- a/gns3server/schemas/controller/appliances.py +++ b/gns3server/schemas/controller/appliances.py @@ -20,7 +20,7 @@ from enum import Enum from typing import Annotated, List, Literal, Optional, Union from uuid import UUID -from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag +from pydantic import AnyUrl, BaseModel, BeforeValidator, Discriminator, EmailStr, Field, Tag from ..common import ExtraConfig @@ -648,6 +648,39 @@ class ApplianceVersionV8(BaseModel): images: Optional[ApplianceVersionImages] = Field(None, title="Images used for this version") +_V8_VERSION_ONLY_FIELDS = frozenset(set(ApplianceVersionV8.model_fields) - set(ApplianceVersion.model_fields)) + + +class ApplianceVersionCreateV1_6(ApplianceVersion): + """Request body to add a version to an appliance with registry version 1-6""" + + registry_version: Literal[1, 2, 3, 4, 5, 6] = Field( + 1, title="Version of the registry compatible with the appliance receiving the version" + ) + + +class ApplianceVersionCreateV8(ApplianceVersionV8): + """Request body to add a version to an appliance with registry version 8""" + + registry_version: Literal[8] = Field( + 8, title="Version of the registry compatible with the appliance receiving the version" + ) + + +def _infer_version_registry_version(data): + if isinstance(data, dict) and "registry_version" not in data: + data = dict(data) + data["registry_version"] = 8 if _V8_VERSION_ONLY_FIELDS.intersection(data) else 1 + return data + + +ApplianceVersionCreate = Annotated[ + Union[ApplianceVersionCreateV1_6, ApplianceVersionCreateV8], + BeforeValidator(_infer_version_registry_version), + Discriminator("registry_version"), +] + + # ============================================================================ # Child Models with Discriminated Union # ============================================================================ diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index ccb85d424..8dd9b7096 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -15,10 +15,10 @@ # along with this program. If not, see . from enum import Enum -from typing import List, Optional, Tuple +from typing import Annotated, List, Literal, Optional, Tuple, Union from uuid import UUID, uuid4 -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Discriminator, Field, field_validator from ..update import PartialUpdateModel from .labels import Label @@ -107,6 +107,7 @@ class UDPPortInfo(BaseModel): rhost: str rport: int type: str + kind: Literal["udp"] class EthernetPortInfo(BaseModel): @@ -117,6 +118,10 @@ class EthernetPortInfo(BaseModel): node_id: UUID interface: str type: str + kind: Literal["ethernet"] + + +LinkIfaceInfo = Annotated[Union[UDPPortInfo, EthernetPortInfo], Discriminator("kind")] class LinkCapture(BaseModel): diff --git a/tests/api/routes/controller/test_appliances.py b/tests/api/routes/controller/test_appliances.py index 94e7673f7..193557e35 100644 --- a/tests/api/routes/controller/test_appliances.py +++ b/tests/api/routes/controller/test_appliances.py @@ -101,3 +101,33 @@ class TestApplianceRoutes: app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version ) assert response.status_code == status.HTTP_409_CONFLICT + + async def test_add_v8_version_appliance(self, app: FastAPI, client: AsyncClient) -> None: + + appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance + new_version = {"name": "v8-99G", "category": "guest", "images": {"hda_disk_image": "empty99G.qcow2"}} + response = await client.post( + app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version + ) + assert response.status_code == status.HTTP_201_CREATED + assert new_version in response.json()["versions"] + + async def test_add_version_appliance_with_registry_version(self, app: FastAPI, client: AsyncClient) -> None: + + appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance + stored_version = {"name": "explicit-99G", "images": {"hda_disk_image": "empty99G.qcow2"}} + response = await client.post( + app.url_path_for("add_appliance_version", appliance_id=appliance_id), + json=dict(stored_version, registry_version=3), + ) + assert response.status_code == status.HTTP_201_CREATED + assert stored_version in response.json()["versions"] + + async def test_add_version_appliance_unknown_registry_version(self, app: FastAPI, client: AsyncClient) -> None: + + appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance + response = await client.post( + app.url_path_for("add_appliance_version", appliance_id=appliance_id), + json={"name": "bad", "registry_version": 7, "images": {"hda_disk_image": "empty99G.qcow2"}}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY diff --git a/tests/api/routes/controller/test_links.py b/tests/api/routes/controller/test_links.py index 518eb22f9..4b642c83d 100644 --- a/tests/api/routes/controller/test_links.py +++ b/tests/api/routes/controller/test_links.py @@ -376,6 +376,7 @@ class TestLinkRoutes: assert result["rhost"] == "127.0.0.1" assert result["rport"] == 30000 assert result["type"] == "udp" + assert result["kind"] == "udp" async def test_get_ethernet_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None: """ @@ -406,3 +407,4 @@ class TestLinkRoutes: assert result["node_id"] == cloud_node.id assert result["interface"] == "eth0" assert result["type"] == "ethernet" + assert result["kind"] == "ethernet" diff --git a/tests/api/test_openapi_discriminators.py b/tests/api/test_openapi_discriminators.py new file mode 100644 index 000000000..2a1bca86a --- /dev/null +++ b/tests/api/test_openapi_discriminators.py @@ -0,0 +1,65 @@ +#!/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 . + +import pytest +from fastapi import FastAPI + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +def openapi(app: FastAPI) -> dict: + return app.openapi() + + +def _ref(name: str) -> str: + return f"#/components/schemas/{name}" + + +def _assert_discriminated(schema: dict, property_name: str, mapping: dict) -> None: + assert [item["$ref"] for item in schema["oneOf"]] == [_ref(name) for name in dict.fromkeys(mapping.values())] + assert schema["discriminator"]["propertyName"] == property_name + assert schema["discriminator"]["mapping"] == {key: _ref(name) for key, name in mapping.items()} + + +async def test_link_iface_response_is_discriminated(openapi: dict) -> None: + path = "/v3/projects/{project_id}/links/{link_id}/iface" + schema = openapi["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + _assert_discriminated(schema, "kind", {"udp": "UDPPortInfo", "ethernet": "EthernetPortInfo"}) + + +async def test_appliance_version_request_is_discriminated(openapi: dict) -> None: + schema = openapi["paths"]["/v3/appliances/{appliance_id}/version"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] + mapping = {str(version): "ApplianceVersionCreateV1_6" for version in range(1, 7)} + mapping["8"] = "ApplianceVersionCreateV8" + _assert_discriminated(schema, "registry_version", mapping) + + +async def test_appliance_settings_are_discriminated(openapi: dict) -> None: + schema = openapi["components"]["schemas"]["ApplianceV8"]["properties"]["settings"]["items"] + _assert_discriminated( + schema, + "template_type", + { + "qemu": "QemuTemplateSetting", + "dynamips": "DynamipsTemplateSetting", + "iou": "IouTemplateSetting", + "docker": "DockerTemplateSetting", + }, + ) diff --git a/tests/controller/test_appliance.py b/tests/controller/test_appliance.py index 0ec004e20..89f58f802 100644 --- a/tests/controller/test_appliance.py +++ b/tests/controller/test_appliance.py @@ -17,10 +17,21 @@ import pydantic import pytest +from pydantic import TypeAdapter from gns3server.controller.appliance import Appliance from gns3server.controller.appliance_to_template import ApplianceToTemplate -from gns3server.schemas.controller.appliances import ApplianceModel +from gns3server.schemas.controller.appliances import ( + ApplianceModel, + ApplianceVersionCreate, + ApplianceVersionCreateV1_6, + ApplianceVersionCreateV8, + DockerTemplateSetting, + DynamipsTemplateSetting, + IouTemplateSetting, + QemuTemplateSetting, + TemplateSetting, +) # v8 mirror of the XRd Control Plane appliance shape (docker, custom_adapters, no versions) XRD_V8 = { @@ -242,3 +253,44 @@ def test_v8_netmiko_device_type_empty_string_clears(): appliance = dict(XRD_V8, netmiko_device_type="") model = ApplianceModel.model_validate(appliance) assert model.netmiko_device_type == "" + + +@pytest.mark.parametrize( + "template_type, properties, expected", + [ + ("qemu", {"ram": 512}, QemuTemplateSetting), + ("dynamips", {"platform": "c7200", "ram": 256}, DynamipsTemplateSetting), + ("iou", {"ram": 256}, IouTemplateSetting), + ("docker", {"image": "alpine:latest"}, DockerTemplateSetting), + ], +) +def test_template_setting_decoded_through_discriminator(template_type, properties, expected): + setting = TypeAdapter(TemplateSetting).validate_python( + {"name": "a", "template_type": template_type, "template_properties": properties} + ) + assert type(setting) is expected + assert setting.template_type == template_type + + +def test_template_setting_unknown_template_type_rejected(): + with pytest.raises(pydantic.ValidationError): + TypeAdapter(TemplateSetting).validate_python({"template_type": "vpcs", "template_properties": {}}) + + +@pytest.mark.parametrize( + "data, expected", + [ + ({"name": "1", "registry_version": 1}, ApplianceVersionCreateV1_6), + ({"name": "6", "registry_version": 6}, ApplianceVersionCreateV1_6), + ({"name": "8", "registry_version": 8}, ApplianceVersionCreateV8), + ({"name": "plain", "images": {"hda_disk_image": "a.qcow2"}}, ApplianceVersionCreateV1_6), + ({"name": "v8", "category": "guest"}, ApplianceVersionCreateV8), + ], +) +def test_appliance_version_decoded_through_discriminator(data, expected): + assert type(TypeAdapter(ApplianceVersionCreate).validate_python(data)) is expected + + +def test_appliance_version_unknown_registry_version_rejected(): + with pytest.raises(pydantic.ValidationError): + TypeAdapter(ApplianceVersionCreate).validate_python({"name": "x", "registry_version": 7}) From 8cb92e76850c3a26bac5ae7bbcc32a8e9eae1771 Mon Sep 17 00:00:00 2001 From: Mark Paronyan Date: Wed, 7 Oct 2026 10:37:06 +0000 Subject: [PATCH 02/12] fix(api): derive OpenAPI version from package, add servers, hide AI stub routes --- gns3server/api/routes/compute/__init__.py | 4 +- gns3server/api/routes/controller/__init__.py | 24 +++- gns3server/api/server.py | 14 ++- gns3server/schemas/config.py | 3 + gns3server/schemas/controller/settings.py | 1 + gns3server/version.py | 2 + pyproject.toml | 1 + tests/api/test_openapi_spec.py | 114 +++++++++++++++++++ 8 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 tests/api/test_openapi_spec.py diff --git a/gns3server/api/routes/compute/__init__.py b/gns3server/api/routes/compute/__init__.py index 934243922..f7815f408 100644 --- a/gns3server/api/routes/compute/__init__.py +++ b/gns3server/api/routes/compute/__init__.py @@ -31,6 +31,7 @@ from gns3server.compute.compute_error import ( from gns3server.compute.error import ImageMissingError, NodeError from gns3server.compute.ubridge.ubridge_error import UbridgeError from gns3server.controller.gns3vm.gns3_vm_error import GNS3VMError +from gns3server.version import __api_version__ from . import ( atm_switch_nodes, @@ -60,7 +61,8 @@ log = logging.getLogger(__name__) compute_api = FastAPI( title="GNS3 compute API", description="This page describes the private compute API for GNS3. PLEASE DO NOT USE DIRECTLY!", - version="3.0.0", + version=__api_version__, + servers=[{"url": "/v3/compute"}], ) compute_api.state.controller_host = None diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py index e22d3d507..49e1b65cb 100644 --- a/gns3server/api/routes/controller/__init__.py +++ b/gns3server/api/routes/controller/__init__.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException, status # Check AI Copilot availability from gns3server.agent import AI_COPILOT_AVAILABLE +from gns3server.config import Config # Conditionally import AI-dependent routes if AI_COPILOT_AVAILABLE: @@ -32,9 +33,11 @@ else: _copilot_router = APIRouter() _llm_router = APIRouter() - @_chat_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"]) - @_copilot_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"]) - @_llm_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"]) + @_chat_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"], include_in_schema=False) + @_copilot_router.api_route( + "/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"], include_in_schema=False + ) + @_llm_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"], include_in_schema=False) async def ai_not_available(path: str = ""): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, @@ -67,6 +70,8 @@ from . import ( ) from .dependencies.authentication import get_current_active_user +_include_ai_in_schema = Config.instance().settings.Server.openapi_include_ai + router = APIRouter() router.include_router(controller.router, tags=["Controller"]) @@ -116,11 +121,19 @@ router.include_router( ) router.include_router( - _llm_router, prefix="/access", dependencies=[Depends(get_current_active_user)], tags=["LLM Model Configurations"] + _llm_router, + prefix="/access", + dependencies=[Depends(get_current_active_user)], + tags=["LLM Model Configurations"], + include_in_schema=_include_ai_in_schema, ) router.include_router( - _copilot_router, prefix="/copilot", dependencies=[Depends(get_current_active_user)], tags=["GNS3 Copilot"] + _copilot_router, + prefix="/copilot", + dependencies=[Depends(get_current_active_user)], + tags=["GNS3 Copilot"], + include_in_schema=_include_ai_in_schema, ) router.include_router( @@ -128,6 +141,7 @@ router.include_router( prefix="/copilot/projects/{project_id}/chat", dependencies=[Depends(get_current_active_user)], tags=["GNS3 Copilot"], + include_in_schema=_include_ai_in_schema, ) router.include_router(api_keys.router, dependencies=[Depends(get_current_active_user)], tags=["API Keys"]) diff --git a/gns3server/api/server.py b/gns3server/api/server.py index 4ea037e83..daaa30902 100644 --- a/gns3server/api/server.py +++ b/gns3server/api/server.py @@ -38,6 +38,7 @@ from uvicorn.main import Server as UvicornServer from gns3server.agent import MCP_AVAILABLE from gns3server.api.routes import controller, index from gns3server.api.routes.compute import compute_api +from gns3server.config import Config from gns3server.controller.controller_error import ( ComputeConflictError, ControllerBadRequestError, @@ -48,6 +49,7 @@ from gns3server.controller.controller_error import ( ControllerUnauthorizedError, ) from gns3server.core import tasks +from gns3server.version import __api_version__ if MCP_AVAILABLE: from gns3server.agent import mcp @@ -58,7 +60,7 @@ else: _mcp_router = APIRouter(prefix="/mcp", tags=["MCP"]) - @_mcp_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"]) + @_mcp_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"], include_in_schema=False) async def mcp_not_available(path: str = ""): raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, @@ -77,7 +79,8 @@ def get_application() -> FastAPI: lifespan=tasks.lifespan, title="GNS3 controller API", description="This page describes the public controller API for GNS3", - version="3.0.0", + version=__api_version__, + servers=[{"url": "/"}], docs_url=None, redoc_url=None, ) @@ -96,7 +99,12 @@ def get_application() -> FastAPI: application.mount("/v3/compute", compute_api, name="compute") # Register MCP routes (stub returns 501 if MCP dependencies are not installed) - application.include_router(_mcp_router, prefix="/v3", tags=["MCP"]) + application.include_router( + _mcp_router, + prefix="/v3", + tags=["MCP"], + include_in_schema=Config.instance().settings.Server.openapi_include_ai, + ) return application diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index f6c978911..f018dc3e2 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -155,6 +155,9 @@ class ServerSettings(BaseModel): False, description="Local server mode, set by the --local command line argument (not meant to be set by hand)" ) enable_http_auth: bool = Field(True, description="Enable compute HTTP authentication") + openapi_include_ai: bool = Field( + True, description="Include the AI Copilot, LLM and MCP routes in the generated OpenAPI specification" + ) name: str = Field( f"{socket.gethostname()} (controller)", description="Server name, default is what is returned by socket.gethostname()", diff --git a/gns3server/schemas/controller/settings.py b/gns3server/schemas/controller/settings.py index aa457dc31..600b41ba7 100644 --- a/gns3server/schemas/controller/settings.py +++ b/gns3server/schemas/controller/settings.py @@ -103,6 +103,7 @@ class ServerSettingsUpdate(BaseModel): local: Optional[bool] = None enable_http_auth: Optional[bool] = None + openapi_include_ai: Optional[bool] = None name: Optional[str] = None protocol: Optional[ServerProtocol] = None host: Optional[str] = None diff --git a/gns3server/version.py b/gns3server/version.py index 2b8d74b08..541a3722e 100644 --- a/gns3server/version.py +++ b/gns3server/version.py @@ -35,3 +35,5 @@ if "dev" in __version__: __version__ += "+" + r except Exception as e: print(e) + +__api_version__ = __version__.split("+", 1)[0] diff --git a/pyproject.toml b/pyproject.toml index ef7b82573..6cba871da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -219,6 +219,7 @@ extend-immutable-calls = [ "scripts/welcome.py" = ["C901", "E722", "PLW1510", "S108", "S112", "S603", "S605", "S606", "S607"] "tests/agent/mcp/test_handlers.py" = ["B905"] "tests/agent/mcp/test_tool_params.py" = ["C901"] +"tests/api/test_openapi_spec.py" = ["S603"] "tests/api/routes/controller/test_images.py" = ["S324", "W292"] "tests/api/routes/controller/test_links.py" = ["RUF015", "W292"] "tests/api/routes/controller/test_users.py" = ["F811"] diff --git a/tests/api/test_openapi_spec.py b/tests/api/test_openapi_spec.py new file mode 100644 index 000000000..0562680c8 --- /dev/null +++ b/tests/api/test_openapi_spec.py @@ -0,0 +1,114 @@ +# +# 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 . + +import json +import subprocess +import sys + +import pytest +from fastapi import FastAPI + +from gns3server.agent import AI_COPILOT_AVAILABLE, MCP_AVAILABLE +from gns3server.version import __api_version__, __version__ + +pytestmark = pytest.mark.asyncio + +AI_PATH_PREFIXES = ( + "/v3/copilot", + "/v3/mcp", + "/v3/access/llm", + "/v3/access/users/{user_id}/llm-model-configs", + "/v3/access/groups/{group_id}/llm-model-configs", +) + +SPEC_SCRIPT = """ +import json +import sys + +from gns3server.config import Config + +Config.instance(files=[sys.argv[1]]) + +from gns3server.api.server import app + +print(json.dumps(app.openapi())) +""" + + +def _operation_ids(spec: dict) -> list: + return [ + operation["operationId"] + for path_item in spec["paths"].values() + for operation in path_item.values() + if isinstance(operation, dict) and "operationId" in operation + ] + + +def _build_spec(tmp_path, include_ai: bool) -> dict: + config_file = tmp_path / "gns3_server.conf" + config_file.write_text(f"[Server]\nopenapi_include_ai = {str(include_ai).lower()}\n") + result = subprocess.run( + [sys.executable, "-c", SPEC_SCRIPT, str(config_file)], + capture_output=True, + text=True, + check=True, + timeout=120, + ) + return json.loads(result.stdout.splitlines()[-1]) + + +class TestOpenAPISpec: + async def test_controller_version_matches_package(self, app: FastAPI) -> None: + + spec = app.openapi() + assert spec["info"]["version"] == __api_version__ + assert spec["info"]["version"] == __version__.split("+")[0] + assert "+" not in spec["info"]["version"] + + async def test_compute_version_matches_package(self, app: FastAPI) -> None: + + compute_app = next(route.app for route in app.routes if getattr(route, "path", None) == "/v3/compute") + assert compute_app.openapi()["info"]["version"] == __api_version__ + + async def test_servers_entry(self, app: FastAPI) -> None: + + assert app.openapi()["servers"] == [{"url": "/"}] + + async def test_no_catch_all_operations(self, app: FastAPI) -> None: + + paths = app.openapi()["paths"] + assert [path for path in paths if "{path}" in path] == [] + + async def test_operation_ids_are_unique(self, app: FastAPI) -> None: + + operation_ids = _operation_ids(app.openapi()) + assert len(operation_ids) == len(set(operation_ids)) + + @pytest.mark.skipif(AI_COPILOT_AVAILABLE or MCP_AVAILABLE, reason="AI extras are installed") + async def test_ai_routes_absent_without_extras(self, app: FastAPI) -> None: + + paths = app.openapi()["paths"] + assert [path for path in paths if path.startswith(AI_PATH_PREFIXES)] == [] + + async def test_setting_hides_ai_routes_and_keeps_the_rest(self, tmp_path) -> None: + + with_ai = _build_spec(tmp_path, include_ai=True) + without_ai = _build_spec(tmp_path, include_ai=False) + + assert [path for path in without_ai["paths"] if path.startswith(AI_PATH_PREFIXES)] == [] + non_ai_paths = {path: item for path, item in with_ai["paths"].items() if not path.startswith(AI_PATH_PREFIXES)} + assert without_ai["paths"] == non_ai_paths + assert len(_operation_ids(without_ai)) == len(set(_operation_ids(without_ai))) From 811b45f8636ccae13030282e2b7cbf7c5cf351ca Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 8 Oct 2026 15:14:34 +0200 Subject: [PATCH 03/12] fix: drop Python 3.10 and add Python 3.15 support --- .github/workflows/testing.yml | 2 +- gns3server/agent/web_wireshark/docker_client.py | 5 +---- gns3server/compute/base_node.py | 12 ++---------- gns3server/controller/compute.py | 5 +---- gns3server/server.py | 6 +++--- pyproject.toml | 9 +++++---- 6 files changed, 13 insertions(+), 26 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 42916ecf5..f09857260 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v7 diff --git a/gns3server/agent/web_wireshark/docker_client.py b/gns3server/agent/web_wireshark/docker_client.py index 00f62edd2..3fcbf0368 100644 --- a/gns3server/agent/web_wireshark/docker_client.py +++ b/gns3server/agent/web_wireshark/docker_client.py @@ -28,11 +28,8 @@ from typing import Any import aiohttp -if sys.version_info >= (3, 11): - from asyncio import timeout as async_timeout -else: - from async_timeout import timeout as async_timeout +from asyncio import timeout as async_timeout from gns3server.utils import parse_version logger = logging.getLogger(__name__) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 033ef96d9..13458aef4 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -671,11 +671,7 @@ class BaseNode: ) # keep forwarding websocket data in both direction - if sys.version_info >= (3, 11, 0): - # Starting with Python 3.11, passing coroutine objects to wait() directly is forbidden. - aws = [asyncio.create_task(ws_forward(telnet_writer)), asyncio.create_task(telnet_forward(telnet_reader))] - else: - aws = [ws_forward(telnet_writer), telnet_forward(telnet_reader)] + aws = [asyncio.create_task(ws_forward(telnet_writer)), asyncio.create_task(telnet_forward(telnet_reader))] done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) for task in done: @@ -762,11 +758,7 @@ class BaseNode: log.warning(f"Exception while forwarding VNC data to WebSocket: {e!r}") # Keep forwarding WebSocket data in both directions - if sys.version_info >= (3, 11, 0): - # Starting with Python 3.11, passing coroutine objects to wait() directly is forbidden. - aws = [asyncio.create_task(ws_forward(vnc_writer)), asyncio.create_task(vnc_forward(vnc_reader))] - else: - aws = [ws_forward(vnc_writer), vnc_forward(vnc_reader)] + aws = [asyncio.create_task(ws_forward(vnc_writer)), asyncio.create_task(vnc_forward(vnc_reader))] done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) for task in done: diff --git a/gns3server/controller/compute.py b/gns3server/controller/compute.py index 1b3b65e38..2b4658c37 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -25,10 +25,7 @@ import sys import aiohttp from fastapi import HTTPException -if sys.version_info >= (3, 11): - from asyncio import timeout as asynctimeout -else: - from async_timeout import timeout as asynctimeout +from asyncio import timeout as asynctimeout import logging diff --git a/gns3server/server.py b/gns3server/server.py index 3ec16e456..099696f55 100644 --- a/gns3server/server.py +++ b/gns3server/server.py @@ -255,9 +255,9 @@ class Server: else: log.info(f"Compute authentication is enabled with username '{config.Server.compute_username}'") - # we only support Python 3 version >= 3.9 - if sys.version_info < (3, 9, 0): - raise SystemExit("Python 3.9 or higher is required") + # we only support Python 3 version >= 3.11 + if sys.version_info < (3, 11, 0): + raise SystemExit("Python 3.11 or higher is required") log.info( f"Running with Python {sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]} and has PID {os.getpid()}" diff --git a/pyproject.toml b/pyproject.toml index ef7b82573..eda392946 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "gns3-server" -description = "GNS3 graphical interface for the GNS3 server." +description = "GNS3 server (controller and compute) for GNS3 network simulator" license-files = ["LICENSE"] authors = [ { name = "Jeremy Grossmann", email = "developers@gns3.com" } ] readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", @@ -21,10 +21,11 @@ classifiers = [ "Natural Language :: English", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: CPython" ] @@ -58,7 +59,7 @@ packages = ["gns3server"] [tool.ruff] -target-version = "py310" +target-version = "py311" line-length = 120 extend-exclude = [ "*.md", From 90a98e7a45474f44862767c93ce815ae0afd93f5 Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 8 Oct 2026 15:16:58 +0200 Subject: [PATCH 04/12] Python 3.15 is not yet available on GA --- .github/workflows/testing.yml | 2 +- pyproject.toml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index f09857260..4f834576e 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v7 diff --git a/pyproject.toml b/pyproject.toml index eda392946..f742b0c7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", - "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: CPython" ] From 330741598a24da97a6b4837f4becd69d373f666d Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 8 Oct 2026 15:25:32 +0200 Subject: [PATCH 05/12] Set target-version = "py310" in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f742b0c7d..3fa83a397 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ packages = ["gns3server"] [tool.ruff] -target-version = "py311" +target-version = "py310" line-length = 120 extend-exclude = [ "*.md", From 059056e97f5721598ece75bb1cac7cb8475a900d Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 8 Oct 2026 15:27:53 +0200 Subject: [PATCH 06/12] Revert "Set target-version = "py310" in pyproject.toml" This reverts commit 330741598a24da97a6b4837f4becd69d373f666d. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3fa83a397..f742b0c7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ packages = ["gns3server"] [tool.ruff] -target-version = "py310" +target-version = "py311" line-length = 120 extend-exclude = [ "*.md", From 2642345b0a2ecb96fc190fc91b81fb81a7c7bafe Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 8 Oct 2026 15:33:53 +0200 Subject: [PATCH 07/12] fix: import errors --- gns3server/agent/web_wireshark/docker_client.py | 4 +--- gns3server/controller/compute.py | 6 ++---- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/gns3server/agent/web_wireshark/docker_client.py b/gns3server/agent/web_wireshark/docker_client.py index 3fcbf0368..e030eab35 100644 --- a/gns3server/agent/web_wireshark/docker_client.py +++ b/gns3server/agent/web_wireshark/docker_client.py @@ -23,13 +23,11 @@ This module handles Docker API operations via aiohttp Unix socket. import asyncio import logging -import sys +from asyncio import timeout as async_timeout from typing import Any import aiohttp - -from asyncio import timeout as async_timeout from gns3server.utils import parse_version logger = logging.getLogger(__name__) diff --git a/gns3server/controller/compute.py b/gns3server/controller/compute.py index 2b4658c37..fd136eb91 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -19,16 +19,14 @@ import asyncio import io import ipaddress import json +import logging import socket import sys +from asyncio import timeout as asynctimeout import aiohttp from fastapi import HTTPException -from asyncio import timeout as asynctimeout - -import logging - from ..controller.controller_error import ( ComputeConflictError, ComputeError, diff --git a/pyproject.toml b/pyproject.toml index f742b0c7d..3fa83a397 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ packages = ["gns3server"] [tool.ruff] -target-version = "py311" +target-version = "py310" line-length = 120 extend-exclude = [ "*.md", From 917b19d808a4ac8aaab5ea4aef0e6ba18d4649ec Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 8 Oct 2026 15:40:29 +0200 Subject: [PATCH 08/12] chore(deps): update dependencies --- requirements.txt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2094cab64..67fc621d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,18 @@ # GNS3 Server Core Dependencies uvicorn==0.54.0 pydantic==2.13.5 -fastapi==0.141.1 +fastapi==0.142.4 python-multipart==0.0.32 -websockets==16.1.1 # version 16.1.1 is the last version that supports Python 3.10 -aiohttp>=3.14.3,<3.15 +websockets==17.2 +aiohttp>=3.14.4,<3.15 aiofiles>=25.1.0,<26.0 Jinja2>=3.1.6,<3.2 sentry-sdk>=2.70.0,<3 # optional dependency psutil>=7.2.2 -async-timeout>=5.0.1,<5.1; python_version < '3.11' # this library has effectively been upstreamed into Python 3.11+ distro>=1.9.0 py-cpuinfo>=9.0.0,<10.0 greenlet==3.5.3; python_version >= '3.13' # necessary to run sqlalchemy on Python >= 3.13 -sqlalchemy==2.0.54 # version 2.0.54 is the last version that supports Python 3.10 +sqlalchemy==2.1.4 aiosqlite==0.22.1 alembic==1.20 bcrypt==5.0.0 @@ -22,7 +21,7 @@ email-validator==2.3.0 watchdog==6.0.0 zstandard==0.25.0 platformdirs>=2.4.0 # fastmcp-slim >=3.4 requires >=4.0.0; upper bound removed for compatibility -truststore>=0.10.4; python_version >= '3.10' +truststore>=0.10.4 # Shared dependencies (also used by AI Copilot) telnetlib3==5.0.1,<6 From f50653107b4e4a7e86400a57acfdc3c068661fc0 Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 8 Oct 2026 16:57:41 +0200 Subject: [PATCH 09/12] style: fix import and lint errors after merging --- gns3server/api/routes/controller/links.py | 2 +- gns3server/schemas/__init__.py | 4 ++-- gns3server/schemas/controller/links.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 9750ad29e..d8625b9ad 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -20,7 +20,7 @@ API routes for links. import logging import os -from typing import Any, List, Optional, Union +from typing import Any, List, Optional from uuid import UUID, uuid4 import aiohttp diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index b02151be1..5338f5b57 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -37,11 +37,11 @@ from .controller.links import ( Link, LinkCapture, LinkCreate, - LinkIfaceInfo, LinkFilterDefinition, LinkFilterParameter, LinkFilters, LinkFilterType, + LinkIfaceInfo, LinkUpdate, MarkerCreate, MarkerDefinitionCreate, @@ -251,11 +251,11 @@ __all__ = [ "Link", "LinkCapture", "LinkCreate", - "LinkIfaceInfo", "LinkFilterDefinition", "LinkFilterParameter", "LinkFilterType", "LinkFilters", + "LinkIfaceInfo", "LinkUpdate", "LoggedInUserUpdate", "MarkerCreate", diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 3aa66df9f..f74635b8c 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -18,7 +18,7 @@ from enum import Enum from typing import Annotated, List, Literal, Optional, Tuple, Union from uuid import UUID, uuid4 -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Discriminator, Field, field_validator, model_validator from ..update import PartialUpdateModel from .labels import Label From d8118fa35507bfaec263ab1def09c079fe6f4a62 Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 8 Oct 2026 17:08:17 +0200 Subject: [PATCH 10/12] chore(deps): downgrade websockets to v16.1.1 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 67fc621d1..940536cc1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ uvicorn==0.54.0 pydantic==2.13.5 fastapi==0.142.4 python-multipart==0.0.32 -websockets==17.2 +websockets==16.1.1 # google-genai 2.10.0 has requirement websockets<17.0 aiohttp>=3.14.4,<3.15 aiofiles>=25.1.0,<26.0 Jinja2>=3.1.6,<3.2 From e0260f27c75bc708daf53ca4c05eaf569c67383a Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 8 Oct 2026 18:39:20 +0200 Subject: [PATCH 11/12] fix(rbac): annotate query variables with Select type for better type checking --- gns3server/db/repositories/rbac.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gns3server/db/repositories/rbac.py b/gns3server/db/repositories/rbac.py index 4417e901d..93cdbe32a 100644 --- a/gns3server/db/repositories/rbac.py +++ b/gns3server/db/repositories/rbac.py @@ -16,11 +16,11 @@ # along with this program. If not, see . import logging -from typing import List, Optional, Union, cast +from typing import Any, List, Optional, Union, cast from urllib.parse import urlparse from uuid import UUID -from sqlalchemy import delete, select, update +from sqlalchemy import Select, delete, select, update from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -331,7 +331,7 @@ class RbacRepository(BaseRepository): Retrieve all user ACEs matching the user_id and privilege name. """ - query = ( + query: Select[Any, Any, Any, Any] = ( select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name) .join(models.Privilege.roles) .join(models.Role.acl_entries) @@ -349,7 +349,7 @@ class RbacRepository(BaseRepository): Retrieve all group ACEs matching the user_id and privilege name. """ - query = ( + query: Select[Any, Any, Any, Any] = ( select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name) .join(models.Privilege.roles) .join(models.Role.acl_entries) From d1d4e7dce5cd61e099c7ed5864a680dc85027f86 Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 8 Oct 2026 18:45:04 +0200 Subject: [PATCH 12/12] fix(deps): install the SQLAlchemy asyncio module so the 'greenlet' library is installed --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 940536cc1..c874bfbb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,8 +11,7 @@ sentry-sdk>=2.70.0,<3 # optional dependency psutil>=7.2.2 distro>=1.9.0 py-cpuinfo>=9.0.0,<10.0 -greenlet==3.5.3; python_version >= '3.13' # necessary to run sqlalchemy on Python >= 3.13 -sqlalchemy==2.1.4 +sqlalchemy[asyncio]==2.1.4 aiosqlite==0.22.1 alembic==1.20 bcrypt==5.0.0