Merge branch '3.1' into api-issue-05-2xftmd

This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-10-08 14:40:53 +02:00
commit 71da996362
46 files changed
+2108 -210

No files matched your search

+115 -32
View File
@@ -38,6 +38,7 @@ from gns3server.controller.controller_error import (
ControllerBadRequestError,
ControllerError,
ControllerForbiddenError,
ControllerTimeoutError,
controller_error_status_code,
)
from gns3server.controller.node import Node
@@ -46,6 +47,14 @@ from gns3server.db.repositories.rbac import RbacRepository
from gns3server.utils import force_unix_path
from gns3server.utils.http_client import HTTPClient
from .dependencies.concurrency import (
GET_RESPONSES,
PUT_RESPONSES,
check_if_match,
if_match_header,
serialize_updates,
set_etag,
)
from .dependencies.database import get_repository
from .dependencies.rbac import has_privilege, has_privilege_on_websocket
@@ -198,6 +207,7 @@ async def create_nodes(
def get_nodes(
project: Project = Depends(dep_project),
tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)"),
name: Optional[str] = Query(None, description="Return only nodes whose name exactly matches (case-sensitive)"),
) -> List[schemas.Node]:
"""
Return all nodes belonging to a given project.
@@ -207,6 +217,7 @@ def get_nodes(
Query Parameters:
- tags: Filter by tags. Multiple tags are ANDed together.
Example: ?tags=vendor:cisco&tags=model:7200
- name: Exact, case-sensitive match on the node name. Combined with other filters using AND.
"""
if project.status == "closed":
@@ -215,6 +226,9 @@ def get_nodes(
else:
nodes = [v.asdict() for v in project.nodes.values()]
if name is not None:
nodes = [node for node in nodes if node.get("name") == name]
# Filter by tags if provided (all filter tags have to match the node tags)
if tags:
filtered_nodes = []
@@ -298,8 +312,13 @@ async def reload_all_nodes(project: Project = Depends(dep_project)) -> None:
_HOST_INTERFACE_NODE_TYPES = {"cloud", "nat"}
@router.get("/{node_id}", response_model=schemas.Node, dependencies=[Depends(has_privilege("Node.Audit"))])
async def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
@router.get(
"/{node_id}",
response_model=schemas.Node,
responses=GET_RESPONSES,
dependencies=[Depends(has_privilege("Node.Audit"))],
)
async def get_node(response: Response, node: Node = Depends(dep_node)) -> schemas.Node:
"""
Return a node from a given project.
@@ -308,31 +327,50 @@ async def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
if node.node_type in _HOST_INTERFACE_NODE_TYPES:
try:
response = await node.get()
await node.parse_node_response(response.json)
node_response = await node.get()
await node.parse_node_response(node_response.json)
except Exception:
# If compute is unreachable, still return cached data
log.warning(f"Could not refresh node {node.id} from compute, returning cached data")
return node.asdict()
node_dict = node.asdict()
set_etag(response, node_dict)
return node_dict
@router.put(
"/{node_id}",
response_model=schemas.Node,
response_model_exclude_unset=True,
responses=PUT_RESPONSES,
dependencies=[Depends(has_privilege("Node.Modify"))],
)
async def update_node(node_update: schemas.NodeUpdate, node: Node = Depends(dep_node)) -> schemas.Node:
async def update_node(
node_update: schemas.NodeUpdate,
response: Response,
node: Node = Depends(dep_node),
if_match: Optional[str] = Depends(if_match_header),
) -> schemas.Node:
"""
Update a node.
If the If-Match header is present, the update is only applied when it matches the current ETag.
Required privilege: Node.Modify
"""
node_data = jsonable_encoder(node_update, exclude_unset=True)
await node.update(**node_data)
return node.asdict()
# Ignore these because we only use them when creating a node
node_data.pop("node_id", None)
node_data.pop("node_type", None)
node_data.pop("compute_id", None)
async with serialize_updates(f"node:{node.id}"):
check_if_match(if_match, node.asdict())
await node.update(**node_data)
node_dict = node.asdict()
set_etag(response, node_dict)
return node_dict
@router.delete(
@@ -373,46 +411,88 @@ async def duplicate_node(duplicate_data: schemas.NodeDuplicate, node: Node = Dep
return new_node.asdict()
LIFECYCLE_RESPONSES: dict[int | str, dict[str, Any]] = {
status.HTTP_408_REQUEST_TIMEOUT: {"model": schemas.ErrorMessage, "description": "Timeout waiting for the node"},
}
async def _run_lifecycle(
node: Node, action: Callable, target: str, wait: bool, timeout: int, tolerate_unsupported: bool = True
) -> dict:
async def _run():
try:
await action()
except HTTPException as e:
if not tolerate_unsupported or not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED:
raise
return
if wait and not node.is_always_running():
await node.wait_for_status(target)
if not wait:
await _run()
return node.asdict()
try:
await asyncio.wait_for(_run(), timeout)
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when waiting for {node.name} to be {target}")
return node.asdict()
@router.post(
"/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))]
"/{node_id}/start",
response_model=schemas.Node,
responses=LIFECYCLE_RESPONSES,
dependencies=[Depends(has_privilege("Node.PowerMgmt"))],
)
async def start_node(start_data: Optional[dict] = None, node: Node = Depends(dep_node)) -> None:
async def start_node(
start_data: Optional[dict] = None,
wait: bool = Query(False, description="Return only when the node status is 'started'"),
timeout: int = Query(240, ge=1, le=3600, description="Seconds to wait when 'wait' is true"),
node: Node = Depends(dep_node),
) -> dict:
"""
Start a node.
Required privilege: Node.PowerMgmt
"""
try:
await node.start(data=start_data)
except HTTPException as e:
if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED:
raise
return await _run_lifecycle(node, lambda: node.start(data=start_data), "started", wait, timeout)
@router.post(
"/{node_id}/stop", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))]
"/{node_id}/stop",
response_model=schemas.Node,
responses=LIFECYCLE_RESPONSES,
dependencies=[Depends(has_privilege("Node.PowerMgmt"))],
)
async def stop_node(node: Node = Depends(dep_node)) -> None:
async def stop_node(
wait: bool = Query(False, description="Return only when the node status is 'stopped'"),
timeout: int = Query(240, ge=1, le=3600, description="Seconds to wait when 'wait' is true"),
node: Node = Depends(dep_node),
) -> dict:
"""
Stop a node.
Errors reported while stopping are returned to the caller.
Required privilege: Node.PowerMgmt
"""
try:
await node.stop()
except HTTPException as e:
if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED:
raise
return await _run_lifecycle(node, lambda: node.stop(strict=True), "stopped", wait, timeout)
@router.post(
"/{node_id}/suspend",
status_code=status.HTTP_204_NO_CONTENT,
response_model=schemas.Node,
responses=LIFECYCLE_RESPONSES,
dependencies=[Depends(has_privilege("Node.PowerMgmt"))],
)
async def suspend_node(node: Node = Depends(dep_node)) -> None:
async def suspend_node(
wait: bool = Query(False, description="Return only when the node status is 'suspended'"),
timeout: int = Query(240, ge=1, le=3600, description="Seconds to wait when 'wait' is true"),
node: Node = Depends(dep_node),
) -> dict:
"""
Suspend a node.
@@ -422,24 +502,27 @@ async def suspend_node(node: Node = Depends(dep_node)) -> None:
Required privilege: Node.PowerMgmt
"""
await node.suspend()
return await _run_lifecycle(node, node.suspend, "suspended", wait, timeout, tolerate_unsupported=False)
@router.post(
"/{node_id}/reload", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))]
"/{node_id}/reload",
response_model=schemas.Node,
responses=LIFECYCLE_RESPONSES,
dependencies=[Depends(has_privilege("Node.PowerMgmt"))],
)
async def reload_node(node: Node = Depends(dep_node)) -> None:
async def reload_node(
wait: bool = Query(False, description="Return only when the node status is 'started'"),
timeout: int = Query(240, ge=1, le=3600, description="Seconds to wait when 'wait' is true"),
node: Node = Depends(dep_node),
) -> dict:
"""
Reload a node.
Required privilege: Node.PowerMgmt
"""
try:
await node.reload()
except HTTPException as e:
if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED:
raise
return await _run_lifecycle(node, node.reload, "started", wait, timeout)
@router.post(