Merge branch '3.1' into api-issue-06-8s9swu

This commit is contained in:
Jeremy Grossmann 2026-10-08 19:01:57 +02:00 committed by GitHub
commit 7a3a34d3fc
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: strategy:
matrix: matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] python-version: ["3.11", "3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7

View File

@ -23,16 +23,11 @@ This module handles Docker API operations via aiohttp Unix socket.
import asyncio import asyncio
import logging import logging
import sys from asyncio import timeout as async_timeout
from typing import Any from typing import Any
import aiohttp 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 from gns3server.utils import parse_version
logger = logging.getLogger(__name__) 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.error import ImageMissingError, NodeError
from gns3server.compute.ubridge.ubridge_error import UbridgeError from gns3server.compute.ubridge.ubridge_error import UbridgeError
from gns3server.controller.gns3vm.gns3_vm_error import GNS3VMError from gns3server.controller.gns3vm.gns3_vm_error import GNS3VMError
from gns3server.version import __api_version__
from . import ( from . import (
atm_switch_nodes, atm_switch_nodes,
@ -60,7 +61,8 @@ log = logging.getLogger(__name__)
compute_api = FastAPI( compute_api = FastAPI(
title="GNS3 compute API", title="GNS3 compute API",
description="This page describes the private compute API for GNS3. PLEASE DO NOT USE DIRECTLY!", 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 compute_api.state.controller_host = None

View File

@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
# Check AI Copilot availability # Check AI Copilot availability
from gns3server.agent import AI_COPILOT_AVAILABLE from gns3server.agent import AI_COPILOT_AVAILABLE
from gns3server.config import Config
# Conditionally import AI-dependent routes # Conditionally import AI-dependent routes
if AI_COPILOT_AVAILABLE: if AI_COPILOT_AVAILABLE:
@ -32,9 +33,11 @@ else:
_copilot_router = APIRouter() _copilot_router = APIRouter()
_llm_router = APIRouter() _llm_router = APIRouter()
@_chat_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"]) @_copilot_router.api_route(
@_llm_router.api_route("/{path:path}", methods=["GET", "POST", "DELETE", "PATCH", "PUT"]) "/{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 = ""): async def ai_not_available(path: str = ""):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, status_code=status.HTTP_501_NOT_IMPLEMENTED,
@ -67,6 +70,8 @@ from . import (
) )
from .dependencies.authentication import get_current_active_user from .dependencies.authentication import get_current_active_user
_include_ai_in_schema = Config.instance().settings.Server.openapi_include_ai
router = APIRouter() router = APIRouter()
router.include_router(controller.router, tags=["Controller"]) router.include_router(controller.router, tags=["Controller"])
@ -116,11 +121,19 @@ router.include_router(
) )
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( 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( router.include_router(
@ -128,6 +141,7 @@ router.include_router(
prefix="/copilot/projects/{project_id}/chat", prefix="/copilot/projects/{project_id}/chat",
dependencies=[Depends(get_current_active_user)], dependencies=[Depends(get_current_active_user)],
tags=["GNS3 Copilot"], 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"]) 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 import logging
from typing import List, Optional, Union from typing import List, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, status
@ -85,9 +85,7 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance:
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Appliance.Allocate"))], dependencies=[Depends(has_privilege("Appliance.Allocate"))],
) )
def add_appliance_version( def add_appliance_version(appliance_id: UUID, appliance_version: schemas.ApplianceVersionCreate) -> dict:
appliance_id: UUID, appliance_version: Union[schemas.ApplianceVersion, schemas.ApplianceVersionV8]
) -> dict:
""" """
Add a version to an appliance. Add a version to an appliance.
@ -109,7 +107,7 @@ def add_appliance_version(
if version.get("name") == appliance_version.name: if version.get("name") == appliance_version.name:
raise ControllerError(message=f"Appliance '{appliance_id}' already has version '{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() return appliance.asdict()

View File

@ -20,7 +20,7 @@ API routes for links.
import logging import logging
import os import os
from typing import Any, List, Optional, Union from typing import Any, List, Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import aiohttp import aiohttp
@ -545,7 +545,7 @@ async def update_marker(marker_name: str, marker_data: schemas.MarkerUpdate, lin
@router.get( @router.get(
"/{link_id}/iface", "/{link_id}/iface",
response_model=Union[schemas.UDPPortInfo, schemas.EthernetPortInfo], response_model=schemas.LinkIfaceInfo,
dependencies=[Depends(has_privilege("Link.Audit"))], dependencies=[Depends(has_privilege("Link.Audit"))],
) )
async def get_iface(link: Link = Depends(dep_link)) -> dict: async def get_iface(link: Link = Depends(dep_link)) -> dict:
@ -576,6 +576,7 @@ async def get_iface(link: Link = Depends(dep_link)) -> dict:
port_type = port.get("type", "") port_type = port.get("type", "")
if "udp" in port_type.lower(): if "udp" in port_type.lower():
ifaces_info = { ifaces_info = {
"kind": "udp",
"node_id": node.id, "node_id": node.id,
"type": f"{port_type}", "type": f"{port_type}",
"lport": port["lport"], "lport": port["lport"],
@ -584,6 +585,7 @@ async def get_iface(link: Link = Depends(dep_link)) -> dict:
} }
else: else:
ifaces_info = { ifaces_info = {
"kind": "ethernet",
"node_id": node.id, "node_id": node.id,
"type": f"{port_type}", "type": f"{port_type}",
"interface": port["interface"], "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.errors import customize_openapi_errors, error_response
from gns3server.api.routes import controller, index from gns3server.api.routes import controller, index
from gns3server.api.routes.compute import compute_api from gns3server.api.routes.compute import compute_api
from gns3server.config import Config
from gns3server.controller.controller_error import ( from gns3server.controller.controller_error import (
ComputeConflictError, ComputeConflictError,
ControllerBadRequestError, ControllerBadRequestError,
@ -49,6 +50,7 @@ from gns3server.controller.controller_error import (
ControllerUnauthorizedError, ControllerUnauthorizedError,
) )
from gns3server.core import tasks from gns3server.core import tasks
from gns3server.version import __api_version__
if MCP_AVAILABLE: if MCP_AVAILABLE:
from gns3server.agent import mcp from gns3server.agent import mcp
@ -59,7 +61,7 @@ else:
_mcp_router = APIRouter(prefix="/mcp", tags=["MCP"]) _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 = ""): async def mcp_not_available(path: str = ""):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED, status_code=status.HTTP_501_NOT_IMPLEMENTED,
@ -84,15 +86,9 @@ def get_application() -> FastAPI:
application = ControllerAPI( application = ControllerAPI(
lifespan=tasks.lifespan, lifespan=tasks.lifespan,
title="GNS3 controller API", title="GNS3 controller API",
description="This page describes the public controller API for GNS3.\n\n" description="This page describes the public controller API for GNS3",
"## Notification streams\n\n" version=__api_version__,
"Notifications are available as HTTP streams of newline delimited JSON objects (`GET /v3/notifications` and " servers=[{"url": "/"}],
"`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",
docs_url=None, docs_url=None,
redoc_url=None, redoc_url=None,
) )
@ -111,7 +107,12 @@ def get_application() -> FastAPI:
application.mount("/v3/compute", compute_api, name="compute") application.mount("/v3/compute", compute_api, name="compute")
# Register MCP routes (stub returns 501 if MCP dependencies are not installed) # 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 return application

View File

@ -671,11 +671,7 @@ class BaseNode:
) )
# keep forwarding websocket data in both direction # keep forwarding websocket data in both direction
if sys.version_info >= (3, 11, 0): aws = [asyncio.create_task(ws_forward(telnet_writer)), asyncio.create_task(telnet_forward(telnet_reader))]
# 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)]
done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED)
for task in done: for task in done:
@ -762,11 +758,7 @@ class BaseNode:
log.warning(f"Exception while forwarding VNC data to WebSocket: {e!r}") log.warning(f"Exception while forwarding VNC data to WebSocket: {e!r}")
# Keep forwarding WebSocket data in both directions # Keep forwarding WebSocket data in both directions
if sys.version_info >= (3, 11, 0): aws = [asyncio.create_task(ws_forward(vnc_writer)), asyncio.create_task(vnc_forward(vnc_reader))]
# 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)]
done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED)
for task in done: for task in done:

View File

@ -19,19 +19,14 @@ import asyncio
import io import io
import ipaddress import ipaddress
import json import json
import logging
import socket import socket
import sys import sys
from asyncio import timeout as asynctimeout
import aiohttp import aiohttp
from fastapi import HTTPException 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 ( from ..controller.controller_error import (
ComputeConflictError, ComputeConflictError,
ComputeError, ComputeError,

View File

@ -16,11 +16,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging import logging
from typing import List, Optional, Union, cast from typing import Any, List, Optional, Union, cast
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID from uuid import UUID
from sqlalchemy import delete, select, update from sqlalchemy import Select, delete, select, update
from sqlalchemy.engine import CursorResult from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -331,7 +331,7 @@ class RbacRepository(BaseRepository):
Retrieve all user ACEs matching the user_id and privilege name. 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) select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name)
.join(models.Privilege.roles) .join(models.Privilege.roles)
.join(models.Role.acl_entries) .join(models.Role.acl_entries)
@ -349,7 +349,7 @@ class RbacRepository(BaseRepository):
Retrieve all group ACEs matching the user_id and privilege name. 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) select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name)
.join(models.Privilege.roles) .join(models.Privilege.roles)
.join(models.Role.acl_entries) .join(models.Role.acl_entries)

View File

@ -17,7 +17,7 @@
# General schemas # General schemas
from .common import ErrorMessage from .common import ErrorMessage
from .config import ServerConfig from .config import ServerConfig
from .controller.appliances import Appliance, ApplianceVersion, ApplianceVersionV8 from .controller.appliances import Appliance, ApplianceVersion, ApplianceVersionCreate, ApplianceVersionV8
from .controller.computes import ( from .controller.computes import (
AutoIdlePC, AutoIdlePC,
Compute, Compute,
@ -41,6 +41,7 @@ from .controller.links import (
LinkFilterParameter, LinkFilterParameter,
LinkFilters, LinkFilters,
LinkFilterType, LinkFilterType,
LinkIfaceInfo,
LinkUpdate, LinkUpdate,
MarkerCreate, MarkerCreate,
MarkerDefinitionCreate, MarkerDefinitionCreate,
@ -167,6 +168,7 @@ __all__ = [
"ApiKeyCreate", "ApiKeyCreate",
"Appliance", "Appliance",
"ApplianceVersion", "ApplianceVersion",
"ApplianceVersionCreate",
"ApplianceVersionV8", "ApplianceVersionV8",
"AutoIdlePC", "AutoIdlePC",
"BatchNIOCreate", "BatchNIOCreate",
@ -253,6 +255,7 @@ __all__ = [
"LinkFilterParameter", "LinkFilterParameter",
"LinkFilterType", "LinkFilterType",
"LinkFilters", "LinkFilters",
"LinkIfaceInfo",
"LinkUpdate", "LinkUpdate",
"LoggedInUserUpdate", "LoggedInUserUpdate",
"MarkerCreate", "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)" 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") 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( name: str = Field(
f"{socket.gethostname()} (controller)", f"{socket.gethostname()} (controller)",
description="Server name, default is what is returned by socket.gethostname()", 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 typing import Annotated, List, Literal, Optional, Union
from uuid import UUID 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 from ..common import ExtraConfig
@ -648,6 +648,39 @@ class ApplianceVersionV8(BaseModel):
images: Optional[ApplianceVersionImages] = Field(None, title="Images used for this version") 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 # Child Models with Discriminated Union
# ============================================================================ # ============================================================================

View File

@ -15,10 +15,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from enum import Enum 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 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 ..update import PartialUpdateModel
from .labels import Label from .labels import Label
@ -205,6 +205,7 @@ class UDPPortInfo(BaseModel):
rhost: str rhost: str
rport: int rport: int
type: str type: str
kind: Literal["udp"]
class EthernetPortInfo(BaseModel): class EthernetPortInfo(BaseModel):
@ -215,6 +216,10 @@ class EthernetPortInfo(BaseModel):
node_id: UUID node_id: UUID
interface: str interface: str
type: str type: str
kind: Literal["ethernet"]
LinkIfaceInfo = Annotated[Union[UDPPortInfo, EthernetPortInfo], Discriminator("kind")]
class LinkCapture(BaseModel): class LinkCapture(BaseModel):

View File

@ -103,6 +103,7 @@ class ServerSettingsUpdate(BaseModel):
local: Optional[bool] = None local: Optional[bool] = None
enable_http_auth: Optional[bool] = None enable_http_auth: Optional[bool] = None
openapi_include_ai: Optional[bool] = None
name: Optional[str] = None name: Optional[str] = None
protocol: Optional[ServerProtocol] = None protocol: Optional[ServerProtocol] = None
host: Optional[str] = None host: Optional[str] = None

View File

@ -255,9 +255,9 @@ class Server:
else: else:
log.info(f"Compute authentication is enabled with username '{config.Server.compute_username}'") log.info(f"Compute authentication is enabled with username '{config.Server.compute_username}'")
# we only support Python 3 version >= 3.9 # we only support Python 3 version >= 3.11
if sys.version_info < (3, 9, 0): if sys.version_info < (3, 11, 0):
raise SystemExit("Python 3.9 or higher is required") raise SystemExit("Python 3.11 or higher is required")
log.info( log.info(
f"Running with Python {sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]} and has PID {os.getpid()}" 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 __version__ += "+" + r
except Exception as e: except Exception as e:
print(e) print(e)
__api_version__ = __version__.split("+", 1)[0]

View File

@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "gns3-server" name = "gns3-server"
description = "GNS3 graphical interface for the GNS3 server." description = "GNS3 server (controller and compute) for GNS3 network simulator"
license-files = ["LICENSE"] license-files = ["LICENSE"]
authors = [ authors = [
{ name = "Jeremy Grossmann", email = "developers@gns3.com" } { name = "Jeremy Grossmann", email = "developers@gns3.com" }
] ]
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.11"
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
"Environment :: Console", "Environment :: Console",
@ -21,10 +21,10 @@ classifiers = [
"Natural Language :: English", "Natural Language :: English",
"Operating System :: POSIX :: Linux", "Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython" "Programming Language :: Python :: Implementation :: CPython"
] ]
@ -219,6 +219,7 @@ extend-immutable-calls = [
"scripts/welcome.py" = ["C901", "E722", "PLW1510", "S108", "S112", "S603", "S605", "S606", "S607"] "scripts/welcome.py" = ["C901", "E722", "PLW1510", "S108", "S112", "S603", "S605", "S606", "S607"]
"tests/agent/mcp/test_handlers.py" = ["B905"] "tests/agent/mcp/test_handlers.py" = ["B905"]
"tests/agent/mcp/test_tool_params.py" = ["C901"] "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_images.py" = ["S324", "W292"]
"tests/api/routes/controller/test_links.py" = ["RUF015", "W292"] "tests/api/routes/controller/test_links.py" = ["RUF015", "W292"]
"tests/api/routes/controller/test_users.py" = ["F811"] "tests/api/routes/controller/test_users.py" = ["F811"]

View File

@ -1,19 +1,17 @@
# GNS3 Server Core Dependencies # GNS3 Server Core Dependencies
uvicorn==0.54.0 uvicorn==0.54.0
pydantic==2.13.5 pydantic==2.13.5
fastapi==0.141.1 fastapi==0.142.4
python-multipart==0.0.32 python-multipart==0.0.32
websockets==16.1.1 # version 16.1.1 is the last version that supports Python 3.10 websockets==16.1.1 # google-genai 2.10.0 has requirement websockets<17.0
aiohttp>=3.14.3,<3.15 aiohttp>=3.14.4,<3.15
aiofiles>=25.1.0,<26.0 aiofiles>=25.1.0,<26.0
Jinja2>=3.1.6,<3.2 Jinja2>=3.1.6,<3.2
sentry-sdk>=2.70.0,<3 # optional dependency sentry-sdk>=2.70.0,<3 # optional dependency
psutil>=7.2.2 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 distro>=1.9.0
py-cpuinfo>=9.0.0,<10.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[asyncio]==2.1.4
sqlalchemy==2.0.54 # version 2.0.54 is the last version that supports Python 3.10
aiosqlite==0.22.1 aiosqlite==0.22.1
alembic==1.20 alembic==1.20
bcrypt==5.0.0 bcrypt==5.0.0
@ -22,7 +20,7 @@ email-validator==2.3.0
watchdog==6.0.0 watchdog==6.0.0
zstandard==0.25.0 zstandard==0.25.0
platformdirs>=2.4.0 # fastmcp-slim >=3.4 requires >=4.0.0; upper bound removed for compatibility 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) # Shared dependencies (also used by AI Copilot)
telnetlib3==5.0.1,<6 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 app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version
) )
assert response.status_code == status.HTTP_409_CONFLICT 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

@ -608,6 +608,7 @@ class TestLinkRoutes:
assert result["rhost"] == "127.0.0.1" assert result["rhost"] == "127.0.0.1"
assert result["rport"] == 30000 assert result["rport"] == 30000
assert result["type"] == "udp" assert result["type"] == "udp"
assert result["kind"] == "udp"
async def test_get_ethernet_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None: async def test_get_ethernet_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
""" """
@ -638,3 +639,4 @@ class TestLinkRoutes:
assert result["node_id"] == cloud_node.id assert result["node_id"] == cloud_node.id
assert result["interface"] == "eth0" assert result["interface"] == "eth0"
assert result["type"] == "ethernet" 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 pydantic
import pytest import pytest
from pydantic import TypeAdapter
from gns3server.controller.appliance import Appliance from gns3server.controller.appliance import Appliance
from gns3server.controller.appliance_to_template import ApplianceToTemplate 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) # v8 mirror of the XRd Control Plane appliance shape (docker, custom_adapters, no versions)
XRD_V8 = { XRD_V8 = {
@ -242,3 +253,44 @@ def test_v8_netmiko_device_type_empty_string_clears():
appliance = dict(XRD_V8, netmiko_device_type="") appliance = dict(XRD_V8, netmiko_device_type="")
model = ApplianceModel.model_validate(appliance) model = ApplianceModel.model_validate(appliance)
assert model.netmiko_device_type == "" 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})