Merge branch '3.1' into image-manager-out-of-sync

This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-10-01 14:44:24 +02:00
commit a2cdc14a25
129 files changed
+944 -863

No files matched your search

+7 -6
View File
@@ -25,13 +25,14 @@ from fastapi import APIRouter, Depends, Request, status
from fastapi.routing import APIRoute, _IncludedRouter
from starlette.routing import BaseRoute, Mount
from uuid import UUID
from typing import Iterator, List, Sequence
from typing import Iterator, List, Optional, Sequence
from gns3server import schemas
from gns3server.controller.controller_error import ControllerBadRequestError, ControllerNotFoundError
from gns3server.controller import Controller
import gns3server.db.models as models
from gns3server.db.repositories.users import UsersRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.images import ImagesRepository
@@ -96,7 +97,7 @@ async def endpoints(
endpoints = [{"endpoint": "/", "name": "All endpoints", "endpoint_type": "root"}]
def add_to_endpoints(endpoint: str, name: str, endpoint_type: str) -> None:
if endpoint not in endpoints:
if not any(e["endpoint"] == endpoint for e in endpoints):
endpoints.append({"endpoint": endpoint, "name": name, "endpoint_type": endpoint_type})
# projects
@@ -175,7 +176,7 @@ async def endpoints(
@router.get("", response_model=List[schemas.ACE], dependencies=[Depends(has_privilege("ACE.Audit"))])
async def get_aces(rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))) -> List[schemas.ACE]:
async def get_aces(rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))) -> List[models.ACE]:
"""
Get all ACL entries.
@@ -193,7 +194,7 @@ async def get_aces(rbac_repo: RbacRepository = Depends(get_repository(RbacReposi
)
async def create_ace(
request: Request, ace_create: schemas.ACECreate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.ACE:
) -> models.ACE:
"""
Create a new ACL entry.
@@ -224,7 +225,7 @@ async def create_ace(
async def get_ace(
ace_id: UUID,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> schemas.ACE:
) -> models.ACE:
"""
Get an ACL entry.
@@ -240,7 +241,7 @@ async def get_ace(
@router.put("/{ace_id}", response_model=schemas.ACE, dependencies=[Depends(has_privilege("ACE.Modify"))])
async def update_ace(
ace_id: UUID, ace_update: schemas.ACEUpdate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.ACE:
) -> Optional[models.ACE]:
"""
Update an ACL entry.
+1 -1
View File
@@ -39,7 +39,7 @@ API_KEY_PREFIX = "gns3_"
API_KEY_BYTES = 32
def _generate_api_key(api_key_id: UUID = None) -> tuple[str, str, str, UUID]:
def _generate_api_key(api_key_id: UUID | None = None) -> tuple[str, str, str, UUID]:
if api_key_id is None:
api_key_id = uuid4()
random_bytes = secrets.token_hex(API_KEY_BYTES)
+2 -2
View File
@@ -23,7 +23,7 @@ Nested under projects: /v3/projects/{project_id}/chat/...
import json
import logging
import uuid
from typing import List, Optional
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import StreamingResponse
@@ -40,7 +40,7 @@ from .dependencies.authentication import get_current_active_user
log = logging.getLogger(__name__)
responses = {404: {"model": schemas.ErrorMessage, "description": "Resource not found"}}
responses: dict[int | str, dict[str, Any]] = {404: {"model": schemas.ErrorMessage, "description": "Resource not found"}}
router = APIRouter(responses=responses)
+7 -6
View File
@@ -23,6 +23,7 @@ from typing import Any, List, Union, Optional
from uuid import UUID
from gns3server.controller import Controller
import gns3server.db.models as models
from gns3server.db.repositories.computes import ComputesRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.services.computes import ComputesService
@@ -31,7 +32,7 @@ from gns3server import schemas
from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
responses = {404: {"model": schemas.ErrorMessage, "description": "Compute not found"}}
responses: dict[int | str, dict[str, Any]] = {404: {"model": schemas.ErrorMessage, "description": "Compute not found"}}
router = APIRouter(responses=responses)
@@ -51,14 +52,14 @@ async def create_compute(
compute_create: schemas.ComputeCreate,
computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)),
connect: Optional[bool] = False,
) -> schemas.Compute:
) -> models.Compute:
"""
Create a new compute on the controller.
Required privilege: Compute.Allocate
"""
return await ComputesService(computes_repo).create_compute(compute_create, connect)
return await ComputesService(computes_repo).create_compute(compute_create, bool(connect))
@router.post(
@@ -86,7 +87,7 @@ async def connect_compute(compute_id: Union[str, UUID]) -> None:
)
async def get_compute(
compute_id: Union[str, UUID], computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository))
) -> schemas.Compute:
) -> Union[models.Compute, dict]:
"""
Return a compute from the controller.
@@ -104,7 +105,7 @@ async def get_compute(
)
async def get_computes(
computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)),
) -> List[schemas.Compute]:
) -> List[models.Compute]:
"""
Return all computes known by the controller.
@@ -124,7 +125,7 @@ async def update_compute(
compute_id: Union[str, UUID],
compute_update: schemas.ComputeUpdate,
computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)),
) -> schemas.Compute:
) -> models.Compute:
"""
Update a compute on the controller.
+11 -14
View File
@@ -20,7 +20,7 @@ import os
import time
import psutil
from fastapi import APIRouter, Request, Depends, WebSocket, WebSocketDisconnect, status
from fastapi import APIRouter, FastAPI, Request, Depends, WebSocket, WebSocketDisconnect, status
from fastapi.responses import StreamingResponse
from fastapi.encoders import jsonable_encoder
from fastapi.routing import Mount
@@ -64,7 +64,7 @@ def get_version(request: Request) -> dict:
# compute subapp
controller_host = None
for route in request.app.routes:
if isinstance(route, Mount) and route.name == "compute":
if isinstance(route, Mount) and route.name == "compute" and isinstance(route.app, FastAPI):
controller_host = route.app.state.controller_host
local_server = Config.instance().settings.Server.local
@@ -129,7 +129,7 @@ async def shutdown() -> None:
try:
future.result()
except Exception as e:
log.error(f"Could not close project: {e}", exc_info=1)
log.error(f"Could not close project: {e}", exc_info=True)
continue
# then shutdown the server itself
@@ -191,8 +191,8 @@ async def statistics() -> dict:
# Node statistics - distinguish open vs closed project nodes
open_project_nodes = []
closed_project_nodes = []
node_by_type = {}
node_by_status = {}
node_by_type: Dict[str, int] = {}
node_by_status: Dict[str, int] = {}
for project in projects:
nodes = project.nodes.values()
@@ -264,9 +264,8 @@ async def controller_http_notifications(request: Request) -> StreamingResponse:
from gns3server.api.server import app
log.info(
f"New client {request.client.host}:{request.client.port} has connected to controller HTTP notification stream"
)
client = f"{request.client.host}:{request.client.port}" if request.client else "unknown"
log.info(f"New client {client} has connected to controller HTTP notification stream")
async def event_stream():
try:
@@ -275,10 +274,7 @@ async def controller_http_notifications(request: Request) -> StreamingResponse:
msg = await queue.get_json(5)
yield f"{msg}\n".encode("utf-8")
finally:
log.info(
f"Client {request.client.host}:{request.client.port} has disconnected from controller HTTP "
f"notification stream"
)
log.info(f"Client {client} has disconnected from controller HTTP notification stream")
return StreamingResponse(event_stream(), media_type="application/json")
@@ -294,14 +290,15 @@ async def controller_ws_notifications(
if current_user is None:
return
log.info(f"New client {websocket.client.host}:{websocket.client.port} has connected to controller WebSocket")
client = f"{websocket.client.host}:{websocket.client.port}" if websocket.client else "unknown"
log.info(f"New client {client} has connected to controller WebSocket")
try:
with Controller.instance().notification.controller_queue() as queue:
while True:
notification = await queue.get_json(5)
await websocket.send_text(notification)
except (ConnectionClosed, WebSocketDisconnect):
log.info(f"Client {websocket.client.host}:{websocket.client.port} has disconnected from controller WebSocket")
log.info(f"Client {client} has disconnected from controller WebSocket")
except WebSocketException as e:
log.warning(f"Error while sending to controller event to WebSocket client: {e}")
@@ -55,7 +55,7 @@ async def get_user_from_token(
user_repo: UsersRepository = Depends(get_repository(UsersRepository)),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
token: Optional[str] = Query(None, include_in_schema=False),
) -> schemas.User:
) -> models.User:
if bearer_token:
# bearer token is used first, then any token passed as a URL parameter
@@ -169,7 +169,7 @@ async def get_current_active_user_from_websocket(
websocket: WebSocket,
token: str = Query(...),
user_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> Optional[schemas.User]:
) -> Optional[models.User]:
# Extract requested subprotocols from headers for proper WebSocket negotiation
# This is critical for protocols like xpra that require specific subprotocols
@@ -238,4 +238,5 @@ async def get_current_active_user_from_websocket(
websocket_error = {"action": "log.error", "event": {"message": err_msg}}
await websocket.send_json(websocket_error)
log.error(err_msg)
return await websocket.close(code=1008)
await websocket.close(code=1008)
return None
@@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import Callable, Type
from typing import AsyncGenerator, Callable, Type
from fastapi import Depends
from starlette.requests import HTTPConnection
from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,7 +22,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.db.repositories.base import BaseRepository
async def get_db_session(request: HTTPConnection) -> AsyncSession:
async def get_db_session(request: HTTPConnection) -> AsyncGenerator[AsyncSession, None]:
async with AsyncSession(request.app.state._db_engine, expire_on_commit=False) as session:
try:
@@ -32,7 +32,7 @@ async def get_db_session(request: HTTPConnection) -> AsyncSession:
def get_repository(repo: Type[BaseRepository]) -> Callable:
def get_repo(db_session: AsyncSession = Depends(get_db_session)) -> Type[BaseRepository]:
def get_repo(db_session: AsyncSession = Depends(get_db_session)) -> BaseRepository:
return repo(db_session)
return get_repo
+4 -2
View File
@@ -20,7 +20,7 @@ API routes for drawings.
from fastapi import APIRouter, Depends, status
from fastapi.encoders import jsonable_encoder
from typing import List
from typing import Any, List
from uuid import UUID
from gns3server.controller import Controller
@@ -30,7 +30,9 @@ from gns3server import schemas
from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
responses = {404: {"model": schemas.ErrorMessage, "description": "Project or drawing not found"}}
responses: dict[int | str, dict[str, Any]] = {
404: {"model": schemas.ErrorMessage, "description": "Project or drawing not found"}
}
router = APIRouter(responses=responses)
+7 -6
View File
@@ -21,7 +21,7 @@ API routes for user groups.
from fastapi import APIRouter, Depends, status
from uuid import UUID
from typing import List
from typing import List, Optional
from gns3server import schemas
from gns3server.controller.controller_error import (
@@ -31,6 +31,7 @@ from gns3server.controller.controller_error import (
ControllerForbiddenError,
)
import gns3server.db.models as models
from gns3server.db.repositories.users import UsersRepository
from gns3server.db.repositories.rbac import RbacRepository
@@ -47,7 +48,7 @@ router = APIRouter()
@router.get("", response_model=List[schemas.UserGroup], dependencies=[Depends(has_privilege("Group.Audit"))])
async def get_user_groups(
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> List[schemas.UserGroup]:
) -> List[models.UserGroup]:
"""
Get all user groups.
@@ -65,7 +66,7 @@ async def get_user_groups(
)
async def create_user_group(
user_group_create: schemas.UserGroupCreate, users_repo: UsersRepository = Depends(get_repository(UsersRepository))
) -> schemas.UserGroup:
) -> models.UserGroup:
"""
Create a new user group.
@@ -82,7 +83,7 @@ async def create_user_group(
async def get_user_group(
user_group_id: UUID,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> schemas.UserGroup:
) -> models.UserGroup:
"""
Get a user group.
@@ -100,7 +101,7 @@ async def update_user_group(
user_group_id: UUID,
user_group_update: schemas.UserGroupUpdate,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> schemas.UserGroup:
) -> Optional[models.UserGroup]:
"""
Update a user group.
@@ -148,7 +149,7 @@ async def delete_user_group(
)
async def get_user_group_members(
user_group_id: UUID, users_repo: UsersRepository = Depends(get_repository(UsersRepository))
) -> List[schemas.User]:
) -> List[models.User]:
"""
Get all user group members.
+6 -5
View File
@@ -40,6 +40,7 @@ from gns3server.utils.images import (
default_images_directory,
get_builtin_disks,
)
import gns3server.db.models as models
from gns3server.db.repositories.images import ImagesRepository
from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.db.repositories.rbac import RbacRepository
@@ -109,7 +110,7 @@ async def create_qemu_image(
image_path: str,
image_data: schemas.QemuDiskImageCreate,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
) -> schemas.Image:
) -> models.Image:
"""
Create a new blank Qemu image.
@@ -158,7 +159,7 @@ async def get_images(
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
image_type: Optional[schemas.ImageType] = None,
availability: Optional[Literal["unknown", "available", "missing", "unavailable", "invalid"]] = None,
) -> List[schemas.Image]:
) -> List[models.Image]:
"""
Return all images.
@@ -182,7 +183,7 @@ async def upload_image(
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
install_appliances: Optional[bool] = False,
) -> schemas.Image:
) -> models.Image:
"""
Upload an image.
@@ -306,7 +307,7 @@ async def install_images(
async def get_image(
image_path: str,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
) -> schemas.Image:
) -> models.Image:
"""
Return an image.
@@ -349,7 +350,7 @@ async def delete_image(
templates = await images_repo.get_image_templates(image.image_id)
if templates:
template_names = ", ".join([template.name for template in templates])
template_names = ", ".join([str(template.name) for template in templates])
raise ControllerError(f"Image '{image_path}' is used by one or more templates: {template_names}")
project_names = Controller.instance().find_projects_using_image(image.filename)
+10 -8
View File
@@ -26,7 +26,7 @@ import aiohttp
from fastapi import APIRouter, Depends, Request, status, WebSocket
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.encoders import jsonable_encoder
from typing import List, Union
from typing import Any, List, Union
from uuid import UUID, uuid4
from gns3server.controller import Controller
@@ -46,7 +46,9 @@ import logging
log = logging.getLogger(__name__)
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or link"}}
responses: dict[int | str, dict[str, Any]] = {
404: {"model": schemas.ErrorMessage, "description": "Could not find project or link"}
}
router = APIRouter(responses=responses)
@@ -91,7 +93,7 @@ async def get_links(project_id: UUID) -> List[schemas.Link]:
},
dependencies=[Depends(has_privilege("Link.Allocate"))],
)
async def create_link(project_id: UUID, link_data: schemas.LinkCreate) -> schemas.Link:
async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> schemas.Link:
"""
Create a new link.
@@ -100,7 +102,7 @@ async def create_link(project_id: UUID, link_data: schemas.LinkCreate) -> schema
project = await Controller.instance().get_loaded_project(str(project_id))
link = await project.add_link()
link_data = jsonable_encoder(link_data, exclude_unset=True)
link_data = jsonable_encoder(link_create, exclude_unset=True)
if "filters" in link_data:
await link.update_filters(link_data["filters"])
if "link_style" in link_data:
@@ -156,14 +158,14 @@ async def get_link(link: Link = Depends(dep_link)) -> schemas.Link:
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Link.Modify"))],
)
async def update_link(link_data: schemas.LinkUpdate, link: Link = Depends(dep_link)) -> schemas.Link:
async def update_link(link_update: schemas.LinkUpdate, link: Link = Depends(dep_link)) -> schemas.Link:
"""
Update a link.
Required privilege: Link.Modify
"""
link_data = jsonable_encoder(link_data, exclude_unset=True)
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:
@@ -293,7 +295,7 @@ async def stream_pcap(request: Request, link: Link = Depends(dep_link)) -> Strea
pcap_streaming_url = link.pcap_streaming_url()
headers = multidict.MultiDict(request.headers)
headers["Host"] = compute.host
headers["Router-Host"] = request.client.host
headers["Router-Host"] = request.client.host if request.client else ""
body = await request.body()
async def compute_pcap_stream():
@@ -487,7 +489,7 @@ async def update_marker(marker_name: str, marker_data: schemas.MarkerUpdate, lin
response_model=Union[schemas.UDPPortInfo, schemas.EthernetPortInfo],
dependencies=[Depends(has_privilege("Link.Audit"))],
)
async def get_iface(link: Link = Depends(dep_link)) -> Union[schemas.UDPPortInfo, schemas.EthernetPortInfo]:
async def get_iface(link: Link = Depends(dep_link)) -> dict:
"""
Return iface info for links to Cloud or NAT devices.
@@ -206,7 +206,11 @@ async def create_user_llm_model_config(
# Extract config fields (excluding table-level fields)
config_fields = config_create.model_dump(exclude={"name", "model_type", "is_default"})
new_config = await llm_repo.create_user_config(
user_id, config_create.name, config_create.model_type, config_fields, is_default=config_create.is_default
user_id,
config_create.name,
config_create.model_type,
config_fields,
is_default=bool(config_create.is_default),
)
return schemas.LLMModelConfigResponse(
@@ -346,6 +350,10 @@ async def set_user_default_llm_model_config(
# Get the updated config
config = await llm_repo.get_user_config(config_id)
if config is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"LLM model configuration '{config_id}' not found"
)
return schemas.LLMModelConfigResponse(
config_id=config.config_id,
name=config.name,
@@ -506,7 +514,11 @@ async def create_group_llm_model_config(
# Extract config fields (excluding table-level fields)
config_fields = config_create.model_dump(exclude={"name", "model_type", "is_default"})
new_config = await llm_repo.create_group_config(
group_id, config_create.name, config_create.model_type, config_fields, is_default=config_create.is_default
group_id,
config_create.name,
config_create.model_type,
config_fields,
is_default=bool(config_create.is_default),
)
return schemas.LLMModelConfigResponse(
@@ -646,6 +658,10 @@ async def set_group_default_llm_model_config(
# Get the updated config
config = await llm_repo.get_group_config(config_id)
if config is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"LLM model configuration '{config_id}' not found"
)
return schemas.LLMModelConfigResponse(
config_id=config.config_id,
name=config.name,
+18 -29
View File
@@ -27,7 +27,7 @@ from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Request,
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
from fastapi.routing import APIRoute
from typing import List, Callable, Optional
from typing import Any, List, Callable, Optional
from uuid import UUID
from gns3server.controller import Controller
@@ -47,7 +47,7 @@ import logging
log = logging.getLogger(__name__)
node_locks = {}
node_locks: dict[str, dict[str, Any]] = {}
class NodeConcurrency(APIRoute):
@@ -84,7 +84,9 @@ class NodeConcurrency(APIRoute):
return custom_route_handler
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or node"}}
responses: dict[int | str, dict[str, Any]] = {
404: {"model": schemas.ErrorMessage, "description": "Could not find project or node"}
}
router = APIRouter(route_class=NodeConcurrency, responses=responses)
@@ -127,7 +129,7 @@ def _check_node_type(node: Node, *required_types: str) -> None:
},
dependencies=[Depends(has_privilege("Node.Allocate"))],
)
async def create_node(node_data: schemas.NodeCreate, project: Project = Depends(dep_project)) -> schemas.Node:
async def create_node(node_create: schemas.NodeCreate, project: Project = Depends(dep_project)) -> schemas.Node:
"""
Create a new node.
@@ -135,8 +137,8 @@ async def create_node(node_data: schemas.NodeCreate, project: Project = Depends(
"""
controller = Controller.instance()
compute = controller.get_compute(str(node_data.compute_id))
node_data = jsonable_encoder(node_data, exclude_unset=True)
compute = controller.get_compute(str(node_create.compute_id))
node_data = jsonable_encoder(node_create, exclude_unset=True)
node = await project.add_node(compute, node_data.pop("name"), node_data.pop("node_id", None), **node_data)
return node.asdict()
@@ -274,14 +276,14 @@ async def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Node.Modify"))],
)
async def update_node(node_data: schemas.NodeUpdate, node: Node = Depends(dep_node)) -> schemas.Node:
async def update_node(node_update: schemas.NodeUpdate, node: Node = Depends(dep_node)) -> schemas.Node:
"""
Update a node.
Required privilege: Node.Modify
"""
node_data = jsonable_encoder(node_data, exclude_unset=True)
node_data = jsonable_encoder(node_update, exclude_unset=True)
# Ignore these because we only use them when creating a node
node_data.pop("node_id", None)
@@ -654,9 +656,8 @@ async def ws_console(
return
compute = node.compute
log.info(
f"New client {websocket.client.host}:{websocket.client.port} has connected to controller console WebSocket"
)
client = f"{websocket.client.host}:{websocket.client.port}" if websocket.client else "unknown"
log.info(f"New client {client} has connected to controller console WebSocket")
compute_host = compute.host
try:
@@ -690,9 +691,7 @@ async def ws_console(
await ws_console_compute.send_bytes(msg["bytes"])
except WebSocketDisconnect:
pass
log.info(
f"Client {websocket.client.host}:{websocket.client.port} has disconnected from controller console WebSocket"
)
log.info(f"Client {client} has disconnected from controller console WebSocket")
async def ws_send(ws_console_compute):
"""
@@ -709,10 +708,7 @@ async def ws_console(
break
except WebSocketDisconnect:
# the client disconnected while the compute was still streaming console output
log.info(
f"Client {websocket.client.host}:{websocket.client.port} has disconnected from controller"
f" console WebSocket"
)
log.info(f"Client {client} has disconnected from controller console WebSocket")
try:
# forward WebSocket data in both directions between the client and the compute console WebSocket
@@ -763,9 +759,8 @@ async def vnc_console(
return
compute = node.compute
log.info(
f"New client {websocket.client.host}:{websocket.client.port} has connected to controller VNC console WebSocket"
)
client = f"{websocket.client.host}:{websocket.client.port}" if websocket.client else "unknown"
log.info(f"New client {client} has connected to controller VNC console WebSocket")
compute_host = compute.host
try:
@@ -796,10 +791,7 @@ async def vnc_console(
await vnc_console_compute.send_bytes(data)
except WebSocketDisconnect:
pass
log.info(
f"Client {websocket.client.host}:{websocket.client.port} has disconnected from controller"
f" VNC console WebSocket"
)
log.info(f"Client {client} has disconnected from controller VNC console WebSocket")
async def vnc_send(vnc_console_compute):
"""
@@ -814,10 +806,7 @@ async def vnc_console(
break
except WebSocketDisconnect:
# the client disconnected while the compute was still streaming VNC console output
log.info(
f"Client {websocket.client.host}:{websocket.client.port} has disconnected from controller"
f" VNC console WebSocket"
)
log.info(f"Client {client} has disconnected from controller VNC console WebSocket")
try:
# forward WebSocket data in both directions between the client and the compute VNC console WebSocket
+11 -10
View File
@@ -21,12 +21,13 @@ API routes for resource pools.
from fastapi import APIRouter, Depends, status
from uuid import UUID
from typing import List
from typing import List, Optional
from gns3server import schemas
from gns3server.controller.controller_error import ControllerError, ControllerBadRequestError, ControllerNotFoundError
from gns3server.controller import Controller
import gns3server.db.models as models
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.db.repositories.pools import ResourcePoolsRepository
@@ -43,7 +44,7 @@ router = APIRouter()
@router.get("", response_model=List[schemas.ResourcePool], dependencies=[Depends(has_privilege("Pool.Audit"))])
async def get_resource_pools(
pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)),
) -> List[schemas.ResourcePool]:
) -> List[models.ResourcePool]:
"""
Get all resource pools.
@@ -62,7 +63,7 @@ async def get_resource_pools(
async def create_resource_pool(
resource_pool_create: schemas.ResourcePoolCreate,
pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)),
) -> schemas.ResourcePool:
) -> models.ResourcePool:
"""
Create a new resource pool
@@ -80,7 +81,7 @@ async def create_resource_pool(
)
async def get_resource_pool(
resource_pool_id: UUID, pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository))
) -> schemas.ResourcePool:
) -> models.ResourcePool:
"""
Get a resource pool.
@@ -100,7 +101,7 @@ async def update_resource_pool(
resource_pool_id: UUID,
resource_pool_update: schemas.ResourcePoolUpdate,
pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)),
) -> schemas.ResourcePool:
) -> Optional[models.ResourcePool]:
"""
Update a resource pool.
@@ -176,7 +177,7 @@ async def delete_resource_pool(
async def get_pool_resources(
resource_pool_id: UUID,
pools_repo: ResourcePoolsRepository = Depends(get_repository(ResourcePoolsRepository)),
) -> List[schemas.Resource]:
) -> List[models.Resource]:
"""
Get all resource in a pool.
@@ -215,13 +216,13 @@ async def add_resource_to_pool(
# we only support projects in resource pools for now
project = Controller.instance().get_project(str(resource_id))
resource = await pools_repo.get_resource(resource_id)
if not resource:
db_resource = await pools_repo.get_resource(resource_id)
if not db_resource:
# the resource is not in the database yet, create it
resource_create = schemas.ResourceCreate(resource_id=resource_id, resource_type="project", name=project.name)
resource = await pools_repo.create_resource(resource_create)
db_resource = await pools_repo.create_resource(resource_create)
await pools_repo.add_resource_to_pool(resource_pool_id, resource)
await pools_repo.add_resource_to_pool(resource_pool_id, db_resource)
@router.delete(
@@ -16,6 +16,7 @@ API route for privileges
"""
from typing import List
import gns3server.db.models as models
from gns3server.db.repositories.rbac import RbacRepository
from .dependencies.database import get_repository
from fastapi import APIRouter, Depends
@@ -33,7 +34,7 @@ router = APIRouter()
)
async def get_privileges(
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> List[schemas.Privilege]:
) -> List[models.Privilege]:
"""
Get all privileges.
+12 -10
View File
@@ -34,7 +34,7 @@ from fastapi import APIRouter, Depends, Request, Body, HTTPException, status, We
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse, FileResponse
from websockets.exceptions import ConnectionClosed, WebSocketException
from typing import List, Optional
from typing import Any, List, Optional
from uuid import UUID
from gns3server import schemas
@@ -57,7 +57,9 @@ from .dependencies.rbac import has_privilege, has_privilege_on_websocket
from .dependencies.authentication import get_current_active_user
from .dependencies.database import get_repository
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project"}}
responses: dict[int | str, dict[str, Any]] = {
404: {"model": schemas.ErrorMessage, "description": "Could not find project"}
}
router = APIRouter(responses=responses)
@@ -636,8 +638,8 @@ async def export_project(
include_images: bool = False,
reset_mac_addresses: bool = False,
keep_compute_ids: bool = False,
compression: schemas.ProjectCompression = "zstd",
compression_level: int = None,
compression: schemas.ProjectCompression = schemas.ProjectCompression.zstd,
compression_level: Optional[int] = None,
) -> StreamingResponse:
"""
Export a project as a portable archive.
@@ -650,19 +652,19 @@ async def export_project(
compression_query = compression.lower()
if compression_query == "zip":
compression = zipfile.ZIP_DEFLATED
zip_compression = zipfile.ZIP_DEFLATED
if compression_level is not None and (compression_level < 0 or compression_level > 9):
raise ControllerBadRequestError("Compression level must be between 0 and 9 for ZIP compression")
elif compression_query == "none":
compression = zipfile.ZIP_STORED
zip_compression = zipfile.ZIP_STORED
elif compression_query == "bzip2":
compression = zipfile.ZIP_BZIP2
zip_compression = zipfile.ZIP_BZIP2
if compression_level is not None and (compression_level < 1 or compression_level > 9):
raise ControllerBadRequestError("Compression level must be between 1 and 9 for BZIP2 compression")
elif compression_query == "lzma":
compression = zipfile.ZIP_LZMA
zip_compression = zipfile.ZIP_LZMA
elif compression_query == "zstd":
compression = zipfile.ZIP_ZSTANDARD
zip_compression = zipfile.ZIP_ZSTANDARD
if compression_level is not None and (compression_level < 1 or compression_level > 22):
raise ControllerBadRequestError("Compression level must be between 1 and 22 for Zstandard compression")
@@ -681,7 +683,7 @@ async def export_project(
f"Exporting project '{project.name}' with '{compression_query}' compression (level {compression_level})"
)
with tempfile.TemporaryDirectory(dir=working_dir) as tmpdir:
with aiozipstream.ZipFile(compression=compression, compresslevel=compression_level) as zstream:
with aiozipstream.ZipFile(compression=zip_compression, compresslevel=compression_level) as zstream:
await export_controller_project(
zstream,
project,
+7 -6
View File
@@ -21,7 +21,7 @@ API routes for roles.
from fastapi import APIRouter, Depends, status
from uuid import UUID
from typing import List
from typing import List, Optional
from gns3server import schemas
from gns3server.controller.controller_error import (
@@ -31,6 +31,7 @@ from gns3server.controller.controller_error import (
ControllerForbiddenError,
)
import gns3server.db.models as models
from gns3server.db.repositories.rbac import RbacRepository
from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
@@ -43,7 +44,7 @@ router = APIRouter()
@router.get("", response_model=List[schemas.Role], dependencies=[Depends(has_privilege("Role.Audit"))])
async def get_roles(rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))) -> List[schemas.Role]:
async def get_roles(rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))) -> List[models.Role]:
"""
Get all roles.
@@ -61,7 +62,7 @@ async def get_roles(rbac_repo: RbacRepository = Depends(get_repository(RbacRepos
)
async def create_role(
role_create: schemas.RoleCreate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.Role:
) -> Optional[models.Role]:
"""
Create a new role.
@@ -78,7 +79,7 @@ async def create_role(
async def get_role(
role_id: UUID,
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> schemas.Role:
) -> models.Role:
"""
Get a role.
@@ -94,7 +95,7 @@ async def get_role(
@router.put("/{role_id}", response_model=schemas.Role, dependencies=[Depends(has_privilege("Role.Modify"))])
async def update_role(
role_id: UUID, role_update: schemas.RoleUpdate, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> schemas.Role:
) -> Optional[models.Role]:
"""
Update a role.
@@ -142,7 +143,7 @@ async def delete_role(
)
async def get_role_privileges(
role_id: UUID, rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> List[schemas.Privilege]:
) -> List[models.Privilege]:
"""
Get all role privileges.
@@ -24,7 +24,7 @@ import logging
log = logging.getLogger()
from fastapi import APIRouter, Depends, status
from typing import List
from typing import Any, List
from uuid import UUID
from gns3server.controller.project import Project
@@ -35,7 +35,9 @@ from gns3server.controller import Controller
from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or snapshot"}}
responses: dict[int | str, dict[str, Any]] = {
404: {"model": schemas.ErrorMessage, "description": "Could not find project or snapshot"}
}
router = APIRouter(responses=responses)
+11 -9
View File
@@ -27,7 +27,7 @@ import logging
log = logging.getLogger(__name__)
from fastapi import APIRouter, Request, HTTPException, Depends, Response, status, Query
from typing import List, Optional
from typing import Any, List, Optional, Union
from uuid import UUID
from gns3server import schemas
@@ -43,7 +43,9 @@ from .dependencies.authentication import get_current_active_user
from .dependencies.rbac import has_privilege
from .dependencies.database import get_repository
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find template"}}
responses: dict[int | str, dict[str, Any]] = {
404: {"model": schemas.ErrorMessage, "description": "Could not find template"}
}
router = APIRouter(responses=responses)
@@ -57,7 +59,7 @@ router = APIRouter(responses=responses)
async def create_template(
template_create: schemas.TemplateCreate,
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
) -> schemas.Template:
) -> dict:
"""
Create a new template.
@@ -80,7 +82,7 @@ async def get_template(
request: Request,
response: Response,
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
) -> schemas.Template:
) -> Union[dict, Response]:
"""
Return a template.
@@ -108,7 +110,7 @@ async def update_template(
template_id: UUID,
template_update: schemas.TemplateUpdate,
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
) -> schemas.Template:
) -> dict:
"""
Update a template.
@@ -125,7 +127,7 @@ async def delete_template(
template_id: UUID,
prune_images: Optional[bool] = False,
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
images_repo: RbacRepository = Depends(get_repository(ImagesRepository)),
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
) -> None:
"""
@@ -155,7 +157,7 @@ async def delete_template(
if str(template.template_id) != str(template_id)
]
if other_templates:
template_names = ", ".join([template.name for template in other_templates])
template_names = ", ".join([str(template.name) for template in other_templates])
raise ControllerError(f"Image '{image.path}' is used by one or more templates: {template_names}")
if referenced_filenames is None:
@@ -193,7 +195,7 @@ async def get_templates(
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
current_user: schemas.User = Depends(get_current_active_user),
tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)"),
) -> List[schemas.Template]:
) -> List[dict]:
"""
Return all templates.
@@ -244,7 +246,7 @@ async def get_templates(
)
async def duplicate_template(
template_id: UUID, templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
) -> schemas.Template:
) -> dict:
"""
Duplicate a template.
+8 -7
View File
@@ -22,7 +22,7 @@ API routes for users.
from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.security import OAuth2PasswordRequestForm
from uuid import UUID
from typing import List
from typing import List, Optional
from gns3server import schemas
from gns3server.controller.controller_error import (
@@ -32,6 +32,7 @@ from gns3server.controller.controller_error import (
ControllerForbiddenError,
)
import gns3server.db.models as models
from gns3server.db.repositories.users import UsersRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.services import auth_service
@@ -168,7 +169,7 @@ async def update_logged_in_user(
user_update: schemas.LoggedInUserUpdate,
current_user: schemas.User = Depends(get_current_active_user),
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> schemas.User:
) -> Optional[models.User]:
"""
Update the current active user.
"""
@@ -180,7 +181,7 @@ async def update_logged_in_user(
@router.get("", response_model=List[schemas.User], dependencies=[Depends(has_privilege("User.Audit"))])
async def get_users(users_repo: UsersRepository = Depends(get_repository(UsersRepository))) -> List[schemas.User]:
async def get_users(users_repo: UsersRepository = Depends(get_repository(UsersRepository))) -> List[models.User]:
"""
Get all users.
@@ -198,7 +199,7 @@ async def get_users(users_repo: UsersRepository = Depends(get_repository(UsersRe
)
async def create_user(
user_create: schemas.UserCreate, users_repo: UsersRepository = Depends(get_repository(UsersRepository))
) -> schemas.User:
) -> models.User:
"""
Create a new user.
@@ -218,7 +219,7 @@ async def create_user(
async def get_user(
user_id: UUID,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> schemas.User:
) -> models.User:
"""
Get a user.
@@ -236,7 +237,7 @@ async def update_user(
user_id: UUID,
user_update: schemas.UserUpdate,
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> schemas.User:
) -> Optional[models.User]:
"""
Update a user.
@@ -287,7 +288,7 @@ async def delete_user(
)
async def get_user_memberships(
user_id: UUID, users_repo: UsersRepository = Depends(get_repository(UsersRepository))
) -> List[schemas.UserGroup]:
) -> List[models.UserGroup]:
"""
Get user memberships.