diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 42916ecf5..4f834576e 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"] 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..e030eab35 100644 --- a/gns3server/agent/web_wireshark/docker_client.py +++ b/gns3server/agent/web_wireshark/docker_client.py @@ -23,16 +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 -if sys.version_info >= (3, 11): - from asyncio import timeout as async_timeout -else: - from async_timeout import timeout as async_timeout - from gns3server.utils import parse_version logger = logging.getLogger(__name__) 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/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 89b2b8395..0acd09dab 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 @@ -590,7 +590,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: @@ -621,6 +621,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"], @@ -629,6 +630,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/api/server.py b/gns3server/api/server.py index a0f02085a..ad7c56675 100644 --- a/gns3server/api/server.py +++ b/gns3server/api/server.py @@ -39,6 +39,7 @@ from gns3server.agent import MCP_AVAILABLE from gns3server.api.errors import customize_openapi_errors, error_response 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, @@ -49,6 +50,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 @@ -59,7 +61,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, @@ -84,15 +86,9 @@ def get_application() -> FastAPI: application = ControllerAPI( lifespan=tasks.lifespan, title="GNS3 controller API", - description="This page describes the public controller API for GNS3.\n\n" - "## Notification streams\n\n" - "Notifications are available as HTTP streams of newline delimited JSON objects (`GET /v3/notifications` and " - "`GET /v3/projects/{project_id}/notifications`) and as WebSockets sending one JSON object per text frame " - "(`/v3/notifications/ws`, `/v3/projects/{project_id}/notifications/ws` and " - "`/v3/projects/{project_id}/notifications/markers/ws`). " - "OpenAPI cannot describe WebSocket routes, they carry the same `Notification` messages as the HTTP streams. " - "Marker events (`marker.match`) are only sent on the markers WebSocket.", - version="3.0.0", + description="This page describes the public controller API for GNS3", + version=__api_version__, + servers=[{"url": "/"}], docs_url=None, redoc_url=None, ) @@ -111,7 +107,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/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..fd136eb91 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -19,19 +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 -if sys.version_info >= (3, 11): - from asyncio import timeout as asynctimeout -else: - from async_timeout import timeout as asynctimeout - -import logging - from ..controller.controller_error import ( ComputeConflictError, ComputeError, 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) diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index cec6735be..64949b0b4 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, @@ -42,6 +42,7 @@ from .controller.links import ( LinkFilterParameter, LinkFilters, LinkFilterType, + LinkIfaceInfo, LinkUpdate, MarkerCreate, MarkerDefinitionCreate, @@ -168,6 +169,7 @@ __all__ = [ "ApiKeyCreate", "Appliance", "ApplianceVersion", + "ApplianceVersionCreate", "ApplianceVersionV8", "AutoIdlePC", "BatchNIOCreate", @@ -255,6 +257,7 @@ __all__ = [ "LinkFilterParameter", "LinkFilterType", "LinkFilters", + "LinkIfaceInfo", "LinkUpdate", "LoggedInUserUpdate", "MarkerCreate", 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/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 d7ae3f528..7f4b1fc22 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 Annotated, List, Literal, Optional, Tuple +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 ..common import ErrorMessage from ..update import PartialUpdateModel @@ -216,6 +216,7 @@ class UDPPortInfo(BaseModel): rhost: str rport: int type: str + kind: Literal["udp"] class EthernetPortInfo(BaseModel): @@ -226,6 +227,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/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/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/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..c5b62ecd9 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,10 @@ 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 :: Implementation :: CPython" ] @@ -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/requirements.txt b/requirements.txt index 2094cab64..c874bfbb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,17 @@ # 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==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 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[asyncio]==2.1.4 aiosqlite==0.22.1 alembic==1.20 bcrypt==5.0.0 @@ -22,7 +20,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 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 e9ae6928b..51618715f 100644 --- a/tests/api/routes/controller/test_links.py +++ b/tests/api/routes/controller/test_links.py @@ -647,6 +647,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: """ @@ -677,3 +678,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/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))) 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})