Merge branch '3.1' into feat/mcp-text-drawing-tool

This commit is contained in:
Jeremy Grossmann 2026-10-10 06:49:24 +02:00 committed by GitHub
commit 2bf35c31e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1548 additions and 119 deletions

View File

@ -80,7 +80,11 @@ def _iter_route_paths(routes: Sequence[BaseRoute], prefix: str = "", include_mou
yield from _iter_route_paths(mounted_routes, _join_paths(prefix, route.path), include_mounted_routes)
@router.get("/endpoints", dependencies=[Depends(has_privilege("ACE.Audit"))])
@router.get(
"/endpoints",
response_model=List[schemas.ACEEndpoint],
dependencies=[Depends(has_privilege("ACE.Audit"))],
)
async def endpoints(
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),

View File

@ -50,12 +50,12 @@ def _generate_api_key(api_key_id: UUID | None = None) -> tuple[str, str, str, UU
return raw_key, key_hash, key_prefix, api_key_id
@router.post("", status_code=status.HTTP_201_CREATED)
@router.post("", response_model=schemas.ApiKeyCreated, status_code=status.HTTP_201_CREATED)
async def create_api_key(
api_key_data: schemas.ApiKeyCreate,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
) -> dict:
) -> schemas.ApiKeyCreated:
"""Create a new API key. The full key is returned only once."""
raw_key, key_hash, key_prefix, new_key_id = _generate_api_key()
@ -66,42 +66,42 @@ async def create_api_key(
key_hash=key_hash,
key_prefix=key_prefix,
)
return {
"api_key_id": str(db_key.api_key_id),
"api_key": raw_key,
"name": db_key.name,
"key_prefix": db_key.key_prefix,
"created_at": db_key.created_at.isoformat() if db_key.created_at else None,
}
return schemas.ApiKeyCreated(
api_key_id=db_key.api_key_id,
api_key=raw_key,
name=db_key.name,
key_prefix=db_key.key_prefix,
created_at=db_key.created_at.isoformat() if db_key.created_at else None,
)
@router.get("")
@router.get("", response_model=list[schemas.ApiKey])
async def list_api_keys(
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
) -> list[dict]:
) -> list[schemas.ApiKey]:
"""List all API keys for the current user."""
keys = await api_keys_repo.get_api_keys_by_user(current_user.user_id)
return [
{
"api_key_id": str(k.api_key_id),
"name": k.name,
"key_prefix": k.key_prefix,
"created_at": k.created_at.isoformat() if k.created_at else None,
"last_used_at": k.last_used_at.isoformat() if k.last_used_at else None,
"revoked": k.revoked,
}
schemas.ApiKey(
api_key_id=k.api_key_id,
name=k.name,
key_prefix=k.key_prefix,
created_at=k.created_at.isoformat() if k.created_at else None,
last_used_at=k.last_used_at.isoformat() if k.last_used_at else None,
revoked=k.revoked,
)
for k in keys
]
@router.post("/{api_key_id}/revoke", status_code=status.HTTP_200_OK)
@router.post("/{api_key_id}/revoke", response_model=schemas.ApiKeyMessage, status_code=status.HTTP_200_OK)
async def revoke_api_key(
api_key_id: UUID,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
) -> dict:
) -> schemas.ApiKeyMessage:
"""Revoke an API key. It will immediately stop working, but can be restored."""
key = await api_keys_repo.get_api_key(api_key_id)
@ -111,15 +111,15 @@ async def revoke_api_key(
raise HTTPException(status_code=403, detail="Cannot modify another user's API key")
await api_keys_repo.revoke_api_key(api_key_id)
return {"message": f"API key '{key.name}' revoked"}
return schemas.ApiKeyMessage(message=f"API key '{key.name}' revoked")
@router.post("/{api_key_id}/restore", status_code=status.HTTP_200_OK)
@router.post("/{api_key_id}/restore", response_model=schemas.ApiKeyMessage, status_code=status.HTTP_200_OK)
async def restore_api_key(
api_key_id: UUID,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
) -> dict:
) -> schemas.ApiKeyMessage:
"""Restore a previously revoked API key."""
key = await api_keys_repo.get_api_key(api_key_id)
@ -129,7 +129,7 @@ async def restore_api_key(
raise HTTPException(status_code=403, detail="Cannot modify another user's API key")
await api_keys_repo.restore_api_key(api_key_id)
return {"message": f"API key '{key.name}' restored"}
return schemas.ApiKeyMessage(message=f"API key '{key.name}' restored")
@router.delete("/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT)

View File

@ -82,6 +82,8 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance:
@router.post(
"/{appliance_id}/version",
response_model=schemas.Appliance,
response_model_exclude_unset=True,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Appliance.Allocate"))],
)

View File

@ -212,7 +212,7 @@ async def vmware_vms(compute_id: Union[str, UUID]) -> List[schemas.ComputeVMware
return result
@router.post("/{compute_id}/dynamips/auto_idlepc")
@router.post("/{compute_id}/dynamips/auto_idlepc", response_model=schemas.IdlePC)
async def dynamips_autoidlepc(compute_id: Union[str, UUID], auto_idle_pc: schemas.AutoIdlePC):
"""
Find a suitable Idle-PC value for a given IOS image. This may take a few minutes.

View File

@ -162,7 +162,12 @@ async def update_iou_license(iou_license: schemas.IOULicense) -> schemas.IOULice
return current_iou_license
@router.get("/statistics", dependencies=[Depends(get_current_active_user)])
@router.get(
"/statistics",
response_model=schemas.ServerStatistics,
response_model_exclude_unset=True,
dependencies=[Depends(get_current_active_user)],
)
async def statistics() -> dict:
"""
Return server statistics including compute resources, projects, and nodes.

View File

@ -30,7 +30,7 @@ from gns3server.controller import Controller
router = APIRouter()
@router.get("/engines")
@router.get("/engines", response_model=List[schemas.GNS3VMEngineInfo])
async def get_engines() -> List[dict]:
"""
Return the list of supported engines for the GNS3VM.
@ -40,7 +40,7 @@ async def get_engines() -> List[dict]:
return gns3_vm.engine_list()
@router.get("/engines/{engine}/vms")
@router.get("/engines/{engine}/vms", response_model=List[schemas.GNS3VMInfo])
async def get_vms(engine: str) -> List[dict]:
"""
Return all the available VMs for a specific virtualization engine.

View File

@ -329,7 +329,12 @@ async def prune_images(
)
@router.post("/install", status_code=status.HTTP_200_OK, dependencies=[Depends(has_privilege("Image.Allocate"))])
@router.post(
"/install",
response_model=schemas.ImageInstallResult,
status_code=status.HTTP_200_OK,
dependencies=[Depends(has_privilege("Image.Allocate"))],
)
async def install_images(
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),

View File

@ -20,7 +20,7 @@ API routes for links.
import logging
import os
from typing import Any, List, Optional
from typing import Any, Dict, List, Optional
from uuid import UUID, uuid4
import aiohttp
@ -33,8 +33,13 @@ from gns3server import schemas
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
from gns3server.api.openapi import PCAP_MEDIA_TYPE, binary_response
from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError
from gns3server.controller.controller_error import (
ControllerError,
ControllerNotFoundError,
controller_error_status_code,
)
from gns3server.controller.link import _UNSET, Link
from gns3server.controller.project import Project
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.utils.http_client import HTTPClient
from gns3server.utils.port_allocator import link_id_to_port
@ -123,7 +128,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_create: schemas.LinkCreate) -> schemas.Link:
async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> dict:
"""
Create a new link.
@ -131,17 +136,22 @@ async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> sche
"""
project = await Controller.instance().get_loaded_project(str(project_id))
return await _create_link(project, link_create)
async def _create_link(project: Project, link_create: schemas.LinkCreate) -> dict:
link = await project.add_link()
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:
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"])
try:
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"])
endpoints = link_data["nodes"]
attached_port = None
for index, endpoint in enumerate(endpoints):
@ -159,13 +169,47 @@ async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> sche
return link.asdict()
@router.post(
"/batch",
response_model=List[schemas.LinkBatchResult],
responses={
404: {"model": schemas.ErrorMessage, "description": "Could not find project"},
},
dependencies=[Depends(has_privilege("Link.Allocate"))],
)
async def create_links(project_id: UUID, links_create: List[schemas.LinkCreate]) -> List[schemas.LinkBatchResult]:
"""
Create several links in the order they are listed.
The response lists one result per link in the same order. A link that fails to be created
does not stop the following ones.
Required privilege: Link.Allocate
"""
project = await Controller.instance().get_loaded_project(str(project_id))
results = []
for link_create in links_create:
try:
link = await _create_link(project, link_create)
results.append(schemas.LinkBatchResult(status_code=status.HTTP_201_CREATED, link=link))
except ControllerError as e:
log.error(f"Could not create link in batch: {e}")
results.append(
schemas.LinkBatchResult(
status_code=controller_error_status_code(e), error=schemas.ErrorMessage(message=str(e))
)
)
return results
@router.get(
"/{link_id}/available_filters",
response_model=List[schemas.LinkFilterDefinition],
response_model=List[schemas.LinkFilterInfo],
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Link.Audit"))],
)
async def get_filters(link: Link = Depends(dep_link)) -> List[schemas.LinkFilterDefinition]:
async def get_filters(link: Link = Depends(dep_link)) -> List[dict]:
"""
Return all filters available for a given link.
@ -309,6 +353,7 @@ async def stop_capture(link: Link = Depends(dep_link)) -> None:
@router.post(
"/{link_id}/capture/wireshark/restart",
response_model=schemas.LinkWiresharkRestart,
status_code=status.HTTP_200_OK,
dependencies=[Depends(has_privilege("Link.Capture"))],
)
@ -466,7 +511,12 @@ async def web_wireshark_websocket(
pass
@router.get("/{link_id}/markers", dependencies=[Depends(has_privilege("Link.Audit"))])
@router.get(
"/{link_id}/markers",
response_model=Dict[str, schemas.Marker],
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Link.Audit"))],
)
async def get_markers(link: Link = Depends(dep_link)) -> dict:
"""
Return all traffic-insight markers configured on this link.
@ -478,7 +528,11 @@ async def get_markers(link: Link = Depends(dep_link)) -> dict:
@router.post(
"/{link_id}/markers", status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Link.Modify"))]
"/{link_id}/markers",
response_model=schemas.Marker,
response_model_exclude_unset=True,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Link.Modify"))],
)
async def create_marker(marker_data: schemas.MarkerCreate, link: Link = Depends(dep_link)) -> dict:
"""
@ -523,7 +577,12 @@ async def delete_marker(marker_name: str, link: Link = Depends(dep_link)) -> Non
await link.stop_marker(marker_name)
@router.put("/{link_id}/markers/{marker_name}", dependencies=[Depends(has_privilege("Link.Modify"))])
@router.put(
"/{link_id}/markers/{marker_name}",
response_model=schemas.Marker,
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Link.Modify"))],
)
async def update_marker(marker_name: str, marker_data: schemas.MarkerUpdate, link: Link = Depends(dep_link)) -> dict:
"""
Update a traffic-insight marker (change BPF, tag, or enabled).

View File

@ -37,8 +37,10 @@ from gns3server.config import Config
from gns3server.controller import Controller
from gns3server.controller.controller_error import (
ControllerBadRequestError,
ControllerError,
ControllerForbiddenError,
ControllerTimeoutError,
controller_error_status_code,
)
from gns3server.controller.node import Node
from gns3server.controller.project import Project
@ -141,13 +143,20 @@ def _check_node_type(node: Node, *required_types: str) -> None:
},
dependencies=[Depends(has_privilege("Node.Allocate"))],
)
async def create_node(node_create: schemas.NodeCreate, project: Project = Depends(dep_project)) -> schemas.Node:
async def create_node(node_create: schemas.NodeCreate, project: Project = Depends(dep_project)) -> dict:
"""
Create a new node.
Set strict_names to get a 409 error instead of an automatic rename when the node name is already used.
Required privilege: Node.Allocate
"""
return await _create_node(project, node_create)
async def _create_node(project: Project, node_create: schemas.NodeCreate) -> dict:
controller = Controller.instance()
compute = controller.get_compute(str(node_create.compute_id))
node_data = jsonable_encoder(node_create, exclude_unset=True)
@ -155,6 +164,41 @@ async def create_node(node_create: schemas.NodeCreate, project: Project = Depend
return node.asdict()
@router.post(
"/batch",
response_model=List[schemas.NodeBatchResult],
responses={
404: {"model": schemas.ErrorMessage, "description": "Could not find project"},
},
dependencies=[Depends(has_privilege("Node.Allocate"))],
)
async def create_nodes(
nodes_create: List[schemas.NodeCreate], project: Project = Depends(dep_project)
) -> List[schemas.NodeBatchResult]:
"""
Create several nodes in the order they are listed.
The response lists one result per node in the same order. A node that fails to be created
does not stop the following ones.
Required privilege: Node.Allocate
"""
results = []
for node_create in nodes_create:
try:
node = await _create_node(project, node_create)
results.append(schemas.NodeBatchResult(status_code=status.HTTP_201_CREATED, node=node))
except ControllerError as e:
log.error(f"Could not create node {node_create.name} in batch: {e}")
results.append(
schemas.NodeBatchResult(
status_code=controller_error_status_code(e), error=schemas.ErrorMessage(message=str(e))
)
)
return results
@router.get(
"",
response_model=List[schemas.Node],
@ -529,7 +573,11 @@ async def get_node_links(node: Node = Depends(dep_node)) -> List[schemas.Link]:
return links
@router.get("/{node_id}/dynamips/auto_idlepc", dependencies=[Depends(has_privilege("Node.Audit"))])
@router.get(
"/{node_id}/dynamips/auto_idlepc",
response_model=schemas.IdlePC,
dependencies=[Depends(has_privilege("Node.Audit"))],
)
async def auto_idlepc(node: Node = Depends(dep_node)) -> dict:
"""
Compute an Idle-PC value for a Dynamips node

View File

@ -31,7 +31,7 @@ import gns3server.utils.zipfile_zstd as zipfile
log = logging.getLogger()
from typing import Any, List, Optional
from typing import Any, Dict, List, Optional
from uuid import UUID
from fastapi import (
@ -237,7 +237,9 @@ async def delete_project(
await rbac_repo.delete_all_ace_starting_with_path(f"/projects/{project.id}")
@router.get("/{project_id}/stats", dependencies=[Depends(has_privilege("Project.Audit"))])
@router.get(
"/{project_id}/stats", response_model=schemas.ProjectStats, dependencies=[Depends(has_privilege("Project.Audit"))]
)
def get_project_stats(project: Project = Depends(dep_project)) -> dict:
"""
Return a project statistics.
@ -248,7 +250,12 @@ def get_project_stats(project: Project = Depends(dep_project)) -> dict:
return project.stats()
@router.get("/{project_id}/markers", dependencies=[Depends(has_privilege("Project.Audit"))])
@router.get(
"/{project_id}/markers",
response_model=Dict[str, schemas.ProjectMarker],
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
def get_project_markers(project: Project = Depends(dep_project)) -> dict:
"""
Return all traffic-insight markers across every link in the project.
@ -279,6 +286,7 @@ async def _replay_response(awaitable):
@router.get(
"/{project_id}/markers/tags/{tag}/replay/range",
response_model=schemas.ReplayTimeline,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def replay_tag_range(
@ -319,6 +327,7 @@ async def replay_tag_range(
@router.get(
"/{project_id}/markers/tags/{tag}/replay/frames",
response_model=schemas.ReplayFrames,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def replay_tag_frames(
@ -359,6 +368,8 @@ async def replay_tag_frames(
@router.get(
"/{project_id}/markers/tags/{tag}/replay/frame/detail",
response_model=schemas.ReplayFrameDetail,
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def replay_tag_frame_detail(
@ -397,7 +408,12 @@ async def replay_tag_frame_detail(
# ---------------------------------------------------------------------------
@router.get("/{project_id}/marker-definitions", dependencies=[Depends(has_privilege("Project.Audit"))])
@router.get(
"/{project_id}/marker-definitions",
response_model=Dict[str, schemas.MarkerDefinitionWithLinks],
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
def get_marker_definitions(project: Project = Depends(dep_project)) -> dict:
"""
Return all project-level marker definitions with their bound link IDs.
@ -419,6 +435,8 @@ def get_marker_definitions(project: Project = Depends(dep_project)) -> dict:
@router.post(
"/{project_id}/marker-definitions",
response_model=schemas.MarkerDefinition,
response_model_exclude_unset=True,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Project.Modify"))],
)
@ -446,7 +464,12 @@ async def create_marker_definition(
return project.marker_definitions.get(name, {})
@router.put("/{project_id}/marker-definitions/{def_name}", dependencies=[Depends(has_privilege("Project.Modify"))])
@router.put(
"/{project_id}/marker-definitions/{def_name}",
response_model=schemas.MarkerDefinition,
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Project.Modify"))],
)
async def update_marker_definition(
def_name: str, def_data: schemas.MarkerDefinitionCreate, project: Project = Depends(dep_project)
) -> dict:

View File

@ -21,7 +21,7 @@ API routes for symbols.
import logging
import os
from typing import List
from typing import Dict, List
from fastapi import APIRouter, Depends, Request, Response, status
from fastapi.responses import FileResponse
@ -40,7 +40,7 @@ log = logging.getLogger(__name__)
router = APIRouter()
@router.get("", dependencies=[Depends(has_privilege("Symbol.Audit"))])
@router.get("", response_model=List[schemas.SymbolInfo], dependencies=[Depends(has_privilege("Symbol.Audit"))])
def get_symbols() -> List[dict]:
"""
Return all symbols.
@ -83,6 +83,7 @@ async def get_symbol(symbol_id: str, request: Request) -> Response:
@router.get(
"/{symbol_id:path}/dimensions",
response_model=schemas.SymbolDimensions,
responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}},
dependencies=[Depends(has_privilege("Symbol.Audit"))],
)
@ -102,7 +103,11 @@ async def get_symbol_dimensions(symbol_id: str) -> dict:
raise ControllerNotFoundError(f"Could not get symbol file: {e}")
@router.get("/default_symbols", dependencies=[Depends(has_privilege("Symbol.Audit"))])
@router.get(
"/default_symbols",
response_model=Dict[str, Dict[str, str]],
dependencies=[Depends(has_privilege("Symbol.Audit"))],
)
def get_default_symbols() -> dict:
"""
Return all default symbols.

View File

@ -86,3 +86,21 @@ class ComputeConflictError(ComputeError):
def response(self):
return self._response
def controller_error_status_code(error: ControllerError) -> int:
"""
HTTP status code returned by the API for a controller error.
"""
if isinstance(error, ControllerTimeoutError):
return 408
if isinstance(error, ControllerUnauthorizedError):
return 401
if isinstance(error, ControllerForbiddenError):
return 403
if isinstance(error, ControllerNotFoundError):
return 404
if isinstance(error, ControllerBadRequestError):
return 400
return 409

View File

@ -101,7 +101,9 @@ class Node:
"default_password",
]
def __init__(self, project, compute, name, node_id=None, node_type=None, template_id=None, **kwargs):
def __init__(
self, project, compute, name, node_id=None, node_type=None, template_id=None, strict_names=False, **kwargs
):
"""
:param project: Project of the node
:param compute: Compute where the server will run
@ -109,6 +111,7 @@ class Node:
:param node_id: UUID of the node (integer)
:param node_type: Type of emulator
:param template_id: Template ID used to create this node
:param strict_names: Fail instead of renaming the node when the name is already used
:param kwargs: Node properties
"""
@ -126,7 +129,7 @@ class Node:
self._label = None
self._links = set()
self._name = None
self.name = name
self._name = project.update_node_name(self, name, strict=strict_names)
self._console = None
self._console_type = None
self._aux = None

View File

@ -557,17 +557,20 @@ class Project:
if name in self._allocated_node_names:
self._allocated_node_names.remove(name)
def update_allocated_node_name(self, base_name):
def update_allocated_node_name(self, base_name, strict=False):
"""
Updates a node name or generate a new if no node
name is available.
:param base_name: new node base name
:param strict: raise an error instead of renaming when the name is already taken
"""
if base_name is None:
return None
base_name = re.sub(r"[ ]", "", base_name) # remove spaces in node name
if strict and base_name in self._allocated_node_names:
raise ControllerError(f"Node name {base_name} is already used")
if base_name in self._allocated_node_names:
base_name = re.sub(r"[0-9]+$", "{0}", base_name)
@ -595,11 +598,11 @@ class Project:
return name
raise ControllerError("A node name could not be allocated (node limit reached?)")
def update_node_name(self, node, new_name):
def update_node_name(self, node, new_name, strict=False):
if new_name and node.name != new_name:
self.remove_allocated_node_name(node.name)
return self.update_allocated_node_name(new_name)
return self.update_allocated_node_name(new_name, strict=strict)
return new_name
@open_required
@ -630,26 +633,32 @@ class Project:
node = await self.add_node(compute, name, node_id, node_type=node_type, **template)
return node
async def _create_node(self, compute, name, node_id, node_type=None, allow_missing_image=False, **kwargs):
async def _create_node(
self, compute, name, node_id, node_type=None, allow_missing_image=False, strict_names=False, **kwargs
):
node = Node(self, compute, name, node_id=node_id, node_type=node_type, **kwargs)
# Hold the lock across the check + POST + register so that concurrent
# node creations on the same compute don't all race past the check and
# each POST /projects (the compute-side sync handler then instantiated
# the Project N times). Once one creation registers the compute, the
# rest see it in the set and return immediately.
async with self._create_node_lock:
if compute not in self._project_created_on_compute:
if compute.id == "local":
data = {"name": self._name, "project_id": self._id, "path": self._path}
else:
data = {"name": self._name, "project_id": self._id}
if self._variables:
data["variables"] = self._variables
await compute.post("/projects", data=data)
self._project_created_on_compute.add(compute)
node = Node(self, compute, name, node_id=node_id, node_type=node_type, strict_names=strict_names, **kwargs)
try:
# Hold the lock across the check + POST + register so that concurrent
# node creations on the same compute don't all race past the check and
# each POST /projects (the compute-side sync handler then instantiated
# the Project N times). Once one creation registers the compute, the
# rest see it in the set and return immediately.
async with self._create_node_lock:
if compute not in self._project_created_on_compute:
if compute.id == "local":
data = {"name": self._name, "project_id": self._id, "path": self._path}
else:
data = {"name": self._name, "project_id": self._id}
if self._variables:
data["variables"] = self._variables
await compute.post("/projects", data=data)
self._project_created_on_compute.add(compute)
await node.create(allow_missing_image=allow_missing_image)
await node.create(allow_missing_image=allow_missing_image)
except BaseException:
self.remove_allocated_node_name(node.name)
raise
self._nodes[node.id] = node
return node

View File

@ -26,29 +26,45 @@ from .controller.computes import (
ComputeUpdate,
ComputeVirtualBoxVM,
ComputeVMwareVM,
IdlePC,
)
from .controller.drawings import Drawing
from .controller.gns3vm import GNS3VM
from .controller.images import Image, ImageSyncJob, ImageSyncRequest, ImageType, ImageUpload
from .controller.gns3vm import GNS3VM, GNS3VMEngineInfo, GNS3VMInfo
from .controller.images import (
Image,
ImageInstallCreated,
ImageInstallResult,
ImageInstallSkipped,
ImageSyncJob,
ImageSyncRequest,
ImageType,
ImageUpload,
)
# Controller schemas
from .controller.links import (
EthernetPortInfo,
Link,
LinkBatchResult,
LinkCapture,
LinkCreate,
LinkFilterDefinition,
LinkFilterInfo,
LinkFilterParameter,
LinkFilters,
LinkFilterType,
LinkIfaceInfo,
LinkUpdate,
LinkWiresharkRestart,
Marker,
MarkerCreate,
MarkerDefinition,
MarkerDefinitionCreate,
MarkerDefinitionWithLinks,
MarkerUpdate,
ProjectMarker,
UDPPortInfo,
)
from .controller.nodes import Node, NodeCapture, NodeCreate, NodeDuplicate, NodeUpdate
from .controller.nodes import Node, NodeBatchResult, NodeCapture, NodeCreate, NodeDuplicate, NodeUpdate
from .controller.projects import (
NodeFile,
Project,
@ -56,6 +72,7 @@ from .controller.projects import (
ProjectCreate,
ProjectDuplicate,
ProjectFile,
ProjectStats,
ProjectUpdate,
)
from .controller.templates import Template, TemplateCreate, TemplateUpdate, TemplateUsage
@ -123,12 +140,32 @@ from .compute.vmware_nodes import VMware, VMwareCreate, VMwareUpdate
from .compute.vpcs_nodes import VPCS, VPCSCreate, VPCSUpdate
from .controller.capabilities import Capabilities
from .controller.iou_license import IOULicense
from .controller.marker_replay import (
ReplayFrame,
ReplayFrameDetail,
ReplayFrames,
ReplayFrameSource,
ReplaySource,
ReplayTimeline,
ReplayTreeNode,
)
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.rbac import ACE, ACECreate, ACEEndpoint, ACEUpdate, Privilege, Role, RoleCreate, RoleUpdate
from .controller.settings import SettingsResponse, SettingsUpdate, SettingsUpdateResponse
from .controller.snapshots import Snapshot, SnapshotCreate
from .controller.statistics import (
ComputeStatistics,
ComputeStatisticsEntry,
LinkStatistics,
NodeStatistics,
ProjectCountStatistics,
ServerStatistics,
WebWiresharkContainer,
WebWiresharkStatistics,
)
from .controller.symbols import SymbolDimensions, SymbolInfo
from .controller.templates.cloud_templates import CloudTemplate, CloudTemplateUpdate
from .controller.templates.docker_templates import DockerTemplate, DockerTemplateUpdate
from .controller.templates.dynamips_templates import (
@ -157,7 +194,7 @@ from .controller.templates.vmware_templates import VMwareTemplate, VMwareTemplat
# Controller template schemas
from .controller.templates.vpcs_templates import VPCSTemplate, VPCSTemplateUpdate
from .controller.tokens import ApiKeyCreate, RefreshTokenRequest, Token
from .controller.tokens import ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyMessage, RefreshTokenRequest, Token
# Schemas for both controller and compute
from .qemu_disk_image import QemuDiskImageCreate, QemuDiskImageFormat, QemuDiskImageUpdate
@ -171,12 +208,16 @@ __all__ = [
"UDPNIO",
"VPCS",
"ACECreate",
"ACEEndpoint",
"ACEUpdate",
"ATMSwitch",
"ATMSwitchCreate",
"ATMSwitchUpdate",
"AnchorNIO",
"ApiKey",
"ApiKeyCreate",
"ApiKeyCreated",
"ApiKeyMessage",
"Appliance",
"ApplianceVersion",
"ApplianceVersionCreate",
@ -211,6 +252,8 @@ __all__ = [
"Compute",
"ComputeCreate",
"ComputeDockerImage",
"ComputeStatistics",
"ComputeStatisticsEntry",
"ComputeUpdate",
"ComputeVMwareVM",
"ComputeVirtualBoxVM",
@ -242,13 +285,19 @@ __all__ = [
"FrameRelaySwitch",
"FrameRelaySwitchCreate",
"FrameRelaySwitchUpdate",
"GNS3VMEngineInfo",
"GNS3VMInfo",
"IOUCreate",
"IOULicense",
"IOUStart",
"IOUTemplate",
"IOUTemplateUpdate",
"IOUUpdate",
"IdlePC",
"Image",
"ImageInstallCreated",
"ImageInstallResult",
"ImageInstallSkipped",
"ImageSyncJob",
"ImageSyncRequest",
"ImageType",
@ -261,17 +310,23 @@ __all__ = [
"LLMModelConfigUpdate",
"LLMModelConfigWithSource",
"Link",
"LinkBatchResult",
"LinkCapture",
"LinkCreate",
"LinkFilterDefinition",
"LinkFilterInfo",
"LinkFilterParameter",
"LinkFilterType",
"LinkFilters",
"LinkIfaceInfo",
"LinkStatistics",
"LinkUpdate",
"LinkWiresharkRestart",
"LoggedInUserUpdate",
"Marker",
"MarkerCreate",
"MarkerDefinition",
"MarkerDefinitionCreate",
"MarkerDefinitionWithLinks",
"MarkerRebuild",
"MarkerToggle",
"MarkerUpdate",
@ -280,10 +335,12 @@ __all__ = [
"NetmikoDeviceType",
"NetmikoDeviceTypeList",
"Node",
"NodeBatchResult",
"NodeCapture",
"NodeCreate",
"NodeDuplicate",
"NodeFile",
"NodeStatistics",
"NodeUpdate",
"Notification",
"NotificationAction",
@ -292,9 +349,12 @@ __all__ = [
"Privilege",
"Project",
"ProjectCompression",
"ProjectCountStatistics",
"ProjectCreate",
"ProjectDuplicate",
"ProjectFile",
"ProjectMarker",
"ProjectStats",
"ProjectUpdate",
"Qemu",
"QemuCreate",
@ -306,6 +366,13 @@ __all__ = [
"QemuUpdate",
"RefreshTokenRequest",
"RenameSession",
"ReplayFrame",
"ReplayFrameDetail",
"ReplayFrameSource",
"ReplayFrames",
"ReplaySource",
"ReplayTimeline",
"ReplayTreeNode",
"Resource",
"ResourceCreate",
"ResourcePool",
@ -315,11 +382,14 @@ __all__ = [
"RoleCreate",
"RoleUpdate",
"ServerConfig",
"ServerStatistics",
"SettingsResponse",
"SettingsUpdate",
"SettingsUpdateResponse",
"Snapshot",
"SnapshotCreate",
"SymbolDimensions",
"SymbolInfo",
"Template",
"TemplateCreate",
"TemplateUpdate",
@ -347,4 +417,6 @@ __all__ = [
"VirtualBoxTemplate",
"VirtualBoxTemplateUpdate",
"VirtualBoxUpdate",
"WebWiresharkContainer",
"WebWiresharkStatistics",
]

View File

@ -153,3 +153,11 @@ class AutoIdlePC(BaseModel):
model_config = ConfigDict(
json_schema_extra={"example": {"platform": "c7200", "image": "/path/to/c7200_image.bin", "ram": 256}}
)
class IdlePC(BaseModel):
"""
Result of an Idle-PC computation.
"""
idlepc: Optional[str] = Field(None, description="Idle-PC value, null when none was found")

View File

@ -55,3 +55,24 @@ class GNS3VM(BaseModel):
vcpus: Optional[int] = Field(None, description="Number of CPUs to allocate for the GNS3 VM")
ram: Optional[int] = Field(None, description="Amount of memory to allocate for the GNS3 VM")
port: Optional[int] = Field(None, gt=0, le=65535)
class GNS3VMEngineInfo(BaseModel):
"""
A virtualization engine supported by the GNS3 VM.
"""
engine_id: str = Field(..., description="Engine identifier")
name: str = Field(..., description="Engine display name")
description: str = Field(..., description="Engine description (may contain HTML)")
support_when_exit: bool = Field(..., description="The engine supports the 'when exit' action")
support_headless: bool = Field(..., description="The engine supports headless mode")
support_ram: bool = Field(..., description="The engine supports RAM and vCPUs allocation")
class GNS3VMInfo(BaseModel):
"""
A virtual machine available on a virtualization engine.
"""
vmname: str = Field(..., description="Virtual machine name")

View File

@ -16,7 +16,8 @@
from datetime import datetime
from enum import Enum
from typing import Annotated, Literal, Optional
from typing import Annotated, List, Literal, Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
@ -100,3 +101,32 @@ class ImageCompatibility(BaseModel):
class ImageCompatibilityCatalog(BaseModel):
image_sizes: list[int]
has_unknown_sizes: bool
class ImageInstallCreated(BaseModel):
"""
A template created from an image by the automatic installation.
"""
template_id: UUID
name: Optional[str] = None
version: Optional[str] = None
template_type: Optional[str] = None
class ImageInstallSkipped(BaseModel):
"""
An image or template that the automatic installation skipped.
"""
name: Optional[str] = None
reason: str
class ImageInstallResult(BaseModel):
"""
Result of the automatic template installation from images.
"""
created: List[ImageInstallCreated]
skipped: List[ImageInstallSkipped]

View File

@ -20,6 +20,7 @@ from uuid import UUID, uuid4
from pydantic import BaseModel, ConfigDict, Discriminator, Field, field_validator, model_validator
from ..common import ErrorMessage
from ..update import PartialUpdateModel
from .labels import Label
@ -138,29 +139,6 @@ class LinkFilters(BaseModel):
)
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.
@ -216,6 +194,16 @@ class Link(LinkBase):
)
class LinkBatchResult(BaseModel):
"""
Outcome of one link of a batch creation request.
"""
status_code: int = Field(..., description="HTTP status code the same request would have returned on its own")
link: Optional[Link] = Field(None, description="The created link, set when status_code is 201")
error: Optional[ErrorMessage] = Field(None, description="The error, set when the link could not be created")
class UDPPortInfo(BaseModel):
"""
UDP port information.
@ -398,3 +386,81 @@ class MarkerDefinitionCreate(BaseModel):
@classmethod
def _both_to_none(cls, v):
return None if v == "both" else v
class Marker(BaseModel):
"""
A traffic-insight marker attached to a link.
"""
bpf: str = Field(..., description="libpcap BPF expression")
tag: Optional[int] = Field(None, description="Correlation id shared by markers replayed together")
enabled: bool = Field(True, description="Whether the marker is capturing")
color: Optional[str] = Field(None, description="Hex color used by the Web UI")
highlight_duration: Optional[int] = Field(None, description="Web UI highlight duration in milliseconds")
capture_node_id: Optional[str] = Field(None, description="Node whose uBridge hosts the marker")
direction: Optional[str] = Field(None, description="'tx' or 'rx', null for both directions")
data_link_type: Optional[str] = Field(None, description="pcap link-layer type of the capture file")
inherited_from: Optional[str] = Field(None, description="Project marker definition this marker was copied from")
class ProjectMarker(Marker):
"""
A marker in the project-wide aggregation, with the link and node it belongs to.
"""
link_id: str
node_id: Optional[str] = None
class MarkerDefinition(BaseModel):
"""
A project-level marker definition inherited by every link.
"""
bpf: str = Field(..., description="libpcap BPF expression")
tag: Optional[int] = Field(None, description="Correlation id shared by markers replayed together")
direction: Optional[str] = Field(None, description="'tx' or 'rx', null for both directions")
color: Optional[str] = Field(None, description="Hex color used by the Web UI")
highlight_duration: Optional[int] = Field(None, description="Web UI highlight duration in milliseconds")
data_link_type: Optional[str] = Field(None, description="pcap link-layer type of inherited markers")
paused: bool = Field(False, description="Whether the inherited markers are paused")
class MarkerDefinitionWithLinks(MarkerDefinition):
"""
A project-level marker definition with the links currently carrying a copy of it.
"""
link_ids: List[str] = Field(..., description="Links carrying an inherited copy of the definition")
class LinkWiresharkRestart(BaseModel):
"""
Result of restarting the Wireshark window of a link capture.
"""
status: str
class LinkFilterParameter(BaseModel):
"""
A parameter of a packet filter.
"""
name: str
type: str
minimum: Optional[int] = None
maximum: Optional[int] = None
unit: Optional[str] = None
class LinkFilterInfo(BaseModel):
"""
A packet filter that can be applied to a link.
"""
type: str = Field(..., description="Filter identifier used as key in the link 'filters' property")
name: str
description: str
parameters: List[LinkFilterParameter]

View File

@ -0,0 +1,114 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
class ReplaySource(BaseModel):
"""
A capture source (one marker pcap) of a replay tag.
"""
node_id: str
link_id: str
marker: str
data_link_type: str
count: int = Field(..., description="Total frames in the capture, unaffected by filters")
class ReplayFrame(BaseModel):
"""
One frame of the merged replay timeline.
"""
ts: str = Field(..., description="Timestamp string to send back unchanged")
len: int = Field(..., description="Captured length in bytes")
node_id: str
link_id: str
marker: str
frame_number: int = Field(..., description="1-based frame number in the source pcap")
src: Optional[str] = None
dst: Optional[str] = None
proto: Optional[str] = None
info: Optional[str] = None
bg: Optional[str] = Field(None, description="Wireshark coloring rule background")
fg: Optional[str] = Field(None, description="Wireshark coloring rule foreground")
class ReplayTimeline(BaseModel):
"""
Replay timeline of a tag.
"""
tag: int
start: Optional[str] = Field(None, description="Timestamp of the first matching frame")
end: Optional[str] = Field(None, description="Timestamp of the last matching frame")
frame_count: int
sources: List[ReplaySource]
frames: List[ReplayFrame]
class ReplayFrames(BaseModel):
"""
Frames of a replay time window.
"""
frames: List[ReplayFrame]
class ReplayFrameSource(BaseModel):
"""
The source pcap and frame a decoded frame comes from.
"""
node_id: str
link_id: str
marker: str
frame_number: int
class ReplayTreeNode(BaseModel):
"""
A node of the decoded protocol tree.
Keys sharkd adds in the future are passed through unchanged.
"""
model_config = ConfigDict(extra="allow")
element: Optional[str] = Field(None, description="Node type")
label: Optional[str] = Field(None, description="Display text")
name: Optional[str] = Field(None, description="Field name, e.g. 'ip.ttl'")
filter_expr: Optional[str] = Field(None, description="Display filter with the value baked in")
pos: Optional[int] = Field(None, description="Byte offset for hex highlighting")
size: Optional[int] = Field(None, description="Byte length for hex highlighting")
expert: Optional[str] = Field(None, description="Expert severity")
generated: Optional[bool] = Field(None, description="Generated by Wireshark")
children: Optional[List["ReplayTreeNode"]] = Field(None, description="Nested fields")
class ReplayFrameDetail(BaseModel):
"""
One decoded frame.
"""
ts: str
source: ReplayFrameSource
field_count: int
hex: str = Field(..., description="Raw frame bytes as hex")
tree: List[ReplayTreeNode]

View File

@ -20,7 +20,7 @@ from uuid import UUID, uuid4
from pydantic import BaseModel, Field
from ..common import ConsoleType, CustomAdapter, NodeStatus
from ..common import ConsoleType, CustomAdapter, ErrorMessage, NodeStatus
from ..update import PartialUpdateModel
from .labels import Label
@ -168,6 +168,10 @@ class NodeCreate(NodeBase):
name: str
node_type: NodeType
node_id: UUID = Field(default_factory=uuid4)
strict_names: bool = Field(
False,
description="Return a 409 error instead of renaming the node when the requested name is already used",
)
class NodeUpdate(PartialUpdateModel, NodeBase):
@ -205,6 +209,16 @@ class Node(NodeBase):
)
class NodeBatchResult(BaseModel):
"""
Outcome of one node of a batch creation request.
"""
status_code: int = Field(..., description="HTTP status code the same request would have returned on its own")
node: Optional[Node] = Field(None, description="The created node, set when status_code is 201")
error: Optional[ErrorMessage] = Field(None, description="The error, set when the node could not be created")
class NodeDuplicate(BaseModel):
"""
Data to duplicate a node.

View File

@ -127,3 +127,15 @@ class ProjectCompression(str, Enum):
bzip2 = "bzip2"
lzma = "lzma"
zstd = "zstd"
class ProjectStats(BaseModel):
"""
Project object counts.
"""
nodes: int
links: int
drawings: int
snapshots: int
markers: int

View File

@ -109,3 +109,13 @@ class Role(DateTimeModelMixin, RoleBase):
is_builtin: bool
privileges: List[Privilege]
model_config = ConfigDict(from_attributes=True)
class ACEEndpoint(BaseModel):
"""
An endpoint that can be used in an ACL entry.
"""
endpoint: str = Field(..., description="Endpoint path to use as the ACE path")
name: str = Field(..., description="Human readable name of the endpoint")
endpoint_type: str = Field(..., description="Kind of resource the endpoint refers to")

View File

@ -0,0 +1,131 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Union
from pydantic import BaseModel, Field
class ComputeStatistics(BaseModel):
"""
Resource usage reported by a compute.
Fields come from the compute itself, so a compute running another build
may omit some of them.
"""
memory_total: Optional[int] = None
memory_free: Optional[int] = None
memory_used: Optional[int] = None
swap_total: Optional[int] = None
swap_free: Optional[int] = None
swap_used: Optional[int] = None
cpu_usage_percent: Optional[int] = None
cpu_count: Optional[int] = None
cpu_count_physical: Optional[int] = None
cpu_model: Optional[str] = None
memory_usage_percent: Optional[int] = None
swap_usage_percent: Optional[int] = None
disk_usage_percent: Optional[int] = None
disk_total: Optional[int] = None
disk_used: Optional[int] = None
disk_free: Optional[int] = None
load_average: Optional[List[float]] = None
load_average_percent: Optional[List[float]] = None
class ComputeStatisticsEntry(BaseModel):
"""
Statistics of one compute.
"""
compute_id: str
compute_name: str
statistics: ComputeStatistics
class ProjectCountStatistics(BaseModel):
"""
Project counts by status.
"""
total: int
opened: int
closed: int
class NodeStatistics(BaseModel):
"""
Node counts across all projects.
"""
total: int
open_project_nodes: int
closed_project_nodes: int
by_type: Dict[str, int] = Field(..., description="Node count by node type")
by_status: Dict[str, int] = Field(..., description="Node count by status, open projects only")
class LinkStatistics(BaseModel):
"""
Link counts across all projects.
"""
total: int
capturing: int
class WebWiresharkContainer(BaseModel):
"""
A Web Wireshark container.
"""
project_id: str
project_name: str
container_id: str
status: str
running: bool
active_sessions: Optional[int] = None
memory_limit: Optional[str] = None
cpu_limit: Optional[str] = None
pids_limit: Optional[Union[int, str]] = Field(None, description="Process limit, or 'unlimited'")
memory: Optional[str] = Field(None, description="Current memory usage")
cpu: Optional[str] = Field(None, description="Current CPU usage")
pids: Optional[int] = Field(None, description="Current process count")
class WebWiresharkStatistics(BaseModel):
"""
Web Wireshark container statistics across all opened projects.
"""
total_containers: int
running_containers: int
active_sessions: int
containers: List[WebWiresharkContainer]
class ServerStatistics(BaseModel):
"""
Server statistics.
"""
uptime_seconds: int
computes: List[ComputeStatisticsEntry]
projects: ProjectCountStatistics
nodes: NodeStatistics
links: LinkStatistics
webwireshark: WebWiresharkStatistics

View File

@ -0,0 +1,37 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pydantic import BaseModel
class SymbolInfo(BaseModel):
"""
A symbol available on the controller.
"""
symbol_id: str
filename: str
theme: str
builtin: bool
class SymbolDimensions(BaseModel):
"""
Symbol dimensions in pixels.
"""
width: int
height: int

View File

@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
@ -43,3 +44,30 @@ class ApiKeyCreate(BaseModel):
"""Schema for creating a new API key."""
name: str
class ApiKey(BaseModel):
"""API key metadata. The secret is never returned after creation."""
api_key_id: UUID
name: str
key_prefix: str
created_at: Optional[str] = None
last_used_at: Optional[str] = None
revoked: bool
class ApiKeyCreated(BaseModel):
"""A newly created API key. The full key is returned only once."""
api_key_id: UUID
api_key: str
name: str
key_prefix: str
created_at: Optional[str] = None
class ApiKeyMessage(BaseModel):
"""Confirmation message returned when an API key is revoked or restored."""
message: str

View File

@ -105,7 +105,7 @@ class TestApplianceRoutes:
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"}}
new_version = {"name": "v8-99G", "images": {"hda_disk_image": "empty99G.qcow2"}}
response = await client.post(
app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version
)

View File

@ -81,6 +81,45 @@ class TestLinkRoutes:
assert len(project.links) == 1
assert list(project.links.values())[0].filters == filters
async def test_create_links_batch(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
valid = {
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
}
same_node = {
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
]
}
unknown_node = {
"nodes": [
{"node_id": str(uuid.uuid4()), "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"):
response = await client.post(
app.url_path_for("create_links", project_id=project.id), json=[same_node, unknown_node, valid]
)
assert response.status_code == status.HTTP_200_OK
results = response.json()
assert [r["status_code"] for r in results] == [409, 404, 201]
assert results[0]["link"] is None
assert results[0]["error"]["message"]
assert results[1]["error"]["message"]
assert results[2]["error"] is None
assert results[2]["link"]["link_id"] in project.links
assert len(project.links) == 1
async def test_create_link_failure(
self, app: FastAPI, client: AsyncClient, compute: Compute, project: Project
) -> None:
@ -609,12 +648,12 @@ class TestLinkRoutes:
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"] == {
assert schemas["LinkFilterInfo"]["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"}
assert responses["items"] == {"$ref": "#/components/schemas/LinkFilterInfo"}
async def test_get_udp_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
"""

View File

@ -19,6 +19,8 @@ HTTP-route tests for the traffic-insight marker endpoints: per-link markers,
project-level definitions, and the project-wide aggregation view.
"""
from unittest.mock import DEFAULT
import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
@ -43,6 +45,25 @@ def _inherited(link, name="arp"):
}
def _record_marker(link):
"""Side effect for a mocked ``start_marker`` that stores the marker like the real one does."""
def side_effect(*, name, bpf, tag=None, color=None, highlight_duration=None, direction=None, **kwargs):
link._markers[name] = {
"bpf": bpf,
"tag": tag,
"enabled": True,
"color": color,
"highlight_duration": highlight_duration,
"capture_node_id": "node-id",
"direction": direction,
"data_link_type": kwargs.get("data_link_type", "DLT_EN10MB"),
}
return DEFAULT
return side_effect
class TestMarkerRoutes:
# -----------------------------------------------------------------------
# Per-link markers
@ -53,13 +74,25 @@ class TestMarkerRoutes:
link = UDPLink(project)
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock:
with asyncio_patch(
"gns3server.controller.udp_link.UDPLink.start_marker", side_effect=_record_marker(link)
) as mock:
response = await client.post(
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
json={"bpf": "icmp", "tag": 3, "color": "#ff5722", "highlight_duration": 800},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == {
"bpf": "icmp",
"tag": 3,
"enabled": True,
"color": "#ff5722",
"highlight_duration": 800,
"capture_node_id": "node-id",
"direction": None,
"data_link_type": "DLT_EN10MB",
}
mock.assert_called_once()
_, kwargs = mock.call_args
assert kwargs["bpf"] == "icmp"
@ -73,12 +106,15 @@ class TestMarkerRoutes:
link = UDPLink(project)
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock:
with asyncio_patch(
"gns3server.controller.udp_link.UDPLink.start_marker", side_effect=_record_marker(link)
) as mock:
response = await client.post(
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
json={"name": "web", "bpf": "tcp port 80"},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["bpf"] == "tcp port 80"
_, kwargs = mock.call_args
assert kwargs["name"] == "web"
@ -141,6 +177,16 @@ class TestMarkerRoutes:
async def test_update_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
link._markers["web"] = {
"bpf": "udp port 53",
"tag": None,
"enabled": True,
"color": None,
"highlight_duration": 1500,
"capture_node_id": "node-id",
"direction": None,
"data_link_type": "DLT_EN10MB",
}
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.update_marker") as mock:
@ -149,6 +195,7 @@ class TestMarkerRoutes:
json={"bpf": "udp port 53", "highlight_duration": 1500},
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == link._markers["web"]
_, kwargs = mock.call_args
assert kwargs["bpf"] == "udp port 53"
assert kwargs["highlight_duration"] == 1500
@ -195,12 +242,27 @@ class TestMarkerRoutes:
async def test_create_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
with asyncio_patch("gns3server.controller.project.Project.create_marker_definition") as mock:
def store_definition(*, name, bpf, highlight_duration=None, **kwargs):
project._marker_definitions[name] = {
"bpf": bpf,
"tag": None,
"direction": None,
"color": None,
"highlight_duration": highlight_duration,
"data_link_type": "DLT_EN10MB",
"paused": False,
}
return DEFAULT
with asyncio_patch(
"gns3server.controller.project.Project.create_marker_definition", side_effect=store_definition
) as mock:
response = await client.post(
app.url_path_for("create_marker_definition", project_id=project.id),
json={"name": "arp", "bpf": "arp", "highlight_duration": 1200},
)
assert response.status_code == status.HTTP_201_CREATED
assert response.json() == project._marker_definitions["arp"]
_, kwargs = mock.call_args
assert kwargs["name"] == "arp"
assert kwargs["bpf"] == "arp"
@ -220,12 +282,15 @@ class TestMarkerRoutes:
async def test_update_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
project._marker_definitions["arp"] = {"bpf": "arp or rarp", "tag": None, "highlight_duration": 900}
with asyncio_patch("gns3server.controller.project.Project.update_marker_definition") as mock:
response = await client.put(
app.url_path_for("update_marker_definition", project_id=project.id, def_name="arp"),
json={"bpf": "arp or rarp", "highlight_duration": 900},
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"bpf": "arp or rarp", "tag": None, "highlight_duration": 900}
_, kwargs = mock.call_args
assert kwargs["bpf"] == "arp or rarp"
assert kwargs["highlight_duration"] == 900

View File

@ -34,7 +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.controller_error import ComputeError, ControllerError
from gns3server.controller.node import Node
from gns3server.controller.project import Project
from gns3server.services import auth_service
@ -73,6 +73,103 @@ class TestNodeRoutes:
assert response.json()["name"] == "test"
assert "name" not in response.json()["properties"]
async def test_create_node_taken_name_is_renamed(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
) -> None:
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
payload = {"name": "R1", "node_type": "vpcs", "compute_id": "example.com"}
first = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
second = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
assert first.json()["name"] == "R1"
assert second.status_code == status.HTTP_201_CREATED
assert second.json()["name"] == "R2"
async def test_create_node_strict_names(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
) -> None:
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
payload = {"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True}
first = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
second = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
assert first.status_code == status.HTTP_201_CREATED
assert second.status_code == status.HTTP_409_CONFLICT
assert "R1" in second.json()["message"]
assert len(project.nodes) == 1
async def test_create_nodes_batch(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
) -> None:
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
nodes = [{"name": "R{0}", "node_type": "vpcs", "compute_id": "example.com"} for _ in range(3)]
response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
assert response.status_code == status.HTTP_200_OK
results = response.json()
assert [r["status_code"] for r in results] == [201, 201, 201]
assert [r["node"]["name"] for r in results] == ["R1", "R2", "R3"]
assert all(r["error"] is None for r in results)
assert len(project.nodes) == 3
async def test_create_nodes_batch_item_failures_keep_order(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
) -> None:
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
nodes = [
{"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True},
{"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True},
{"name": "R1", "node_type": "vpcs", "compute_id": "unknown"},
{"name": "R1", "node_type": "vpcs", "compute_id": "example.com"},
]
response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
assert response.status_code == status.HTTP_200_OK
results = response.json()
assert [r["status_code"] for r in results] == [201, 409, 404, 201]
assert results[0]["node"]["name"] == "R1"
assert results[1]["node"] is None
assert "R1" in results[1]["error"]["message"]
assert results[2]["error"]["message"]
assert results[3]["node"]["name"] == "R2"
assert len(project.nodes) == 2
async def test_create_nodes_batch_failed_node_releases_name(
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
) -> None:
compute.post = AsyncioMagicMock(side_effect=ControllerError("compute failure"))
nodes = [{"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True}]
response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
assert response.json()[0]["status_code"] == status.HTTP_409_CONFLICT
assert len(project.nodes) == 0
response_ok = MagicMock()
response_ok.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response_ok)
response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
assert response.json()[0]["status_code"] == status.HTTP_201_CREATED
assert response.json()[0]["node"]["name"] == "R1"
async def test_list_node(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None:
response = MagicMock()

View File

@ -0,0 +1,464 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Runtime JSON shape of the endpoints that used to return untyped objects, and
the OpenAPI contract that now describes them.
"""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
from gns3server.controller import Controller, marker_replay
from gns3server.controller.link import Link
from gns3server.controller.project import Project
from gns3server.controller.udp_link import UDPLink
from tests.controller.test_marker_replay import _write_pcap
pytestmark = pytest.mark.asyncio
TYPED_OPERATIONS = [
("post", "/v3/access/api-keys"),
("get", "/v3/access/api-keys"),
("post", "/v3/access/api-keys/{api_key_id}/revoke"),
("post", "/v3/access/api-keys/{api_key_id}/restore"),
("get", "/v3/statistics"),
("get", "/v3/projects/{project_id}/stats"),
("post", "/v3/images/install"),
("post", "/v3/appliances/{appliance_id}/version"),
("get", "/v3/projects/{project_id}/markers"),
("get", "/v3/projects/{project_id}/marker-definitions"),
("post", "/v3/projects/{project_id}/marker-definitions"),
("put", "/v3/projects/{project_id}/marker-definitions/{def_name}"),
("get", "/v3/projects/{project_id}/markers/tags/{tag}/replay/range"),
("get", "/v3/projects/{project_id}/markers/tags/{tag}/replay/frames"),
("get", "/v3/projects/{project_id}/markers/tags/{tag}/replay/frame/detail"),
("get", "/v3/projects/{project_id}/links/{link_id}/markers"),
("post", "/v3/projects/{project_id}/links/{link_id}/markers"),
("put", "/v3/projects/{project_id}/links/{link_id}/markers/{marker_name}"),
("get", "/v3/symbols"),
("get", "/v3/symbols/{symbol_id}/dimensions"),
("get", "/v3/symbols/default_symbols"),
("get", "/v3/projects/{project_id}/links/{link_id}/available_filters"),
("post", "/v3/projects/{project_id}/links/{link_id}/capture/wireshark/restart"),
("get", "/v3/projects/{project_id}/nodes/{node_id}/dynamips/auto_idlepc"),
("post", "/v3/computes/{compute_id}/dynamips/auto_idlepc"),
("get", "/v3/gns3vm/engines"),
("get", "/v3/gns3vm/engines/{engine}/vms"),
("get", "/v3/access/acl/endpoints"),
]
def _is_untyped(schema: dict) -> bool:
if not schema:
return True
if "$ref" in schema:
return False
if schema.get("type") == "array":
return _is_untyped(schema.get("items", {}))
if schema.get("type") == "object":
extra = schema.get("additionalProperties")
if "properties" in schema:
return False
return extra in (None, True, {})
for key in ("anyOf", "oneOf"):
if key in schema:
return any(_is_untyped(s) for s in schema[key] if s.get("type") != "null")
return False
class TestOpenAPIContract:
async def test_no_untyped_success_response(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.get("/openapi.json")
assert response.status_code == status.HTTP_200_OK
paths = response.json()["paths"]
untyped = []
for method, path in TYPED_OPERATIONS:
operation = paths[path][method]
success = [r for code, r in operation["responses"].items() if code.startswith("2") and code != "204"]
assert success, f"{method.upper()} {path} declares no success response"
for r in success:
schema = r["content"]["application/json"]["schema"]
if _is_untyped(schema):
untyped.append(f"{method.upper()} {path}")
assert not untyped
class TestApiKeys:
async def _create(self, app: FastAPI, client: AsyncClient, name: str = "ci") -> dict:
response = await client.post(app.url_path_for("create_api_key"), json={"name": name})
assert response.status_code == status.HTTP_201_CREATED
return response.json()
async def test_create(self, app: FastAPI, client: AsyncClient) -> None:
body = await self._create(app, client, "created")
assert set(body) == {"api_key_id", "api_key", "name", "key_prefix", "created_at"}
assert body["name"] == "created"
assert body["api_key"].startswith(f"gns3_{body['api_key_id']}_")
assert body["key_prefix"] == body["api_key"][:8]
assert isinstance(body["created_at"], str)
uuid.UUID(body["api_key_id"])
async def test_list(self, app: FastAPI, client: AsyncClient) -> None:
created = await self._create(app, client, "listed")
response = await client.get(app.url_path_for("list_api_keys"))
assert response.status_code == status.HTTP_200_OK
entry = next(k for k in response.json() if k["api_key_id"] == created["api_key_id"])
assert set(entry) == {"api_key_id", "name", "key_prefix", "created_at", "last_used_at", "revoked"}
assert entry["name"] == "listed"
assert entry["last_used_at"] is None
assert entry["revoked"] is False
assert entry["created_at"] == created["created_at"]
async def test_revoke_and_restore(self, app: FastAPI, client: AsyncClient) -> None:
created = await self._create(app, client, "toggled")
key_id = created["api_key_id"]
response = await client.post(app.url_path_for("revoke_api_key", api_key_id=key_id))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"message": "API key 'toggled' revoked"}
keys = (await client.get(app.url_path_for("list_api_keys"))).json()
assert next(k for k in keys if k["api_key_id"] == key_id)["revoked"] is True
response = await client.post(app.url_path_for("restore_api_key", api_key_id=key_id))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"message": "API key 'toggled' restored"}
keys = (await client.get(app.url_path_for("list_api_keys"))).json()
assert next(k for k in keys if k["api_key_id"] == key_id)["revoked"] is False
async def test_revoke_unknown_key(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.post(app.url_path_for("revoke_api_key", api_key_id=str(uuid.uuid4())))
assert response.status_code == status.HTTP_404_NOT_FOUND
COMPUTE_STATISTICS = {
"memory_total": 8000,
"memory_free": 4000,
"memory_used": 4000,
"swap_total": 1000,
"swap_free": 900,
"swap_used": 100,
"cpu_usage_percent": 12,
"cpu_count": 4,
"cpu_count_physical": None,
"cpu_model": "Test CPU",
"memory_usage_percent": 50,
"swap_usage_percent": 10,
"disk_usage_percent": 30,
"disk_total": 100000,
"disk_used": 30000,
"disk_free": 70000,
"load_average": [0.5, 0.4, 0.3],
"load_average_percent": [12.5, 10.0, 7.5],
}
class TestStatistics:
async def test_statistics_shape(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
webwireshark = {
"total_containers": 1,
"running_containers": 1,
"active_sessions": 0,
"containers": [
{
"project_id": project.id,
"project_name": project.name,
"container_id": "abcdef123456",
"status": "running",
"running": True,
"active_sessions": 0,
"memory_limit": "unlimited",
"cpu_limit": "unlimited",
"pids_limit": "unlimited",
"memory": "10MiB / 1GiB",
"cpu": "0.5%",
"pids": 3,
},
{
"project_id": project.id,
"project_name": project.name,
"container_id": "123456abcdef",
"status": "exited",
"running": False,
},
],
}
with patch(
"gns3server.api.routes.controller.controller.collect_webwireshark_stats",
new=AsyncMock(return_value=webwireshark),
):
response = await client.get(app.url_path_for("statistics"))
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert set(body) == {"uptime_seconds", "computes", "projects", "nodes", "links", "webwireshark"}
assert isinstance(body["uptime_seconds"], int)
assert body["projects"] == {"total": 1, "opened": 1, "closed": 0}
assert body["nodes"] == {
"total": 0,
"open_project_nodes": 0,
"closed_project_nodes": 0,
"by_type": {},
"by_status": {},
}
assert body["links"] == {"total": 0, "capturing": 0}
assert body["webwireshark"] == webwireshark
async def test_statistics_compute_entry(
self, app: FastAPI, client: AsyncClient, controller: Controller, project: Project
) -> None:
compute = MagicMock()
compute.id = "local"
compute.name = "local"
reply = MagicMock()
reply.json = dict(COMPUTE_STATISTICS)
compute.get = AsyncMock(return_value=reply)
controller._computes = {"local": compute}
response = await client.get(app.url_path_for("statistics"))
assert response.status_code == status.HTTP_200_OK
assert response.json()["computes"] == [
{"compute_id": "local", "compute_name": "local", "statistics": COMPUTE_STATISTICS}
]
async def test_statistics_compute_missing_fields_stay_absent(
self, app: FastAPI, client: AsyncClient, controller: Controller, project: Project
) -> None:
compute = MagicMock()
compute.id = "old"
compute.name = "old"
reply = MagicMock()
reply.json = {"memory_total": 8000, "cpu_count": 2}
compute.get = AsyncMock(return_value=reply)
controller._computes = {"old": compute}
response = await client.get(app.url_path_for("statistics"))
assert response.status_code == status.HTTP_200_OK
assert response.json()["computes"][0]["statistics"] == {"memory_total": 8000, "cpu_count": 2}
async def test_project_stats(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
link._markers["a"] = {"bpf": "icmp"}
link._markers["b"] = {"bpf": "arp"}
project._links = {link.id: link}
response = await client.get(app.url_path_for("get_project_stats", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"nodes": 0, "links": 1, "drawings": 0, "snapshots": 0, "markers": 2}
class TestSymbols:
async def test_list_shape(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.get(app.url_path_for("get_symbols"))
assert response.status_code == status.HTTP_200_OK
symbols = response.json()
assert symbols
for symbol in symbols:
assert set(symbol) == {"symbol_id", "filename", "theme", "builtin"}
assert isinstance(symbol["builtin"], bool)
async def test_default_symbols(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.get(app.url_path_for("get_default_symbols"))
assert response.status_code == status.HTTP_200_OK
themes = response.json()
assert themes["Classic"]["firewall"] == ":/symbols/classic/firewall.svg"
assert themes == Controller.instance().symbols.default_symbols()
async def test_dimensions(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.get(
app.url_path_for("get_symbol_dimensions", symbol_id=":/symbols/classic/firewall.svg")
)
assert response.status_code == status.HTTP_200_OK
body = response.json()
assert set(body) == {"width", "height"}
assert isinstance(body["width"], int)
assert isinstance(body["height"], int)
class TestLinkRoutes:
async def test_wireshark_restart(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
with patch("gns3server.controller.link.Link._restart_web_wireshark", new=AsyncMock()):
response = await client.post(app.url_path_for("restart_wireshark", project_id=project.id, link_id=link.id))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {"status": "restarted"}
async def test_available_filters_keep_optional_parameter_keys_absent(
self, app: FastAPI, client: AsyncClient, project: Project
) -> None:
link = Link(project)
project._links = {link.id: link}
with patch.object(Link, "_get_filter_node", return_value=object()):
response = await client.get(app.url_path_for("get_filters", project_id=project.id, link_id=link.id))
assert response.status_code == status.HTTP_200_OK
by_type = {f["type"]: f for f in response.json()}
assert by_type["bpf"]["parameters"] == [{"name": "Filters", "type": "text"}]
assert by_type["delay"]["parameters"][0] == {
"name": "Latency",
"minimum": 1,
"maximum": 32767,
"unit": "ms",
"type": "int",
}
async def test_link_markers_keep_missing_keys_absent(
self, app: FastAPI, client: AsyncClient, project: Project
) -> None:
link = UDPLink(project)
link._markers["legacy"] = {"bpf": "icmp", "tag": 1, "enabled": True}
link._markers["global-arp"] = {"bpf": "arp", "inherited_from": "arp", "capture_node_id": "n1"}
project._links = {link.id: link}
response = await client.get(app.url_path_for("get_markers", project_id=project.id, link_id=link.id))
assert response.status_code == status.HTTP_200_OK
assert response.json() == link._markers
class TestMarkerAggregation:
async def test_project_markers_and_definitions(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
link._markers["web"] = {
"bpf": "tcp port 80",
"tag": 4,
"enabled": False,
"color": "#ff5722",
"highlight_duration": 800,
"capture_node_id": "n1",
"direction": "tx",
"data_link_type": "DLT_EN10MB",
}
link._markers["global-arp"] = {
"bpf": "arp",
"tag": None,
"enabled": True,
"color": None,
"highlight_duration": None,
"capture_node_id": "n1",
"direction": None,
"data_link_type": "DLT_EN10MB",
"inherited_from": "arp",
}
project._links = {link.id: link}
project._marker_definitions = {
"arp": {
"bpf": "arp",
"tag": None,
"direction": None,
"color": None,
"highlight_duration": None,
"data_link_type": "DLT_EN10MB",
"paused": False,
},
"old": {"bpf": "icmp", "tag": 5},
}
response = await client.get(app.url_path_for("get_project_markers", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
f"{link.id}/web": {**link._markers["web"], "link_id": link.id, "node_id": "n1"},
f"{link.id}/global-arp": {**link._markers["global-arp"], "link_id": link.id, "node_id": "n1"},
}
response = await client.get(app.url_path_for("get_marker_definitions", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"arp": {**project._marker_definitions["arp"], "link_ids": [link.id]},
"old": {"bpf": "icmp", "tag": 5, "link_ids": []},
}
class TestReplayFrameDetail:
async def test_frame_detail_passes_unknown_tree_keys_through(
self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch
) -> None:
detail = {
"ts": "1693472000.000000",
"source": {"node_id": "n1", "link_id": "l1", "marker": "icmp", "frame_number": 1},
"field_count": 3,
"hex": "0200",
"tree": [
{
"element": "proto",
"label": "Internet Protocol",
"name": "ip",
"filter_expr": "ip",
"pos": 14,
"size": 20,
"children": [{"label": "TTL: 64", "name": "ip.ttl", "expert": "Chat", "generated": True}],
"future_key": {"a": 1},
}
],
}
monkeypatch.setattr(marker_replay, "decode_frame", AsyncMock(return_value=detail))
response = await client.get(
app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
params={"ts": "1693472000.000000", "node_id": "n1", "link_id": "l1", "marker": "icmp"},
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == detail
async def test_range_empty_timeline(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
link._markers["icmp"] = {
"bpf": "icmp",
"tag": 9,
"enabled": False,
"color": None,
"highlight_duration": None,
"capture_node_id": "n1",
"direction": None,
"data_link_type": "DLT_EN10MB",
}
project._links = {link.id: link}
_write_pcap(f"{project.markers_directory}/n1_{link.id}_icmp.pcap", [])
response = await client.get(app.url_path_for("replay_tag_range", project_id=project.id, tag=9))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"tag": 9,
"start": None,
"end": None,
"frame_count": 0,
"sources": [
{"node_id": "n1", "link_id": link.id, "marker": "icmp", "data_link_type": "DLT_EN10MB", "count": 0}
],
"frames": [],
}

View File

@ -1118,6 +1118,46 @@ async def test_node_name(project):
assert node.name == "R3"
@pytest.mark.asyncio
async def test_node_name_strict(project):
compute = MagicMock()
compute.id = "local"
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
node = await project.add_node(compute, "R1", None, node_type="vpcs", strict_names=True)
assert node.name == "R1"
with pytest.raises(ControllerError):
await project.add_node(compute, "R1", None, node_type="vpcs", strict_names=True)
assert len(project.nodes) == 1
node = await project.add_node(compute, "R1", None, node_type="vpcs", strict_names=False)
assert node.name == "R2"
node = await project.add_node(compute, "T{0}", None, node_type="vpcs", strict_names=True)
assert node.name == "T1"
node = await project.add_node(compute, "T{0}", None, node_type="vpcs", strict_names=True)
assert node.name == "T2"
@pytest.mark.asyncio
async def test_node_name_released_when_creation_fails(project):
compute = MagicMock()
compute.id = "local"
compute.post = AsyncioMagicMock(side_effect=ControllerError("failure"))
with pytest.raises(ControllerError):
await project.add_node(compute, "R1", None, node_type="vpcs")
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
node = await project.add_node(compute, "R1", None, node_type="vpcs", strict_names=True)
assert node.name == "R1"
@pytest.mark.asyncio
async def test_duplicate_node(project):