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

This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-10-08 19:01:57 +02:00
commit 7a3a34d3fc
24 files changed
+378 -70

No files matched your search

@@ -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__)
+3 -1
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
+19 -5
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"])
@@ -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()
+4 -2
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
@@ -545,7 +545,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:
@@ -576,6 +576,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"],
@@ -584,6 +585,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"],
+12 -11
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
+2 -10
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:
+2 -7
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,
+4 -4
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)
+4 -1
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,
@@ -41,6 +41,7 @@ from .controller.links import (
LinkFilterParameter,
LinkFilters,
LinkFilterType,
LinkIfaceInfo,
LinkUpdate,
MarkerCreate,
MarkerDefinitionCreate,
@@ -167,6 +168,7 @@ __all__ = [
"ApiKeyCreate",
"Appliance",
"ApplianceVersion",
"ApplianceVersionCreate",
"ApplianceVersionV8",
"AutoIdlePC",
"BatchNIOCreate",
@@ -253,6 +255,7 @@ __all__ = [
"LinkFilterParameter",
"LinkFilterType",
"LinkFilters",
"LinkIfaceInfo",
"LinkUpdate",
"LoggedInUserUpdate",
"MarkerCreate",
+3
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()",
+34 -1
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
# ============================================================================
+7 -2
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 ..update import PartialUpdateModel
from .labels import Label
@@ -205,6 +205,7 @@ class UDPPortInfo(BaseModel):
rhost: str
rport: int
type: str
kind: Literal["udp"]
class EthernetPortInfo(BaseModel):
@@ -215,6 +216,10 @@ class EthernetPortInfo(BaseModel):
node_id: UUID
interface: str
type: str
kind: Literal["ethernet"]
LinkIfaceInfo = Annotated[Union[UDPPortInfo, EthernetPortInfo], Discriminator("kind")]
class LinkCapture(BaseModel):
@@ -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
+3 -3
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()}"
+2
View File
@@ -35,3 +35,5 @@ if "dev" in __version__:
__version__ += "+" + r
except Exception as e:
print(e)
__api_version__ = __version__.split("+", 1)[0]