Merge branch '3.1' into api-issue-05-2xftmd

This commit is contained in:
Jeremy Grossmann 2026-10-08 19:04:36 +02:00 committed by GitHub
commit 57e4d1632d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 378 additions and 70 deletions

View File

@ -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

View File

@ -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__)

View File

@ -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

View File

@ -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"])

View File

@ -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()

View File

@ -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"],

View File

@ -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

View File

@ -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:

View File

@ -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,

View File

@ -16,11 +16,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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)

View File

@ -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",

View File

@ -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()",

View File

@ -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
# ============================================================================

View File

@ -15,10 +15,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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):

View File

@ -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

View File

@ -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()}"

View File

@ -35,3 +35,5 @@ if "dev" in __version__:
__version__ += "+" + r
except Exception as e:
print(e)
__api_version__ = __version__.split("+", 1)[0]

View File

@ -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"]

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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 <http://www.gnu.org/licenses/>.
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",
},
)

View File

@ -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 <http://www.gnu.org/licenses/>.
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)))

View File

@ -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})