Merge branch '3.1' into api-issue-07-x7z6we

This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-10-08 19:10:42 +02:00
commit 2dd1652e8a
30 files changed
+549 -72

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
#
# 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/>.
from typing import Any, Dict
BINARY_SCHEMA: Dict[str, Any] = {"type": "string", "format": "binary"}
NOTIFICATION_LINE_SCHEMA: Dict[str, Any] = {
"type": "object",
"description": "One notification event per line",
"properties": {
"action": {"type": "string"},
"event": {"description": "Event payload, its shape depends on the action"},
},
"required": ["action", "event"],
"additionalProperties": True,
}
PCAP_MEDIA_TYPE = "application/vnd.tcpdump.pcap"
NDJSON_MEDIA_TYPE = "application/x-ndjson"
OCTET_STREAM_MEDIA_TYPE = "application/octet-stream"
ZIP_MEDIA_TYPE = "application/zip"
def binary_response(media_type: str = OCTET_STREAM_MEDIA_TYPE, description: str = "Binary content") -> Dict[str, Any]:
return {"description": description, "content": {media_type: {"schema": BINARY_SCHEMA}}}
def ndjson_response(description: str = "Stream of newline-delimited JSON notifications") -> Dict[str, Any]:
return {"description": description, "content": {NDJSON_MEDIA_TYPE: {"schema": NOTIFICATION_LINE_SCHEMA}}}
def binary_request_body(media_type: str = OCTET_STREAM_MEDIA_TYPE) -> Dict[str, Any]:
return {"requestBody": {"required": True, "content": {media_type: {"schema": BINARY_SCHEMA}}}}
+3 -1
View File
@@ -32,6 +32,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,
@@ -61,8 +62,9 @@ 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",
generate_unique_id_function=generate_operation_id,
version=__api_version__,
servers=[{"url": "/v3/compute"}],
)
compute_api.state.controller_host = None
+19 -2
View File
@@ -19,6 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
# Check AI Copilot availability
from gns3server.agent import AI_COPILOT_AVAILABLE
from gns3server.api.operation_ids import add_stub_routes
from gns3server.config import Config
# Conditionally import AI-dependent routes
if AI_COPILOT_AVAILABLE:
@@ -33,6 +34,11 @@ else:
_copilot_router = APIRouter()
_llm_router = APIRouter()
@_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,
@@ -69,6 +75,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"])
@@ -118,11 +126,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(
@@ -130,6 +146,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()
@@ -32,6 +32,7 @@ from starlette.requests import ClientDisconnect
import gns3server.db.models as models
from gns3server import schemas
from gns3server.api.openapi import binary_request_body
from gns3server.compute.qemu import Qemu
from gns3server.config import Config
from gns3server.controller import Controller
@@ -214,6 +215,7 @@ async def get_images(
response_model=schemas.ImageUpload,
response_model_exclude_unset=True,
status_code=status.HTTP_201_CREATED,
openapi_extra=binary_request_body(),
dependencies=[Depends(has_privilege("Image.Allocate"))],
)
async def upload_image(
+11 -3
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
@@ -31,6 +31,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from gns3server import schemas
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
from gns3server.api.openapi import PCAP_MEDIA_TYPE, binary_response
from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError
from gns3server.controller.link import _UNSET, Link
@@ -330,7 +331,12 @@ async def restart_wireshark(http_request: Request, link: Link = Depends(dep_link
return {"status": "restarted"}
@router.get("/{link_id}/capture/stream", dependencies=[Depends(has_privilege("Link.Capture"))])
@router.get(
"/{link_id}/capture/stream",
response_class=StreamingResponse,
responses={200: binary_response(PCAP_MEDIA_TYPE, "Packet capture stream")},
dependencies=[Depends(has_privilege("Link.Capture"))],
)
async def stream_pcap(request: Request, link: Link = Depends(dep_link)) -> StreamingResponse:
"""
Stream the PCAP capture file from compute.
@@ -539,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:
@@ -570,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"],
@@ -578,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"],
+14 -1
View File
@@ -32,6 +32,7 @@ from fastapi.responses import StreamingResponse
from fastapi.routing import APIRoute
from gns3server import schemas
from gns3server.api.openapi import binary_request_body, binary_response
from gns3server.config import Config
from gns3server.controller import Controller
from gns3server.controller.controller_error import (
@@ -632,7 +633,12 @@ async def list_node_files(
return res.json
@router.get("/{node_id}/files/{file_path:path}", dependencies=[Depends(has_privilege("Node.Audit"))])
@router.get(
"/{node_id}/files/{file_path:path}",
response_class=StreamingResponse,
responses={200: binary_response(description="File content")},
dependencies=[Depends(has_privilege("Node.Audit"))],
)
async def get_file(file_path: str, node: Node = Depends(dep_node)) -> Response:
"""
Return a file from the node directory.
@@ -673,6 +679,13 @@ async def get_file(file_path: str, node: Node = Depends(dep_node)) -> Response:
@router.post(
"/{node_id}/files/{file_path:path}",
status_code=status.HTTP_201_CREATED,
responses={
201: {
"description": "File written",
"content": {"application/json": {"schema": {"type": "null"}}},
}
},
openapi_extra=binary_request_body(),
dependencies=[Depends(has_privilege("Node.Modify"))],
)
async def post_file(file_path: str, request: Request, node: Node = Depends(dep_node)):
+26 -3
View File
@@ -51,6 +51,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from websockets.exceptions import ConnectionClosed, WebSocketException
from gns3server import schemas
from gns3server.api.openapi import ZIP_MEDIA_TYPE, binary_request_body, binary_response
from gns3server.api.responses import NDJSON_MEDIA_TYPE, NOTIFICATION_STREAM_RESPONSES, NDJSONResponse
from gns3server.controller import Controller, marker_replay
from gns3server.controller.controller_error import ControllerBadRequestError, ControllerError
@@ -688,7 +689,12 @@ async def project_marker_ws_notifications(
log.warning(f"Error while sending marker event to WebSocket client: {e}")
@router.get("/{project_id}/export", dependencies=[Depends(has_privilege("Project.Audit"))])
@router.get(
"/{project_id}/export",
response_class=StreamingResponse,
responses={200: binary_response(ZIP_MEDIA_TYPE, "Portable project archive")},
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def export_project(
project: Project = Depends(dep_project),
include_snapshots: bool = False,
@@ -774,6 +780,7 @@ async def export_project(
"/{project_id}/import",
status_code=status.HTTP_201_CREATED,
response_model=schemas.Project,
openapi_extra=binary_request_body(ZIP_MEDIA_TYPE),
dependencies=[Depends(has_privilege("Project.Allocate"))],
)
async def import_project(project_id: UUID, request: Request, name: Optional[str] = None) -> schemas.Project:
@@ -898,7 +905,12 @@ async def unlock_project(project: Project = Depends(dep_project)) -> None:
project.unlock()
@router.get("/{project_id}/files/{file_path:path}", dependencies=[Depends(has_privilege("Project.Audit"))])
@router.get(
"/{project_id}/files/{file_path:path}",
response_class=FileResponse,
responses={200: binary_response(description="File content")},
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def get_file(file_path: str, project: Project = Depends(dep_project)) -> FileResponse:
"""
Return a file from a project.
@@ -920,7 +932,17 @@ async def get_file(file_path: str, project: Project = Depends(dep_project)) -> F
return FileResponse(path, media_type="application/octet-stream")
@router.get("/{project_id}/gns3file", dependencies=[Depends(has_privilege("Project.Audit"))])
@router.get(
"/{project_id}/gns3file",
response_class=FileResponse,
responses={
200: {
"description": "Project topology file",
"content": {"application/json": {"schema": {"type": "object"}}},
}
},
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def get_project_gns3_file(project: Project = Depends(dep_project)) -> FileResponse:
"""
Return the .gns3 topology file of a project.
@@ -938,6 +960,7 @@ async def get_project_gns3_file(project: Project = Depends(dep_project)) -> File
@router.post(
"/{project_id}/files/{file_path:path}",
status_code=status.HTTP_204_NO_CONTENT,
openapi_extra=binary_request_body(),
dependencies=[Depends(has_privilege("Project.Modify"))],
)
async def write_file(file_path: str, request: Request, project: Project = Depends(dep_project)) -> None:
+7 -1
View File
@@ -27,6 +27,7 @@ from fastapi import APIRouter, Depends, Request, Response, status
from fastapi.responses import FileResponse
from gns3server import schemas
from gns3server.api.openapi import binary_request_body, binary_response
from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerError, ControllerForbiddenError, ControllerNotFoundError
from gns3server.utils.get_resource import get_resource
@@ -53,7 +54,11 @@ def get_symbols() -> List[dict]:
@router.get(
"/{symbol_id:path}/raw",
responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}},
response_class=FileResponse,
responses={
200: binary_response(description="Symbol file"),
404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"},
},
dependencies=[Depends(has_privilege("Symbol.Audit"))],
)
async def get_symbol(symbol_id: str, request: Request) -> Response:
@@ -112,6 +117,7 @@ def get_default_symbols() -> dict:
@router.post(
"/{symbol_id:path}/raw",
status_code=status.HTTP_204_NO_CONTENT,
openapi_extra=binary_request_body(),
dependencies=[Depends(has_privilege("Symbol.Allocate"))],
)
async def upload_symbol(symbol_id: str, request: Request) -> None:
+12 -10
View File
@@ -40,6 +40,7 @@ from gns3server.api.operation_ids import add_stub_routes, generate_operation_id
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,
@@ -50,6 +51,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
@@ -60,6 +62,7 @@ else:
_mcp_router = APIRouter(prefix="/mcp", tags=["MCP"])
@_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,
@@ -86,15 +89,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,
generate_unique_id_function=generate_operation_id,
@@ -114,7 +111,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