diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 42916ecf5..4f834576e 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v7 diff --git a/gns3server/agent/web_wireshark/docker_client.py b/gns3server/agent/web_wireshark/docker_client.py index 00f62edd2..e030eab35 100644 --- a/gns3server/agent/web_wireshark/docker_client.py +++ b/gns3server/agent/web_wireshark/docker_client.py @@ -23,16 +23,11 @@ This module handles Docker API operations via aiohttp Unix socket. import asyncio import logging -import sys +from asyncio import timeout as async_timeout from typing import Any import aiohttp -if sys.version_info >= (3, 11): - from asyncio import timeout as async_timeout -else: - from async_timeout import timeout as async_timeout - from gns3server.utils import parse_version logger = logging.getLogger(__name__) diff --git a/gns3server/api/errors.py b/gns3server/api/errors.py new file mode 100644 index 000000000..adf9d35a9 --- /dev/null +++ b/gns3server/api/errors.py @@ -0,0 +1,79 @@ +#!/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 . + +""" +Error response helpers shared by the exception handlers and the OpenAPI schema. +""" + +from collections.abc import Mapping +from http import HTTPStatus +from typing import Any + +from fastapi.responses import JSONResponse + +from gns3server.schemas.common import ErrorMessage + +AUTHENTICATED_ERROR_RESPONSES = { + 401: "Authentication is required or the credentials are invalid", + 403: "Not enough privileges to perform this operation", + 422: "Request validation error", +} + + +def code_for_status(status_code: int) -> str: + try: + phrase = HTTPStatus(status_code).phrase + except ValueError: + return "error" + return phrase.lower().replace(" ", "_").replace("-", "_") + + +def error_response( + status_code: int, + message: Any, + code: str | None = None, + details: dict[str, Any] | None = None, + headers: Mapping[str, str] | None = None, +) -> JSONResponse: + content: dict[str, Any] = {"message": message, "code": code or code_for_status(status_code)} + if details: + content["details"] = details + return JSONResponse(status_code=status_code, content=content, headers=headers) + + +def customize_openapi_errors(schema: dict[str, Any]) -> dict[str, Any]: + components = schema.setdefault("components", {}).setdefault("schemas", {}) + components.pop("HTTPValidationError", None) + components.pop("ValidationError", None) + if "ErrorMessage" not in components: + error_schema = ErrorMessage.model_json_schema(ref_template="#/components/schemas/{model}") + components["ErrorMessage"] = {k: v for k, v in error_schema.items() if k != "$defs"} + + for path_item in schema.get("paths", {}).values(): + for operation in path_item.values(): + if not isinstance(operation, dict) or "responses" not in operation: + continue + responses = operation["responses"] + wanted = ["422"] if "422" in responses else [] + if operation.get("security"): + wanted = ["401", "403", "422"] + for status_code in wanted: + responses[status_code] = { + "description": AUTHENTICATED_ERROR_RESPONSES[int(status_code)], + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorMessage"}}}, + } + return schema diff --git a/gns3server/api/openapi.py b/gns3server/api/openapi.py new file mode 100644 index 000000000..cf5dce209 --- /dev/null +++ b/gns3server/api/openapi.py @@ -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 . + +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}}}} diff --git a/gns3server/api/operation_ids.py b/gns3server/api/operation_ids.py new file mode 100644 index 000000000..ae8c9a2ae --- /dev/null +++ b/gns3server/api/operation_ids.py @@ -0,0 +1,55 @@ +#!/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 . + +""" +Readable and unique OpenAPI operationIds. +""" + +import re +from typing import Callable + +from fastapi import APIRouter +from fastapi.routing import APIRoute + +STUB_METHODS = ("GET", "POST", "DELETE", "PATCH", "PUT") + + +def _slug(value: str) -> str: + + return re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_") + + +def generate_operation_id(route: APIRoute) -> str: + """ + Builds an operationId such as "nodes_get_node" from the first tag and the route name. + """ + + name = _slug(route.name) + if route.tags: + tag = _slug(str(route.tags[0])) + if tag: + return f"{tag}_{name}" + return name + + +def add_stub_routes(router: APIRouter, endpoint: Callable, name: str) -> None: + """ + Registers a catch-all route per HTTP method so that every method gets its own operationId. + """ + + for method in STUB_METHODS: + router.add_api_route("/{path:path}", endpoint, methods=[method], name=f"{name}_{method.lower()}") diff --git a/gns3server/api/responses.py b/gns3server/api/responses.py new file mode 100644 index 000000000..55330c038 --- /dev/null +++ b/gns3server/api/responses.py @@ -0,0 +1,41 @@ +# +# 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 . + +from typing import Any + +from fastapi.responses import JSONResponse + +from gns3server import schemas + +NDJSON_MEDIA_TYPE = "application/x-ndjson" + + +class NDJSONResponse(JSONResponse): + """ + Only used as the response_class of streaming routes to publish the application/x-ndjson media type + and the schema of the streamed objects in the OpenAPI document. + """ + + media_type = NDJSON_MEDIA_TYPE + + +NOTIFICATION_STREAM_RESPONSES: dict[int | str, dict[str, Any]] = { + 200: { + "model": schemas.Notification, + "description": "Stream of notifications, one JSON object per line. " + "Each line is a Notification, the first one is a ping and then a ping is sent at least every 5 seconds.", + } +} diff --git a/gns3server/api/routes/compute/__init__.py b/gns3server/api/routes/compute/__init__.py index 934243922..8e48ee48d 100644 --- a/gns3server/api/routes/compute/__init__.py +++ b/gns3server/api/routes/compute/__init__.py @@ -21,6 +21,7 @@ from fastapi import Depends, FastAPI, Request from fastapi.responses import JSONResponse from starlette.exceptions import HTTPException as StarletteHTTPException +from gns3server.api.operation_ids import generate_operation_id from gns3server.compute.compute_error import ( ComputeError, ComputeForbiddenError, @@ -31,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, @@ -60,7 +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 diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py index e22d3d507..9ebf5a2df 100644 --- a/gns3server/api/routes/controller/__init__.py +++ b/gns3server/api/routes/controller/__init__.py @@ -18,6 +18,8 @@ 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: @@ -32,15 +34,21 @@ 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, detail="AI Copilot is not available. Install AI dependencies with: pip install gns3-server[ai-features]", ) + add_stub_routes(_chat_router, ai_not_available, "chat_not_available") + add_stub_routes(_copilot_router, ai_not_available, "copilot_not_available") + add_stub_routes(_llm_router, ai_not_available, "llm_not_available") + from . import ( acl, @@ -67,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"]) @@ -116,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( @@ -128,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"]) diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py index ad8645554..1d3f0e413 100644 --- a/gns3server/api/routes/controller/appliances.py +++ b/gns3server/api/routes/controller/appliances.py @@ -19,7 +19,7 @@ API routes for appliances. """ import logging -from typing import List, Optional, Union +from typing import List, Optional from uuid import UUID from fastapi import APIRouter, Depends, status @@ -85,9 +85,7 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance: status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Appliance.Allocate"))], ) -def add_appliance_version( - appliance_id: UUID, appliance_version: Union[schemas.ApplianceVersion, schemas.ApplianceVersionV8] -) -> dict: +def add_appliance_version(appliance_id: UUID, appliance_version: schemas.ApplianceVersionCreate) -> dict: """ Add a version to an appliance. @@ -109,7 +107,7 @@ def add_appliance_version( if version.get("name") == appliance_version.name: raise ControllerError(message=f"Appliance '{appliance_id}' already has version '{appliance_version.name}'") - appliance.versions.append(appliance_version.model_dump(exclude_unset=True)) + appliance.versions.append(appliance_version.model_dump(exclude_unset=True, exclude={"registry_version"})) return appliance.asdict() diff --git a/gns3server/api/routes/controller/controller.py b/gns3server/api/routes/controller/controller.py index 1b8667ec8..cef3ee77e 100644 --- a/gns3server/api/routes/controller/controller.py +++ b/gns3server/api/routes/controller/controller.py @@ -30,6 +30,7 @@ from websockets.exceptions import ConnectionClosed, WebSocketException from gns3server import schemas from gns3server.agent.web_wireshark.stats import collect_webwireshark_stats +from gns3server.api.responses import NDJSON_MEDIA_TYPE, NOTIFICATION_STREAM_RESPONSES, NDJSONResponse from gns3server.config import Config from gns3server.controller import Controller from gns3server.controller.controller_error import ControllerError, ControllerForbiddenError @@ -254,10 +255,19 @@ async def statistics() -> dict: } -@router.get("/notifications", dependencies=[Depends(get_current_active_user)]) +@router.get( + "/notifications", + dependencies=[Depends(get_current_active_user)], + response_class=NDJSONResponse, + responses=NOTIFICATION_STREAM_RESPONSES, +) async def controller_http_notifications(request: Request) -> StreamingResponse: """ - Receive controller notifications about the controller from HTTP stream. + Receive controller notifications from an HTTP stream of newline delimited JSON objects (see the Notification schema). + + The same messages are available as WebSocket text frames on `/v3/notifications/ws`. + Controller actions: `compute.*`, `template.*`, `project.created`, `project.updated`, `project.opened`, + `project.closed`, `project.deleted`, `settings.updated`, `log.*` and `ping`. """ from gns3server.api.server import app @@ -274,7 +284,7 @@ async def controller_http_notifications(request: Request) -> StreamingResponse: finally: log.info(f"Client {client} has disconnected from controller HTTP notification stream") - return StreamingResponse(event_stream(), media_type="application/json") + return StreamingResponse(event_stream(), media_type=NDJSON_MEDIA_TYPE) @router.websocket("/notifications/ws") @@ -282,7 +292,8 @@ async def controller_ws_notifications( websocket: WebSocket, current_user: schemas.User = Depends(get_current_active_user_from_websocket) ) -> None: """ - Receive project notifications about the controller from WebSocket. + Receive controller notifications from a WebSocket, one Notification JSON object per text frame. + Same messages as the HTTP stream `/v3/notifications`. """ if current_user is None: diff --git a/gns3server/api/routes/controller/dependencies/authentication.py b/gns3server/api/routes/controller/dependencies/authentication.py index b5689ade8..9fa30672e 100644 --- a/gns3server/api/routes/controller/dependencies/authentication.py +++ b/gns3server/api/routes/controller/dependencies/authentication.py @@ -36,7 +36,12 @@ from gns3server.services.access_tickets import TICKET_PREFIX from .database import get_repository log = logging.getLogger(__name__) -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v3/access/users/login", auto_error=False) +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl="/v3/access/users/login", + auto_error=False, + description="Send the access token as a bearer token. " + "An API key (gns3__) is also accepted as the bearer token.", +) def _reject_refresh_token(token_data) -> None: diff --git a/gns3server/api/routes/controller/dependencies/concurrency.py b/gns3server/api/routes/controller/dependencies/concurrency.py new file mode 100644 index 000000000..a67b6d595 --- /dev/null +++ b/gns3server/api/routes/controller/dependencies/concurrency.py @@ -0,0 +1,115 @@ +# +# 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 . + +""" +Optional optimistic concurrency (ETag / If-Match) for controller resources. +""" + +import asyncio +import contextlib +import hashlib +import json +from typing import Any, AsyncIterator, Optional + +from fastapi import Header, HTTPException, Response, status +from fastapi.encoders import jsonable_encoder + +from gns3server import schemas + +ETAG_RESPONSE_HEADERS: dict[str, Any] = { + "ETag": { + "description": "Entity tag of the current representation of the resource", + "schema": {"type": "string"}, + } +} + +PRECONDITION_FAILED_RESPONSE: dict[int | str, dict[str, Any]] = { + status.HTTP_412_PRECONDITION_FAILED: { + "model": schemas.ErrorMessage, + "description": "The If-Match header does not match the current ETag of the resource", + } +} + +GET_RESPONSES: dict[int | str, dict[str, Any]] = {status.HTTP_200_OK: {"headers": ETAG_RESPONSE_HEADERS}} + +PUT_RESPONSES: dict[int | str, dict[str, Any]] = {**GET_RESPONSES, **PRECONDITION_FAILED_RESPONSE} + +_locks: dict[str, list[Any]] = {} + + +def compute_etag(resource: Any) -> str: + """ + Compute a strong entity tag from the serialized resource. + """ + + payload = json.dumps(jsonable_encoder(resource), sort_keys=True, separators=(",", ":"), default=str) + return f'"{hashlib.sha256(payload.encode()).hexdigest()[:32]}"' + + +def set_etag(response: Response, resource: Any) -> None: + """ + Add the ETag header of the resource to the response. + """ + + response.headers["ETag"] = compute_etag(resource) + + +def if_match_header( + if_match: Optional[str] = Header( + None, + alias="If-Match", + description="Only apply the update if this entity tag matches the current resource", + ), +) -> Optional[str]: + """ + Dependency returning the raw If-Match request header. + """ + + return if_match + + +def check_if_match(if_match: Optional[str], resource: Any) -> None: + """ + Raise a 412 error if If-Match is present and does not match the resource. + """ + + if if_match is None: + return + candidates = [tag.strip() for tag in if_match.split(",")] + if "*" in candidates: + return + if compute_etag(resource) not in candidates: + raise HTTPException( + status_code=status.HTTP_412_PRECONDITION_FAILED, + detail="The resource was modified since it was last retrieved (If-Match does not match the current ETag)", + ) + + +@contextlib.asynccontextmanager +async def serialize_updates(key: str) -> AsyncIterator[None]: + """ + Serialize updates of the same resource so that the If-Match check and the update are atomic. + """ + + entry = _locks.setdefault(key, [asyncio.Lock(), 0]) + entry[1] += 1 + try: + async with entry[0]: + yield + finally: + entry[1] -= 1 + if entry[1] <= 0: + _locks.pop(key, None) diff --git a/gns3server/api/routes/controller/images.py b/gns3server/api/routes/controller/images.py index f35c2417f..c82f2464a 100644 --- a/gns3server/api/routes/controller/images.py +++ b/gns3server/api/routes/controller/images.py @@ -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( diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index b7fa2b40d..79a471787 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -20,25 +20,34 @@ API routes for links. import logging import os -from typing import Any, List, Union +from typing import Any, List, Optional from uuid import UUID, uuid4 import aiohttp import multidict -from fastapi import APIRouter, Depends, Request, WebSocket, status +from fastapi import APIRouter, Depends, Request, Response, WebSocket, status from fastapi.encoders import jsonable_encoder 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 +from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError from gns3server.controller.link import _UNSET, Link from gns3server.db.repositories.rbac import RbacRepository from gns3server.utils.http_client import HTTPClient from gns3server.utils.port_allocator import link_id_to_port from gns3server.utils.websocket_to_websocket import websocket_proxy +from .dependencies.concurrency import ( + GET_RESPONSES, + PUT_RESPONSES, + check_if_match, + if_match_header, + serialize_updates, + set_etag, +) from .dependencies.database import get_repository from .dependencies.rbac import has_privilege, has_privilege_on_websocket @@ -51,6 +60,29 @@ responses: dict[int | str, dict[str, Any]] = { router = APIRouter(responses=responses) +def _select_port(node, endpoint, other_port): + if endpoint.get("port_name") is not None: + port = node.get_port_by_name(endpoint["port_name"]) + if port is None: + raise ControllerNotFoundError(f"Port named {endpoint['port_name']} for {node.name} not found") + return port + if endpoint.get("port") == "auto": + port = node.get_free_port(other_port.link_type if other_port else None) + if port is None: + raise ControllerError(f"No free port available on {node.name}", code="no_free_port") + return port + return None + + +def _peer_port(project, endpoint): + if endpoint.get("port") == "auto": + return None + node = project.get_node(endpoint["node_id"]) + if endpoint.get("port_name") is not None: + return node.get_port_by_name(endpoint["port_name"]) + return node.get_port(endpoint["adapter_number"], endpoint["port_number"]) + + async def dep_link(project_id: UUID, link_id: UUID) -> Link: """ Dependency to retrieve a link. @@ -110,21 +142,30 @@ async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> sche if "show_filters_icon" in link_data: await link.update_show_filters_icon(link_data["show_filters_icon"]) try: - for node in link_data["nodes"]: - await link.add_node( - project.get_node(node["node_id"]), - node.get("adapter_number", 0), - node.get("port_number", 0), - label=node.get("label"), - ) + endpoints = link_data["nodes"] + attached_port = None + for index, endpoint in enumerate(endpoints): + node = project.get_node(endpoint["node_id"]) + peer_port = attached_port or _peer_port(project, endpoints[1 - index]) + port = _select_port(node, endpoint, peer_port) + adapter_number = port.adapter_number if port else endpoint["adapter_number"] + port_number = port.port_number if port else endpoint["port_number"] + await link.add_node(node, adapter_number, port_number, label=endpoint.get("label")) + attached_port = node.get_port(adapter_number, port_number) except ControllerError as e: + link.release_ports() await project.delete_link(link.id) raise e return link.asdict() -@router.get("/{link_id}/available_filters", dependencies=[Depends(has_privilege("Link.Audit"))]) -async def get_filters(link: Link = Depends(dep_link)) -> List[dict]: +@router.get( + "/{link_id}/available_filters", + response_model=List[schemas.LinkFilterDefinition], + response_model_exclude_unset=True, + dependencies=[Depends(has_privilege("Link.Audit"))], +) +async def get_filters(link: Link = Depends(dep_link)) -> List[schemas.LinkFilterDefinition]: """ Return all filters available for a given link. @@ -138,43 +179,58 @@ async def get_filters(link: Link = Depends(dep_link)) -> List[dict]: "/{link_id}", response_model=schemas.Link, response_model_exclude_unset=True, + responses=GET_RESPONSES, dependencies=[Depends(has_privilege("Link.Audit"))], ) -async def get_link(link: Link = Depends(dep_link)) -> schemas.Link: +async def get_link(response: Response, link: Link = Depends(dep_link)) -> schemas.Link: """ Return a link. Required privilege: Link.Audit """ - return link.asdict() + link_dict = link.asdict() + set_etag(response, link_dict) + return link_dict @router.put( "/{link_id}", response_model=schemas.Link, response_model_exclude_unset=True, + responses=PUT_RESPONSES, dependencies=[Depends(has_privilege("Link.Modify"))], ) -async def update_link(link_update: schemas.LinkUpdate, link: Link = Depends(dep_link)) -> schemas.Link: +async def update_link( + link_update: schemas.LinkUpdate, + response: Response, + link: Link = Depends(dep_link), + if_match: Optional[str] = Depends(if_match_header), +) -> schemas.Link: """ Update a link. + If the If-Match header is present, the update is only applied when it matches the current ETag. + Required privilege: Link.Modify """ link_data = jsonable_encoder(link_update, exclude_unset=True) - if "filters" in link_data: - await link.update_filters(link_data["filters"]) - if "link_style" in link_data: - await link.update_link_style(link_data["link_style"]) - if "suspend" in link_data: - await link.update_suspend(link_data["suspend"]) - if "show_filters_icon" in link_data: - await link.update_show_filters_icon(link_data["show_filters_icon"]) - if "nodes" in link_data: - await link.update_nodes(link_data["nodes"]) - return link.asdict() + async with serialize_updates(f"link:{link.id}"): + check_if_match(if_match, link.asdict()) + if "filters" in link_data: + await link.update_filters(link_data["filters"]) + if "link_style" in link_data: + await link.update_link_style(link_data["link_style"]) + if "suspend" in link_data: + await link.update_suspend(link_data["suspend"]) + if "show_filters_icon" in link_data: + await link.update_show_filters_icon(link_data["show_filters_icon"]) + if "nodes" in link_data: + await link.update_nodes(link_data["nodes"]) + link_dict = link.asdict() + set_etag(response, link_dict) + return link_dict @router.delete( @@ -275,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. @@ -484,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: @@ -515,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"], @@ -523,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"], diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index 18134ee36..4000f0da6 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -32,15 +32,28 @@ 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 ControllerBadRequestError, ControllerForbiddenError +from gns3server.controller.controller_error import ( + ControllerBadRequestError, + ControllerForbiddenError, + ControllerTimeoutError, +) from gns3server.controller.node import Node from gns3server.controller.project import Project from gns3server.db.repositories.rbac import RbacRepository from gns3server.utils import force_unix_path from gns3server.utils.http_client import HTTPClient +from .dependencies.concurrency import ( + GET_RESPONSES, + PUT_RESPONSES, + check_if_match, + if_match_header, + serialize_updates, + set_etag, +) from .dependencies.database import get_repository from .dependencies.rbac import has_privilege, has_privilege_on_websocket @@ -256,8 +269,13 @@ async def reload_all_nodes(project: Project = Depends(dep_project)) -> None: _HOST_INTERFACE_NODE_TYPES = {"cloud", "nat"} -@router.get("/{node_id}", response_model=schemas.Node, dependencies=[Depends(has_privilege("Node.Audit"))]) -async def get_node(node: Node = Depends(dep_node)) -> schemas.Node: +@router.get( + "/{node_id}", + response_model=schemas.Node, + responses=GET_RESPONSES, + dependencies=[Depends(has_privilege("Node.Audit"))], +) +async def get_node(response: Response, node: Node = Depends(dep_node)) -> schemas.Node: """ Return a node from a given project. @@ -266,31 +284,50 @@ async def get_node(node: Node = Depends(dep_node)) -> schemas.Node: if node.node_type in _HOST_INTERFACE_NODE_TYPES: try: - response = await node.get() - await node.parse_node_response(response.json) + node_response = await node.get() + await node.parse_node_response(node_response.json) except Exception: # If compute is unreachable, still return cached data log.warning(f"Could not refresh node {node.id} from compute, returning cached data") - return node.asdict() + node_dict = node.asdict() + set_etag(response, node_dict) + return node_dict @router.put( "/{node_id}", response_model=schemas.Node, response_model_exclude_unset=True, + responses=PUT_RESPONSES, dependencies=[Depends(has_privilege("Node.Modify"))], ) -async def update_node(node_update: schemas.NodeUpdate, node: Node = Depends(dep_node)) -> schemas.Node: +async def update_node( + node_update: schemas.NodeUpdate, + response: Response, + node: Node = Depends(dep_node), + if_match: Optional[str] = Depends(if_match_header), +) -> schemas.Node: """ Update a node. + If the If-Match header is present, the update is only applied when it matches the current ETag. + Required privilege: Node.Modify """ node_data = jsonable_encoder(node_update, exclude_unset=True) - await node.update(**node_data) - return node.asdict() + # Ignore these because we only use them when creating a node + node_data.pop("node_id", None) + node_data.pop("node_type", None) + node_data.pop("compute_id", None) + + async with serialize_updates(f"node:{node.id}"): + check_if_match(if_match, node.asdict()) + await node.update(**node_data) + node_dict = node.asdict() + set_etag(response, node_dict) + return node_dict @router.delete( @@ -331,46 +368,88 @@ async def duplicate_node(duplicate_data: schemas.NodeDuplicate, node: Node = Dep return new_node.asdict() +LIFECYCLE_RESPONSES: dict[int | str, dict[str, Any]] = { + status.HTTP_408_REQUEST_TIMEOUT: {"model": schemas.ErrorMessage, "description": "Timeout waiting for the node"}, +} + + +async def _run_lifecycle( + node: Node, action: Callable, target: str, wait: bool, timeout: int, tolerate_unsupported: bool = True +) -> dict: + async def _run(): + try: + await action() + except HTTPException as e: + if not tolerate_unsupported or not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: + raise + return + if wait and not node.is_always_running(): + await node.wait_for_status(target) + + if not wait: + await _run() + return node.asdict() + try: + await asyncio.wait_for(_run(), timeout) + except asyncio.TimeoutError: + raise ControllerTimeoutError(f"Timeout when waiting for {node.name} to be {target}") + return node.asdict() + + @router.post( - "/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))] + "/{node_id}/start", + response_model=schemas.Node, + responses=LIFECYCLE_RESPONSES, + dependencies=[Depends(has_privilege("Node.PowerMgmt"))], ) -async def start_node(start_data: Optional[dict] = None, node: Node = Depends(dep_node)) -> None: +async def start_node( + start_data: Optional[dict] = None, + wait: bool = Query(False, description="Return only when the node status is 'started'"), + timeout: int = Query(240, ge=1, le=3600, description="Seconds to wait when 'wait' is true"), + node: Node = Depends(dep_node), +) -> dict: """ Start a node. Required privilege: Node.PowerMgmt """ - try: - await node.start(data=start_data) - except HTTPException as e: - if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: - raise + return await _run_lifecycle(node, lambda: node.start(data=start_data), "started", wait, timeout) @router.post( - "/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))] + "/{node_id}/stop", + response_model=schemas.Node, + responses=LIFECYCLE_RESPONSES, + dependencies=[Depends(has_privilege("Node.PowerMgmt"))], ) -async def stop_node(node: Node = Depends(dep_node)) -> None: +async def stop_node( + wait: bool = Query(False, description="Return only when the node status is 'stopped'"), + timeout: int = Query(240, ge=1, le=3600, description="Seconds to wait when 'wait' is true"), + node: Node = Depends(dep_node), +) -> dict: """ Stop a node. + Errors reported while stopping are returned to the caller. + Required privilege: Node.PowerMgmt """ - try: - await node.stop() - except HTTPException as e: - if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: - raise + return await _run_lifecycle(node, lambda: node.stop(strict=True), "stopped", wait, timeout) @router.post( "/{node_id}/suspend", - status_code=status.HTTP_204_NO_CONTENT, + response_model=schemas.Node, + responses=LIFECYCLE_RESPONSES, dependencies=[Depends(has_privilege("Node.PowerMgmt"))], ) -async def suspend_node(node: Node = Depends(dep_node)) -> None: +async def suspend_node( + wait: bool = Query(False, description="Return only when the node status is 'suspended'"), + timeout: int = Query(240, ge=1, le=3600, description="Seconds to wait when 'wait' is true"), + node: Node = Depends(dep_node), +) -> dict: """ Suspend a node. @@ -380,24 +459,27 @@ async def suspend_node(node: Node = Depends(dep_node)) -> None: Required privilege: Node.PowerMgmt """ - await node.suspend() + return await _run_lifecycle(node, node.suspend, "suspended", wait, timeout, tolerate_unsupported=False) @router.post( - "/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))] + "/{node_id}/reload", + response_model=schemas.Node, + responses=LIFECYCLE_RESPONSES, + dependencies=[Depends(has_privilege("Node.PowerMgmt"))], ) -async def reload_node(node: Node = Depends(dep_node)) -> None: +async def reload_node( + wait: bool = Query(False, description="Return only when the node status is 'started'"), + timeout: int = Query(240, ge=1, le=3600, description="Seconds to wait when 'wait' is true"), + node: Node = Depends(dep_node), +) -> dict: """ Reload a node. Required privilege: Node.PowerMgmt """ - try: - await node.reload() - except HTTPException as e: - if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: - raise + return await _run_lifecycle(node, node.reload, "started", wait, timeout) @router.post( @@ -551,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. @@ -592,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)): diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 264dd475b..aceb39811 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -34,12 +34,25 @@ log = logging.getLogger() from typing import Any, List, Optional from uuid import UUID -from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status +from fastapi import ( + APIRouter, + Body, + Depends, + HTTPException, + Query, + Request, + Response, + WebSocket, + WebSocketDisconnect, + status, +) from fastapi.encoders import jsonable_encoder 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 from gns3server.controller.export_project import export_project as export_controller_project @@ -55,6 +68,14 @@ from gns3server.utils.asyncio import aiozipstream from gns3server.utils.path import is_safe_path from .dependencies.authentication import get_current_active_user +from .dependencies.concurrency import ( + GET_RESPONSES, + PUT_RESPONSES, + check_if_match, + if_match_header, + serialize_updates, + set_etag, +) from .dependencies.database import get_repository from .dependencies.rbac import has_privilege, has_privilege_on_websocket @@ -150,34 +171,51 @@ async def create_project( return project.asdict() -@router.get("/{project_id}", response_model=schemas.Project, dependencies=[Depends(has_privilege("Project.Audit"))]) -def get_project(project: Project = Depends(dep_project)) -> schemas.Project: +@router.get( + "/{project_id}", + response_model=schemas.Project, + responses=GET_RESPONSES, + dependencies=[Depends(has_privilege("Project.Audit"))], +) +def get_project(response: Response, project: Project = Depends(dep_project)) -> schemas.Project: """ Return a project. Required privilege: Project.Audit """ - return project.asdict() + project_dict = project.asdict() + set_etag(response, project_dict) + return project_dict @router.put( "/{project_id}", response_model=schemas.Project, response_model_exclude_unset=True, + responses=PUT_RESPONSES, dependencies=[Depends(has_privilege("Project.Modify"))], ) async def update_project( - project_data: schemas.ProjectUpdate, project: Project = Depends(dep_project) + project_data: schemas.ProjectUpdate, + response: Response, + project: Project = Depends(dep_project), + if_match: Optional[str] = Depends(if_match_header), ) -> schemas.Project: """ Update a project. + If the If-Match header is present, the update is only applied when it matches the current ETag. + Required privilege: Project.Modify """ - await project.update(**jsonable_encoder(project_data, exclude_unset=True)) - return project.asdict() + async with serialize_updates(f"project:{project.id}"): + check_if_match(if_match, project.asdict()) + await project.update(**jsonable_encoder(project_data, exclude_unset=True)) + project_dict = project.asdict() + set_etag(response, project_dict) + return project_dict @router.delete( @@ -531,10 +569,20 @@ async def load_project(path: str = Body(..., embed=True)) -> schemas.Project: return project.asdict() -@router.get("/{project_id}/notifications", dependencies=[Depends(has_privilege("Project.Audit"))]) +@router.get( + "/{project_id}/notifications", + dependencies=[Depends(has_privilege("Project.Audit"))], + response_class=NDJSONResponse, + responses=NOTIFICATION_STREAM_RESPONSES, +) async def project_http_notifications(project_id: UUID) -> StreamingResponse: """ - Receive project notifications about the controller from HTTP stream. + Receive project notifications from an HTTP stream of newline delimited JSON objects (see the Notification schema). + + The same messages are available as WebSocket text frames on `/v3/projects/{project_id}/notifications/ws`. + Marker events (`marker.match`) are not sent here, they are only available on + `/v3/projects/{project_id}/notifications/markers/ws`. + Project actions: `node.*`, `link.*`, `drawing.*`, `project.updated`, `snapshot.restored`, `log.*` and `ping`. Required privilege: Project.Audit """ @@ -563,7 +611,7 @@ async def project_http_notifications(project_id: UUID) -> StreamingResponse: log.info(f"Project '{project.id}' is automatically closing due to no client listening") await project.close() - return StreamingResponse(event_stream(), media_type="application/json") + return StreamingResponse(event_stream(), media_type=NDJSON_MEDIA_TYPE) @router.websocket("/{project_id}/notifications/ws") @@ -573,7 +621,8 @@ async def project_ws_notifications( current_user: schemas.User = Depends(has_privilege_on_websocket("Project.Audit")), ) -> None: """ - Receive project notifications about the controller from WebSocket. + Receive project notifications from a WebSocket, one Notification JSON object per text frame. + Same messages as the HTTP stream `/v3/projects/{project_id}/notifications`. Required privilege: Project.Audit """ @@ -611,8 +660,8 @@ async def project_marker_ws_notifications( current_user: schemas.User = Depends(has_privilege_on_websocket("Project.Audit")), ) -> None: """ - Receive marker notifications (e.g. marker.match) for a project on a - dedicated WebSocket, separate from the main project stream so high-frequency + Receive marker notifications (`marker.match`, see the Notification schema) for a project on a + dedicated WebSocket, one JSON object per text frame, separate from the main project stream so high-frequency marker.matches do not block topology events (node.*/link.*). Required privilege: Project.Audit @@ -640,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, @@ -726,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: @@ -850,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. @@ -872,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. @@ -890,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: diff --git a/gns3server/api/routes/controller/symbols.py b/gns3server/api/routes/controller/symbols.py index 98d6a0c91..a9c80741a 100644 --- a/gns3server/api/routes/controller/symbols.py +++ b/gns3server/api/routes/controller/symbols.py @@ -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: diff --git a/gns3server/api/routes/controller/users.py b/gns3server/api/routes/controller/users.py index d072e4009..e88c3d7c0 100644 --- a/gns3server/api/routes/controller/users.py +++ b/gns3server/api/routes/controller/users.py @@ -65,12 +65,7 @@ async def login( headers={"WWW-Authenticate": "Bearer"}, ) - token = schemas.Token( - access_token=auth_service.create_access_token(user.username, token_version=user.token_version), - token_type="bearer", - refresh_token=auth_service.create_refresh_token(user.username, token_version=user.token_version), - ) - return token + return auth_service.create_token_response(user) @router.post("/authenticate", response_model=schemas.Token) @@ -91,12 +86,7 @@ async def authenticate( headers={"WWW-Authenticate": "Bearer"}, ) - token = schemas.Token( - access_token=auth_service.create_access_token(user.username, token_version=user.token_version), - token_type="bearer", - refresh_token=auth_service.create_refresh_token(user.username, token_version=user.token_version), - ) - return token + return auth_service.create_token_response(user) @router.post("/refresh", response_model=schemas.Token) @@ -135,11 +125,7 @@ async def refresh_access_token( headers={"WWW-Authenticate": "Bearer"}, ) - return schemas.Token( - access_token=auth_service.create_access_token(user.username, token_version=user.token_version), - token_type="bearer", - refresh_token=auth_service.create_refresh_token(user.username, token_version=user.token_version), - ) + return auth_service.create_token_response(user) @router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) diff --git a/gns3server/api/server.py b/gns3server/api/server.py index 4ea037e83..63da932a6 100644 --- a/gns3server/api/server.py +++ b/gns3server/api/server.py @@ -19,7 +19,7 @@ FastAPI app """ -from typing import cast +from typing import Any, cast from fastapi import FastAPI, HTTPException, Request, status from fastapi.exceptions import RequestValidationError @@ -29,15 +29,18 @@ from fastapi.openapi.docs import ( get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html, ) -from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from sqlalchemy.exc import SQLAlchemyError +from starlette.exceptions import HTTPException as StarletteHTTPException from uvicorn.main import Server as UvicornServer # MCP is an optional feature — import only if dependencies are installed from gns3server.agent import MCP_AVAILABLE +from gns3server.api.errors import customize_openapi_errors, error_response +from gns3server.api.operation_ids import add_stub_routes, generate_operation_id 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, @@ -48,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 @@ -58,28 +62,39 @@ 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, detail="MCP is not available. Install AI dependencies with: pip install gns3-server[ai-features]", ) + add_stub_routes(_mcp_router, mcp_not_available, "not_available") + import logging log = logging.getLogger(__name__) +class ControllerAPI(FastAPI): + def openapi(self) -> dict[str, Any]: + if self.openapi_schema is None: + customize_openapi_errors(super().openapi()) + return cast(dict[str, Any], self.openapi_schema) + + def get_application() -> FastAPI: - application = FastAPI( + application = ControllerAPI( lifespan=tasks.lifespan, title="GNS3 controller API", description="This page describes the public controller API for GNS3", - version="3.0.0", + version=__api_version__, + servers=[{"url": "/"}], docs_url=None, redoc_url=None, + generate_unique_id_function=generate_operation_id, ) application.add_middleware( @@ -96,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 @@ -151,82 +171,65 @@ async def redoc_html(): @app.exception_handler(ControllerError) async def controller_error_handler(request: Request, exc: ControllerError): log.error(f"Controller error in {request.url.path} ({request.method}): {exc}") - return JSONResponse( - status_code=status.HTTP_409_CONFLICT, - content={"message": str(exc)}, - ) + return error_response(status.HTTP_409_CONFLICT, str(exc), exc.code, exc.details) @app.exception_handler(ControllerTimeoutError) async def controller_timeout_error_handler(request: Request, exc: ControllerTimeoutError): log.error(f"Controller timeout error in {request.url.path} ({request.method}): {exc}") - return JSONResponse( - status_code=status.HTTP_408_REQUEST_TIMEOUT, - content={"message": str(exc)}, - ) + return error_response(status.HTTP_408_REQUEST_TIMEOUT, str(exc), exc.code, exc.details) @app.exception_handler(ControllerUnauthorizedError) async def controller_unauthorized_error_handler(request: Request, exc: ControllerUnauthorizedError): log.error(f"Controller unauthorized error in {request.url.path} ({request.method}): {exc}") - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={"message": str(exc)}, - ) + return error_response(status.HTTP_401_UNAUTHORIZED, str(exc), exc.code, exc.details) @app.exception_handler(ControllerForbiddenError) async def controller_forbidden_error_handler(request: Request, exc: ControllerForbiddenError): log.error(f"Controller forbidden error in {request.url.path} ({request.method}): {exc}") - return JSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content={"message": str(exc)}, - ) + return error_response(status.HTTP_403_FORBIDDEN, str(exc), exc.code, exc.details) @app.exception_handler(ControllerNotFoundError) async def controller_not_found_error_handler(request: Request, exc: ControllerNotFoundError): log.error(f"Controller not found error in {request.url.path} ({request.method}): {exc}") - return JSONResponse( - status_code=status.HTTP_404_NOT_FOUND, - content={"message": str(exc)}, - ) + return error_response(status.HTTP_404_NOT_FOUND, str(exc), exc.code, exc.details) @app.exception_handler(ControllerBadRequestError) async def controller_bad_request_error_handler(request: Request, exc: ControllerBadRequestError): log.error(f"Controller bad request error in {request.url.path} ({request.method}): {exc}") - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={"message": str(exc)}, - ) + return error_response(status.HTTP_400_BAD_REQUEST, str(exc), exc.code, exc.details) @app.exception_handler(ComputeConflictError) async def compute_conflict_error_handler(request: Request, exc: ComputeConflictError): log.error(f"Controller received error from compute for request '{exc.url()}': {exc}") - return JSONResponse( - status_code=status.HTTP_409_CONFLICT, - content={"message": str(exc)}, - ) + return error_response(status.HTTP_409_CONFLICT, str(exc), exc.code, exc.details) -# make sure the content key is "message", not "detail" per default -@app.exception_handler(HTTPException) -async def http_exception_handler(request: Request, exc: HTTPException): - return JSONResponse(status_code=exc.status_code, content={"message": exc.detail}, headers=exc.headers) +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + return error_response(exc.status_code, exc.detail, headers=exc.headers) @app.exception_handler(SQLAlchemyError) async def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError): log.error(f"Controller database error in {request.url.path} ({request.method}): {exc}") - return JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"message": "Database error detected, please check logs to find details"}, + return error_response( + status.HTTP_500_INTERNAL_SERVER_ERROR, + "Database error detected, please check logs to find details", + "database_error", ) @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): log.error(f"Request validation error in {request.url.path} ({request.method}): {exc}") - return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"message": str(exc)}) + errors = [ + {"loc": list(error.get("loc", ())), "msg": error.get("msg"), "type": error.get("type")} + for error in exc.errors() + ] + return error_response(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc), "validation_error", {"errors": errors}) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 6a85a97c7..9c53bb0aa 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -671,11 +671,7 @@ class BaseNode: ) # keep forwarding websocket data in both direction - if sys.version_info >= (3, 11, 0): - # Starting with Python 3.11, passing coroutine objects to wait() directly is forbidden. - aws = [asyncio.create_task(ws_forward(telnet_writer)), asyncio.create_task(telnet_forward(telnet_reader))] - else: - aws = [ws_forward(telnet_writer), telnet_forward(telnet_reader)] + aws = [asyncio.create_task(ws_forward(telnet_writer)), asyncio.create_task(telnet_forward(telnet_reader))] done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) for task in done: @@ -762,11 +758,7 @@ class BaseNode: log.warning(f"Exception while forwarding VNC data to WebSocket: {e!r}") # Keep forwarding WebSocket data in both directions - if sys.version_info >= (3, 11, 0): - # Starting with Python 3.11, passing coroutine objects to wait() directly is forbidden. - aws = [asyncio.create_task(ws_forward(vnc_writer)), asyncio.create_task(vnc_forward(vnc_reader))] - else: - aws = [ws_forward(vnc_writer), vnc_forward(vnc_reader)] + aws = [asyncio.create_task(ws_forward(vnc_writer)), asyncio.create_task(vnc_forward(vnc_reader))] done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED) for task in done: diff --git a/gns3server/compute/project.py b/gns3server/compute/project.py index 713f221c5..3e7b3cad3 100644 --- a/gns3server/compute/project.py +++ b/gns3server/compute/project.py @@ -480,11 +480,13 @@ class Project: stat_info = await wait_run_in_executor(lambda e=entry: e.stat()) is_dir = await wait_run_in_executor(lambda e=entry: e.is_dir()) if is_dir: + created_at: str | None = None + modified_at: str | None = None try: created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat() modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() except (OSError, OverflowError, ValueError): - created_at = modified_at = "" + created_at = modified_at = None files.append( { "path": rel_path, @@ -506,7 +508,7 @@ class Project: created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat() modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() except (OSError, OverflowError, ValueError): - created_at = modified_at = "" + created_at = modified_at = None files.append( { "path": rel_path, @@ -537,7 +539,7 @@ class Project: created_at = datetime.datetime.fromtimestamp(stat_info.st_ctime).isoformat() modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() except (OSError, OverflowError, ValueError): - created_at = modified_at = "" + created_at = modified_at = None files.append( { "path": os.path.relpath(dir_full_path, base_path), @@ -567,7 +569,7 @@ class Project: modified_at = datetime.datetime.fromtimestamp(stat_info.st_mtime).isoformat() except (OSError, OverflowError, ValueError) as e: log.warning(f"Invalid timestamp for '{rel_path}': {e}") - created_at = modified_at = "" + created_at = modified_at = None files.append( { "path": rel_path, diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py index 9a65812a8..075256d7e 100644 --- a/gns3server/controller/__init__.py +++ b/gns3server/controller/__init__.py @@ -710,7 +710,9 @@ class Controller: except KeyError: if compute_id == "vm": raise ControllerNotFoundError("Cannot use a node on the GNS3 VM server with the GNS3 VM not configured") - raise ControllerNotFoundError(f"Compute ID {compute_id} doesn't exist") + raise ControllerNotFoundError( + f"Compute ID {compute_id} doesn't exist", code="compute_not_found", details={"compute_id": compute_id} + ) def has_compute(self, compute_id): """ @@ -748,7 +750,9 @@ class Controller: try: return self._projects[project_id] except KeyError: - raise ControllerNotFoundError(f"Project ID {project_id} doesn't exist") + raise ControllerNotFoundError( + f"Project ID {project_id} doesn't exist", code="project_not_found", details={"project_id": project_id} + ) async def get_loaded_project(self, project_id): """ diff --git a/gns3server/controller/compute.py b/gns3server/controller/compute.py index 863e7a8c0..bd0cf01d4 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -19,19 +19,14 @@ import asyncio import io import ipaddress import json +import logging import socket import sys +from asyncio import timeout as asynctimeout import aiohttp from fastapi import HTTPException -if sys.version_info >= (3, 11): - from asyncio import timeout as asynctimeout -else: - from async_timeout import timeout as asynctimeout - -import logging - from ..controller.controller_error import ( ComputeConflictError, ComputeError, diff --git a/gns3server/controller/controller_error.py b/gns3server/controller/controller_error.py index c6f531498..ac6a747aa 100644 --- a/gns3server/controller/controller_error.py +++ b/gns3server/controller/controller_error.py @@ -15,11 +15,17 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from typing import Any + class ControllerError(Exception): - def __init__(self, message: str): + default_code = "conflict" + + def __init__(self, message: str, code: str | None = None, details: dict[str, Any] | None = None): super().__init__() self._message = message + self._code = code or self.default_code + self._details = details def __repr__(self): return self._message @@ -27,30 +33,33 @@ class ControllerError(Exception): def __str__(self): return self._message + @property + def code(self) -> str: + return self._code + + @property + def details(self) -> dict[str, Any] | None: + return self._details + class ControllerNotFoundError(ControllerError): - def __init__(self, message: str): - super().__init__(message) + default_code = "not_found" class ControllerBadRequestError(ControllerError): - def __init__(self, message: str): - super().__init__(message) + default_code = "bad_request" class ControllerUnauthorizedError(ControllerError): - def __init__(self, message: str): - super().__init__(message) + default_code = "unauthorized" class ControllerForbiddenError(ControllerError): - def __init__(self, message: str): - super().__init__(message) + default_code = "forbidden" class ControllerTimeoutError(ControllerError): - def __init__(self, message: str): - super().__init__(message) + default_code = "timeout" class ComputeError(ControllerError): @@ -65,8 +74,10 @@ class ComputeConflictError(ComputeError): :param response: compute JSON response """ + default_code = "compute_conflict" + def __init__(self, url, response): - super().__init__(response["message"]) + super().__init__(response["message"], code=response.get("code"), details=response.get("details")) self._url = url self._response = response diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 4db47a766..6d6236893 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -399,9 +399,22 @@ class Link: port = node.get_port(adapter_number, port_number) if port is None: - raise ControllerNotFoundError(f"Port {adapter_number}/{port_number} for {node.name} not found") + raise ControllerNotFoundError( + f"Port {adapter_number}/{port_number} for {node.name} not found", + code="port_not_found", + details={"node_id": node.id, "adapter_number": adapter_number, "port_number": port_number}, + ) if port.link is not None: - raise ControllerError("Port is already used") + raise ControllerError( + "Port is already used", + code="port_in_use", + details={ + "node_id": node.id, + "adapter_number": adapter_number, + "port_number": port_number, + "link_id": port.link.id, + }, + ) self._link_type = port.link_type @@ -438,8 +451,15 @@ class Link: {"node": node, "adapter_number": adapter_number, "port_number": port_number, "port": port, "label": label} ) + if not batch: + port.link = self + if len(self._nodes) == 2 and not batch: - await self.create() + try: + await self.create() + except BaseException: + self.release_ports() + raise self._bind_members() self._created = True self._project.emit_notification("link.created", self.asdict()) @@ -447,6 +467,16 @@ class Link: if dump: self._project.dump() + def release_ports(self): + for n in self._nodes: + # The live port, for the same staleness reason as _bind_members; + # the link could be different from self if we rollback an + # already existing link + port = n["node"].get_port(n["adapter_number"], n["port_number"]) or n["port"] + if port.link == self: + port.link = None + n["node"].links.discard(self) + async def update_nodes(self, nodes): for node_data in nodes: node = self._project.get_node(node_data["node_id"]) @@ -490,14 +520,7 @@ class Link: """ Delete the link """ - for n in self._nodes: - # The live port, for the same staleness reason as _bind_members; - # the link could be different from self if we rollback an - # already existing link - port = n["node"].get_port(n["adapter_number"], n["port_number"]) or n["port"] - if port.link == self: - port.link = None - n["node"].remove_link(self) + self.release_ports() async def reset(self): """ diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index c8d20f10a..4468165e7 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -16,6 +16,7 @@ # along with this program. If not, see . import asyncio +import contextlib import copy import html import logging @@ -134,6 +135,8 @@ class Node: self._command_line = None self._node_directory = None self._status = "stopped" + self._status_changed = asyncio.Event() + self._transition = None self._template_id = template_id self._x = 0 self._y = 0 @@ -785,7 +788,7 @@ class Node: elif key == "command_line": self._command_line = value elif key == "status": - self._status = value + self._set_status(value) elif key == "console_type": self._console_type = value elif key == "aux_type": @@ -901,6 +904,30 @@ class Node: async def destroy(self): await self.delete() + @contextlib.asynccontextmanager + async def _transitioning(self, transition): + """ + Report a transitional status while a lifecycle call is in flight + """ + + self._transition = transition + self.project.emit_notification("node.updated", self.asdict()) + try: + yield + finally: + self._transition = None + + async def wait_for_status(self, status): + """ + Wait until the controller side status of the node matches + + :param status: expected status + """ + + while self._status != status: + self._status_changed.clear() + await self._status_changed.wait() + async def start(self, data=None): """ Start a node @@ -924,37 +951,61 @@ class Node: f"Cannot start node '{self._name}': {len(failed_links)} deferred link(s) " "could not be restored. Please try again." ) - try: - # For IOU: we need to send the licence everytime we start a node - if self.node_type == "iou": - license_check = self._project.controller.iou_license.get("license_check", True) - iourc_content = self._project.controller.iou_license.get("iourc_content", None) - await self.post( - "/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content} - ) - else: - await self.post("/start", data=data, timeout=240) - except asyncio.TimeoutError: - raise ControllerTimeoutError(f"Timeout when starting {self._name}") + async with self._transitioning("starting"): + try: + # For IOU: we need to send the licence everytime we start a node + if self.node_type == "iou": + license_check = self._project.controller.iou_license.get("license_check", True) + iourc_content = self._project.controller.iou_license.get("iourc_content", None) + await self.post( + "/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content} + ) + else: + await self.post("/start", data=data, timeout=240) + except asyncio.TimeoutError: + raise ControllerTimeoutError(f"Timeout when starting {self._name}") + self._confirm_status("started") # A started node may have brought kernel anchors into existence that # a link needs to finish wiring (the Ethernet-switch fast path joins # the peer's anchor on the switch side — see UDPLink.node_started). for link in self._links: await link.node_started(self) - async def stop(self): + async def stop(self, strict=False): """ Stop a node + + :param strict: Raise the errors reported by the compute instead of ignoring them """ if self.missing_image: return - try: - await self.post("/stop", timeout=240, dont_connect=True) - # We don't care if a node is down at this step - except (ComputeError, ControllerError): - pass - except asyncio.TimeoutError: - raise ControllerTimeoutError(f"Timeout when stopping {self._name}") + stopped = False + async with self._transitioning("stopping"): + try: + await self.post("/stop", timeout=240, dont_connect=True) + stopped = True + except (ComputeError, ControllerError): + # We don't care if a node is down at this step + if strict: + raise + except asyncio.TimeoutError: + raise ControllerTimeoutError(f"Timeout when stopping {self._name}") + if stopped: + self._confirm_status("stopped") + + def _set_status(self, status): + self._status = status + self._status_changed.set() + + def _confirm_status(self, status): + """ + Record the state reached once the compute accepted a lifecycle call, + without waiting for the asynchronous compute notification + """ + + if not self.is_always_running(): + self._set_status(status) + self.project.emit_notification("node.updated", self.asdict()) async def suspend(self): """ @@ -963,7 +1014,8 @@ class Node: try: await self.post("/suspend", timeout=240) except asyncio.TimeoutError: - raise ControllerTimeoutError(f"Timeout when reloading {self._name}") + raise ControllerTimeoutError(f"Timeout when suspending {self._name}") + self._confirm_status("suspended") async def reload(self): """ @@ -973,6 +1025,7 @@ class Node: await self.post("/reload", timeout=240) except asyncio.TimeoutError: raise ControllerTimeoutError(f"Timeout when reloading {self._name}") + self._confirm_status("started") async def reset_console(self): """ @@ -1147,6 +1200,27 @@ class Node: return port return None + def get_port_by_name(self, name): + """ + Return the port with this name or None if the port is not found + """ + for port in self.ports: + if port.name == name: + return port + return None + + def get_free_port(self, link_type=None): + """ + Return the lowest free port, optionally restricted to a link type, + or None if there is no such port + """ + free_ports = [ + port for port in self.ports if port.link is None and (link_type is None or port.link_type == link_type) + ] + if not free_ports: + return None + return min(free_ports, key=lambda port: (port.adapter_number, port.port_number)) + def _list_ports(self): """ Rebuild the port list, preserving the link back-references. @@ -1311,7 +1385,7 @@ class Node: additional_data = { "project_id": self._project.id, "command_line": self._command_line, - "status": self._status, + "status": self._transition or self._status, "console_host": str(self._compute.console_host), "node_directory": self._node_directory, "ports": [port.asdict() for port in self.ports], diff --git a/gns3server/controller/ports/port.py b/gns3server/controller/ports/port.py index 9d45cbda1..4782f885c 100644 --- a/gns3server/controller/ports/port.py +++ b/gns3server/controller/ports/port.py @@ -41,6 +41,10 @@ class Port: def link(self, val): self._link = val + @property + def name(self): + return self._name + @property def adapter_number(self): return self._adapter_number diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index e2def78c9..76d54b3ae 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -748,7 +748,9 @@ class Project: try: return self._nodes[node_id] except KeyError: - raise ControllerNotFoundError(f"Node ID {node_id} doesn't exist") + raise ControllerNotFoundError( + f"Node ID {node_id} doesn't exist", code="node_not_found", details={"node_id": node_id} + ) def _get_closed_data(self, section, id_key): """ @@ -817,7 +819,9 @@ class Project: try: return self._drawings[drawing_id] except KeyError: - raise ControllerNotFoundError(f"Drawing ID {drawing_id} doesn't exist") + raise ControllerNotFoundError( + f"Drawing ID {drawing_id} doesn't exist", code="drawing_not_found", details={"drawing_id": drawing_id} + ) @open_required async def delete_drawing(self, drawing_id): @@ -1099,7 +1103,9 @@ class Project: try: return self._links[link_id] except KeyError: - raise ControllerNotFoundError(f"Link ID {link_id} doesn't exist") + raise ControllerNotFoundError( + f"Link ID {link_id} doesn't exist", code="link_not_found", details={"link_id": link_id} + ) @property def links(self): @@ -1460,7 +1466,11 @@ class Project: try: return self._snapshots[snapshot_id] except KeyError: - raise ControllerNotFoundError(f"Snapshot ID {snapshot_id} doesn't exist") + raise ControllerNotFoundError( + f"Snapshot ID {snapshot_id} doesn't exist", + code="snapshot_not_found", + details={"snapshot_id": snapshot_id}, + ) def _load_snapshot_config(self): diff --git a/gns3server/db/repositories/rbac.py b/gns3server/db/repositories/rbac.py index 4417e901d..93cdbe32a 100644 --- a/gns3server/db/repositories/rbac.py +++ b/gns3server/db/repositories/rbac.py @@ -16,11 +16,11 @@ # along with this program. If not, see . import logging -from typing import List, Optional, Union, cast +from typing import Any, List, Optional, Union, cast from urllib.parse import urlparse from uuid import UUID -from sqlalchemy import delete, select, update +from sqlalchemy import Select, delete, select, update from sqlalchemy.engine import CursorResult from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -331,7 +331,7 @@ class RbacRepository(BaseRepository): Retrieve all user ACEs matching the user_id and privilege name. """ - query = ( + query: Select[Any, Any, Any, Any] = ( select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name) .join(models.Privilege.roles) .join(models.Role.acl_entries) @@ -349,7 +349,7 @@ class RbacRepository(BaseRepository): Retrieve all group ACEs matching the user_id and privilege name. """ - query = ( + query: Select[Any, Any, Any, Any] = ( select(models.ACE.path, models.ACE.propagate, models.ACE.allowed, models.Privilege.name) .join(models.Privilege.roles) .join(models.Role.acl_entries) diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index bf77db5e6..dee12620b 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -17,7 +17,7 @@ # General schemas from .common import ErrorMessage from .config import ServerConfig -from .controller.appliances import Appliance, ApplianceVersion, ApplianceVersionV8 +from .controller.appliances import Appliance, ApplianceVersion, ApplianceVersionCreate, ApplianceVersionV8 from .controller.computes import ( AutoIdlePC, Compute, @@ -37,6 +37,11 @@ from .controller.links import ( Link, LinkCapture, LinkCreate, + LinkFilterDefinition, + LinkFilterParameter, + LinkFilters, + LinkFilterType, + LinkIfaceInfo, LinkUpdate, MarkerCreate, MarkerDefinitionCreate, @@ -119,6 +124,7 @@ from .compute.vpcs_nodes import VPCS, VPCSCreate, VPCSUpdate from .controller.capabilities import Capabilities from .controller.iou_license import IOULicense from .controller.netmiko import NetmikoDeviceType, NetmikoDeviceTypeList +from .controller.notifications import Notification, NotificationAction from .controller.pools import Resource, ResourceCreate, ResourcePool, ResourcePoolCreate, ResourcePoolUpdate from .controller.rbac import ACE, ACECreate, ACEUpdate, Privilege, Role, RoleCreate, RoleUpdate from .controller.settings import SettingsResponse, SettingsUpdate, SettingsUpdateResponse @@ -173,6 +179,7 @@ __all__ = [ "ApiKeyCreate", "Appliance", "ApplianceVersion", + "ApplianceVersionCreate", "ApplianceVersionV8", "AutoIdlePC", "BatchNIOCreate", @@ -256,6 +263,11 @@ __all__ = [ "Link", "LinkCapture", "LinkCreate", + "LinkFilterDefinition", + "LinkFilterParameter", + "LinkFilterType", + "LinkFilters", + "LinkIfaceInfo", "LinkUpdate", "LoggedInUserUpdate", "MarkerCreate", @@ -273,6 +285,8 @@ __all__ = [ "NodeDuplicate", "NodeFile", "NodeUpdate", + "Notification", + "NotificationAction", "OpenAIMessage", "OpenAIToolCall", "Privilege", diff --git a/gns3server/schemas/common.py b/gns3server/schemas/common.py index 3b700ee5e..8631e7bab 100644 --- a/gns3server/schemas/common.py +++ b/gns3server/schemas/common.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from enum import Enum -from typing import Optional +from typing import Any, Optional from pydantic import BaseModel, Field, field_validator @@ -26,6 +26,11 @@ class ErrorMessage(BaseModel): """ message: str + code: Optional[str] = Field( + None, + description="Stable machine-readable error code, for example port_in_use or node_not_found", + ) + details: Optional[dict[str, Any]] = Field(None, description="Optional structured data about the error") class NodeStatus(str, Enum): @@ -36,6 +41,8 @@ class NodeStatus(str, Enum): stopped = "stopped" started = "started" suspended = "suspended" + starting = "starting" + stopping = "stopping" class CustomAdapter(BaseModel): diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index e17084a0e..d9bb78295 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -155,6 +155,9 @@ class ServerSettings(BaseModel): False, description="Local server mode, set by the --local command line argument (not meant to be set by hand)" ) enable_http_auth: bool = Field(True, description="Enable compute HTTP authentication") + openapi_include_ai: bool = Field( + True, description="Include the AI Copilot, LLM and MCP routes in the generated OpenAPI specification" + ) name: str = Field( f"{socket.gethostname()} (controller)", description="Server name, default is what is returned by socket.gethostname()", diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py index 4a5b48da1..ce1a71b98 100644 --- a/gns3server/schemas/controller/appliances.py +++ b/gns3server/schemas/controller/appliances.py @@ -20,7 +20,7 @@ from enum import Enum from typing import Annotated, List, Literal, Optional, Union from uuid import UUID -from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag +from pydantic import AnyUrl, BaseModel, BeforeValidator, Discriminator, EmailStr, Field, Tag from ..common import ExtraConfig @@ -648,6 +648,39 @@ class ApplianceVersionV8(BaseModel): images: Optional[ApplianceVersionImages] = Field(None, title="Images used for this version") +_V8_VERSION_ONLY_FIELDS = frozenset(set(ApplianceVersionV8.model_fields) - set(ApplianceVersion.model_fields)) + + +class ApplianceVersionCreateV1_6(ApplianceVersion): + """Request body to add a version to an appliance with registry version 1-6""" + + registry_version: Literal[1, 2, 3, 4, 5, 6] = Field( + 1, title="Version of the registry compatible with the appliance receiving the version" + ) + + +class ApplianceVersionCreateV8(ApplianceVersionV8): + """Request body to add a version to an appliance with registry version 8""" + + registry_version: Literal[8] = Field( + 8, title="Version of the registry compatible with the appliance receiving the version" + ) + + +def _infer_version_registry_version(data): + if isinstance(data, dict) and "registry_version" not in data: + data = dict(data) + data["registry_version"] = 8 if _V8_VERSION_ONLY_FIELDS.intersection(data) else 1 + return data + + +ApplianceVersionCreate = Annotated[ + Union[ApplianceVersionCreateV1_6, ApplianceVersionCreateV8], + BeforeValidator(_infer_version_registry_version), + Discriminator("registry_version"), +] + + # ============================================================================ # Child Models with Discriminated Union # ============================================================================ diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 465907549..f1bc2c480 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -15,10 +15,10 @@ # along with this program. If not, see . from enum import Enum -from typing import List, Optional, Tuple +from typing import Annotated, List, Literal, Optional, Tuple, Union from uuid import UUID, uuid4 -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Discriminator, Field, field_validator, model_validator from ..update import PartialUpdateModel from .labels import Label @@ -35,6 +35,32 @@ class LinkNode(BaseModel): label: Optional[Label] = None +class LinkNodeCreate(BaseModel): + """ + Link node data for link creation. + + The port is given either by adapter_number and port_number, by port_name + or by port set to "auto" (first free compatible port). + """ + + node_id: UUID + adapter_number: Optional[int] = None + port_number: Optional[int] = None + port_name: Optional[str] = Field(None, description="Name of the port on the node") + port: Optional[Literal["auto"]] = Field(None, description='Use "auto" to select the first free compatible port') + label: Optional[Label] = None + + @model_validator(mode="after") + def check_port_selector(self): + numbered = self.adapter_number is not None or self.port_number is not None + if numbered and (self.adapter_number is None or self.port_number is None): + raise ValueError("adapter_number and port_number must be given together") + forms = sum([numbered, self.port_name is not None, self.port is not None]) + if forms != 1: + raise ValueError("exactly one of adapter_number and port_number, port_name or port must be given") + return self + + class LinkType(str, Enum): """ Link type. @@ -54,6 +80,87 @@ class LinkStyle(BaseModel): control_offset: Optional[Tuple[float, float]] = None +class LinkFilterType(str, Enum): + """ + Packet filter type. The first five run on relay and kernel-datapath links + alike; the rest are kernel-datapath only (tc netem / cls_bpf / the eBPF + classifier on the host anchor). + """ + + frequency_drop = "frequency_drop" + packet_loss = "packet_loss" + delay = "delay" + corrupt = "corrupt" + bpf = "bpf" + rate = "rate" + reorder = "reorder" + gemodel = "gemodel" + duplicate = "duplicate" + seed = "seed" + limit = "limit" + quota = "quota" + window_drop = "window_drop" + + +class LinkFilters(BaseModel): + """ + Packet filters applied on a link. Each filter is an array of positional values. + A filter set to an empty array or to its disabled value (0, 0 for delay, empty text for bpf) is not applied. + """ + + model_config = ConfigDict(extra="forbid") + + frequency_drop: Optional[List[Annotated[int, Field(ge=-1, le=32767)]]] = Field( + None, + max_length=1, + description="[frequency]: -1 drops every packet, N > 0 drops every Nth packet, 0 disables the filter", + ) + packet_loss: Optional[List[Annotated[int, Field(ge=0, le=100)]]] = Field( + None, + max_length=1, + description="[chance]: percentage chance for a packet to be lost, 0 disables the filter", + ) + delay: Optional[List[Annotated[int, Field(ge=0, le=32767)]]] = Field( + None, + max_length=2, + description="[latency, jitter] in milliseconds: latency must be 1 to 32767 unless both values are 0, " + "which disables the filter; jitter is 0 to 32767", + ) + corrupt: Optional[List[Annotated[int, Field(ge=0, le=100)]]] = Field( + None, + max_length=1, + description="[chance]: percentage chance for a packet to be corrupted, 0 disables the filter", + ) + bpf: Optional[List[str]] = Field( + None, + max_length=1, + description="[expressions]: BPF expressions, one per line, matching packets are dropped", + ) + + +class LinkFilterParameter(BaseModel): + """ + Parameter of a packet filter. + """ + + name: str + type: Literal["int", "text", "str"] + minimum: Optional[int] = None + maximum: Optional[int] = None + unit: Optional[str] = None + + +class LinkFilterDefinition(BaseModel): + """ + Packet filter available on a link. + """ + + type: LinkFilterType + name: str + description: str + parameters: List[LinkFilterParameter] + + class LinkBase(BaseModel): """ Link data. @@ -62,7 +169,12 @@ class LinkBase(BaseModel): nodes: Optional[List[LinkNode]] = Field(None, min_length=0, max_length=2) suspend: Optional[bool] = None link_style: Optional[LinkStyle] = None - filters: Optional[dict] = None + filters: Optional[dict] = Field( + None, + description="Packet filters applied on a link: filter type → array of positional values. " + "The vocabulary is capability-gated per link (see /available_filters) and validated by the " + "controller, so it is not enumerated here", + ) markers: Optional[dict] = Field( None, description="Traffic-insight markers on this link: name → {bpf, tag, enabled}" ) @@ -71,7 +183,8 @@ class LinkBase(BaseModel): class LinkCreate(LinkBase): link_id: UUID = Field(default_factory=uuid4) - nodes: List[LinkNode] = Field(..., min_length=2, max_length=2) + # LinkNodeCreate narrows the port selector, so it cannot be a LinkNode subclass + nodes: List[LinkNodeCreate] = Field(..., min_length=2, max_length=2) # type: ignore[assignment] class LinkUpdate(PartialUpdateModel, LinkBase): @@ -113,6 +226,7 @@ class UDPPortInfo(BaseModel): rhost: str rport: int type: str + kind: Literal["udp"] class EthernetPortInfo(BaseModel): @@ -123,6 +237,10 @@ class EthernetPortInfo(BaseModel): node_id: UUID interface: str type: str + kind: Literal["ethernet"] + + +LinkIfaceInfo = Annotated[Union[UDPPortInfo, EthernetPortInfo], Discriminator("kind")] class LinkCapture(BaseModel): diff --git a/gns3server/schemas/controller/notifications.py b/gns3server/schemas/controller/notifications.py new file mode 100644 index 000000000..11ed85e4c --- /dev/null +++ b/gns3server/schemas/controller/notifications.py @@ -0,0 +1,154 @@ +# +# 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 . + +from enum import Enum +from typing import Any, Dict, List, Optional, Union +from uuid import UUID + +from pydantic import BaseModel, Field + +from .computes import Compute +from .drawings import Drawing +from .links import Link +from .nodes import Node +from .projects import Project +from .snapshots import Snapshot + + +class NotificationAction(str, Enum): + """ + Values of the "action" field of a notification. + """ + + ping = "ping" + log_info = "log.info" + log_warning = "log.warning" + log_error = "log.error" + node_created = "node.created" + node_updated = "node.updated" + node_deleted = "node.deleted" + node_interface_status = "node.interface_status" + link_created = "link.created" + link_updated = "link.updated" + link_deleted = "link.deleted" + link_web_wireshark_started = "link.web_wireshark_started" + drawing_created = "drawing.created" + drawing_updated = "drawing.updated" + drawing_deleted = "drawing.deleted" + marker_match = "marker.match" + snapshot_restored = "snapshot.restored" + project_created = "project.created" + project_updated = "project.updated" + project_opened = "project.opened" + project_closed = "project.closed" + project_deleted = "project.deleted" + compute_created = "compute.created" + compute_updated = "compute.updated" + compute_deleted = "compute.deleted" + template_created = "template.created" + template_updated = "template.updated" + template_deleted = "template.deleted" + settings_updated = "settings.updated" + + +class PingEvent(BaseModel): + """ + Event of a "ping" notification, sent when the stream is opened and then at least every 5 seconds. + """ + + cpu_usage_percent: float = Field(..., description="CPU usage of the server in percent") + memory_usage_percent: float = Field(..., description="Memory usage of the server in percent") + disk_usage_percent: float = Field(..., description="Disk usage of the project directory in percent") + compute_id: Optional[str] = Field(None, description="Compute that sent the ping (project streams only)") + + +class LogEvent(BaseModel): + """ + Event of a "log.info", "log.warning" or "log.error" notification. + """ + + message: str = Field(..., description="Log message") + + +class NodeInterfaceStatusEvent(BaseModel): + """ + Event of a "node.interface_status" notification. + """ + + project_id: UUID + node_id: UUID + adapter_number: int + port_number: int + status: str = Field(..., description="Status of the interface") + + +class MarkerMatchEvent(BaseModel): + """ + Event of a "marker.match" notification, only sent on the markers WebSocket. + """ + + project_id: UUID + node_id: UUID + link_id: Optional[UUID] = None + filter: str = Field(..., description="Name of the packet filter that matched") + tag: Optional[int] = Field(None, description="Tag of the marker") + ts: float = Field(..., description="Timestamp reported by uBridge (seconds since the epoch)") + len: int = Field(0, description="Length of the matching packet") + dir: Optional[str] = Field(None, description="Direction of the packet relative to the capture node") + + +class LinkWebWiresharkStartedEvent(BaseModel): + """ + Event of a "link.web_wireshark_started" notification. + """ + + link_id: UUID + ws_url: Optional[str] = Field(None, description="URL of the Web Wireshark session") + + +class SettingsUpdatedEvent(BaseModel): + """ + Event of a "settings.updated" notification. + """ + + changed: List[str] = Field(..., description="Names of the settings that changed (values are never sent)") + restart_required: List[str] = Field(..., description="Names of the changed settings that need a server restart") + + +class Notification(BaseModel): + """ + A message of the notification streams. HTTP streams send one JSON object per line (application/x-ndjson), + WebSocket streams send one JSON object per text frame. + """ + + action: NotificationAction = Field(..., description="Type of the notification") + event: Union[ + PingEvent, + LogEvent, + NodeInterfaceStatusEvent, + MarkerMatchEvent, + LinkWebWiresharkStartedEvent, + SettingsUpdatedEvent, + Node, + Link, + Drawing, + Project, + Compute, + Snapshot, + Dict[str, Any], + ] = Field(..., description="Payload of the notification, its content depends on the action") + project_id: Optional[UUID] = Field(None, description="Project the notification belongs to, when set by the sender") + compute_id: Optional[str] = Field(None, description="Compute that emitted the notification, when set by the sender") diff --git a/gns3server/schemas/controller/projects.py b/gns3server/schemas/controller/projects.py index 6354b7696..1b84b5586 100644 --- a/gns3server/schemas/controller/projects.py +++ b/gns3server/schemas/controller/projects.py @@ -15,6 +15,7 @@ # along with this program. If not, see . +from datetime import datetime from enum import Enum from typing import List, Optional from uuid import UUID @@ -111,8 +112,8 @@ class NodeFile(BaseModel): path: str = Field(..., description="File name") size: int = Field(..., description="File size in bytes") - created_at: str = Field(..., description="File creation time (ISO 8601)") - modified_at: str = Field(..., description="File modification time (ISO 8601)") + created_at: Optional[datetime] = Field(None, description="File creation time (ISO 8601)") + modified_at: Optional[datetime] = Field(None, description="File modification time (ISO 8601)") file_type: str = Field(..., description="File type determined by the file command") diff --git a/gns3server/schemas/controller/settings.py b/gns3server/schemas/controller/settings.py index aa457dc31..600b41ba7 100644 --- a/gns3server/schemas/controller/settings.py +++ b/gns3server/schemas/controller/settings.py @@ -103,6 +103,7 @@ class ServerSettingsUpdate(BaseModel): local: Optional[bool] = None enable_http_auth: Optional[bool] = None + openapi_include_ai: Optional[bool] = None name: Optional[str] = None protocol: Optional[ServerProtocol] = None host: Optional[str] = None diff --git a/gns3server/schemas/controller/snapshots.py b/gns3server/schemas/controller/snapshots.py index bf46f2bbe..d86fd21aa 100644 --- a/gns3server/schemas/controller/snapshots.py +++ b/gns3server/schemas/controller/snapshots.py @@ -15,10 +15,11 @@ # along with this program. If not, see . +from datetime import datetime, timezone from typing import Optional from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class SnapshotBase(BaseModel): @@ -44,4 +45,14 @@ class Snapshot(SnapshotBase): name: str = Field(..., description="Name of the snapshot") filename: str = Field(..., description="Filename of the snapshot") description: str = Field(..., description="Description of the snapshot") - created_at: int = Field(..., description="Date of the snapshot (UTC timestamp)") + created_at: int = Field( + ..., description="Date of the snapshot (UTC timestamp). Deprecated: use created", deprecated=True + ) + created: datetime = Field(..., description="Date of the snapshot (ISO 8601, UTC)") + + @model_validator(mode="before") + @classmethod + def _derive_created(cls, data): + if isinstance(data, dict) and "created" not in data and data.get("created_at") is not None: + data = {**data, "created": datetime.fromtimestamp(data["created_at"], tz=timezone.utc)} + return data diff --git a/gns3server/schemas/controller/tokens.py b/gns3server/schemas/controller/tokens.py index c6bde5d0e..1ecd6545a 100644 --- a/gns3server/schemas/controller/tokens.py +++ b/gns3server/schemas/controller/tokens.py @@ -22,7 +22,9 @@ from pydantic import BaseModel class Token(BaseModel): access_token: str token_type: str + expires_in: int refresh_token: Optional[str] = None + refresh_expires_in: Optional[int] = None class TokenData(BaseModel): diff --git a/gns3server/schemas/controller/topology.py b/gns3server/schemas/controller/topology.py index 0c4f39489..365ca5983 100644 --- a/gns3server/schemas/controller/topology.py +++ b/gns3server/schemas/controller/topology.py @@ -32,6 +32,11 @@ from .nodes import Node from .projects import Supplier, Variable +class TopologyLink(Link): + # Legacy topology files store filters as untyped dicts + filters: Optional[dict] = None + + class TopologyType(str, Enum): topology = "topology" @@ -39,7 +44,7 @@ class TopologyType(str, Enum): class TopologyContent(BaseModel): computes: List[Compute] = Field(..., description="List of computes") drawings: List[Drawing] = Field(..., description="List of drawings") - links: List[Link] = Field(..., description="List of links") + links: List[TopologyLink] = Field(..., description="List of links") nodes: List[Node] = Field(..., description="List of nodes") diff --git a/gns3server/server.py b/gns3server/server.py index 3ec16e456..099696f55 100644 --- a/gns3server/server.py +++ b/gns3server/server.py @@ -255,9 +255,9 @@ class Server: else: log.info(f"Compute authentication is enabled with username '{config.Server.compute_username}'") - # we only support Python 3 version >= 3.9 - if sys.version_info < (3, 9, 0): - raise SystemExit("Python 3.9 or higher is required") + # we only support Python 3 version >= 3.11 + if sys.version_info < (3, 11, 0): + raise SystemExit("Python 3.11 or higher is required") log.info( f"Running with Python {sys.version_info[0]}.{sys.version_info[1]}.{sys.version_info[2]} and has PID {os.getpid()}" diff --git a/gns3server/services/authentication.py b/gns3server/services/authentication.py index ecca18578..d4d6f6b79 100644 --- a/gns3server/services/authentication.py +++ b/gns3server/services/authentication.py @@ -29,7 +29,7 @@ from joserfc.jwk import OctKey from pydantic import ValidationError from gns3server.config import Config -from gns3server.schemas.controller.tokens import TokenData +from gns3server.schemas.controller.tokens import Token, TokenData log = logging.getLogger(__name__) @@ -47,6 +47,9 @@ def _extract_alg(token: str) -> str: return "" +BEARER_TOKEN_TYPE = "bearer" + + class AuthService: def hash_password(self, password: str) -> str: @@ -61,8 +64,9 @@ class AuthService: def _create_token(self, username, token_version, token_type, expires_in, secret_key=None) -> str: """Shared helper to create any kind of signed JWT token.""" - expire = datetime.now(timezone.utc) + timedelta(minutes=expires_in) - to_encode = {"sub": username, "exp": expire, "ver": token_version, "type": token_type} + issued_at = datetime.now(timezone.utc) + expire = issued_at + timedelta(minutes=expires_in) + to_encode = {"sub": username, "iat": issued_at, "exp": expire, "ver": token_version, "type": token_type} if secret_key is None: secret_key = Config.instance().settings.Controller.jwt_secret_key if secret_key is None: @@ -89,6 +93,17 @@ class AuthService: expires_in = Config.instance().settings.Controller.jwt_refresh_token_expire_minutes return self._create_token(username, token_version, "refresh", expires_in, secret_key) + def create_token_response(self, user) -> Token: + + settings = Config.instance().settings.Controller + return Token( + access_token=self.create_access_token(user.username, token_version=user.token_version), + token_type=BEARER_TOKEN_TYPE, + expires_in=settings.jwt_access_token_expire_minutes * 60, + refresh_token=self.create_refresh_token(user.username, token_version=user.token_version), + refresh_expires_in=settings.jwt_refresh_token_expire_minutes * 60, + ) + def get_token_data(self, token: str, secret_key: Optional[str] = None) -> TokenData: def auth_error(detail: str) -> HTTPException: diff --git a/gns3server/version.py b/gns3server/version.py index 2b8d74b08..541a3722e 100644 --- a/gns3server/version.py +++ b/gns3server/version.py @@ -35,3 +35,5 @@ if "dev" in __version__: __version__ += "+" + r except Exception as e: print(e) + +__api_version__ = __version__.split("+", 1)[0] diff --git a/pyproject.toml b/pyproject.toml index 425402005..5ff415217 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "gns3-server" -description = "GNS3 graphical interface for the GNS3 server." +description = "GNS3 server (controller and compute) for GNS3 network simulator" license-files = ["LICENSE"] authors = [ { name = "Jeremy Grossmann", email = "developers@gns3.com" } ] readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", @@ -21,10 +21,10 @@ classifiers = [ "Natural Language :: English", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython" ] @@ -236,6 +236,7 @@ extend-immutable-calls = [ "scripts/welcome.py" = ["C901", "E722", "PLW1510", "S108", "S112", "S603", "S605", "S606", "S607"] "tests/agent/mcp/test_handlers.py" = ["B905"] "tests/agent/mcp/test_tool_params.py" = ["C901"] +"tests/api/test_openapi_spec.py" = ["S603"] "tests/api/routes/controller/test_images.py" = ["S324", "W292"] "tests/api/routes/controller/test_links.py" = ["RUF015", "W292"] "tests/api/routes/controller/test_users.py" = ["F811"] diff --git a/requirements.txt b/requirements.txt index 2094cab64..c874bfbb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,17 @@ # GNS3 Server Core Dependencies uvicorn==0.54.0 pydantic==2.13.5 -fastapi==0.141.1 +fastapi==0.142.4 python-multipart==0.0.32 -websockets==16.1.1 # version 16.1.1 is the last version that supports Python 3.10 -aiohttp>=3.14.3,<3.15 +websockets==16.1.1 # google-genai 2.10.0 has requirement websockets<17.0 +aiohttp>=3.14.4,<3.15 aiofiles>=25.1.0,<26.0 Jinja2>=3.1.6,<3.2 sentry-sdk>=2.70.0,<3 # optional dependency psutil>=7.2.2 -async-timeout>=5.0.1,<5.1; python_version < '3.11' # this library has effectively been upstreamed into Python 3.11+ distro>=1.9.0 py-cpuinfo>=9.0.0,<10.0 -greenlet==3.5.3; python_version >= '3.13' # necessary to run sqlalchemy on Python >= 3.13 -sqlalchemy==2.0.54 # version 2.0.54 is the last version that supports Python 3.10 +sqlalchemy[asyncio]==2.1.4 aiosqlite==0.22.1 alembic==1.20 bcrypt==5.0.0 @@ -22,7 +20,7 @@ email-validator==2.3.0 watchdog==6.0.0 zstandard==0.25.0 platformdirs>=2.4.0 # fastmcp-slim >=3.4 requires >=4.0.0; upper bound removed for compatibility -truststore>=0.10.4; python_version >= '3.10' +truststore>=0.10.4 # Shared dependencies (also used by AI Copilot) telnetlib3==5.0.1,<6 diff --git a/tests/api/routes/controller/test_appliances.py b/tests/api/routes/controller/test_appliances.py index 94e7673f7..193557e35 100644 --- a/tests/api/routes/controller/test_appliances.py +++ b/tests/api/routes/controller/test_appliances.py @@ -101,3 +101,33 @@ class TestApplianceRoutes: app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version ) assert response.status_code == status.HTTP_409_CONFLICT + + async def test_add_v8_version_appliance(self, app: FastAPI, client: AsyncClient) -> None: + + appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance + new_version = {"name": "v8-99G", "category": "guest", "images": {"hda_disk_image": "empty99G.qcow2"}} + response = await client.post( + app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version + ) + assert response.status_code == status.HTTP_201_CREATED + assert new_version in response.json()["versions"] + + async def test_add_version_appliance_with_registry_version(self, app: FastAPI, client: AsyncClient) -> None: + + appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance + stored_version = {"name": "explicit-99G", "images": {"hda_disk_image": "empty99G.qcow2"}} + response = await client.post( + app.url_path_for("add_appliance_version", appliance_id=appliance_id), + json=dict(stored_version, registry_version=3), + ) + assert response.status_code == status.HTTP_201_CREATED + assert stored_version in response.json()["versions"] + + async def test_add_version_appliance_unknown_registry_version(self, app: FastAPI, client: AsyncClient) -> None: + + appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance + response = await client.post( + app.url_path_for("add_appliance_version", appliance_id=appliance_id), + json={"name": "bad", "registry_version": 7, "images": {"hda_disk_image": "empty99G.qcow2"}}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY diff --git a/tests/api/routes/controller/test_links.py b/tests/api/routes/controller/test_links.py index 08c73a8f7..68f54f037 100644 --- a/tests/api/routes/controller/test_links.py +++ b/tests/api/routes/controller/test_links.py @@ -115,6 +115,110 @@ class TestLinkRoutes: assert response.status_code == status.HTTP_409_CONFLICT assert len(project.links) == 0 + async def test_create_link_by_port_name( + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] + ) -> None: + + node1, node2 = nodes + + with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"): + response = await client.post( + app.url_path_for("create_link", project_id=project.id), + json={ + "nodes": [ + {"node_id": node1.id, "port_name": "E0"}, + {"node_id": node2.id, "adapter_number": 2, "port_number": 4}, + ] + }, + ) + + assert response.status_code == status.HTTP_201_CREATED + first, second = response.json()["nodes"] + assert (first["adapter_number"], first["port_number"]) == (0, 3) + assert (second["adapter_number"], second["port_number"]) == (2, 4) + + async def test_create_link_unknown_port_name( + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] + ) -> None: + + node1, node2 = nodes + + response = await client.post( + app.url_path_for("create_link", project_id=project.id), + json={ + "nodes": [ + {"node_id": node1.id, "port_name": "nope"}, + {"node_id": node2.id, "adapter_number": 2, "port_number": 4}, + ] + }, + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert len(project.links) == 0 + + async def test_create_link_auto_port( + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] + ) -> None: + + node1, node2 = nodes + node1._ports = [EthernetPort("E1", 0, 0, 5), EthernetPort("E0", 0, 0, 3)] + + with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"): + response = await client.post( + app.url_path_for("create_link", project_id=project.id), + json={"nodes": [{"node_id": node1.id, "port": "auto"}, {"node_id": node2.id, "port": "auto"}]}, + ) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["nodes"][0]["port_number"] == 3 + + response = await client.post( + app.url_path_for("create_link", project_id=project.id), + json={"nodes": [{"node_id": node1.id, "port": "auto"}, {"node_id": node2.id, "port": "auto"}]}, + ) + + assert response.status_code == status.HTTP_409_CONFLICT + assert response.json()["code"] == "no_free_port" + assert len(project.links) == 1 + + async def test_create_link_port_in_use_code( + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] + ) -> None: + + node1, node2 = nodes + body = { + "nodes": [ + {"node_id": node1.id, "adapter_number": 0, "port_number": 3}, + {"node_id": node2.id, "adapter_number": 2, "port_number": 4}, + ] + } + + with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"): + first = await client.post(app.url_path_for("create_link", project_id=project.id), json=body) + second = await client.post(app.url_path_for("create_link", project_id=project.id), json=body) + + assert first.status_code == status.HTTP_201_CREATED + assert second.status_code == status.HTTP_409_CONFLICT + assert second.json()["code"] == "port_in_use" + assert len(project.links) == 1 + + async def test_create_link_invalid_port_selector( + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] + ) -> None: + + node1, node2 = nodes + valid = {"node_id": node2.id, "adapter_number": 2, "port_number": 4} + + for bad in ( + {"node_id": node1.id}, + {"node_id": node1.id, "adapter_number": 0}, + {"node_id": node1.id, "port_name": "E0", "port": "auto"}, + {"node_id": node1.id, "adapter_number": 0, "port_number": 3, "port_name": "E0"}, + ): + response = await client.post( + app.url_path_for("create_link", project_id=project.id), json={"nodes": [bad, valid]} + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + async def test_get_link( self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] ) -> None: @@ -170,6 +274,43 @@ class TestLinkRoutes: assert response.status_code == status.HTTP_200_OK assert response.json()["kernel_datapath"] is True + async def test_link_etag_and_if_match( + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] + ) -> None: + + node1, node2 = nodes + with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"): + response = await client.post( + app.url_path_for("create_link", project_id=project.id), + json={ + "nodes": [ + {"node_id": node1.id, "adapter_number": 0, "port_number": 3}, + {"node_id": node2.id, "adapter_number": 2, "port_number": 4}, + ] + }, + ) + link_id = response.json()["link_id"] + url = app.url_path_for("get_link", project_id=project.id, link_id=link_id) + + response = await client.get(url) + assert response.status_code == status.HTTP_200_OK + etag = response.headers["ETag"] + assert (await client.get(url)).headers["ETag"] == etag + + response = await client.put(url, json={"show_filters_icon": False}, headers={"If-Match": etag}) + assert response.status_code == status.HTTP_200_OK + assert response.headers["ETag"] != etag + assert (await client.get(url)).headers["ETag"] == response.headers["ETag"] + + response = await client.put(url, json={"show_filters_icon": True}, headers={"If-Match": etag}) + assert response.status_code == status.HTTP_412_PRECONDITION_FAILED + assert "message" in response.json() + assert (await client.get(url)).json()["show_filters_icon"] is False + + response = await client.put(url, json={"show_filters_icon": True}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["show_filters_icon"] is True + async def test_update_link_suspend( self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] ) -> None: @@ -362,6 +503,119 @@ class TestLinkRoutes: assert response.status_code == status.HTTP_200_OK assert response.json() == FILTERS + async def test_update_link_filters(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = Link(project) + project._links = {link.id: link} + filters = { + "frequency_drop": [50], + "packet_loss": [10], + "delay": [10, 5], + "corrupt": [3], + "bpf": ["icmp"], + } + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": filters} + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["filters"] == filters + assert link.filters == filters + + async def test_update_link_disabled_filters(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = Link(project) + project._links = {link.id: link} + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link.id), + json={"filters": {"delay": [0, 0], "packet_loss": [0], "corrupt": [], "bpf": [""]}}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["filters"] == {} + + @pytest.mark.parametrize( + "filters", + [ + {"packet_loss": [101]}, + {"packet_loss": [-1]}, + {"corrupt": [101]}, + {"frequency_drop": [-2]}, + {"frequency_drop": [32768]}, + {"delay": [32768, 0]}, + {"delay": [10, -1]}, + # a second packet_loss value (correlation) is valid — it is a + # kernel-datapath parameter the controller accepts; gemodel out + # of range stands in for the invalid slot instead + {"gemodel": [101, 50, 50]}, + {"bpf": [1]}, + {"packet_loss": ["abc"]}, + ], + ) + async def test_update_link_invalid_filter_values( + self, app: FastAPI, client: AsyncClient, project: Project, filters: dict + ) -> None: + + link = Link(project) + project._links = {link.id: link} + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": filters} + ) + # The filter vocabulary is capability-gated per link, so values are + # validated by the controller (409), not by a strict request schema + assert response.status_code == status.HTTP_409_CONFLICT + assert "message" in response.json() + assert link.filters == {} + + async def test_update_link_unknown_filter_type(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = Link(project) + project._links = {link.id: link} + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link.id), + json={"filters": {"unknown": [1]}}, + ) + assert response.status_code == status.HTTP_409_CONFLICT + assert "message" in response.json() + assert link.filters == {} + + async def test_update_link_packet_loss_correlation( + self, app: FastAPI, client: AsyncClient, project: Project + ) -> None: + # The optional packet_loss correlation (second value) is a + # kernel-datapath parameter accepted by the controller + link = Link(project) + project._links = {link.id: link} + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link.id), + json={"filters": {"packet_loss": [1, 2]}}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["filters"] == {"packet_loss": [1, 2]} + + async def test_update_link_invalid_delay_latency(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = Link(project) + project._links = {link.id: link} + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": {"delay": [0, 5]}} + ) + assert response.status_code == status.HTTP_409_CONFLICT + + async def test_openapi_link_filters(self, app: FastAPI) -> None: + + schemas = app.openapi()["components"]["schemas"] + # The filters vocabulary is capability-gated per link and validated by + # the controller, so the Link schema carries it as a free-form object + # (no strict LinkFilters model on the request path) + assert "LinkFilters" not in schemas + filters_property = schemas["Link"]["properties"]["filters"] + assert filters_property["anyOf"][0] == {"additionalProperties": True, "type": "object"} + assert schemas["LinkFilterDefinition"]["properties"]["parameters"]["items"] == { + "$ref": "#/components/schemas/LinkFilterParameter" + } + path = "/v3/projects/{project_id}/links/{link_id}/available_filters" + responses = app.openapi()["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + assert responses["items"] == {"$ref": "#/components/schemas/LinkFilterDefinition"} + async def test_get_udp_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None: """ Test getting UDP tunnel interface information from a link. @@ -403,6 +657,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: """ @@ -433,3 +688,4 @@ class TestLinkRoutes: assert result["node_id"] == cloud_node.id assert result["interface"] == "eth0" assert result["type"] == "ethernet" + assert result["kind"] == "ethernet" diff --git a/tests/api/routes/controller/test_nodes.py b/tests/api/routes/controller/test_nodes.py index 063b22714..458bfb309 100644 --- a/tests/api/routes/controller/test_nodes.py +++ b/tests/api/routes/controller/test_nodes.py @@ -34,6 +34,7 @@ from pydantic import SecretStr from gns3server.api.routes.controller.nodes import vnc_console, ws_console from gns3server.config import Config from gns3server.controller.compute import Compute +from gns3server.controller.controller_error import ComputeError from gns3server.controller.node import Node from gns3server.controller.project import Project from gns3server.services import auth_service @@ -250,6 +251,77 @@ class TestNodeRoutes: assert "name" not in response.json()["properties"] assert response.json()["tags"] == ["tag1", "tag2"] + async def test_node_etag_on_get_and_put( + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node + ) -> None: + + compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048})) + url = app.url_path_for("update_node", project_id=project.id, node_id=node.id) + + response = await client.get(url) + assert response.status_code == status.HTTP_200_OK + etag = response.headers["ETag"] + assert etag.startswith('"') and etag.endswith('"') + assert (await client.get(url)).headers["ETag"] == etag + + response = await client.put(url, json={"name": "renamed"}) + assert response.status_code == status.HTTP_200_OK + assert response.headers["ETag"] != etag + assert (await client.get(url)).headers["ETag"] == response.headers["ETag"] + + async def test_update_node_with_matching_if_match( + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node + ) -> None: + + compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048})) + url = app.url_path_for("update_node", project_id=project.id, node_id=node.id) + + etag = (await client.get(url)).headers["ETag"] + response = await client.put(url, json={"name": "renamed"}, headers={"If-Match": etag}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == "renamed" + assert response.headers["ETag"] != etag + + async def test_update_node_with_stale_if_match( + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node + ) -> None: + + compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048})) + url = app.url_path_for("update_node", project_id=project.id, node_id=node.id) + + stale_etag = (await client.get(url)).headers["ETag"] + assert (await client.put(url, json={"name": "first"})).status_code == status.HTTP_200_OK + + response = await client.put(url, json={"name": "second"}, headers={"If-Match": stale_etag}) + assert response.status_code == status.HTTP_412_PRECONDITION_FAILED + assert "message" in response.json() + assert node.name == "first" + + async def test_update_node_without_if_match( + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node + ) -> None: + + compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048})) + url = app.url_path_for("update_node", project_id=project.id, node_id=node.id) + + assert (await client.put(url, json={"name": "first"})).status_code == status.HTTP_200_OK + assert (await client.put(url, json={"name": "second"})).status_code == status.HTTP_200_OK + assert node.name == "second" + + async def test_update_node_with_wildcard_and_list_if_match( + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node + ) -> None: + + compute.put = AsyncioMagicMock(return_value=MagicMock(json={"console": 2048})) + url = app.url_path_for("update_node", project_id=project.id, node_id=node.id) + + etag = (await client.get(url)).headers["ETag"] + response = await client.put(url, json={"name": "a"}, headers={"If-Match": "*"}) + assert response.status_code == status.HTTP_200_OK + etag = (await client.get(url)).headers["ETag"] + response = await client.put(url, json={"name": "b"}, headers={"If-Match": f'"other", {etag}'}) + assert response.status_code == status.HTTP_200_OK + async def test_start_all_nodes(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None: compute.post = AsyncioMagicMock() @@ -292,7 +364,8 @@ class TestNodeRoutes: compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("start_node", project_id=project.id, node_id=node.id), json={}) - assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.status_code == status.HTTP_200_OK + assert response.json()["status"] == "started" async def test_stop_node( self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node @@ -300,6 +373,84 @@ class TestNodeRoutes: compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("stop_node", project_id=project.id, node_id=node.id)) + assert response.status_code == status.HTTP_200_OK + assert response.json()["status"] == "stopped" + + async def test_start_node_wait( + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node + ) -> None: + + compute.post = AsyncioMagicMock() + response = await client.post( + app.url_path_for("start_node", project_id=project.id, node_id=node.id) + "?wait=true&timeout=5", json={} + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["status"] == "started" + response = await client.get(app.url_path_for("get_node", project_id=project.id, node_id=node.id)) + assert response.json()["status"] == "started" + + async def test_start_node_reports_starting_while_in_flight( + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node + ) -> None: + + seen = [] + + async def post(*args, **kwargs): + seen.append(node.asdict()["status"]) + + compute.post = post + response = await client.post(app.url_path_for("start_node", project_id=project.id, node_id=node.id), json={}) + assert response.status_code == status.HTTP_200_OK + assert seen == ["starting"] + assert response.json()["status"] == "started" + + async def test_stop_node_reports_stopping_while_in_flight( + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node + ) -> None: + + seen = [] + + async def post(*args, **kwargs): + seen.append(node.asdict()["status"]) + + compute.post = post + node._status = "started" + response = await client.post(app.url_path_for("stop_node", project_id=project.id, node_id=node.id)) + assert response.status_code == status.HTTP_200_OK + assert seen == ["stopping"] + assert response.json()["status"] == "stopped" + + async def test_start_node_wait_timeout( + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node + ) -> None: + + async def post(*args, **kwargs): + await asyncio.sleep(10) + + compute.post = post + response = await client.post( + app.url_path_for("start_node", project_id=project.id, node_id=node.id) + "?wait=true&timeout=1", json={} + ) + assert response.status_code == status.HTTP_408_REQUEST_TIMEOUT + assert "message" in response.json() + assert node.asdict()["status"] == "stopped" + + async def test_stop_node_reports_compute_error( + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node + ) -> None: + + compute.post = AsyncioMagicMock(side_effect=ComputeError("compute unreachable")) + node._status = "started" + response = await client.post(app.url_path_for("stop_node", project_id=project.id, node_id=node.id)) + assert response.status_code == status.HTTP_409_CONFLICT + assert node.asdict()["status"] == "started" + + async def test_stop_all_nodes_tolerates_compute_error( + self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node + ) -> None: + + compute.post = AsyncioMagicMock(side_effect=ComputeError("compute unreachable")) + response = await client.post(app.url_path_for("stop_all_nodes", project_id=project.id)) assert response.status_code == status.HTTP_204_NO_CONTENT async def test_suspend_node( @@ -308,7 +459,8 @@ class TestNodeRoutes: compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("suspend_node", project_id=project.id, node_id=node.id)) - assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.status_code == status.HTTP_200_OK + assert response.json()["status"] == "suspended" async def test_suspend_node_unsupported( self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node @@ -338,7 +490,8 @@ class TestNodeRoutes: compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("reload_node", project_id=project.id, node_id=node.id)) - assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.status_code == status.HTTP_200_OK + assert response.json()["status"] == "started" async def test_isolate_node( self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute, node: Node diff --git a/tests/api/routes/controller/test_notifications.py b/tests/api/routes/controller/test_notifications.py new file mode 100644 index 000000000..ff6209e19 --- /dev/null +++ b/tests/api/routes/controller/test_notifications.py @@ -0,0 +1,88 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +import uuid + +import pytest +from fastapi import FastAPI +from pydantic import ValidationError + +from gns3server import schemas +from gns3server.utils.notification_queue import NotificationQueue + +STREAM_PATHS = ["/v3/notifications", "/v3/projects/{project_id}/notifications"] + + +class TestNotificationOpenAPI: + def test_notification_schema_in_spec(self, app: FastAPI) -> None: + + spec = app.openapi() + schema = spec["components"]["schemas"]["Notification"] + assert set(schema["required"]) == {"action", "event"} + assert {"action", "event", "project_id", "compute_id"} <= set(schema["properties"]) + action_schema = spec["components"]["schemas"]["NotificationAction"] + assert {"ping", "node.created", "node.updated", "link.created", "marker.match"} <= set(action_schema["enum"]) + + @pytest.mark.parametrize("path", STREAM_PATHS) + def test_stream_responses_reference_schema(self, app: FastAPI, path: str) -> None: + + content = app.openapi()["paths"][path]["get"]["responses"]["200"]["content"] + assert list(content) == ["application/x-ndjson"] + assert content["application/x-ndjson"]["schema"] == {"$ref": "#/components/schemas/Notification"} + + +class TestNotificationSchema: + def test_ping(self) -> None: + + message = { + "action": "ping", + "event": {"cpu_usage_percent": 1.5, "memory_usage_percent": 40.0, "disk_usage_percent": 10.0}, + } + notification = schemas.Notification.model_validate(message) + assert notification.action == schemas.NotificationAction.ping + + def test_unknown_action(self) -> None: + + with pytest.raises(ValidationError): + schemas.Notification.model_validate({"action": "unknown.action", "event": {}}) + + def test_optional_ids(self) -> None: + + project_id = str(uuid.uuid4()) + notification = schemas.Notification.model_validate( + {"action": "log.info", "event": {"message": "hello"}, "project_id": project_id, "compute_id": "local"} + ) + assert str(notification.project_id) == project_id + assert notification.compute_id == "local" + + def test_generic_event(self) -> None: + + notification = schemas.Notification.model_validate( + {"action": "template.deleted", "event": {"template_id": str(uuid.uuid4())}} + ) + assert notification.action == schemas.NotificationAction.template_deleted + + @pytest.mark.asyncio + async def test_queue_message_matches_schema(self) -> None: + + queue = NotificationQueue() + queue.put_nowait(("log.warning", {"message": "disk space is low"}, {"compute_id": "local"})) + await queue.get_json(5) + message = json.loads(await queue.get_json(5)) + notification = schemas.Notification.model_validate(message) + assert notification.action == schemas.NotificationAction.log_warning + assert notification.compute_id == "local" diff --git a/tests/api/routes/controller/test_projects.py b/tests/api/routes/controller/test_projects.py index 71e2e37be..fa5e8e270 100644 --- a/tests/api/routes/controller/test_projects.py +++ b/tests/api/routes/controller/test_projects.py @@ -139,6 +139,33 @@ class TestControllerProjectRoutes: assert response.json()["path"] == path assert response.json()["project_id"] == "10010203-0405-0607-0809-0a0b0c0d0e0f" + async def test_project_etag_and_if_match(self, app: FastAPI, client: AsyncClient, controller: Controller) -> None: + + params = {"name": "test", "project_id": "10010203-0405-0607-0809-0a0b0c0d0e0f"} + assert (await client.post(app.url_path_for("create_project"), json=params)).status_code == 201 + get_url = app.url_path_for("get_project", project_id="10010203-0405-0607-0809-0a0b0c0d0e0f") + put_url = app.url_path_for("update_project", project_id="10010203-0405-0607-0809-0a0b0c0d0e0f") + + response = await client.get(get_url) + assert response.status_code == status.HTTP_200_OK + etag = response.headers["ETag"] + assert (await client.get(get_url)).headers["ETag"] == etag + + response = await client.put(put_url, json={"name": "test2"}, headers={"If-Match": etag}) + assert response.status_code == status.HTTP_200_OK + new_etag = response.headers["ETag"] + assert new_etag != etag + assert (await client.get(get_url)).headers["ETag"] == new_etag + + response = await client.put(put_url, json={"name": "test3"}, headers={"If-Match": etag}) + assert response.status_code == status.HTTP_412_PRECONDITION_FAILED + assert "message" in response.json() + assert (await client.get(get_url)).json()["name"] == "test2" + + response = await client.put(put_url, json={"name": "test4"}) + assert response.status_code == status.HTTP_200_OK + assert response.json()["name"] == "test4" + async def test_update_project_with_variables( self, app: FastAPI, client: AsyncClient, controller: Controller ) -> None: diff --git a/tests/api/routes/controller/test_snapshots.py b/tests/api/routes/controller/test_snapshots.py index 73f6e1ca7..aeff6d9f9 100644 --- a/tests/api/routes/controller/test_snapshots.py +++ b/tests/api/routes/controller/test_snapshots.py @@ -17,6 +17,7 @@ import os import uuid +from datetime import datetime, timezone import pytest import pytest_asyncio @@ -54,6 +55,9 @@ class TestSnapshotRoutes: response = await client.get(app.url_path_for("get_snapshots", project_id=project.id)) assert response.status_code == status.HTTP_200_OK assert len(response.json()) == 1 + data = response.json()[0] + assert isinstance(data["created_at"], int) + assert datetime.fromisoformat(data["created"]) == datetime.fromtimestamp(data["created_at"], tz=timezone.utc) async def test_delete_snapshot( self, app: FastAPI, client: AsyncClient, project: Project, snapshot: Snapshot @@ -80,3 +84,15 @@ class TestSnapshotRoutes: response = await client.post(app.url_path_for("create_snapshot", project_id=project.id), json={"name": "snap1"}) assert response.status_code == status.HTTP_201_CREATED assert len(os.listdir(os.path.join(project.path, "snapshots"))) == 2 + data = response.json() + assert isinstance(data["created_at"], int) + assert datetime.fromisoformat(data["created"]) == datetime.fromtimestamp(data["created_at"], tz=timezone.utc) + + async def test_snapshot_schema_timestamps(self, app: FastAPI) -> None: + + schemas = app.openapi()["components"]["schemas"] + assert schemas["Snapshot"]["properties"]["created"]["format"] == "date-time" + assert schemas["Snapshot"]["properties"]["created_at"]["type"] == "integer" + assert schemas["Snapshot"]["properties"]["created_at"]["deprecated"] is True + for field in ("created_at", "modified_at"): + assert {"type": "string", "format": "date-time"} in schemas["NodeFile"]["properties"][field]["anyOf"] diff --git a/tests/api/routes/controller/test_users.py b/tests/api/routes/controller/test_users.py index 0eb708139..dc218c90e 100644 --- a/tests/api/routes/controller/test_users.py +++ b/tests/api/routes/controller/test_users.py @@ -446,6 +446,74 @@ class TestLogout: assert response.status_code == status.HTTP_401_UNAUTHORIZED +class TestTokenLifetime: + async def _issue(self, app, client, user, endpoint): + credentials = {"username": user.username, "password": "user1_password"} + if endpoint == "login": + return await client.post( + app.url_path_for("login"), + data=credentials, + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + auth = await client.post(app.url_path_for("authenticate"), json=credentials) + if endpoint == "authenticate": + return auth + return await client.post( + app.url_path_for("refresh_access_token"), json={"refresh_token": auth.json()["refresh_token"]} + ) + + @pytest.mark.parametrize("endpoint", ("login", "authenticate", "refresh_access_token")) + async def test_token_response_includes_lifetimes( + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, + config: Config, + endpoint: str, + ) -> None: + + response = await self._issue(app, unauthorized_client, test_user, endpoint) + assert response.status_code == status.HTTP_200_OK + body = response.json() + controller = config.settings.Controller + assert body["expires_in"] == controller.jwt_access_token_expire_minutes * 60 + assert body["refresh_expires_in"] == controller.jwt_refresh_token_expire_minutes * 60 + assert body["access_token"] + assert body["token_type"] == "bearer" + assert body["refresh_token"] + + @pytest.mark.parametrize("endpoint", ("login", "authenticate", "refresh_access_token")) + async def test_expires_in_matches_jwt_claims( + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, + config: Config, + endpoint: str, + ) -> None: + + response = await self._issue(app, unauthorized_client, test_user, endpoint) + body = response.json() + key = OctKey.import_key(config.settings.Controller.jwt_secret_key) + access = jwt.decode(body["access_token"], key, algorithms=["HS256"]).claims + refresh = jwt.decode(body["refresh_token"], key, algorithms=["HS256"]).claims + assert access["exp"] - access["iat"] == body["expires_in"] + assert refresh["exp"] - refresh["iat"] == body["refresh_expires_in"] + + async def test_openapi_declares_token_lifetimes_as_integers(self, app: FastAPI) -> None: + + schema = app.openapi()["components"]["schemas"]["Token"] + assert schema["properties"]["expires_in"]["type"] == "integer" + refresh_expires_in = schema["properties"]["refresh_expires_in"] + assert "integer" in str(refresh_expires_in) + assert "expires_in" in schema["required"] + + async def test_openapi_security_scheme_mentions_api_keys(self, app: FastAPI) -> None: + + schemes = app.openapi()["components"]["securitySchemes"] + assert any("API key" in scheme.get("description", "") for scheme in schemes.values()) + + class TestRefreshToken: async def test_login_returns_refresh_token( self, diff --git a/tests/api/routes/controller/test_version.py b/tests/api/routes/controller/test_version.py index 39fd1ee17..a56e1ff26 100644 --- a/tests/api/routes/controller/test_version.py +++ b/tests/api/routes/controller/test_version.py @@ -42,7 +42,10 @@ class TestVersionRoutes: params = {"version": "0.4.2"} response = await client.post(app.url_path_for("check_version"), json=params) assert response.status_code == status.HTTP_409_CONFLICT - assert response.json() == {"message": f"Client version 0.4.2 is not the same as server version {__version__}"} + assert response.json() == { + "message": f"Client version 0.4.2 is not the same as server version {__version__}", + "code": "conflict", + } async def test_version_invalid_input_schema(self, app: FastAPI, client: AsyncClient) -> None: diff --git a/tests/api/routes/test_error_responses.py b/tests/api/routes/test_error_responses.py new file mode 100644 index 000000000..db3577494 --- /dev/null +++ b/tests/api/routes/test_error_responses.py @@ -0,0 +1,221 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import uuid +from typing import Tuple +from unittest.mock import MagicMock + +import pytest +import pytest_asyncio +from fastapi import FastAPI, status +from httpx import AsyncClient + +from gns3server.api.errors import code_for_status +from gns3server.controller.compute import Compute +from gns3server.controller.controller_error import ComputeConflictError, ControllerError +from gns3server.controller.node import Node +from gns3server.controller.ports.ethernet_port import EthernetPort +from gns3server.controller.project import Project +from gns3server.db.models import User +from gns3server.schemas import ErrorMessage +from gns3server.services import auth_service +from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY +from tests.utils import AsyncioMagicMock, asyncio_patch + +pytestmark = pytest.mark.asyncio + + +class TestErrorResponses: + @pytest_asyncio.fixture + async def nodes(self, compute: Compute, project: Project) -> Tuple[Node, Node]: + + response = MagicMock() + response.json = {"console": 2048} + compute.post = AsyncioMagicMock(return_value=response) + + node1 = await project.add_node(compute, "node1", None, node_type="qemu") + node1._ports = [EthernetPort("E0", 0, 0, 3)] + node2 = await project.add_node(compute, "node2", None, node_type="qemu") + node2._ports = [EthernetPort("E0", 0, 2, 4)] + return node1, node2 + + async def test_unauthorized(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + response = await client.get( + app.url_path_for("get_project", project_id=project.id), + headers={"Authorization": "Bearer invalid_token"}, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + body = response.json() + assert isinstance(body["message"], str) + assert body["code"] == "unauthorized" + assert "detail" not in body + + async def test_forbidden(self, app: FastAPI, client: AsyncClient, test_user: User, project: Project) -> None: + + token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY) + response = await client.delete( + app.url_path_for("delete_project", project_id=project.id), + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + body = response.json() + assert isinstance(body["message"], str) + assert body["code"] == "forbidden" + + async def test_unknown_node(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + node_id = str(uuid.uuid4()) + response = await client.get(app.url_path_for("get_node", project_id=project.id, node_id=node_id)) + assert response.status_code == status.HTTP_404_NOT_FOUND + body = response.json() + assert body["message"] == f"Node ID {node_id} doesn't exist" + assert body["code"] == "node_not_found" + assert body["details"] == {"node_id": node_id} + + async def test_unknown_project(self, app: FastAPI, client: AsyncClient) -> None: + + project_id = str(uuid.uuid4()) + response = await client.get(app.url_path_for("get_project", project_id=project_id)) + assert response.status_code == status.HTTP_404_NOT_FOUND + body = response.json() + assert body["code"] == "project_not_found" + assert body["details"] == {"project_id": project_id} + + async def test_unknown_route(self, client: AsyncClient) -> None: + + response = await client.get("/v3/no/such/route") + assert response.status_code == status.HTTP_404_NOT_FOUND + body = response.json() + assert body["message"] == "Not Found" + assert body["code"] == "not_found" + assert "detail" not in body + + async def test_port_in_use( + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] + ) -> None: + + node1, node2 = nodes + payload = { + "nodes": [ + {"node_id": node1.id, "adapter_number": 0, "port_number": 3}, + {"node_id": node2.id, "adapter_number": 2, "port_number": 4}, + ] + } + with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"): + first = await client.post(app.url_path_for("create_link", project_id=project.id), json=payload) + assert first.status_code == status.HTTP_201_CREATED + second = await client.post(app.url_path_for("create_link", project_id=project.id), json=payload) + + assert second.status_code == status.HTTP_409_CONFLICT + body = second.json() + assert body["message"] == "Port is already used" + assert body["code"] == "port_in_use" + assert body["details"]["node_id"] == node1.id + assert body["details"]["adapter_number"] == 0 + assert body["details"]["port_number"] == 3 + assert body["details"]["link_id"] == first.json()["link_id"] + + async def test_generic_conflict( + self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] + ) -> None: + + node1, _ = nodes + node1._ports = [EthernetPort("E0", 0, 0, 3), EthernetPort("E1", 0, 0, 4)] + response = await client.post( + app.url_path_for("create_link", project_id=project.id), + json={ + "nodes": [ + {"node_id": node1.id, "adapter_number": 0, "port_number": 3}, + {"node_id": node1.id, "adapter_number": 0, "port_number": 4}, + ] + }, + ) + assert response.status_code == status.HTTP_409_CONFLICT + body = response.json() + assert body["message"] == "Cannot connect to itself" + assert body["code"] == "conflict" + assert "details" not in body + + async def test_validation_error(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + response = await client.post(app.url_path_for("create_link", project_id=project.id), json={"nodes": "bad"}) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + body = response.json() + assert isinstance(body["message"], str) + assert body["code"] == "validation_error" + assert body["details"]["errors"] + assert {"loc", "msg", "type"} <= set(body["details"]["errors"][0]) + assert "detail" not in body + + async def test_message_only_clients_keep_working(self, app: FastAPI, client: AsyncClient) -> None: + + response = await client.get(app.url_path_for("get_project", project_id=str(uuid.uuid4()))) + assert ErrorMessage.model_validate({"message": response.json()["message"]}).code is None + assert isinstance(response.json()["message"], str) + + +class TestErrorCodes: + async def test_default_codes(self) -> None: + + assert ControllerError("x").code == "conflict" + assert ControllerError("x", code="custom", details={"a": 1}).details == {"a": 1} + + async def test_compute_conflict_code(self) -> None: + + error = ComputeConflictError("http://c/x", {"message": "boom", "code": "port_in_use"}) + assert error.code == "port_in_use" + assert ComputeConflictError("http://c/x", {"message": "boom"}).code == "compute_conflict" + + @pytest.mark.parametrize( + "status_code,code", + [(401, "unauthorized"), (403, "forbidden"), (404, "not_found"), (409, "conflict"), (501, "not_implemented")], + ) + async def test_code_for_status(self, status_code: int, code: str) -> None: + + assert code_for_status(status_code) == code + + +class TestOpenAPIErrorSchemas: + async def test_error_schemas(self, client: AsyncClient) -> None: + + response = await client.get("/openapi.json") + assert response.status_code == status.HTTP_200_OK + spec = response.json() + + schemas = spec["components"]["schemas"] + assert "HTTPValidationError" not in schemas + assert "ValidationError" not in schemas + assert set(schemas["ErrorMessage"]["properties"]) == {"message", "code", "details"} + assert schemas["ErrorMessage"]["required"] == ["message"] + + error_ref = {"$ref": "#/components/schemas/ErrorMessage"} + authenticated = 0 + for path, path_item in spec["paths"].items(): + for method, operation in path_item.items(): + responses = operation["responses"] + if "422" in responses: + assert responses["422"]["content"]["application/json"]["schema"] == error_ref, (method, path) + if operation.get("security"): + authenticated += 1 + for code in ("401", "403", "422"): + assert responses[code]["content"]["application/json"]["schema"] == error_ref, ( + method, + path, + code, + ) + assert authenticated > 0 + assert "HTTPValidationError" not in response.text diff --git a/tests/api/routes/test_openapi.py b/tests/api/routes/test_openapi.py new file mode 100644 index 000000000..9ff6a2f6f --- /dev/null +++ b/tests/api/routes/test_openapi.py @@ -0,0 +1,68 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest +from fastapi import FastAPI + +pytestmark = pytest.mark.asyncio + +STREAM_AND_BINARY_RESPONSES = [ + ("/v3/notifications", "get", "200", "application/x-ndjson"), + ("/v3/projects/{project_id}/notifications", "get", "200", "application/x-ndjson"), + ("/v3/projects/{project_id}/export", "get", "200", "application/zip"), + ("/v3/projects/{project_id}/gns3file", "get", "200", "application/json"), + ("/v3/projects/{project_id}/files/{file_path}", "get", "200", "application/octet-stream"), + ("/v3/projects/{project_id}/nodes/{node_id}/files/{file_path}", "get", "200", "application/octet-stream"), + ("/v3/projects/{project_id}/nodes/{node_id}/files/{file_path}", "post", "201", "application/json"), + ("/v3/projects/{project_id}/links/{link_id}/capture/stream", "get", "200", "application/vnd.tcpdump.pcap"), + ("/v3/symbols/{symbol_id}/raw", "get", "200", "application/octet-stream"), +] + +RAW_BODY_REQUESTS = [ + ("/v3/images/upload/{image_path}", "application/octet-stream"), + ("/v3/symbols/{symbol_id}/raw", "application/octet-stream"), + ("/v3/projects/{project_id}/files/{file_path}", "application/octet-stream"), + ("/v3/projects/{project_id}/nodes/{node_id}/files/{file_path}", "application/octet-stream"), + ("/v3/projects/{project_id}/import", "application/zip"), +] + + +class TestOpenAPIBinaryRoutes: + @pytest.mark.parametrize("path, method, status_code, media_type", STREAM_AND_BINARY_RESPONSES) + async def test_response_content( + self, app: FastAPI, path: str, method: str, status_code: str, media_type: str + ) -> None: + + content = app.openapi()["paths"][path][method]["responses"][status_code]["content"] + assert media_type in content + assert content[media_type]["schema"] != {} + if media_type != "application/json": + assert "application/json" not in content + + @pytest.mark.parametrize("path, media_type", RAW_BODY_REQUESTS) + async def test_request_body(self, app: FastAPI, path: str, media_type: str) -> None: + + request_body = app.openapi()["paths"][path]["post"]["requestBody"] + assert request_body["required"] is True + assert request_body["content"][media_type]["schema"] == {"type": "string", "format": "binary"} + + async def test_binary_responses_use_binary_schema(self, app: FastAPI) -> None: + + paths = app.openapi()["paths"] + for path, method, status_code, media_type in STREAM_AND_BINARY_RESPONSES: + if media_type in ("application/octet-stream", "application/zip", "application/vnd.tcpdump.pcap"): + schema = paths[path][method]["responses"][status_code]["content"][media_type]["schema"] + assert schema == {"type": "string", "format": "binary"} diff --git a/tests/api/routes/test_operation_ids.py b/tests/api/routes/test_operation_ids.py new file mode 100644 index 000000000..26a0905ea --- /dev/null +++ b/tests/api/routes/test_operation_ids.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import re +import warnings +from collections import Counter + +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.openapi.utils import get_openapi + +from gns3server.api.operation_ids import STUB_METHODS, add_stub_routes, generate_operation_id +from gns3server.api.routes.compute import compute_api +from gns3server.api.server import app as controller_app + +HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace") + + +def build_operation_ids(application: FastAPI) -> list[str]: + + with warnings.catch_warnings(): + warnings.simplefilter("error") + spec = get_openapi(title="test", version="0", routes=application.routes) + return [ + operation["operationId"] + for path_item in spec["paths"].values() + for method, operation in path_item.items() + if method in HTTP_METHODS + ] + + +@pytest.mark.parametrize("application", [controller_app, compute_api], ids=["controller", "compute"]) +def test_operation_ids_are_unique_and_readable(application: FastAPI) -> None: + + operation_ids = build_operation_ids(application) + assert operation_ids + duplicates = [operation_id for operation_id, count in Counter(operation_ids).items() if count > 1] + assert duplicates == [] + invalid = [operation_id for operation_id in operation_ids if not re.fullmatch(r"[a-z0-9_]+", operation_id)] + assert invalid == [] + + +def test_operation_id_uses_first_tag_and_function_name() -> None: + + router = APIRouter() + + @router.get("/nodes/{node_id}", tags=["Compute nodes", "Other"]) + async def get_node(node_id: str): + return {} + + @router.get("/untagged") + async def list_untagged(): + return {} + + assert generate_operation_id(router.routes[0]) == "compute_nodes_get_node" + assert generate_operation_id(router.routes[1]) == "list_untagged" + + +def test_stub_routes_get_one_operation_id_per_method() -> None: + + async def not_available(path: str = ""): + return {} + + application = FastAPI(generate_unique_id_function=generate_operation_id) + router = APIRouter(tags=["Stub"]) + add_stub_routes(router, not_available, "stub") + application.include_router(router, prefix="/stub") + + assert sorted(build_operation_ids(application)) == sorted(f"stub_stub_{method.lower()}" for method in STUB_METHODS) diff --git a/tests/api/test_openapi_discriminators.py b/tests/api/test_openapi_discriminators.py new file mode 100644 index 000000000..2a1bca86a --- /dev/null +++ b/tests/api/test_openapi_discriminators.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest +from fastapi import FastAPI + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +def openapi(app: FastAPI) -> dict: + return app.openapi() + + +def _ref(name: str) -> str: + return f"#/components/schemas/{name}" + + +def _assert_discriminated(schema: dict, property_name: str, mapping: dict) -> None: + assert [item["$ref"] for item in schema["oneOf"]] == [_ref(name) for name in dict.fromkeys(mapping.values())] + assert schema["discriminator"]["propertyName"] == property_name + assert schema["discriminator"]["mapping"] == {key: _ref(name) for key, name in mapping.items()} + + +async def test_link_iface_response_is_discriminated(openapi: dict) -> None: + path = "/v3/projects/{project_id}/links/{link_id}/iface" + schema = openapi["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + _assert_discriminated(schema, "kind", {"udp": "UDPPortInfo", "ethernet": "EthernetPortInfo"}) + + +async def test_appliance_version_request_is_discriminated(openapi: dict) -> None: + schema = openapi["paths"]["/v3/appliances/{appliance_id}/version"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] + mapping = {str(version): "ApplianceVersionCreateV1_6" for version in range(1, 7)} + mapping["8"] = "ApplianceVersionCreateV8" + _assert_discriminated(schema, "registry_version", mapping) + + +async def test_appliance_settings_are_discriminated(openapi: dict) -> None: + schema = openapi["components"]["schemas"]["ApplianceV8"]["properties"]["settings"]["items"] + _assert_discriminated( + schema, + "template_type", + { + "qemu": "QemuTemplateSetting", + "dynamips": "DynamipsTemplateSetting", + "iou": "IouTemplateSetting", + "docker": "DockerTemplateSetting", + }, + ) diff --git a/tests/api/test_openapi_spec.py b/tests/api/test_openapi_spec.py new file mode 100644 index 000000000..0562680c8 --- /dev/null +++ b/tests/api/test_openapi_spec.py @@ -0,0 +1,114 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +import subprocess +import sys + +import pytest +from fastapi import FastAPI + +from gns3server.agent import AI_COPILOT_AVAILABLE, MCP_AVAILABLE +from gns3server.version import __api_version__, __version__ + +pytestmark = pytest.mark.asyncio + +AI_PATH_PREFIXES = ( + "/v3/copilot", + "/v3/mcp", + "/v3/access/llm", + "/v3/access/users/{user_id}/llm-model-configs", + "/v3/access/groups/{group_id}/llm-model-configs", +) + +SPEC_SCRIPT = """ +import json +import sys + +from gns3server.config import Config + +Config.instance(files=[sys.argv[1]]) + +from gns3server.api.server import app + +print(json.dumps(app.openapi())) +""" + + +def _operation_ids(spec: dict) -> list: + return [ + operation["operationId"] + for path_item in spec["paths"].values() + for operation in path_item.values() + if isinstance(operation, dict) and "operationId" in operation + ] + + +def _build_spec(tmp_path, include_ai: bool) -> dict: + config_file = tmp_path / "gns3_server.conf" + config_file.write_text(f"[Server]\nopenapi_include_ai = {str(include_ai).lower()}\n") + result = subprocess.run( + [sys.executable, "-c", SPEC_SCRIPT, str(config_file)], + capture_output=True, + text=True, + check=True, + timeout=120, + ) + return json.loads(result.stdout.splitlines()[-1]) + + +class TestOpenAPISpec: + async def test_controller_version_matches_package(self, app: FastAPI) -> None: + + spec = app.openapi() + assert spec["info"]["version"] == __api_version__ + assert spec["info"]["version"] == __version__.split("+")[0] + assert "+" not in spec["info"]["version"] + + async def test_compute_version_matches_package(self, app: FastAPI) -> None: + + compute_app = next(route.app for route in app.routes if getattr(route, "path", None) == "/v3/compute") + assert compute_app.openapi()["info"]["version"] == __api_version__ + + async def test_servers_entry(self, app: FastAPI) -> None: + + assert app.openapi()["servers"] == [{"url": "/"}] + + async def test_no_catch_all_operations(self, app: FastAPI) -> None: + + paths = app.openapi()["paths"] + assert [path for path in paths if "{path}" in path] == [] + + async def test_operation_ids_are_unique(self, app: FastAPI) -> None: + + operation_ids = _operation_ids(app.openapi()) + assert len(operation_ids) == len(set(operation_ids)) + + @pytest.mark.skipif(AI_COPILOT_AVAILABLE or MCP_AVAILABLE, reason="AI extras are installed") + async def test_ai_routes_absent_without_extras(self, app: FastAPI) -> None: + + paths = app.openapi()["paths"] + assert [path for path in paths if path.startswith(AI_PATH_PREFIXES)] == [] + + async def test_setting_hides_ai_routes_and_keeps_the_rest(self, tmp_path) -> None: + + with_ai = _build_spec(tmp_path, include_ai=True) + without_ai = _build_spec(tmp_path, include_ai=False) + + assert [path for path in without_ai["paths"] if path.startswith(AI_PATH_PREFIXES)] == [] + non_ai_paths = {path: item for path, item in with_ai["paths"].items() if not path.startswith(AI_PATH_PREFIXES)} + assert without_ai["paths"] == non_ai_paths + assert len(_operation_ids(without_ai)) == len(set(_operation_ids(without_ai))) diff --git a/tests/controller/test_appliance.py b/tests/controller/test_appliance.py index 0ec004e20..89f58f802 100644 --- a/tests/controller/test_appliance.py +++ b/tests/controller/test_appliance.py @@ -17,10 +17,21 @@ import pydantic import pytest +from pydantic import TypeAdapter from gns3server.controller.appliance import Appliance from gns3server.controller.appliance_to_template import ApplianceToTemplate -from gns3server.schemas.controller.appliances import ApplianceModel +from gns3server.schemas.controller.appliances import ( + ApplianceModel, + ApplianceVersionCreate, + ApplianceVersionCreateV1_6, + ApplianceVersionCreateV8, + DockerTemplateSetting, + DynamipsTemplateSetting, + IouTemplateSetting, + QemuTemplateSetting, + TemplateSetting, +) # v8 mirror of the XRd Control Plane appliance shape (docker, custom_adapters, no versions) XRD_V8 = { @@ -242,3 +253,44 @@ def test_v8_netmiko_device_type_empty_string_clears(): appliance = dict(XRD_V8, netmiko_device_type="") model = ApplianceModel.model_validate(appliance) assert model.netmiko_device_type == "" + + +@pytest.mark.parametrize( + "template_type, properties, expected", + [ + ("qemu", {"ram": 512}, QemuTemplateSetting), + ("dynamips", {"platform": "c7200", "ram": 256}, DynamipsTemplateSetting), + ("iou", {"ram": 256}, IouTemplateSetting), + ("docker", {"image": "alpine:latest"}, DockerTemplateSetting), + ], +) +def test_template_setting_decoded_through_discriminator(template_type, properties, expected): + setting = TypeAdapter(TemplateSetting).validate_python( + {"name": "a", "template_type": template_type, "template_properties": properties} + ) + assert type(setting) is expected + assert setting.template_type == template_type + + +def test_template_setting_unknown_template_type_rejected(): + with pytest.raises(pydantic.ValidationError): + TypeAdapter(TemplateSetting).validate_python({"template_type": "vpcs", "template_properties": {}}) + + +@pytest.mark.parametrize( + "data, expected", + [ + ({"name": "1", "registry_version": 1}, ApplianceVersionCreateV1_6), + ({"name": "6", "registry_version": 6}, ApplianceVersionCreateV1_6), + ({"name": "8", "registry_version": 8}, ApplianceVersionCreateV8), + ({"name": "plain", "images": {"hda_disk_image": "a.qcow2"}}, ApplianceVersionCreateV1_6), + ({"name": "v8", "category": "guest"}, ApplianceVersionCreateV8), + ], +) +def test_appliance_version_decoded_through_discriminator(data, expected): + assert type(TypeAdapter(ApplianceVersionCreate).validate_python(data)) is expected + + +def test_appliance_version_unknown_registry_version_rejected(): + with pytest.raises(pydantic.ValidationError): + TypeAdapter(ApplianceVersionCreate).validate_python({"name": "x", "registry_version": 7}) diff --git a/tests/controller/test_link.py b/tests/controller/test_link.py index 8f932ae28..6d93b19a9 100644 --- a/tests/controller/test_link.py +++ b/tests/controller/test_link.py @@ -15,6 +15,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import asyncio from unittest.mock import MagicMock import pytest @@ -530,3 +531,67 @@ async def test_update_link_style(project, compute): } link._project.emit_notification.assert_called_with("link.updated", link.asdict()) assert project.dump.called + + +@pytest.mark.asyncio +async def test_add_node_concurrent_same_port(project, compute): + project.dump = MagicMock() + node1 = Node(project, compute, "node1", node_type="qemu") + node1._ports = [EthernetPort("E0", 0, 0, 4)] + node2 = Node(project, compute, "node2", node_type="qemu") + node2._ports = [EthernetPort("E0", 0, 0, 4), EthernetPort("E1", 0, 0, 5)] + node3 = Node(project, compute, "node3", node_type="qemu") + node3._ports = [EthernetPort("E0", 0, 0, 4)] + + async def slow_create(): + await asyncio.sleep(0) + + async def connect(other, other_port): + link = Link(project) + link.create = slow_create + try: + await link.add_node(other, 0, other_port) + await link.add_node(node1, 0, 4) + except ControllerError as e: + return e + return link + + results = await asyncio.gather(connect(node2, 4), connect(node3, 4)) + errors = [r for r in results if isinstance(r, ControllerError)] + assert len(errors) == 1 + assert errors[0].code == "port_in_use" + assert node1._ports[0].link in results + + +@pytest.mark.asyncio +async def test_add_node_create_failure_releases_ports(project, compute): + project.dump = MagicMock() + node1 = Node(project, compute, "node1", node_type="qemu") + node1._ports = [EthernetPort("E0", 0, 0, 4)] + node2 = Node(project, compute, "node2", node_type="qemu") + node2._ports = [EthernetPort("E0", 0, 0, 4)] + + link = Link(project) + link.create = AsyncioMagicMock(side_effect=ControllerError("boom")) + await link.add_node(node1, 0, 4) + with pytest.raises(ControllerError): + await link.add_node(node2, 0, 4) + + assert node1._ports[0].link is None + assert node2._ports[0].link is None + assert not node1.links + assert not node2.links + + +def test_node_get_port_by_name_and_free_port(project, compute): + node = Node(project, compute, "node1", node_type="qemu") + node._ports = [EthernetPort("E1", 0, 0, 1), EthernetPort("E0", 0, 0, 0), SerialPort("S0", 0, 1, 0)] + + assert node.get_port_by_name("E0") is node._ports[1] + assert node.get_port_by_name("nope") is None + assert node.get_free_port() is node._ports[1] + assert node.get_free_port("serial") is node._ports[2] + node._ports[1].link = object() + assert node.get_free_port("ethernet") is node._ports[0] + node._ports[0].link = object() + assert node.get_free_port("ethernet") is None